diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000000..a888486d38 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "prowler-plugins", + "description": "Prowler Cloud Security for Claude Code", + "owner": { + "name": "Prowler", + "email": "support@prowler.com" + }, + "plugins": [ + { + "name": "prowler", + "source": "./claude_plugins/prowler", + "description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.", + "category": "security", + "homepage": "https://prowler.com" + } + ] +} diff --git a/.config/wt.toml b/.config/wt.toml new file mode 100644 index 0000000000..fa6883b014 --- /dev/null +++ b/.config/wt.toml @@ -0,0 +1,29 @@ +# 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" +envs = "wt step copy-ignored" + +# Block 2: install Python deps (uv manages the venv on `uv sync`). +[[pre-start]] +deps = "uv sync" + +# Block 3: prepare pnpm via corepack. +[[pre-start]] +corepack-enable = "corepack enable" + +[[pre-start]] +corepack-install = "cd ui && corepack install" + +# Block 4: 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: source .venv/bin/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 71a63171e7..a7a90108a6 100644 --- a/.env +++ b/.env @@ -6,14 +6,20 @@ PROWLER_UI_VERSION="stable" AUTH_URL=http://localhost:3000 API_BASE_URL=http://prowler-api:8080/api/v1 +# deprecated, use UI_API_BASE_URL NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL} +UI_API_BASE_URL=${API_BASE_URL} +# deprecated, use UI_API_DOCS_URL NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs +UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST=true UI_PORT=3000 # openssl rand -base64 32 AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" -# Google Tag Manager ID +# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress) +# deprecated, use UI_GOOGLE_TAG_MANAGER_ID NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" +UI_GOOGLE_TAG_MANAGER_ID="" #### MCP Server #### PROWLER_MCP_VERSION=stable @@ -139,13 +145,19 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute -# Sentry settings -SENTRY_ENVIRONMENT=local +# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒ +# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's +# server/edge SDKs. +UI_SENTRY_DSN= +UI_SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local -NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} +# Reserved runtime public config (registered now; no UI consumer yet) +# POSTHOG_KEY= +# POSTHOG_HOST= +# REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.16.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b953610fa1..ed97ff5a1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,14 +1,14 @@ # SDK -/* @prowler-cloud/sdk -/prowler/ @prowler-cloud/sdk @prowler-cloud/detection-and-remediation -/tests/ @prowler-cloud/sdk @prowler-cloud/detection-and-remediation -/dashboard/ @prowler-cloud/sdk -/docs/ @prowler-cloud/sdk -/examples/ @prowler-cloud/sdk -/util/ @prowler-cloud/sdk -/contrib/ @prowler-cloud/sdk -/permissions/ @prowler-cloud/sdk -/codecov.yml @prowler-cloud/sdk @prowler-cloud/api +/* @prowler-cloud/detection-remediation +/prowler/ @prowler-cloud/detection-remediation +/tests/ @prowler-cloud/detection-remediation +/dashboard/ @prowler-cloud/detection-remediation +/docs/ @prowler-cloud/detection-remediation +/examples/ @prowler-cloud/detection-remediation +/util/ @prowler-cloud/detection-remediation +/contrib/ @prowler-cloud/detection-remediation +/permissions/ @prowler-cloud/detection-remediation +/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api # API /api/ @prowler-cloud/api @@ -17,7 +17,7 @@ /ui/ @prowler-cloud/ui # AI -/mcp_server/ @prowler-cloud/ai +/mcp_server/ @prowler-cloud/detection-remediation # Platform /.github/ @prowler-cloud/platform diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..ae9a9b4f68 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [prowler-cloud] +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +# polar: # Replace with a single Polar username +# buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +# thanks_dev: # Replace with a single thanks.dev username +# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/new-check-request.yml b/.github/ISSUE_TEMPLATE/new-check-request.yml new file mode 100644 index 0000000000..6c6687e14f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-check-request.yml @@ -0,0 +1,143 @@ +name: "🔎 New Check Request" +description: Request a new Prowler security check +title: "[New Check]: " +labels: ["feature-request", "status/needs-triage"] + +body: + - type: checkboxes + id: search + attributes: + label: Existing check search + description: Confirm this check does not already exist before opening a new request. + options: + - label: I have searched existing issues, Prowler Hub, and the public roadmap, and this check does not already exist. + required: true + + - type: markdown + attributes: + value: | + Use this form to describe the security condition that Prowler should evaluate. + + The most useful inputs for [Prowler Studio](https://github.com/prowler-cloud/prowler-studio) are: + - What should be detected + - What PASS and FAIL mean + - Vendor docs, API references, SDK methods, CLI commands, or reference code + + - type: dropdown + id: provider + attributes: + label: Provider + description: Cloud or platform this check targets. + options: + - AWS + - Azure + - GCP + - Kubernetes + - GitHub + - Microsoft 365 + - OCI + - Alibaba Cloud + - Cloudflare + - MongoDB Atlas + - Google Workspace + - OpenStack + - Vercel + - NHN + - Other / New provider + validations: + required: true + + - type: input + id: other_provider_name + attributes: + label: New provider name + description: Only fill this if you selected "Other / New provider" above. + placeholder: "NewProviderName" + validations: + required: false + + - type: input + id: service_name + attributes: + label: Service or product area + description: Optional. Main service, product, or feature to audit. + placeholder: "s3, bedrock, entra, repository, apiserver" + validations: + required: false + + - type: input + id: suggested_check_name + attributes: + label: Suggested check name + description: Optional. Use `snake_case` following `__`, with lowercase letters and underscores only. + placeholder: "bedrock_guardrail_sensitive_information_filter_enabled" + validations: + required: false + + - type: textarea + id: context + attributes: + label: Context and goal + description: Describe the security problem, why it matters, and what this new check should help detect. + placeholder: |- + - Security condition to validate: + - Why it matters: + - Resource, feature, or configuration involved: + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: Expected behavior + description: Explain what the check should evaluate and what PASS, FAIL, or MANUAL should mean. + placeholder: |- + - Resource or scope to evaluate: + - PASS when: + - FAIL when: + - MANUAL when (if applicable): + - Exclusions, thresholds, or edge cases: + validations: + required: true + + - type: textarea + id: references + attributes: + label: References + description: Add vendor docs, API references, SDK methods, CLI commands, endpoint docs, sample payloads, or similar reference material. + placeholder: |- + - Product or service documentation: + - API or SDK reference: + - CLI command or endpoint documentation: + - Sample payload or response: + - Security advisory or benchmark: + validations: + required: true + + - type: dropdown + id: severity + attributes: + label: Suggested severity + description: Your best estimate. Reviewers will confirm during triage. + options: + - Critical + - High + - Medium + - Low + - Informational + - Not sure + validations: + required: true + + - type: textarea + id: implementation_notes + attributes: + label: Additional implementation notes + description: Optional. Add permissions, unsupported regions, config knobs, product limitations, or anything else that may affect implementation. + placeholder: |- + - Required permissions or scopes: + - Region, tenant, or subscription limitations: + - Configurable behavior or thresholds: + - Other constraints: + validations: + required: false diff --git a/.github/actions/osv-scanner/action.yml b/.github/actions/osv-scanner/action.yml new file mode 100644 index 0000000000..de5116fdcf --- /dev/null +++ b/.github/actions/osv-scanner/action.yml @@ -0,0 +1,169 @@ +name: 'OSV-Scanner' +description: 'Install osv-scanner and scan a lockfile, failing on CRITICAL severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).' +author: 'Prowler' + +inputs: + lockfile: + description: 'Path to the lockfile to scan, relative to the repository root (e.g. uv.lock, api/uv.lock, ui/pnpm-lock.yaml).' + required: true + severity-levels: + description: 'Comma-separated severity levels that fail the scan. Default: CRITICAL.' + required: false + default: 'CRITICAL' + version: + description: 'osv-scanner release tag to install. When overriding, you MUST also override binary-sha256.' + required: false + default: 'v2.3.8' + binary-sha256: + description: 'Expected SHA256 of osv-scanner_linux_amd64 for the given version. Default tracks v2.3.8. See https://github.com/google/osv-scanner/releases/download//osv-scanner_SHA256SUMS.' + required: false + default: 'bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc' + post-pr-comment: + description: 'Post or update a PR comment with the scan report. Only effective on pull_request events. Requires pull-requests: write permission on the caller job.' + required: false + default: 'true' + +runs: + using: 'composite' + steps: + - name: Install osv-scanner + shell: bash + env: + OSV_SCANNER_VERSION: ${{ inputs.version }} + # Download the binary AND the published SHA256SUMS file, then verify the + # binary checksum against the upstream-signed manifest. Aborts on mismatch. + run: | + set -euo pipefail + if command -v osv-scanner >/dev/null 2>&1; then + INSTALLED="$(osv-scanner --version 2>&1 | awk '/scanner version/ {print $NF; exit}')" + if [ "v${INSTALLED}" = "${OSV_SCANNER_VERSION}" ]; then + echo "osv-scanner ${OSV_SCANNER_VERSION} already installed." + exit 0 + fi + fi + BASE="https://github.com/google/osv-scanner/releases/download/${OSV_SCANNER_VERSION}" + BIN_NAME="osv-scanner_linux_amd64" + curl -fSL --retry 3 "${BASE}/${BIN_NAME}" -o "${RUNNER_TEMP}/${BIN_NAME}" + curl -fSL --retry 3 "${BASE}/osv-scanner_SHA256SUMS" -o "${RUNNER_TEMP}/osv-scanner_SHA256SUMS" + (cd "${RUNNER_TEMP}" && sha256sum --check --ignore-missing osv-scanner_SHA256SUMS) + chmod +x "${RUNNER_TEMP}/${BIN_NAME}" + sudo mv "${RUNNER_TEMP}/${BIN_NAME}" /usr/local/bin/osv-scanner + rm -f "${RUNNER_TEMP}/osv-scanner_SHA256SUMS" + osv-scanner --version + + - name: Run osv-scanner + id: scan + shell: bash + working-directory: ${{ github.workspace }} + env: + OSV_LOCKFILE: ${{ inputs.lockfile }} + OSV_SEVERITY_LEVELS: ${{ inputs.severity-levels }} + OSV_REPORT_FILE: ${{ runner.temp }}/osv-scanner-findings.json + # Per-vulnerability ignores (reason + expiry) live in osv-scanner.toml at the repo root, if present. + # Severity filter is enforced in the wrapper via OSV_SEVERITY_LEVELS. + # `continue-on-error: true` lets the PR-comment step run even when findings exist; + # the gate step below re-fails the job from the wrapper exit code. + continue-on-error: true + run: ./.github/scripts/osv-scan.sh --lockfile="${OSV_LOCKFILE}" + + - name: Post osv-scanner report on PR + if: >- + always() + && inputs.post-pr-comment == 'true' + && github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + OSV_REPORT_FILE: ${{ runner.temp }}/osv-scanner-findings.json + OSV_LOCKFILE: ${{ inputs.lockfile }} + OSV_SEVERITY_LEVELS: ${{ inputs.severity-levels }} + with: + script: | + const fs = require('fs'); + const lockfile = process.env.OSV_LOCKFILE; + const severityLevels = process.env.OSV_SEVERITY_LEVELS; + const reportFile = process.env.OSV_REPORT_FILE; + const marker = ``; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + let findings = []; + if (fs.existsSync(reportFile)) { + try { + findings = JSON.parse(fs.readFileSync(reportFile, 'utf8')); + } catch (err) { + core.warning(`Could not parse ${reportFile}: ${err.message}`); + return; + } + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body?.includes(marker)); + + if (findings.length === 0) { + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + core.info(`Deleted stale osv-scanner comment for ${lockfile}.`); + } else { + core.info(`No findings and no stale comment for ${lockfile}.`); + } + return; + } + + const sevIcon = (s) => ({ + CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🟢', UNKNOWN: '⚪', + }[s] || '⚪'); + const escape = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' '); + const rows = findings.map(f => + `| ${sevIcon(f.severity)} ${f.severity}${f.score ? ` (${f.score})` : ''} | \`${escape(f.id)}\` | \`${escape(f.ecosystem)}/${escape(f.package)}\` | \`${escape(f.version)}\` | ${escape(f.summary || '(no summary)')} |` + ); + + const body = [ + marker, + `## 🔒 osv-scanner: ${findings.length} finding(s) in \`${lockfile}\``, + '', + `Severity gate: \`${severityLevels}\``, + '', + '| Severity | ID | Package | Version | Summary |', + '|----------|----|---------|---------|---------|', + ...rows, + '', + `To accept a finding, add an \`[[IgnoredVulns]]\` entry to \`osv-scanner.toml\` at the repo root with a reason and \`ignoreUntil\`.`, + '', + `[View run](${runUrl})`, + ].join('\n'); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + core.info(`Updated osv-scanner comment for ${lockfile}.`); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + core.info(`Posted new osv-scanner comment for ${lockfile}.`); + } + + - name: Enforce osv-scanner severity gate + shell: bash + env: + SCAN_OUTCOME: ${{ steps.scan.outcome }} + run: | + if [ "${SCAN_OUTCOME}" != "success" ]; then + echo "osv-scanner gate: scan reported findings (outcome=${SCAN_OUTCOME})" >&2 + exit 1 + fi diff --git a/.github/actions/setup-python-poetry/action.yml b/.github/actions/setup-python-uv/action.yml similarity index 50% rename from .github/actions/setup-python-poetry/action.yml rename to .github/actions/setup-python-uv/action.yml index 505ba86da6..d3293004a9 100644 --- a/.github/actions/setup-python-poetry/action.yml +++ b/.github/actions/setup-python-uv/action.yml @@ -1,5 +1,5 @@ -name: 'Setup Python with Poetry' -description: 'Setup Python environment with Poetry and install dependencies' +name: 'Setup Python with uv' +description: 'Setup Python environment with uv and install dependencies' author: 'Prowler' inputs: @@ -7,15 +7,15 @@ inputs: description: 'Python version to use' required: true working-directory: - description: 'Working directory for Poetry' + description: 'Working directory for uv' required: false default: '.' - poetry-version: - description: 'Poetry version to install' + uv-version: + description: 'uv version to install' required: false - default: '2.1.1' + default: '0.11.14' install-dependencies: - description: 'Install Python dependencies with Poetry' + description: 'Install Python dependencies with uv' required: false default: 'true' @@ -39,65 +39,70 @@ runs: sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml fi - - name: Install poetry - shell: bash - run: | - python -m pip install --upgrade pip - pipx install poetry==${INPUTS_POETRY_VERSION} - env: - INPUTS_POETRY_VERSION: ${{ inputs.poetry-version }} - - - name: Update poetry.lock with latest Prowler commit + - name: Update uv.lock with latest Prowler commit if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" - sed -i '/url = "https:\/\/github\.com\/prowler-cloud\/prowler\.git"/,/resolved_reference = / { - s/resolved_reference = "[a-f0-9]\{40\}"/resolved_reference = "'"$LATEST_COMMIT"'"/ - }' poetry.lock - echo "Updated resolved_reference:" - grep -A2 -B2 "resolved_reference" poetry.lock + sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock + echo "Updated uv.lock entry:" + grep "prowler-cloud/prowler" uv.lock - - name: Update SDK resolved_reference to latest commit (prowler repo on push) + - name: Update uv.lock SDK commit (prowler repo on push) if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" - sed -i '/url = "https:\/\/github\.com\/prowler-cloud\/prowler\.git"/,/resolved_reference = / { - s/resolved_reference = "[a-f0-9]\{40\}"/resolved_reference = "'"$LATEST_COMMIT"'"/ - }' poetry.lock - echo "Updated resolved_reference:" - grep -A2 -B2 "resolved_reference" poetry.lock + sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock + echo "Updated uv.lock entry:" + grep "prowler-cloud/prowler" uv.lock - - name: Update poetry.lock (prowler repo only) - if: github.repository == 'prowler-cloud/prowler' + - name: Install uv shell: bash - working-directory: ${{ inputs.working-directory }} - run: poetry lock + env: + UV_VERSION: ${{ inputs.uv-version }} + run: pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir "uv==${UV_VERSION}" - name: Set up Python ${{ inputs.python-version }} uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ inputs.python-version }} - cache: 'poetry' - cache-dependency-path: ${{ inputs.working-directory }}/poetry.lock + cache: 'pip' - name: Install Python dependencies if: inputs.install-dependencies == 'true' shell: bash working-directory: ${{ inputs.working-directory }} run: | - poetry install --no-root - poetry run pip list + uv sync --no-install-project + uv run pip list - name: Update Prowler Cloud API Client if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} run: | - poetry remove prowler-cloud-api-client - poetry add ./prowler-cloud-api-client + uv remove prowler-cloud-api-client + uv add ./prowler-cloud-api-client diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 9073e2fbcb..b7b758fb64 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -63,7 +63,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.2' - name: Run Trivy vulnerability scan (SARIF) if: inputs.upload-sarif == 'true' && github.event_name == 'push' @@ -76,7 +76,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.2' - name: Upload Trivy results to GitHub Security tab if: inputs.upload-sarif == 'true' && github.event_name == 'push' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 28eff02ff6..6a24af5fce 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,17 +6,17 @@ version: 2 updates: # v5 - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "monthly" - open-pull-requests-limit: 25 - target-branch: master - labels: - - "dependencies" - - "pip" - cooldown: - default-days: 7 + # - package-ecosystem: "pip" + # directory: "/" + # schedule: + # interval: "monthly" + # open-pull-requests-limit: 25 + # target-branch: master + # labels: + # - "dependencies" + # - "pip" + # cooldown: + # default-days: 7 # Dependabot Updates are temporary disabled - 2025/03/19 # - package-ecosystem: "pip" @@ -66,6 +66,18 @@ updates: cooldown: default-days: 7 + # - package-ecosystem: "pre-commit" + # directory: "/" + # schedule: + # interval: "monthly" + # open-pull-requests-limit: 25 + # target-branch: master + # labels: + # - "dependencies" + # - "pre-commit" + # cooldown: + # default-days: 7 + # Dependabot Updates are temporary disabled - 2025/04/15 # v4.6 # - package-ecosystem: "pip" diff --git a/.github/labeler.yml b/.github/labeler.yml index 49ec92e0d5..b9abb1dfd0 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -72,6 +72,16 @@ provider/vercel: - any-glob-to-any-file: "prowler/providers/vercel/**" - any-glob-to-any-file: "tests/providers/vercel/**" +provider/okta: + - changed-files: + - any-glob-to-any-file: "prowler/providers/okta/**" + - any-glob-to-any-file: "tests/providers/okta/**" + +provider/linode: + - changed-files: + - any-glob-to-any-file: "prowler/providers/linode/**" + - any-glob-to-any-file: "tests/providers/linode/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" @@ -109,6 +119,8 @@ mutelist: - any-glob-to-any-file: "tests/providers/googleworkspace/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/vercel/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/vercel/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/okta/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/okta/lib/mutelist/**" integration/s3: - changed-files: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 28365d4acb..f2065a3db0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -36,6 +36,7 @@ Please add a detailed description of how to review this PR. #### UI - [ ] All issue/task requirements work as expected on the UI +- [ ] If this PR adds or updates npm dependencies, include package-health evidence (maintenance, popularity, known vulnerabilities, license, release age) and explain why existing/native alternatives are insufficient. - [ ] Screenshots/Video of the functionality flow (if applicable) - Mobile (X < 640px) - [ ] Screenshots/Video of the functionality flow (if applicable) - Table (640px > X < 1024px) - [ ] Screenshots/Video of the functionality flow (if applicable) - Desktop (X > 1024px) @@ -48,7 +49,7 @@ Please add a detailed description of how to review this PR. - [ ] Performance test results (if applicable) - [ ] Any other relevant evidence of the implementation (if applicable) - [ ] Verify if API specs need to be regenerated. -- [ ] Check if version updates are required (e.g., specs, Poetry, etc.). +- [ ] Check if version updates are required (e.g., specs, uv, etc.). - [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/api/CHANGELOG.md), if applicable. ### License diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000000..be910a7f4e --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:best-practices", + ":enablePreCommit", + ":semanticCommits", + ":enableVulnerabilityAlertsWithLabel(security)", + "docker:enableMajor", + "helpers:pinGitHubActionDigestsToSemver", + "helpers:disableTypesNodeMajor", + "security:openssf-scorecard", + "customManagers:githubActionsVersions", + "customManagers:dockerfileVersions" + ], + "timezone": "Europe/Madrid", + "baseBranchPatterns": [ + "master" + ], + "labels": [ + "dependencies" + ], + "dependencyDashboardTitle": "Dependency Dashboard", + "prConcurrentLimit": 20, + "prHourlyLimit": 10, + "vulnerabilityAlerts": { + "prHourlyLimit": 0, + "prConcurrentLimit": 0 + }, + "configMigration": true, + "minimumReleaseAge": "7 days", + "rangeStrategy": "pin", + "packageRules": [ + { + "description": "Patches: 1st of every month, Madrid overnight window (22:00-06:00)", + "matchUpdateTypes": [ + "patch" + ], + "schedule": [ + "* 22-23,0-5 1 * *" + ], + "enabled": false + }, + { + "description": "Minors: 8th of every 3 months, Madrid overnight window (22:00-06:00)", + "matchUpdateTypes": [ + "minor" + ], + "schedule": [ + "* 22-23,0-5 8 */3 *" + ], + "enabled": false + }, + { + "description": "Majors: 15th of every 3 months, Madrid overnight window", + "matchUpdateTypes": [ + "major" + ], + "schedule": [ + "* 22-23,0-5 15 */3 *" + ], + "enabled": false + }, + { + "description": "GitHub Actions - single grouped PR, no changelog, scope=ci", + "matchManagers": [ + "github-actions" + ], + "groupName": "github-actions", + "semanticCommitScope": "ci", + "addLabels": [ + "no-changelog" + ] + }, + { + "description": "Docker images - single grouped PR, no changelog, scope=docker", + "matchManagers": [ + "dockerfile", + "docker-compose" + ], + "groupName": "docker", + "semanticCommitScope": "docker", + "addLabels": [ + "no-changelog" + ] + }, + { + "description": "Pre-commit hooks - single grouped PR, scope=pre-commit", + "matchManagers": [ + "pre-commit" + ], + "groupName": "pre-commit hooks", + "semanticCommitScope": "pre-commit", + "addLabels": [ + "no-changelog" + ] + }, + { + "description": "UI - scope=ui", + "matchFileNames": [ + "ui/**" + ], + "semanticCommitScope": "ui" + }, + { + "description": "API - scope=api", + "matchFileNames": [ + "api/**" + ], + "semanticCommitScope": "api" + }, + { + "description": "MCP server - scope=mcp", + "matchFileNames": [ + "mcp_server/**" + ], + "semanticCommitScope": "mcp" + }, + { + "description": "Python SDK (root) - scope=sdk", + "matchFileNames": [ + "pyproject.toml", + "poetry.lock", + "util/prowler-bulk-provisioning/**" + ], + "semanticCommitScope": "sdk" + }, + { + "description": "UI devDependencies - no changelog", + "matchFileNames": [ + "ui/**" + ], + "matchDepTypes": [ + "devDependencies" + ], + "addLabels": [ + "no-changelog" + ] + } + ] +} diff --git a/.github/scripts/osv-scan.sh b/.github/scripts/osv-scan.sh new file mode 100755 index 0000000000..16afc6668c --- /dev/null +++ b/.github/scripts/osv-scan.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Run osv-scanner and fail when findings match the configured severity levels. +# +# Replaces `safety check --policy-file .safety-policy.yml`. Used by: +# - .github/actions/osv-scanner/action.yml (composite action) +# - .github/workflows/api-security.yml, sdk-security.yml, ui-security.yml +# +# Severity levels (comma-separated) are read from OSV_SEVERITY_LEVELS. +# Default: CRITICAL — only CVSS >= 9.0 findings fail the scan. +# osv-scanner has no native CVSS threshold (google/osv-scanner#1400, closed +# not-planned). Severity is derived from $group.max_severity (numeric CVSS +# score string) which osv-scanner emits per group. +# +# CVSS v3 score → categorical label: +# CRITICAL >= 9.0 +# HIGH >= 7.0 +# MEDIUM >= 4.0 +# LOW > 0.0 +# UNKNOWN no score available +# +# Per-vulnerability ignores (with reason + expiry) live in osv-scanner.toml at +# the repo root, if it exists. Without that file, osv-scanner uses defaults. +# +# Usage: +# osv-scan.sh [osv-scanner pass-through args...] +# Examples: +# osv-scan.sh --lockfile=uv.lock +# osv-scan.sh --recursive . +# OSV_SEVERITY_LEVELS=CRITICAL osv-scan.sh --lockfile=uv.lock + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +CONFIG="${ROOT}/osv-scanner.toml" +SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-CRITICAL}" + +for bin in osv-scanner jq; do + if ! command -v "${bin}" >/dev/null 2>&1; then + echo "error: ${bin} not found in PATH" >&2 + exit 2 + fi +done + +SCAN_ARGS=() +if [ -f "${CONFIG}" ]; then + SCAN_ARGS+=(--config="${CONFIG}") +fi + +# Exit codes: 0=clean, 1=findings, 127=no supported files, 128=internal error. +STDERR="$(mktemp)" +trap 'rm -f "${STDERR}"' EXIT + +set +e +OUTPUT="$(osv-scanner scan source "${SCAN_ARGS[@]}" --format=json "$@" 2>"${STDERR}")" +RC=$? +set -e + +case "${RC}" in + 0|1) ;; + 127) echo "osv-scanner: no supported lockfiles in scan target"; exit 0 ;; + *) + echo "osv-scanner: exited with code ${RC}" >&2 + [ -s "${STDERR}" ] && cat "${STDERR}" >&2 + exit "${RC}" + ;; +esac + +# Build a JSON array of normalized severity levels for jq. +SEVERITY_JSON="$(printf '%s' "${SEVERITY_LEVELS}" | jq -Rc ' + split(",") | map(ascii_upcase | sub("^\\s+"; "") | sub("\\s+$"; "")) +')" + +# Walk each vulnerability, look up its group's max_severity (numeric CVSS), +# map to a categorical label, then filter by OSV_SEVERITY_LEVELS. +FINDINGS="$(printf '%s' "${OUTPUT}" | jq --argjson sevs "${SEVERITY_JSON}" ' + [ .results[]?.packages[]? + | . as $pkg + | ($pkg.groups // []) as $groups + | ($pkg.vulnerabilities // [])[] + | . as $vuln + | ([ $groups[] | select((.ids // []) | index($vuln.id)) ][0] // {}) as $group + | (($group.max_severity // "") | tonumber? // null) as $score + | (if $score == null then "UNKNOWN" + elif $score >= 9.0 then "CRITICAL" + elif $score >= 7.0 then "HIGH" + elif $score >= 4.0 then "MEDIUM" + elif $score > 0 then "LOW" + else "UNKNOWN" + end) as $label + | { + id: $vuln.id, + severity: $label, + score: $score, + summary: ($vuln.summary // null), + package: $pkg.package.name, + version: $pkg.package.version, + ecosystem: $pkg.package.ecosystem + } + | select(.severity as $s | $sevs | any(. == $s)) + ] +')" + +COUNT="$(printf '%s' "${FINDINGS}" | jq 'length')" + +# Write the findings JSON to OSV_REPORT_FILE so callers (e.g. the composite +# action's PR-comment step) can consume the same data the gate decision uses. +if [ -n "${OSV_REPORT_FILE:-}" ]; then + printf '%s' "${FINDINGS}" > "${OSV_REPORT_FILE}" +fi + +if [ "${COUNT}" -gt 0 ]; then + echo "osv-scanner: ${COUNT} finding(s) at severity ${SEVERITY_LEVELS}" + printf '%s' "${FINDINGS}" | jq -r ' + .[] | " [\(.severity)\(if .score then " \(.score)" else "" end)] \(.id) \(.ecosystem)/\(.package)@\(.version) — \(.summary // "(no summary)")" + ' + echo + echo "To accept a finding, create osv-scanner.toml at the repo root with a reason and ignoreUntil." + exit 1 +fi + +echo "osv-scanner: no findings at severity levels: ${SEVERITY_LEVELS}" diff --git a/.github/workflows/api-bump-version.yml b/.github/workflows/api-bump-version.yml deleted file mode 100644 index 7ddbf195f5..0000000000 --- a/.github/workflows/api-bump-version.yml +++ /dev/null @@ -1,289 +0,0 @@ -name: 'API: Bump Version' - -on: - release: - types: - - 'published' - -concurrency: - group: ${{ github.workflow }}-${{ github.event.release.tag_name }} - cancel-in-progress: false - -env: - PROWLER_VERSION: ${{ github.event.release.tag_name }} - BASE_BRANCH: master - -jobs: - detect-release-type: - runs-on: ubuntu-latest - timeout-minutes: 5 - 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_api_version: ${{ steps.get_api_version.outputs.current_api_version }} - 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 API version - id: get_api_version - run: | - CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml) - echo "current_api_version=${CURRENT_API_VERSION}" >> "${GITHUB_OUTPUT}" - echo "Current API version: $CURRENT_API_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 - 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 API minor version - run: | - MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION} - MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION} - CURRENT_API_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION}" - - # API version follows Prowler minor + 1 - # For Prowler 5.17.0 -> API 1.18.0 - # For next master (Prowler 5.18.0) -> API 1.19.0 - NEXT_API_VERSION=1.$((MINOR_VERSION + 2)).0 - - echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" - echo "NEXT_API_VERSION=${NEXT_API_VERSION}" >> "${GITHUB_ENV}" - - echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.0" - echo "Current API version: $CURRENT_API_VERSION" - echo "Next API minor version (for master): $NEXT_API_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_API_VERSION: ${{ needs.detect-release-type.outputs.current_api_version }} - - - name: Bump API versions in files for master - run: | - set -e - - sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml - sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${NEXT_API_VERSION}\"|" api/src/backend/api/v1/views.py - sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for next API minor version 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: master - commit-message: 'chore(api): Bump version to v${{ env.NEXT_API_VERSION }}' - branch: api-version-bump-to-v${{ env.NEXT_API_VERSION }} - title: 'chore(api): Bump version to v${{ env.NEXT_API_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler API version to v${{ env.NEXT_API_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. - - ### 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 API patch version - run: | - MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION} - MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION} - CURRENT_API_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION}" - VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} - - # API version follows Prowler minor + 1 - # For Prowler 5.17.0 release -> version branch v5.17 should have API 1.18.1 - FIRST_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).1 - - echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" - echo "FIRST_API_PATCH_VERSION=${FIRST_API_PATCH_VERSION}" >> "${GITHUB_ENV}" - echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" - - echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.0" - echo "First API patch version (for ${VERSION_BRANCH}): $FIRST_API_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_API_VERSION: ${{ needs.detect-release-type.outputs.current_api_version }} - - - name: Bump API versions in files for version branch - run: | - set -e - - sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml - sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${FIRST_API_PATCH_VERSION}\"|" api/src/backend/api/v1/views.py - sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for first API patch version 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: 'chore(api): Bump version to v${{ env.FIRST_API_PATCH_VERSION }}' - branch: api-version-bump-to-v${{ env.FIRST_API_PATCH_VERSION }} - title: 'chore(api): Bump version to v${{ env.FIRST_API_PATCH_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler API version to v${{ env.FIRST_API_PATCH_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_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: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Calculate next API 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_API_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION}" - VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} - - # Extract current API patch to increment it - if [[ $CURRENT_API_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - API_PATCH=${BASH_REMATCH[3]} - - # API version follows Prowler minor + 1 - # Keep same API minor (based on Prowler minor), increment patch - NEXT_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).$((API_PATCH + 1)) - - echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" - echo "NEXT_API_PATCH_VERSION=${NEXT_API_PATCH_VERSION}" >> "${GITHUB_ENV}" - echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" - - echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION}" - echo "Current API version: $CURRENT_API_VERSION" - echo "Next API patch version: $NEXT_API_PATCH_VERSION" - echo "Target branch: $VERSION_BRANCH" - else - echo "::error::Invalid API version format: $CURRENT_API_VERSION" - exit 1 - fi - 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_API_VERSION: ${{ needs.detect-release-type.outputs.current_api_version }} - - - name: Bump API versions in files for version branch - run: | - set -e - - sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml - sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${NEXT_API_PATCH_VERSION}\"|" api/src/backend/api/v1/views.py - sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for next API patch version 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: 'chore(api): Bump version to v${{ env.NEXT_API_PATCH_VERSION }}' - branch: api-version-bump-to-v${{ env.NEXT_API_PATCH_VERSION }} - title: 'chore(api): Bump version to v${{ env.NEXT_API_PATCH_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler API version to v${{ env.NEXT_API_PATCH_VERSION }} after releasing Prowler v${{ env.PROWLER_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/api-code-quality.yml b/.github/workflows/api-code-quality.yml index 68928833a2..362f0dbbbf 100644 --- a/.github/workflows/api-code-quality.yml +++ b/.github/workflows/api-code-quality.yml @@ -17,6 +17,8 @@ concurrency: env: API_WORKING_DIR: ./api +permissions: {} + jobs: api-code-quality: runs-on: ubuntu-latest @@ -33,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -41,6 +43,7 @@ jobs: pypi.org:443 files.pythonhosted.org:443 api.github.com:443 + raw.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -50,7 +53,7 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -61,25 +64,25 @@ jobs: api/CHANGELOG.md api/AGENTS.md - - name: Setup Python with Poetry + - name: Setup Python with uv if: steps.check-changes.outputs.any_changed == 'true' - uses: ./.github/actions/setup-python-poetry + uses: ./.github/actions/setup-python-uv with: python-version: ${{ matrix.python-version }} working-directory: ./api - - name: Poetry check + - name: uv lock check if: steps.check-changes.outputs.any_changed == 'true' - run: poetry check --lock + run: uv lock --check - name: Ruff lint if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run ruff check . --exclude contrib + run: uv run ruff check . --exclude contrib - name: Ruff format if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run ruff format --check . --exclude contrib + run: uv run ruff format --check . --exclude contrib - name: Pylint if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run pylint --disable=W,C,R,E -j 0 -rn -sn src/ + run: uv run pylint --disable=W,C,R,E -j 0 -rn -sn src/ diff --git a/.github/workflows/api-codeql.yml b/.github/workflows/api-codeql.yml index 3832900a89..634c63cb11 100644 --- a/.github/workflows/api-codeql.yml +++ b/.github/workflows/api-codeql.yml @@ -24,6 +24,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: api-analyze: name: CodeQL Security Analysis @@ -42,7 +44,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -59,12 +61,12 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/api-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/api-container-build-push.yml b/.github/workflows/api-container-build-push.yml index 0cd3b82071..23f3e7fc6c 100644 --- a/.github/workflows/api-container-build-push.yml +++ b/.github/workflows/api-container-build-push.yml @@ -33,6 +33,8 @@ env: PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-api +permissions: {} + jobs: setup: if: github.repository == 'prowler-cloud/prowler' @@ -44,7 +46,7 @@ jobs: contents: read steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block @@ -63,7 +65,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -106,7 +108,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -120,6 +122,7 @@ jobs: github.com:443 powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 pypi.org:443 registry-1.docker.io:443 release-assets.githubusercontent.com:443 @@ -130,25 +133,29 @@ jobs: with: persist-credentials: false - - name: Pin prowler SDK to latest master commit - if: github.event_name == 'push' + - name: Refresh prowler SDK pin to current branch tip run: | - LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1) - sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml + # api/pyproject.toml has `@master` on master and `@v5.X` on release + # branches (set by prepare-release.yml). uv lock --upgrade-package + # re-resolves whichever ref is present against the current branch tip + # and writes the SHA into api/uv.lock. The Dockerfile runs + # `uv sync --locked`, which is what actually drives the install. + pip install --no-cache-dir "uv==0.11.14" + (cd api && uv lock --upgrade-package prowler) - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push API container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.WORKING_DIRECTORY }} push: true @@ -156,7 +163,7 @@ jobs: tags: | ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }} cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }} # Create and push multi-architecture manifest create-manifest: @@ -168,7 +175,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -177,8 +184,9 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -228,7 +236,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -264,27 +272,3 @@ jobs: payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" step-outcome: ${{ steps.outcome.outputs.outcome }} update-ts: ${{ needs.notify-release-started.outputs.message-ts }} - - trigger-deployment: - needs: [setup, container-build-push] - if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - - steps: - - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 - with: - egress-policy: block - allowed-endpoints: > - api.github.com:443 - - - name: Trigger API deployment - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.CLOUD_DISPATCH }} - event-type: api-prowler-deployment - client-payload: '{"sha": "${{ github.sha }}", "short_sha": "${{ needs.setup.outputs.short-sha }}"}' diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 14c163e89a..d12bf5d2d4 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -5,6 +5,9 @@ on: branches: - 'master' - 'v5.*' + paths: + - 'api/**' + - '.github/workflows/api-container-checks.yml' pull_request: branches: - 'master' @@ -18,6 +21,8 @@ env: API_WORKING_DIR: ./api IMAGE_NAME: prowler-api +permissions: {} + jobs: api-dockerfile-lint: if: github.repository == 'prowler-cloud/prowler' @@ -28,7 +33,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -42,7 +47,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: api/Dockerfile @@ -55,16 +60,7 @@ jobs: api-container-build-and-scan: if: github.repository == 'prowler-cloud/prowler' - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - arch: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - arch: arm64 + runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read @@ -73,7 +69,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -84,6 +80,7 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 debian.map.fastlydns.net:80 release-assets.githubusercontent.com:443 objects.githubusercontent.com:443 @@ -104,7 +101,7 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: api/** files_ignore: | @@ -115,25 +112,24 @@ jobs: - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Build container for ${{ matrix.arch }} + - name: Build container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.API_WORKING_DIR }} push: false load: true - platforms: ${{ matrix.platform }} - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} - cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }} - - name: Scan container with Trivy for ${{ matrix.arch }} + - name: Scan container with Trivy if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }}-${{ matrix.arch }} - fail-on-critical: 'false' + image-tag: ${{ github.sha }} + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index 9cd6b14623..ed4e5476d2 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -5,6 +5,13 @@ on: branches: - "master" - "v5.*" + paths: + - 'api/**' + - '.github/workflows/api-tests.yml' + - '.github/workflows/api-security.yml' + - '.github/actions/setup-python-uv/**' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' pull_request: branches: - "master" @@ -17,12 +24,15 @@ concurrency: env: API_WORKING_DIR: ./api +permissions: {} + jobs: api-security-scans: runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read + pull-requests: write # osv-scanner action posts/updates a PR comment with findings strategy: matrix: python-version: @@ -33,17 +43,20 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > pypi.org:443 files.pythonhosted.org:443 github.com:443 - auth.safetycli.com:443 - pyup.io:443 - data.safetycli.com:443 api.github.com:443 + objects.githubusercontent.com:443 + raw.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + api.osv.dev:443 + api.deps.dev:443 + osv-vulnerabilities.storage.googleapis.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -53,34 +66,39 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** .github/workflows/api-security.yml + .github/actions/osv-scanner/** + .github/scripts/osv-scan.sh files_ignore: | api/docs/** api/README.md api/CHANGELOG.md api/AGENTS.md - - name: Setup Python with Poetry + - name: Setup Python with uv if: steps.check-changes.outputs.any_changed == 'true' - uses: ./.github/actions/setup-python-poetry + uses: ./.github/actions/setup-python-uv with: python-version: ${{ matrix.python-version }} working-directory: ./api - name: Bandit if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run bandit -q -lll -x '*_test.py,./contrib/' -r . + # Exclude .venv because uv places the project venv inside ./api; otherwise + # bandit would recurse into installed third-party packages. + run: uv run bandit -q -lll -x '*_test.py,./contrib/,./.venv/' -r . - - name: Safety + - name: Dependency vulnerability scan with osv-scanner if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check --ignore 79023,79027,86217 - # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - # TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0` + uses: ./.github/actions/osv-scanner + with: + lockfile: api/uv.lock - name: Vulture - if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run vulture --exclude "contrib,tests,conftest.py" --min-confidence 100 . + # Run even when osv-scanner reports findings so dead-code signal isn't masked by SCA failures. + if: ${{ !cancelled() && steps.check-changes.outputs.any_changed == 'true' }} + run: uv run vulture --exclude "contrib,tests,conftest.py,.venv" --min-confidence 100 . diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index ce00b03108..36937bdc68 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -30,6 +30,8 @@ env: VALKEY_DB: 0 API_WORKING_DIR: ./api +permissions: {} + jobs: api-tests: runs-on: ubuntu-latest @@ -76,7 +78,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -85,6 +87,7 @@ jobs: files.pythonhosted.org:443 cli.codecov.io:443 keybase.io:443 + raw.githubusercontent.com:443 ingest.codecov.io:443 storage.googleapis.com:443 o26192.ingest.us.sentry.io:443 @@ -99,7 +102,7 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -110,16 +113,16 @@ jobs: api/CHANGELOG.md api/AGENTS.md - - name: Setup Python with Poetry + - name: Setup Python with uv if: steps.check-changes.outputs.any_changed == 'true' - uses: ./.github/actions/setup-python-poetry + uses: ./.github/actions/setup-python-uv with: python-version: ${{ matrix.python-version }} working-directory: ./api - name: Run tests with pytest if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run pytest --cov=./src/backend --cov-report=xml src/backend + run: uv run pytest --cov=./src/backend --cov-report=xml src/backend - name: Upload coverage reports to Codecov if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index feb8c7c9bf..b1ea9ec7a2 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -17,6 +17,8 @@ env: BACKPORT_LABEL_PREFIX: backport-to- BACKPORT_LABEL_IGNORE: was-backported +permissions: {} + jobs: backport: if: github.event.pull_request.merged == true && !(contains(github.event.pull_request.labels.*.name, 'backport')) && !(contains(github.event.pull_request.labels.*.name, 'was-backported')) @@ -28,11 +30,12 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > api.github.com:443 + github.com:443 - name: Check labels id: label_check @@ -46,7 +49,7 @@ jobs: - name: Backport PR if: steps.label_check.outputs.label_check == 'success' - uses: sorenlouv/backport-github-action@516854e7c9f962b9939085c9a92ea28411d1ae90 # v10.2.0 + uses: sorenlouv/backport-github-action@9460b7102fea25466026ce806c9ebf873ac48721 # v11.0.0 with: github_token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} auto_backport_label_prefix: ${{ env.BACKPORT_LABEL_PREFIX }} diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000000..079e3134b7 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,409 @@ +name: 'Release: Bump Versions' + +on: + release: + types: + - 'published' + +concurrency: + group: release-bump-versions-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + PROWLER_VERSION: ${{ github.event.release.tag_name }} + DOCS_FILE: docs/getting-started/installation/prowler-app.mdx + +permissions: {} + +jobs: + detect-release-type: + runs-on: ubuntu-latest + timeout-minutes: 5 + 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 }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - 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 + echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)" + exit 1 + fi + + bump-minor-master: + name: Bump versions on master (minor release) + 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout master + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: master + persist-credentials: false + + - name: Compute next versions for master + run: | + MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION} + MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION} + + # SDK / UI / docs mirror the Prowler version directly. + NEXT_SDK_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0 + + # API is an independent stream: 1..X + # After Prowler 5.M.0 release, master moves on to next API minor: 1.(M+2).0 + NEXT_API_VERSION=1.$((MINOR_VERSION + 2)).0 + + # Read current versions to drive sed replacements. + CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml) + CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' "${DOCS_FILE}") + + echo "NEXT_SDK_VERSION=${NEXT_SDK_VERSION}" >> "${GITHUB_ENV}" + echo "NEXT_API_VERSION=${NEXT_API_VERSION}" >> "${GITHUB_ENV}" + echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" + echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}" + + echo "Released Prowler version: $PROWLER_VERSION" + echo "Next SDK/UI version (master): $NEXT_SDK_VERSION" + echo "Next API version (master): $NEXT_API_VERSION (current: $CURRENT_API_VERSION)" + echo "Docs target version: $PROWLER_VERSION (current: $CURRENT_DOCS_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 }} + + - name: Decide whether to bump docs on master + id: docs_decision + run: | + # Skip docs bump 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 docs bump: current ($CURRENT_DOCS_VERSION) >= release ($PROWLER_VERSION)" + else + echo "skip=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Bump SDK version (pyproject.toml, config.py) + run: | + set -e + sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_SDK_VERSION}\"|" pyproject.toml + sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_SDK_VERSION}\"|" prowler/config/config.py + + - name: Bump API version (api/pyproject.toml, specs/v1.yaml) + run: | + set -e + sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml + sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml + + - name: Regenerate lockfiles after version bump + run: | + set -e + # The bumps above edit pyproject.toml / api/pyproject.toml but leave + # uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in + # the container builds. Refresh both with the uv version the images + # pin (plain `uv lock`, no --upgrade: only the version line changes). + pip install --no-cache-dir "uv==0.11.14" + uv lock + (cd api && uv lock) + + - name: Bump UI version (.env) + run: | + set -e + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_SDK_VERSION}|" .env + + - name: Bump docs versions (prowler-app.mdx) + if: steps.docs_decision.outputs.skip == 'false' + run: | + set -e + 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}" + + - name: Show consolidated diff + run: git --no-pager diff + + - name: Create PR for next versions to master + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: master + commit-message: 'chore(release): Bump versions to v${{ env.NEXT_SDK_VERSION }}' + branch: release-version-bump-to-v${{ env.NEXT_SDK_VERSION }} + title: 'chore(release): Bump versions to v${{ env.NEXT_SDK_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler versions on master after releasing Prowler v${{ env.PROWLER_VERSION }}. + + | Area | File(s) | New version | + | --- | --- | --- | + | SDK | `pyproject.toml`, `prowler/config/config.py` | v${{ env.NEXT_SDK_VERSION }} | + | API | `api/pyproject.toml`, `api/src/backend/api/specs/v1.yaml` | v${{ env.NEXT_API_VERSION }} | + | UI | `.env` (`NEXT_PUBLIC_PROWLER_RELEASE_VERSION`) | v${{ env.NEXT_SDK_VERSION }} | + | Docs | `docs/getting-started/installation/prowler-app.mdx` (`PROWLER_UI_VERSION`, `PROWLER_API_VERSION`) | v${{ env.PROWLER_VERSION }} (skipped if already at or ahead) | + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + bump-minor-version-branch: + name: Bump versions on version branch (minor release) + 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - 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: Compute first patch versions for version branch + run: | + MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION} + MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION} + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + # SDK / UI first patch mirrors Prowler version directly. + FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1 + + # API on this branch stays on the 1..X stream, starting at .1 + FIRST_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).1 + + CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml) + + echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "FIRST_API_PATCH_VERSION=${FIRST_API_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "Released Prowler version: $PROWLER_VERSION" + echo "Version branch: $VERSION_BRANCH" + echo "First SDK/UI patch: $FIRST_PATCH_VERSION" + echo "First API patch: $FIRST_API_PATCH_VERSION (current: $CURRENT_API_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 }} + + - name: Bump SDK version (pyproject.toml, config.py) + run: | + set -e + sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${FIRST_PATCH_VERSION}\"|" pyproject.toml + sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${FIRST_PATCH_VERSION}\"|" prowler/config/config.py + + - name: Bump API version (api/pyproject.toml, specs/v1.yaml) + run: | + set -e + sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml + sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml + + - name: Regenerate lockfiles after version bump + run: | + set -e + # The bumps above edit pyproject.toml / api/pyproject.toml but leave + # uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in + # the container builds. Refresh both with the uv version the images + # pin (plain `uv lock`, no --upgrade: only the version line changes). + pip install --no-cache-dir "uv==0.11.14" + uv lock + (cd api && uv lock) + + - name: Bump UI version (.env) + run: | + set -e + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env + + - name: Show consolidated diff + run: git --no-pager diff + + - name: Create PR for first patch versions to version branch + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + 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 versions to v${{ env.FIRST_PATCH_VERSION }}' + branch: release-version-bump-to-v${{ env.FIRST_PATCH_VERSION }} + title: 'chore(release): Bump versions to v${{ env.FIRST_PATCH_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler versions on `${{ env.VERSION_BRANCH }}` after releasing Prowler v${{ env.PROWLER_VERSION }}. + + | Area | File(s) | New version | + | --- | --- | --- | + | SDK | `pyproject.toml`, `prowler/config/config.py` | v${{ env.FIRST_PATCH_VERSION }} | + | API | `api/pyproject.toml`, `api/src/backend/api/specs/v1.yaml` | v${{ env.FIRST_API_PATCH_VERSION }} | + | UI | `.env` (`NEXT_PUBLIC_PROWLER_RELEASE_VERSION`) | v${{ env.FIRST_PATCH_VERSION }} | + | Docs | (not touched on version branches) | — | + + ### 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-branch: + name: Bump versions on version branch (patch release) + 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Compute next patch versions + 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} + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + # SDK / UI patch mirrors Prowler version directly. + NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1)) + + CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml) + + # API on this branch stays on 1..X; bump its patch component. + if [[ $CURRENT_API_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + API_PATCH=${BASH_REMATCH[3]} + NEXT_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).$((API_PATCH + 1)) + else + echo "::error::Invalid API version format: $CURRENT_API_VERSION" + exit 1 + fi + + echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "NEXT_API_PATCH_VERSION=${NEXT_API_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "Released Prowler version: $PROWLER_VERSION" + echo "Version branch: $VERSION_BRANCH" + echo "Next SDK/UI patch: $NEXT_PATCH_VERSION" + echo "Next API patch: $NEXT_API_PATCH_VERSION (current: $CURRENT_API_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_PATCH_VERSION: ${{ needs.detect-release-type.outputs.patch_version }} + + - name: Bump SDK version (pyproject.toml, config.py) + run: | + set -e + sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_PATCH_VERSION}\"|" pyproject.toml + sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_PATCH_VERSION}\"|" prowler/config/config.py + + - name: Bump API version (api/pyproject.toml, specs/v1.yaml) + run: | + set -e + sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml + sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml + + - name: Regenerate lockfiles after version bump + run: | + set -e + # The bumps above edit pyproject.toml / api/pyproject.toml but leave + # uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in + # the container builds. Refresh both with the uv version the images + # pin (plain `uv lock`, no --upgrade: only the version line changes). + pip install --no-cache-dir "uv==0.11.14" + uv lock + (cd api && uv lock) + + - name: Bump UI version (.env) + run: | + set -e + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env + + - name: Show consolidated diff + run: git --no-pager diff + + - name: Create PR for next patch versions to version branch + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + 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 versions to v${{ env.NEXT_PATCH_VERSION }}' + branch: release-version-bump-to-v${{ env.NEXT_PATCH_VERSION }} + title: 'chore(release): Bump versions to v${{ env.NEXT_PATCH_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler versions on `${{ env.VERSION_BRANCH }}` after releasing Prowler v${{ env.PROWLER_VERSION }}. + + | Area | File(s) | New version | + | --- | --- | --- | + | SDK | `pyproject.toml`, `prowler/config/config.py` | v${{ env.NEXT_PATCH_VERSION }} | + | API | `api/pyproject.toml`, `api/src/backend/api/specs/v1.yaml` | v${{ env.NEXT_API_PATCH_VERSION }} | + | UI | `.env` (`NEXT_PUBLIC_PROWLER_RELEASE_VERSION`) | v${{ env.NEXT_PATCH_VERSION }} | + | Docs | (not touched on version branches) | — | + + ### 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/ci-zizmor.yml b/.github/workflows/ci-zizmor.yml index 5962b01efd..c0c68d21b9 100644 --- a/.github/workflows/ci-zizmor.yml +++ b/.github/workflows/ci-zizmor.yml @@ -21,6 +21,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: zizmor: if: github.repository == 'prowler-cloud/prowler' @@ -34,7 +36,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -49,6 +51,6 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 + uses: zizmorcore/zizmor-action@a16621b09c6db4281f81a93cb393b05dcd7b7165 # v0.5.5 with: token: ${{ github.token }} diff --git a/.github/workflows/comment-label-update.yml b/.github/workflows/comment-label-update.yml index 3f5f3bb01f..0af688b412 100644 --- a/.github/workflows/comment-label-update.yml +++ b/.github/workflows/comment-label-update.yml @@ -9,6 +9,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.issue.number }} cancel-in-progress: false +permissions: {} + jobs: update-labels: if: contains(github.event.issue.labels.*.name, 'status/awaiting-response') @@ -20,7 +22,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index a1195ef389..502881bc73 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -4,8 +4,6 @@ on: pull_request: branches: - 'master' - - 'v3' - - 'v4.*' - 'v5.*' types: - 'opened' @@ -16,6 +14,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true +permissions: {} + jobs: conventional-commit-check: runs-on: ubuntu-latest @@ -26,7 +26,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit diff --git a/.github/workflows/create-backport-label.yml b/.github/workflows/create-backport-label.yml index 334406f76f..4af9fefd5f 100644 --- a/.github/workflows/create-backport-label.yml +++ b/.github/workflows/create-backport-label.yml @@ -13,6 +13,8 @@ env: BACKPORT_LABEL_PREFIX: backport-to- BACKPORT_LABEL_COLOR: B60205 +permissions: {} + jobs: create-label: runs-on: ubuntu-latest @@ -23,7 +25,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -41,14 +43,11 @@ jobs: echo "Processing release tag: $RELEASE_TAG" - # Remove 'v' prefix if present (e.g., v3.2.0 -> 3.2.0) VERSION_ONLY="${RELEASE_TAG#v}" - # Check if it's a minor version (X.Y.0) if [[ "$VERSION_ONLY" =~ ^([0-9]+)\.([0-9]+)\.0$ ]]; then echo "Release $RELEASE_TAG (version $VERSION_ONLY) is a minor version. Proceeding to create backport label." - # Extract X.Y from X.Y.0 (e.g., 5.6 from 5.6.0) MAJOR="${BASH_REMATCH[1]}" MINOR="${BASH_REMATCH[2]}" TWO_DIGIT_VERSION="${MAJOR}.${MINOR}" @@ -60,7 +59,6 @@ jobs: echo "Label name: $LABEL_NAME" echo "Label description: $LABEL_DESC" - # Check if label already exists if gh label list --repo ${{ github.repository }} --limit 1000 | grep -q "^${LABEL_NAME}[[:space:]]"; then echo "Label '$LABEL_NAME' already exists." else diff --git a/.github/workflows/docs-bump-version.yml b/.github/workflows/docs-bump-version.yml deleted file mode 100644 index e8c33ae53e..0000000000 --- a/.github/workflows/docs-bump-version.yml +++ /dev/null @@ -1,282 +0,0 @@ -name: 'Docs: Bump Version' - -on: - release: - types: - - 'published' - -concurrency: - group: ${{ github.workflow }}-${{ github.event.release.tag_name }} - cancel-in-progress: false - -env: - PROWLER_VERSION: ${{ github.event.release.tag_name }} - BASE_BRANCH: master - -jobs: - detect-release-type: - runs-on: ubuntu-latest - timeout-minutes: 5 - 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 }} - 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 - 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 - 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: master - 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: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - 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 }} - - - name: Bump versions in documentation for patch version - 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 }} - 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` - - ### 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/find-secrets.yml b/.github/workflows/find-secrets.yml index af0a9c7e78..ac3efaaa69 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -14,6 +14,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: scan-secrets: runs-on: ubuntu-latest @@ -23,21 +25,26 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: - egress-policy: block + # We can't block as Trufflehog needs to verify secrets against vendors + egress-policy: audit allowed-endpoints: > github.com:443 ghcr.io:443 pkg-containers.githubusercontent.com:443 + www.formbucket.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 + # PRs only need the diff range; push to master/release walks the new range from event.before. + # 50 is enough headroom for the longest realistic PR/push chain without paying for a full clone. + fetch-depth: 50 persist-credentials: false - - name: Scan for secrets with TruffleHog - uses: trufflesecurity/trufflehog@ef6e76c3c4023279497fab4721ffa071a722fd05 # v3.92.4 + - name: Scan diff for secrets with TruffleHog + # Action auto-injects --since-commit/--branch from event payload; passing them in extra_args produces duplicate flags. + uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3 with: - extra_args: '--results=verified,unknown' + extra_args: --results=verified,unknown diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index a0a24c2516..1691f21d35 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -21,6 +21,8 @@ concurrency: env: CHART_PATH: contrib/k8s/helm/prowler-app +permissions: {} + jobs: helm-lint: if: github.repository == 'prowler-cloud/prowler' @@ -31,17 +33,17 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Helm - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 - name: Update chart dependencies run: helm dependency update ${{ env.CHART_PATH }} diff --git a/.github/workflows/helm-chart-release.yml b/.github/workflows/helm-chart-release.yml index c0c5773bdb..ca179adeef 100644 --- a/.github/workflows/helm-chart-release.yml +++ b/.github/workflows/helm-chart-release.yml @@ -13,6 +13,8 @@ concurrency: env: CHART_PATH: contrib/k8s/helm/prowler-app +permissions: {} + jobs: release-helm-chart: if: github.repository == 'prowler-cloud/prowler' @@ -24,17 +26,17 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Helm - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 - name: Set appVersion from release tag run: | diff --git a/.github/workflows/issue-lock-on-close.yml b/.github/workflows/issue-lock-on-close.yml new file mode 100644 index 0000000000..3778c77d05 --- /dev/null +++ b/.github/workflows/issue-lock-on-close.yml @@ -0,0 +1,53 @@ +name: 'Tools: Lock Issue on Close' + +on: + issues: + types: + - closed + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: false + +permissions: {} + +jobs: + lock: + if: | + github.repository == 'prowler-cloud/prowler' && + github.event.issue.locked == false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + + - name: Comment and lock issue + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: 'This issue is now locked as it has been closed. If you are still hitting a related problem, please open a new issue and link back to this one for context. Thanks!' + }); + } catch (error) { + core.warning(`Failed to post lock comment on issue #${issue_number}: ${error.message}`); + } + + const lockParams = { owner, repo, issue_number }; + if (context.payload.issue.state_reason === 'completed') { + lockParams.lock_reason = 'resolved'; + } + await github.rest.issues.lock(lockParams); diff --git a/.github/workflows/issue-triage.lock.yml b/.github/workflows/issue-triage.lock.yml index fcc6f1e363..6533306322 100644 --- a/.github/workflows/issue-triage.lock.yml +++ b/.github/workflows/issue-triage.lock.yml @@ -66,12 +66,12 @@ jobs: title: ${{ steps.compute-text.outputs.title }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -135,12 +135,12 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -772,7 +772,7 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Safe Outputs if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: safe-output path: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -793,13 +793,13 @@ jobs: await main(); - name: Upload sanitized agent output if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent-output path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent_outputs path: | @@ -839,7 +839,7 @@ jobs: - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent-artifacts path: | @@ -870,17 +870,17 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0 with: destination: /opt/gh-aw/actions - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ @@ -982,23 +982,23 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0 with: destination: /opt/gh-aw/actions - name: Download agent artifacts continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: agent-artifacts path: /tmp/gh-aw/threat-detection/ - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: agent-output path: /tmp/gh-aw/threat-detection/ @@ -1071,7 +1071,7 @@ jobs: await main(); - name: Upload threat detection log if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: threat-detection.log path: /tmp/gh-aw/threat-detection/detection.log @@ -1091,12 +1091,12 @@ jobs: activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0 with: destination: /opt/gh-aw/actions - name: Add eyes reaction for immediate feedback @@ -1164,17 +1164,17 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Setup Scripts - uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 + uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0 with: destination: /opt/gh-aw/actions - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f4a136f7fd..5d519b20d1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -15,6 +15,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true +permissions: {} + jobs: labeler: runs-on: ubuntu-latest @@ -25,12 +27,12 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit - name: Apply labels to PR - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: sync-labels: true @@ -44,7 +46,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -60,7 +62,7 @@ jobs: "Alan-TheGentleman" "alejandrobailo" "amitsharm" - "andoniaf" + # "andoniaf" "cesararroba" "danibarranqueroo" "HugoPBrito" @@ -76,6 +78,7 @@ jobs: "StylusFrost" "toniblyx" "davidm4r" + "pfe-nazaries" ) echo "Checking if $AUTHOR is a member of prowler-cloud organization" diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml new file mode 100644 index 0000000000..1a01e97762 --- /dev/null +++ b/.github/workflows/markdown-lint.yml @@ -0,0 +1,60 @@ +name: 'Docs: Markdown Lint' + +on: + push: + branches: + - 'master' + - 'v5.*' + pull_request: + branches: + - 'master' + - 'v5.*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + markdown-lint: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + registry.npmjs.org:443 + release-assets.githubusercontent.com:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: ui/.nvmrc + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + package_json_file: ui/package.json + run_install: false + + - name: Run markdownlint + # Pin must match .pre-commit-config.yaml so prek and CI behave identically. + # pnpm dlx doesn't accept --ignore-scripts as a flag; the env var + # disables postinstall scripts on transitives the same way. + env: + pnpm_config_ignore_scripts: 'true' + run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md' diff --git a/.github/workflows/mcp-container-build-push.yml b/.github/workflows/mcp-container-build-push.yml index 3f59a455db..bcad46ba49 100644 --- a/.github/workflows/mcp-container-build-push.yml +++ b/.github/workflows/mcp-container-build-push.yml @@ -32,6 +32,8 @@ env: PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-mcp +permissions: {} + jobs: setup: if: github.repository == 'prowler-cloud/prowler' @@ -43,7 +45,7 @@ jobs: contents: read steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block @@ -62,7 +64,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -104,7 +106,7 @@ jobs: packages: write steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -112,6 +114,7 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 ghcr.io:443 pkg-containers.githubusercontent.com:443 files.pythonhosted.org:443 @@ -123,18 +126,18 @@ jobs: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push MCP container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.WORKING_DIRECTORY }} push: true @@ -150,7 +153,7 @@ jobs: org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }} ${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }} cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }} # Create and push multi-architecture manifest create-manifest: @@ -162,18 +165,19 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 github.com:443 release-assets.githubusercontent.com:443 - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -223,7 +227,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -259,27 +263,3 @@ jobs: payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" step-outcome: ${{ steps.outcome.outputs.outcome }} update-ts: ${{ needs.notify-release-started.outputs.message-ts }} - - trigger-deployment: - needs: [setup, container-build-push] - if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - - steps: - - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 - with: - egress-policy: block - allowed-endpoints: > - api.github.com:443 - - - name: Trigger MCP deployment - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.CLOUD_DISPATCH }} - event-type: mcp-prowler-deployment - client-payload: '{"sha": "${{ github.sha }}", "short_sha": "${{ needs.setup.outputs.short-sha }}"}' diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index f8fbfca12f..23e6ba2984 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -5,6 +5,9 @@ on: branches: - 'master' - 'v5.*' + paths: + - 'mcp_server/**' + - '.github/workflows/mcp-container-checks.yml' pull_request: branches: - 'master' @@ -18,6 +21,8 @@ env: MCP_WORKING_DIR: ./mcp_server IMAGE_NAME: prowler-mcp +permissions: {} + jobs: mcp-dockerfile-lint: if: github.repository == 'prowler-cloud/prowler' @@ -28,7 +33,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -42,7 +47,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: mcp_server/Dockerfile @@ -54,16 +59,7 @@ jobs: mcp-container-build-and-scan: if: github.repository == 'prowler-cloud/prowler' - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - arch: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - arch: arm64 + runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read @@ -72,7 +68,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -80,6 +76,7 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 ghcr.io:443 pkg-containers.githubusercontent.com:443 files.pythonhosted.org:443 @@ -87,6 +84,9 @@ jobs: api.github.com:443 mirror.gcr.io:443 check.trivy.dev:443 + get.trivy.dev:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -96,7 +96,7 @@ jobs: - name: Check for MCP changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: mcp_server/** files_ignore: | @@ -105,25 +105,24 @@ jobs: - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Build MCP container for ${{ matrix.arch }} + - name: Build MCP container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.MCP_WORKING_DIR }} push: false load: true - platforms: ${{ matrix.platform }} - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} - cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }} - - name: Scan MCP container with Trivy for ${{ matrix.arch }} + - name: Scan MCP container with Trivy if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }}-${{ matrix.arch }} - fail-on-critical: 'false' + image-tag: ${{ github.sha }} + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/mcp-pypi-release.yml b/.github/workflows/mcp-pypi-release.yml index d6272034ec..124f67eb19 100644 --- a/.github/workflows/mcp-pypi-release.yml +++ b/.github/workflows/mcp-pypi-release.yml @@ -14,6 +14,8 @@ env: PYTHON_VERSION: "3.12" WORKING_DIRECTORY: ./mcp_server +permissions: {} + jobs: validate-release: if: github.repository == 'prowler-cloud/prowler' @@ -27,7 +29,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -65,7 +67,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -84,12 +86,34 @@ jobs: with: python-version: ${{ env.PYTHON_VERSION }} + # The MCP server version (mcp_server/pyproject.toml) is decoupled from the Prowler release + # version: it only changes when MCP code changes. mcp-bump-version.yml normally keeps it in + # sync with mcp_server/CHANGELOG.md (separate from the release bump-version.yml), but this + # publish workflow still runs on every release. + # Pre-flight PyPI check covers the legitimate "no MCP changes for this release" case (and any + # workflow_dispatch re-runs) without failing with HTTP 400 (version exists). + - name: Check if prowler-mcp version already exists on PyPI + id: pypi-check + working-directory: ${{ env.WORKING_DIRECTORY }} + run: | + MCP_VERSION=$(grep '^version' pyproject.toml | head -1 | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/') + echo "mcp_version=${MCP_VERSION}" >> "$GITHUB_OUTPUT" + if curl -fsS "https://pypi.org/pypi/prowler-mcp/${MCP_VERSION}/json" >/dev/null 2>&1; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice title=Skipping prowler-mcp publish::Version ${MCP_VERSION} already exists on PyPI; bump mcp_server/pyproject.toml to publish a new release." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Publishing prowler-mcp::Version ${MCP_VERSION} not on PyPI yet; proceeding." + fi + - name: Build prowler-mcp package + if: steps.pypi-check.outputs.skip != 'true' working-directory: ${{ env.WORKING_DIRECTORY }} run: uv build - name: Publish prowler-mcp package to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + if: steps.pypi-check.outputs.skip != 'true' + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/ print-hash: true diff --git a/.github/workflows/mcp-security.yml b/.github/workflows/mcp-security.yml new file mode 100644 index 0000000000..4deb6a478d --- /dev/null +++ b/.github/workflows/mcp-security.yml @@ -0,0 +1,68 @@ +name: 'MCP: Security' + +on: + push: + branches: + - 'master' + - 'v5.*' + paths: + - 'mcp_server/pyproject.toml' + - 'mcp_server/uv.lock' + - '.github/workflows/mcp-security.yml' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' + pull_request: + branches: + - 'master' + - 'v5.*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + mcp-security-scans: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write # osv-scanner action posts/updates a PR comment with findings + + steps: + - name: Harden Runner + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + api.osv.dev:443 + api.deps.dev:443 + osv-vulnerabilities.storage.googleapis.com:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # zizmor: ignore[artipacked] + persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + + - name: Check for MCP dependency changes + id: check-changes + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + mcp_server/pyproject.toml + mcp_server/uv.lock + .github/workflows/mcp-security.yml + .github/actions/osv-scanner/** + .github/scripts/osv-scan.sh + + - name: Dependency vulnerability scan with osv-scanner + if: steps.check-changes.outputs.any_changed == 'true' + uses: ./.github/actions/osv-scanner + with: + lockfile: mcp_server/uv.lock diff --git a/.github/workflows/nightly-arm64-container-builds.yml b/.github/workflows/nightly-arm64-container-builds.yml new file mode 100644 index 0000000000..061ec61ff2 --- /dev/null +++ b/.github/workflows/nightly-arm64-container-builds.yml @@ -0,0 +1,98 @@ +name: 'Nightly: ARM64 Container Builds' + +# Mitigation for amd64-only PR container-checks: build amd64+arm64 nightly against +# master to keep arm-specific Dockerfile regressions caught quickly. Build only — +# no push, no Trivy (weekly checks already cover that). + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: {} + +jobs: + build-arm64: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - component: sdk + context: . + dockerfile: ./Dockerfile + image_name: prowler + - component: api + context: ./api + dockerfile: ./api/Dockerfile + image_name: prowler-api + - component: ui + context: ./ui + dockerfile: ./ui/Dockerfile + image_name: prowler-ui + target: prod + build_args: | + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51LwpXXXX + - component: mcp + context: ./mcp_server + dockerfile: ./mcp_server/Dockerfile + image_name: prowler-mcp + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build ${{ matrix.component }} container (linux/arm64) + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + target: ${{ matrix.target }} + push: false + load: false + platforms: linux/arm64 + tags: ${{ matrix.image_name }}:nightly-arm64 + build-args: ${{ matrix.build_args }} + cache-from: type=gha,scope=arm64 + cache-to: type=gha,mode=min,scope=arm64 + + notify-failure: + needs: build-arm64 + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Notify Slack on failure + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + text: ":rotating_light: Nightly arm64 container build failed for prowler — <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|view run>" + errors: true diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml index c729875843..0b9bc20552 100644 --- a/.github/workflows/pr-check-changelog.yml +++ b/.github/workflows/pr-check-changelog.yml @@ -16,6 +16,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true +permissions: {} + jobs: check-changelog: if: contains(github.event.pull_request.labels.*.name, 'no-changelog') == false @@ -29,7 +31,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -39,20 +41,25 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 + fetch-depth: 1 # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + - name: Fetch PR base ref for tj-actions/changed-files + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: git fetch --depth=1 origin "${BASE_REF}" + - name: Get changed files id: changed-files - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** ui/** prowler/** mcp_server/** - poetry.lock + uv.lock pyproject.toml - name: Check for folder changes and changelog presence @@ -77,9 +84,9 @@ jobs: fi done - # Check root-level dependency files (poetry.lock, pyproject.toml) + # Check root-level dependency files (uv.lock, pyproject.toml) # These are associated with the prowler folder changelog - root_deps_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep -E "^(poetry\.lock|pyproject\.toml)$" || true) + root_deps_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep -E "^(uv\.lock|pyproject\.toml)$" || true) if [ -n "$root_deps_changed" ]; then echo "Detected changes in root dependency files: $root_deps_changed" # Check if prowler/CHANGELOG.md was already updated (might have been caught above) diff --git a/.github/workflows/pr-check-compliance-mapping.yml b/.github/workflows/pr-check-compliance-mapping.yml index dcf61602ba..4df61f49b0 100644 --- a/.github/workflows/pr-check-compliance-mapping.yml +++ b/.github/workflows/pr-check-compliance-mapping.yml @@ -16,9 +16,17 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true +permissions: {} + jobs: check-compliance-mapping: - if: contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false + if: >- + github.event.pull_request.state == 'open' && + contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false && + ( + (github.event.action != 'labeled' && github.event.action != 'unlabeled') + || github.event.label.name == 'no-compliance-check' + ) runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -27,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -37,13 +45,18 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 + fetch-depth: 1 # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + - name: Fetch PR base ref for tj-actions/changed-files + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: git fetch --depth=1 origin "${BASE_REF}" + - name: Get changed files id: changed-files - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | prowler/providers/**/services/**/*.metadata.json diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml index 330a038fa0..ff3871bf73 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -15,6 +15,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true +permissions: {} + jobs: check-conflicts: runs-on: ubuntu-latest @@ -26,7 +28,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -34,12 +36,18 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - persist-credentials: false + fetch-depth: 1 + # zizmor: ignore[artipacked] + persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + + - name: Fetch PR base ref for tj-actions/changed-files + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: git fetch --depth=1 origin "${BASE_REF}" - name: Get changed files id: changed-files - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: '**' diff --git a/.github/workflows/pr-merged.yml b/.github/workflows/pr-merged.yml index 62eca02e58..2a1f3d77f1 100644 --- a/.github/workflows/pr-merged.yml +++ b/.github/workflows/pr-merged.yml @@ -12,6 +12,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false +permissions: {} + jobs: trigger-cloud-pull-request: if: | @@ -24,7 +26,7 @@ jobs: contents: read steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index f0aee83174..ca324aa006 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -17,6 +17,8 @@ concurrency: env: PROWLER_VERSION: ${{ inputs.prowler_version }} +permissions: {} + jobs: prepare-release: if: github.event_name == 'workflow_dispatch' && github.repository == 'prowler-cloud/prowler' @@ -27,7 +29,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -38,15 +40,11 @@ jobs: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} persist-credentials: false - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Setup Python with uv + uses: ./.github/actions/setup-python-uv with: python-version: '3.12' - - - name: Install Poetry - run: | - python3 -m pip install --user poetry==2.1.1 - echo "$HOME/.local/bin" >> $GITHUB_PATH + install-dependencies: 'false' - name: Configure Git run: | @@ -55,7 +53,7 @@ jobs: - name: Parse version and determine branch run: | - # Validate version format (reusing pattern from sdk-bump-version.yml) + # Validate version format (reusing pattern from bump-version.yml) if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then MAJOR_VERSION=${BASH_REMATCH[1]} MINOR_VERSION=${BASH_REMATCH[2]} @@ -301,17 +299,6 @@ jobs: fi echo "✓ api/pyproject.toml prowler dependency: $CURRENT_PROWLER_REF" - - name: Verify API version in api/src/backend/api/v1/views.py - if: ${{ env.HAS_API_CHANGES == 'true' }} - run: | - CURRENT_API_VERSION=$(grep 'spectacular_settings.VERSION = ' api/src/backend/api/v1/views.py | sed -E 's/.*spectacular_settings.VERSION = "([^"]+)".*/\1/' | tr -d '[:space:]') - API_VERSION_TRIMMED=$(echo "$API_VERSION" | tr -d '[:space:]') - if [ "$CURRENT_API_VERSION" != "$API_VERSION_TRIMMED" ]; then - echo "ERROR: API version mismatch in views.py (expected: '$API_VERSION_TRIMMED', found: '$CURRENT_API_VERSION')" - exit 1 - fi - echo "✓ api/src/backend/api/v1/views.py version: $CURRENT_API_VERSION" - - name: Verify API version in api/src/backend/api/specs/v1.yaml if: ${{ env.HAS_API_CHANGES == 'true' }} run: | @@ -340,17 +327,18 @@ jobs: exit 1 fi - # Update poetry lock file - echo "Updating poetry.lock file..." + # Update uv lock file + echo "Updating uv.lock file..." + pip install --no-cache-dir uv==0.11.14 cd api - poetry lock + uv lock cd .. echo "✓ Prepared prowler dependency update to: $UPDATED_PROWLER_REF" - name: Create PR for API dependency update if: ${{ env.PATCH_VERSION == '0' }} - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} commit-message: 'chore(api): update prowler dependency to ${{ env.BRANCH_NAME }} for release ${{ env.PROWLER_VERSION }}' @@ -358,7 +346,7 @@ jobs: base: ${{ env.BRANCH_NAME }} add-paths: | api/pyproject.toml - api/poetry.lock + api/uv.lock title: "chore(api): Update prowler dependency to ${{ env.BRANCH_NAME }} for release ${{ env.PROWLER_VERSION }}" body: | ### Description @@ -367,7 +355,7 @@ jobs: **Changes:** - Updates `api/pyproject.toml` prowler dependency from `@master` to `@${{ env.BRANCH_NAME }}` - - Updates `api/poetry.lock` file with resolved dependencies + - Updates `api/uv.lock` file with resolved dependencies This PR should be merged into the `${{ env.BRANCH_NAME }}` release branch. @@ -380,7 +368,7 @@ jobs: no-changelog - name: Create draft release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: ${{ env.PROWLER_VERSION }} name: Prowler ${{ env.PROWLER_VERSION }} diff --git a/.github/workflows/renovate-config-validate.yml b/.github/workflows/renovate-config-validate.yml new file mode 100644 index 0000000000..af32eac074 --- /dev/null +++ b/.github/workflows/renovate-config-validate.yml @@ -0,0 +1,57 @@ +name: 'CI: Renovate Config Validate' + +on: + pull_request: + branches: + - 'master' + paths: + - '.github/renovate.json' + - '.pre-commit-config.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +env: + # renovate: datasource=pypi depName=prek + PREK_VERSION: '0.4.0' + +jobs: + validate: + name: Validate Renovate config + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + objects.githubusercontent.com:443 + codeload.github.com:443 + release-assets.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + registry.npmjs.org:443 + nodejs.org:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 + + - name: Install prek + run: uv tool install "prek==${PREK_VERSION}" + + - name: Validate Renovate config + run: prek run renovate-config-validator --files .github/renovate.json diff --git a/.github/workflows/sdk-bump-version.yml b/.github/workflows/sdk-bump-version.yml deleted file mode 100644 index 99010c6af3..0000000000 --- a/.github/workflows/sdk-bump-version.yml +++ /dev/null @@ -1,245 +0,0 @@ -name: 'SDK: Bump Version' - -on: - release: - types: - - 'published' - -concurrency: - group: ${{ github.workflow }}-${{ github.event.release.tag_name }} - cancel-in-progress: false - -env: - PROWLER_VERSION: ${{ github.event.release.tag_name }} - BASE_BRANCH: master - -jobs: - detect-release-type: - runs-on: ubuntu-latest - timeout-minutes: 5 - 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 }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 - with: - egress-policy: audit - - - 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 - 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} - - NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0 - echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}" - - echo "Current 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 }} - - - name: Bump versions in files for master - run: | - set -e - - sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_MINOR_VERSION}\"|" pyproject.toml - sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_MINOR_VERSION}\"|" prowler/config/config.py - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for next minor version 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: 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 }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler version to v${{ env.NEXT_MINOR_VERSION }} after releasing v${{ env.PROWLER_VERSION }}. - - ### 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} - - FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1 - VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} - - 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 }} - - - name: Bump versions in files for version branch - run: | - set -e - - sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${FIRST_PATCH_VERSION}\"|" pyproject.toml - sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${FIRST_PATCH_VERSION}\"|" prowler/config/config.py - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for first patch version 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: '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 }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler version to v${{ env.FIRST_PATCH_VERSION }} in version branch after releasing v${{ env.PROWLER_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: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - 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} - - NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1)) - VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} - - echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}" - echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" - - echo "Current 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 }} - - - name: Bump versions in files for version branch - run: | - set -e - - sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_PATCH_VERSION}\"|" pyproject.toml - sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_PATCH_VERSION}\"|" prowler/config/config.py - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for next patch version 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: '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 }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler version to v${{ env.NEXT_PATCH_VERSION }} after releasing v${{ env.PROWLER_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-check-duplicate-test-names.yml b/.github/workflows/sdk-check-duplicate-test-names.yml index ac8a01d117..7c81e3ae3f 100644 --- a/.github/workflows/sdk-check-duplicate-test-names.yml +++ b/.github/workflows/sdk-check-duplicate-test-names.yml @@ -5,11 +5,16 @@ on: branches: - 'master' - 'v5.*' + paths: + - 'tests/providers/**/*_test.py' + - '.github/workflows/sdk-check-duplicate-test-names.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: check-duplicate-test-names: if: github.repository == 'prowler-cloud/prowler' @@ -20,7 +25,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml index b854e9ddeb..2b1efc69a9 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -14,6 +14,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: sdk-code-quality: if: github.repository == 'prowler-cloud/prowler' @@ -27,10 +29,11 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -46,7 +49,7 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ./** files_ignore: | @@ -69,35 +72,26 @@ jobs: contrib/** **/AGENTS.md - - name: Install Poetry + - name: Setup Python with uv if: steps.check-changes.outputs.any_changed == 'true' - run: pipx install poetry==2.1.1 - - - name: Set up Python ${{ matrix.python-version }} - if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: ./.github/actions/setup-python-uv with: python-version: ${{ matrix.python-version }} - cache: 'poetry' - - name: Install dependencies + - name: Check uv lock file if: steps.check-changes.outputs.any_changed == 'true' - run: | - poetry install --no-root - poetry run pip list - - - name: Check Poetry lock file - if: steps.check-changes.outputs.any_changed == 'true' - run: poetry check --lock + run: uv lock --check - name: Lint with flake8 if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude contrib,ui,api,skills + run: uv run flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude .venv,contrib,ui,api,skills,mcp_server - name: Check format with black if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run black --exclude "api|ui|skills" --check . + # mcp_server has its own pyproject and uses ruff format, exclude it so SDK black + # does not fight ruff over rules it never formatted. + run: uv run black --exclude "\.venv|api|ui|skills|mcp_server" --check . - name: Lint with pylint if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run pylint --disable=W,C,R,E -j 0 -rn -sn prowler/ + run: uv run pylint --disable=W,C,R,E -j 0 -rn -sn prowler/ diff --git a/.github/workflows/sdk-codeql.yml b/.github/workflows/sdk-codeql.yml index fa5056c2d6..0696f7c6a5 100644 --- a/.github/workflows/sdk-codeql.yml +++ b/.github/workflows/sdk-codeql.yml @@ -30,6 +30,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: sdk-analyze: if: github.repository == 'prowler-cloud/prowler' @@ -49,7 +51,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -64,12 +66,12 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/sdk-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/sdk-container-build-push.yml b/.github/workflows/sdk-container-build-push.yml index 4a0563b89f..2dbaeec4a5 100644 --- a/.github/workflows/sdk-container-build-push.yml +++ b/.github/workflows/sdk-container-build-push.yml @@ -3,9 +3,7 @@ name: 'SDK: Container Build and Push' on: push: branches: - - 'v3' # For v3-latest - - 'v4.6' # For v4-latest - - 'master' # For latest + - 'master' paths-ignore: - '.github/**' - '!.github/workflows/sdk-container-build-push.yml' @@ -47,6 +45,8 @@ env: # AWS configuration (for ECR) AWS_REGION: us-east-1 +permissions: {} + jobs: setup: if: github.repository == 'prowler-cloud/prowler' @@ -54,14 +54,13 @@ jobs: timeout-minutes: 5 outputs: prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }} - prowler_version_major: ${{ steps.get-prowler-version.outputs.prowler_version_major }} latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }} stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }} permissions: contents: read steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -74,48 +73,19 @@ jobs: with: persist-credentials: false - - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install Poetry - run: | - pipx install poetry==2.1.1 - pipx inject poetry poetry-bumpversion - - name: Get Prowler version and set tags id: get-prowler-version run: | - PROWLER_VERSION="$(poetry version -s 2>/dev/null)" + PROWLER_VERSION="$(grep -E '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' | tr -d '[:space:]')" echo "prowler_version=${PROWLER_VERSION}" >> "${GITHUB_OUTPUT}" - # Extract major version PROWLER_VERSION_MAJOR="${PROWLER_VERSION%%.*}" - echo "prowler_version_major=${PROWLER_VERSION_MAJOR}" >> "${GITHUB_OUTPUT}" - - # Set version-specific tags - case ${PROWLER_VERSION_MAJOR} in - 3) - echo "latest_tag=v3-latest" >> "${GITHUB_OUTPUT}" - echo "stable_tag=v3-stable" >> "${GITHUB_OUTPUT}" - echo "✓ Prowler v3 detected - tags: v3-latest, v3-stable" - ;; - 4) - echo "latest_tag=v4-latest" >> "${GITHUB_OUTPUT}" - echo "stable_tag=v4-stable" >> "${GITHUB_OUTPUT}" - echo "✓ Prowler v4 detected - tags: v4-latest, v4-stable" - ;; - 5) - echo "latest_tag=latest" >> "${GITHUB_OUTPUT}" - echo "stable_tag=stable" >> "${GITHUB_OUTPUT}" - echo "✓ Prowler v5 detected - tags: latest, stable" - ;; - *) - echo "::error::Unsupported Prowler major version: ${PROWLER_VERSION_MAJOR}" - exit 1 - ;; - esac + if [[ "${PROWLER_VERSION_MAJOR}" != "5" ]]; then + echo "::error::Unsupported Prowler major version: ${PROWLER_VERSION_MAJOR}" + exit 1 + fi + echo "latest_tag=latest" >> "${GITHUB_OUTPUT}" + echo "stable_tag=stable" >> "${GITHUB_OUTPUT}" notify-release-started: if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') @@ -128,7 +98,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -171,7 +141,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -179,6 +149,7 @@ jobs: public.ecr.aws:443 registry-1.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 auth.docker.io:443 debian.map.fastlydns.net:80 github.com:443 @@ -197,13 +168,13 @@ jobs: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to Public ECR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: public.ecr.aws username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} @@ -212,12 +183,12 @@ jobs: AWS_REGION: ${{ env.AWS_REGION }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push SDK container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: ${{ env.DOCKERFILE_PATH }} @@ -226,7 +197,7 @@ jobs: tags: | ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }} cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }} # Create and push multi-architecture manifest create-manifest: @@ -238,7 +209,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -246,19 +217,20 @@ jobs: auth.docker.io:443 public.ecr.aws:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 github.com:443 release-assets.githubusercontent.com:443 api.ecr-public.us-east-1.amazonaws.com:443 - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to Public ECR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: public.ecr.aws username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} @@ -295,7 +267,7 @@ jobs: # Push to toniblyx/prowler only for current version (latest/stable/release tags) - name: Login to DockerHub (toniblyx) if: needs.setup.outputs.latest_tag == 'latest' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }} password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }} @@ -320,7 +292,7 @@ jobs: # Re-login as prowlercloud for cleanup of intermediate tags - name: Login to DockerHub (prowlercloud) if: always() - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -348,7 +320,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -384,39 +356,3 @@ jobs: payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" step-outcome: ${{ steps.outcome.outputs.outcome }} update-ts: ${{ needs.notify-release-started.outputs.message-ts }} - - dispatch-v3-deployment: - needs: [setup, container-build-push] - if: always() && needs.setup.outputs.prowler_version_major == '3' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 - with: - egress-policy: audit - - - name: Calculate short SHA - id: short-sha - run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - - - name: Dispatch v3 deployment (latest) - if: github.event_name == 'push' - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.DISPATCH_OWNER }}/${{ secrets.DISPATCH_REPO }} - event-type: dispatch - client-payload: '{"version":"v3-latest","tag":"${{ steps.short-sha.outputs.short_sha }}"}' - - - name: Dispatch v3 deployment (release) - if: github.event_name == 'release' - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.DISPATCH_OWNER }}/${{ secrets.DISPATCH_REPO }} - event-type: dispatch - client-payload: '{"version":"release","tag":"${{ needs.setup.outputs.prowler_version }}"}' diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index dacf160a86..2b436db468 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -5,6 +5,12 @@ on: branches: - 'master' - 'v5.*' + paths: + - 'prowler/**' + - 'Dockerfile*' + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/sdk-container-checks.yml' pull_request: branches: - 'master' @@ -17,6 +23,8 @@ concurrency: env: IMAGE_NAME: prowler +permissions: {} + jobs: sdk-dockerfile-lint: if: github.repository == 'prowler-cloud/prowler' @@ -27,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -41,7 +49,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: Dockerfile @@ -54,16 +62,7 @@ jobs: sdk-container-build-and-scan: if: github.repository == 'prowler-cloud/prowler' - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - arch: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - arch: arm64 + runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read @@ -72,7 +71,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -80,11 +79,13 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 api.github.com:443 mirror.gcr.io:443 check.trivy.dev:443 debian.map.fastlydns.net:80 release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 pypi.org:443 files.pythonhosted.org:443 www.powershellgallery.com:443 @@ -102,50 +103,38 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: ./** + files: | + prowler/** + Dockerfile* + pyproject.toml + uv.lock + .github/workflows/sdk-container-checks.yml files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Build SDK container for ${{ matrix.arch }} + - name: Build SDK container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: false load: true - platforms: ${{ matrix.platform }} - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} - cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }} - - name: Scan SDK container with Trivy for ${{ matrix.arch }} + - name: Scan SDK container with Trivy if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }}-${{ matrix.arch }} - fail-on-critical: 'false' + image-tag: ${{ github.sha }} + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/sdk-pypi-release.yml b/.github/workflows/sdk-pypi-release.yml index 5173760205..06e3908be0 100644 --- a/.github/workflows/sdk-pypi-release.yml +++ b/.github/workflows/sdk-pypi-release.yml @@ -13,6 +13,8 @@ env: RELEASE_TAG: ${{ github.event.release.tag_name }} PYTHON_VERSION: '3.12' +permissions: {} + jobs: validate-release: if: github.repository == 'prowler-cloud/prowler' @@ -26,7 +28,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -64,7 +66,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -73,19 +75,17 @@ jobs: with: persist-credentials: false - - name: Install Poetry - run: pipx install poetry==2.1.1 - - - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Setup Python with uv + uses: ./.github/actions/setup-python-uv with: python-version: ${{ env.PYTHON_VERSION }} + install-dependencies: 'false' - name: Build Prowler package - run: poetry build + run: uv build - name: Publish Prowler package to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: print-hash: true @@ -102,7 +102,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -111,13 +111,11 @@ jobs: with: persist-credentials: false - - name: Install Poetry - run: pipx install poetry==2.1.1 - - - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: Setup Python with uv + uses: ./.github/actions/setup-python-uv with: python-version: ${{ env.PYTHON_VERSION }} + install-dependencies: 'false' - name: Install toml package run: pip install toml @@ -128,9 +126,9 @@ jobs: python util/replicate_pypi_package.py - name: Build prowler-cloud package - run: poetry build + run: uv build - name: Publish prowler-cloud package to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: print-hash: true diff --git a/.github/workflows/sdk-refresh-aws-services-regions.yml b/.github/workflows/sdk-refresh-aws-services-regions.yml index ce8d1c8292..41ec249a53 100644 --- a/.github/workflows/sdk-refresh-aws-services-regions.yml +++ b/.github/workflows/sdk-refresh-aws-services-regions.yml @@ -13,6 +13,8 @@ env: PYTHON_VERSION: '3.12' AWS_REGION: 'us-east-1' +permissions: {} + jobs: refresh-aws-regions: if: github.repository == 'prowler-cloud/prowler' @@ -25,7 +27,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -45,7 +47,7 @@ jobs: run: pip install boto3 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: aws-region: ${{ env.AWS_REGION }} role-to-assume: ${{ secrets.DEV_IAM_ROLE_ARN }} @@ -56,7 +58,7 @@ jobs: - name: Create pull request id: create-pr - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>' diff --git a/.github/workflows/sdk-refresh-oci-regions.yml b/.github/workflows/sdk-refresh-oci-regions.yml index 67f87076b7..65a36c7714 100644 --- a/.github/workflows/sdk-refresh-oci-regions.yml +++ b/.github/workflows/sdk-refresh-oci-regions.yml @@ -12,6 +12,8 @@ concurrency: env: PYTHON_VERSION: '3.12' +permissions: {} + jobs: refresh-oci-regions: if: github.repository == 'prowler-cloud/prowler' @@ -23,7 +25,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -53,7 +55,7 @@ jobs: - name: Create pull request id: create-pr - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>' diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index 2229568b3f..11a7894ce8 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -5,6 +5,16 @@ on: branches: - 'master' - 'v5.*' + paths: + - 'prowler/**' + - 'tests/**' + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/sdk-tests.yml' + - '.github/workflows/sdk-security.yml' + - '.github/actions/setup-python-uv/**' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' pull_request: branches: - 'master' @@ -14,6 +24,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: sdk-security-scans: if: github.repository == 'prowler-cloud/prowler' @@ -21,20 +33,23 @@ jobs: timeout-minutes: 15 permissions: contents: read + pull-requests: write # osv-scanner action posts/updates a PR comment with findings steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > pypi.org:443 files.pythonhosted.org:443 github.com:443 - auth.safetycli.com:443 - pyup.io:443 - data.safetycli.com:443 api.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + api.osv.dev:443 + api.deps.dev:443 + osv-vulnerabilities.storage.googleapis.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -44,54 +59,39 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: - ./** + files: | + prowler/** + tests/** + pyproject.toml + uv.lock + .github/workflows/sdk-tests.yml .github/workflows/sdk-security.yml + .github/actions/setup-python-uv/** + .github/actions/osv-scanner/** + .github/scripts/osv-scan.sh files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - - name: Install Poetry + - name: Setup Python with uv if: steps.check-changes.outputs.any_changed == 'true' - run: pipx install poetry==2.1.1 - - - name: Set up Python 3.12 - if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: ./.github/actions/setup-python-uv with: python-version: '3.12' - cache: 'poetry' - - - name: Install dependencies - if: steps.check-changes.outputs.any_changed == 'true' - run: poetry install --no-root - name: Security scan with Bandit if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run bandit -q -lll -x '*_test.py,./contrib/,./api/,./ui' -r . + run: uv run bandit -q -lll -x '*_test.py,./.venv/,./contrib/,./api/,./ui' -r . - - name: Security scan with Safety + - name: Dependency vulnerability scan with osv-scanner if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check -r pyproject.toml + uses: ./.github/actions/osv-scanner + with: + lockfile: uv.lock - name: Dead code detection with Vulture - if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run vulture --exclude "contrib,api,ui" --min-confidence 100 . + # Run even when osv-scanner reports findings so dead-code signal isn't masked by SCA failures. + if: ${{ !cancelled() && steps.check-changes.outputs.any_changed == 'true' }} + run: uv run vulture --exclude ".venv,contrib,api,ui" --min-confidence 100 . diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index d0da08cc55..4fc6cab0b7 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -14,6 +14,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: sdk-tests: if: github.repository == 'prowler-cloud/prowler' @@ -27,10 +29,11 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -44,6 +47,7 @@ jobs: schema.ocsf.io:443 registry-1.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 o26192.ingest.us.sentry.io:443 management.azure.com:443 @@ -67,7 +71,7 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ./** files_ignore: | @@ -90,31 +94,22 @@ jobs: contrib/** **/AGENTS.md - - name: Install Poetry + - name: Setup Python with uv if: steps.check-changes.outputs.any_changed == 'true' - run: pipx install poetry==2.1.1 - - - name: Set up Python ${{ matrix.python-version }} - if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: ./.github/actions/setup-python-uv with: python-version: ${{ matrix.python-version }} - cache: 'poetry' - - - name: Install dependencies - if: steps.check-changes.outputs.any_changed == 'true' - run: poetry install --no-root # AWS Provider - name: Check if AWS files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-aws - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/aws/** ./tests/**/aws/** - ./poetry.lock + ./uv.lock - name: Resolve AWS services under test if: steps.changed-aws.outputs.any_changed == 'true' @@ -216,11 +211,11 @@ jobs: echo "AWS service_paths='${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}'" if [ "${STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL}" = "true" ]; then - poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws + uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then echo "No AWS service paths detected; skipping AWS tests." else - poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS} + uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS} fi env: STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }} @@ -239,16 +234,16 @@ jobs: - name: Check if Azure files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-azure - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/azure/** ./tests/**/azure/** - ./poetry.lock + ./uv.lock - name: Run Azure tests if: steps.changed-azure.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/azure --cov-report=xml:azure_coverage.xml tests/providers/azure + run: uv run pytest -n auto --cov=./prowler/providers/azure --cov-report=xml:azure_coverage.xml tests/providers/azure - name: Upload Azure coverage to Codecov if: steps.changed-azure.outputs.any_changed == 'true' @@ -263,16 +258,16 @@ jobs: - name: Check if GCP files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-gcp - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/gcp/** ./tests/**/gcp/** - ./poetry.lock + ./uv.lock - name: Run GCP tests if: steps.changed-gcp.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/gcp --cov-report=xml:gcp_coverage.xml tests/providers/gcp + run: uv run pytest -n auto --cov=./prowler/providers/gcp --cov-report=xml:gcp_coverage.xml tests/providers/gcp - name: Upload GCP coverage to Codecov if: steps.changed-gcp.outputs.any_changed == 'true' @@ -287,16 +282,16 @@ jobs: - name: Check if Kubernetes files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-kubernetes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/kubernetes/** ./tests/**/kubernetes/** - ./poetry.lock + ./uv.lock - name: Run Kubernetes tests if: steps.changed-kubernetes.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/kubernetes --cov-report=xml:kubernetes_coverage.xml tests/providers/kubernetes + run: uv run pytest -n auto --cov=./prowler/providers/kubernetes --cov-report=xml:kubernetes_coverage.xml tests/providers/kubernetes - name: Upload Kubernetes coverage to Codecov if: steps.changed-kubernetes.outputs.any_changed == 'true' @@ -311,16 +306,16 @@ jobs: - name: Check if GitHub files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-github - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/github/** ./tests/**/github/** - ./poetry.lock + ./uv.lock - name: Run GitHub tests if: steps.changed-github.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/github --cov-report=xml:github_coverage.xml tests/providers/github + run: uv run pytest -n auto --cov=./prowler/providers/github --cov-report=xml:github_coverage.xml tests/providers/github - name: Upload GitHub coverage to Codecov if: steps.changed-github.outputs.any_changed == 'true' @@ -331,20 +326,44 @@ jobs: flags: prowler-py${{ matrix.python-version }}-github files: ./github_coverage.xml + # Okta Provider + - name: Check if Okta files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-okta + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/okta/** + ./tests/**/okta/** + ./uv.lock + + - name: Run Okta tests + if: steps.changed-okta.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/okta --cov-report=xml:okta_coverage.xml tests/providers/okta + + - name: Upload Okta coverage to Codecov + if: steps.changed-okta.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-okta + files: ./okta_coverage.xml + # NHN Provider - name: Check if NHN files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-nhn - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/nhn/** ./tests/**/nhn/** - ./poetry.lock + ./uv.lock - name: Run NHN tests if: steps.changed-nhn.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/nhn --cov-report=xml:nhn_coverage.xml tests/providers/nhn + run: uv run pytest -n auto --cov=./prowler/providers/nhn --cov-report=xml:nhn_coverage.xml tests/providers/nhn - name: Upload NHN coverage to Codecov if: steps.changed-nhn.outputs.any_changed == 'true' @@ -359,16 +378,16 @@ jobs: - name: Check if M365 files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-m365 - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/m365/** ./tests/**/m365/** - ./poetry.lock + ./uv.lock - name: Run M365 tests if: steps.changed-m365.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/m365 --cov-report=xml:m365_coverage.xml tests/providers/m365 + run: uv run pytest -n auto --cov=./prowler/providers/m365 --cov-report=xml:m365_coverage.xml tests/providers/m365 - name: Upload M365 coverage to Codecov if: steps.changed-m365.outputs.any_changed == 'true' @@ -383,16 +402,16 @@ jobs: - name: Check if IaC files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-iac - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/iac/** ./tests/**/iac/** - ./poetry.lock + ./uv.lock - name: Run IaC tests if: steps.changed-iac.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/iac --cov-report=xml:iac_coverage.xml tests/providers/iac + run: uv run pytest -n auto --cov=./prowler/providers/iac --cov-report=xml:iac_coverage.xml tests/providers/iac - name: Upload IaC coverage to Codecov if: steps.changed-iac.outputs.any_changed == 'true' @@ -407,16 +426,16 @@ jobs: - name: Check if MongoDB Atlas files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-mongodbatlas - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/mongodbatlas/** ./tests/**/mongodbatlas/** - ./poetry.lock + ./uv.lock - name: Run MongoDB Atlas tests if: steps.changed-mongodbatlas.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/mongodbatlas --cov-report=xml:mongodbatlas_coverage.xml tests/providers/mongodbatlas + run: uv run pytest -n auto --cov=./prowler/providers/mongodbatlas --cov-report=xml:mongodbatlas_coverage.xml tests/providers/mongodbatlas - name: Upload MongoDB Atlas coverage to Codecov if: steps.changed-mongodbatlas.outputs.any_changed == 'true' @@ -431,16 +450,16 @@ jobs: - name: Check if OCI files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-oraclecloud - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/oraclecloud/** ./tests/**/oraclecloud/** - ./poetry.lock + ./uv.lock - name: Run OCI tests if: steps.changed-oraclecloud.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/oraclecloud --cov-report=xml:oraclecloud_coverage.xml tests/providers/oraclecloud + run: uv run pytest -n auto --cov=./prowler/providers/oraclecloud --cov-report=xml:oraclecloud_coverage.xml tests/providers/oraclecloud - name: Upload OCI coverage to Codecov if: steps.changed-oraclecloud.outputs.any_changed == 'true' @@ -455,16 +474,16 @@ jobs: - name: Check if OpenStack files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-openstack - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/openstack/** ./tests/**/openstack/** - ./poetry.lock + ./uv.lock - name: Run OpenStack tests if: steps.changed-openstack.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/openstack --cov-report=xml:openstack_coverage.xml tests/providers/openstack + run: uv run pytest -n auto --cov=./prowler/providers/openstack --cov-report=xml:openstack_coverage.xml tests/providers/openstack - name: Upload OpenStack coverage to Codecov if: steps.changed-openstack.outputs.any_changed == 'true' @@ -479,16 +498,16 @@ jobs: - name: Check if Google Workspace files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-googleworkspace - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/googleworkspace/** ./tests/**/googleworkspace/** - ./poetry.lock + ./uv.lock - name: Run Google Workspace tests if: steps.changed-googleworkspace.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/googleworkspace --cov-report=xml:googleworkspace_coverage.xml tests/providers/googleworkspace + run: uv run pytest -n auto --cov=./prowler/providers/googleworkspace --cov-report=xml:googleworkspace_coverage.xml tests/providers/googleworkspace - name: Upload Google Workspace coverage to Codecov if: steps.changed-googleworkspace.outputs.any_changed == 'true' @@ -503,16 +522,16 @@ jobs: - name: Check if Vercel files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-vercel - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/**/vercel/** ./tests/**/vercel/** - ./poetry.lock + ./uv.lock - name: Run Vercel tests if: steps.changed-vercel.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/vercel --cov-report=xml:vercel_coverage.xml tests/providers/vercel + run: uv run pytest -n auto --cov=./prowler/providers/vercel --cov-report=xml:vercel_coverage.xml tests/providers/vercel - name: Upload Vercel coverage to Codecov if: steps.changed-vercel.outputs.any_changed == 'true' @@ -523,20 +542,119 @@ jobs: flags: prowler-py${{ matrix.python-version }}-vercel files: ./vercel_coverage.xml + # Scaleway Provider + - name: Check if Scaleway files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-scaleway + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/scaleway/** + ./tests/**/scaleway/** + ./uv.lock + + - name: Run Scaleway tests + if: steps.changed-scaleway.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/scaleway --cov-report=xml:scaleway_coverage.xml tests/providers/scaleway + + - name: Upload Scaleway coverage to Codecov + if: steps.changed-scaleway.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-scaleway + files: ./scaleway_coverage.xml + + # StackIT Provider + - name: Check if StackIT files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-stackit + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/stackit/** + ./tests/**/stackit/** + ./uv.lock + + - name: Run StackIT tests + if: steps.changed-stackit.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/stackit --cov-report=xml:stackit_coverage.xml tests/providers/stackit + + - name: Upload StackIT coverage to Codecov + if: steps.changed-stackit.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-stackit + files: ./stackit_coverage.xml + + # Linode Provider + - name: Check if Linode files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-linode + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/linode/** + ./tests/**/linode/** + ./uv.lock + + - name: Run Linode tests + if: steps.changed-linode.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/linode --cov-report=xml:linode_coverage.xml tests/providers/linode + + - name: Upload Linode coverage to Codecov + if: steps.changed-linode.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-linode + files: ./linode_coverage.xml + + # External Provider (dynamic loading) + - name: Check if External Provider files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-external + uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + with: + files: | + ./prowler/providers/common/** + ./prowler/config/** + ./prowler/lib/** + ./tests/providers/external/** + ./uv.lock + + - name: Run External Provider tests + if: steps.changed-external.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external + + - name: Upload External Provider coverage to Codecov + if: steps.changed-external.outputs.any_changed == 'true' + + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-external + files: ./external_coverage.xml + # Lib - name: Check if Lib files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-lib - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/lib/** ./tests/lib/** - ./poetry.lock + ./uv.lock - name: Run Lib tests if: steps.changed-lib.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/lib --cov-report=xml:lib_coverage.xml tests/lib + run: uv run pytest -n auto --cov=./prowler/lib --cov-report=xml:lib_coverage.xml tests/lib - name: Upload Lib coverage to Codecov if: steps.changed-lib.outputs.any_changed == 'true' @@ -551,16 +669,16 @@ jobs: - name: Check if Config files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-config - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ./prowler/config/** ./tests/config/** - ./poetry.lock + ./uv.lock - name: Run Config tests if: steps.changed-config.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/config --cov-report=xml:config_coverage.xml tests/config + run: uv run pytest -n auto --cov=./prowler/config --cov-report=xml:config_coverage.xml tests/config - name: Upload Config coverage to Codecov if: steps.changed-config.outputs.any_changed == 'true' diff --git a/.github/workflows/test-impact-analysis.yml b/.github/workflows/test-impact-analysis.yml index f03f99d0fc..625d0f483f 100644 --- a/.github/workflows/test-impact-analysis.yml +++ b/.github/workflows/test-impact-analysis.yml @@ -31,6 +31,8 @@ on: description: "Whether there are UI E2E tests to run" value: ${{ jobs.analyze.outputs.has-ui-e2e }} +permissions: {} + jobs: analyze: runs-on: ubuntu-latest @@ -50,7 +52,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -66,7 +68,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/ui-bump-version.yml b/.github/workflows/ui-bump-version.yml deleted file mode 100644 index 9566c53006..0000000000 --- a/.github/workflows/ui-bump-version.yml +++ /dev/null @@ -1,251 +0,0 @@ -name: 'UI: Bump Version' - -on: - release: - types: - - 'published' - -concurrency: - group: ${{ github.workflow }}-${{ github.event.release.tag_name }} - cancel-in-progress: false - -env: - PROWLER_VERSION: ${{ github.event.release.tag_name }} - BASE_BRANCH: master - -jobs: - detect-release-type: - runs-on: ubuntu-latest - timeout-minutes: 5 - 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 }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 - with: - egress-policy: audit - - - 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 - 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} - - NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0 - echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}" - - echo "Current 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 }} - - - name: Bump UI version in .env for master - run: | - set -e - - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for next minor version 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: master - commit-message: 'chore(ui): Bump version to v${{ env.NEXT_MINOR_VERSION }}' - branch: ui-version-bump-to-v${{ env.NEXT_MINOR_VERSION }} - title: 'chore(ui): Bump version to v${{ env.NEXT_MINOR_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler UI version to v${{ env.NEXT_MINOR_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. - - ### Files Updated - - `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_VERSION` - - ### 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} - - FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1 - VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} - - 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 }} - - - name: Bump UI version in .env for version branch - run: | - set -e - - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for first patch version 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: 'chore(ui): Bump version to v${{ env.FIRST_PATCH_VERSION }}' - branch: ui-version-bump-to-v${{ env.FIRST_PATCH_VERSION }} - title: 'chore(ui): Bump version to v${{ env.FIRST_PATCH_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler UI version to v${{ env.FIRST_PATCH_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}. - - ### Files Updated - - `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_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: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - 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} - - NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1)) - VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} - - echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}" - echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" - - echo "Current 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 }} - - - name: Bump UI version in .env for version branch - run: | - set -e - - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env - - echo "Files modified:" - git --no-pager diff - - - name: Create PR for next patch version 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: 'chore(ui): Bump version to v${{ env.NEXT_PATCH_VERSION }}' - branch: ui-version-bump-to-v${{ env.NEXT_PATCH_VERSION }} - title: 'chore(ui): Bump version to v${{ env.NEXT_PATCH_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - Bump Prowler UI version to v${{ env.NEXT_PATCH_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. - - ### Files Updated - - `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_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/ui-codeql.yml b/.github/workflows/ui-codeql.yml index c4ab182e92..3dcb4f42eb 100644 --- a/.github/workflows/ui-codeql.yml +++ b/.github/workflows/ui-codeql.yml @@ -26,6 +26,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: ui-analyze: if: github.repository == 'prowler-cloud/prowler' @@ -45,7 +47,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -60,12 +62,12 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/ui-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index c8886a5ae9..44768fea80 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -32,8 +32,7 @@ env: PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui - # Build args - NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1 +permissions: {} jobs: setup: @@ -46,7 +45,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -65,7 +64,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -108,12 +107,13 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > registry-1.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 auth.docker.io:443 registry.npmjs.org:443 dl-cdn.alpinelinux.org:443 @@ -127,29 +127,28 @@ jobs: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push UI container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.WORKING_DIRECTORY }} build-args: | NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }} - NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} push: true platforms: ${{ matrix.platform }} tags: | ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }} cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }} # Create and push multi-architecture manifest create-manifest: @@ -161,7 +160,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -170,9 +169,10 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 - name: Login to DockerHub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -222,7 +222,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -258,27 +258,3 @@ jobs: payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" step-outcome: ${{ steps.outcome.outputs.outcome }} update-ts: ${{ needs.notify-release-started.outputs.message-ts }} - - trigger-deployment: - needs: [setup, container-build-push] - if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - - steps: - - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 - with: - egress-policy: block - allowed-endpoints: > - api.github.com:443 - - - name: Trigger UI deployment - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - repository: ${{ secrets.CLOUD_DISPATCH }} - event-type: ui-prowler-deployment - client-payload: '{"sha": "${{ github.sha }}", "short_sha": "${{ needs.setup.outputs.short-sha }}"}' diff --git a/.github/workflows/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index 10a910ace4..a931e50f22 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -5,6 +5,9 @@ on: branches: - 'master' - 'v5.*' + paths: + - 'ui/**' + - '.github/workflows/ui-container-checks.yml' pull_request: branches: - 'master' @@ -18,6 +21,8 @@ env: UI_WORKING_DIR: ./ui IMAGE_NAME: prowler-ui +permissions: {} + jobs: ui-dockerfile-lint: if: github.repository == 'prowler-cloud/prowler' @@ -28,7 +33,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -42,7 +47,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ui/Dockerfile @@ -55,16 +60,7 @@ jobs: ui-container-build-and-scan: if: github.repository == 'prowler-cloud/prowler' - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - arch: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - arch: arm64 + runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read @@ -73,7 +69,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -81,6 +77,7 @@ jobs: registry-1.docker.io:443 auth.docker.io:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 registry.npmjs.org:443 dl-cdn.alpinelinux.org:443 fonts.googleapis.com:443 @@ -89,6 +86,8 @@ jobs: mirror.gcr.io:443 check.trivy.dev:443 get.trivy.dev:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -98,7 +97,7 @@ jobs: - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ui/** files_ignore: | @@ -108,28 +107,27 @@ jobs: - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Build UI container for ${{ matrix.arch }} + - name: Build UI container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.UI_WORKING_DIR }} target: prod push: false load: true - platforms: ${{ matrix.platform }} - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} - cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }} build-args: | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51LwpXXXX - - name: Scan UI container with Trivy for ${{ matrix.arch }} + - name: Scan UI container with Trivy if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }}-${{ matrix.arch }} - fail-on-critical: 'false' + image-tag: ${{ github.sha }} + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 53533474fa..7165882d62 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -15,6 +15,12 @@ on: - 'ui/**' - 'api/**' # API changes can affect UI E2E +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} + jobs: # First, analyze which tests need to run impact-analysis: @@ -34,7 +40,8 @@ jobs: AUTH_SECRET: 'fallback-ci-secret-for-testing' AUTH_TRUST_HOST: true NEXTAUTH_URL: 'http://localhost:3000' - NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' + AUTH_URL: 'http://localhost:3000' + UI_API_BASE_URL: 'http://localhost:8080/api/v1' E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} @@ -71,6 +78,14 @@ jobs: E2E_ALIBABACLOUD_ACCESS_KEY_ID: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_ID }} E2E_ALIBABACLOUD_ACCESS_KEY_SECRET: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_SECRET }} E2E_ALIBABACLOUD_ROLE_ARN: ${{ secrets.E2E_ALIBABACLOUD_ROLE_ARN }} + E2E_OKTA_DOMAIN: ${{ secrets.E2E_OKTA_DOMAIN }} + E2E_OKTA_CLIENT_ID: ${{ secrets.E2E_OKTA_CLIENT_ID }} + E2E_OKTA_BASE64_PRIVATE_KEY: ${{ secrets.E2E_OKTA_BASE64_PRIVATE_KEY }} + E2E_GOOGLEWORKSPACE_CUSTOMER_ID: ${{ secrets.E2E_GOOGLEWORKSPACE_CUSTOMER_ID }} + E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON: ${{ secrets.E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON }} + E2E_GOOGLEWORKSPACE_DELEGATED_USER: ${{ secrets.E2E_GOOGLEWORKSPACE_DELEGATED_USER }} + E2E_VERCEL_TEAM_ID: ${{ secrets.E2E_VERCEL_TEAM_ID }} + E2E_VERCEL_API_TOKEN: ${{ secrets.E2E_VERCEL_API_TOKEN }} # Pass E2E paths from impact analysis E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }} RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }} @@ -79,7 +94,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit @@ -124,6 +139,22 @@ jobs: echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env + - name: Build API image from current code + # docker-compose.yml references prowlercloud/prowler-api:latest from the registry, + # which lags behind PR changes; build locally so E2E exercises the API image + # produced by this PR. + # + # The image installs the SDK from git@master (api/uv.lock), so a PR changing BOTH the SDK + # and the API would run against the OLD SDK and crash on startup. Overlay the checkout's + # SDK source so both run together. New SDK dependencies still need an api/uv.lock bump. + run: | + docker build -t prowlercloud/prowler-api:pr-base ./api + docker build -t prowlercloud/prowler-api:latest -f - prowler <<'DOCKERFILE' + FROM prowlercloud/prowler-api:pr-base + RUN rm -rf /home/prowler/.venv/lib/python3.12/site-packages/prowler + COPY --chown=prowler:prowler . /home/prowler/.venv/lib/python3.12/site-packages/prowler + DOCKERFILE + - name: Start API services run: | export PROWLER_API_VERSION=latest @@ -135,7 +166,7 @@ jobs: timeout=150 elapsed=0 while [ $elapsed -lt $timeout ]; do - if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then + if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then echo "Prowler API is ready!" exit 0 fi @@ -152,18 +183,18 @@ jobs: for fixture in api/fixtures/dev/*.json; do if [ -f "$fixture" ]; then echo "Loading $fixture" - poetry run python manage.py loaddata "$fixture" --database admin + uv run python manage.py loaddata "$fixture" --database admin fi done ' - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: '24.13.0' + node-version-file: 'ui/.nvmrc' - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: package_json_file: ui/package.json run_install: false @@ -172,7 +203,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm and Next.js cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.STORE_PATH }} @@ -192,7 +223,7 @@ jobs: run: pnpm run build - name: Cache Playwright browsers - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: playwright-cache with: path: ~/.cache/ms-playwright @@ -259,12 +290,12 @@ jobs: fi - name: Upload test reports - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: failure() with: name: playwright-report path: ui/playwright-report/ - retention-days: 30 + retention-days: 7 - name: Cleanup services if: always() @@ -283,7 +314,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: audit diff --git a/.github/workflows/ui-security.yml b/.github/workflows/ui-security.yml new file mode 100644 index 0000000000..2dc444654f --- /dev/null +++ b/.github/workflows/ui-security.yml @@ -0,0 +1,68 @@ +name: 'UI: Security' + +on: + push: + branches: + - 'master' + - 'v5.*' + paths: + - 'ui/package.json' + - 'ui/pnpm-lock.yaml' + - '.github/workflows/ui-security.yml' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' + pull_request: + branches: + - 'master' + - 'v5.*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + ui-security-scans: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write # osv-scanner action posts/updates a PR comment with findings + + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + api.osv.dev:443 + api.deps.dev:443 + osv-vulnerabilities.storage.googleapis.com:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # zizmor: ignore[artipacked] + persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + + - name: Check for UI dependency changes + id: check-changes + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ui/package.json + ui/pnpm-lock.yaml + .github/workflows/ui-security.yml + .github/actions/osv-scanner/** + .github/scripts/osv-scan.sh + + - name: Dependency vulnerability scan with osv-scanner + if: steps.check-changes.outputs.any_changed == 'true' + uses: ./.github/actions/osv-scanner + with: + lockfile: ui/pnpm-lock.yaml diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 57cb149523..2dc50a26bb 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -1,14 +1,14 @@ -name: 'UI: Tests' +name: "UI: Tests" on: push: branches: - - 'master' - - 'v5.*' + - "master" + - "v5.*" pull_request: branches: - - 'master' - - 'v5.*' + - "master" + - "v5.*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -16,7 +16,8 @@ concurrency: env: UI_WORKING_DIR: ./ui - NODE_VERSION: '24.13.0' + +permissions: {} jobs: ui-tests: @@ -30,7 +31,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: egress-policy: block allowed-endpoints: > @@ -40,6 +41,9 @@ jobs: fonts.gstatic.com:443 api.github.com:443 release-assets.githubusercontent.com:443 + cdn.playwright.dev:443 + objects.githubusercontent.com:443 + playwright.download.prss.microsoft.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -49,7 +53,7 @@ jobs: - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ui/** @@ -62,7 +66,7 @@ jobs: - name: Get changed source files for targeted tests id: changed-source if: steps.check-changes.outputs.any_changed == 'true' - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ui/**/*.ts @@ -78,7 +82,7 @@ jobs: - name: Check for critical path changes (run all tests) id: critical-changes if: steps.check-changes.outputs.any_changed == 'true' - uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ui/lib/** @@ -88,15 +92,15 @@ jobs: ui/vitest.config.ts ui/vitest.setup.ts - - name: Setup Node.js ${{ env.NODE_VERSION }} + - name: Setup Node.js if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: 'ui/.nvmrc' - name: Setup pnpm if: steps.check-changes.outputs.any_changed == 'true' - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: package_json_file: ui/package.json run_install: false @@ -108,7 +112,7 @@ jobs: - name: Setup pnpm and Next.js cache if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.STORE_PATH }} @@ -127,11 +131,19 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run healthcheck + - name: Check product-tour alignment + if: steps.check-changes.outputs.any_changed == 'true' + run: pnpm run tour:check + + - name: Run pnpm audit + if: steps.check-changes.outputs.any_changed == 'true' + run: pnpm run audit + - name: Run unit tests (all - critical paths changed) if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed == 'true' run: | echo "Critical paths changed - running ALL unit tests" - pnpm run test:run + pnpm run test:unit - name: Run unit tests (related to changes only) if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files != '' @@ -140,7 +152,7 @@ jobs: echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" # Convert space-separated to vitest related format (remove ui/ prefix for relative paths) CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ') - pnpm exec vitest related $CHANGED_FILES --run + pnpm exec vitest related $CHANGED_FILES --run --project unit env: STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }} @@ -148,7 +160,25 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files == '' run: | echo "Only test files changed - running ALL unit tests" - pnpm run test:run + pnpm run test:unit + + - name: Cache Playwright browsers + if: steps.check-changes.outputs.any_changed == 'true' + id: playwright-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install Playwright Chromium browser + if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install chromium + + - name: Run browser tests + if: steps.check-changes.outputs.any_changed == 'true' + run: pnpm run test:browser - name: Build application if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 931ef1b94c..0cfc6215e1 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,21 +1,19 @@ rules: secrets-outside-env: ignore: - - api-bump-version.yml - api-container-build-push.yml - api-tests.yml - backport.yml - - docs-bump-version.yml + - bump-version.yml - issue-triage.lock.yml - mcp-container-build-push.yml + - nightly-arm64-container-builds.yml - pr-merged.yml - prepare-release.yml - - sdk-bump-version.yml - sdk-container-build-push.yml - sdk-refresh-aws-services-regions.yml - sdk-refresh-oci-regions.yml - sdk-tests.yml - - ui-bump-version.yml - ui-container-build-push.yml - ui-e2e-tests-v2.yml superfluous-actions: diff --git a/.gitignore b/.gitignore index 8686f4f143..6c11a8698c 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,6 @@ htmlcov/ **/mcp-config.json **/mcpServers.json .mcp/ -.mcp.json # AI Coding Assistants - Cursor .cursorignore @@ -84,6 +83,7 @@ continue.json .continuerc.json # AI Coding Assistants - OpenCode +.opencode/ opencode.json # AI Coding Assistants - GitHub Copilot @@ -150,6 +150,8 @@ node_modules # Persistent data _data/ +/openspec/ +/.gitmodules # AI Instructions (generated by skills/setup.sh from AGENTS.md) CLAUDE.md @@ -167,3 +169,7 @@ GEMINI.md # Claude Code .claude/* + +# Docker +docker-compose.override.yml +docker-compose-dev.override.yml diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000000..02a11d111b --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,10 @@ +{ + "extends": "markdownlint/style/prettier", + "first-line-h1": false, + "no-duplicate-heading": { + "siblings_only": true + }, + "no-inline-html": false, + "line-length": false, + "no-bare-urls": false +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000000..7aa58ccd1a --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,16 @@ +node_modules/ +ui/node_modules/ +.git/ +.venv/ +**/.venv/ +dist/ +build/ +htmlcov/ +.next/ +ui/.next/ +ui/out/ +contrib/ + +# Auto-generated content (keepachangelog format legitimately repeats section headings). +# Revisit with the team — see beads task on markdownlint rule triage. +**/CHANGELOG.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a45f436284..159c1f5a16 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,148 +1,217 @@ +# Priority tiers (lower = runs first, same priority = concurrent): +# P0 — fast file fixers +# P10 — validators and guards +# P20 — auto-formatters +# P30 — linters +# P40 — security scanners +# P50 — dependency validation + +default_install_hook_types: [pre-commit] +# Hooks run on commit only by default; +# NOTE: default_stages does NOT override a hook's manifest stages, so fixers shipping pre-push in their +# manifest need an explicit stages: ["pre-commit"] below to stay off push. +default_stages: [pre-commit] + repos: - ## GENERAL - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + ## GENERAL (prek built-in — no external repo needed) + - repo: builtin hooks: - id: check-merge-conflict + priority: 10 - id: check-yaml - args: ["--unsafe"] - exclude: prowler/config/llm_config.yaml + args: ["--allow-multiple-documents"] + exclude: (prowler/config/llm_config.yaml|contrib/) + priority: 10 - id: check-json + priority: 10 - id: end-of-file-fixer + stages: ["pre-commit"] + priority: 0 - id: trailing-whitespace + stages: ["pre-commit"] + priority: 0 - id: no-commit-to-branch + priority: 10 - id: pretty-format-json args: ["--autofix", --no-sort-keys, --no-ensure-ascii] + stages: ["pre-commit"] + priority: 10 ## TOML - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.13.0 + rev: v2.16.0 hooks: - id: pretty-format-toml args: [--autofix] files: pyproject.toml + priority: 20 ## GITHUB ACTIONS - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.6.0 + rev: v1.24.1 hooks: - id: zizmor - files: ^\.github/ + # zizmor only audits workflows, composite actions and dependabot + # config; broader paths trip exit 3 ("no audit was performed"). + files: ^\.github/(workflows|actions)/.+\.ya?ml$|^\.github/dependabot\.ya?ml$ + priority: 30 + + ## RENOVATE + - repo: https://github.com/renovatebot/pre-commit-hooks + rev: 43.150.0 + hooks: + - id: renovate-config-validator + files: ^\.github/renovate\.json$ + priority: 10 ## BASH - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.10.0 + rev: v0.11.0 hooks: - id: shellcheck exclude: contrib + priority: 30 - ## PYTHON + ## PYTHON — SDK (prowler/, tests/, dashboard/, util/, scripts/) - repo: https://github.com/myint/autoflake - rev: v2.3.1 + rev: v2.3.3 hooks: - id: autoflake - exclude: ^skills/ - args: - [ - "--in-place", - "--remove-all-unused-imports", - "--remove-unused-variable", - ] + name: "SDK - autoflake" + files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] } + args: ["--in-place", "--remove-all-unused-imports", "--remove-unused-variable"] + priority: 20 - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 8.0.1 hooks: - id: isort - exclude: ^skills/ + name: "SDK - isort" + files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] } args: ["--profile", "black"] + stages: ["pre-commit"] + priority: 20 - repo: https://github.com/psf/black - rev: 24.4.2 + rev: 26.3.1 hooks: - id: black - exclude: ^skills/ + name: "SDK - black" + files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] } + priority: 20 - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 + rev: 7.3.0 hooks: - id: flake8 - exclude: (contrib|^skills/) + name: "SDK - flake8" + files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] } args: ["--ignore=E266,W503,E203,E501,W605"] + priority: 30 - - repo: https://github.com/python-poetry/poetry - rev: 2.1.1 + ## PYTHON — API + MCP Server (ruff) + # Run ruff through `uv run` against each project so prek uses the exact ruff + # version pinned in that project's uv.lock — the same version GitHub Actions + # runs via `uv run ruff`. This removes the drift between the local hooks and + # CI. api/ and mcp_server/ are separate uv projects, so they need separate + # hooks (each `uv run --project` resolves its own pinned ruff + config). + - repo: local hooks: - - id: poetry-check - name: API - poetry-check - args: ["--directory=./api"] - pass_filenames: false + - id: ruff-check-api + name: "API - ruff check" + entry: uv run --project ./api ruff check --fix + language: system + files: { glob: ["api/**/*.py"] } + priority: 30 + - id: ruff-format-api + name: "API - ruff format" + entry: uv run --project ./api ruff format + language: system + files: { glob: ["api/**/*.py"] } + priority: 20 + - id: ruff-check-mcp + name: "MCP - ruff check" + entry: uv run --project ./mcp_server ruff check --fix + language: system + files: { glob: ["mcp_server/**/*.py"] } + priority: 30 + - id: ruff-format-mcp + name: "MCP - ruff format" + entry: uv run --project ./mcp_server ruff format + language: system + files: { glob: ["mcp_server/**/*.py"] } + priority: 20 - - id: poetry-lock - name: API - poetry-lock - args: ["--directory=./api"] + ## PYTHON — uv (API + SDK) + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.14 + hooks: + - id: uv-lock + name: API - uv-lock + args: ["--check", "--project=./api"] + files: { glob: ["api/{pyproject.toml,uv.lock}"] } pass_filenames: false + priority: 50 - - id: poetry-check - name: SDK - poetry-check - args: ["--directory=./"] + - id: uv-lock + name: SDK - uv-lock + args: ["--check", "--project=./"] + files: { glob: ["{pyproject.toml,uv.lock}"] } pass_filenames: false + priority: 50 - - id: poetry-lock - name: SDK - poetry-lock - args: ["--directory=./"] - pass_filenames: false + ## MARKDOWN + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.45.0 + hooks: + - id: markdownlint + priority: 30 + ## CONTAINERS - repo: https://github.com/hadolint/hadolint - rev: v2.13.0-beta + rev: v2.14.0 hooks: - id: hadolint args: ["--ignore=DL3013"] + priority: 30 + ## LOCAL HOOKS - repo: local hooks: - id: pylint - name: pylint - entry: bash -c 'pylint --disable=W,C,R,E -j 0 -rn -sn prowler/' + name: "SDK - pylint" + entry: pylint --disable=W,C,R,E -j 0 -rn -sn language: system - files: '.*\.py' + types: [python] + files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] } + priority: 30 - id: trufflehog name: TruffleHog description: Detect secrets in your data. - entry: bash -c 'trufflehog --no-update git file://. --only-verified --fail' + entry: bash -c 'trufflehog --no-update git file://. --since-commit HEAD --only-verified --fail' # For running trufflehog in docker, use the following entry instead: # entry: bash -c 'docker run -v "$(pwd):/workdir" -i --rm trufflesecurity/trufflehog:latest git file:///workdir --only-verified --fail' language: system + pass_filenames: false stages: ["pre-commit", "pre-push"] + priority: 40 - id: bandit name: bandit description: "Bandit is a tool for finding common security issues in Python code" - entry: bash -c 'bandit -q -lll -x '*_test.py,./contrib/,./.venv/,./skills/' -r .' + entry: bandit -q -lll language: system + types: [python] files: '.*\.py' - - - id: safety - name: safety - description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" - # TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X - # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - # TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0` - entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,86217' - language: system + exclude: { glob: ["{contrib,skills}/**", "**/.venv/**", "**/*_test.py"] } + priority: 40 - id: vulture name: vulture description: "Vulture finds unused code in Python programs." - entry: bash -c 'vulture --exclude "contrib,.venv,api/src/backend/api/tests/,api/src/backend/conftest.py,api/src/backend/tasks/tests/,skills/" --min-confidence 100 .' + entry: vulture --min-confidence 100 language: system + types: [python] files: '.*\.py' - - - id: ui-checks - name: UI - Husky Pre-commit - description: "Run UI pre-commit checks (Claude Code validation + healthcheck)" - entry: bash -c 'cd ui && .husky/pre-commit' - language: system - files: '^ui/.*\.(ts|tsx|js|jsx|json|css)$' - pass_filenames: false - verbose: true + priority: 40 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 17d338d2e9..fabae0afa5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -11,15 +11,11 @@ build: python: "3.11" jobs: post_create_environment: - # Install poetry - # https://python-poetry.org/docs/#installing-manually - - python -m pip install poetry + - python -m pip install uv==0.11.14 post_install: - # Install dependencies with 'docs' dependency group - # https://python-poetry.org/docs/managing-dependencies/#dependency-groups # VIRTUAL_ENV needs to be set manually for now. # See https://github.com/readthedocs/readthedocs.org/pull/11152/ - - VIRTUAL_ENV=${READTHEDOCS_VIRTUALENV_PATH} python -m poetry install --only=docs + - VIRTUAL_ENV=${READTHEDOCS_VIRTUALENV_PATH} uv sync --group docs --no-install-project mkdocs: configuration: mkdocs.yml diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000000..744c94a193 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,98 @@ +# Trivy ignore file for prowlercloud/prowler SDK container image. +# Each entry below documents (a) the affected package and why it ships in the +# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the +# upstream fix status. Entries carry an expiry so they auto-force re-review. +# Entries are scoped per-package so suppressions cannot drift onto unrelated +# packages that may be assigned the same CVE in the future. +# +# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml + +# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks. +# CVE-2026-8376 — perl heap buffer overflow when compiling regex. +# Packages: perl, perl-base, perl-modules-5.36, libperl5.36. +# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be +# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime; +# neither vulnerable code path (Archive::Tar parsing or regex compilation of +# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix +# is available yet. +CVE-2026-42496 pkg:perl exp:2026-07-15 +CVE-2026-42496 pkg:perl-base exp:2026-07-15 +CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15 +CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15 +CVE-2026-8376 pkg:perl exp:2026-07-15 +CVE-2026-8376 pkg:perl-base exp:2026-07-15 +CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15 +CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15 + +# CVE-2025-7458 — SQLite integer overflow. +# Package: libsqlite3-0. +# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The +# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is +# internal and bounded. No Debian bookworm fix is available. +CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15 + +# CVE-2026-43185 — Linux kernel ksmbd signedness bug. +# Package: linux-libc-dev. +# Why ignored: linux-libc-dev ships kernel headers for build-time compilation, +# not a running kernel. Containers execute against the host kernel, so these +# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and +# has not been backported to Debian's 6.1 LTS line. +CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15 + +# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in +# zipOpenNewFileInZip4_64. +# Packages: zlib1g, zlib1g-dev. +# Why ignored: Debian Security Tracker status for bookworm is , with +# the published rationale "contrib/minizip not built and src:zlib not producing +# binary packages" — i.e. the vulnerable symbol is not present in the libz.so +# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in +# zlib 1.3.1, available in Debian trixie (13); migrating the base image would +# clear it fully. +# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853 +CVE-2023-45853 pkg:zlib1g exp:2026-07-15 +CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15 + +# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to +# an unchecked packet_length field in transport.c (heap corruption, possible RCE). +# Package: libssh2-1. +# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4 +# (installed in the SDK Dockerfile for the networking/PowerShell stack). The +# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP +# client parsing transport packets from a server. Prowler never uses libcurl's +# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the +# affected code is unreachable at runtime. Fixed upstream in libssh2 commit +# 97acf3df (PR #2052); no Debian bookworm fix is available yet. +# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200 +CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15 + +# --- API container image (api/Dockerfile) --- +# The entries below are specific to the Prowler API image, which ships +# PowerShell and additional build tooling on top of the same bookworm base. + +# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in +# `xml.parsers.expat` and `xml.etree.ElementTree`. +# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*). +# Why ignored: the API runs under the Python 3.12 interpreter shipped in its +# `.venv`; the system `python3.11` is only present because `python3-dev` is +# pulled in to compile native extensions (xmlsec, lxml) and is never executed +# at runtime. The vulnerable path requires parsing attacker-controlled XML with +# the affected interpreter, which Prowler does not do with the system Python. +# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet. +CVE-2026-7210 pkg:python3.11 exp:2026-07-15 +CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15 +CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15 + +# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE). +# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records. +# Package: libunbound8. +# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking +# stack (GnuTLS DANE support); only the shared library ships in the image. Both +# vulnerabilities require operating a live Unbound recursive DNSSEC validator +# that processes attacker-influenced DNS responses. Prowler never starts an +# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet. +CVE-2026-33278 pkg:libunbound8 exp:2026-07-15 +CVE-2026-42960 pkg:libunbound8 exp:2026-07-15 diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 0000000000..b2828287f7 --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,3 @@ +.envrc +ui/.env.local +openspec/ diff --git a/AGENTS.md b/AGENTS.md index 5302e38f52..c9d63a8c27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,11 +11,12 @@ Use these skills for detailed patterns on-demand: ### Generic Skills (Any Project) + | Skill | Description | URL | |-------|-------------|-----| | `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) | | `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) | -| `nextjs-15` | App Router, Server Actions, streaming | [SKILL.md](skills/nextjs-15/SKILL.md) | +| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | [SKILL.md](skills/nextjs-16/SKILL.md) | | `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) | | `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) | | `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) | @@ -28,6 +29,7 @@ Use these skills for detailed patterns on-demand: | `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) | ### Prowler-Specific Skills + | Skill | Description | URL | |-------|-------------|-----| | `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) | @@ -49,6 +51,7 @@ Use these skills for detailed patterns on-demand: | `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) | | `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) | | `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) | +| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) | | `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) | | `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) | @@ -60,12 +63,17 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: |--------|-------| | Add changelog entry for a PR or feature | `prowler-changelog` | | Adding DRF pagination or permissions | `django-drf` | +| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` | +| Adding indexes or constraints to database tables | `django-migration-psql` | | Adding new providers | `prowler-provider` | | Adding privilege escalation detection queries | `prowler-attack-paths-query` | | Adding services to existing providers | `prowler-provider` | +| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` | | After creating/modifying a skill | `skill-sync` | -| App Router / Server Actions | `nextjs-15` | +| App Router / Server Actions | `nextjs-16` | +| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` | | Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | | Committing changes | `prowler-commit` | | Configuring MCP servers in agentic workflows | `gh-aw` | | Create PR that requires changelog entry | `prowler-changelog` | @@ -78,13 +86,16 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Creating a git commit | `prowler-commit` | | Creating new checks | `prowler-sdk-check` | | Creating new skills | `skill-creator` | +| Creating or reviewing Django migrations | `django-migration-psql` | | Creating/modifying Prowler UI components | `prowler-ui` | | Creating/modifying models, views, serializers | `prowler-api` | | Creating/updating compliance frameworks | `prowler-compliance` | | Debug why a GitHub Actions job is failing | `prowler-ci` | | Debugging gh-aw compilation errors | `gh-aw` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | | Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` | | Fixing bug | `tdd` | +| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` | | General Prowler development questions | `prowler` | | Implementing JSON:API endpoints | `django-drf` | | Implementing feature | `tdd` | @@ -98,10 +109,14 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` | | Refactoring code | `tdd` | | Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | | Review PR requirements: template, title conventions, changelog gate | `prowler-pr` | | Review changelog format and conventions | `prowler-changelog` | | Reviewing JSON:API compliance | `jsonapi` | | Reviewing compliance framework PRs | `prowler-compliance-review` | +| Running makemigrations or pgmakemigrations | `django-migration-psql` | +| Syncing compliance framework with upstream catalog | `prowler-compliance` | | Testing RLS tenant isolation | `prowler-test-api` | | Testing hooks or utilities | `vitest` | | Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` | @@ -129,6 +144,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Writing React components | `react-19` | | Writing TypeScript types/interfaces | `typescript` | | Writing Vitest tests | `vitest` | +| Writing data backfill or data migration | `django-migration-psql` | | Writing documentation | `prowler-docs` | | Writing unit tests for UI | `vitest` | @@ -140,9 +156,9 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure, | Component | Location | Tech Stack | |-----------|----------|------------| -| SDK | `prowler/` | Python 3.10+, Poetry | +| SDK | `prowler/` | Python 3.10+, uv | | API | `api/` | Django 5.1, DRF, Celery | -| UI | `ui/` | Next.js 15, React 19, Tailwind 4 | +| UI | `ui/` | Next.js 16, React 19, Tailwind 4 | | MCP Server | `mcp_server/` | FastMCP, Python 3.12+ | | Dashboard | `dashboard/` | Dash, Plotly | @@ -152,13 +168,13 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure, ```bash # Setup -poetry install --with dev -poetry run pre-commit install +uv sync +uv run prek install # Code quality -poetry run make lint -poetry run make format -poetry run pre-commit run --all-files +uv run make lint +uv run make format +uv run prek run --all-files ``` --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f530feb99a..90c1ed1954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,37 @@ -# Do you want to learn on how to... +# Do you want to learn on how to -- Contribute with your code or fixes to Prowler -- Create a new check for a provider -- Create a new security compliance framework -- Add a custom output format -- Add a new integration -- Contribute with documentation +- [Contribute with your code or fixes to Prowler](https://docs.prowler.com/developer-guide/introduction) +- [Create a new provider](https://docs.prowler.com/developer-guide/provider) +- [Create a new service](https://docs.prowler.com/developer-guide/services) +- [Create a new check for a provider](https://docs.prowler.com/developer-guide/checks) +- [Create a new security compliance framework](https://docs.prowler.com/developer-guide/security-compliance-framework) +- [Add a custom output format](https://docs.prowler.com/developer-guide/outputs) +- [Add a new integration](https://docs.prowler.com/developer-guide/integrations) +- [Contribute with documentation](https://docs.prowler.com/developer-guide/documentation) +- [Write unit tests](https://docs.prowler.com/developer-guide/unit-testing) +- [Write integration tests](https://docs.prowler.com/developer-guide/integration-testing) +- [Write end-to-end tests](https://docs.prowler.com/developer-guide/end2end-testing) +- [Debug Prowler](https://docs.prowler.com/developer-guide/debugging) +- [Configure checks](https://docs.prowler.com/developer-guide/configurable-checks) +- [Rename checks](https://docs.prowler.com/developer-guide/renaming-checks) +- [Follow the check metadata guidelines](https://docs.prowler.com/developer-guide/check-metadata-guidelines) +- [Extend the MCP server](https://docs.prowler.com/developer-guide/mcp-server) +- [Extend Lighthouse AI](https://docs.prowler.com/developer-guide/lighthouse-architecture) +- [Add AI skills](https://docs.prowler.com/developer-guide/ai-skills) + +Provider-specific developer notes: + +- [AWS](https://docs.prowler.com/developer-guide/aws-details) +- [Azure](https://docs.prowler.com/developer-guide/azure-details) +- [Google Cloud](https://docs.prowler.com/developer-guide/gcp-details) +- [Alibaba Cloud](https://docs.prowler.com/developer-guide/alibabacloud-details) +- [Kubernetes](https://docs.prowler.com/developer-guide/kubernetes-details) +- [Microsoft 365](https://docs.prowler.com/developer-guide/m365-details) +- [GitHub](https://docs.prowler.com/developer-guide/github-details) +- [LLM](https://docs.prowler.com/developer-guide/llm-details) Want some swag as appreciation for your contribution? -# Prowler Developer Guide -https://goto.prowler.com/devguide +## Prowler Developer Guide + + diff --git a/Dockerfile b/Dockerfile index 0dbe63ece0..205fa239be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS build +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" @@ -6,9 +6,12 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} -ARG TRIVY_VERSION=0.69.2 +ARG TRIVY_VERSION=0.71.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 @@ -57,28 +76,40 @@ USER prowler WORKDIR /home/prowler # Copy necessary files -COPY prowler/ /home/prowler/prowler/ -COPY dashboard/ /home/prowler/dashboard/ -COPY pyproject.toml /home/prowler -COPY README.md /home/prowler/ -COPY prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py +COPY --chown=prowler:prowler prowler/ /home/prowler/prowler/ +COPY --chown=prowler:prowler dashboard/ /home/prowler/dashboard/ +COPY --chown=prowler:prowler pyproject.toml uv.lock /home/prowler/ +COPY --chown=prowler:prowler README.md /home/prowler/ +COPY --chown=prowler:prowler prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py # Install Python dependencies ENV HOME='/home/prowler' ENV PATH="${HOME}/.local/bin:${PATH}" #hadolint ignore=DL3013 RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir poetry + pip install --no-cache-dir uv==0.11.14 -RUN poetry install --compile && \ - rm -rf ~/.cache/pip +RUN uv sync --locked --compile-bytecode && \ + rm -rf ~/.cache/uv # Install PowerShell modules -RUN poetry run python prowler/providers/m365/lib/powershell/m365_powershell.py +RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py + +USER root + +# Remove build-only packages from the final image after Python dependencies are installed. +RUN apt-get purge -y --auto-remove \ + build-essential \ + pkg-config \ + libzstd-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +USER prowler # Remove deprecated dash dependencies RUN pip uninstall dash-html-components -y && \ pip uninstall dash-core-components -y USER prowler -ENTRYPOINT ["poetry", "run", "prowler"] +ENTRYPOINT ["/home/prowler/.venv/bin/prowler"] diff --git a/Makefile b/Makefile index 2000d6d5bd..b05a7dc2db 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,34 @@ .DEFAULT_GOAL:=help +DEV_LOCAL := ./scripts/development/dev-local.sh + +.PHONY: dev dev-setup dev-attach dev-launch dev-stop dev-clean dev-wipe dev-status + +##@ Local Development +dev: ## Start local API, worker, and database logs + $(DEV_LOCAL) all + +dev-setup: ## Bootstrap local dependencies, migrations, and fixtures + $(DEV_LOCAL) setup + +dev-attach: ## Attach to the local tmux development session + $(DEV_LOCAL) attach + +dev-launch: ## Start the local stack on fixed ports and attach + $(DEV_LOCAL) launch + +dev-stop: ## Stop the local tmux session and containers + $(DEV_LOCAL) kill + +dev-clean: ## Remove stopped local development containers + $(DEV_LOCAL) clean + +dev-wipe: ## Stop everything and delete local development data + $(DEV_LOCAL) wipe + +dev-status: ## Show local development container status + $(DEV_LOCAL) status + ##@ Testing test: ## Test with pytest rm -rf .coverage && \ @@ -16,18 +45,41 @@ coverage-html: ## Show Test Coverage coverage html && \ open htmlcov/index.html -##@ Linting -format: ## Format Code - @echo "Running black..." - black . +##@ Code Quality +# `make` is the single entrypoint and mirrors CI exactly (uv run + same flags): +# SDK (prowler/, util/) -> flake8 + black + pylint +# API & MCP server -> ruff (rules live in each project's pyproject.toml) +# `format` applies fixes (incl. ruff's import/upgrade autofixes); `lint` only +# verifies and is what CI gates on. +.PHONY: format format-sdk format-api format-mcp lint lint-sdk lint-api lint-mcp -lint: ## Lint Code - @echo "Running flake8..." - flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude contrib - @echo "Running black... " - black --check . - @echo "Running pylint..." - pylint --disable=W,C,R,E -j 0 prowler util +format: format-sdk format-api format-mcp ## Format & autofix all components (SDK, API, MCP) + +lint: lint-sdk lint-api lint-mcp ## Lint all components (SDK, API, MCP) — mirrors CI + +format-sdk: ## Format SDK code (black) + uv run black --exclude "\.venv|api|ui|skills|mcp_server" . + +lint-sdk: ## Lint SDK code (flake8, black --check, pylint) + uv run flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude .venv,contrib,ui,api,skills,mcp_server + uv run black --exclude "\.venv|api|ui|skills|mcp_server" --check . + uv run pylint --disable=W,C,R,E -j 0 -rn -sn prowler/ + +format-api: ## Format & autofix API code (ruff) + cd api && uv run ruff check . --exclude contrib --fix + cd api && uv run ruff format . --exclude contrib + +lint-api: ## Lint API code (ruff check + format --check) + cd api && uv run ruff check . --exclude contrib + cd api && uv run ruff format --check . --exclude contrib + +format-mcp: ## Format & autofix MCP server code (ruff) + cd mcp_server && uv run ruff check . --fix + cd mcp_server && uv run ruff format . + +lint-mcp: ## Lint MCP server code (ruff check + format --check) + cd mcp_server && uv run ruff check . + cd mcp_server && uv run ruff format --check . ##@ PyPI pypi-clean: ## Delete the distribution files @@ -35,7 +87,7 @@ pypi-clean: ## Delete the distribution files pypi-build: ## Build package $(MAKE) pypi-clean && \ - poetry build + uv build pypi-upload: ## Upload package python3 -m twine upload --repository pypi dist/* @@ -56,4 +108,3 @@ run-api-dev: ## Start development environment with API, PostgreSQL, Valkey, MCP, ##@ Development Environment build-and-run-api-dev: build-no-cache-dev run-api-dev - diff --git a/README.md b/README.md index 87ca1f152a..e2314d8a69 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

- - + Prowler logo + Prowler logo

- Prowler is the Open Cloud Security platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size. + Prowler is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.

Secure ANY cloud at AI Speed at prowler.com @@ -22,8 +22,8 @@ PyPI Downloads Docker Pulls AWS ECR Gallery - - + Codecov coverage + Linux Foundation insights health score

Version @@ -36,12 +36,12 @@


- + Prowler Cloud demo

# Description -**Prowler** is the world’s most widely used _open-source cloud security platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size. +**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size. Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including: @@ -83,16 +83,35 @@ prowler dashboard ## Attack Paths -Attack Paths automatically extends every completed AWS scan with a Neo4j graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan and therefore requires: +Attack Paths automatically extends every completed AWS scan with a graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan. -- An accessible Neo4j instance (the Docker Compose files already ships a `neo4j` service). -- The following environment variables so Django and Celery can connect: +Two graph backends are supported as the long-lived sink: - | Variable | Description | Default | - | --- | --- | --- | - | `NEO4J_HOST` | Hostname used by the API containers. | `neo4j` | - | `NEO4J_PORT` | Bolt port exposed by Neo4j. | `7687` | - | `NEO4J_USER` / `NEO4J_PASSWORD` | Credentials with rights to create per-tenant databases. | `neo4j` / `neo4j_password` | +- **Neo4j** (default; the Docker Compose files already ship a `neo4j` service). +- **Amazon Neptune** (cloud-managed; opt-in). + +Select the sink with `ATTACK_PATHS_SINK_DATABASE` (`neo4j` or `neptune`; default `neo4j`). + +> Note: Cartography ingestion always uses a temporary Neo4j database, regardless of the configured sink. The `NEO4J_*` variables below must remain set even when `ATTACK_PATHS_SINK_DATABASE=neptune`. + +### Neo4j sink + +| Variable | Description | Default | +| --- | --- | --- | +| `NEO4J_HOST` | Hostname used by the API containers. | `neo4j` | +| `NEO4J_PORT` | Bolt port exposed by Neo4j. | `7687` | +| `NEO4J_USER` / `NEO4J_PASSWORD` | Credentials with rights to create per-tenant databases. | `neo4j` / `neo4j_password` | + +### Neptune sink + +| Variable | Description | Default | +| --- | --- | --- | +| `NEPTUNE_WRITER_ENDPOINT` | Bolt host for the Neptune writer instance. Required when sink is `neptune`. | _empty_ | +| `NEPTUNE_READER_ENDPOINT` | Optional reader endpoint for read-only queries. Falls back to the writer when unset. | _empty_ | +| `NEPTUNE_PORT` | Bolt port exposed by Neptune. | `8182` | +| `AWS_REGION` | Region the Neptune cluster lives in. Required when sink is `neptune`. | _empty_ | + +Neptune authenticates with SigV4 using the standard boto3 credential chain. The worker's IAM role (or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) supplies the credentials. There is no Neptune password variable. Every AWS provider scan will enqueue an Attack Paths ingestion job automatically. Other cloud providers will be added in future iterations. @@ -104,23 +123,27 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 572 | 83 | 41 | 17 | Official | UI, API, CLI | -| Azure | 165 | 20 | 18 | 13 | Official | UI, API, CLI | -| GCP | 100 | 13 | 15 | 11 | Official | UI, API, CLI | -| Kubernetes | 83 | 7 | 7 | 9 | Official | UI, API, CLI | -| GitHub | 21 | 2 | 1 | 2 | Official | UI, API, CLI | -| M365 | 89 | 9 | 4 | 5 | Official | UI, API, CLI | -| OCI | 48 | 13 | 3 | 10 | Official | UI, API, CLI | -| Alibaba Cloud | 61 | 9 | 3 | 9 | Official | UI, API, CLI | -| Cloudflare | 29 | 2 | 0 | 5 | Official | UI, API, CLI | +| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI | +| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI | +| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI | +| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI | +| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI | +| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI | +| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI | +| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI | +| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI | | IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI | -| MongoDB Atlas | 10 | 3 | 0 | 8 | Official | UI, API, CLI | +| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI | | LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI | | Image | N/A | N/A | N/A | N/A | Official | CLI, API | -| Google Workspace | 1 | 1 | 0 | 1 | Official | CLI | -| OpenStack | 27 | 4 | 0 | 8 | Official | UI, API, CLI | -| Vercel | 30 | 6 | 0 | 5 | Official | CLI | -| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | +| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI | +| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI | +| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI | +| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI | +| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI | +| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI | +| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI | > [!Note] > The numbers in the table are updated periodically. @@ -144,11 +167,13 @@ Prowler App offers flexible installation methods tailored to various environment ### Docker Compose -**Requirements** +#### Requirements -* `Docker Compose` installed: https://docs.docker.com/compose/install/. +- `Docker Compose` installed: https://docs.docker.com/compose/install/. -**Commands** +#### Commands + +_macOS/Linux:_ ``` console VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name) @@ -158,6 +183,16 @@ curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${V docker compose up -d ``` +_Windows PowerShell:_ + +``` powershell +$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" +# Environment variables can be customized in the .env file. Using default values in production environments is not recommended. +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" +docker compose up -d +``` + > [!WARNING] > 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API. @@ -173,20 +208,20 @@ You can find more information in the [Troubleshooting](./docs/troubleshooting.md ### From GitHub -**Requirements** +#### Requirements -* `git` installed. -* `poetry` v2 installed: [poetry installation](https://python-poetry.org/docs/#installation). -* `pnpm` installed: [pnpm installation](https://pnpm.io/installation). -* `Docker Compose` installed: https://docs.docker.com/compose/install/. +- `git` installed. +- `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/). +- `pnpm` installed: [pnpm installation](https://pnpm.io/installation). +- `Docker Compose` installed: https://docs.docker.com/compose/install/. -**Commands to run the API** +#### Commands to run the API ``` console git clone https://github.com/prowler-cloud/prowler cd prowler/api -poetry install -eval $(poetry env activate) +uv sync +source .venv/bin/activate set -a source .env docker compose up postgres valkey -d @@ -194,41 +229,36 @@ cd src/backend python manage.py migrate --database admin gunicorn -c config/guniconf.py config.wsgi:application ``` -> [!IMPORTANT] -> As of Poetry v2.0.0, the `poetry shell` command has been deprecated. Use `poetry env activate` instead for environment activation. -> -> If your Poetry version is below v2.0.0, continue using `poetry shell` to activate your environment. -> For further guidance, refer to the Poetry Environment Activation Guide https://python-poetry.org/docs/managing-environments/#activating-the-environment. > After completing the setup, access the API documentation at http://localhost:8080/api/v1/docs. -**Commands to run the API Worker** +#### Commands to run the API Worker ``` console git clone https://github.com/prowler-cloud/prowler cd prowler/api -poetry install -eval $(poetry env activate) +uv sync +source .venv/bin/activate set -a source .env cd src/backend python -m celery -A config.celery worker -l info -E ``` -**Commands to run the API Scheduler** +#### Commands to run the API Scheduler ``` console git clone https://github.com/prowler-cloud/prowler cd prowler/api -poetry install -eval $(poetry env activate) +uv sync +source .venv/bin/activate set -a source .env cd src/backend python -m celery -A config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler ``` -**Commands to run the UI** +#### Commands to run the UI ``` console git clone https://github.com/prowler-cloud/prowler @@ -240,20 +270,13 @@ pnpm start > Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started. -**Pre-commit Hooks Setup** +#### Pre-commit Hooks Setup Some pre-commit hooks require tools installed on your system: 1. **Install [TruffleHog](https://github.com/trufflesecurity/trufflehog#install)** (secret scanning) — see the [official installation options](https://github.com/trufflesecurity/trufflehog#install). -2. **Install [Safety](https://github.com/pyupio/safety)** (dependency vulnerability checking): - - ```console - # Requires a Python environment (e.g. via pyenv) - pip install safety - ``` - -3. **Install [Hadolint](https://github.com/hadolint/hadolint#install)** (Dockerfile linting) — see the [official installation options](https://github.com/hadolint/hadolint#install). +2. **Install [Hadolint](https://github.com/hadolint/hadolint#install)** (Dockerfile linting) — see the [official installation options](https://github.com/hadolint/hadolint#install). ## Prowler CLI ### Pip package @@ -267,14 +290,14 @@ prowler -v ### Containers -**Available Versions of Prowler CLI** +#### Available Versions of Prowler CLI The following versions of Prowler CLI are available, depending on your requirements: - `latest`: Synchronizes with the `master` branch. Note that this version is not stable. - `v4-latest`: Synchronizes with the `v4` branch. Note that this version is not stable. - `v3-latest`: Synchronizes with the `v3` branch. Note that this version is not stable. -- `` (release): Stable releases corresponding to specific versions. You can find the complete list of releases [here](https://github.com/prowler-cloud/prowler/releases). +- `` (release): Stable releases corresponding to specific versions. See the [complete list of Prowler releases](https://github.com/prowler-cloud/prowler/releases). - `stable`: Always points to the latest release. - `v4-stable`: Always points to the latest release for v4. - `v3-stable`: Always points to the latest release for v3. @@ -289,23 +312,47 @@ The container images are available here: ### From GitHub -Python >=3.10, <3.13 is required with pip and Poetry: +Python >=3.10, <3.13 is required with [uv](https://docs.astral.sh/uv/): ``` console git clone https://github.com/prowler-cloud/prowler cd prowler -eval $(poetry env activate) -poetry install +uv sync +source .venv/bin/activate python prowler-cli.py -v ``` > [!IMPORTANT] > To clone Prowler on Windows, configure Git to support long file paths by running the following command: `git config core.longpaths true`. -> [!IMPORTANT] -> As of Poetry v2.0.0, the `poetry shell` command has been deprecated. Use `poetry env activate` instead for environment activation. -> -> If your Poetry version is below v2.0.0, continue using `poetry shell` to activate your environment. -> For further guidance, refer to the Poetry Environment Activation Guide https://python-poetry.org/docs/managing-environments/#activating-the-environment. +# 🛡️ GitHub Action + +The official **Prowler GitHub Action** runs Prowler scans in your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. Scans run on any [supported provider](https://docs.prowler.com/user-guide/providers/), with optional [`--push-to-cloud`](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings) to send findings to Prowler Cloud and optional SARIF upload so findings show up in the repo's **Security → Code scanning** tab and as inline PR annotations. + +```yaml +name: Prowler IaC Scan +on: + pull_request: + +permissions: + contents: read + security-events: write + actions: read + +jobs: + prowler: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: prowler-cloud/prowler@5.25 + with: + provider: iac + output-formats: sarif json-ocsf + upload-sarif: true + flags: --severity critical high +``` + +Full configuration, per-provider authentication, and SARIF examples: [Prowler GitHub Action tutorial](docs/user-guide/tutorials/prowler-app-github-action.mdx). Marketplace listing: [Prowler Security Scan](https://github.com/marketplace/actions/prowler-security-scan). # ✏️ High level architecture @@ -317,11 +364,14 @@ python prowler-cli.py -v - **Prowler SDK**: A Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities. - **Prowler MCP Server**: A Model Context Protocol server that provides AI tools for Lighthouse, the AI-powered security assistant. This is a critical dependency for Lighthouse functionality. -![Prowler App Architecture](docs/products/img/prowler-app-architecture.png) +![Prowler App Architecture](docs/images/products/prowler-app-architecture.png) + + + ## Prowler CLI -**Running Prowler** +### Running Prowler Prowler can be executed across various environments, offering flexibility to meet your needs. It can be run from: diff --git a/action.yml b/action.yml new file mode 100644 index 0000000000..2be0649402 --- /dev/null +++ b/action.yml @@ -0,0 +1,307 @@ +name: Prowler Security Scan +description: Run Prowler cloud security scanner using the official Docker image +branding: + icon: cloud + color: green + +inputs: + provider: + description: Cloud provider to scan (e.g. aws, azure, gcp, github, kubernetes, iac). See https://docs.prowler.com for supported providers. + required: true + image-tag: + description: > + Docker image tag for prowlercloud/prowler. + Default is "stable" (latest release). Available tags: + "stable" (latest release), "latest" (master branch, not stable), + "" (pinned release version). + See all tags at https://hub.docker.com/r/prowlercloud/prowler/tags + required: false + default: stable + output-formats: + description: Output format(s) for scan results (e.g. "json-ocsf", "sarif json-ocsf") + required: false + default: json-ocsf + push-to-cloud: + description: Push scan findings to Prowler Cloud. Requires the PROWLER_CLOUD_API_KEY environment variable. See https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-cli + required: false + default: "false" + flags: + description: 'Additional CLI flags passed to the Prowler scan (e.g. "--severity critical high --compliance cis_aws"). Values containing spaces can be quoted, e.g. "--resource-tag ''Environment=My Server''".' + required: false + default: "" + extra-env: + description: > + Space-, newline-, or comma-separated list of host environment variable NAMES to forward to the Prowler container + (e.g. "AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN" for AWS, + "GITHUB_PERSONAL_ACCESS_TOKEN" for GitHub, "CLOUDFLARE_API_TOKEN" for Cloudflare). + List names only; set the values via `env:` at the workflow or job level (typically from `secrets.*`). + See the README for per-provider examples. + required: false + default: "" + upload-sarif: + description: 'Upload SARIF results to GitHub Code Scanning (requires "sarif" in output-formats and both `security-events: write` and `actions: read` permissions)' + required: false + default: "false" + sarif-file: + description: Path to the SARIF file to upload (auto-detected from output/ if not set) + required: false + default: "" + sarif-category: + description: Category for the SARIF upload (used to distinguish multiple analyses) + required: false + default: prowler + fail-on-findings: + description: Fail the workflow step when Prowler detects findings (exit code 3). By default the action tolerates findings and succeeds. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + env: + INPUT_IMAGE_TAG: ${{ inputs.image-tag }} + INPUT_UPLOAD_SARIF: ${{ inputs.upload-sarif }} + INPUT_OUTPUT_FORMATS: ${{ inputs.output-formats }} + run: | + # Validate image tag format (alphanumeric, dots, hyphens, underscores only) + if [[ ! "$INPUT_IMAGE_TAG" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "::error::Invalid image-tag '${INPUT_IMAGE_TAG}'. Must contain only alphanumeric characters, dots, hyphens, and underscores." + exit 1 + fi + + # Warn if upload-sarif is enabled but sarif not in output-formats + if [ "$INPUT_UPLOAD_SARIF" = "true" ]; then + if [[ ! "$INPUT_OUTPUT_FORMATS" =~ (^|[[:space:]])sarif($|[[:space:]]) ]]; then + echo "::warning::upload-sarif is enabled but 'sarif' is not included in output-formats ('${INPUT_OUTPUT_FORMATS}'). SARIF upload will fail unless you add 'sarif' to output-formats." + fi + fi + + - name: Run Prowler scan + shell: bash + env: + INPUT_PROVIDER: ${{ inputs.provider }} + INPUT_IMAGE_TAG: ${{ inputs.image-tag }} + INPUT_OUTPUT_FORMATS: ${{ inputs.output-formats }} + INPUT_PUSH_TO_CLOUD: ${{ inputs.push-to-cloud }} + INPUT_FLAGS: ${{ inputs.flags }} + INPUT_EXTRA_ENV: ${{ inputs.extra-env }} + INPUT_FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} + run: | + set -e + + # Parse space-separated inputs with shlex so values with spaces can be quoted + # (e.g. `--resource-tag 'Environment=My Server'`). + mapfile -t OUTPUT_FORMATS < <(python3 -c 'import shlex, os; [print(t) for t in shlex.split(os.environ.get("INPUT_OUTPUT_FORMATS", ""))]') + mapfile -t EXTRA_FLAGS < <(python3 -c 'import shlex, os; [print(t) for t in shlex.split(os.environ.get("INPUT_FLAGS", ""))]') + mapfile -t EXTRA_ENV_NAMES < <(python3 -c 'import shlex, os; [print(t) for t in shlex.split(os.environ.get("INPUT_EXTRA_ENV", "").replace(",", " "))]') + + env_args=() + for var in "${EXTRA_ENV_NAMES[@]}"; do + [ -z "$var" ] && continue + if [[ ! "$var" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "::error::Invalid env var name '${var}' in extra-env. Names must match ^[A-Za-z_][A-Za-z0-9_]*$." + exit 1 + fi + env_args+=("-e" "$var") + done + + push_args=() + if [ "$INPUT_PUSH_TO_CLOUD" = "true" ]; then + push_args=("--push-to-cloud") + env_args+=("-e" "PROWLER_CLOUD_API_KEY") + fi + + mkdir -p "$GITHUB_WORKSPACE/output" + chmod 777 "$GITHUB_WORKSPACE/output" + + set +e + docker run --rm \ + "${env_args[@]}" \ + -v "$GITHUB_WORKSPACE:/home/prowler/workspace" \ + -v "$GITHUB_WORKSPACE/output:/home/prowler/workspace/output" \ + -w /home/prowler/workspace \ + "prowlercloud/prowler:${INPUT_IMAGE_TAG}" \ + "$INPUT_PROVIDER" \ + --output-formats "${OUTPUT_FORMATS[@]}" \ + "${push_args[@]}" \ + "${EXTRA_FLAGS[@]}" + exit_code=$? + set -e + + # Exit code 3 = findings detected + if [ "$exit_code" -eq 3 ] && [ "$INPUT_FAIL_ON_FINDINGS" != "true" ]; then + echo "::notice::Prowler detected findings (exit code 3). Set fail-on-findings to 'true' to fail the workflow on findings." + exit 0 + fi + exit $exit_code + + - name: Upload scan results + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: prowler-${{ inputs.provider }} + path: output/ + retention-days: 30 + if-no-files-found: warn + + - name: Find SARIF file + if: always() && inputs.upload-sarif == 'true' + id: find-sarif + shell: bash + env: + INPUT_SARIF_FILE: ${{ inputs.sarif-file }} + run: | + if [ -n "$INPUT_SARIF_FILE" ]; then + echo "sarif_path=$INPUT_SARIF_FILE" >> "$GITHUB_OUTPUT" + else + sarif_file=$(find output/ -name '*.sarif' -type f | head -1) + if [ -z "$sarif_file" ]; then + echo "::warning::No .sarif file found in output/. Ensure 'sarif' is included in output-formats." + echo "sarif_path=" >> "$GITHUB_OUTPUT" + else + echo "sarif_path=$sarif_file" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Upload SARIF to GitHub Code Scanning + if: always() && inputs.upload-sarif == 'true' && steps.find-sarif.outputs.sarif_path != '' + uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + with: + sarif_file: ${{ steps.find-sarif.outputs.sarif_path }} + category: ${{ inputs.sarif-category }} + + - name: Write scan summary + if: always() + shell: bash + env: + INPUT_PROVIDER: ${{ inputs.provider }} + INPUT_UPLOAD_SARIF: ${{ inputs.upload-sarif }} + INPUT_PUSH_TO_CLOUD: ${{ inputs.push-to-cloud }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + BRANCH: ${{ github.head_ref || github.ref_name }} + GH_TOKEN: ${{ github.token }} + run: | + set +e + + # Build a link to the scan step in the workflow logs. Requires `actions: read` + # on the caller's GITHUB_TOKEN; silently skips the link if unavailable. + scan_step_url="" + if [ -n "${GH_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then + job_info=$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT:-1}/jobs" \ + --jq ".jobs[] | select(.runner_name == \"${RUNNER_NAME:-}\")" 2>/dev/null) + if [ -n "$job_info" ]; then + job_id=$(jq -r '.id // empty' <<<"$job_info") + step_number=$(jq -r '[.steps[]? | select((.name // "") | test("Run Prowler scan"; "i")) | .number] | first // empty' <<<"$job_info") + if [ -z "$step_number" ]; then + step_number=$(jq -r '[.steps[]? | select(.status == "in_progress") | .number] | first // empty' <<<"$job_info") + fi + if [ -n "$job_id" ] && [ -n "$step_number" ]; then + scan_step_url="${REPO_URL}/actions/runs/${GITHUB_RUN_ID}/job/${job_id}#step:${step_number}:1" + elif [ -n "$job_id" ]; then + scan_step_url="${REPO_URL}/actions/runs/${GITHUB_RUN_ID}/job/${job_id}" + fi + fi + fi + + # Map provider code to a properly-cased display name. + case "$INPUT_PROVIDER" in + alibabacloud) provider_name="Alibaba Cloud" ;; + aws) provider_name="AWS" ;; + azure) provider_name="Azure" ;; + cloudflare) provider_name="Cloudflare" ;; + gcp) provider_name="GCP" ;; + github) provider_name="GitHub" ;; + googleworkspace) provider_name="Google Workspace" ;; + iac) provider_name="IaC" ;; + image) provider_name="Container Image" ;; + kubernetes) provider_name="Kubernetes" ;; + llm) provider_name="LLM" ;; + m365) provider_name="Microsoft 365" ;; + mongodbatlas) provider_name="MongoDB Atlas" ;; + nhn) provider_name="NHN" ;; + openstack) provider_name="OpenStack" ;; + oraclecloud) provider_name="Oracle Cloud" ;; + vercel) provider_name="Vercel" ;; + *) provider_name="${INPUT_PROVIDER^}" ;; + esac + + ocsf_file=$(find output/ -name '*.ocsf.json' -type f 2>/dev/null | head -1) + + { + echo "## Prowler ${provider_name} Scan Summary" + echo "" + + counts="" + if [ -n "$ocsf_file" ] && [ -s "$ocsf_file" ]; then + counts=$(jq -r '[ + length, + ([.[] | select(.status_code == "FAIL")] | length), + ([.[] | select(.status_code == "PASS")] | length), + ([.[] | select(.status_code == "MUTED")] | length), + ([.[] | select(.status_code == "FAIL" and .severity == "Critical")] | length), + ([.[] | select(.status_code == "FAIL" and .severity == "High")] | length), + ([.[] | select(.status_code == "FAIL" and .severity == "Medium")] | length), + ([.[] | select(.status_code == "FAIL" and .severity == "Low")] | length), + ([.[] | select(.status_code == "FAIL" and .severity == "Informational")] | length) + ] | @tsv' "$ocsf_file" 2>/dev/null) + fi + + if [ -n "$counts" ]; then + read -r total fail pass muted critical high medium low info <<<"$counts" + + line="**${fail:-0} failing** · ${pass:-0} passing" + [ "${muted:-0}" -gt 0 ] && line="${line} · ${muted} muted" + echo "${line} — ${total:-0} checks total" + echo "" + echo "| Severity | Failing |" + echo "|----------|---------|" + echo "| ‼️ Critical | ${critical:-0} |" + echo "| 🔴 High | ${high:-0} |" + echo "| 🟠 Medium | ${medium:-0} |" + echo "| 🔵 Low | ${low:-0} |" + echo "| ⚪ Informational | ${info:-0} |" + echo "" + else + echo "_No findings report was produced. Check the scan logs above._" + echo "" + fi + + if [ -n "$scan_step_url" ]; then + echo "**Scan logs:** [view in workflow run](${scan_step_url})" + echo "" + fi + + echo "**Get the full report:** [\`prowler-${INPUT_PROVIDER}\` artifact](${RUN_URL}#artifacts)" + + if [ "$INPUT_UPLOAD_SARIF" = "true" ] && [ -n "$BRANCH" ]; then + encoded_branch=$(jq -nr --arg b "$BRANCH" '$b|@uri') + echo "" + echo "**See results in GitHub Code Security:** [open alerts on \`${BRANCH}\`](${REPO_URL}/security/code-scanning?query=is%3Aopen+branch%3A${encoded_branch})" + fi + + if [ "$INPUT_PUSH_TO_CLOUD" != "true" ]; then + echo "" + echo "---" + echo "" + echo "### Scale ${provider_name} security with Prowler Cloud ☁️" + echo "" + echo "Send this scan's findings to **[Prowler Cloud](https://cloud.prowler.com)** and get:" + echo "" + echo "- **Unified findings** across every cloud, SaaS provider (M365, Google Workspace, GitHub, MongoDB Atlas), IaC repo, Kubernetes cluster, and container image" + echo "- **Posture over time** with alerts, and notifications" + echo "- **Prowler Lighthouse AI**: agentic assistant that triages findings, explains root cause and helps with remediation" + echo "- **50+ Compliance frameworks** mapped automatically" + echo "- **Enterprise-ready platform**: SOC 2 Type 2, SSO/SAML, AWS Security Hub, S3 and Jira integrations" + echo "" + echo "**Get started in 3 steps:**" + echo "1. Create an account at [cloud.prowler.com](https://cloud.prowler.com)" + echo "2. Generate a Prowler Cloud API key ([docs](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-cli))" + echo "3. Add \`PROWLER_CLOUD_API_KEY\` to your GitHub secrets and set \`push-to-cloud: true\` on this action" + echo "" + echo "See [prowler.com/pricing](https://prowler.com/pricing) for plan details." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/api/.env.example b/api/.env.example index 0be5388790..f97d868890 100644 --- a/api/.env.example +++ b/api/.env.example @@ -24,6 +24,9 @@ DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute # Decide whether to allow Django manage database table partitions DJANGO_MANAGE_DB_PARTITIONS=[True|False] DJANGO_CELERY_DEADLOCK_ATTEMPTS=5 +# Optional: bound Celery's prefork pool size. Unset → Celery uses os.cpu_count(). +# Useful on Kubernetes nodes with many CPUs where unbounded prefork balloons memory. +# DJANGO_CELERY_WORKER_CONCURRENCY=4 DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= diff --git a/api/AGENTS.md b/api/AGENTS.md index 2bdee1b28c..0483e55c04 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -10,7 +10,7 @@ > - [`jsonapi`](../skills/jsonapi/SKILL.md) - Strict JSON:API v1.1 spec compliance > - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns -### Auto-invoke Skills +## Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: @@ -81,7 +81,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: ## DECISION TREES ### Serializer Selection -``` +```text Read → Serializer Create → CreateSerializer Update → UpdateSerializer @@ -89,7 +89,7 @@ Nested read → IncludeSerializer ``` ### Task vs View -``` +```text < 100ms → View > 100ms or external API → Celery task Needs retry → Celery task @@ -105,7 +105,7 @@ Django 5.1.x | DRF 3.15.x | djangorestframework-jsonapi 7.x | Celery 5.4.x | Pos ## PROJECT STRUCTURE -``` +```text api/src/backend/ ├── api/ # Main Django app │ ├── v1/ # API version 1 (views, serializers, urls) @@ -124,24 +124,24 @@ api/src/backend/ ```bash # Development -poetry run python src/backend/manage.py runserver -poetry run celery -A config.celery worker -l INFO +uv run python src/backend/manage.py runserver +uv run celery -A config.celery worker -l INFO # Database -poetry run python src/backend/manage.py makemigrations -poetry run python src/backend/manage.py migrate +uv run python src/backend/manage.py makemigrations +uv run python src/backend/manage.py migrate # Testing & Linting -poetry run pytest -x --tb=short -poetry run make lint +uv run pytest -x --tb=short +uv run make lint ``` --- ## QA CHECKLIST -- [ ] `poetry run pytest` passes -- [ ] `poetry run make lint` passes +- [ ] `uv run pytest` passes +- [ ] `uv run make lint` passes - [ ] Migrations created if models changed - [ ] New endpoints have `@extend_schema` decorators - [ ] RLS properly applied for tenant data diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 43b836d4da..f7c6d37b28 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,22 +2,322 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.24.0] (Prowler UNRELEASED) - -### 🚀 Added - -- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469) -- Filter RBAC role lookup by `tenant_id` to prevent cross-tenant privilege leak [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) -- `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420) -- `Vercel` provider support [(#10190)](https://github.com/prowler-cloud/prowler/pull/10190) +## [1.33.0] (Prowler UNRELEASED) ### 🔄 Changed +- Attack Paths: AWS Neptune is now supported as a persistent sink database, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`), Cartography's (bumped to 0.138.1) per-scan ingest database stays on Neo4j [(#11524)](https://github.com/prowler-cloud/prowler/pull/11524) + +--- + +## [1.32.2] (Prowler UNRELEASED) + +### 🐞 Fixed + +- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696) + +--- + +## [1.32.1] (Prowler v5.31.1) + +### 🐞 Fixed + +- API key auth no longer mutates `TenantAPIKey.objects` during admin DB lookups [(#11686)](https://github.com/prowler-cloud/prowler/pull/11686) + +--- + +## [1.32.0] (Prowler v5.31.0) + +### 🚀 Added + +- Provider group filters for API endpoints that support cloud provider filtering, including exact and `__in` variants [(#11573)](https://github.com/prowler-cloud/prowler/pull/11573) +- Provider filters for `GET /api/v1/compliance-overviews`, `/metadata`, and `/requirements`, using latest completed scans per matching provider [(#11587)](https://github.com/prowler-cloud/prowler/pull/11587) +- Server-Sent Events (SSE) infrastructure for the API: a base viewset, a tenant-aware channel manager, and channel-name helpers backed by `django-eventstream` over Valkey Pub/Sub and served through the Gunicorn ASGI worker, so feature endpoints can stream events to clients over a single long-lived connection [(#11556)](https://github.com/prowler-cloud/prowler/pull/11556) +- `DJANGO_CELERY_WORKER_CONCURRENCY` to configure Celery workers concurrency. Unset for default behaviour [(#11075)](https://github.com/prowler-cloud/prowler/pull/11075) + +### 🔄 Changed + +- Gunicorn worker timeout raised from the 30s default to 120s, so long-running requests are no longer killed prematurely [(#11631)](https://github.com/prowler-cloud/prowler/pull/11631) +- Sentry now drops ASGI's `RequestAborted` errors from health-check probe disconnects on `/health/live` [(#11632)](https://github.com/prowler-cloud/prowler/pull/11632) +- Gunicorn keep-alive timeout now exceeds the load balancer idle timeout, stopping 502s from reused connections [(#11647)](https://github.com/prowler-cloud/prowler/pull/11647) +- API runs under the Uvicorn worker so keep-alive outlives the load balancer idle timeout, fixing Gunicorn's intermittent 502s [(#11663)](https://github.com/prowler-cloud/prowler/pull/11663) +- SAML logins no longer wipe a user's roles when the IdP does not send the `userType` attribute; existing roles are kept, and when `userType` names a role that does not exist it is now created with read-only access (visibility over all providers, no management permissions) instead of no permissions at all [(#11520)](https://github.com/prowler-cloud/prowler/pull/11520) + +### 🐞 Fixed + +- Database connections no longer leak under the ASGI worker, which previously exhausted the read replica's connection slots and caused 500s on read endpoints [(#11640)](https://github.com/prowler-cloud/prowler/pull/11640) + +### 🔐 Security + +- `aiohttp` to 3.14.0 and `idna` to 3.15, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image to `python:3.12.13-slim-bookworm` and `trivy` to 0.71.0, patching OS and Go module CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- `trivy` binary bumped to 0.71.0 patching embedded `golang.org/x/crypto`, `golang.org/x/net`, and Go `stdlib` CVEs [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [1.31.3] (Prowler v5.30.3) + +### 🔐 Security + +- SAML logins now link to an existing account only when the asserted email domain matches the ACS endpoint and the user is already a member of that domain's tenant, fixing a cross-tenant account takeover [(GHSA-h8m9-jgf8-vwvp)](https://github.com/prowler-cloud/prowler/security/advisories/GHSA-h8m9-jgf8-vwvp) + +--- + +## [1.31.2] (Prowler v5.30.2) + +### 🔄 Changed + +- `scan-compliance-overviews` task now streams the findings aggregation and the requirement-row writes so it runs faster and its peak memory no longer grows with the number of regions and frameworks [(#11591)](https://github.com/prowler-cloud/prowler/pull/11591) + +--- + +## [1.31.1] (Prowler v5.30.1) + +### 🐞 Fixed + +- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557) +- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) + +--- + +## [1.31.0] (Prowler v5.30.0) + +### 🚀 Added + +- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494) +- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) + +### 🔄 Changed + +- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) + +### 🐞 Fixed + +- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) +- Compliance catalog now warms in background during startup. `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the API timeout [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530) + +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + +--- + +## [1.30.3] (Prowler v5.29.3) + +### 🐞 Fixed + +- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491) + +--- + +## [1.30.1] (Prowler v5.29.1) + +### 🐞 Fixed + +- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420) +- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421) + +--- + +## [1.30.0] (Prowler v5.29.0) + +### 🔄 Changed + +- Scan finding ingestion: bulk-resolve `Resource`/`ResourceTag` rows, replace per-mapping `SELECT FOR UPDATE` with deferred `ResourceTagMapping.bulk_create(ignore_conflicts=True)`, wrap each micro-batch in a single `rls_transaction`, and raise `SCAN_DB_BATCH_SIZE` to 1000 [(#11249)](https://github.com/prowler-cloud/prowler/pull/11249) +- Faster `GET /api/v1/finding-groups/latest` aggregation on tenants where one recent scan holds most findings [(#11380)](https://github.com/prowler-cloud/prowler/pull/11380) + +--- + +## [1.29.1] (Prowler v5.28.1) + +### 🐞 Fixed + +- `finding-groups` slow response with finding-level filters such as `region`; check title and description are now read from the daily summaries, which drops sorting by `check_title` [(#11326)](https://github.com/prowler-cloud/prowler/pull/11326) + +--- + +## [1.29.0] (Prowler v5.28.0) + +### 🚀 Added + +- `okta` provider support [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184) +- `resource.metadata` attribute included in `/api/v1/findings?include=resources` [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187) + +--- + +## [1.28.0] (Prowler v5.27.0) + +### 🚀 Added + +- GIN index on `findings(categories, resource_services, resource_regions, resource_types)` to speed up `/api/v1/finding-groups` array filters [(#11001)](https://github.com/prowler-cloud/prowler/pull/11001) +- `GET /health/live` and `GET /health/ready` Kubernetes-style probe endpoints following the IETF Health Check Response Format (`application/health+json`). Readiness verifies PostgreSQL, Valkey and Neo4j connectivity and returns 503 with per-dependency detail when any is unreachable [(#11200)](https://github.com/prowler-cloud/prowler/pull/11200) + +### 🔄 Changed + +- Replace `poetry` with `uv` as package manager [(#10775)](https://github.com/prowler-cloud/prowler/pull/10775) +- Remove orphaned `gin_resources_search_idx` declaration from `Resource.Meta.indexes` (DB index dropped in `0072_drop_unused_indexes`) [(#11001)](https://github.com/prowler-cloud/prowler/pull/11001) +- PDF compliance reports cap detail tables at 100 failed findings per check (configurable via `DJANGO_PDF_MAX_FINDINGS_PER_CHECK`) to bound worker memory on large scans [(#11160)](https://github.com/prowler-cloud/prowler/pull/11160) + +### 🐞 Fixed + +- `perform_scan_task` and `perform_scheduled_scan_task` now short-circuit with a warning and `return None` when the target provider no longer exists, instead of letting `handle_provider_deletion` raise `ProviderDeletedException`. `perform_scheduled_scan_task` also removes any orphan `PeriodicTask` it finds so beat stops re-firing scans for deleted providers. Prevents queued messages for deleted providers from being recorded as `FAILURE` [(#11185)](https://github.com/prowler-cloud/prowler/pull/11185) +- Attack Paths: `BEDROCK-001` and `BEDROCK-002` now target roles trusting `bedrock-agentcore.amazonaws.com` instead of `bedrock.amazonaws.com`, eliminating false positives against regular Bedrock service roles (Agents, Knowledge Bases, model invocation) [(#11141)](https://github.com/prowler-cloud/prowler/pull/11141) + +--- + +## [1.27.1] (Prowler v5.26.1) + +### 🐞 Fixed + +- `POST /api/v1/scans` was intermittently failing with `Scan matching query does not exist` in the `scan-perform` worker; the Celery task is now published via `transaction.on_commit` so the worker cannot read the Scan before the dispatch-wide transaction commits [(#11122)](https://github.com/prowler-cloud/prowler/pull/11122) + +--- + +## [1.27.0] (Prowler v5.26.0) + +### 🚀 Added + +- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929) + +### 🔄 Changed + +- ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982) + +### 🔐 Security + +- `trivy` binary from 0.69.2 to 0.70.0 and `cryptography` from 46.0.6 to 46.0.7 (transitive via prowler SDK) in the API image for CVE-2026-33186 and CVE-2026-39892 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978) + +--- + +## [1.26.1] (Prowler v5.25.1) + +### 🐞 Fixed + +- Attack Paths: AWS scans no longer fail when enabled regions cannot be retrieved, and scans stuck in `scheduled` state are now cleaned up after the stale threshold [(#10917)](https://github.com/prowler-cloud/prowler/pull/10917) +- Scan report and compliance downloads now redirect to a presigned S3 URL instead of streaming through the API worker, preventing gunicorn timeouts on large files [(#10927)](https://github.com/prowler-cloud/prowler/pull/10927) + +--- + +## [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}/` [(#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) + +--- + +## [1.25.4] (Prowler v5.24.4) + +### 🚀 Added + +- `DJANGO_SENTRY_TRACES_SAMPLE_RATE` env var (default `0.02`) enables Sentry performance tracing for the API [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873) + +### 🔄 Changed + +- 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) + +### 🚀 Added + +- `/overviews/findings`, `/overviews/findings-severity` and `/overviews/services` now reflect newly-muted findings without waiting for the next scan. The post-mute `reaggregate-all-finding-group-summaries` task was extended to re-run the same per-scan pipeline that scan completion runs (`ScanSummary`, `DailySeveritySummary`, `FindingGroupDailySummary`) on the latest scan of every `(provider, day)` pair, keeping the pre-aggregated tables in sync with `Finding.muted` updates [(#10827)](https://github.com/prowler-cloud/prowler/pull/10827) + +### 🐞 Fixed + +- Finding groups aggregated `status` now treats muted findings as resolved: a group is `FAIL` only while at least one non-muted FAIL remains, otherwise it is `PASS` (including fully-muted groups). The `filter[status]` filter and the `sort=status` ordering share the same semantics, keeping `status` consistent with `fail_count` and the orthogonal `muted` flag [(#10825)](https://github.com/prowler-cloud/prowler/pull/10825) +- `aggregate_findings` is now idempotent: it deletes the scan's existing `ScanSummary` rows before `bulk_create`, so re-runs (such as the post-mute reaggregation pipeline) no longer violate the `unique_scan_summary` constraint and no longer abort the downstream `DailySeveritySummary` / `FindingGroupDailySummary` recomputation for the affected scan [(#10827)](https://github.com/prowler-cloud/prowler/pull/10827) +- Attack Paths: Findings on AWS were silently dropped during the Neo4j merge for resources whose Cartography node is keyed by a short identifier (e.g. EC2 instances) rather than the full ARN [(#10839)](https://github.com/prowler-cloud/prowler/pull/10839) + +--- + +## [1.25.2] (Prowler v5.24.2) + +### 🔄 Changed + +- Finding groups `/resources` endpoints now materialize the filtered finding IDs into a Python list before filtering `ResourceFindingMapping`, so PostgreSQL switches from a Merge Semi Join that read hundreds of thousands of RFM index entries to a Nested Loop Index Scan over `finding_id`. The `has_mappings.exists()` pre-check is removed, and a request-scoped cache deduplicates the finding-id round-trip across the helpers that build different RFM querysets [(#10816)](https://github.com/prowler-cloud/prowler/pull/10816) + +### 🐞 Fixed + +- `/finding-groups/latest//resources` now selects the latest completed scan per provider by `-completed_at` (then `-inserted_at`) instead of `-inserted_at`, matching the `/finding-groups/latest` summary path and the daily-summary upsert so overlapping scans no longer produce diverging `delta`/`new_count` between the two endpoints [(#10802)](https://github.com/prowler-cloud/prowler/pull/10802) + + +## [1.25.1] (Prowler v5.24.1) + +### 🔄 Changed + +- Attack Paths: Restore `SYNC_BATCH_SIZE` and `FINDINGS_BATCH_SIZE` defaults to 1000, upgrade Cartography to 0.135.0, enable Celery queue priority for cleanup task, rewrite Finding insertion, remove AWS graph cleanup and add timing logs [(#10729)](https://github.com/prowler-cloud/prowler/pull/10729) + +### 🐞 Fixed + +- Finding group resources endpoints now include findings without associated resources (orphaned IaC findings) as simulated resource rows, and return one row per finding when multiple findings share a resource [(#10708)](https://github.com/prowler-cloud/prowler/pull/10708) +- Attack Paths: Missing `tenant_id` filter while getting related findings after scan completes [(#10722)](https://github.com/prowler-cloud/prowler/pull/10722) +- Finding group counters `pass_count`, `fail_count` and `manual_count` now exclude muted findings [(#10753)](https://github.com/prowler-cloud/prowler/pull/10753) +- Silent data loss in `ResourceFindingMapping` bulk insert that left findings orphaned when `INSERT ... ON CONFLICT DO NOTHING` dropped rows without raising; added explicit `unique_fields` [(#10724)](https://github.com/prowler-cloud/prowler/pull/10724) +- `DELETE /tenants/{tenant_pk}/memberships/{id}` now deletes the expelled user's account when the removed membership was their last one, and blacklists every outstanding refresh token for that user so their existing sessions can no longer mint new access tokens [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) + +--- + +## [1.25.0] (Prowler v5.24.0) + +### 🔄 Changed + +- Bump Poetry to `2.3.4` in Dockerfile and pre-commit hooks. Regenerate `api/poetry.lock` [(#10681)](https://github.com/prowler-cloud/prowler/pull/10681) +- Attack Paths: Remove dead `cleanup_findings` no-op and its supporting `prowler_finding_lastupdated` index [(#10684)](https://github.com/prowler-cloud/prowler/pull/10684) + +### 🐞 Fixed + +- Worker-beat race condition on cold start: replaced `sleep 15` with API service healthcheck dependency (Docker Compose) and init containers (Helm), aligned Gunicorn default port to `8080` [(#10603)](https://github.com/prowler-cloud/prowler/pull/10603) +- API container startup crash on Linux due to root-owned bind-mount preventing JWT key generation [(#10646)](https://github.com/prowler-cloud/prowler/pull/10646) + +### 🔐 Security + +- `pytest` from 8.2.2 to 9.0.3 to fix CVE-2025-71176 [(#10678)](https://github.com/prowler-cloud/prowler/pull/10678) + +--- + +## [1.24.0] (Prowler v5.23.0) + +### 🚀 Added + +- RBAC role lookup filtered by `tenant_id` to prevent cross-tenant privilege leak [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) +- `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420) +- `Vercel` provider support [(#10190)](https://github.com/prowler-cloud/prowler/pull/10190) +- Finding groups list and latest endpoints support `sort=delta`, ordering by `new_count` then `changed_count` so groups with the most new findings rank highest [(#10606)](https://github.com/prowler-cloud/prowler/pull/10606) +- Finding group resources endpoints (`/finding-groups/{check_id}/resources` and `/finding-groups/latest/{check_id}/resources`) now expose `finding_id` per row, pointing to the most recent matching Finding for each resource. UUIDv7 ordering guarantees `Max(finding__id)` resolves to the latest snapshot [(#10630)](https://github.com/prowler-cloud/prowler/pull/10630) +- Handle CIS and CISA SCuBA compliance framework from google workspace [(#10629)](https://github.com/prowler-cloud/prowler/pull/10629) +- Sort support for all finding group counter fields: `pass_muted_count`, `fail_muted_count`, `manual_muted_count`, and all `new_*`/`changed_*` status-mute breakdown counters [(#10655)](https://github.com/prowler-cloud/prowler/pull/10655) + +### 🔄 Changed + +- Finding groups list/latest/resources now expose `status` ∈ `{FAIL, PASS, MANUAL}` and `muted: bool` as orthogonal fields. The aggregated `status` reflects the underlying check outcome regardless of mute state, and `muted=true` signals that every finding in the group/resource is muted. New `manual_count` is exposed alongside `pass_count`/`fail_count`, plus `pass_muted_count`/`fail_muted_count`/`manual_muted_count` siblings so clients can isolate the muted half of each status. The `new_*`/`changed_*` deltas are now broken down by status and mute state via 12 new counters (`new_fail_count`, `new_fail_muted_count`, `new_pass_count`, `new_pass_muted_count`, `new_manual_count`, `new_manual_muted_count` and the matching `changed_*` set). New `filter[muted]=true|false` and `sort=status` (FAIL > PASS > MANUAL) / `sort=muted` are supported. `filter[status]=MUTED` is no longer accepted [(#10630)](https://github.com/prowler-cloud/prowler/pull/10630) - Attack Paths: Periodic cleanup of stale scans with dead-worker detection via Celery inspect, marking orphaned `EXECUTING` scans as `FAILED` and recovering `graph_data_ready` [(#10387)](https://github.com/prowler-cloud/prowler/pull/10387) - Attack Paths: Replace `_provider_id` property with `_Provider_{uuid}` label for provider isolation, add regex-based label injection for custom queries [(#10402)](https://github.com/prowler-cloud/prowler/pull/10402) ### 🐞 Fixed +- `reaggregate_all_finding_group_summaries_task` now refreshes finding group daily summaries for every `(provider, day)` combination instead of only the latest scan per provider, matching the unbounded scope of `mute_historical_findings_task`. Mute rule operations no longer leave older daily summaries drifting from the underlying muted findings [(#10630)](https://github.com/prowler-cloud/prowler/pull/10630) - Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428) - Populate compliance data inside `check_metadata` for findings, which was always returned as `null` [(#10449)](https://github.com/prowler-cloud/prowler/pull/10449) - 403 error for admin users listing tenants due to roles query not using the admin database connection [(#10460)](https://github.com/prowler-cloud/prowler/pull/10460) @@ -28,10 +328,14 @@ All notable changes to the **Prowler API** are documented in this file. - Membership `post_delete` signal using raw FK ids to avoid `DoesNotExist` during cascade deletions [(#10497)](https://github.com/prowler-cloud/prowler/pull/10497) - Finding group resources endpoints returning false 404 when filters match no results, and `sort` parameter being ignored [(#10510)](https://github.com/prowler-cloud/prowler/pull/10510) - Jira integration failing with `JiraInvalidIssueTypeError` on non-English Jira instances due to hardcoded `"Task"` issue type; now dynamically fetches available issue types per project [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) +- Finding group `first_seen_at` now reflects when a new finding appeared in the scan instead of the oldest carry-forward date across all unchanged findings [(#10595)](https://github.com/prowler-cloud/prowler/pull/10595) +- Attack Paths: Remove `clear_cache` call from read-only query endpoints; cache clearing belongs to the scan/ingestion flow, not API queries [(#10586)](https://github.com/prowler-cloud/prowler/pull/10586) ### 🔐 Security - Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469) +- `authlib` bumped from 1.6.6 to 1.6.9 to fix CVE-2026-28802 (JWT `alg: none` validation bypass) [(#10579)](https://github.com/prowler-cloud/prowler/pull/10579) +- `aiohttp` bumped from 3.13.3 to 3.13.5 to fix CVE-2026-34520 (the C parser accepted null bytes and control characters in response headers) [(#10538)](https://github.com/prowler-cloud/prowler/pull/10538) --- diff --git a/api/Dockerfile b/api/Dockerfile index ffa12c6f88..9da0ffafed 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,16 +1,20 @@ -FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build LABEL maintainer="https://github.com/prowler-cloud/api" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} -ARG TRIVY_VERSION=0.69.2 +ARG TRIVY_VERSION=0.71.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 \ + git \ libicu72 \ gcc \ g++ \ @@ -22,6 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libtool \ libxslt1-dev \ python3-dev \ + git \ && rm -rf /var/lib/apt/lists/* # Install PowerShell @@ -57,6 +62,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 @@ -68,21 +89,38 @@ WORKDIR /home/prowler # Ensure output directory exists RUN mkdir -p /tmp/prowler_api_output -COPY pyproject.toml ./ +COPY --chown=prowler:prowler pyproject.toml uv.lock ./ RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir poetry + pip install --no-cache-dir uv==0.11.14 ENV PATH="/home/prowler/.local/bin:$PATH" -# Add `--no-root` to avoid installing the current project as a package -RUN poetry install --no-root && \ - rm -rf ~/.cache/pip +# Add `--no-install-project` to avoid installing the current project as a package +RUN uv sync --locked --no-install-project && \ + rm -rf ~/.cache/uv -RUN poetry run python "$(poetry env info --path)/src/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py" +RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py -COPY src/backend/ ./backend/ -COPY docker-entrypoint.sh ./docker-entrypoint.sh +USER root + +# Remove build-only packages from the final image after Python dependencies are installed. +RUN apt-get purge -y --auto-remove \ + gcc \ + g++ \ + make \ + libxml2-dev \ + libxmlsec1-dev \ + pkg-config \ + libtool \ + libxslt1-dev \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +USER prowler + +COPY --chown=prowler:prowler src/backend/ ./backend/ +COPY --chown=prowler:prowler docker-entrypoint.sh ./docker-entrypoint.sh WORKDIR /home/prowler/backend diff --git a/api/README.md b/api/README.md index 60a2669718..c510479ab2 100644 --- a/api/README.md +++ b/api/README.md @@ -2,7 +2,7 @@ This repository contains the JSON API and Task Runner components for Prowler, which facilitate a complete backend that interacts with the Prowler SDK and is used by the Prowler UI. -# Components +## Components The Prowler API is composed of the following components: - The JSON API, which is an API built with Django Rest Framework. @@ -10,13 +10,13 @@ The Prowler API is composed of the following components: - The PostgreSQL database, which is used to store the data. - The Valkey database, which is an in-memory database which is used as a message broker for the Celery workers. -## Note about Valkey +### Note about Valkey [Valkey](https://valkey.io/) is an open source (BSD) high performance key/value datastore. Valkey exposes a Redis 7.2 compliant API. Any service that exposes the Redis API can be used with Prowler API. -# Modify environment variables +## Modify environment variables Under the root path of the project, you can find a file called `.env`. This file shows all the environment variables that the project uses. You should review it and set the values for the variables you want to change. @@ -24,23 +24,22 @@ If you don’t set `DJANGO_TOKEN_SIGNING_KEY` or `DJANGO_TOKEN_VERIFYING_KEY`, t **Important note**: Every Prowler version (or repository branches and tags) could have different variables set in its `.env` file. Please use the `.env` file that corresponds with each version. -## Local deployment -Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the Poetry interpreter, not before. Otherwise, variables will not be loaded properly. +### Local deployment +Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the virtual environment, not before. Otherwise, variables will not be loaded properly. To do this, you can run: ```console -poetry shell set -a source .env ``` -# 🚀 Production deployment -## Docker deployment +## 🚀 Production deployment +### Docker deployment This method requires `docker` and `docker compose`. -### Clone the repository +#### Clone the repository ```console # HTTPS @@ -51,13 +50,13 @@ git clone git@github.com:prowler-cloud/api.git ``` -### Build the base image +#### Build the base image ```console docker compose --profile prod build ``` -### Run the production service +#### Run the production service This command will start the Django production server and the Celery worker and also the Valkey and PostgreSQL databases. @@ -69,7 +68,7 @@ You can access the server in `http://localhost:8080`. > **NOTE:** notice how the port is different. When developing using docker, the port will be `8080` to prevent conflicts. -### View the Production Server Logs +#### View the Production Server Logs To view the logs for any component (e.g., Django, Celery worker), you can use the following command with a wildcard. This command will follow logs for any container that matches the specified pattern: @@ -78,7 +77,7 @@ docker logs -f $(docker ps --format "{{.Names}}" | grep 'api-') ## Local deployment -To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `poetry` and `docker compose` are installed. +To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `uv` and `docker compose` are installed. ### Clone the repository @@ -90,11 +89,10 @@ git clone https://github.com/prowler-cloud/api.git git clone git@github.com:prowler-cloud/api.git ``` -### Install all dependencies with Poetry +### Install all dependencies with uv ```console -poetry install -poetry shell +uv sync ``` ## Start the PostgreSQL Database and Valkey @@ -135,13 +133,13 @@ gunicorn -c config/guniconf.py config.wsgi:application > By default, the Gunicorn server will try to use as many workers as your machine can handle. You can manually change that in the `src/backend/config/guniconf.py` file. -# 🧪 Development guide +## 🧪 Development guide -## Local deployment +### Local deployment -To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `poetry` and `docker compose` are installed. +To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `uv` and `docker compose` are installed. -### Clone the repository +#### Clone the repository ```console # HTTPS @@ -152,7 +150,7 @@ git clone git@github.com:prowler-cloud/api.git ``` -### Start the PostgreSQL Database and Valkey +#### Start the PostgreSQL Database and Valkey The PostgreSQL database (version 16.3) and Valkey (version 7) are required for the development environment. To make development easier, we have provided a `docker-compose` file that will start these components for you. @@ -163,16 +161,15 @@ The PostgreSQL database (version 16.3) and Valkey (version 7) are required for t docker compose up postgres valkey -d ``` -### Install the Python dependencies +#### Install the Python dependencies -> You must have Poetry installed +> You must have uv installed ```console -poetry install -poetry shell +uv sync ``` -### Apply migrations +#### Apply migrations For migrations, you need to force the `admin` database router. Assuming you have the correct environment variables and Python virtual environment, run: @@ -181,7 +178,7 @@ cd src/backend python manage.py migrate --database admin ``` -### Run the Django development server +#### Run the Django development server ```console cd src/backend @@ -191,7 +188,7 @@ python manage.py runserver You can access the server in `http://localhost:8000`. All changes in the code will be automatically reloaded in the server. -### Run the Celery worker +#### Run the Celery worker ```console python -m celery -A config.celery worker -l info -E @@ -199,11 +196,47 @@ python -m celery -A config.celery worker -l info -E The Celery worker does not detect and reload changes in the code, so you need to restart it manually when you make changes. -## Docker deployment +### Makefile-Assisted Local Deployment + +This method is an additional local development workflow. It does not replace the manual local deployment or the Docker deployment described in this guide. + +PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. Additionally, this workflow creates a `tmux` session with panes for the API, worker, and PostgreSQL logs. + +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +From the repository root, run: + +```console +make dev +``` + +The API will be available at: + +```console +http://localhost:8080/api/v1 +``` + +Use these commands to manage the local stack: + +```console +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +This workflow does not start the UI. Start it separately from the `ui/` directory when needed. + +### Docker deployment This method requires `docker` and `docker compose`. -### Clone the repository +#### Clone the repository ```console # HTTPS @@ -214,13 +247,13 @@ git clone git@github.com:prowler-cloud/api.git ``` -### Build the base image +#### Build the base image ```console docker compose --profile dev build ``` -### Run the development service +#### Run the development service This command will start the Django development server and the Celery worker and also the Valkey and PostgreSQL databases. @@ -233,7 +266,7 @@ All changes in the code will be automatically reloaded in the server. > **NOTE:** notice how the port is different. When developing using docker, the port will be `8080` to prevent conflicts. -### View the development server logs +#### View the development server logs To view the logs for any component (e.g., Django, Celery worker), you can use the following command with a wildcard. This command will follow logs for any container that matches the specified pattern: @@ -241,41 +274,38 @@ To view the logs for any component (e.g., Django, Celery worker), you can use th docker logs -f $(docker ps --format "{{.Names}}" | grep 'api-') ``` -## Applying migrations +### Applying migrations For migrations, you need to force the `admin` database router. Assuming you have the correct environment variables and Python virtual environment, run: ```console -poetry shell cd src/backend -python manage.py migrate --database admin +uv run python manage.py migrate --database admin ``` -## Apply fixtures +### Apply fixtures Fixtures are used to populate the database with initial development data. ```console -poetry shell cd src/backend -python manage.py loaddata api/fixtures/0_dev_users.json --database admin +uv run python manage.py loaddata api/fixtures/0_dev_users.json --database admin ``` > The default credentials are `dev@prowler.com:Thisisapassword123@` or `dev2@prowler.com:Thisisapassword123@` -## Run tests +### Run tests Note that the tests will fail if you use the same `.env` file as the development environment. For best results, run in a new shell with no environment variables set. ```console -poetry shell cd src/backend -pytest +uv run pytest ``` -# Custom commands +## Custom commands Django provides a way to create custom commands that can be run from the command line. @@ -284,11 +314,10 @@ Django provides a way to create custom commands that can be run from the command To run a custom command, you need to be in the `prowler/api/src/backend` directory and run: ```console -poetry shell -python manage.py +uv run python manage.py ``` -## Generate dummy data +### Generate dummy data ```console python manage.py findings --tenant @@ -305,10 +334,10 @@ This command creates, for a given tenant, a provider, scan and a set of findings > > The last step is required to access the findings details, since the UI needs that to print all the information. -### Example +#### Example ```console -~/backend $ poetry run python manage.py findings --tenant +~/backend $ uv run python manage.py findings --tenant fffb1893-3fc7-4623-a5d9-fae47da1c528 --findings 25000 --re sources 1000 --batch 5000 --alias test-script diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index a980595af2..e077a3bfd6 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -5,9 +5,9 @@ apply_migrations() { echo "Applying database migrations..." # Fix Inconsistent migration history after adding sites app - poetry run python manage.py check_and_fix_socialaccount_sites_migration --database admin + uv run python manage.py check_and_fix_socialaccount_sites_migration --database admin - poetry run python manage.py migrate --database admin + uv run python manage.py migrate --database admin } apply_fixtures() { @@ -15,19 +15,25 @@ apply_fixtures() { for fixture in api/fixtures/dev/*.json; do if [ -f "$fixture" ]; then echo "Loading $fixture" - poetry run python manage.py loaddata "$fixture" --database admin + uv run python manage.py loaddata "$fixture" --database admin fi done } start_dev_server() { - echo "Starting the development server..." - poetry run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}" + echo "Starting the development server (Gunicorn ASGI, debug + reload)..." + # Same server/worker as prod (config.asgi via the native `asgi` worker), so + # SSE streams run on the event loop exactly as they do in production. DEBUG is + # on so guniconf's `reload = DEBUG` hot-reloads edited code (and flips + # `preload_app` off so reload actually takes). + export DJANGO_DEBUG="${DJANGO_DEBUG:-True}" + export DJANGO_BIND_ADDRESS="${DJANGO_BIND_ADDRESS:-0.0.0.0}" + exec uv run gunicorn -c config/guniconf.py config.asgi:application } start_prod_server() { echo "Starting the Gunicorn server..." - poetry run gunicorn -c config/guniconf.py config.wsgi:application + exec uv run gunicorn -c config/guniconf.py config.asgi:application } resolve_worker_hostname() { @@ -47,7 +53,7 @@ resolve_worker_hostname() { start_worker() { echo "Starting the worker..." - poetry run python -m celery -A config.celery worker \ + exec uv run python -m celery -A config.celery worker \ -n "$(resolve_worker_hostname)" \ -l "${DJANGO_LOGGING_LEVEL:-info}" \ -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ @@ -56,8 +62,7 @@ start_worker() { start_worker_beat() { echo "Starting the worker-beat..." - sleep 15 - poetry run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler + exec uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler } manage_db_partitions() { @@ -65,10 +70,19 @@ manage_db_partitions() { echo "Managing DB partitions..." # For now we skip the deletion of partitions until we define the data retention policy # --yes auto approves the operation without the need of an interactive terminal - poetry run python manage.py pgpartition --using admin --skip-delete --yes + uv run python manage.py pgpartition --using admin --skip-delete --yes fi } +# Identify this process to Postgres (application_name=:) so +# connections are attributable by component in pg_stat_activity. Web tiers +# report "api"; everything else uses the launch subcommand. +case "$1" in + prod|dev) DJANGO_APP_COMPONENT="api" ;; + *) DJANGO_APP_COMPONENT="$1" ;; +esac +export DJANGO_APP_COMPONENT + case "$1" in dev) apply_migrations diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md new file mode 100644 index 0000000000..af4eefc1ac --- /dev/null +++ b/api/docs/orphan-task-recovery.md @@ -0,0 +1,106 @@ +# Orphan Celery task recovery + +When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the +task it was running can be left non-terminal forever: the `TaskResult` stays +`STARTED` and nothing re-runs it. This page describes the mechanisms that detect and +recover allowlisted idempotent orphans so pending-task alerts do not fire. Scan tasks +are not auto-recovered (re-running a scan is not safe to do automatically); the +watchdog covers the summary/aggregation and deletion tasks. + +## How recovery works + +1. **Durable delivery.** The broker is configured so a task message is acknowledged + only after the task finishes (`task_acks_late`), one task is reserved at a time + (`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task + (`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown + window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work + before it is force-killed. `scan-perform`, `scan-perform-scheduled` and + `integration-jira` opt out of redelivery with `acks_late=False`, so a crash drops + them rather than re-running and duplicating findings or Jira issues. Other + non-recovered side-effect tasks keep `acks_late=True`, so the broker can still + re-deliver them after a worker loss: the S3 upload rebuilds from worker-local files + that did not survive the crash and so no-ops, but Security Hub re-reads findings from + the DB and re-sends them to AWS. + +2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of + minutes (a `django_celery_beat` periodic task created by migration). For each + in-flight task result with an allowlisted idempotent task name, it pings the + worker recorded on the task's `TaskResult`: + - worker responds -> the task is still running, leave it alone; + - worker is gone (and the task started before a short grace window) -> it is a + real orphan: the stale task is revoked and marked terminal (clearing the + pending/started alert), and the task is re-enqueued from its stored name and + kwargs. + + The re-run is safe because only tasks with proven idempotency are allowlisted: the + summary/aggregation tasks clear and re-write their own rows, and deletions are + idempotent. Scan tasks and external side effects are excluded: re-running a scan is + not safe to do automatically, Jira sends would create duplicate issues, the S3 + upload rebuilds from worker-local files that do not survive a crash, and + report/Security Hub recovery is out of scope. + +3. **Recovery cap.** A per-task Valkey counter limits how often the same task is + re-enqueued. After `--max-attempts` recoveries (default 3) the orphan is marked + terminal instead of re-enqueued, so a task that repeatedly kills its worker cannot + loop forever. + +A Postgres advisory lock ensures that, even with multiple API/worker replicas, only +one reconciliation runs at a time; the others no-op. + +## On-demand command + +The same logic is available as a management command, useful right after a deploy or +for manual intervention: + +```bash +python manage.py reconcile_orphan_tasks # recover now +python manage.py reconcile_orphan_tasks --dry-run # report orphans, change nothing +python manage.py reconcile_orphan_tasks --grace-minutes 5 --max-attempts 3 +``` + +## Configuration + +All settings have safe defaults; override via environment variables. + +| Env var | Default | Purpose | +| --- | --- | --- | +| `DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | Tasks reserved per worker process. | +| `DJANGO_CELERY_WORKER_CONCURRENCY` | unset | Optional Celery prefork pool size. When unset, Celery uses its CPU-based default. Set this on worker containers to bound idle memory on hosts with many CPUs. | +| `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` | `60` | Seconds the worker drains/re-queues on `SIGTERM` before force-kill. | +| `DJANGO_CELERY_TASK_TIME_LIMIT` | `21600` (6h) | Hard limit for most tasks; connection checks are capped at 120s. | +| `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. | +| `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. | +| `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. | +| `DJANGO_TASK_RECOVERY_ENABLED` | `false` | Master switch for orphan-task recovery, disabled by default (opt-in); set to `true` to enable. When off, no orphan is detected, marked terminal, or re-enqueued (attack-paths stale cleanup still runs). | +| `DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED` | `true` | Auto re-enqueue orphaned scan summary/aggregation tasks. | +| `DJANGO_TASK_RECOVERY_DELETIONS_ENABLED` | `true` | Auto re-enqueue orphaned provider/tenant deletion tasks. | + +Recovery is opt-in: with the master flag off (the default) the sweep does nothing. +Once enabled, the per-group flags default to on, so every group recovers unless you +turn one off; a task whose group flag is off is marked terminal instead of +re-enqueued. + +Turning recovery off only disables this watchdog sweep; it does not change Celery's +broker-level redelivery (`task_acks_late`/`task_reject_on_worker_lost`), which still +re-delivers tasks that keep `acks_late=True` on worker loss, independently of this flag. + +`task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`. + +## Deployment requirement + +Two conditions must both hold for the soft shutdown to actually drain work: + +1. **The worker must receive `SIGTERM`.** The container entrypoint `exec`s the + Celery process so it runs as PID 1; otherwise `SIGTERM` from `docker stop`/ECS + hits the entrypoint shell, never reaches Celery, and the worker is hard-killed + (SIGKILL) at the grace deadline without draining. Custom entrypoints must + preserve the `exec`. +2. **The orchestrator must give the worker enough time** before force-killing it. + Set the stop grace period to exceed `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` + plus a margin: + - **docker-compose:** `stop_grace_period` on the worker services (set to `120s`). + - **AWS ECS:** the worker container `stopTimeout` (configured in the deployment + repository). + +If either condition is missing, long tasks are still recovered by the watchdog, +but they are cut mid-run on every deploy instead of draining. diff --git a/api/poetry.lock b/api/poetry.lock deleted file mode 100644 index 10dc9ff829..0000000000 --- a/api/poetry.lock +++ /dev/null @@ -1,9375 +0,0 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. - -[[package]] -name = "about-time" -version = "4.2.1" -description = "Easily measure timing and throughput of code blocks, with beautiful human friendly representations." -optional = false -python-versions = ">=3.7, <4" -groups = ["main"] -files = [ - {file = "about-time-4.2.1.tar.gz", hash = "sha256:6a538862d33ce67d997429d14998310e1dbfda6cb7d9bbfbf799c4709847fece"}, - {file = "about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341"}, -] - -[[package]] -name = "adal" -version = "1.2.7" -description = "Note: This library is already replaced by MSAL Python, available here: https://pypi.org/project/msal/ .ADAL Python remains available here as a legacy. The ADAL for Python library makes it easy for python application to authenticate to Azure Active Directory (AAD) in order to access AAD protected web resources." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "adal-1.2.7-py2.py3-none-any.whl", hash = "sha256:2a7451ed7441ddbc57703042204a3e30ef747478eea022c70f789fc7f084bc3d"}, - {file = "adal-1.2.7.tar.gz", hash = "sha256:d74f45b81317454d96e982fd1c50e6fb5c99ac2223728aea8764433a39f566f1"}, -] - -[package.dependencies] -cryptography = ">=1.1.0" -PyJWT = ">=1.0.0,<3" -python-dateutil = ">=2.1.0,<3" -requests = ">=2.0.0,<3" - -[[package]] -name = "aioboto3" -version = "15.5.0" -description = "Async boto3 wrapper" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6"}, - {file = "aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979"}, -] - -[package.dependencies] -aiobotocore = {version = "2.25.1", extras = ["boto3"]} -aiofiles = ">=23.2.1" - -[package.extras] -chalice = ["chalice (>=1.24.0)"] -s3cse = ["cryptography (>=44.0.1)"] - -[[package]] -name = "aiobotocore" -version = "2.25.1" -description = "Async client for aws services using botocore and aiohttp" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"}, - {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"}, -] - -[package.dependencies] -aiohttp = ">=3.9.2,<4.0.0" -aioitertools = ">=0.5.1,<1.0.0" -boto3 = {version = ">=1.40.46,<1.40.62", optional = true, markers = "extra == \"boto3\""} -botocore = ">=1.40.46,<1.40.62" -jmespath = ">=0.7.1,<2.0.0" -multidict = ">=6.0.0,<7.0.0" -python-dateutil = ">=2.1,<3.0.0" -wrapt = ">=1.10.10,<2.0.0" - -[package.extras] -awscli = ["awscli (>=1.42.46,<1.42.62)"] -boto3 = ["boto3 (>=1.40.46,<1.40.62)"] -httpx = ["httpx (>=0.25.1,<0.29)"] - -[[package]] -name = "aiofiles" -version = "24.1.0" -description = "File support for asyncio." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, - {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -description = "Happy Eyeballs for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, - {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, - {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, - {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, - {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, - {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, - {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, - {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, - {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, - {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, - {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, - {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, - {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, - {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, - {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, - {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, - {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e"}, - {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7"}, - {file = "aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f"}, - {file = "aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1"}, - {file = "aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538"}, - {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.5.0" -aiosignal = ">=1.4.0" -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -propcache = ">=0.2.0" -yarl = ">=1.17.0,<2.0" - -[package.extras] -speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] - -[[package]] -name = "aioitertools" -version = "0.13.0" -description = "itertools and builtins for AsyncIO and mixed iterables" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be"}, - {file = "aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c"}, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, - {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" -typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} - -[[package]] -name = "alibabacloud-actiontrail20200706" -version = "2.4.1" -description = "Alibaba Cloud ActionTrail (20200706) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_actiontrail20200706-2.4.1-py3-none-any.whl", hash = "sha256:5dee0009db9b7cba182fbac742820f6a949287a8faafb843b5107f7dc89136da"}, - {file = "alibabacloud_actiontrail20200706-2.4.1.tar.gz", hash = "sha256:b65c6b37a96443fbe625dd5a4dd1be52a7476006a411db75206908b11588ffa8"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-credentials" -version = "1.0.3" -description = "The alibabacloud credentials module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud-credentials-1.0.3.tar.gz", hash = "sha256:9d8707e96afc6f348e23f5677ed15a21c2dfce7cfe6669776548ee4c80e1dfaf"}, - {file = "alibabacloud_credentials-1.0.3-py3-none-any.whl", hash = "sha256:30c8302f204b663c655d97e1c283ee9f9f84a6257d7901b931477d6cf34445a8"}, -] - -[package.dependencies] -aiofiles = ">=22.1.0,<25.0.0" -alibabacloud-credentials-api = ">=1.0.0,<2.0.0" -alibabacloud-tea = ">=0.4.0" -APScheduler = ">=3.10.0,<4.0.0" - -[[package]] -name = "alibabacloud-credentials-api" -version = "1.0.0" -description = "Alibaba Cloud Gateway SPI SDK Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f"}, -] - -[[package]] -name = "alibabacloud-cs20151215" -version = "6.1.0" -description = "Alibaba Cloud CS (20151215) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_cs20151215-6.1.0-py3-none-any.whl", hash = "sha256:75e90b1bb9acca2236244bb0e44234ca4805d456ea4303ba4225ac15152a458e"}, - {file = "alibabacloud_cs20151215-6.1.0.tar.gz", hash = "sha256:5b3d99306701bf499ddd57cd9f2905b7721cb1bb4bb38ffe4d051f7b4e80e355"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-darabonba-array" -version = "0.1.0" -description = "Alibaba Cloud Darabonba Array SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_array-0.1.0.tar.gz", hash = "sha256:7f9a7c632518ff4f0cebb0d4e825a48c12e7cf0b9016ea25054dd73732e155aa"}, -] - -[[package]] -name = "alibabacloud-darabonba-encode-util" -version = "0.0.2" -description = "Darabonba Util Library for Alibaba Cloud Python SDK" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_encode_util-0.0.2.tar.gz", hash = "sha256:f1c484f276d60450fa49b4b2987194e741fcb2f7faae7f287c0ae65abc85fd4d"}, -] - -[[package]] -name = "alibabacloud-darabonba-map" -version = "0.0.1" -description = "Alibaba Cloud Darabonba Map SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_map-0.0.1.tar.gz", hash = "sha256:adb17384658a1a8f72418f1838d4b6a5fd2566bfd392a3ef06d9dbb0a595a23f"}, -] - -[[package]] -name = "alibabacloud-darabonba-signature-util" -version = "0.0.4" -description = "Darabonba Util Library for Alibaba Cloud Python SDK" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_signature_util-0.0.4.tar.gz", hash = "sha256:71d79b2ae65957bcfbf699ced894fda782b32f9635f1616635533e5a90d5feb0"}, -] - -[package.dependencies] -cryptography = ">=3.0.0" - -[[package]] -name = "alibabacloud-darabonba-string" -version = "0.0.4" -description = "Alibaba Cloud Darabonba String Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud-darabonba-string-0.0.4.tar.gz", hash = "sha256:ec6614c0448dadcbc5e466485838a1f8cfdd911135bea739e20b14511270c6f7"}, -] - -[[package]] -name = "alibabacloud-darabonba-time" -version = "0.0.1" -description = "Alibaba Cloud Darabonba Time SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_time-0.0.1.tar.gz", hash = "sha256:0ad9c7b0696570d1a3f40106cc7777f755fd92baa0d1dcab5b7df78dde5b922d"}, -] - -[[package]] -name = "alibabacloud-ecs20140526" -version = "7.2.5" -description = "Alibaba Cloud Elastic Compute Service (20140526) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_ecs20140526-7.2.5-py3-none-any.whl", hash = "sha256:10bda5e185f6ba899e7d51477373595c629d66db7530a8a37433fb4e9034a96f"}, - {file = "alibabacloud_ecs20140526-7.2.5.tar.gz", hash = "sha256:2abbe630ce42d69061821f38950b938c5982cc31902ccd7132d05be328765a55"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-endpoint-util" -version = "0.0.4" -description = "The endpoint-util module of alibabaCloud Python SDK." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90"}, -] - -[[package]] -name = "alibabacloud-gateway-oss" -version = "0.0.17" -description = "Alibaba Cloud OSS SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_oss-0.0.17.tar.gz", hash = "sha256:8c4b66c8c7dd285fc210ee232ab3f062b5573258752804d19382000746531e29"}, -] - -[package.dependencies] -alibabacloud_credentials = ">=0.3.5" -alibabacloud_darabonba_array = ">=0.1.0,<1.0.0" -alibabacloud_darabonba_encode_util = ">=0.0.2,<1.0.0" -alibabacloud_darabonba_map = ">=0.0.1,<1.0.0" -alibabacloud_darabonba_signature_util = ">=0.0.4,<1.0.0" -alibabacloud_darabonba_string = ">=0.0.4,<1.0.0" -alibabacloud_darabonba_time = ">=0.0.1,<1.0.0" -alibabacloud_gateway_oss_util = ">=0.0.3,<1.0.0" -alibabacloud_gateway_spi = ">=0.0.1,<1.0.0" -alibabacloud_openapi_util = ">=0.2.1,<1.0.0" -alibabacloud_oss_util = ">=0.0.5,<1.0.0" -alibabacloud_tea_util = ">=0.3.11,<1.0.0" -alibabacloud_tea_xml = ">=0.0.2,<1.0.0" - -[[package]] -name = "alibabacloud-gateway-oss-util" -version = "0.0.3" -description = "Alibaba Cloud OSS Util Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_oss_util-0.0.3.tar.gz", hash = "sha256:5eb7fa450dc7350d5c71577974b9d7f489479e5c5ec7efc1c5376385e8c1c0a5"}, -] - -[[package]] -name = "alibabacloud-gateway-sls" -version = "0.4.0" -description = "Alibaba Cloud SLS Gateway Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_sls-0.4.0-py3-none-any.whl", hash = "sha256:a0299a83a5528025983b42b7533a28028461bced5e180a66f97999e0134760a6"}, - {file = "alibabacloud_gateway_sls-0.4.0.tar.gz", hash = "sha256:9d2aceb377c9b3ed0558149fda16fe39fa114cc0a22e22a88dc76efdda34633b"}, -] - -[package.dependencies] -alibabacloud-credentials = ">=1.0.2,<2.0.0" -alibabacloud-darabonba-array = ">=0.1.0,<1.0.0" -alibabacloud-darabonba-encode-util = ">=0.0.2,<1.0.0" -alibabacloud-darabonba-map = ">=0.0.1,<1.0.0" -alibabacloud-darabonba-signature-util = ">=0.0.4,<1.0.0" -alibabacloud-darabonba-string = ">=0.0.4,<1.0.0" -alibabacloud-gateway-sls-util = ">=0.4.0,<1.0.0" -alibabacloud-gateway-spi = ">=0.0.2,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-gateway-sls-util" -version = "0.4.0" -description = "Alibaba Cloud SLS Util Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_sls_util-0.4.0-py3-none-any.whl", hash = "sha256:c91ab7fe55af526a01d25b0d431088c4d241b160db055da3d8cb7330bd74595a"}, - {file = "alibabacloud_gateway_sls_util-0.4.0.tar.gz", hash = "sha256:f8b683a36a2ae3fe9a8225d3d97773ea769bdf9cdf4f4d033eab2eb6062ddd1f"}, -] - -[package.dependencies] -aliyun-log-fastpb = ">=0.2.0" -lz4 = ">=4.3.2" -zstd = ">=1.5.5.1" - -[[package]] -name = "alibabacloud-gateway-spi" -version = "0.0.3" -description = "Alibaba Cloud Gateway SPI SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b"}, -] - -[package.dependencies] -alibabacloud_credentials = ">=0.3.4" - -[[package]] -name = "alibabacloud-openapi-util" -version = "0.2.4" -description = "Aliyun Tea OpenApi Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd"}, - {file = "alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34"}, -] - -[package.dependencies] -alibabacloud-tea-util = ">=0.3.13,<1.0.0" -cryptography = ">=3.0.0" - -[[package]] -name = "alibabacloud-oss-util" -version = "0.0.6" -description = "The oss util module of alibabaCloud Python SDK." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6"}, -] - -[package.dependencies] -alibabacloud-tea = "*" - -[[package]] -name = "alibabacloud-oss20190517" -version = "1.0.6" -description = "Alibaba Cloud Object Storage Service (20190517) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_oss20190517-1.0.6-py3-none-any.whl", hash = "sha256:365fda353de6658a1a289f4d70dcd0394e2a8e2921b6b5834ba6d9772121d2f6"}, - {file = "alibabacloud_oss20190517-1.0.6.tar.gz", hash = "sha256:7cd0fb16af613ceb38d2e0e529aa1f58038c7cf59eb67c8c8775ae44ea717852"}, -] - -[package.dependencies] -alibabacloud-gateway-oss = ">=0.0.9,<1.0.0" -alibabacloud-gateway-spi = ">=0.0.1,<1.0.0" -alibabacloud-openapi-util = ">=0.2.1,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.6,<1.0.0" -alibabacloud-tea-util = ">=0.3.11,<1.0.0" - -[[package]] -name = "alibabacloud-ram20150501" -version = "1.2.0" -description = "Alibaba Cloud Resource Access Management (20150501) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_ram20150501-1.2.0-py3-none-any.whl", hash = "sha256:03a0f2a0259848787c1f74e802b486184a88e04183486bd9398766971e5eb00a"}, - {file = "alibabacloud_ram20150501-1.2.0.tar.gz", hash = "sha256:6253513c8880769f4fd5b36fedddb362a9ca628ad9ae9c05c0eeacf5fbc95b42"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-rds20140815" -version = "12.0.0" -description = "Alibaba Cloud rds (20140815) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_rds20140815-12.0.0-py3-none-any.whl", hash = "sha256:0bd7e2018a428d86b1b0681087336e74665b48fc3eb0a13c4f4377ed5eab2b08"}, - {file = "alibabacloud_rds20140815-12.0.0.tar.gz", hash = "sha256:e7421d94f18a914c0a06b0e7fad0daff557713f1c97d415d463a78c1270e9b98"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-sas20181203" -version = "6.1.0" -description = "Alibaba Cloud Threat Detection (20181203) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_sas20181203-6.1.0-py3-none-any.whl", hash = "sha256:1ad735332c50c7961be036b17420d56b5ec3b5557e3aea1daa19491e8b75da20"}, - {file = "alibabacloud_sas20181203-6.1.0.tar.gz", hash = "sha256:e49ffd53e630274a8bf5a8299ca753023ad118510c80f6d9c6fb018b7479bf37"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-sls20201230" -version = "5.9.0" -description = "Alibaba Cloud Log Service (20201230) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_sls20201230-5.9.0-py3-none-any.whl", hash = "sha256:c4ae14096817a9686af5a0ae2389f1f6a8781e60b9edb8643445250cf15c26f1"}, - {file = "alibabacloud_sls20201230-5.9.0.tar.gz", hash = "sha256:bea830b64fbc7ed1719ba386ceeefb120f08d705f03eb0e02409dc6f12a291da"}, -] - -[package.dependencies] -alibabacloud-gateway-sls = ">=0.3.0,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-sts20150401" -version = "1.1.6" -description = "Alibaba Cloud Sts (20150401) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_sts20150401-1.1.6-py3-none-any.whl", hash = "sha256:627f5ca1f86e19b0bf8ce0e99071a36fb65579fad9256fbee38fdc8d500598e9"}, - {file = "alibabacloud_sts20150401-1.1.6.tar.gz", hash = "sha256:c2529b41e0e4531e21cb393e4df346e19fd6d54cc6337d1138dbcd2191438d4c"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-tea" -version = "0.4.3" -description = "The tea module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a"}, -] - -[package.dependencies] -aiohttp = ">=3.7.0,<4.0.0" -requests = ">=2.21.0,<3.0.0" - -[[package]] -name = "alibabacloud-tea-openapi" -version = "0.4.1" -description = "Alibaba Cloud openapi SDK Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_openapi-0.4.1-py3-none-any.whl", hash = "sha256:e46bfa3ca34086d2c357d217a0b7284ecbd4b3bab5c88e075e73aec637b0e4a0"}, - {file = "alibabacloud_tea_openapi-0.4.1.tar.gz", hash = "sha256:2384b090870fdb089c3c40f3fb8cf0145b8c7d6c14abbac521f86a01abb5edaf"}, -] - -[package.dependencies] -alibabacloud-credentials = ">=1.0.2,<2.0.0" -alibabacloud-gateway-spi = ">=0.0.2,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" -cryptography = ">=3.0.0,<45.0.0" -darabonba-core = ">=1.0.3,<2.0.0" - -[[package]] -name = "alibabacloud-tea-util" -version = "0.3.14" -description = "The tea-util module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe"}, - {file = "alibabacloud_tea_util-0.3.14.tar.gz", hash = "sha256:708e7c9f64641a3c9e0e566365d2f23675f8d7c2a3e2971d9402ceede0408cdb"}, -] - -[package.dependencies] -alibabacloud-tea = ">=0.3.3" - -[[package]] -name = "alibabacloud-tea-xml" -version = "0.0.3" -description = "The tea-xml module of alibabaCloud Python SDK." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c"}, -] - -[package.dependencies] -alibabacloud-tea = ">=0.4.0" - -[[package]] -name = "alibabacloud-vpc20160428" -version = "6.13.0" -description = "Alibaba Cloud Virtual Private Cloud (20160428) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_vpc20160428-6.13.0-py3-none-any.whl", hash = "sha256:933cf1e74322a20a2df27ca6323760d857744a4246eeadc9fb3eae01322fb1c6"}, - {file = "alibabacloud_vpc20160428-6.13.0.tar.gz", hash = "sha256:daf00679a83d422799f9fcf263739fe1f360641675843cbfbe623833fc8b1681"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alive-progress" -version = "3.3.0" -description = "A new kind of Progress Bar, with real-time throughput, ETA, and very cool animations!" -optional = false -python-versions = "<4,>=3.9" -groups = ["main"] -files = [ - {file = "alive-progress-3.3.0.tar.gz", hash = "sha256:457dd2428b48dacd49854022a46448d236a48f1b7277874071c39395307e830c"}, - {file = "alive_progress-3.3.0-py3-none-any.whl", hash = "sha256:63dd33bb94cde15ad9e5b666dbba8fedf71b72a4935d6fb9a92931e69402c9ff"}, -] - -[package.dependencies] -about-time = "4.2.1" -graphemeu = "0.7.2" - -[[package]] -name = "aliyun-log-fastpb" -version = "0.2.0" -description = "Fast protobuf serialization for Aliyun Log using PyO3 and quick-protobuf" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:51633d92d2b349aed4843c0b503454fb4f7d73eeaaa54f82aa5a36c10c064ef5"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d2984aafc61ccbbf1db2589ce90b6d5a26e72dba137fb1fdf7f61ce3faa967c0"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:181fc61ac9934f58b0880fa5617a4a4dc709dba09f8be95b5a71e828f2e48053"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12b8bfddf0bc5450f16f1954c6387a73da124fae10d1205a17a0117e66bb56db"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8fbc83cbaa51d332e5e68871c1200014f1f3de54a8cba4fb55a634ee145cd4e4"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a86a6e11dd227d595fa23f69d30588446af19d045d1003bd1b66b5c9a55485"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd92c0b84ba300c1d1c227204c5f2fff243cea80bc3f9399293385e87c82ee3e"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c07a6d81a3eab6666949240da305236ed2350c305154d7e39fcc121fc52291"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cff4fbdd0edff94adcee1dcabf16daacb5d336a12fc897887aa6e4f0ad25152"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5a451809e2a062accbb8dae8750e507e58806e4a8da48d69215cdeef428e9d63"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:61f09df30232f1f5628d13310cf0e175171399ea1c75a8470e9f9d97b045bfb5"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:a5fbf0d41d8c0c964a3dc8dd0ee2e732f876b803e0ed3432550ef3b84dde84f1"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ae2f84ed0777e00045791044a56413f370afbd5b061505f5ded540c04b19c58e"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-win32.whl", hash = "sha256:967f9656c805602fd9be07d8c2756ad89204c852c99689c3c71aa035416ef42a"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:bbdcf7b85f0f3437c2a8e8a1db0ef5584d21468b7c7a358269a4c651c84f4a54"}, - {file = "aliyun_log_fastpb-0.2.0.tar.gz", hash = "sha256:91c714e76fb941c9a0db6b1aa1f4c56cb1626254ff5444c1179860f5e5b63d93"}, -] - -[[package]] -name = "amqp" -version = "5.3.1" -description = "Low-level AMQP client for Python (fork of amqplib)." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2"}, - {file = "amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432"}, -] - -[package.dependencies] -vine = ">=5.0.0,<6.0.0" - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.12.1" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, - {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, -] - -[package.dependencies] -idna = ">=2.8" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] - -[[package]] -name = "applicationinsights" -version = "0.11.10" -description = "This project extends the Application Insights API surface to support Python." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea"}, - {file = "applicationinsights-0.11.10.tar.gz", hash = "sha256:0b761f3ef0680acf4731906dfc1807faa6f2a57168ae74592db0084a6099f7b3"}, -] - -[[package]] -name = "apscheduler" -version = "3.11.2" -description = "In-process task scheduler with Cron-like capabilities" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d"}, - {file = "apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41"}, -] - -[package.dependencies] -tzlocal = ">=3.0" - -[package.extras] -doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] -etcd = ["etcd3", "protobuf (<=3.21.0)"] -gevent = ["gevent"] -mongodb = ["pymongo (>=3.0)"] -redis = ["redis (>=3.0)"] -rethinkdb = ["rethinkdb (>=2.4.0)"] -sqlalchemy = ["sqlalchemy (>=1.4)"] -test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytest-timeout", "pytz", "twisted ; python_version < \"3.14\""] -tornado = ["tornado (>=4.3)"] -twisted = ["twisted"] -zookeeper = ["kazoo"] - -[[package]] -name = "argcomplete" -version = "3.5.3" -description = "Bash tab completion for argparse" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "argcomplete-3.5.3-py3-none-any.whl", hash = "sha256:2ab2c4a215c59fd6caaff41a869480a23e8f6a5f910b266c1808037f4e375b61"}, - {file = "argcomplete-3.5.3.tar.gz", hash = "sha256:c12bf50eded8aebb298c7b7da7a5ff3ee24dffd9f5281867dfe1424b58c55392"}, -] - -[package.extras] -test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] - -[[package]] -name = "asgiref" -version = "3.11.0" -description = "ASGI specs, helper code, and adapters" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"}, - {file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"}, -] - -[package.extras] -tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] - -[[package]] -name = "astroid" -version = "3.2.4" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25"}, - {file = "astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a"}, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.11\" and python_full_version < \"3.11.3\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - -[[package]] -name = "attrs" -version = "25.4.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, -] - -[[package]] -name = "authlib" -version = "1.6.6" -description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"}, - {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"}, -] - -[package.dependencies] -cryptography = "*" - -[[package]] -name = "autopep8" -version = "2.3.2" -description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128"}, - {file = "autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758"}, -] - -[package.dependencies] -pycodestyle = ">=2.12.0" - -[[package]] -name = "awsipranges" -version = "0.3.3" -description = "Work with the AWS IP address ranges in native Python." -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf"}, - {file = "awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0"}, -] - -[[package]] -name = "azure-cli-core" -version = "2.83.0" -description = "Microsoft Azure Command-Line Tools Core Module" -optional = false -python-versions = ">=3.10.0" -groups = ["main"] -files = [ - {file = "azure_cli_core-2.83.0-py3-none-any.whl", hash = "sha256:3136f1434cb6fbd2f5b1d7f82b15cff3d4ba4a638808a86584376a829fd26b8a"}, - {file = "azure_cli_core-2.83.0.tar.gz", hash = "sha256:ac59ae4307a961891587d746984a3349b7afe9759ed8267e1cdd614aeeeabbf9"}, -] - -[package.dependencies] -argcomplete = ">=3.5.2,<3.6.0" -azure-cli-telemetry = "==1.1.0.*" -azure-core = ">=1.38.0,<1.39.0" -azure-mgmt-core = ">=1.2.0,<2" -cryptography = "*" -distro = {version = "*", markers = "sys_platform == \"linux\""} -humanfriendly = ">=10.0,<11.0" -jmespath = "*" -knack = ">=0.11.0,<0.12.0" -microsoft-security-utilities-secret-masker = ">=1.0.0b4,<1.1.0" -msal = [ - {version = "1.35.0b1", extras = ["broker"], markers = "sys_platform == \"win32\""}, - {version = "1.35.0b1", markers = "sys_platform != \"win32\""}, -] -msal-extensions = "1.2.0" -packaging = ">=20.9" -pkginfo = ">=1.5.0.1" -psutil = {version = ">=5.9", markers = "sys_platform != \"cygwin\""} -py-deviceid = "*" -PyJWT = ">=2.1.0" -pyopenssl = ">=17.1.0" -requests = {version = "*", extras = ["socks"]} - -[[package]] -name = "azure-cli-telemetry" -version = "1.1.0" -description = "Microsoft Azure CLI Telemetry Package" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-cli-telemetry-1.1.0.tar.gz", hash = "sha256:d922379cda1b48952be75fb3bd2ac5e7ceecf569492a6088bab77894c624a278"}, - {file = "azure_cli_telemetry-1.1.0-py3-none-any.whl", hash = "sha256:2fc12608c0cf0ea6e69b392af9cab92f1249340b8caff7e9674cf91b3becb337"}, -] - -[package.dependencies] -applicationinsights = ">=0.11.1,<0.12" -portalocker = ">=1.6,<3" - -[[package]] -name = "azure-common" -version = "1.1.28" -description = "Microsoft Azure Client Library for Python (Common)" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-common-1.1.28.zip", hash = "sha256:4ac0cd3214e36b6a1b6a442686722a5d8cc449603aa833f3f0f40bda836704a3"}, - {file = "azure_common-1.1.28-py2.py3-none-any.whl", hash = "sha256:5c12d3dcf4ec20599ca6b0d3e09e86e146353d443e7fcc050c9a19c1f9df20ad"}, -] - -[[package]] -name = "azure-core" -version = "1.38.1" -description = "Microsoft Azure Core Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4"}, - {file = "azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6"}, -] - -[package.dependencies] -requests = ">=2.21.0" -typing-extensions = ">=4.6.0" - -[package.extras] -aio = ["aiohttp (>=3.0)"] -tracing = ["opentelemetry-api (>=1.26,<2.0)"] - -[[package]] -name = "azure-identity" -version = "1.21.0" -description = "Microsoft Azure Identity Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_identity-1.21.0-py3-none-any.whl", hash = "sha256:258ea6325537352440f71b35c3dffe9d240eae4a5126c1b7ce5efd5766bd9fd9"}, - {file = "azure_identity-1.21.0.tar.gz", hash = "sha256:ea22ce6e6b0f429bc1b8d9212d5b9f9877bd4c82f1724bfa910760612c07a9a6"}, -] - -[package.dependencies] -azure-core = ">=1.31.0" -cryptography = ">=2.5" -msal = ">=1.30.0" -msal-extensions = ">=1.2.0" -typing-extensions = ">=4.0.0" - -[[package]] -name = "azure-keyvault-certificates" -version = "4.10.0" -description = "Microsoft Corporation Key Vault Certificates Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_keyvault_certificates-4.10.0-py3-none-any.whl", hash = "sha256:fa76cbc329274cb5f4ab61b0ed7d209d44377df4b4d6be2fd01e741c2fbb83a9"}, - {file = "azure_keyvault_certificates-4.10.0.tar.gz", hash = "sha256:004ff47a73152f9f40f678e5a07719b753a3ca86f0460bfeaaf6a23304872e05"}, -] - -[package.dependencies] -azure-core = ">=1.31.0" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-keyvault-keys" -version = "4.10.0" -description = "Microsoft Azure Key Vault Keys Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_keyvault_keys-4.10.0-py3-none-any.whl", hash = "sha256:210227e0061f641a79755f0e0bcbcf27bbfb4df630a933c43a99a29962283d0d"}, - {file = "azure_keyvault_keys-4.10.0.tar.gz", hash = "sha256:511206ae90aec1726a4d6ff5a92d754bd0c0f1e8751891368d30fb70b62955f1"}, -] - -[package.dependencies] -azure-core = ">=1.31.0" -cryptography = ">=2.1.4" -isodate = ">=0.6.1" -typing-extensions = ">=4.0.1" - -[[package]] -name = "azure-keyvault-secrets" -version = "4.10.0" -description = "Microsoft Corporation Key Vault Secrets Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_keyvault_secrets-4.10.0-py3-none-any.whl", hash = "sha256:9dbde256077a4ee1a847646671580692e3f9bea36bcfc189c3cf2b9a94eb38b9"}, - {file = "azure_keyvault_secrets-4.10.0.tar.gz", hash = "sha256:666fa42892f9cee749563e551a90f060435ab878977c95265173a8246d546a36"}, -] - -[package.dependencies] -azure-core = ">=1.31.0" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-apimanagement" -version = "5.0.0" -description = "Microsoft Azure API Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_apimanagement-5.0.0-py3-none-any.whl", hash = "sha256:b88c42a392333b60722fb86f15d092dfc19a8d67510dccd15c217381dff4e6ec"}, - {file = "azure_mgmt_apimanagement-5.0.0.tar.gz", hash = "sha256:0ab7fe17e70fe3154cd840ff47d19d7a4610217003eaa7c21acf3511a6e57999"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-applicationinsights" -version = "4.1.0" -description = "Microsoft Azure Application Insights Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_applicationinsights-4.1.0-py3-none-any.whl", hash = "sha256:9e71f29b01e505a773501451d12fd6a10482cf4b13e9ac2bff72f5380496d979"}, - {file = "azure_mgmt_applicationinsights-4.1.0.tar.gz", hash = "sha256:15531390f12ce3d767cd3f1949af36aa39077c145c952fec4d80303c86ec7b6c"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-authorization" -version = "4.0.0" -description = "Microsoft Azure Authorization Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-authorization-4.0.0.zip", hash = "sha256:69b85abc09ae64fc72975bd43431170d8c7eb5d166754b98aac5f3845de57dc4"}, - {file = "azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-compute" -version = "34.0.0" -description = "Microsoft Azure Compute Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_compute-34.0.0-py3-none-any.whl", hash = "sha256:f8f7b1c5c187a26fae4d1f099adf93561244242f28899484d9a42747bf0d5af4"}, - {file = "azure_mgmt_compute-34.0.0.tar.gz", hash = "sha256:58cd01d025efa02870b84dbfb69834a3b23501a135658c03854d2434e8dfee1e"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-containerinstance" -version = "10.1.0" -description = "Microsoft Azure Container Instance Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-containerinstance-10.1.0.zip", hash = "sha256:78d437adb28574f448c838ed5f01f9ced378196098061deb59d9f7031704c17e"}, - {file = "azure_mgmt_containerinstance-10.1.0-py3-none-any.whl", hash = "sha256:ee7977b7b70f2233e44ec6ce8c99027f3f7892bb3452b4bad46df340d9f98959"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-containerregistry" -version = "12.0.0" -description = "Microsoft Azure Container Registry Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_containerregistry-12.0.0-py3-none-any.whl", hash = "sha256:464abd4d3d9ecc0456ed8f63a6b9b93afc2e3e194f2d34f26a758afb67ad3b5c"}, - {file = "azure_mgmt_containerregistry-12.0.0.tar.gz", hash = "sha256:f19f8faa7881deaf2b5015c0eb050a92e2380cd9d18dee33cdb5f27d44a06c03"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-containerservice" -version = "34.1.0" -description = "Microsoft Azure Container Service Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_containerservice-34.1.0-py3-none-any.whl", hash = "sha256:1faa1714e0100c6ee4cfb8d2eadb1c270b548a84b0070c74e9fe646056a5cb12"}, - {file = "azure_mgmt_containerservice-34.1.0.tar.gz", hash = "sha256:637a6cf8f06636c016ad151d76f9c7ba75bd05d4334b3dd7837eb8b517f30dbe"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-core" -version = "1.6.0" -description = "Microsoft Azure Management Core Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_mgmt_core-1.6.0-py3-none-any.whl", hash = "sha256:0460d11e85c408b71c727ee1981f74432bc641bb25dfcf1bb4e90a49e776dbc4"}, - {file = "azure_mgmt_core-1.6.0.tar.gz", hash = "sha256:b26232af857b021e61d813d9f4ae530465255cb10b3dde945ad3743f7a58e79c"}, -] - -[package.dependencies] -azure-core = ">=1.32.0" - -[[package]] -name = "azure-mgmt-cosmosdb" -version = "9.7.0" -description = "Microsoft Azure Cosmos DB Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_cosmosdb-9.7.0-py3-none-any.whl", hash = "sha256:be735a554d16995c8cefe413e62119985f8fabae1cb45a6f6ad2c3958bed14da"}, - {file = "azure_mgmt_cosmosdb-9.7.0.tar.gz", hash = "sha256:b5072d319f11953d8f12e22459aded1912d5f27e442e1d8b49596a85005410a1"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-databricks" -version = "2.0.0" -description = "Microsoft Azure Data Bricks Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-databricks-2.0.0.zip", hash = "sha256:70d11362dc2d17f5fb1db0cfe65c1af55b8f136f1a0db9a5b51e7acf760cf5b9"}, - {file = "azure_mgmt_databricks-2.0.0-py3-none-any.whl", hash = "sha256:0c29434a7339e74231bd171a6c08dcdf8153abaebd332658d7f66b8ea143fa17"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-datafactory" -version = "9.2.0" -description = "Microsoft Azure Data Factory Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_datafactory-9.2.0-py3-none-any.whl", hash = "sha256:d870a7a6099227e91d1c258a956c2aa32c2ea4c0a4409913d8f215887349f128"}, - {file = "azure_mgmt_datafactory-9.2.0.tar.gz", hash = "sha256:5132e9c24c441ac225f2a60225924baa55079ca81eff7db99a70d661d64bb0d7"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-eventgrid" -version = "10.4.0" -description = "Microsoft Azure Event Grid Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_eventgrid-10.4.0-py3-none-any.whl", hash = "sha256:5e4637245bbff33298d5f427971b870dbb03d873a3ef68f328190a7b7a38c56f"}, - {file = "azure_mgmt_eventgrid-10.4.0.tar.gz", hash = "sha256:303e5e27cf4bb5ec833ba4e5a9ef70b5bc410e190412ec47cde59d82e413fb7e"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-eventhub" -version = "11.2.0" -description = "Microsoft Azure Event Hub Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_eventhub-11.2.0-py3-none-any.whl", hash = "sha256:a7e2618eca58d8e52c7ff7d4a04a4fae12685351746e6d01b933b43e7ea3b906"}, - {file = "azure_mgmt_eventhub-11.2.0.tar.gz", hash = "sha256:31c47f18f73d2d83345cde5909568e28858c2548a35b10e23194b4767a9ce7e3"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-keyvault" -version = "10.3.1" -description = "Microsoft Azure Key Vault Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure-mgmt-keyvault-10.3.1.tar.gz", hash = "sha256:34b92956aefbdd571cae5a03f7078e037d8087b2c00cfa6748835dc73abb5a30"}, - {file = "azure_mgmt_keyvault-10.3.1-py3-none-any.whl", hash = "sha256:a18a27a06551482d31f92bc43ac8b0846af02cd69511f80090865b4c5caa3c21"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-loganalytics" -version = "12.0.0" -description = "Microsoft Azure Log Analytics Management Client Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-mgmt-loganalytics-12.0.0.zip", hash = "sha256:da128a7e0291be7fa2063848df92a9180cf5c16d42adc09d2bc2efd711536bfb"}, - {file = "azure_mgmt_loganalytics-12.0.0-py2.py3-none-any.whl", hash = "sha256:75ac1d47dd81179905c40765be8834643d8994acff31056ddc1863017f3faa02"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.2.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-logic" -version = "10.0.0" -description = "Microsoft Azure Logic Apps Management Client Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "azure-mgmt-logic-10.0.0.zip", hash = "sha256:b3fa4864f14aaa7af41d778d925f051ed29b6016f46344765ecd0f49d0f04dd6"}, - {file = "azure_mgmt_logic-10.0.0-py3-none-any.whl", hash = "sha256:525c78afedf3edb35eb0a16152c8beba89769ee1bc6af01bcdc42842a551e443"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-monitor" -version = "6.0.2" -description = "Microsoft Azure Monitor Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-monitor-6.0.2.tar.gz", hash = "sha256:5ffbf500e499ab7912b1ba6d26cef26480d9ae411532019bb78d72562196e07b"}, - {file = "azure_mgmt_monitor-6.0.2-py3-none-any.whl", hash = "sha256:fe4cf41e6680b74a228f81451dc5522656d599c6f343ecf702fc790fda9a357b"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-network" -version = "28.1.0" -description = "Microsoft Azure Network Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_network-28.1.0-py3-none-any.whl", hash = "sha256:8ddb0e9ec8f10c9c152d60fc945908d113e4591f397ea3e40b92290ec2b01658"}, - {file = "azure_mgmt_network-28.1.0.tar.gz", hash = "sha256:8c84bffb5ec75c6e0244e58ecf07c00d5fc421d616b0cb369c6fe585af33cf87"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-postgresqlflexibleservers" -version = "1.1.0" -description = "Microsoft Azure Postgresqlflexibleservers Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_postgresqlflexibleservers-1.1.0-py3-none-any.whl", hash = "sha256:87ddb5a5e6d12c45769485d234cfe0322140e3a0a7636d0e61fb00ac544b5d20"}, - {file = "azure_mgmt_postgresqlflexibleservers-1.1.0.tar.gz", hash = "sha256:9ede9d8ba63e9d2879cb74adc903c649af3bc5460a02787287b0cd18d754af14"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-rdbms" -version = "10.1.0" -description = "Microsoft Azure RDBMS Management Client Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "azure-mgmt-rdbms-10.1.0.zip", hash = "sha256:a87d401c876c84734cdd4888af551e4a1461b4b328d9816af60cb8ac5979f035"}, - {file = "azure_mgmt_rdbms-10.1.0-py3-none-any.whl", hash = "sha256:8eac17d1341a91d7ed914435941ba917b5ef1568acabc3e65653603966a7cc88"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-recoveryservices" -version = "3.1.0" -description = "Microsoft Azure Recovery Services Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_mgmt_recoveryservices-3.1.0-py3-none-any.whl", hash = "sha256:21c58afdf4ae66806783e95f8cd17e3bec31be7178c48784db21f0b05de7fa66"}, - {file = "azure_mgmt_recoveryservices-3.1.0.tar.gz", hash = "sha256:7f2db98401708cf145322f50bc491caf7967bec4af3bf7b0984b9f07d3092687"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.5.0" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-recoveryservicesbackup" -version = "9.2.0" -description = "Microsoft Azure Recovery Services Backup Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_recoveryservicesbackup-9.2.0-py3-none-any.whl", hash = "sha256:c0002858d0166b6a10189a1fd580a49c83dc31b111e98010a5b2ea0f767dfff1"}, - {file = "azure_mgmt_recoveryservicesbackup-9.2.0.tar.gz", hash = "sha256:c402b3e22a6c3879df56bc37e0063142c3352c5102599ff102d19824f1b32b29"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-resource" -version = "23.3.0" -description = "Microsoft Azure Resource Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_resource-23.3.0-py3-none-any.whl", hash = "sha256:ab216ee28e29db6654b989746e0c85a1181f66653929d2cb6e48fba66d9af323"}, - {file = "azure_mgmt_resource-23.3.0.tar.gz", hash = "sha256:fc4f1fd8b6aad23f8af4ed1f913df5f5c92df117449dc354fea6802a2829fea4"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-search" -version = "9.1.0" -description = "Microsoft Azure Search Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-search-9.1.0.tar.gz", hash = "sha256:53bc6eeadb0974d21f120bb21bb5e6827df6d650e17347460fd83e2d68883599"}, - {file = "azure_mgmt_search-9.1.0-py3-none-any.whl", hash = "sha256:488ff81477e980e2b7abf0b857387c74ebbad419e6f6126044e3e6fad2da72b6"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-security" -version = "7.0.0" -description = "Microsoft Azure Security Center Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure-mgmt-security-7.0.0.tar.gz", hash = "sha256:5912eed7e9d3758fdca8d26e1dc26b41943dc4703208a1184266e2c252e1ad66"}, - {file = "azure_mgmt_security-7.0.0-py3-none-any.whl", hash = "sha256:85a6d8b7a5cd74884a548ed53fed034449f54a9989edd64e9020c5837db96933"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" - -[[package]] -name = "azure-mgmt-sql" -version = "3.0.1" -description = "Microsoft Azure SQL Management Client Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-mgmt-sql-3.0.1.zip", hash = "sha256:129042cc011225e27aee6ef2697d585fa5722e5d1aeb0038af6ad2451a285457"}, - {file = "azure_mgmt_sql-3.0.1-py2.py3-none-any.whl", hash = "sha256:1d1dd940d4d41be4ee319aad626341251572a5bf4a2addec71779432d9a1381f"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.2.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-storage" -version = "22.1.1" -description = "Microsoft Azure Storage Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_storage-22.1.1-py3-none-any.whl", hash = "sha256:a4a4064918dcfa4f1cbebada5bf064935d66f2a3647a2f46a1f1c9348736f5d9"}, - {file = "azure_mgmt_storage-22.1.1.tar.gz", hash = "sha256:25aaa5ae8c40c30e2f91f8aae6f52906b0557e947d5c1b9817d4ff9decc11340"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-subscription" -version = "3.1.1" -description = "Microsoft Azure Subscription Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-subscription-3.1.1.zip", hash = "sha256:4e255b4ce9b924357bb8c5009b3c88a2014d3203b2495e2256fa027bf84e800e"}, - {file = "azure_mgmt_subscription-3.1.1-py3-none-any.whl", hash = "sha256:38d4574a8d47fa17e3587d756e296cb63b82ad8fb21cd8543bcee443a502bf48"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -msrest = ">=0.7.1" - -[[package]] -name = "azure-mgmt-synapse" -version = "2.0.0" -description = "Microsoft Azure Synapse Management Client Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-mgmt-synapse-2.0.0.zip", hash = "sha256:bec6bdfaeb55b4fdd159f2055e8875bf50a720bb0fce80a816e92a2359b898c8"}, - {file = "azure_mgmt_synapse-2.0.0-py2.py3-none-any.whl", hash = "sha256:e901274009be843a7bf2eedeab32c0941fabb2addea9a1ad1560395073965f0f"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.2.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-web" -version = "8.0.0" -description = "Microsoft Azure Web Apps Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_web-8.0.0-py3-none-any.whl", hash = "sha256:0536aac05bfc673b56ed930f2966b77856e84df675d376e782a7af6bb92449af"}, - {file = "azure_mgmt_web-8.0.0.tar.gz", hash = "sha256:c8d9c042c09db7aacb20270a9effed4d4e651e365af32d80897b84dc7bf35098"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-monitor-query" -version = "2.0.0" -description = "Microsoft Corporation Azure Monitor Query Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_monitor_query-2.0.0-py3-none-any.whl", hash = "sha256:8f52d581271d785e12f49cd5aaa144b8910fb843db2373855a7ef94c7fc462ea"}, - {file = "azure_monitor_query-2.0.0.tar.gz", hash = "sha256:7b05f2fcac4fb67fc9f77a7d4c5d98a0f3099fb73b57c69ec1b080773994671b"}, -] - -[package.dependencies] -azure-core = ">=1.30.0" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-storage-blob" -version = "12.24.1" -description = "Microsoft Azure Blob Storage Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_storage_blob-12.24.1-py3-none-any.whl", hash = "sha256:77fb823fdbac7f3c11f7d86a5892e2f85e161e8440a7489babe2195bf248f09e"}, - {file = "azure_storage_blob-12.24.1.tar.gz", hash = "sha256:052b2a1ea41725ba12e2f4f17be85a54df1129e13ea0321f5a2fcc851cbf47d4"}, -] - -[package.dependencies] -azure-core = ">=1.30.0" -cryptography = ">=2.1.4" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[package.extras] -aio = ["azure-core[aio] (>=1.30.0)"] - -[[package]] -name = "azure-synapse-artifacts" -version = "0.21.0" -description = "Microsoft Azure Synapse Artifacts Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_synapse_artifacts-0.21.0-py3-none-any.whl", hash = "sha256:3311919df13a2b42f1fb9debf5d512080c35d64d02b9f84ff944848835289a8d"}, - {file = "azure_synapse_artifacts-0.21.0.tar.gz", hash = "sha256:d7e37516cf8569e03c604d921e3407d7140cf7523b67b67f757caf999e3c8ee7"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.6.0" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "backoff" -version = "2.2.1" -description = "Function decoration for backoff and retry" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, - {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, -] - -[[package]] -name = "bandit" -version = "1.7.9" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "bandit-1.7.9-py3-none-any.whl", hash = "sha256:52077cb339000f337fb25f7e045995c4ad01511e716e5daac37014b9752de8ec"}, - {file = "bandit-1.7.9.tar.gz", hash = "sha256:7c395a436743018f7be0a4cbb0a4ea9b902b6d87264ddecf8cfdc73b4f78ff61"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] -yaml = ["PyYAML"] - -[[package]] -name = "billiard" -version = "4.2.4" -description = "Python multiprocessing fork with improvements and bugfixes" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5"}, - {file = "billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f"}, -] - -[[package]] -name = "blinker" -version = "1.9.0" -description = "Fast, simple object-to-object and broadcast signaling" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, - {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, -] - -[[package]] -name = "boto3" -version = "1.40.61" -description = "The AWS SDK for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, - {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, -] - -[package.dependencies] -botocore = ">=1.40.61,<1.41.0" -jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.14.0,<0.15.0" - -[package.extras] -crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] - -[[package]] -name = "botocore" -version = "1.40.61" -description = "Low-level, data-driven core of boto 3." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"}, - {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"}, -] - -[package.dependencies] -jmespath = ">=0.7.1,<2.0.0" -python-dateutil = ">=2.1,<3.0.0" -urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} - -[package.extras] -crt = ["awscrt (==0.27.6)"] - -[[package]] -name = "cartography" -version = "0.132.0" -description = "Explore assets and their relationships across your technical infrastructure." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "cartography-0.132.0-py3-none-any.whl", hash = "sha256:c070aa51d0ab4479cb043cae70b35e7df49f2fb5f1fa95ccf10000bbeb952262"}, - {file = "cartography-0.132.0.tar.gz", hash = "sha256:7c6332bc57fd2629d7b83aee7bd95a7b2edb0d51ef746efa0461399e0b66625c"}, -] - -[package.dependencies] -adal = ">=1.2.4" -aioboto3 = ">=13.0.0" -azure-cli-core = ">=2.26.0" -azure-identity = ">=1.5.0" -azure-keyvault-certificates = ">=4.0.0" -azure-keyvault-keys = ">=4.0.0" -azure-keyvault-secrets = ">=4.0.0" -azure-mgmt-authorization = ">=0.60.0" -azure-mgmt-compute = ">=5.0.0" -azure-mgmt-containerinstance = ">=10.0.0" -azure-mgmt-containerservice = ">=30.0.0" -azure-mgmt-cosmosdb = ">=6.0.0" -azure-mgmt-datafactory = ">=8.0.0" -azure-mgmt-eventgrid = ">=10.0.0" -azure-mgmt-eventhub = ">=10.1.0" -azure-mgmt-keyvault = ">=10.0.0" -azure-mgmt-logic = ">=10.0.0" -azure-mgmt-monitor = ">=3.0.0" -azure-mgmt-network = ">=25.0.0" -azure-mgmt-resource = ">=10.2.0,<25.0.0" -azure-mgmt-security = ">=5.0.0" -azure-mgmt-sql = ">=3.0.1,<4" -azure-mgmt-storage = ">=16.0.0" -azure-mgmt-synapse = ">=2.0.0" -azure-mgmt-web = ">=7.0.0" -azure-synapse-artifacts = ">=0.17.0" -backoff = ">=2.1.2" -boto3 = ">=1.15.1" -botocore = ">=1.18.1" -cloudflare = ">=4.1.0,<5.0.0" -crowdstrike-falconpy = ">=0.5.1" -cryptography = "*" -dnspython = ">=1.15.0" -duo-client = "*" -google-api-python-client = ">=1.7.8" -google-auth = ">=2.37.0" -google-cloud-asset = ">=1.0.0" -google-cloud-resource-manager = ">=1.14.2" -httpx = ">=0.24.0" -kubernetes = ">=22.6.0" -marshmallow = ">=3.0.0rc7" -msgraph-sdk = "*" -msrestazure = ">=0.6.4" -neo4j = ">=6.0.0" -oci = ">=2.71.0" -okta = "<1.0.0" -packageurl-python = "*" -packaging = "*" -pagerduty = ">=4.0.1" -policyuniverse = ">=1.1.0.0" -PyJWT = {version = ">=2.0.0", extras = ["crypto"]} -python-dateutil = "*" -python-digitalocean = ">=1.16.0" -pyyaml = ">=5.3.1" -requests = ">=2.22.0" -scaleway = ">=2.10.0" -slack-sdk = ">=3.37.0" -statsd = "*" -typer = ">=0.9.0" -types-aiobotocore-ecr = "*" -xmltodict = "*" - -[[package]] -name = "celery" -version = "5.6.2" -description = "Distributed Task Queue." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5"}, - {file = "celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b"}, -] - -[package.dependencies] -billiard = ">=4.2.1,<5.0" -click = ">=8.1.2,<9.0" -click-didyoumean = ">=0.3.0" -click-plugins = ">=1.1.1" -click-repl = ">=0.2.0" -kombu = ">=5.6.0" -python-dateutil = ">=2.8.2" -tzlocal = "*" -vine = ">=5.1.0,<6.0" - -[package.extras] -arangodb = ["pyArango (>=2.0.2)"] -auth = ["cryptography (==46.0.3)"] -azureblockblob = ["azure-identity (>=1.19.0)", "azure-storage-blob (>=12.15.0)"] -brotli = ["brotli (>=1.0.0) ; platform_python_implementation == \"CPython\"", "brotlipy (>=0.7.0) ; platform_python_implementation == \"PyPy\""] -cassandra = ["cassandra-driver (>=3.25.0,<4)"] -consul = ["python-consul2 (==0.1.5)"] -cosmosdbsql = ["pydocumentdb (==2.3.5)"] -couchbase = ["couchbase (>=3.0.0) ; platform_python_implementation != \"PyPy\" and (platform_system != \"Windows\" or python_version < \"3.10\")"] -couchdb = ["pycouchdb (==1.16.0)"] -django = ["Django (>=2.2.28)"] -dynamodb = ["boto3 (>=1.26.143)"] -elasticsearch = ["elastic-transport (<=9.1.0)", "elasticsearch (<=9.1.2)"] -eventlet = ["eventlet (>=0.32.0) ; python_version < \"3.10\""] -gcs = ["google-cloud-firestore (==2.22.0)", "google-cloud-storage (>=2.10.0)", "grpcio (==1.75.1)"] -gevent = ["gevent (>=1.5.0)"] -librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] -memcache = ["pylibmc (==1.6.3) ; platform_system != \"Windows\""] -mongodb = ["kombu[mongodb]"] -msgpack = ["kombu[msgpack]"] -pydantic = ["pydantic (>=2.12.0a1) ; python_version >= \"3.14\"", "pydantic (>=2.4) ; python_version < \"3.14\""] -pymemcache = ["python-memcached (>=1.61)"] -pyro = ["pyro4 (==4.82) ; python_version < \"3.11\""] -pytest = ["pytest-celery[all] (>=1.2.0,<1.3.0)"] -redis = ["kombu[redis]"] -s3 = ["boto3 (>=1.26.143)"] -slmq = ["softlayer_messaging (>=1.0.3)"] -solar = ["ephem (==4.2) ; platform_python_implementation != \"PyPy\""] -sqlalchemy = ["kombu[sqlalchemy]"] -sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.5.0)", "pycurl (>=7.43.0.5,<7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version < \"3.9\"", "pycurl (>=7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "urllib3 (>=1.26.16)"] -tblib = ["tblib (==3.2.2)"] -yaml = ["kombu[yaml]"] -zookeeper = ["kazoo (>=1.3.1)"] -zstd = ["zstandard (==0.23.0)"] - -[[package]] -name = "certifi" -version = "2026.1.4" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, -] - -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, -] - -[[package]] -name = "circuitbreaker" -version = "2.1.3" -description = "Python Circuit Breaker pattern implementation" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1"}, - {file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"}, -] - -[[package]] -name = "click" -version = "8.3.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "click-didyoumean" -version = "0.3.1" -description = "Enables git-like *did-you-mean* feature in click" -optional = false -python-versions = ">=3.6.2" -groups = ["main"] -files = [ - {file = "click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c"}, - {file = "click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463"}, -] - -[package.dependencies] -click = ">=7" - -[[package]] -name = "click-plugins" -version = "1.1.1.2" -description = "An extension module for click to enable registering CLI commands via setuptools entry-points." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6"}, - {file = "click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261"}, -] - -[package.dependencies] -click = ">=4.0" - -[package.extras] -dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] - -[[package]] -name = "click-repl" -version = "0.3.0" -description = "REPL plugin for Click" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9"}, - {file = "click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812"}, -] - -[package.dependencies] -click = ">=7.0" -prompt-toolkit = ">=3.0.36" - -[package.extras] -testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] - -[[package]] -name = "cloudflare" -version = "4.3.1" -description = "The official Python library for the cloudflare API" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6"}, - {file = "cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258"}, -] - -[package.dependencies] -anyio = ">=3.5.0,<5" -distro = ">=1.7.0,<2" -httpx = ">=0.23.0,<1" -pydantic = ">=1.9.0,<3" -sniffio = "*" -typing-extensions = ">=4.10,<5" - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} - -[[package]] -name = "contextlib2" -version = "21.6.0" -description = "Backports and enhancements for the contextlib module" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, - {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, -] - -[[package]] -name = "contourpy" -version = "1.3.3" -description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false -python-versions = ">=3.11" -groups = ["main"] -files = [ - {file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"}, - {file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db"}, - {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620"}, - {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f"}, - {file = "contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff"}, - {file = "contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42"}, - {file = "contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470"}, - {file = "contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb"}, - {file = "contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1"}, - {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7"}, - {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411"}, - {file = "contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69"}, - {file = "contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b"}, - {file = "contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc"}, - {file = "contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5"}, - {file = "contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9"}, - {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659"}, - {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7"}, - {file = "contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d"}, - {file = "contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263"}, - {file = "contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9"}, - {file = "contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d"}, - {file = "contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b"}, - {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a"}, - {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e"}, - {file = "contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3"}, - {file = "contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8"}, - {file = "contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301"}, - {file = "contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a"}, - {file = "contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3"}, - {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b"}, - {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36"}, - {file = "contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d"}, - {file = "contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd"}, - {file = "contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339"}, - {file = "contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772"}, - {file = "contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0"}, - {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4"}, - {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f"}, - {file = "contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae"}, - {file = "contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc"}, - {file = "contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77"}, - {file = "contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880"}, -] - -[package.dependencies] -numpy = ">=1.25" - -[package.extras] -bokeh = ["bokeh", "selenium"] -docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] -mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", "types-Pillow"] -test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] -test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] - -[[package]] -name = "coverage" -version = "7.5.4" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, - {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, - {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, - {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, - {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, - {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, - {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, - {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, - {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, - {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, - {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, - {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, - {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, - {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "cron-descriptor" -version = "1.4.5" -description = "A Python library that converts cron expressions into human readable strings." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013"}, - {file = "cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca"}, -] - -[package.extras] -dev = ["polib"] - -[[package]] -name = "crowdstrike-falconpy" -version = "1.6.0" -description = "The CrowdStrike Falcon SDK for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "crowdstrike_falconpy-1.6.0-py3-none-any.whl", hash = "sha256:2cae39e42510f473e77f3a647d56aa8f55d9de0a1963bad353b195cb5ae61ea4"}, - {file = "crowdstrike_falconpy-1.6.0.tar.gz", hash = "sha256:663402ac9bc56625478460b4865446371de2d74f5e96cb5d16672119c113346f"}, -] - -[package.dependencies] -requests = "*" -urllib3 = "*" - -[package.extras] -dev = ["bandit", "coverage", "flake8", "pydocstyle", "pylint", "pytest", "pytest-cov"] - -[[package]] -name = "cryptography" -version = "44.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main", "dev"] -files = [ - {file = "cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d"}, - {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904"}, - {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44"}, - {file = "cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d"}, - {file = "cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d"}, - {file = "cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f"}, - {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5"}, - {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b"}, - {file = "cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028"}, - {file = "cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c"}, - {file = "cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] -pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "cycler" -version = "0.12.1" -description = "Composable style cycles" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, - {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, -] - -[package.extras] -docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] -tests = ["pytest", "pytest-cov", "pytest-xdist"] - -[[package]] -name = "darabonba-core" -version = "1.0.5" -description = "The darabonba module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c"}, -] - -[package.dependencies] -aiohttp = ">=3.7.0,<4.0.0" -alibabacloud-tea = "*" -requests = ">=2.21.0,<3.0.0" - -[[package]] -name = "dash" -version = "3.1.1" -description = "A Python framework for building reactive web-apps. Developed by Plotly." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "dash-3.1.1-py3-none-any.whl", hash = "sha256:66fff37e79c6aa114cd55aea13683d1e9afe0e3f96b35388baca95ff6cfdad23"}, - {file = "dash-3.1.1.tar.gz", hash = "sha256:916b31cec46da0a3339da0e9df9f446126aa7f293c0544e07adf9fe4ba060b18"}, -] - -[package.dependencies] -Flask = ">=1.0.4,<3.2" -importlib-metadata = "*" -nest-asyncio = "*" -plotly = ">=5.0.0" -requests = "*" -retrying = "*" -setuptools = "*" -typing-extensions = ">=4.1.1" -Werkzeug = "<3.2" - -[package.extras] -async = ["flask[async]"] -celery = ["celery[redis] (>=5.1.2,<5.4.0)", "kombu (<5.4.0)", "redis (>=3.5.3,<=5.0.4)"] -ci = ["black (==22.3.0)", "flake8 (==7.0.0)", "flaky (==3.8.1)", "flask-talisman (==1.0.0)", "ipython (<9.0.0)", "jupyterlab (<4.0.0)", "mimesis (<=11.1.0)", "mock (==4.0.3)", "mypy (==1.15.0) ; python_version >= \"3.12\"", "numpy (<=1.26.3)", "openpyxl", "orjson (==3.10.3)", "pandas (>=1.4.0)", "pyarrow", "pylint (==3.0.3)", "pyright (==1.1.398) ; python_version >= \"3.7\"", "pytest-mock", "pytest-rerunfailures", "pytest-sugar (==0.9.6)", "pyzmq (==25.1.2)", "xlrd (>=2.0.1)"] -compress = ["flask-compress"] -dev = ["PyYAML (>=5.4.1)", "coloredlogs (>=15.0.1)", "fire (>=0.4.0)"] -diskcache = ["diskcache (>=5.2.1)", "multiprocess (>=0.70.12)", "psutil (>=5.8.0)"] -testing = ["beautifulsoup4 (>=4.8.2)", "cryptography", "dash-testing-stub (>=0.0.2)", "lxml (>=4.6.2)", "multiprocess (>=0.70.12)", "percy (>=2.0.2)", "psutil (>=5.8.0)", "pytest (>=6.0.2)", "requests[security] (>=2.21.0)", "selenium (>=3.141.0,<=4.2.0)", "waitress (>=1.4.4)"] - -[[package]] -name = "dash-bootstrap-components" -version = "2.0.3" -description = "Bootstrap themed components for use in Plotly Dash" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "dash_bootstrap_components-2.0.3-py3-none-any.whl", hash = "sha256:82754d3d001ad5482b8a82b496c7bf98a1c68d2669d607a89dda7ec627304af5"}, - {file = "dash_bootstrap_components-2.0.3.tar.gz", hash = "sha256:5c161b04a6e7ed19a7d54e42f070c29fd6c385d5a7797e7a82999aa2fc15b1de"}, -] - -[package.dependencies] -dash = ">=3.0.4" - -[package.extras] -pandas = ["numpy (>=2.0.2)", "pandas (>=2.2.3)"] - -[[package]] -name = "debugpy" -version = "1.8.20" -description = "An implementation of the Debug Adapter Protocol for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64"}, - {file = "debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642"}, - {file = "debugpy-1.8.20-cp310-cp310-win32.whl", hash = "sha256:c29dd9d656c0fbd77906a6e6a82ae4881514aa3294b94c903ff99303e789b4a2"}, - {file = "debugpy-1.8.20-cp310-cp310-win_amd64.whl", hash = "sha256:3ca85463f63b5dd0aa7aaa933d97cbc47c174896dcae8431695872969f981893"}, - {file = "debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b"}, - {file = "debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344"}, - {file = "debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec"}, - {file = "debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb"}, - {file = "debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d"}, - {file = "debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b"}, - {file = "debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390"}, - {file = "debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3"}, - {file = "debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a"}, - {file = "debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf"}, - {file = "debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393"}, - {file = "debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7"}, - {file = "debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173"}, - {file = "debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad"}, - {file = "debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f"}, - {file = "debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be"}, - {file = "debugpy-1.8.20-cp38-cp38-macosx_15_0_x86_64.whl", hash = "sha256:b773eb026a043e4d9c76265742bc846f2f347da7e27edf7fe97716ea19d6bfc5"}, - {file = "debugpy-1.8.20-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:20d6e64ea177ab6732bffd3ce8fc6fb8879c60484ce14c3b3fe183b1761459ca"}, - {file = "debugpy-1.8.20-cp38-cp38-win32.whl", hash = "sha256:0dfd9adb4b3c7005e9c33df430bcdd4e4ebba70be533e0066e3a34d210041b66"}, - {file = "debugpy-1.8.20-cp38-cp38-win_amd64.whl", hash = "sha256:60f89411a6c6afb89f18e72e9091c3dfbcfe3edc1066b2043a1f80a3bbb3e11f"}, - {file = "debugpy-1.8.20-cp39-cp39-macosx_15_0_x86_64.whl", hash = "sha256:bff8990f040dacb4c314864da95f7168c5a58a30a66e0eea0fb85e2586a92cd6"}, - {file = "debugpy-1.8.20-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:70ad9ae09b98ac307b82c16c151d27ee9d68ae007a2e7843ba621b5ce65333b5"}, - {file = "debugpy-1.8.20-cp39-cp39-win32.whl", hash = "sha256:9eeed9f953f9a23850c85d440bf51e3c56ed5d25f8560eeb29add815bd32f7ee"}, - {file = "debugpy-1.8.20-cp39-cp39-win_amd64.whl", hash = "sha256:760813b4fff517c75bfe7923033c107104e76acfef7bda011ffea8736e9a66f8"}, - {file = "debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7"}, - {file = "debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33"}, -] - -[[package]] -name = "decorator" -version = "5.2.1" -description = "Decorators for Humans" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, - {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -description = "XML bomb protection for Python stdlib modules" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] -files = [ - {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, - {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, -] - -[[package]] -name = "detect-secrets" -version = "1.5.0" -description = "Tool for detecting secrets in the codebase" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060"}, - {file = "detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a"}, -] - -[package.dependencies] -pyyaml = "*" -requests = "*" - -[package.extras] -gibberish = ["gibberish-detector"] -word-list = ["pyahocorasick"] - -[[package]] -name = "dill" -version = "0.4.1" -description = "serialize all of Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d"}, - {file = "dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "distro" -version = "1.9.0" -description = "Distro - an OS platform information API" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, - {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, -] - -[[package]] -name = "dj-rest-auth" -version = "7.0.1" -description = "Authentication and Registration in Django Rest Framework" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "dj-rest-auth-7.0.1.tar.gz", hash = "sha256:3f8c744cbcf05355ff4bcbef0c8a63645da38e29a0fdef3c3332d4aced52fb90"}, -] - -[package.dependencies] -Django = ">=4.2,<6.0" -django-allauth = {version = ">=64.0.0", extras = ["socialaccount"], optional = true, markers = "extra == \"with-social\""} -djangorestframework = ">=3.13.0" - -[package.extras] -with-social = ["django-allauth[socialaccount] (>=64.0.0)"] - -[[package]] -name = "django" -version = "5.1.15" -description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432"}, - {file = "django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947"}, -] - -[package.dependencies] -asgiref = ">=3.8.1,<4" -sqlparse = ">=0.3.1" -tzdata = {version = "*", markers = "sys_platform == \"win32\""} - -[package.extras] -argon2 = ["argon2-cffi (>=19.1.0)"] -bcrypt = ["bcrypt"] - -[[package]] -name = "django-allauth" -version = "65.15.0" -description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d"}, - {file = "django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8"}, -] - -[package.dependencies] -asgiref = ">=3.8.1" -Django = ">=4.2.16" -oauthlib = {version = ">=3.3.0,<4", optional = true, markers = "extra == \"socialaccount\""} -pyjwt = {version = ">=2.0,<3", extras = ["crypto"], optional = true, markers = "extra == \"socialaccount\""} -python3-saml = {version = ">=1.15.0,<2.0.0", optional = true, markers = "extra == \"saml\""} -requests = {version = ">=2.0.0,<3", optional = true, markers = "extra == \"socialaccount\""} - -[package.extras] -headless = ["pyjwt[crypto] (>=2.0,<3)"] -headless-spec = ["PyYAML (>=6,<7)"] -idp-oidc = ["oauthlib (>=3.3.0,<4)", "pyjwt[crypto] (>=2.0,<3)"] -mfa = ["fido2 (>=1.1.2,<3)", "qrcode (>=7.0.0,<9)"] -openid = ["python3-openid (>=3.0.8,<4)"] -saml = ["python3-saml (>=1.15.0,<2.0.0)"] -socialaccount = ["oauthlib (>=3.3.0,<4)", "pyjwt[crypto] (>=2.0,<3)", "requests (>=2.0.0,<3)"] -steam = ["python3-openid (>=3.0.8,<4)"] - -[[package]] -name = "django-celery-beat" -version = "2.9.0" -description = "Database-backed Periodic Tasks." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157"}, - {file = "django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c"}, -] - -[package.dependencies] -celery = ">=5.2.3,<6.0" -cron-descriptor = ">=1.2.32,<2.0.0" -Django = ">=2.2,<6.1" -django-timezone-field = ">=5.0" -python-crontab = ">=2.3.4" -tzdata = "*" - -[[package]] -name = "django-celery-results" -version = "2.6.0" -description = "Celery result backends for Django." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73"}, - {file = "django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c"}, -] - -[package.dependencies] -celery = ">=5.2.7,<6.0" -Django = ">=3.2.25" - -[[package]] -name = "django-cors-headers" -version = "4.4.0" -description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "django_cors_headers-4.4.0-py3-none-any.whl", hash = "sha256:5c6e3b7fe870876a1efdfeb4f433782c3524078fa0dc9e0195f6706ce7a242f6"}, - {file = "django_cors_headers-4.4.0.tar.gz", hash = "sha256:92cf4633e22af67a230a1456cb1b7a02bb213d6536d2dcb2a4a24092ea9cebc2"}, -] - -[package.dependencies] -asgiref = ">=3.6" -django = ">=3.2" - -[[package]] -name = "django-environ" -version = "0.11.2" -description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." -optional = false -python-versions = ">=3.6,<4" -groups = ["main"] -files = [ - {file = "django-environ-0.11.2.tar.gz", hash = "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be"}, - {file = "django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05"}, -] - -[package.extras] -develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] -docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] -testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] - -[[package]] -name = "django-filter" -version = "24.3" -description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64"}, - {file = "django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3"}, -] - -[package.dependencies] -Django = ">=4.2" - -[[package]] -name = "django-guid" -version = "3.5.0" -description = "Middleware that enables single request-response cycle tracing by injecting a unique ID into project logs" -optional = false -python-versions = "<4.0,>=3.8" -groups = ["main"] -files = [ - {file = "django_guid-3.5.0-py3-none-any.whl", hash = "sha256:28f52cfeac47e8e22ea889a3845bc2b1c604dd842e495dadd44ad5184db72c76"}, - {file = "django_guid-3.5.0.tar.gz", hash = "sha256:5f32f70287e4f36addc79f29f2a7b2f56fc5f4e4cfb2023141525be8baa35d9e"}, -] - -[package.dependencies] -django = {version = ">=4.0,<6.0", markers = "python_version >= \"3.10\""} - -[[package]] -name = "django-postgres-extra" -version = "2.0.9" -description = "Bringing all of PostgreSQL's awesomeness to Django." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "django_postgres_extra-2.0.9-py3-none-any.whl", hash = "sha256:0a4f9fd7f843e2ef5cfe7a291fcb883bd72d2ca8e4b369d0070866205e00b404"}, - {file = "django_postgres_extra-2.0.9.tar.gz", hash = "sha256:9e47c436d033712d0e2611b8c1583566dd4f97700e5360001bf3623913a92046"}, -] - -[package.dependencies] -Django = ">=2.0,<6.0" -python-dateutil = ">=2.8.0,<=3.0.0" - -[package.extras] -analysis = ["autoflake (==1.4)", "autopep8 (==1.6.0)", "black (==22.3.0)", "django-stubs (==1.16.0) ; python_version > \"3.6\"", "django-stubs (==1.9.0) ; python_version <= \"3.6\"", "docformatter (==1.4)", "flake8 (==4.0.1)", "isort (==5.10.0)", "mypy (==0.971) ; python_version <= \"3.6\"", "mypy (==1.2.0) ; python_version > \"3.6\"", "types-dj-database-url (==1.3.0.0)", "types-psycopg2 (==2.9.21.9)", "types-python-dateutil (==2.8.19.12)", "typing-extensions (==4.1.0) ; python_version <= \"3.6\"", "typing-extensions (==4.5.0) ; python_version > \"3.6\""] -docs = ["Sphinx (==2.2.0)", "docutils (<0.18)", "sphinx-rtd-theme (==0.4.3)"] -publish = ["build (==0.7.0)", "twine (==3.7.1)"] -test = ["coveralls (==3.3.0)", "dj-database-url (==0.5.0)", "freezegun (==1.1.0)", "psycopg2 (>=2.8.4,<3.0.0)", "pytest (==6.2.5)", "pytest-benchmark (==3.4.1)", "pytest-cov (==3.0.0)", "pytest-django (==4.4.0)", "pytest-freezegun (==0.4.2)", "pytest-lazy-fixture (==0.6.3)", "snapshottest (==0.6.0)", "tox (==3.24.4)"] - -[[package]] -name = "django-silk" -version = "5.3.2" -description = "Silky smooth profiling for the Django Framework" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "django_silk-5.3.2-py3-none-any.whl", hash = "sha256:49f1caebfda28b1707f0cfef524e0476beb82b8c5e40f5ccff7f73a6b4f6d3ac"}, - {file = "django_silk-5.3.2.tar.gz", hash = "sha256:b0db54eebedb8d16f572321bd6daccac0bd3f547ae2618bb45d96fe8fc02229d"}, -] - -[package.dependencies] -autopep8 = "*" -Django = ">=4.2" -gprof2dot = ">=2017.9.19" -sqlparse = "*" - -[[package]] -name = "django-timezone-field" -version = "7.2.1" -description = "A Django app providing DB, form, and REST framework fields for zoneinfo and pytz timezone objects." -optional = false -python-versions = "<4.0,>=3.8" -groups = ["main"] -files = [ - {file = "django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44"}, - {file = "django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e"}, -] - -[package.dependencies] -Django = ">=3.2,<6.1" - -[[package]] -name = "djangorestframework" -version = "3.15.2" -description = "Web APIs for Django, made easy." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20"}, - {file = "djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad"}, -] - -[package.dependencies] -django = ">=4.2" - -[[package]] -name = "djangorestframework-jsonapi" -version = "7.0.2" -description = "A Django REST framework API adapter for the JSON:API spec." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "djangorestframework-jsonapi-7.0.2.tar.gz", hash = "sha256:d6c72a2bee539f1093dd86620e862af2d1a0e60408e38a710146286dbde71d75"}, - {file = "djangorestframework_jsonapi-7.0.2-py2.py3-none-any.whl", hash = "sha256:be457adb50aac77eec8893048bf46ad6926dcd26204aa10965a1430610828d50"}, -] - -[package.dependencies] -django = ">=4.2" -djangorestframework = ">=3.14" -inflection = ">=0.5.0" - -[package.extras] -django-filter = ["django-filter (>=2.4)"] -django-polymorphic = ["django-polymorphic (>=3.0)"] -openapi = ["pyyaml (>=5.4)", "uritemplate (>=3.0.1)"] - -[[package]] -name = "djangorestframework-simplejwt" -version = "5.5.1" -description = "A minimal JSON Web Token authentication plugin for Django REST Framework" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469"}, - {file = "djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f"}, -] - -[package.dependencies] -django = ">=4.2" -djangorestframework = ">=3.14" -pyjwt = ">=1.7.1" - -[package.extras] -crypto = ["cryptography (>=3.3.1)"] -dev = ["Sphinx", "cryptography", "freezegun", "ipython", "pre-commit", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "pyupgrade", "ruff", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel", "yesqa"] -doc = ["Sphinx", "sphinx_rtd_theme (>=0.1.9)"] -lint = ["pre-commit", "pyupgrade", "ruff", "yesqa"] -python-jose = ["python-jose (==3.3.0)"] -test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] - -[[package]] -name = "dnspython" -version = "2.8.0" -description = "DNS toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, - {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, -] - -[package.extras] -dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] -dnssec = ["cryptography (>=45)"] -doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] -doq = ["aioquic (>=1.2.0)"] -idna = ["idna (>=3.10)"] -trio = ["trio (>=0.30)"] -wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] - -[[package]] -name = "docker" -version = "7.1.0" -description = "A Python library for the Docker Engine API." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, - {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, -] - -[package.dependencies] -pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} -requests = ">=2.26.0" -urllib3 = ">=1.26.0" - -[package.extras] -dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] -docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] -ssh = ["paramiko (>=2.4.3)"] -websockets = ["websocket-client (>=1.3.0)"] - -[[package]] -name = "dogpile-cache" -version = "1.5.0" -description = "A caching front-end based on the Dogpile lock." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d"}, - {file = "dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec"}, -] - -[package.dependencies] -decorator = ">=4.0.0" -stevedore = ">=3.0.0" - -[package.extras] -bmemcached = ["python-binary-memcached"] -memcached = ["python-memcached"] -pifpaf = ["pifpaf (>=3.3.0)"] -pylibmc = ["pylibmc"] -pymemcache = ["pymemcache"] -redis = ["redis"] -valkey = ["valkey"] - -[[package]] -name = "dparse" -version = "0.6.4" -description = "A parser for Python dependency files" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"}, - {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"}, -] - -[package.dependencies] -packaging = "*" - -[package.extras] -all = ["pipenv", "poetry", "pyyaml"] -conda = ["pyyaml"] -pipenv = ["pipenv"] -poetry = ["poetry"] - -[[package]] -name = "drf-extensions" -version = "0.8.0" -description = "Extensions for Django REST Framework" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "drf_extensions-0.8.0-py2.py3-none-any.whl", hash = "sha256:ab7bd854c9061c27ab55233b66d758001e5c2d81aaa9d117cbbe1c9ea49c77ab"}, - {file = "drf_extensions-0.8.0.tar.gz", hash = "sha256:c3f27bca74a2def53e8454a5c7b327595195df51e121743120b2f51ef5a52aaa"}, -] - -[package.dependencies] -Django = ">=2.2,<6.0" -djangorestframework = ">=3.10.3" -packaging = ">=24.1" - -[[package]] -name = "drf-nested-routers" -version = "0.95.0" -description = "Nested resources for the Django Rest Framework" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "drf_nested_routers-0.95.0-py2.py3-none-any.whl", hash = "sha256:dd489c33d667aaa81383ffaa8c74781d2b353d8f0795716ae37fc59ee297b7c4"}, - {file = "drf_nested_routers-0.95.0.tar.gz", hash = "sha256:815978f802e578fd7035c74040c104909cbe97615de89a275d77e928f4029891"}, -] - -[package.dependencies] -Django = ">=4.2" -djangorestframework = ">=3.15.0" - -[[package]] -name = "drf-simple-apikey" -version = "2.2.1" -description = "API Key authentication and permissions for Django REST." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "drf_simple_apikey-2.2.1-py2.py3-none-any.whl", hash = "sha256:2a60b35676d14f907c47dee179dd0fa7425a84c34d6ff5b48d08d3b87ff32809"}, - {file = "drf_simple_apikey-2.2.1.tar.gz", hash = "sha256:e5a52804bbac12c8db80c10a3d51a8514fc59fc8385b5e751099a2bc944ad25d"}, -] - -[package.dependencies] -cryptography = ">=38.0.4" -django = ">=4.2" -djangorestframework = ">=3.14.0" - -[package.extras] -test = ["coverage", "pytest", "pytest-django"] -tooling = ["black (==22.3.0)", "bump2version", "pylint"] - -[[package]] -name = "drf-spectacular" -version = "0.27.2" -description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "drf-spectacular-0.27.2.tar.gz", hash = "sha256:a199492f2163c4101055075ebdbb037d59c6e0030692fc83a1a8c0fc65929981"}, - {file = "drf_spectacular-0.27.2-py3-none-any.whl", hash = "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b"}, -] - -[package.dependencies] -Django = ">=2.2" -djangorestframework = ">=3.10.3" -inflection = ">=0.3.1" -jsonschema = ">=2.6.0" -PyYAML = ">=5.1" -uritemplate = ">=2.0.0" - -[package.extras] -offline = ["drf-spectacular-sidecar"] -sidecar = ["drf-spectacular-sidecar"] - -[[package]] -name = "drf-spectacular-jsonapi" -version = "0.5.1" -description = "open api 3 schema generator for drf-json-api package based on drf-spectacular package." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "drf-spectacular-jsonapi-0.5.1.tar.gz", hash = "sha256:e45f87f3cce2692f4f546e0785d8fcc32c6b49770fff858065c267ae8f9cfde5"}, - {file = "drf_spectacular_jsonapi-0.5.1-py3-none-any.whl", hash = "sha256:abac728abd83e2544408cc900d682d532ca2088f2f9321d1c9101bcdfdabca78"}, -] - -[package.dependencies] -Django = ">=3.2" -djangorestframework = ">=3.13" -djangorestframework-jsonapi = ">=6.0.0" -drf-extensions = ">=0.7.1" -drf-spectacular = ">=0.25.0" - -[[package]] -name = "dulwich" -version = "0.23.0" -description = "Python Git Library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "dulwich-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c13b0d5a9009cde23ecb8cb201df6e23e2a7a82c5e2d6ba6443fbb322c9befc6"}, - {file = "dulwich-0.23.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a68faf8612bf93de1285048d6ad13160f0fb3c5596a86e694e78f4e212886fa5"}, - {file = "dulwich-0.23.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d971566826f16ec67c70641c1fbdb337323aa5b533799bc5a4641f4750e73b36"}, - {file = "dulwich-0.23.0-cp310-cp310-win32.whl", hash = "sha256:27d970adf539806dfc4fe3e4c9e8dc6ebf0318977a56e24d22f13413535a51ba"}, - {file = "dulwich-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:025178533e884ffdb0d9d8db4b8870745d438cbfecb782fd1b56c3b6438e86cf"}, - {file = "dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc"}, - {file = "dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527"}, - {file = "dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261"}, - {file = "dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a"}, - {file = "dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd"}, - {file = "dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e"}, - {file = "dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e"}, - {file = "dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9"}, - {file = "dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf"}, - {file = "dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59"}, - {file = "dulwich-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:624e2223c8b705b3a217f9c8d3bfed3a573093be0b0ba033c46cba8411fb9630"}, - {file = "dulwich-0.23.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b4eaf326d15bb3fc5316c777b0312f0fe02f6f82a4368cd971d0ce2167b7ec34"}, - {file = "dulwich-0.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d754afaf7c133a015c75cc2be11703138b4be932e0eeeb2c70add56083f31109"}, - {file = "dulwich-0.23.0-cp313-cp313-win32.whl", hash = "sha256:ac53ec438bde3c1f479782c34240479b36cd47230d091979137b7ecc12c0242e"}, - {file = "dulwich-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:50d3b4ba45671fb8b7d2afbd02c10b4edbc3290a1f92260e64098b409e9ca35c"}, - {file = "dulwich-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8e18ea3fa49f10932077f39c0b960b5045870c550c3d7c74f3cfaac09457cd6"}, - {file = "dulwich-0.23.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3e6df0eb8cca21f210e3ddce2ccb64482646893dbec2fee9f3411d037595bf7b"}, - {file = "dulwich-0.23.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:90c0064d7df8e7fe83d3a03c7d60b9e07a92698b18442f926199b2c3f0bf34d4"}, - {file = "dulwich-0.23.0-cp39-cp39-win32.whl", hash = "sha256:84eef513aba501cbc1f223863f3b4b351fe732d3fb590cab9bdf5d33eb1a1248"}, - {file = "dulwich-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:dce943da48217c26e15790fd6df62d27a7f1d067102780351ebf2635fc0ba482"}, - {file = "dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135"}, - {file = "dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae"}, -] - -[package.dependencies] -urllib3 = ">=1.25" - -[package.extras] -dev = ["dissolve (>=0.1.1)", "mypy (==1.16.0)", "ruff (==0.11.13)"] -fastimport = ["fastimport"] -https = ["urllib3 (>=1.24.1)"] -merge = ["merge3"] -paramiko = ["paramiko"] -pgp = ["gpg"] - -[[package]] -name = "duo-client" -version = "5.5.0" -description = "Reference client for Duo Security APIs" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "duo_client-5.5.0-py3-none-any.whl", hash = "sha256:4fbf1e97a2b25ef64e9f88171ab817162cf45bafc1c63026af4883baf8892a12"}, - {file = "duo_client-5.5.0.tar.gz", hash = "sha256:303109e047fe7525ba4fc4a294c1f3deb4125066e89c10d33f7430378867b1d6"}, -] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "durationpy" -version = "0.10" -description = "Module for converting between datetime.timedelta and Go's Duration strings." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286"}, - {file = "durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba"}, -] - -[[package]] -name = "email-validator" -version = "2.2.0" -description = "A robust email address syntax and deliverability validation library." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, - {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, -] - -[package.dependencies] -dnspython = ">=2.0.0" -idna = ">=2.0.0" - -[[package]] -name = "execnet" -version = "2.1.2" -description = "execnet: rapid multi-Python deployment" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, - {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, -] - -[package.extras] -testing = ["hatch", "pre-commit", "pytest", "tox"] - -[[package]] -name = "filelock" -version = "3.20.3" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, - {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, -] - -[[package]] -name = "flask" -version = "3.1.3" -description = "A simple framework for building complex web applications." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"}, - {file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"}, -] - -[package.dependencies] -blinker = ">=1.9.0" -click = ">=8.1.3" -itsdangerous = ">=2.2.0" -jinja2 = ">=3.1.2" -markupsafe = ">=2.1.1" -werkzeug = ">=3.1.0" - -[package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] - -[[package]] -name = "fonttools" -version = "4.62.1" -description = "Tools to manipulate font files" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, - {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, - {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"}, - {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"}, - {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"}, - {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"}, - {file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"}, - {file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"}, - {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"}, - {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"}, - {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"}, - {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"}, - {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"}, - {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"}, - {file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"}, - {file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"}, - {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"}, - {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"}, - {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"}, - {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"}, - {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"}, - {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"}, - {file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"}, - {file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"}, - {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"}, - {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"}, - {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"}, - {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"}, - {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"}, - {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"}, - {file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"}, - {file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"}, - {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"}, - {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"}, - {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"}, - {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"}, - {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"}, - {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"}, - {file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"}, - {file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"}, - {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"}, - {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"}, - {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"}, - {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"}, - {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"}, - {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"}, - {file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"}, - {file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"}, - {file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"}, - {file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"}, -] - -[package.extras] -all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] -graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] -lxml = ["lxml (>=4.0)"] -pathops = ["skia-pathops (>=0.5.0)"] -plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.45.0)"] -symfont = ["sympy"] -type1 = ["xattr ; sys_platform == \"darwin\""] -unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] -woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] - -[[package]] -name = "freezegun" -version = "1.5.1" -description = "Let your Python tests travel through time" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, - {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, -] - -[package.dependencies] -python-dateutil = ">=2.7" - -[[package]] -name = "frozenlist" -version = "1.8.0" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, - {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, - {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, - {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, - {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, - {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, - {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, - {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, - {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, - {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, - {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, - {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, - {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, - {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, - {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, - {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, - {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, - {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, - {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, - {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, - {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, - {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, - {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, - {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, - {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, - {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, -] - -[[package]] -name = "gevent" -version = "25.9.1" -description = "Coroutine-based network library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "gevent-25.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:856b990be5590e44c3a3dc6c8d48a40eaccbb42e99d2b791d11d1e7711a4297e"}, - {file = "gevent-25.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fe1599d0b30e6093eb3213551751b24feeb43db79f07e89d98dd2f3330c9063e"}, - {file = "gevent-25.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:f0d8b64057b4bf1529b9ef9bd2259495747fba93d1f836c77bfeaacfec373fd0"}, - {file = "gevent-25.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56cbc820e3136ba52cd690bdf77e47a4c239964d5f80dc657c1068e0fe9521c"}, - {file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5fa9ce5122c085983e33e0dc058f81f5264cebe746de5c401654ab96dddfca8"}, - {file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:03c74fec58eda4b4edc043311fca8ba4f8744ad1632eb0a41d5ec25413581975"}, - {file = "gevent-25.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8ae9f895e8651d10b0a8328a61c9c53da11ea51b666388aa99b0ce90f9fdc27"}, - {file = "gevent-25.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5aff9e8342dc954adb9c9c524db56c2f3557999463445ba3d9cbe3dada7b7"}, - {file = "gevent-25.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1cdf6db28f050ee103441caa8b0448ace545364f775059d5e2de089da975c457"}, - {file = "gevent-25.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:812debe235a8295be3b2a63b136c2474241fa5c58af55e6a0f8cfc29d4936235"}, - {file = "gevent-25.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b28b61ff9216a3d73fe8f35669eefcafa957f143ac534faf77e8a19eb9e6883a"}, - {file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e4b6278b37373306fc6b1e5f0f1cf56339a1377f67c35972775143d8d7776ff"}, - {file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d99f0cb2ce43c2e8305bf75bee61a8bde06619d21b9d0316ea190fc7a0620a56"}, - {file = "gevent-25.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:72152517ecf548e2f838c61b4be76637d99279dbaa7e01b3924df040aa996586"}, - {file = "gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86"}, - {file = "gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692"}, - {file = "gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2"}, - {file = "gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74"}, - {file = "gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51"}, - {file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5"}, - {file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f"}, - {file = "gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3"}, - {file = "gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed"}, - {file = "gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245"}, - {file = "gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82"}, - {file = "gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48"}, - {file = "gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7"}, - {file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47"}, - {file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117"}, - {file = "gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa"}, - {file = "gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1"}, - {file = "gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356"}, - {file = "gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8"}, - {file = "gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e"}, - {file = "gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c"}, - {file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f"}, - {file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6"}, - {file = "gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7"}, - {file = "gevent-25.9.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f18f80aef6b1f6907219affe15b36677904f7cfeed1f6a6bc198616e507ae2d7"}, - {file = "gevent-25.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b274a53e818124a281540ebb4e7a2c524778f745b7a99b01bdecf0ca3ac0ddb0"}, - {file = "gevent-25.9.1-cp39-cp39-win32.whl", hash = "sha256:c6c91f7e33c7f01237755884316110ee7ea076f5bdb9aa0982b6dc63243c0a38"}, - {file = "gevent-25.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a44b0121f3d7c800740ff80351c897e85e76a7e4764690f35c5ad9ec17de5"}, - {file = "gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd"}, -] - -[package.dependencies] -cffi = {version = ">=1.17.1", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} -greenlet = {version = ">=3.2.2", markers = "platform_python_implementation == \"CPython\""} -"zope.event" = "*" -"zope.interface" = "*" - -[package.extras] -dnspython = ["dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\""] -docs = ["furo", "repoze.sphinx.autointerface", "sphinx", "sphinxcontrib-programoutput", "zope.schema"] -monitor = ["psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""] -recommended = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""] -test = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "coverage (>=5.0) ; sys_platform != \"win32\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "objgraph", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\"", "requests"] - -[[package]] -name = "google-api-core" -version = "2.29.0" -description = "Google API client core library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"}, - {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"}, -] - -[package.dependencies] -google-auth = ">=2.14.1,<3.0.0" -googleapis-common-protos = ">=1.56.2,<2.0.0" -grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} -grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} -proto-plus = ">=1.22.3,<2.0.0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" -requests = ">=2.18.0,<3.0.0" - -[package.extras] -async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] -grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] - -[[package]] -name = "google-api-python-client" -version = "2.163.0" -description = "Google API Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_api_python_client-2.163.0-py2.py3-none-any.whl", hash = "sha256:080e8bc0669cb4c1fb8efb8da2f5b91a2625d8f0e7796cfad978f33f7016c6c4"}, - {file = "google_api_python_client-2.163.0.tar.gz", hash = "sha256:88dee87553a2d82176e2224648bf89272d536c8f04dcdda37ef0a71473886dd7"}, -] - -[package.dependencies] -google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" -google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -google-auth-httplib2 = ">=0.2.0,<1.0.0" -httplib2 = ">=0.19.0,<1.dev0" -uritemplate = ">=3.0.1,<5" - -[[package]] -name = "google-auth" -version = "2.48.0" -description = "Google Authentication Library" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f"}, - {file = "google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce"}, -] - -[package.dependencies] -cryptography = ">=38.0.3" -pyasn1-modules = ">=0.2.1" -rsa = ">=3.1.4,<5" - -[package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] -cryptography = ["cryptography (>=38.0.3)"] -enterprise-cert = ["pyopenssl"] -pyjwt = ["pyjwt (>=2.0)"] -pyopenssl = ["pyopenssl (>=20.0.0)"] -reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0)"] -testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] -urllib3 = ["packaging", "urllib3"] - -[[package]] -name = "google-auth-httplib2" -version = "0.2.1" -description = "Google Authentication Library: httplib2 transport" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_auth_httplib2-0.2.1-py3-none-any.whl", hash = "sha256:1be94c611db91c01f9703e7f62b0a59bbd5587a95571c7b6fade510d648bc08b"}, - {file = "google_auth_httplib2-0.2.1.tar.gz", hash = "sha256:5ef03be3927423c87fb69607b42df23a444e434ddb2555b73b3679793187b7de"}, -] - -[package.dependencies] -google-auth = ">=1.32.0,<3.0.0" -httplib2 = ">=0.19.0,<1.0.0" - -[[package]] -name = "google-cloud-access-context-manager" -version = "0.3.0" -description = "Google Cloud Access Context Manager Protobufs" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_cloud_access_context_manager-0.3.0-py3-none-any.whl", hash = "sha256:5d15ad51547f06c281e35f16b4ffcb3e98bb2d898b01470f88b94edfb2eeb0a3"}, - {file = "google_cloud_access_context_manager-0.3.0.tar.gz", hash = "sha256:f3aa35c9225b7aaef85ecdacedcc1577789be8d458b7a41b6ad23b504786e5f9"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[[package]] -name = "google-cloud-asset" -version = "4.2.0" -description = "Google Cloud Asset API client library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_cloud_asset-4.2.0-py3-none-any.whl", hash = "sha256:fd7ea04c64948a4779790343204cd5b41d4772d6ab1d05a9125e28a637ac0862"}, - {file = "google_cloud_asset-4.2.0.tar.gz", hash = "sha256:1734906cfd9b6ea6922861c8f1b4fcabe90d53ca267ee88499e8532b7593b35f"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" -google-cloud-access-context-manager = ">=0.1.2,<1.0.0" -google-cloud-org-policy = ">=0.1.2,<2.0.0" -google-cloud-os-config = ">=1.0.0,<2.0.0" -grpc-google-iam-v1 = ">=0.14.0,<1.0.0" -grpcio = ">=1.33.2,<2.0.0" -proto-plus = ">=1.22.3,<2.0.0" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[[package]] -name = "google-cloud-org-policy" -version = "1.16.0" -description = "Google Cloud Org Policy API client library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_cloud_org_policy-1.16.0-py3-none-any.whl", hash = "sha256:96d1ed38f795182600a58f8eb2879e1577ce663b6b27df0b8a3050960cff87a5"}, - {file = "google_cloud_org_policy-1.16.0.tar.gz", hash = "sha256:c72147127d88d9809af8738b2abe34806eac529c3cdc57aa915cc08a1b842a13"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" -grpcio = ">=1.33.2,<2.0.0" -proto-plus = ">=1.22.3,<2.0.0" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[[package]] -name = "google-cloud-os-config" -version = "1.23.0" -description = "Google Cloud Os Config API client library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_cloud_os_config-1.23.0-py3-none-any.whl", hash = "sha256:fea865018391abca42a9a74d270ddab516ae0865d6e9ad3bcb503286ca01c069"}, - {file = "google_cloud_os_config-1.23.0.tar.gz", hash = "sha256:a629cf55b3ede36b2df89814c6ccf3c1d43c7f1b43db6c7c02eb4860851baf3a"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" -grpcio = ">=1.33.2,<2.0.0" -proto-plus = ">=1.22.3,<2.0.0" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[[package]] -name = "google-cloud-resource-manager" -version = "1.16.0" -description = "Google Cloud Resource Manager API client library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28"}, - {file = "google_cloud_resource_manager-1.16.0.tar.gz", hash = "sha256:cc938f87cc36c2672f062b1e541650629e0d954c405a4dac35ceedee70c267c3"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" -grpc-google-iam-v1 = ">=0.14.0,<1.0.0" -grpcio = ">=1.33.2,<2.0.0" -proto-plus = ">=1.22.3,<2.0.0" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[[package]] -name = "googleapis-common-protos" -version = "1.72.0" -description = "Common protobufs used in Google APIs" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, - {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, -] - -[package.dependencies] -grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0)"] - -[[package]] -name = "gprof2dot" -version = "2025.4.14" -description = "Generate a dot graph from the output of several profilers." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "gprof2dot-2025.4.14-py3-none-any.whl", hash = "sha256:0742e4c0b4409a5e8777e739388a11e1ed3750be86895655312ea7c20bd0090e"}, - {file = "gprof2dot-2025.4.14.tar.gz", hash = "sha256:35743e2d2ca027bf48fa7cba37021aaf4a27beeae1ae8e05a50b55f1f921a6ce"}, -] - -[[package]] -name = "graphemeu" -version = "0.7.2" -description = "Unicode grapheme helpers" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "graphemeu-0.7.2-py3-none-any.whl", hash = "sha256:1444520f6899fd30114fc2a39f297d86d10fa0f23bf7579f772f8bc7efaa2542"}, - {file = "graphemeu-0.7.2.tar.gz", hash = "sha256:42bbe373d7c146160f286cd5f76b1a8ad29172d7333ce10705c5cc282462a4f8"}, -] - -[package.extras] -dev = ["pytest"] -docs = ["sphinx", "sphinx-autobuild"] - -[[package]] -name = "greenlet" -version = "3.3.1" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "platform_python_implementation == \"CPython\"" -files = [ - {file = "greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13"}, - {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4"}, - {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5"}, - {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5"}, - {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe"}, - {file = "greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729"}, - {file = "greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4"}, - {file = "greenlet-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:7932f5f57609b6a3b82cc11877709aa7a98e3308983ed93552a1c377069b20c8"}, - {file = "greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c"}, - {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd"}, - {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5"}, - {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f"}, - {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2"}, - {file = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9"}, - {file = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f"}, - {file = "greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b"}, - {file = "greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4"}, - {file = "greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975"}, - {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36"}, - {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba"}, - {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca"}, - {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336"}, - {file = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1"}, - {file = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149"}, - {file = "greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a"}, - {file = "greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1"}, - {file = "greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3"}, - {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac"}, - {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd"}, - {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e"}, - {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3"}, - {file = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951"}, - {file = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2"}, - {file = "greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946"}, - {file = "greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d"}, - {file = "greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5"}, - {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b"}, - {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e"}, - {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d"}, - {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f"}, - {file = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683"}, - {file = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1"}, - {file = "greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a"}, - {file = "greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79"}, - {file = "greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242"}, - {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774"}, - {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97"}, - {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab"}, - {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2"}, - {file = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53"}, - {file = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249"}, - {file = "greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451"}, - {file = "greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil", "setuptools"] - -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.3" -description = "IAM API client library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6"}, - {file = "grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389"}, -] - -[package.dependencies] -googleapis-common-protos = {version = ">=1.56.0,<2.0.0", extras = ["grpc"]} -grpcio = ">=1.44.0,<2.0.0" -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[[package]] -name = "grpcio" -version = "1.76.0" -description = "HTTP/2-based RPC framework" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"}, - {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"}, - {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"}, - {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"}, - {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"}, - {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"}, - {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"}, - {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"}, - {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"}, - {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"}, - {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"}, - {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"}, - {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"}, - {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"}, - {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"}, - {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"}, - {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"}, - {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"}, - {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"}, - {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"}, - {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"}, - {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"}, - {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"}, - {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"}, - {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"}, - {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"}, - {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"}, - {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"}, - {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"}, - {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"}, - {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"}, - {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"}, - {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"}, - {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"}, - {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"}, - {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"}, - {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"}, - {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"}, - {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"}, - {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"}, - {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"}, - {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"}, - {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"}, - {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"}, - {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"}, - {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"}, - {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"}, - {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"}, - {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"}, - {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"}, - {file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"}, - {file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"}, - {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"}, - {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"}, - {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"}, - {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"}, - {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"}, - {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"}, - {file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"}, - {file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"}, - {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"}, -] - -[package.dependencies] -typing-extensions = ">=4.12,<5.0" - -[package.extras] -protobuf = ["grpcio-tools (>=1.76.0)"] - -[[package]] -name = "grpcio-status" -version = "1.76.0" -description = "Status proto mapping for gRPC" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18"}, - {file = "grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd"}, -] - -[package.dependencies] -googleapis-common-protos = ">=1.5.5" -grpcio = ">=1.76.0" -protobuf = ">=6.31.1,<7.0.0" - -[[package]] -name = "gunicorn" -version = "23.0.0" -description = "WSGI HTTP Server for UNIX" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, - {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, -] - -[package.dependencies] -packaging = "*" - -[package.extras] -eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] -gevent = ["gevent (>=1.4.0)"] -setproctitle = ["setproctitle"] -testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] -tornado = ["tornado (>=0.2)"] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "h2" -version = "4.3.0" -description = "Pure-Python HTTP/2 protocol implementation" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, - {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, -] - -[package.dependencies] -hpack = ">=4.1,<5" -hyperframe = ">=6.1,<7" - -[[package]] -name = "hpack" -version = "4.1.0" -description = "Pure-Python HPACK header encoding" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, - {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.16" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httplib2" -version = "0.31.2" -description = "A comprehensive HTTP client library." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349"}, - {file = "httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24"}, -] - -[package.dependencies] -pyparsing = ">=3.1,<4" - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "humanfriendly" -version = "10.0" -description = "Human friendly output for text interfaces using Python" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] -files = [ - {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, - {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, -] - -[package.dependencies] -pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} - -[[package]] -name = "hyperframe" -version = "6.1.0" -description = "Pure-Python HTTP/2 framing" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, - {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, -] - -[[package]] -name = "iamdata" -version = "0.1.202602021" -description = "IAM data for AWS actions, resources, and conditions based on IAM policy documents. Checked for updates daily." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "iamdata-0.1.202602021-py3-none-any.whl", hash = "sha256:48419662d75dd0e1ea22b9cc98fd70201d4c72760c6897acc46ad9ab90633d18"}, - {file = "iamdata-0.1.202602021.tar.gz", hash = "sha256:c24265fc3694076f65da91a8aa9361b60da25f7b8cfd8ba4ddd6aa1b9bb5153e"}, -] - -[[package]] -name = "idna" -version = "3.11" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, - {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] - -[[package]] -name = "inflection" -version = "0.5.1" -description = "A port of Ruby on Rails inflector to Python" -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, - {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "iso8601" -version = "2.1.0" -description = "Simple module to parse ISO 8601 dates" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, - {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, -] - -[[package]] -name = "isodate" -version = "0.7.2" -description = "An ISO 8601 date/time/duration parser and formatter" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, - {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, -] - -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, - {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, -] - -[package.extras] -colors = ["colorama (>=0.4.6)"] - -[[package]] -name = "itsdangerous" -version = "2.2.0" -description = "Safely pass data to untrusted environments and back." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, - {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jiter" -version = "0.13.0" -description = "Fast iterable JSON parser." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e"}, - {file = "jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a"}, - {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5"}, - {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721"}, - {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060"}, - {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c"}, - {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae"}, - {file = "jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2"}, - {file = "jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5"}, - {file = "jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b"}, - {file = "jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894"}, - {file = "jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d"}, - {file = "jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096"}, - {file = "jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911"}, - {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701"}, - {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c"}, - {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4"}, - {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165"}, - {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018"}, - {file = "jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411"}, - {file = "jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5"}, - {file = "jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3"}, - {file = "jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1"}, - {file = "jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654"}, - {file = "jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5"}, - {file = "jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663"}, - {file = "jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505"}, - {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152"}, - {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726"}, - {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0"}, - {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089"}, - {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93"}, - {file = "jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08"}, - {file = "jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2"}, - {file = "jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228"}, - {file = "jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394"}, - {file = "jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92"}, - {file = "jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9"}, - {file = "jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf"}, - {file = "jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a"}, - {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb"}, - {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2"}, - {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f"}, - {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159"}, - {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663"}, - {file = "jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa"}, - {file = "jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820"}, - {file = "jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68"}, - {file = "jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72"}, - {file = "jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc"}, - {file = "jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b"}, - {file = "jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10"}, - {file = "jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef"}, - {file = "jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6"}, - {file = "jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d"}, - {file = "jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d"}, - {file = "jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0"}, - {file = "jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91"}, - {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09"}, - {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607"}, - {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66"}, - {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2"}, - {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad"}, - {file = "jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d"}, - {file = "jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df"}, - {file = "jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d"}, - {file = "jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6"}, - {file = "jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f"}, - {file = "jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d"}, - {file = "jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0"}, - {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40"}, - {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202"}, - {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0"}, - {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95"}, - {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59"}, - {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe"}, - {file = "jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939"}, - {file = "jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9"}, - {file = "jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6"}, - {file = "jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8"}, - {file = "jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024"}, - {file = "jiter-0.13.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:4397ee562b9f69d283e5674445551b47a5e8076fdde75e71bfac5891113dc543"}, - {file = "jiter-0.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f90023f8f672e13ea1819507d2d21b9d2d1c18920a3b3a5f1541955a85b5504"}, - {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed0240dd1536a98c3ab55e929c60dfff7c899fecafcb7d01161b21a99fc8c363"}, - {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6207fc61c395b26fffdcf637a0b06b4326f35bfa93c6e92fe1a166a21aeb6731"}, - {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00203f47c214156df427b5989de74cb340c65c8180d09be1bf9de81d0abad599"}, - {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c26ad6967c9dcedf10c995a21539c3aa57d4abad7001b7a84f621a263a6b605"}, - {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a576f5dce9ac7de5d350b8e2f552cf364f32975ed84717c35379a51c7cb198bd"}, - {file = "jiter-0.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b22945be8425d161f2e536cdae66da300b6b000f1c0ba3ddf237d1bfd45d21b8"}, - {file = "jiter-0.13.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6eeb7db8bc77dc20476bc2f7407a23dbe3d46d9cc664b166e3d474e1c1de4baa"}, - {file = "jiter-0.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:19cd6f85e1dc090277c3ce90a5b7d96f32127681d825e71c9dce28788e39fc0c"}, - {file = "jiter-0.13.0-cp39-cp39-win32.whl", hash = "sha256:dc3ce84cfd4fa9628fe62c4f85d0d597a4627d4242cfafac32a12cc1455d00f7"}, - {file = "jiter-0.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:9ffda299e417dc83362963966c50cb76d42da673ee140de8a8ac762d4bb2378b"}, - {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c"}, - {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2"}, - {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434"}, - {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d"}, - {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a"}, - {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f"}, - {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59"}, - {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19"}, - {file = "jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4"}, -] - -[[package]] -name = "jmespath" -version = "1.1.0" -description = "JSON Matching Expressions" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, - {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, -] - -[[package]] -name = "joblib" -version = "1.5.3" -description = "Lightweight pipelining with Python functions" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, - {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, -] - -[[package]] -name = "jsonpatch" -version = "1.33" -description = "Apply JSON-Patches (RFC 6902)" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" -groups = ["main"] -files = [ - {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, - {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, -] - -[package.dependencies] -jsonpointer = ">=1.9" - -[[package]] -name = "jsonpickle" -version = "4.1.1" -description = "jsonpickle encodes/decodes any Python object to/from JSON" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91"}, - {file = "jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1"}, -] - -[package.extras] -cov = ["pytest-cov"] -dev = ["black", "pyupgrade"] -docs = ["furo", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -packaging = ["build", "setuptools (>=61.2)", "setuptools_scm[toml] (>=6.0)", "twine"] -testing = ["PyYAML", "atheris (>=2.3.0,<2.4.0) ; python_version < \"3.12\"", "bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=6.0,!=8.1.*)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy (>=1.9.3) ; python_version > \"3.10\"", "scipy ; python_version <= \"3.10\"", "simplejson", "sqlalchemy", "ujson"] - -[[package]] -name = "jsonpointer" -version = "3.0.0" -description = "Identify specific nodes in a JSON document (RFC 6901)" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, - {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, -] - -[[package]] -name = "jsonschema" -version = "4.23.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, - {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" -referencing = ">=0.28.4" -rpds-py = ">=0.7.1" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, - {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "keystoneauth1" -version = "5.13.0" -description = "Authentication Library for OpenStack Identity" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41"}, - {file = "keystoneauth1-5.13.0.tar.gz", hash = "sha256:57c9ca407207899b50d8ff1ca8abb4a4e7427461bfc1877eb8519c3989ce63ec"}, -] - -[package.dependencies] -iso8601 = ">=2.0.0" -os-service-types = ">=1.2.0" -pbr = ">=2.0.0" -requests = ">=2.14.2" -stevedore = ">=1.20.0" -typing-extensions = ">=4.12" - -[package.extras] -betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"] -kerberos = ["requests-kerberos (>=0.8.0)"] -oauth1 = ["oauthlib (>=0.6.2)"] -saml2 = ["lxml (>=4.2.0)"] - -[[package]] -name = "kiwisolver" -version = "1.4.9" -description = "A fast implementation of the Cassowary constraint solver" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, - {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, - {file = "kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634"}, - {file = "kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611"}, - {file = "kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536"}, - {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16"}, - {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089"}, - {file = "kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464"}, - {file = "kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2"}, - {file = "kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7"}, - {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999"}, - {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2"}, - {file = "kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145"}, - {file = "kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54"}, - {file = "kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60"}, - {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8"}, - {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2"}, - {file = "kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c"}, - {file = "kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d"}, - {file = "kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c"}, - {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386"}, - {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552"}, - {file = "kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce"}, - {file = "kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7"}, - {file = "kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1"}, - {file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"}, -] - -[[package]] -name = "knack" -version = "0.11.0" -description = "A Command-Line Interface framework" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "knack-0.11.0-py3-none-any.whl", hash = "sha256:6704c867840978a119a193914a90e2e98c7be7dff764c8fcd8a2286c5a978d00"}, - {file = "knack-0.11.0.tar.gz", hash = "sha256:eb6568001e9110b1b320941431c51033d104cc98cda2254a5c2b09ba569fd494"}, -] - -[package.dependencies] -argcomplete = "*" -jmespath = "*" -packaging = "*" -pygments = "*" -pyyaml = "*" -tabulate = "*" - -[[package]] -name = "kombu" -version = "5.6.2" -description = "Messaging library for Python." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93"}, - {file = "kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55"}, -] - -[package.dependencies] -amqp = ">=5.1.1,<6.0.0" -packaging = "*" -tzdata = ">=2025.2" -vine = "5.1.0" - -[package.extras] -azureservicebus = ["azure-servicebus (>=7.10.0)"] -azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6.0)"] -confluentkafka = ["confluent-kafka (>=2.2.0)"] -consul = ["python-consul2 (==0.1.5)"] -gcpubsub = ["google-cloud-monitoring (>=2.16.0)", "google-cloud-pubsub (>=2.18.4)", "grpcio (==1.75.1)", "protobuf (==6.32.1)"] -librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] -mongodb = ["pymongo (==4.15.3)"] -msgpack = ["msgpack (==1.1.2)"] -pyro = ["pyro4 (==4.82)"] -qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"] -redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"] -slmq = ["softlayer_messaging (>=1.0.3)"] -sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] -sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16)"] -yaml = ["PyYAML (>=3.10)"] -zookeeper = ["kazoo (>=2.8.0)"] - -[[package]] -name = "kubernetes" -version = "32.0.1" -description = "Kubernetes python client" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998"}, - {file = "kubernetes-32.0.1.tar.gz", hash = "sha256:42f43d49abd437ada79a79a16bd48a604d3471a117a8347e87db693f2ba0ba28"}, -] - -[package.dependencies] -certifi = ">=14.5.14" -durationpy = ">=0.7" -google-auth = ">=1.0.1" -oauthlib = ">=3.2.2" -python-dateutil = ">=2.5.3" -pyyaml = ">=5.4.1" -requests = "*" -requests-oauthlib = "*" -six = ">=1.9.0" -urllib3 = ">=1.24.2" -websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0" - -[package.extras] -adal = ["adal (>=1.0.2)"] - -[[package]] -name = "lxml" -version = "5.3.2" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "lxml-5.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c4b84d6b580a9625dfa47269bf1fd7fbba7ad69e08b16366a46acb005959c395"}, - {file = "lxml-5.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4c08ecb26e4270a62f81f81899dfff91623d349e433b126931c9c4577169666"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef926e9f11e307b5a7c97b17c5c609a93fb59ffa8337afac8f89e6fe54eb0b37"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017ceeabe739100379fe6ed38b033cd244ce2da4e7f6f07903421f57da3a19a2"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae97d9435dc90590f119d056d233c33006b2fd235dd990d5564992261ee7ae8"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:910f39425c6798ce63c93976ae5af5fff6949e2cb446acbd44d6d892103eaea8"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9780de781a0d62a7c3680d07963db3048b919fc9e3726d9cfd97296a65ffce1"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:1a06b0c6ba2e3ca45a009a78a4eb4d6b63831830c0a83dcdc495c13b9ca97d3e"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:4c62d0a34d1110769a1bbaf77871a4b711a6f59c4846064ccb78bc9735978644"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:8f961a4e82f411b14538fe5efc3e6b953e17f5e809c463f0756a0d0e8039b700"}, - {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3dfc78f5f9251b6b8ad37c47d4d0bfe63ceb073a916e5b50a3bf5fd67a703335"}, - {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10e690bc03214d3537270c88e492b8612d5e41b884f232df2b069b25b09e6711"}, - {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa837e6ee9534de8d63bc4c1249e83882a7ac22bd24523f83fad68e6ffdf41ae"}, - {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:da4c9223319400b97a2acdfb10926b807e51b69eb7eb80aad4942c0516934858"}, - {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc0e9bdb3aa4d1de703a437576007d366b54f52c9897cae1a3716bb44fc1fc85"}, - {file = "lxml-5.3.2-cp310-cp310-win32.win32.whl", hash = "sha256:dd755a0a78dd0b2c43f972e7b51a43be518ebc130c9f1a7c4480cf08b4385486"}, - {file = "lxml-5.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:d64ea1686474074b38da13ae218d9fde0d1dc6525266976808f41ac98d9d7980"}, - {file = "lxml-5.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d61a7d0d208ace43986a92b111e035881c4ed45b1f5b7a270070acae8b0bfb4"}, - {file = "lxml-5.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856dfd7eda0b75c29ac80a31a6411ca12209183e866c33faf46e77ace3ce8a79"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a01679e4aad0727bedd4c9407d4d65978e920f0200107ceeffd4b019bd48529"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6b37b4c3acb8472d191816d4582379f64d81cecbdce1a668601745c963ca5cc"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3df5a54e7b7c31755383f126d3a84e12a4e0333db4679462ef1165d702517477"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c09a40f28dcded933dc16217d6a092be0cc49ae25811d3b8e937c8060647c353"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1ef20f1851ccfbe6c5a04c67ec1ce49da16ba993fdbabdce87a92926e505412"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f79a63289dbaba964eb29ed3c103b7911f2dce28c36fe87c36a114e6bd21d7ad"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:75a72697d95f27ae00e75086aed629f117e816387b74a2f2da6ef382b460b710"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:b9b00c9ee1cc3a76f1f16e94a23c344e0b6e5c10bec7f94cf2d820ce303b8c01"}, - {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:77cbcab50cbe8c857c6ba5f37f9a3976499c60eada1bf6d38f88311373d7b4bc"}, - {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29424058f072a24622a0a15357bca63d796954758248a72da6d512f9bd9a4493"}, - {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7d82737a8afe69a7c80ef31d7626075cc7d6e2267f16bf68af2c764b45ed68ab"}, - {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:95473d1d50a5d9fcdb9321fdc0ca6e1edc164dce4c7da13616247d27f3d21e31"}, - {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2162068f6da83613f8b2a32ca105e37a564afd0d7009b0b25834d47693ce3538"}, - {file = "lxml-5.3.2-cp311-cp311-win32.whl", hash = "sha256:f8695752cf5d639b4e981afe6c99e060621362c416058effd5c704bede9cb5d1"}, - {file = "lxml-5.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:d1a94cbb4ee64af3ab386c2d63d6d9e9cf2e256ac0fd30f33ef0a3c88f575174"}, - {file = "lxml-5.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:16b3897691ec0316a1aa3c6585f61c8b7978475587c5b16fc1d2c28d283dc1b0"}, - {file = "lxml-5.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8d4b34a0eeaf6e73169dcfd653c8d47f25f09d806c010daf074fba2db5e2d3f"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cd7a959396da425022e1e4214895b5cfe7de7035a043bcc2d11303792b67554"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cac5eaeec3549c5df7f8f97a5a6db6963b91639389cdd735d5a806370847732b"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b5f7d77334877c2146e7bb8b94e4df980325fab0a8af4d524e5d43cd6f789d"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13f3495cfec24e3d63fffd342cc8141355d1d26ee766ad388775f5c8c5ec3932"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e70ad4c9658beeff99856926fd3ee5fde8b519b92c693f856007177c36eb2e30"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:507085365783abd7879fa0a6fa55eddf4bdd06591b17a2418403bb3aff8a267d"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:5bb304f67cbf5dfa07edad904732782cbf693286b9cd85af27059c5779131050"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:3d84f5c093645c21c29a4e972b84cb7cf682f707f8706484a5a0c7ff13d7a988"}, - {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bdc13911db524bd63f37b0103af014b7161427ada41f1b0b3c9b5b5a9c1ca927"}, - {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ec944539543f66ebc060ae180d47e86aca0188bda9cbfadff47d86b0dc057dc"}, - {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:59d437cc8a7f838282df5a199cf26f97ef08f1c0fbec6e84bd6f5cc2b7913f6e"}, - {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e275961adbd32e15672e14e0cc976a982075208224ce06d149c92cb43db5b93"}, - {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:038aeb6937aa404480c2966b7f26f1440a14005cb0702078c173c028eca72c31"}, - {file = "lxml-5.3.2-cp312-cp312-win32.whl", hash = "sha256:3c2c8d0fa3277147bff180e3590be67597e17d365ce94beb2efa3138a2131f71"}, - {file = "lxml-5.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:77809fcd97dfda3f399102db1794f7280737b69830cd5c961ac87b3c5c05662d"}, - {file = "lxml-5.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77626571fb5270ceb36134765f25b665b896243529eefe840974269b083e090d"}, - {file = "lxml-5.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78a533375dc7aa16d0da44af3cf6e96035e484c8c6b2b2445541a5d4d3d289ee"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6f62b2404b3f3f0744bbcabb0381c5fe186fa2a9a67ecca3603480f4846c585"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ea918da00091194526d40c30c4996971f09dacab032607581f8d8872db34fbf"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c35326f94702a7264aa0eea826a79547d3396a41ae87a70511b9f6e9667ad31c"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3bef90af21d31c4544bc917f51e04f94ae11b43156356aff243cdd84802cbf2"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52fa7ba11a495b7cbce51573c73f638f1dcff7b3ee23697467dc063f75352a69"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad131e2c4d2c3803e736bb69063382334e03648de2a6b8f56a878d700d4b557d"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:00a4463ca409ceacd20490a893a7e08deec7870840eff33dc3093067b559ce3e"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:87e8d78205331cace2b73ac8249294c24ae3cba98220687b5b8ec5971a2267f1"}, - {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bf6389133bb255e530a4f2f553f41c4dd795b1fbb6f797aea1eff308f1e11606"}, - {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3709fc752b42fb6b6ffa2ba0a5b9871646d97d011d8f08f4d5b3ee61c7f3b2b"}, - {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abc795703d0de5d83943a4badd770fbe3d1ca16ee4ff3783d7caffc252f309ae"}, - {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:98050830bb6510159f65d9ad1b8aca27f07c01bb3884ba95f17319ccedc4bcf9"}, - {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ba465a91acc419c5682f8b06bcc84a424a7aa5c91c220241c6fd31de2a72bc6"}, - {file = "lxml-5.3.2-cp313-cp313-win32.whl", hash = "sha256:56a1d56d60ea1ec940f949d7a309e0bff05243f9bd337f585721605670abb1c1"}, - {file = "lxml-5.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:1a580dc232c33d2ad87d02c8a3069d47abbcdce974b9c9cc82a79ff603065dbe"}, - {file = "lxml-5.3.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1a59f7fe888d0ec1916d0ad69364c5400cfa2f885ae0576d909f342e94d26bc9"}, - {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d67b50abc2df68502a26ed2ccea60c1a7054c289fb7fc31c12e5e55e4eec66bd"}, - {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cb08d2cb047c98d6fbbb2e77d6edd132ad6e3fa5aa826ffa9ea0c9b1bc74a84"}, - {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:495ddb7e10911fb4d673d8aa8edd98d1eadafb3b56e8c1b5f427fd33cadc455b"}, - {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:884d9308ac7d581b705a3371185282e1b8eebefd68ccf288e00a2d47f077cc51"}, - {file = "lxml-5.3.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:37f3d7cf7f2dd2520df6cc8a13df4c3e3f913c8e0a1f9a875e44f9e5f98d7fee"}, - {file = "lxml-5.3.2-cp36-cp36m-win32.whl", hash = "sha256:e885a1bf98a76dff0a0648850c3083b99d9358ef91ba8fa307c681e8e0732503"}, - {file = "lxml-5.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:b45f505d0d85f4cdd440cd7500689b8e95110371eaa09da0c0b1103e9a05030f"}, - {file = "lxml-5.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b53cd668facd60b4f0dfcf092e01bbfefd88271b5b4e7b08eca3184dd006cb30"}, - {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5dea998c891f082fe204dec6565dbc2f9304478f2fc97bd4d7a940fec16c873"}, - {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d46bc3e58b01e4f38d75e0d7f745a46875b7a282df145aca9d1479c65ff11561"}, - {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661feadde89159fd5f7d7639a81ccae36eec46974c4a4d5ccce533e2488949c8"}, - {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:43af2a69af2cacc2039024da08a90174e85f3af53483e6b2e3485ced1bf37151"}, - {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:1539f962d82436f3d386eb9f29b2a29bb42b80199c74a695dff51b367a61ec0a"}, - {file = "lxml-5.3.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:6673920bf976421b5fac4f29b937702eef4555ee42329546a5fc68bae6178a48"}, - {file = "lxml-5.3.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9fa722a9cd8845594593cce399a49aa6bfc13b6c83a7ee05e2ab346d9253d52f"}, - {file = "lxml-5.3.2-cp37-cp37m-win32.whl", hash = "sha256:2eadd4efa487f4710755415aed3d6ae9ac8b4327ea45226ffccb239766c8c610"}, - {file = "lxml-5.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:83d8707b1b08cd02c04d3056230ec3b771b18c566ec35e723e60cdf037064e08"}, - {file = "lxml-5.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc6e8678bfa5ccba370103976ccfcf776c85c83da9220ead41ea6fd15d2277b4"}, - {file = "lxml-5.3.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bed509662f67f719119ad56006cd4a38efa68cfa74383060612044915e5f7ad"}, - {file = "lxml-5.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3925975fadd6fd72a6d80541a6ec75dfbad54044a03aa37282dafcb80fbdfa"}, - {file = "lxml-5.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83c0462dedc5213ac586164c6d7227da9d4d578cf45dd7fbab2ac49b63a008eb"}, - {file = "lxml-5.3.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:53e3f9ca72858834688afa17278649d62aa768a4b2018344be00c399c4d29e95"}, - {file = "lxml-5.3.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:32ba634ef3f1b20f781019a91d78599224dc45745dd572f951adbf1c0c9b0d75"}, - {file = "lxml-5.3.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1b16504c53f41da5fcf04868a80ac40a39d3eec5329caf761114caec6e844ad1"}, - {file = "lxml-5.3.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1f9682786138549da44ca4c49b20e7144d063b75f2b2ba611f4cff9b83db1062"}, - {file = "lxml-5.3.2-cp38-cp38-win32.whl", hash = "sha256:d8f74ef8aacdf6ee5c07566a597634bb8535f6b53dc89790db43412498cf6026"}, - {file = "lxml-5.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:49f1cee0fa27e1ee02589c696a9bdf4027e7427f184fa98e6bef0c6613f6f0fa"}, - {file = "lxml-5.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:741c126bcf9aa939e950e64e5e0a89c8e01eda7a5f5ffdfc67073f2ed849caea"}, - {file = "lxml-5.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ab6e9e6aca1fd7d725ffa132286e70dee5b9a4561c5ed291e836440b82888f89"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58e8c9b9ed3c15c2d96943c14efc324b69be6352fe5585733a7db2bf94d97841"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7811828ddfb8c23f4f1fbf35e7a7b2edec2f2e4c793dee7c52014f28c4b35238"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72968623efb1e12e950cbdcd1d0f28eb14c8535bf4be153f1bfffa818b1cf189"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ebfceaa2ea588b54efb6160e3520983663d45aed8a3895bb2031ada080fb5f04"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d685d458505b2bfd2e28c812749fe9194a2b0ce285a83537e4309a187ffa270b"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:334e0e414dab1f5366ead8ca34ec3148415f236d5660e175f1d640b11d645847"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02e56f7de72fa82561eae69628a7d6febd7891d72248c7ff7d3e7814d4031017"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:638d06b4e1d34d1a074fa87deed5fb55c18485fa0dab97abc5604aad84c12031"}, - {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:354dab7206d22d7a796fa27c4c5bffddd2393da2ad61835355a4759d435beb47"}, - {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9d9f82ff2c3bf9bb777cb355149f7f3a98ec58f16b7428369dc27ea89556a4c"}, - {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:95ad58340e3b7d2b828efc370d1791856613c5cb62ae267158d96e47b3c978c9"}, - {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:30fe05f4b7f6e9eb32862745512e7cbd021070ad0f289a7f48d14a0d3fc1d8a9"}, - {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34c688fef86f73dbca0798e0a61bada114677006afa524a8ce97d9e5fabf42e6"}, - {file = "lxml-5.3.2-cp39-cp39-win32.whl", hash = "sha256:4d6d3d1436d57f41984920667ec5ef04bcb158f80df89ac4d0d3f775a2ac0c87"}, - {file = "lxml-5.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:2996e1116bbb3ae2a1fbb2ba4da8f92742290b4011e7e5bce2bd33bbc9d9485a"}, - {file = "lxml-5.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:521ab9c80b98c30b2d987001c3ede2e647e92eeb2ca02e8cb66ef5122d792b24"}, - {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f1231b0f9810289d41df1eacc4ebb859c63e4ceee29908a0217403cddce38d0"}, - {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271f1a4d5d2b383c36ad8b9b489da5ea9c04eca795a215bae61ed6a57cf083cd"}, - {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:6fca8a5a13906ba2677a5252752832beb0f483a22f6c86c71a2bb320fba04f61"}, - {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ea0c3b7922209160faef194a5b6995bfe7fa05ff7dda6c423ba17646b7b9de10"}, - {file = "lxml-5.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0a006390834603e5952a2ff74b9a31a6007c7cc74282a087aa6467afb4eea987"}, - {file = "lxml-5.3.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:eae4136a3b8c4cf76f69461fc8f9410d55d34ea48e1185338848a888d71b9675"}, - {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48e06be8d8c58e7feaedd8a37897a6122637efb1637d7ce00ddf5f11f9a92ad"}, - {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b83aed409134093d90e114007034d2c1ebcd92e501b71fd9ec70e612c8b2eb"}, - {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7a0e77edfe26d3703f954d46bed52c3ec55f58586f18f4b7f581fc56954f1d84"}, - {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:19f6fcfd15b82036b4d235749d78785eb9c991c7812012dc084e0d8853b4c1c0"}, - {file = "lxml-5.3.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d49919c95d31ee06eefd43d8c6f69a3cc9bdf0a9b979cc234c4071f0eb5cb173"}, - {file = "lxml-5.3.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2d0a60841410123c533990f392819804a8448853f06daf412c0f383443925e89"}, - {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b7f729e03090eb4e3981f10efaee35e6004b548636b1a062b8b9a525e752abc"}, - {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:579df6e20d8acce3bcbc9fb8389e6ae00c19562e929753f534ba4c29cfe0be4b"}, - {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2abcf3f3b8367d6400b908d00d4cd279fc0b8efa287e9043820525762d383699"}, - {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:348c06cb2e3176ce98bee8c397ecc89181681afd13d85870df46167f140a305f"}, - {file = "lxml-5.3.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:617ecaccd565cbf1ac82ffcaa410e7da5bd3a4b892bb3543fb2fe19bd1c4467d"}, - {file = "lxml-5.3.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c3eb4278dcdb9d86265ed2c20b9ecac45f2d6072e3904542e591e382c87a9c00"}, - {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:258b6b53458c5cbd2a88795557ff7e0db99f73a96601b70bc039114cd4ee9e02"}, - {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a9d8d25ed2f2183e8471c97d512a31153e123ac5807f61396158ef2793cb6e"}, - {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73bcb635a848c18a3e422ea0ab0092f2e4ef3b02d8ebe87ab49748ebc8ec03d8"}, - {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1545de0a69a16ced5767bae8cca1801b842e6e49e96f5e4a8a5acbef023d970b"}, - {file = "lxml-5.3.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:165fcdc2f40fc0fe88a3c3c06c9c2a097388a90bda6a16e6f7c9199c903c9b8e"}, - {file = "lxml-5.3.2.tar.gz", hash = "sha256:773947d0ed809ddad824b7b14467e1a481b8976e87278ac4a730c2f7c7fcddc1"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html-clean = ["lxml_html_clean"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.11,<3.1.0)"] - -[[package]] -name = "lz4" -version = "4.4.5" -description = "LZ4 Bindings for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d"}, - {file = "lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f"}, - {file = "lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3"}, - {file = "lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758"}, - {file = "lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1"}, - {file = "lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc"}, - {file = "lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd"}, - {file = "lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397"}, - {file = "lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4"}, - {file = "lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43"}, - {file = "lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7"}, - {file = "lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb"}, - {file = "lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989"}, - {file = "lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d"}, - {file = "lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004"}, - {file = "lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b"}, - {file = "lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e"}, - {file = "lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a"}, - {file = "lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5"}, - {file = "lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e"}, - {file = "lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e"}, - {file = "lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50"}, - {file = "lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33"}, - {file = "lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301"}, - {file = "lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c"}, - {file = "lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a"}, - {file = "lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d"}, - {file = "lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c"}, - {file = "lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64"}, - {file = "lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832"}, - {file = "lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22"}, - {file = "lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9"}, - {file = "lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f"}, - {file = "lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba"}, - {file = "lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d"}, - {file = "lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67"}, - {file = "lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d"}, - {file = "lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901"}, - {file = "lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb"}, - {file = "lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd"}, - {file = "lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f"}, - {file = "lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6"}, - {file = "lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9"}, - {file = "lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668"}, - {file = "lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f"}, - {file = "lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67"}, - {file = "lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be"}, - {file = "lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7"}, - {file = "lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad"}, - {file = "lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294"}, - {file = "lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86"}, - {file = "lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547"}, - {file = "lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581"}, - {file = "lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce"}, - {file = "lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7"}, - {file = "lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0"}, - {file = "lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0"}, -] - -[package.extras] -docs = ["sphinx (>=1.6.0)", "sphinx_bootstrap_theme"] -flake8 = ["flake8"] -tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"] - -[[package]] -name = "markdown" -version = "3.10.2" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, - {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, -] - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "marshmallow" -version = "3.26.2" -description = "A lightweight library for converting complex datatypes to and from native Python datatypes." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, - {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, -] - -[package.dependencies] -packaging = ">=17.0" - -[package.extras] -dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] -docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] -tests = ["pytest", "simplejson"] - -[[package]] -name = "matplotlib" -version = "3.10.8" -description = "Python plotting package" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7"}, - {file = "matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17"}, - {file = "matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933"}, - {file = "matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2"}, - {file = "matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f"}, - {file = "matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce"}, - {file = "matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565"}, - {file = "matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008"}, - {file = "matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1"}, - {file = "matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2"}, - {file = "matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3"}, -] - -[package.dependencies] -contourpy = ">=1.0.1" -cycler = ">=0.10" -fonttools = ">=4.22.0" -kiwisolver = ">=1.3.1" -numpy = ">=1.23" -packaging = ">=20.0" -pillow = ">=8" -pyparsing = ">=3" -python-dateutil = ">=2.7" - -[package.extras] -dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "microsoft-kiota-abstractions" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_abstractions-1.9.2-py3-none-any.whl", hash = "sha256:a8853d272a84da59d6a2fe11a76c28e9c55bdab268a345ba48e918cb6822b607"}, - {file = "microsoft_kiota_abstractions-1.9.2.tar.gz", hash = "sha256:29cdafe8d0672f23099556e0b120dca6231c752cca9393e1e0092fa9ca594572"}, -] - -[package.dependencies] -opentelemetry-api = ">=1.27.0" -opentelemetry-sdk = ">=1.27.0" -std-uritemplate = ">=2.0.0" - -[[package]] -name = "microsoft-kiota-authentication-azure" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_authentication_azure-1.9.2-py3-none-any.whl", hash = "sha256:56840f8b15df8aedfd143fb2deb7cc7fae4ac0bafb1a50546b7313a7b3ab4ca0"}, - {file = "microsoft_kiota_authentication_azure-1.9.2.tar.gz", hash = "sha256:171045f522a93d9340fbddc4cabb218f14f1d9d289e82e535b3d9291986c3d5a"}, -] - -[package.dependencies] -aiohttp = ">=3.8.0" -azure-core = ">=1.21.1" -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" -opentelemetry-api = ">=1.27.0" -opentelemetry-sdk = ">=1.27.0" - -[[package]] -name = "microsoft-kiota-http" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_http-1.9.2-py3-none-any.whl", hash = "sha256:3a2d930a70d0184d9f4848473f929ee892462cae1acfaf33b2d193f1828c76c2"}, - {file = "microsoft_kiota_http-1.9.2.tar.gz", hash = "sha256:2ba3d04a3d1d5d600736eebc1e33533d54d87799ac4fbb92c9cce4a97809af61"}, -] - -[package.dependencies] -httpx = {version = ">=0.25,<1.0.0", extras = ["http2"]} -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" -opentelemetry-api = ">=1.27.0" -opentelemetry-sdk = ">=1.27.0" - -[[package]] -name = "microsoft-kiota-serialization-form" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_form-1.9.2-py3-none-any.whl", hash = "sha256:7b997efb2c8750b1d4fbc00878ba2a3e6e1df3fcefc8815226c90fcc9c54f218"}, - {file = "microsoft_kiota_serialization_form-1.9.2.tar.gz", hash = "sha256:badfbe65d8ec3369bd58b01022d13ef590edf14babeef94188efe3f4ec24fe41"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "microsoft-kiota-serialization-json" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_json-1.9.2-py3-none-any.whl", hash = "sha256:8f4ecf485607fff3df5ce8fa9b9c957bc7f4bff1658b183703e180af753098e3"}, - {file = "microsoft_kiota_serialization_json-1.9.2.tar.gz", hash = "sha256:19f7beb69c67b2cb77ca96f77824ee78a693929e20237bb5476ea54f69118bf1"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "microsoft-kiota-serialization-multipart" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_multipart-1.9.2-py3-none-any.whl", hash = "sha256:641ad374046f1c7adff90d110bdc68d77418adb1e479a716f4ffea3647f0ead6"}, - {file = "microsoft_kiota_serialization_multipart-1.9.2.tar.gz", hash = "sha256:b1851409205668d83f5c7a35a8b6fca974b341985b4a92841e95aaec93b7ca0a"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "microsoft-kiota-serialization-text" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_text-1.9.2-py3-none-any.whl", hash = "sha256:6e63129ea29eb9b976f4ed56fc6595d204e29fc309958b639299e9f9f4e5edb4"}, - {file = "microsoft_kiota_serialization_text-1.9.2.tar.gz", hash = "sha256:4289508ebac0cefdc4fa21c545051769a9409913972355ccda9116b647f978f2"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "microsoft-security-utilities-secret-masker" -version = "1.0.0b4" -description = "A tool for detecting and masking secrets" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "microsoft_security_utilities_secret_masker-1.0.0b4-py3-none-any.whl", hash = "sha256:0429fcaad10fc8ae3f940ab84fd2926e4f50ede134162144123b35937be831a8"}, - {file = "microsoft_security_utilities_secret_masker-1.0.0b4.tar.gz", hash = "sha256:a30bd361ac18c8b52f6844076bc26465335949ea9c7a004d95f5196ec6fdef3e"}, -] - -[[package]] -name = "msal" -version = "1.35.0b1" -description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "msal-1.35.0b1-py3-none-any.whl", hash = "sha256:bf656775c64bbc2103d8255980f5c3c966c7432106795e1fe70ca338a7e43150"}, - {file = "msal-1.35.0b1.tar.gz", hash = "sha256:fe8143079183a5c952cd9f3ba66a148fe7bae9fb9952bd0e834272bfbeb34508"}, -] - -[package.dependencies] -cryptography = ">=2.5,<49" -PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]} -pymsalruntime = [ - {version = ">=0.14,<0.21", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Windows\" and extra == \"broker\""}, - {version = ">=0.17,<0.21", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Darwin\" and extra == \"broker\""}, - {version = ">=0.18,<0.21", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Linux\" and extra == \"broker\""}, -] -requests = ">=2.0.0,<3" - -[package.extras] -broker = ["pymsalruntime (>=0.14,<0.21) ; python_version >= \"3.8\" and platform_system == \"Windows\"", "pymsalruntime (>=0.17,<0.21) ; python_version >= \"3.8\" and platform_system == \"Darwin\"", "pymsalruntime (>=0.18,<0.21) ; python_version >= \"3.8\" and platform_system == \"Linux\""] - -[[package]] -name = "msal-extensions" -version = "1.2.0" -description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "msal_extensions-1.2.0-py3-none-any.whl", hash = "sha256:cf5ba83a2113fa6dc011a254a72f1c223c88d7dfad74cc30617c4679a417704d"}, - {file = "msal_extensions-1.2.0.tar.gz", hash = "sha256:6f41b320bfd2933d631a215c91ca0dd3e67d84bd1a2f50ce917d5874ec646bef"}, -] - -[package.dependencies] -msal = ">=1.29,<2" -portalocker = ">=1.4,<3" - -[[package]] -name = "msgraph-core" -version = "1.3.8" -description = "Core component of the Microsoft Graph Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "msgraph_core-1.3.8-py3-none-any.whl", hash = "sha256:86d83edcf62119946f201d13b7e857c947ef67addb088883940197081de85bea"}, - {file = "msgraph_core-1.3.8.tar.gz", hash = "sha256:6e883f9d4c4ad57501234749e07b010478c1a5f19550ef4cf005bbcac4a63ae7"}, -] - -[package.dependencies] -httpx = {version = ">=0.23.0", extras = ["http2"]} -microsoft-kiota-abstractions = ">=1.8.0,<2.0.0" -microsoft-kiota-authentication-azure = ">=1.8.0,<2.0.0" -microsoft-kiota-http = ">=1.8.0,<2.0.0" - -[package.extras] -dev = ["bumpver", "isort", "mypy", "pylint", "pytest", "yapf"] - -[[package]] -name = "msgraph-sdk" -version = "1.23.0" -description = "The Microsoft Graph Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "msgraph_sdk-1.23.0-py3-none-any.whl", hash = "sha256:58e0047b4ca59fd82022c02cd73fec0170a3d84f3b76721e3db2a0314df9a58a"}, - {file = "msgraph_sdk-1.23.0.tar.gz", hash = "sha256:6dd1ba9a46f5f0ce8599fd9610133adbd9d1493941438b5d3632fce9e55ed607"}, -] - -[package.dependencies] -azure-identity = ">=1.12.0" -microsoft-kiota-serialization-form = ">=1.8.0,<2.0.0" -microsoft-kiota-serialization-json = ">=1.8.0,<2.0.0" -microsoft-kiota-serialization-multipart = ">=1.8.0,<2.0.0" -microsoft-kiota-serialization-text = ">=1.8.0,<2.0.0" -msgraph_core = ">=1.3.1" - -[package.extras] -dev = ["bumpver", "isort", "mypy", "pylint", "pytest", "yapf"] - -[[package]] -name = "msrest" -version = "0.7.1" -description = "AutoRest swagger generator Python client runtime." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32"}, - {file = "msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9"}, -] - -[package.dependencies] -azure-core = ">=1.24.0" -certifi = ">=2017.4.17" -isodate = ">=0.6.0" -requests = ">=2.16,<3.0" -requests-oauthlib = ">=0.5.0" - -[package.extras] -async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] - -[[package]] -name = "msrestazure" -version = "0.6.4.post1" -description = "AutoRest swagger generator Python client runtime. Azure-specific module." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "msrestazure-0.6.4.post1-py2.py3-none-any.whl", hash = "sha256:2264493b086c2a0a82ddf5fd87b35b3fffc443819127fed992ac5028354c151e"}, - {file = "msrestazure-0.6.4.post1.tar.gz", hash = "sha256:39842007569e8c77885ace5c46e4bf2a9108fcb09b1e6efdf85b6e2c642b55d4"}, -] - -[package.dependencies] -adal = ">=0.6.0,<2.0.0" -msrest = ">=0.6.0,<2.0.0" -six = "*" - -[[package]] -name = "multidict" -version = "6.7.1" -description = "multidict implementation" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, - {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, - {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, - {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, - {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, - {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, - {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, - {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, - {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, - {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, - {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, - {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, - {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, - {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, - {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, - {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, - {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, - {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, - {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, - {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, - {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, - {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, - {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, - {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, - {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, - {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, - {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, - {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, - {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, -] - -[[package]] -name = "mypy" -version = "1.10.1" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"}, - {file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"}, - {file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"}, - {file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"}, - {file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"}, - {file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"}, - {file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"}, - {file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"}, - {file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"}, - {file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"}, - {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, - {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, - {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, - {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, - {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, - {file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"}, - {file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"}, - {file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"}, - {file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"}, - {file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"}, - {file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"}, - {file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"}, - {file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"}, - {file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"}, - {file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"}, - {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, - {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -typing-extensions = ">=4.1.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "narwhals" -version = "2.16.0" -description = "Extremely lightweight compatibility layer between dataframe libraries" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "narwhals-2.16.0-py3-none-any.whl", hash = "sha256:846f1fd7093ac69d63526e50732033e86c30ea0026a44d9b23991010c7d1485d"}, - {file = "narwhals-2.16.0.tar.gz", hash = "sha256:155bb45132b370941ba0396d123cf9ed192bf25f39c4cea726f2da422ca4e145"}, -] - -[package.extras] -cudf = ["cudf-cu12 (>=24.10.0)"] -dask = ["dask[dataframe] (>=2024.8)"] -duckdb = ["duckdb (>=1.1)"] -ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"] -modin = ["modin"] -pandas = ["pandas (>=1.1.3)"] -polars = ["polars (>=0.20.4)"] -pyarrow = ["pyarrow (>=13.0.0)"] -pyspark = ["pyspark (>=3.5.0)"] -pyspark-connect = ["pyspark[connect] (>=3.5.0)"] -sql = ["duckdb (>=1.1)", "sqlparse"] -sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"] - -[[package]] -name = "neo4j" -version = "6.1.0" -description = "Neo4j Bolt driver for Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d"}, - {file = "neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84"}, -] - -[package.dependencies] -pytz = "*" - -[package.extras] -numpy = ["numpy (>=1.21.2,<3.0.0)"] -pandas = ["numpy (>=1.21.2,<3.0.0)", "pandas (>=1.1.0,<3.0.0)"] -pyarrow = ["pyarrow (>=6.0.0,<23.0.0)"] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -description = "Patch asyncio to allow nested event loops" -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, - {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, -] - -[[package]] -name = "nltk" -version = "3.9.4" -description = "Natural Language Toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f"}, - {file = "nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0"}, -] - -[package.dependencies] -click = "*" -joblib = "*" -regex = ">=2021.8.3" -tqdm = "*" - -[package.extras] -all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] -corenlp = ["requests"] -machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] -plot = ["matplotlib"] -tgrep = ["pyparsing"] -twitter = ["twython"] - -[[package]] -name = "numpy" -version = "2.0.2" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, - {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, - {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, - {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, - {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, - {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, - {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, - {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, - {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, - {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, - {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, - {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, - {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, - {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, - {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, - {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, - {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, - {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, - {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, - {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, - {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, - {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, - {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, - {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, - {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, - {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, -] - -[[package]] -name = "oauthlib" -version = "3.3.1" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, - {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, -] - -[package.extras] -rsa = ["cryptography (>=3.0.0)"] -signals = ["blinker (>=1.4.0)"] -signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] - -[[package]] -name = "oci" -version = "2.160.3" -description = "Oracle Cloud Infrastructure Python SDK" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"}, - {file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"}, -] - -[package.dependencies] -certifi = "*" -circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""} -cryptography = ">=3.2.1,<46.0.0" -pyOpenSSL = ">=17.5.0,<25.0.0" -python-dateutil = ">=2.5.3,<3.0.0" -pytz = ">=2016.10" - -[package.extras] -adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""] - -[[package]] -name = "okta" -version = "0.0.4" -description = "Okta client APIs" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "okta-0.0.4.tar.gz", hash = "sha256:53e792c68d3684ff4140b4cb1c02af3821090368f8110fde54c0bdb638449332"}, -] - -[package.dependencies] -python-dateutil = ">=2.4.2" -requests = ">=2.5.3" -six = ">=1.9.0" - -[[package]] -name = "openai" -version = "1.109.1" -description = "The official Python library for the openai API" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315"}, - {file = "openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869"}, -] - -[package.dependencies] -anyio = ">=3.5.0,<5" -distro = ">=1.7.0,<2" -httpx = ">=0.23.0,<1" -jiter = ">=0.4.0,<1" -pydantic = ">=1.9.0,<3" -sniffio = "*" -tqdm = ">4" -typing-extensions = ">=4.11,<5" - -[package.extras] -aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"] -datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] -realtime = ["websockets (>=13,<16)"] -voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] - -[[package]] -name = "openstacksdk" -version = "4.2.0" -description = "An SDK for building applications to work with OpenStack" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "openstacksdk-4.2.0-py3-none-any.whl", hash = "sha256:238be0fa5d9899872b00787ab38e84f92fd6dc87525fde0965dadcdc12196dc6"}, - {file = "openstacksdk-4.2.0.tar.gz", hash = "sha256:5cb9450dcce8054a2caf89d8be9e55057ddfa219a954e781032241eb29280445"}, -] - -[package.dependencies] -cryptography = ">=2.7" -decorator = ">=4.4.1" -"dogpile.cache" = ">=0.6.5" -iso8601 = ">=0.1.11" -jmespath = ">=0.9.0" -jsonpatch = ">=1.16,<1.20 || >1.20" -keystoneauth1 = ">=3.18.0" -os-service-types = ">=1.7.0" -pbr = ">=2.0.0,<2.1.0 || >2.1.0" -platformdirs = ">=3" -psutil = ">=3.2.2" -PyYAML = ">=3.13" -requestsexceptions = ">=1.2.0" - -[[package]] -name = "opentelemetry-api" -version = "1.39.1" -description = "OpenTelemetry Python API" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, - {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, -] - -[package.dependencies] -importlib-metadata = ">=6.0,<8.8.0" -typing-extensions = ">=4.5.0" - -[[package]] -name = "opentelemetry-sdk" -version = "1.39.1" -description = "OpenTelemetry Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, - {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, -] - -[package.dependencies] -opentelemetry-api = "1.39.1" -opentelemetry-semantic-conventions = "0.60b1" -typing-extensions = ">=4.5.0" - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.60b1" -description = "OpenTelemetry Semantic Conventions" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, - {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, -] - -[package.dependencies] -opentelemetry-api = "1.39.1" -typing-extensions = ">=4.5.0" - -[[package]] -name = "os-service-types" -version = "1.8.2" -description = "Python library for consuming OpenStack sevice-types-authority data" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e"}, - {file = "os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575"}, -] - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" -typing-extensions = ">=4.1.0" - -[[package]] -name = "packageurl-python" -version = "0.17.6" -description = "A purl aka. Package URL parser and builder" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9"}, - {file = "packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25"}, -] - -[package.extras] -build = ["setuptools", "wheel"] -lint = ["black", "isort", "mypy"] -sqlalchemy = ["sqlalchemy (>=2.0.0)"] -test = ["pytest"] - -[[package]] -name = "packaging" -version = "26.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, -] - -[[package]] -name = "pagerduty" -version = "6.1.0" -description = "Clients for PagerDuty's Public APIs" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "pagerduty-6.1.0-py3-none-any.whl", hash = "sha256:ca4954b917cb8e92f83e6b4e18d0f81fdaa73768edb7ad6e859edcc8f950f4eb"}, - {file = "pagerduty-6.1.0.tar.gz", hash = "sha256:84dfba74f68142c4a71c88af4858f1eb8671e7bc564bc133ac41c59daa7b54f8"}, -] - -[package.dependencies] -httpx = "*" - -[[package]] -name = "pandas" -version = "2.2.3" -description = "Powerful data structures for data analysis, time series, and statistics" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, - {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, - {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"}, - {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"}, - {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"}, - {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"}, - {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"}, - {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"}, - {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"}, - {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"}, - {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"}, - {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"}, - {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"}, - {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"}, - {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"}, - {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"}, - {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"}, - {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"}, - {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"}, - {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"}, - {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"}, - {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"}, - {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"}, - {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"}, - {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"}, - {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"}, - {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"}, - {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"}, - {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"}, - {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"}, - {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"}, - {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"}, -] - -[package.dependencies] -numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, -] -python-dateutil = ">=2.8.2" -pytz = ">=2020.1" -tzdata = ">=2022.7" - -[package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] -aws = ["s3fs (>=2022.11.0)"] -clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] -compression = ["zstandard (>=0.19.0)"] -computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] -consortium-standard = ["dataframe-api-compat (>=0.1.7)"] -excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] -feather = ["pyarrow (>=10.0.1)"] -fss = ["fsspec (>=2022.11.0)"] -gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] -hdf5 = ["tables (>=3.8.0)"] -html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] -mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] -output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] -parquet = ["pyarrow (>=10.0.1)"] -performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] -plot = ["matplotlib (>=3.6.3)"] -postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] -pyarrow = ["pyarrow (>=10.0.1)"] -spss = ["pyreadstat (>=1.2.0)"] -sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] -test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] -xml = ["lxml (>=4.9.2)"] - -[[package]] -name = "pbr" -version = "7.0.3" -description = "Python Build Reasonableness" -optional = false -python-versions = ">=2.6" -groups = ["main"] -files = [ - {file = "pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b"}, - {file = "pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29"}, -] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "pillow" -version = "12.1.1" -description = "Python Imaging Library (fork)" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, - {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, - {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, - {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, - {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, - {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, - {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, - {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, - {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, - {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, - {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, - {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, - {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, - {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, - {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, - {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, - {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, - {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, - {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, - {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, - {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, - {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, - {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, - {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, - {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, - {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, - {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, - {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, - {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, - {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, - {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, - {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, - {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, - {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, - {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, - {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, - {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, - {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, - {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, - {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, - {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, - {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, - {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, - {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, - {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, - {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, - {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, - {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, - {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, - {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -xmp = ["defusedxml"] - -[[package]] -name = "pkginfo" -version = "1.12.1.2" -description = "Query metadata from sdists / bdists / installed packages." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, - {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, -] - -[package.extras] -testing = ["pytest", "pytest-cov", "wheel"] - -[[package]] -name = "platformdirs" -version = "4.5.1" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, - {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - -[[package]] -name = "plotly" -version = "6.5.2" -description = "An open-source interactive data visualization library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "plotly-6.5.2-py3-none-any.whl", hash = "sha256:91757653bd9c550eeea2fa2404dba6b85d1e366d54804c340b2c874e5a7eb4a4"}, - {file = "plotly-6.5.2.tar.gz", hash = "sha256:7478555be0198562d1435dee4c308268187553cc15516a2f4dd034453699e393"}, -] - -[package.dependencies] -narwhals = ">=1.15.1" -packaging = "*" - -[package.extras] -dev = ["plotly[dev-optional]"] -dev-build = ["build", "jupyter", "plotly[dev-core]"] -dev-core = ["pytest", "requests", "ruff (==0.11.12)"] -dev-optional = ["anywidget", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "numpy", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "plotly[dev-build]", "plotly[kaleido]", "polars[timezone]", "pyarrow", "pyshp", "pytz", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] -express = ["numpy"] -kaleido = ["kaleido (>=1.1.0)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "policyuniverse" -version = "1.5.1.20231109" -description = "Parse and Process AWS IAM Policies, Statements, ARNs, and wildcards." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321"}, - {file = "policyuniverse-1.5.1.20231109.tar.gz", hash = "sha256:74e56d410560915c2c5132e361b0130e4bffe312a2f45230eac50d7c094bc40a"}, -] - -[package.extras] -dev = ["black", "pre-commit"] -tests = ["bandit", "coveralls", "pytest"] - -[[package]] -name = "portalocker" -version = "2.10.1" -description = "Wraps the portalocker recipe for easy usage" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "portalocker-2.10.1-py3-none-any.whl", hash = "sha256:53a5984ebc86a025552264b459b46a2086e269b21823cb572f8f28ee759e45bf"}, - {file = "portalocker-2.10.1.tar.gz", hash = "sha256:ef1bf844e878ab08aee7e40184156e1151f228f103aa5c6bd0724cc330960f8f"}, -] - -[package.dependencies] -pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} - -[package.extras] -docs = ["sphinx (>=1.7.1)"] -redis = ["redis"] -tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -description = "Library for building powerful interactive command lines in Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, - {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, -] - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "propcache" -version = "0.4.1" -description = "Accelerated property cache" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, - {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, - {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, - {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, - {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, - {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, - {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, - {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, - {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, - {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, - {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, - {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, - {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, - {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, - {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, - {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, - {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, - {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, - {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, - {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, - {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, - {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, - {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, - {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, - {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, - {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, - {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, - {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, - {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, - {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, - {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, - {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, - {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, - {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, - {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, - {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, - {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, - {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, - {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, - {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, - {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, - {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, - {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, - {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, - {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, - {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, - {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, - {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, - {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, - {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, - {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, - {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, - {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, - {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, - {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, - {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, - {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, - {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, - {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, -] - -[[package]] -name = "proto-plus" -version = "1.27.0" -description = "Beautiful, Pythonic protocol buffers" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82"}, - {file = "proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4"}, -] - -[package.dependencies] -protobuf = ">=3.19.0,<7.0.0" - -[package.extras] -testing = ["google-api-core (>=1.31.5)"] - -[[package]] -name = "protobuf" -version = "6.33.5" -description = "" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"}, - {file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"}, - {file = "protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5"}, - {file = "protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190"}, - {file = "protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd"}, - {file = "protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0"}, - {file = "protobuf-6.33.5-cp39-cp39-win32.whl", hash = "sha256:a3157e62729aafb8df6da2c03aa5c0937c7266c626ce11a278b6eb7963c4e37c"}, - {file = "protobuf-6.33.5-cp39-cp39-win_amd64.whl", hash = "sha256:8f04fa32763dcdb4973d537d6b54e615cc61108c7cb38fe59310c3192d29510a"}, - {file = "protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02"}, - {file = "protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c"}, -] - -[[package]] -name = "prowler" -version = "5.23.0" -description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks." -optional = false -python-versions = ">=3.10,<3.13" -groups = ["main"] -files = [] -develop = false - -[package.dependencies] -alibabacloud_actiontrail20200706 = "2.4.1" -alibabacloud_credentials = "1.0.3" -alibabacloud_cs20151215 = "6.1.0" -alibabacloud_ecs20140526 = "7.2.5" -alibabacloud-gateway-oss-util = "0.0.3" -alibabacloud_oss20190517 = "1.0.6" -alibabacloud_ram20150501 = "1.2.0" -alibabacloud-rds20140815 = "12.0.0" -alibabacloud_sas20181203 = "6.1.0" -alibabacloud-sls20201230 = "5.9.0" -alibabacloud_sts20150401 = "1.1.6" -alibabacloud_tea_openapi = "0.4.1" -alibabacloud_vpc20160428 = "6.13.0" -alive-progress = "3.3.0" -awsipranges = "0.3.3" -azure-identity = "1.21.0" -azure-keyvault-keys = "4.10.0" -azure-mgmt-apimanagement = "5.0.0" -azure-mgmt-applicationinsights = "4.1.0" -azure-mgmt-authorization = "4.0.0" -azure-mgmt-compute = "34.0.0" -azure-mgmt-containerregistry = "12.0.0" -azure-mgmt-containerservice = "34.1.0" -azure-mgmt-cosmosdb = "9.7.0" -azure-mgmt-databricks = "2.0.0" -azure-mgmt-keyvault = "10.3.1" -azure-mgmt-loganalytics = "12.0.0" -azure-mgmt-monitor = "6.0.2" -azure-mgmt-network = "28.1.0" -azure-mgmt-postgresqlflexibleservers = "1.1.0" -azure-mgmt-rdbms = "10.1.0" -azure-mgmt-recoveryservices = "3.1.0" -azure-mgmt-recoveryservicesbackup = "9.2.0" -azure-mgmt-resource = "23.3.0" -azure-mgmt-search = "9.1.0" -azure-mgmt-security = "7.0.0" -azure-mgmt-sql = "3.0.1" -azure-mgmt-storage = "22.1.1" -azure-mgmt-subscription = "3.1.1" -azure-mgmt-web = "8.0.0" -azure-monitor-query = "2.0.0" -azure-storage-blob = "12.24.1" -boto3 = "1.40.61" -botocore = "1.40.61" -cloudflare = "4.3.1" -colorama = "0.4.6" -cryptography = "44.0.3" -dash = "3.1.1" -dash-bootstrap-components = "2.0.3" -defusedxml = ">=0.7.1" -detect-secrets = "1.5.0" -dulwich = "0.23.0" -google-api-python-client = "2.163.0" -google-auth-httplib2 = ">=0.1,<0.3" -h2 = "4.3.0" -jsonschema = "4.23.0" -kubernetes = "32.0.1" -markdown = "3.10.2" -microsoft-kiota-abstractions = "1.9.2" -msgraph-sdk = "1.23.0" -numpy = "2.0.2" -oci = "2.160.3" -openstacksdk = "4.2.0" -pandas = "2.2.3" -py-iam-expand = "0.1.0" -py-ocsf-models = "0.8.1" -pydantic = ">=2.0,<3.0" -pygithub = "2.8.0" -python-dateutil = ">=2.9.0.post0,<3.0.0" -pytz = "2025.1" -schema = "0.7.5" -shodan = "1.31.0" -slack-sdk = "3.39.0" -tabulate = "0.9.0" -tzlocal = "5.3.1" -uuid6 = "2024.7.10" - -[package.source] -type = "git" -url = "https://github.com/prowler-cloud/prowler.git" -reference = "master" -resolved_reference = "6ac90eb1b58590b6f2f51645dbef17b9231053f4" - -[[package]] -name = "psutil" -version = "7.2.2" -description = "Cross-platform lib for process and system monitoring." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, - {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, - {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, - {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, - {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, - {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, - {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, - {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, - {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, -] - -[package.extras] -dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] -test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] - -[[package]] -name = "psycopg2-binary" -version = "2.9.9" -description = "psycopg2 - Python-PostgreSQL Database Adapter" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"}, - {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"}, - {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, -] - -[[package]] -name = "py-deviceid" -version = "0.1.1" -description = "A simple library to get or create a unique device id for a device in Python." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "py_deviceid-0.1.1-py3-none-any.whl", hash = "sha256:c0e32815e87a08087a0811c18f4402ee88b28a321f997753d75ecdaab570321b"}, - {file = "py_deviceid-0.1.1.tar.gz", hash = "sha256:c3e7577ada23666e7f39e69370dfdaa76fe9de79c02635376d6aa0229bfa30e3"}, -] - -[[package]] -name = "py-iam-expand" -version = "0.1.0" -description = "This is a Python package to expand and deobfuscate IAM policies." -optional = false -python-versions = "<3.14,>3.9.1" -groups = ["main"] -files = [ - {file = "py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510"}, - {file = "py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96"}, -] - -[package.dependencies] -iamdata = ">=0.1.202504091" - -[[package]] -name = "py-ocsf-models" -version = "0.8.1" -description = "This is a Python implementation of the OCSF models. The models are used to represent the data of the OCSF Schema defined in https://schema.ocsf.io/." -optional = false -python-versions = "<3.15,>3.9.1" -groups = ["main"] -files = [ - {file = "py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e"}, - {file = "py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c"}, -] - -[package.dependencies] -cryptography = ">=44.0.3,<47" -email-validator = "2.2.0" -pydantic = ">=2.12.0,<3.0.0" - -[[package]] -name = "pyasn1" -version = "0.6.2" -description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, - {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -description = "A collection of ASN.1-based protocols modules" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, - {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, -] - -[package.dependencies] -pyasn1 = ">=0.6.1,<0.7.0" - -[[package]] -name = "pycodestyle" -version = "2.14.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, - {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, -] - -[[package]] -name = "pycparser" -version = "3.0" -description = "C parser in Python" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, - {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pygithub" -version = "2.8.0" -description = "Use the full Github API v3" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"}, - {file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"}, -] - -[package.dependencies] -pyjwt = {version = ">=2.4.0", extras = ["crypto"]} -pynacl = ">=1.4.0" -requests = ">=2.14.0" -typing-extensions = ">=4.5.0" -urllib3 = ">=1.26.0" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjwt" -version = "2.11.0" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, - {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, -] - -[package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] - -[[package]] -name = "pylint" -version = "3.2.5" -description = "python code static checker" -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "pylint-3.2.5-py3-none-any.whl", hash = "sha256:32cd6c042b5004b8e857d727708720c54a676d1e22917cf1a2df9b4d4868abd6"}, - {file = "pylint-3.2.5.tar.gz", hash = "sha256:e9b7171e242dcc6ebd0aaa7540481d1a72860748a0a7816b8fe6cf6c80a6fe7e"}, -] - -[package.dependencies] -astroid = ">=3.2.2,<=3.3.0.dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = [ - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version == \"3.11\""}, -] -isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2.0" -tomlkit = ">=0.10.1" - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "pymsalruntime" -version = "0.18.1" -description = "The MSALRuntime Python Interop Package" -optional = false -python-versions = ">=3.6" -groups = ["main"] -markers = "(platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\") and sys_platform == \"win32\"" -files = [ - {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0c22e2e83faa10de422bbfaacc1bb2887c9025ee8a53f0fc2e4f7db01c4a7b66"}, - {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8ce2944a0f944833d047bb121396091e00287e2b6373716106da86ea99abf379"}, - {file = "pymsalruntime-0.18.1-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:9f7945ae0ee78357e9ca87d381f1c19763629a7197391ae7f84f4967a9f06e5b"}, - {file = "pymsalruntime-0.18.1-cp310-cp310-win32.whl", hash = "sha256:10020abdfc34bbbf3414b86359de551d2d8bc7c241bc38c59a2468c4d49f21d5"}, - {file = "pymsalruntime-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:f9aec2f44470d71feae35b611d1d8f15a549d96446e4f60e1ca1fb71856fffed"}, - {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e9320fb187fe1298d2165fa248af00907ca15d3a903a1d35fed86f6bc20b5880"}, - {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:9b2cecf3a570b7812d2007764df6dfbc27fca401a0d74532d5403aa20a9ef380"}, - {file = "pymsalruntime-0.18.1-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:6f66fd99668abc3d4b8d93a9eb80c75178dc63186c79e6dbe133427b279835e0"}, - {file = "pymsalruntime-0.18.1-cp311-cp311-win32.whl", hash = "sha256:74416947b1071054f3258cac3448a7adf708888727bf283267df2bb27f0998f1"}, - {file = "pymsalruntime-0.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:beb926655aae3367b7e4bda2baad86f9271beefee1121f71642da0ed4de37fd2"}, - {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6c07651cf4e07690d1b022da0977f56820ef553ac6dcbf4c9e68e9611020997"}, - {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0b6c4f54ec13309cc7b717ac8760c2d9856d4924cefa2b794b6d03db4cfdeef8"}, - {file = "pymsalruntime-0.18.1-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:06c73a47f024fcf36006b89fe32f2f6f6a004aa661cf8a03d3e496d1ef84cfe8"}, - {file = "pymsalruntime-0.18.1-cp312-cp312-win32.whl", hash = "sha256:ace12bf9b7fcbf1bf21a03c227717e09ba99acd9190623fe0821a08832ece4eb"}, - {file = "pymsalruntime-0.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:f9fd8ea52395f52f7d62498e47754adf2bfe6530816ff57eff1ba6f524aee51b"}, - {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:047a98b6709cddf6a1f50f78ee16d06fea0f42a44971b6d3e2988537277a1a17"}, - {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:910e653c65cd66fa9ce46dec103d3948da2276f7d4d315631a145eaab968d9a8"}, - {file = "pymsalruntime-0.18.1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:7ae0b160983ea0715d8ac69b441bbd29e7a9f31c9a5a2c350c79a794f5599f38"}, - {file = "pymsalruntime-0.18.1-cp313-cp313-win32.whl", hash = "sha256:adf4200a1b423fe5d8e984c142cc64f0b76a9b0f7f8ff767490a2dde94fa642b"}, - {file = "pymsalruntime-0.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:5a759aa551d084b160799f6df59c9891898ab305eb75ff1705bf04281675eb4b"}, - {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_arm64.whl", hash = "sha256:12b8990c4da1327ea46f6271bd57b28a90d3e795deacb370052914c3ff40d4c5"}, - {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:8dd68f9fedc200950093378b30a2ade4517324cef060788a759b575ea58dc6b2"}, - {file = "pymsalruntime-0.18.1-cp38-cp38-manylinux_2_35_x86_64.whl", hash = "sha256:7183b1b1542a277db119fe55285c7609c661b8506b99cd7e53b7066ce6b838e4"}, - {file = "pymsalruntime-0.18.1-cp38-cp38-win32.whl", hash = "sha256:56c3d708ba86311f049b004de81aa97655fed82782d3ec67e14ae1e27d4f5e5b"}, - {file = "pymsalruntime-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:a8adc80fcf723b980976b81a0b409affe80f32d89ae6096d856fd20471d2f0c1"}, - {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:600d0f2b9b03dfb457ee1e13f191c2c217c0f6bceca512f1741e5215bc4bc5dc"}, - {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:daae8515ae8adac8662d8230f22af242f87c72d86f308ec51b7432f316199c1b"}, - {file = "pymsalruntime-0.18.1-cp39-cp39-manylinux_2_35_x86_64.whl", hash = "sha256:864b8b9555a180c6baf8a57df3976b2e511582d54099561fbfe73f9f0b95c9f5"}, - {file = "pymsalruntime-0.18.1-cp39-cp39-win32.whl", hash = "sha256:b90a3c8079ded9d5abc765bd90fdc34f6e49412793740ddbc6122a601008d50f"}, - {file = "pymsalruntime-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:852dc82b3eaad0cce2c583314705183bf216e7fa7178040defd3a13195c1c406"}, -] - -[[package]] -name = "pynacl" -version = "1.6.2" -description = "Python binding to the Networking and Cryptography (NaCl) library" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, - {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, - {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, - {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, - {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, - {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, - {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, - {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, - {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, - {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, - {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, - {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, - {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} - -[package.extras] -docs = ["sphinx (<7)", "sphinx_rtd_theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] - -[[package]] -name = "pyopenssl" -version = "24.3.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"}, - {file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"}, -] - -[package.dependencies] -cryptography = ">=41.0.5,<45" - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] -test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] - -[[package]] -name = "pyparsing" -version = "3.3.2" -description = "pyparsing - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, - {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pyreadline3" -version = "3.5.4" -description = "A python implementation of GNU readline." -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, - {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, -] - -[package.extras] -dev = ["build", "flake8", "mypy", "pytest", "twine"] - -[[package]] -name = "pysocks" -version = "1.7.1" -description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] -files = [ - {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, - {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, - {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, -] - -[[package]] -name = "pytest" -version = "8.2.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, - {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2.0" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-celery" -version = "1.3.0" -description = "Pytest plugin for Celery" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "pytest_celery-1.3.0-py3-none-any.whl", hash = "sha256:f02201d7770584a0c412a1ded329a142170c24012467c7046f2c72cc8205ad5d"}, - {file = "pytest_celery-1.3.0.tar.gz", hash = "sha256:bd9e5b0f594ec5de9ab97cf27e3a11c644718a761bab6b997d01800fd7394f64"}, -] - -[package.dependencies] -celery = "*" -debugpy = ">=1.8.12,<2.0.0" -docker = ">=7.1.0,<8.0.0" -kombu = "*" -psutil = ">=7.0.0" -pytest-docker-tools = ">=3.1.3" -redis = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"redis\""} -tenacity = ">=9.0.0" - -[package.extras] -all = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"] -memcached = ["python-memcached"] -redis = ["redis"] -sqs = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16,<2.0)"] - -[[package]] -name = "pytest-cov" -version = "5.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, - {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-django" -version = "4.8.0" -description = "A Django plugin for pytest." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90"}, - {file = "pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[package.extras] -docs = ["sphinx", "sphinx-rtd-theme"] -testing = ["Django", "django-configurations (>=2.0)"] - -[[package]] -name = "pytest-docker-tools" -version = "3.1.9" -description = "Docker integration tests for pytest" -optional = false -python-versions = "<4.0.0,>=3.9.0" -groups = ["main"] -files = [ - {file = "pytest_docker_tools-3.1.9-py2.py3-none-any.whl", hash = "sha256:36f8e88d56d84ea177df68a175673681243dd991d2807fbf551d90f60341bfdb"}, - {file = "pytest_docker_tools-3.1.9.tar.gz", hash = "sha256:1b6a0cb633c20145731313335ef15bcf5571839c06726764e60cbe495324782b"}, -] - -[package.dependencies] -docker = ">=4.3.1" -pytest = ">=6.0.1" - -[[package]] -name = "pytest-env" -version = "1.1.3" -description = "pytest plugin that allows you to add environment variables." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_env-1.1.3-py3-none-any.whl", hash = "sha256:aada77e6d09fcfb04540a6e462c58533c37df35fa853da78707b17ec04d17dfc"}, - {file = "pytest_env-1.1.3.tar.gz", hash = "sha256:fcd7dc23bb71efd3d35632bde1bbe5ee8c8dc4489d6617fb010674880d96216b"}, -] - -[package.dependencies] -pytest = ">=7.4.3" - -[package.extras] -test = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "pytest-mock (>=3.12)"] - -[[package]] -name = "pytest-randomly" -version = "3.15.0" -description = "Pytest plugin to randomly order tests and control random.seed." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_randomly-3.15.0-py3-none-any.whl", hash = "sha256:0516f4344b29f4e9cdae8bce31c4aeebf59d0b9ef05927c33354ff3859eeeca6"}, - {file = "pytest_randomly-3.15.0.tar.gz", hash = "sha256:b908529648667ba5e54723088edd6f82252f540cc340d748d1fa985539687047"}, -] - -[package.dependencies] -pytest = "*" - -[[package]] -name = "pytest-xdist" -version = "3.6.1" -description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, - {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, -] - -[package.dependencies] -execnet = ">=2.1" -pytest = ">=7.0.0" - -[package.extras] -psutil = ["psutil (>=3.0)"] -setproctitle = ["setproctitle"] -testing = ["filelock"] - -[[package]] -name = "python-crontab" -version = "3.3.0" -description = "Python Crontab API" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884"}, - {file = "python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b"}, -] - -[package.extras] -cron-description = ["cron-descriptor"] -cron-schedule = ["croniter"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-digitalocean" -version = "1.17.0" -description = "digitalocean.com API to manage Droplets and Images" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "python-digitalocean-1.17.0.tar.gz", hash = "sha256:107854fde1aafa21774e8053cf253b04173613c94531f75d5a039ad770562b24"}, - {file = "python_digitalocean-1.17.0-py3-none-any.whl", hash = "sha256:0032168e022e85fca314eb3f8dfaabf82087f2ed40839eb28f1eeeeca5afb1fa"}, -] - -[package.dependencies] -jsonpickle = "*" -requests = "*" - -[[package]] -name = "python3-saml" -version = "1.16.0" -description = "Saml Python Toolkit. Add SAML support to your Python software using this library" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "python3-saml-1.16.0.tar.gz", hash = "sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133"}, - {file = "python3_saml-1.16.0-py2-none-any.whl", hash = "sha256:c49097863c278ff669a337a96c46dc1f25d16307b4bb2679d2d1733cc4f5176a"}, - {file = "python3_saml-1.16.0-py3-none-any.whl", hash = "sha256:20b97d11b04f01ee22e98f4a38242e2fea2e28fbc7fbc9bdd57cab5ac7fc2d0d"}, -] - -[package.dependencies] -isodate = ">=0.6.1" -lxml = ">=4.6.5,<4.7.0 || >4.7.0" -xmlsec = ">=1.3.9" - -[package.extras] -test = ["coverage (>=4.5.2)", "flake8 (>=3.6.0,<=5.0.0)", "freezegun (>=0.3.11,<=1.1.0)", "pytest (>=4.6)"] - -[[package]] -name = "pytz" -version = "2025.1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, - {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, -] - -[[package]] -name = "pywin32" -version = "311" -description = "Python for Window Extensions" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, - {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, - {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, - {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, - {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, - {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, - {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, - {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, - {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, - {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, - {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, - {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, - {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, - {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, - {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, - {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, - {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, - {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, - {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, - {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, -] -markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "redis" -version = "7.1.0" -description = "Python client for Redis database and key-value store" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b"}, - {file = "redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c"}, -] - -[package.dependencies] -async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} - -[package.extras] -circuit-breaker = ["pybreaker (>=1.4.0)"] -hiredis = ["hiredis (>=3.2.0)"] -jwt = ["pyjwt (>=2.9.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] - -[[package]] -name = "referencing" -version = "0.37.0" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, - {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" -typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} - -[[package]] -name = "regex" -version = "2026.1.15" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"}, - {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"}, - {file = "regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618"}, - {file = "regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13"}, - {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3"}, - {file = "regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218"}, - {file = "regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a"}, - {file = "regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3"}, - {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a"}, - {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f"}, - {file = "regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026"}, - {file = "regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2"}, - {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1"}, - {file = "regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569"}, - {file = "regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7"}, - {file = "regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec"}, - {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1"}, - {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681"}, - {file = "regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5"}, - {file = "regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d"}, - {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22"}, - {file = "regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913"}, - {file = "regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a"}, - {file = "regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056"}, - {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e"}, - {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10"}, - {file = "regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6"}, - {file = "regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31"}, - {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3"}, - {file = "regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f"}, - {file = "regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e"}, - {file = "regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337"}, - {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be"}, - {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8"}, - {file = "regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09"}, - {file = "regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2"}, - {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60"}, - {file = "regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952"}, - {file = "regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10"}, - {file = "regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829"}, - {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac"}, - {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6"}, - {file = "regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde"}, - {file = "regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160"}, - {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1"}, - {file = "regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1"}, - {file = "regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903"}, - {file = "regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705"}, - {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8"}, - {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf"}, - {file = "regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a"}, - {file = "regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521"}, - {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db"}, - {file = "regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e"}, - {file = "regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf"}, - {file = "regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70"}, - {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55b4ea996a8e4458dd7b584a2f89863b1655dd3d17b88b46cbb9becc495a0ec5"}, - {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1e28be779884189cdd57735e997f282b64fd7ccf6e2eef3e16e57d7a34a815"}, - {file = "regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0057de9eaef45783ff69fa94ae9f0fd906d629d0bd4c3217048f46d1daa32e9b"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7cd0b2be0f0269283a45c0d8b2c35e149d1319dcb4a43c9c3689fa935c1ee6"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8db052bbd981e1666f09e957f3790ed74080c2229007c1dd67afdbf0b469c48b"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:343db82cb3712c31ddf720f097ef17c11dab2f67f7a3e7be976c4f82eba4e6df"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e9d0118d97794367309635df398bdfd7c33b93e2fdfa0b239661cd74b4c14e"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:008b185f235acd1e53787333e5690082e4f156c44c87d894f880056089e9bc7c"}, - {file = "regex-2026.1.15-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fd65af65e2aaf9474e468f9e571bd7b189e1df3a61caa59dcbabd0000e4ea839"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f42e68301ff4afee63e365a5fc302b81bb8ba31af625a671d7acb19d10168a8c"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f7792f27d3ee6e0244ea4697d92b825f9a329ab5230a78c1a68bd274e64b5077"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dbaf3c3c37ef190439981648ccbf0c02ed99ae066087dd117fcb616d80b010a4"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:adc97a9077c2696501443d8ad3fa1b4fc6d131fc8fd7dfefd1a723f89071cf0a"}, - {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:069f56a7bf71d286a6ff932a9e6fb878f151c998ebb2519a9f6d1cee4bffdba3"}, - {file = "regex-2026.1.15-cp39-cp39-win32.whl", hash = "sha256:ea4e6b3566127fda5e007e90a8fd5a4169f0cf0619506ed426db647f19c8454a"}, - {file = "regex-2026.1.15-cp39-cp39-win_amd64.whl", hash = "sha256:cda1ed70d2b264952e88adaa52eea653a33a1b98ac907ae2f86508eb44f65cdc"}, - {file = "regex-2026.1.15-cp39-cp39-win_arm64.whl", hash = "sha256:b325d4714c3c48277bfea1accd94e193ad6ed42b4bad79ad64f3b8f8a31260a5"}, - {file = "regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5"}, -] - -[[package]] -name = "reportlab" -version = "4.4.10" -description = "The Reportlab Toolkit" -optional = false -python-versions = "<4,>=3.9" -groups = ["main"] -files = [ - {file = "reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24"}, - {file = "reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487"}, -] - -[package.dependencies] -charset-normalizer = "*" -pillow = ">=9.0.0" - -[package.extras] -accel = ["rl_accel (>=0.9.0,<1.1)"] -bidi = ["rlbidi"] -pycairo = ["freetype-py (>=2.3.0,<2.4)", "rlPyCairo (>=0.2.0,<1)"] -renderpm = ["rl_renderPM (>=4.0.3,<4.1)"] -shaping = ["uharfbuzz"] - -[[package]] -name = "requests" -version = "2.32.5" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7", optional = true, markers = "extra == \"socks\""} -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "requests-file" -version = "3.0.1" -description = "File transport adapter for Requests" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2"}, - {file = "requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576"}, -] - -[package.dependencies] -requests = ">=1.0.0" - -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -description = "OAuthlib authentication support for Requests." -optional = false -python-versions = ">=3.4" -groups = ["main"] -files = [ - {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, - {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, -] - -[package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" - -[package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] - -[[package]] -name = "requestsexceptions" -version = "1.4.0" -description = "Import exceptions from potentially bundled packages in requests." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3"}, - {file = "requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065"}, -] - -[[package]] -name = "retrying" -version = "1.4.2" -description = "Retrying" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59"}, - {file = "retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39"}, -] - -[[package]] -name = "rich" -version = "14.3.2" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["main", "dev"] -files = [ - {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, - {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "rpds-py" -version = "0.30.0" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, - {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, - {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, - {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, - {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, - {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, - {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, - {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, - {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, - {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, - {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, - {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, - {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, - {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, - {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, - {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, - {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, - {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, - {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, - {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, - {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, - {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, - {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, - {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, - {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, - {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, - {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, - {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, - {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, - {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, - {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, - {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, - {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, - {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, - {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, - {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, - {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, - {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, - {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, - {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, - {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, - {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, - {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, - {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, - {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, - {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, - {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, - {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, - {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, - {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, - {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, - {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, - {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, - {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, - {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, - {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, - {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, - {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, - {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, - {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, - {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, - {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, - {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, - {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, - {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, - {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, - {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, - {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, - {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, - {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, - {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, - {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, - {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, - {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, - {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, - {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, - {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, - {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, - {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, - {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, - {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, - {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, - {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, - {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, - {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, - {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, - {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, - {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, - {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, - {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, - {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, - {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, - {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, - {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, - {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, - {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, - {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, - {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, - {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, - {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, - {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, - {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, - {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, - {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, -] - -[[package]] -name = "rsa" -version = "4.9.1" -description = "Pure-Python RSA implementation" -optional = false -python-versions = "<4,>=3.6" -groups = ["main"] -files = [ - {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, - {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - -[[package]] -name = "ruamel-yaml" -version = "0.19.1" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93"}, - {file = "ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993"}, -] - -[package.extras] -docs = ["mercurial (>5.7)", "ryd"] -jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] -libyaml = ["ruamel.yaml.clibz (>=0.3.7) ; platform_python_implementation == \"CPython\""] -oldlibyaml = ["ruamel.yaml.clib ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "ruff" -version = "0.5.0" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:ee770ea8ab38918f34e7560a597cc0a8c9a193aaa01bfbd879ef43cb06bd9c4c"}, - {file = "ruff-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38f3b8327b3cb43474559d435f5fa65dacf723351c159ed0dc567f7ab735d1b6"}, - {file = "ruff-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7594f8df5404a5c5c8f64b8311169879f6cf42142da644c7e0ba3c3f14130370"}, - {file = "ruff-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adc7012d6ec85032bc4e9065110df205752d64010bed5f958d25dbee9ce35de3"}, - {file = "ruff-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d505fb93b0fabef974b168d9b27c3960714d2ecda24b6ffa6a87ac432905ea38"}, - {file = "ruff-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dc5cfd3558f14513ed0d5b70ce531e28ea81a8a3b1b07f0f48421a3d9e7d80a"}, - {file = "ruff-0.5.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:db3ca35265de239a1176d56a464b51557fce41095c37d6c406e658cf80bbb362"}, - {file = "ruff-0.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1a321c4f68809fddd9b282fab6a8d8db796b270fff44722589a8b946925a2a8"}, - {file = "ruff-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c4dfcd8d34b143916994b3876b63d53f56724c03f8c1a33a253b7b1e6bf2a7d"}, - {file = "ruff-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81e5facfc9f4a674c6a78c64d38becfbd5e4f739c31fcd9ce44c849f1fad9e4c"}, - {file = "ruff-0.5.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e589e27971c2a3efff3fadafb16e5aef7ff93250f0134ec4b52052b673cf988d"}, - {file = "ruff-0.5.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2ffbc3715a52b037bcb0f6ff524a9367f642cdc5817944f6af5479bbb2eb50e"}, - {file = "ruff-0.5.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cd096e23c6a4f9c819525a437fa0a99d1c67a1b6bb30948d46f33afbc53596cf"}, - {file = "ruff-0.5.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:46e193b36f2255729ad34a49c9a997d506e58f08555366b2108783b3064a0e1e"}, - {file = "ruff-0.5.0-py3-none-win32.whl", hash = "sha256:49141d267100f5ceff541b4e06552e98527870eafa1acc9dec9139c9ec5af64c"}, - {file = "ruff-0.5.0-py3-none-win_amd64.whl", hash = "sha256:e9118f60091047444c1b90952736ee7b1792910cab56e9b9a9ac20af94cd0440"}, - {file = "ruff-0.5.0-py3-none-win_arm64.whl", hash = "sha256:ed5c4df5c1fb4518abcb57725b576659542bdbe93366f4f329e8f398c4b71178"}, - {file = "ruff-0.5.0.tar.gz", hash = "sha256:eb641b5873492cf9bd45bc9c5ae5320648218e04386a5f0c264ad6ccce8226a1"}, -] - -[[package]] -name = "s3transfer" -version = "0.14.0" -description = "An Amazon S3 Transfer Manager" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, - {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, -] - -[package.dependencies] -botocore = ">=1.37.4,<2.0a0" - -[package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] - -[[package]] -name = "safety" -version = "3.7.0" -description = "Scan dependencies for known vulnerabilities and licenses." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"}, - {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"}, -] - -[package.dependencies] -authlib = ">=1.2.0" -click = ">=8.0.2" -dparse = ">=0.6.4" -filelock = ">=3.16.1,<4.0" -httpx = "*" -jinja2 = ">=3.1.0" -marshmallow = ">=3.15.0" -nltk = ">=3.9" -packaging = ">=21.0" -pydantic = ">=2.6.0" -requests = "*" -ruamel-yaml = ">=0.17.21" -safety-schemas = "0.0.16" -tenacity = ">=8.1.0" -tomlkit = "*" -typer = ">=0.16.0" -typing-extensions = ">=4.7.1" - -[package.extras] -github = ["pygithub (>=1.43.3)"] -gitlab = ["python-gitlab (>=1.3.0)"] -spdx = ["spdx-tools (>=0.8.2)"] - -[[package]] -name = "safety-schemas" -version = "0.0.16" -description = "Schemas for Safety tools" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"}, - {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"}, -] - -[package.dependencies] -dparse = ">=0.6.4" -packaging = ">=21.0" -pydantic = ">=2.6.0" -ruamel-yaml = ">=0.17.21" -typing-extensions = ">=4.7.1" - -[[package]] -name = "scaleway" -version = "2.10.3" -description = "Scaleway SDK for Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "scaleway-2.10.3-py3-none-any.whl", hash = "sha256:dbf381440d6caf37c878cf16445a63f4969a4aac2257c9b72c744d10ff223a0c"}, - {file = "scaleway-2.10.3.tar.gz", hash = "sha256:b1f9dd1b1450767205234c6f5a345e5e25dc039c780253d698893b5c344ce594"}, -] - -[package.dependencies] -scaleway-core = "2.10.3" - -[[package]] -name = "scaleway-core" -version = "2.10.3" -description = "Scaleway SDK for Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "scaleway_core-2.10.3-py3-none-any.whl", hash = "sha256:fd4112144554d6adae22ff737555eeb0e38cb1063250b3e88c9aebc1b957793b"}, - {file = "scaleway_core-2.10.3.tar.gz", hash = "sha256:56432f755d694669429de51d51c1d0b3361b28dc2f939b28e4cb954610ee76be"}, -] - -[package.dependencies] -python-dateutil = ">=2.8.2,<3.0.0" -PyYAML = ">=6.0,<7.0" -requests = ">=2.28.1,<3.0.0" - -[[package]] -name = "schema" -version = "0.7.5" -description = "Simple data validation library" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"}, - {file = "schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197"}, -] - -[package.dependencies] -contextlib2 = ">=0.5.5" - -[[package]] -name = "sentry-sdk" -version = "2.56.0" -description = "Python client for Sentry (https://sentry.io)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02"}, - {file = "sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168"}, -] - -[package.dependencies] -certifi = "*" -django = {version = ">=1.8", optional = true, markers = "extra == \"django\""} -urllib3 = ">=1.26.11" - -[package.extras] -aiohttp = ["aiohttp (>=3.5)"] -anthropic = ["anthropic (>=0.16)"] -arq = ["arq (>=0.23)"] -asyncpg = ["asyncpg (>=0.23)"] -beam = ["apache-beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -celery-redbeat = ["celery-redbeat (>=2)"] -chalice = ["chalice (>=1.16.0)"] -clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -fastapi = ["fastapi (>=0.79.0)"] -flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] -google-genai = ["google-genai (>=1.29.0)"] -grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] -http2 = ["httpcore[http2] (==1.*)"] -httpx = ["httpx (>=0.16.0)"] -huey = ["huey (>=2)"] -huggingface-hub = ["huggingface_hub (>=0.22)"] -langchain = ["langchain (>=0.0.210)"] -langgraph = ["langgraph (>=0.6.6)"] -launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] -litellm = ["litellm (>=1.77.5)"] -litestar = ["litestar (>=2.0.0)"] -loguru = ["loguru (>=0.5)"] -mcp = ["mcp (>=1.15.0)"] -openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] -openfeature = ["openfeature-sdk (>=0.7.1)"] -opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro"] -opentelemetry-otlp = ["opentelemetry-distro[otlp] (>=0.35b0)"] -pure-eval = ["asttokens", "executing", "pure_eval"] -pydantic-ai = ["pydantic-ai (>=1.0.0)"] -pymongo = ["pymongo (>=3.1)"] -pyspark = ["pyspark (>=2.4.4)"] -quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] -rq = ["rq (>=0.6)"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -starlette = ["starlette (>=0.19.1)"] -starlite = ["starlite (>=1.48)"] -statsig = ["statsig (>=0.55.3)"] -tornado = ["tornado (>=6)"] -unleash = ["UnleashClient (>=6.0.1)"] - -[[package]] -name = "setuptools" -version = "80.10.2" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173"}, - {file = "setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - -[[package]] -name = "shellingham" -version = "1.5.4" -description = "Tool to Detect Surrounding Shell" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, - {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, -] - -[[package]] -name = "shodan" -version = "1.31.0" -description = "Python library and command-line utility for Shodan (https://developer.shodan.io)" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "shodan-1.31.0.tar.gz", hash = "sha256:c73275386ea02390e196c35c660706a28dd4d537c5a21eb387ab6236fac251f6"}, -] - -[package.dependencies] -click = "*" -click-plugins = "*" -colorama = "*" -requests = ">=2.2.1" -tldextract = "*" -XlsxWriter = "*" - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "slack-sdk" -version = "3.39.0" -description = "The Slack API Platform SDK for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"}, - {file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"}, -] - -[package.extras] -optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "sqlparse" -version = "0.5.5" -description = "A non-validating SQL parser." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"}, - {file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"}, -] - -[package.extras] -dev = ["build"] -doc = ["sphinx"] - -[[package]] -name = "statsd" -version = "4.0.1" -description = "A simple statsd client." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "statsd-4.0.1-py2.py3-none-any.whl", hash = "sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093"}, - {file = "statsd-4.0.1.tar.gz", hash = "sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128"}, -] - -[[package]] -name = "std-uritemplate" -version = "2.0.8" -description = "std-uritemplate implementation for Python" -optional = false -python-versions = "<4.0,>=3.8" -groups = ["main"] -files = [ - {file = "std_uritemplate-2.0.8-py3-none-any.whl", hash = "sha256:839807a7f9d07f0bad1a88977c3428bd97b9ff0d229412a0bf36123d8c724257"}, - {file = "std_uritemplate-2.0.8.tar.gz", hash = "sha256:138ceff2c5bfef18a650372a5e8c82fe7f780c87235513de6c342fb5f7e18347"}, -] - -[[package]] -name = "stevedore" -version = "5.6.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"}, - {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, -] - -[[package]] -name = "tabulate" -version = "0.9.0" -description = "Pretty-print tabular data" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, - {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, -] - -[package.extras] -widechars = ["wcwidth"] - -[[package]] -name = "tenacity" -version = "9.1.2" -description = "Retry code until it succeeds" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, - {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, -] - -[package.extras] -doc = ["reno", "sphinx"] -test = ["pytest", "tornado (>=4.5)", "typeguard"] - -[[package]] -name = "tldextract" -version = "5.3.1" -description = "Accurately separates a URL's subdomain, domain, and public suffix, using the Public Suffix List (PSL). By default, this includes the public ICANN TLDs and their exceptions. You can optionally support the Public Suffix List's private domains as well." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9"}, - {file = "tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb"}, -] - -[package.dependencies] -filelock = ">=3.0.8" -idna = "*" -requests = ">=2.1.0" -requests-file = ">=1.4" - -[package.extras] -release = ["build", "twine"] -testing = ["mypy", "pytest", "pytest-gitignore", "pytest-mock", "responses", "ruff", "syrupy", "tox", "tox-uv", "types-filelock", "types-requests"] - -[[package]] -name = "tomlkit" -version = "0.14.0" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680"}, - {file = "tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"}, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -description = "Fast, Extensible Progress Meter" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, - {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] -discord = ["requests"] -notebook = ["ipywidgets (>=6)"] -slack = ["slack-sdk"] -telegram = ["requests"] - -[[package]] -name = "typer" -version = "0.21.1" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01"}, - {file = "typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d"}, -] - -[package.dependencies] -click = ">=8.0.0" -rich = ">=10.11.0" -shellingham = ">=1.3.0" -typing-extensions = ">=3.7.4.3" - -[[package]] -name = "types-aiobotocore-ecr" -version = "3.1.1" -description = "Type annotations for aiobotocore ECR 3.1.1 service generated with mypy-boto3-builder 8.12.0" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "types_aiobotocore_ecr-3.1.1-py3-none-any.whl", hash = "sha256:e5c02e06ff057bbe7821fb40ac7de67d2335fdc7987ea31392051efe81ceb69c"}, - {file = "types_aiobotocore_ecr-3.1.1.tar.gz", hash = "sha256:155edc63c612e1a7861fa746376a5143cc4f3ca05b60c27d68ced23e8567a344"}, -] - -[package.dependencies] -typing-extensions = {version = "*", markers = "python_version < \"3.12\""} - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "tzdata" -version = "2025.3" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -groups = ["main", "dev"] -files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, -] -markers = {dev = "sys_platform == \"win32\""} - -[[package]] -name = "tzlocal" -version = "5.3.1" -description = "tzinfo object for the local timezone" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, - {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, -] - -[package.dependencies] -tzdata = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] - -[[package]] -name = "uritemplate" -version = "4.2.0" -description = "Implementation of RFC 6570 URI Templates" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"}, - {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, -] - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "uuid6" -version = "2024.7.10" -description = "New time-based UUID formats which are suited for use as a database key" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7"}, - {file = "uuid6-2024.7.10.tar.gz", hash = "sha256:2d29d7f63f593caaeea0e0d0dd0ad8129c9c663b29e19bdf882e864bedf18fb0"}, -] - -[[package]] -name = "vine" -version = "5.1.0" -description = "Python promises." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc"}, - {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, -] - -[[package]] -name = "vulture" -version = "2.14" -description = "Find dead code" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9"}, - {file = "vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415"}, -] - -[[package]] -name = "wcwidth" -version = "0.5.3" -description = "Measures the displayed width of unicode strings in a terminal" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "wcwidth-0.5.3-py3-none-any.whl", hash = "sha256:d584eff31cd4753e1e5ff6c12e1edfdb324c995713f75d26c29807bb84bf649e"}, - {file = "wcwidth-0.5.3.tar.gz", hash = "sha256:53123b7af053c74e9fe2e92ac810301f6139e64379031f7124574212fb3b4091"}, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -description = "WebSocket client for Python with low level API options" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, - {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, -] - -[package.extras] -docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] -optional = ["python-socks", "wsaccel"] -test = ["pytest", "websockets"] - -[[package]] -name = "werkzeug" -version = "3.1.7" -description = "The comprehensive WSGI web application library." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f"}, - {file = "werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351"}, -] - -[package.dependencies] -markupsafe = ">=2.1.1" - -[package.extras] -watchdog = ["watchdog (>=2.3)"] - -[[package]] -name = "wrapt" -version = "1.17.3" -description = "Module for decorators, wrappers and monkey patching." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, - {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, - {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"}, - {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"}, - {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"}, - {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"}, - {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"}, - {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"}, - {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"}, - {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"}, - {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"}, - {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"}, - {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"}, - {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"}, - {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"}, - {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"}, - {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"}, - {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"}, - {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"}, - {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"}, - {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"}, - {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"}, - {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"}, - {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"}, - {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"}, - {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"}, - {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"}, - {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"}, - {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"}, - {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"}, - {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"}, - {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"}, - {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"}, - {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"}, - {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"}, - {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"}, - {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"}, - {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"}, - {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"}, - {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"}, - {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"}, - {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"}, - {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"}, - {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"}, - {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"}, - {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225"}, - {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a"}, - {file = "wrapt-1.17.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f"}, - {file = "wrapt-1.17.3-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00"}, - {file = "wrapt-1.17.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56"}, - {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5"}, - {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22"}, - {file = "wrapt-1.17.3-cp38-cp38-win32.whl", hash = "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c"}, - {file = "wrapt-1.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2"}, - {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc"}, - {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9"}, - {file = "wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d"}, - {file = "wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a"}, - {file = "wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139"}, - {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df"}, - {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b"}, - {file = "wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81"}, - {file = "wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f"}, - {file = "wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f"}, - {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, - {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, -] - -[[package]] -name = "xlsxwriter" -version = "3.2.9" -description = "A Python module for creating Excel XLSX files." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3"}, - {file = "xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c"}, -] - -[[package]] -name = "xmlsec" -version = "1.3.14" -description = "Python bindings for the XML Security Library" -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "xmlsec-1.3.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4dea6df3ffcb65d0b215678c3a0fe7bbc66785d6eae81291296e372498bad43a"}, - {file = "xmlsec-1.3.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fa1311f7489d050dde9028f5a2b5849c2927bb09c9a93491cb2f28fdc563912"}, - {file = "xmlsec-1.3.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28cd9f513cf01dc0c5b9d9f0728714ecde2e7f46b3b6f63de91f4ae32f3008b3"}, - {file = "xmlsec-1.3.14-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77749b338503fb6e151052c664064b34264f4168e2cb0cca1de78b7e5312a783"}, - {file = "xmlsec-1.3.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4af81ce8044862ec865782efd353d22abdcd95b92364eef3c934de57ae6d5852"}, - {file = "xmlsec-1.3.14-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cf35a25be3eb6263b2e0544ba26294651113fab79064f994d347a2ca5973e8e2"}, - {file = "xmlsec-1.3.14-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:004e8a82e26728bf8a60f8ece1ef3ffafdac30ef538139dfe28870e8503ca64a"}, - {file = "xmlsec-1.3.14-cp310-cp310-win32.whl", hash = "sha256:e6cbc914d77678db0c8bc39e723d994174633d18f9d6be4665ec29cce978a96d"}, - {file = "xmlsec-1.3.14-cp310-cp310-win_amd64.whl", hash = "sha256:4922afa9234d1c5763950b26c328a5320019e55eb6000272a79dfe54fee8e704"}, - {file = "xmlsec-1.3.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7799a9ff3593f9dd43464e18b1a621640bffc40456c47c23383727f937dca7fc"}, - {file = "xmlsec-1.3.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1fe23c2dd5f5dbcb24f40e2c1061e2672a32aabee7cf8ac5337036a485607d72"}, - {file = "xmlsec-1.3.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0be3b7a28e54a03b87faf07fb3c6dc3e50a2c79b686718c3ad08300b8bf6bb67"}, - {file = "xmlsec-1.3.14-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48e894ad3e7de373f56efc09d6a56f7eae73a8dd4cec8943313134849e9c6607"}, - {file = "xmlsec-1.3.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:204d3c586b8bd6f02a5d4c59850a8157205569d40c32567f49576fa5795d897d"}, - {file = "xmlsec-1.3.14-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6679cec780386d848e7351d4b0de92c4483289ea4f0a2187e216159f939a4c6b"}, - {file = "xmlsec-1.3.14-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c4d41c83c8a2b8d8030204391ebeb6174fbdb044f0331653c4b5a4ce4150bcc0"}, - {file = "xmlsec-1.3.14-cp311-cp311-win32.whl", hash = "sha256:df4aa0782a53032fd35e18dcd6d328d6126324bfcfdef0cb5c2856f25b4b6f94"}, - {file = "xmlsec-1.3.14-cp311-cp311-win_amd64.whl", hash = "sha256:1072878301cb9243a54679e0520e6a5be2266c07a28b0ecef9e029d05a90ffcd"}, - {file = "xmlsec-1.3.14-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1eb3dcf244a52f796377112d8f238dbb522eb87facffb498425dc8582a84a6bf"}, - {file = "xmlsec-1.3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:330147ce59fbe56a9be5b2085d739c55a569f112576b3f1b33681f87416eaf33"}, - {file = "xmlsec-1.3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed4034939d8566ccdcd3b4e4f23c63fd807fb8763ae5668d59a19e11640a8242"}, - {file = "xmlsec-1.3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a98eadfcb0c3b23ccceb7a2f245811f8d784bd287640dcfe696a26b9db1e2fc0"}, - {file = "xmlsec-1.3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86ff7b2711557c1087b72b0a1a88d82eafbf2a6d38b97309a6f7101d4a7041c3"}, - {file = "xmlsec-1.3.14-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:774d5d1e45f07f953c1cc14fd055c1063f0725f7248b6b0e681f59fd8638934d"}, - {file = "xmlsec-1.3.14-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bd10ca3201f164482775a7ce61bf7ee9aade2e7d032046044dd0f6f52c91d79d"}, - {file = "xmlsec-1.3.14-cp312-cp312-win32.whl", hash = "sha256:19c86bab1498e4c2e56d8e2c878f461ccb6e56b67fd7522b0c8fda46d8910781"}, - {file = "xmlsec-1.3.14-cp312-cp312-win_amd64.whl", hash = "sha256:d0762f4232bce2c7f6c0af329db8b821b4460bbe123a2528fb5677d03db7a4b5"}, - {file = "xmlsec-1.3.14-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:03ccba7dacf197850de954666af0221c740a5de631a80136362a1559223fab75"}, - {file = "xmlsec-1.3.14-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c12900e1903e289deb84eb893dca88591d6884d3e3cda4fb711b8812118416e8"}, - {file = "xmlsec-1.3.14-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6566434e2e5c58e472362a6187f208601f1627a148683a6f92bd16479f1d9e20"}, - {file = "xmlsec-1.3.14-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2401e162aaab7d9416c3405bac7a270e5f370988a0f1f46f0f29b735edba87e1"}, - {file = "xmlsec-1.3.14-cp36-cp36m-win32.whl", hash = "sha256:ba3b39c493e3b04354615068a3218f30897fcc2f42c6d8986d0c1d63aca87782"}, - {file = "xmlsec-1.3.14-cp36-cp36m-win_amd64.whl", hash = "sha256:4edd8db4df04bbac9c4a5ab4af855b74fe2bf2c248d07cac2e6d92a485f1a685"}, - {file = "xmlsec-1.3.14-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6dd86f440fec9242515c64f0be93fec8b4289287db1f6de2651eee9995aaecb"}, - {file = "xmlsec-1.3.14-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad1634cabe0915fe2a12e142db0ed2daf5be80cbe3891a2cecbba0750195cc6b"}, - {file = "xmlsec-1.3.14-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba457ff87c39cbae3c5020475a728d24bbd9d00376df9af9724cd3bb59ff07a"}, - {file = "xmlsec-1.3.14-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12d90059308bb0c1b94bde065784e6852999d08b91bcb2048c17e62b954acb07"}, - {file = "xmlsec-1.3.14-cp37-cp37m-win32.whl", hash = "sha256:ce4e165a1436697e5e39587c4fba24db4545a5c9801e0d749f1afd09ad3ab901"}, - {file = "xmlsec-1.3.14-cp37-cp37m-win_amd64.whl", hash = "sha256:7e8e0171916026cbe8e2022c959558d02086655fd3c3466f2bc0451b09cf9ee8"}, - {file = "xmlsec-1.3.14-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c42735cc68fdb4c6065cf0a0701dfff3a12a1734c63a36376349af9a5481f27b"}, - {file = "xmlsec-1.3.14-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:38e035bf48300b7dbde2dd01d3b8569f8584fc9c73809be13886e6b6c77b74fb"}, - {file = "xmlsec-1.3.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73eabf5ef58189d81655058cf328c1dfa9893d89f1bff5fc941481f08533f338"}, - {file = "xmlsec-1.3.14-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bddd2a2328b4e08c8a112e06cf2cd2b4d281f4ad94df15b4cef18f06cdc49d78"}, - {file = "xmlsec-1.3.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57fed3bc7943681c9ed4d2221600ab440f060d8d1a8f92f346f2b41effe175b8"}, - {file = "xmlsec-1.3.14-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:147934bd39dfd840663fb6b920ea9201455fa886427975713f1b42d9f20b9b29"}, - {file = "xmlsec-1.3.14-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e732a75fcb6b84872b168f972fbbf3749baf76308635f14015d1d35ed0c5719c"}, - {file = "xmlsec-1.3.14-cp38-cp38-win32.whl", hash = "sha256:b109cdf717257fd4daa77c1d3ec8a3fb2a81318a6d06a36c55a8a53ae381ae5e"}, - {file = "xmlsec-1.3.14-cp38-cp38-win_amd64.whl", hash = "sha256:b7ba2ea38e3d9efa520b14f3c0b7d99a7c055244ae5ba8bc9f4ca73b18f3a215"}, - {file = "xmlsec-1.3.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1b9b5de6bc69fdec23147e5f712cb05dc86df105462f254f140d743cc680cc7b"}, - {file = "xmlsec-1.3.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:82ac81deb7d7bf5cc8a748148948e5df5386597ff43fb92ec651cc5c7addb0e7"}, - {file = "xmlsec-1.3.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bae37b2920115cf00759ee9fb7841cbdebcef3a8a92734ab93ae8fa41ac581d"}, - {file = "xmlsec-1.3.14-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4fac2a787ae3b9fb761f9aec6b9f10f2d1c1b87abb574ebd8ff68435bdc97e3d"}, - {file = "xmlsec-1.3.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34c61ec0c0e70fda710290ae74b9efe1928d9242ed82c4eecf97aa696cff68e6"}, - {file = "xmlsec-1.3.14-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:995e87acecc263a2f6f2aa3cc204268f651cac8f4d7a2047f11b2cd49979cc38"}, - {file = "xmlsec-1.3.14-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2f84a1c509c52773365645a87949081ee9ea9c535cd452048cc8ca4ad3b45666"}, - {file = "xmlsec-1.3.14-cp39-cp39-win32.whl", hash = "sha256:7882963e9cb9c0bd0e8c2715a29159a366417ff4a30d8baf42b05bc5cf249446"}, - {file = "xmlsec-1.3.14-cp39-cp39-win_amd64.whl", hash = "sha256:a487c3d144f791c32f5e560aa27a705fba23171728b8a8511f36de053ff6bc93"}, - {file = "xmlsec-1.3.14.tar.gz", hash = "sha256:934f804f2f895bcdb86f1eaee236b661013560ee69ec108d29cdd6e5f292a2d9"}, -] - -[package.dependencies] -lxml = ">=3.8" - -[[package]] -name = "xmltodict" -version = "1.0.2" -description = "Makes working with XML feel like you are working with JSON" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d"}, - {file = "xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649"}, -] - -[package.extras] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "yarl" -version = "1.22.0" -description = "Yet another URL library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, - {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, - {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, - {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, - {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, - {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, - {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, - {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, - {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, - {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, - {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, - {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, - {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, - {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, - {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, - {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, - {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, - {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, - {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, - {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, - {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, - {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, - {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, - {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, - {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, - {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, - {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, - {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, - {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, - {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, - {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, - {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, - {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, - {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, - {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, - {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, - {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, - {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, - {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, - {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, - {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, - {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, - {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748"}, - {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859"}, - {file = "yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9"}, - {file = "yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054"}, - {file = "yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b"}, - {file = "yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60"}, - {file = "yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890"}, - {file = "yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba"}, - {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca"}, - {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba"}, - {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b"}, - {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e"}, - {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8"}, - {file = "yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b"}, - {file = "yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed"}, - {file = "yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2"}, - {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, - {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" -propcache = ">=0.2.1" - -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[[package]] -name = "zope-event" -version = "6.1" -description = "Very basic event publishing system" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0"}, - {file = "zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0"}, -] - -[package.extras] -docs = ["Sphinx"] -test = ["zope.testrunner (>=6.4)"] - -[[package]] -name = "zope-interface" -version = "8.2" -description = "Interfaces for Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "zope_interface-8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:788c293f3165964ec6527b2d861072c68eef53425213f36d3893ebee89a89623"}, - {file = "zope_interface-8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9a4e785097e741a1c953b3970ce28f2823bd63c00adc5d276f2981dd66c96c15"}, - {file = "zope_interface-8.2-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:16c69da19a06566664ddd4785f37cad5693a51d48df1515d264c20d005d322e2"}, - {file = "zope_interface-8.2-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c31acfa3d7cde48bec45701b0e1f4698daffc378f559bfb296837d8c834732f6"}, - {file = "zope_interface-8.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0723507127f8269b8f3f22663168f717e9c9742107d1b6c9f419df561b71aa6d"}, - {file = "zope_interface-8.2-cp310-cp310-win_amd64.whl", hash = "sha256:3bf73a910bb27344def2d301a03329c559a79b308e1e584686b74171d736be4e"}, - {file = "zope_interface-8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c65ade7ea85516e428651048489f5e689e695c79188761de8c622594d1e13322"}, - {file = "zope_interface-8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef4b43659e1348f35f38e7d1a6bbc1682efde239761f335ffc7e31e798b65b"}, - {file = "zope_interface-8.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dfc4f44e8de2ff4eba20af4f0a3ca42d3c43ab24a08e49ccd8558b7a4185b466"}, - {file = "zope_interface-8.2-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8f094bfb49179ec5dc9981cb769af1275702bd64720ef94874d9e34da1390d4c"}, - {file = "zope_interface-8.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d2bb8e7364e18f083bf6744ccf30433b2a5f236c39c95df8514e3c13007098ce"}, - {file = "zope_interface-8.2-cp311-cp311-win_amd64.whl", hash = "sha256:6f4b4dfcfdfaa9177a600bb31cebf711fdb8c8e9ed84f14c61c420c6aa398489"}, - {file = "zope_interface-8.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:624b6787fc7c3e45fa401984f6add2c736b70a7506518c3b537ffaacc4b29d4c"}, - {file = "zope_interface-8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc9ded9e97a0ed17731d479596ed1071e53b18e6fdb2fc33af1e43f5fd2d3aaa"}, - {file = "zope_interface-8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:532367553e4420c80c0fc0cabcc2c74080d495573706f66723edee6eae53361d"}, - {file = "zope_interface-8.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2bf9cf275468bafa3c72688aad8cfcbe3d28ee792baf0b228a1b2d93bd1d541a"}, - {file = "zope_interface-8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0009d2d3c02ea783045d7804da4fd016245e5c5de31a86cebba66dd6914d59a2"}, - {file = "zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640"}, - {file = "zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec"}, - {file = "zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c"}, - {file = "zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664"}, - {file = "zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0"}, - {file = "zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb"}, - {file = "zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028"}, - {file = "zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb"}, - {file = "zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf"}, - {file = "zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080"}, - {file = "zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c"}, - {file = "zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c"}, - {file = "zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48"}, - {file = "zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224"}, -] - -[package.extras] -docs = ["Sphinx", "furo", "repoze.sphinx.autointerface"] -test = ["coverage[toml]", "zope.event", "zope.testing"] -testing = ["coverage[toml]", "zope.event", "zope.testing"] - -[[package]] -name = "zstd" -version = "1.5.7.3" -description = "ZSTD Bindings for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "zstd-1.5.7.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e72b353870286648a63261437b75f297e2967a26f210da4dfa4c08949935de7a"}, - {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:26aff5f24caeffde35f1b757499e935bc60a8e0d9e1ea8bde05dcf7d53df9325"}, - {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:586a820fbd06e3d9a9d9def572e779254bf8dee7406b8c6dc44eff6807d60c6d"}, - {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:35a147b10fd16ebb3a2595e361780388feb8f336d70772a05dfb7a8348a47bfd"}, - {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c2a80c51e2175ffcd6f08b2a4c9fbc121aad69fbbcebb3364e783a96d0488fda"}, - {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_i686.whl", hash = "sha256:5f20f74a782f3296d1585d9bbc49d422e339b154c66398c74537e433446c51ba"}, - {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_x86_64.whl", hash = "sha256:2550c2e6bfbff0904f28821005f176bfdaec1872d60053665a284fb0254a10e7"}, - {file = "zstd-1.5.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76f3535616887a1a38e8c6d0de693a23c5bb1f190651eb20d96bfc8e4ab706a0"}, - {file = "zstd-1.5.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67507937e8e4c2a8dfed8e7fa77f4043ec9e6e831a5faebf0f99138b1a25ccbd"}, - {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd0a2309c524608ce7b940abcc9f8eb5447c6ea2c834a630e0081211ab9d40ec"}, - {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2b497306580d544406b5414c8485c4037a9283ad2ca6ae4ccdf3732c9563141d"}, - {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:e9939a98ea946d1f9e8f9fecc940ae939b8e9e5ef9d71b104f7843567d764f30"}, - {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:d32c0fe8f6b805b7cbeaade462b094a843e84d893d8c6f66ab705e8777cc1850"}, - {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8aa33b1ef24602b2ef1e8aa67ea3c8f821854a4dbf70c3c8c46b96b54b6ceb5d"}, - {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1bd69fa9c4c97fd04206c919dedbf9f75f544ebb77880db51a13c1e3802cd655"}, - {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aee96742a64ede2e35dc0316ef0cd1e50089e889ce77e82ca8edf40174a1439c"}, - {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ac207573d2815a51f4f4fd4e255408396491729a01f690b9f5fb672d39e5610"}, - {file = "zstd-1.5.7.3-cp310-cp310-win32.whl", hash = "sha256:04e62e4f9eba79699d072d3c96731ed4aff99f1d334eb967489b091186a6078f"}, - {file = "zstd-1.5.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:0794b23b9950af240888087d2bd5943aa4be67273ba32cdafabdc5704778b90e"}, - {file = "zstd-1.5.7.3-cp310-cp310-win_arm64.whl", hash = "sha256:7827fd4901f3e71a7a755d26719549658f08e04fdf0870a952ed08e71b484435"}, - {file = "zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a"}, - {file = "zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7"}, - {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5"}, - {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5"}, - {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424"}, - {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7"}, - {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5"}, - {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb"}, - {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc"}, - {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e"}, - {file = "zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d"}, - {file = "zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3"}, - {file = "zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527"}, - {file = "zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203"}, - {file = "zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8"}, - {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54"}, - {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304"}, - {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699"}, - {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8"}, - {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41"}, - {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328"}, - {file = "zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3"}, - {file = "zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c"}, - {file = "zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f"}, - {file = "zstd-1.5.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2455e56f1d265dacbd450510b8c2f632a5d8d92c23282e7723fb04af37001a2"}, - {file = "zstd-1.5.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3486dc4f1b4e52bb059f8eec1f31daa3e540062c0f522f221782cf132a8bc9a8"}, - {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_i686.whl", hash = "sha256:1cb47bf10ffcb6a782edacfe758da2c94879f7e89c6628feb3f1254daf8cc596"}, - {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:07b1378d1230ddeea8773f99d7518a3060e6468c76edd502057cb795fe278d7e"}, - {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ee34317f013e3405108f5baea53502159809cfc4510598d614257525500c70d"}, - {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c19127ca2c79855376a34a2d7a6969408094b25c1f44485b0373eba4be851b98"}, - {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e79cae70dd08cb247391312463085c624c0302e8c860d13f87f4c76502d8202"}, - {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e83e91e5daf89037c737f5529da0f80da80a78a6ad0b1d70a09860eb267dea4"}, - {file = "zstd-1.5.7.3-cp313-cp313-win32.whl", hash = "sha256:2283f3bb910c028e1b9fe76b834016012ab021025a0ea197e27a1333f85e3031"}, - {file = "zstd-1.5.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ad5fe4c36bab5dfa5a4b8d050bd07c50c1e69f94d381bc65337ab14cd69e5b1"}, - {file = "zstd-1.5.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e878172b0eb69ac2edc6576eb862e00747c7c25e638fb354630a1ea7cfddf49"}, - {file = "zstd-1.5.7.3-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:7e0a7e94d5b63b4cacf2396079ca9584d11f49f87cb4e5aa21f126a8f6b83446"}, - {file = "zstd-1.5.7.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5412c86c34cbaf6906433ef3f2c96c407f208782f06cd3e5f01f066788adb3b8"}, - {file = "zstd-1.5.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f94246befb1e473211a298c96e5768f3c63eaad814ac14d160d79ae9858e1d03"}, - {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31050e17a1a546fb82c90eee8ee3c30d22b9d0594b5937e69d38b7a5084af2a2"}, - {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ba8ec5dfd48c86d19f880713246f85d09ee06e8cd17141956258650878000d6"}, - {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_i686.whl", hash = "sha256:3005540ba406157f3e205c998709ab5f8e68b390c658c7c238eb8986092089d5"}, - {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:3934b54a3b7df039fcd4cf7b0f0a38c86ce44d26321255ffc3fac73d6cdcc59d"}, - {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e9230cd3e9153e2bed16f332558f8f3f7d869f4d15e8fa3f9c360bfa163a8b4a"}, - {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bffba70af539f14f9df5367b1add9119f14d5e35b658aef7b765417ea461e0e"}, - {file = "zstd-1.5.7.3-cp314-cp314-win32.whl", hash = "sha256:a006e70c88ab67bb56989e11d820adc7601a6a7ad5558b3c6c690b19a1dadc5b"}, - {file = "zstd-1.5.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:cb4957c330c7b94b0546c7b9529723b49e865608683b9503a251fe793da9d4db"}, - {file = "zstd-1.5.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:a785426081ab7cafe4522876ac771d701766deea9a6d8352e87744da00e6637f"}, - {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_i686.whl", hash = "sha256:b52ef154793be0399befd742328ec6f5dff95154248d6d18dd65851cf22a1a5f"}, - {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:8024a8ba9156b1b2e64e69d147df5ddedeaed107f9da02a3428fd7baf3e5b920"}, - {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_i686.whl", hash = "sha256:31ac7fbacca4759aad4b6abc13bbc05e68788e9e85a968255f7624b3b8db31df"}, - {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_x86_64.whl", hash = "sha256:d03b2927c5843ded4d1319836a33a9c21675d2f86f916a2f234a060d4c67d87c"}, - {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_i686.whl", hash = "sha256:5dfbf2564eb574fc1f45613ecf28036a82533c3dd70e7bb1c9854168c638da7a"}, - {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_x86_64.whl", hash = "sha256:7f2f5776b902f41daf7b63e75a9384b0d7c855f824f14dabefc67814b8fa5611"}, - {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_i686.whl", hash = "sha256:ffbeabcabcb644d29289277f9023aa51c04de71935695f5388da9c8428c81e0f"}, - {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_x86_64.whl", hash = "sha256:0b891ca9ad84562941367ab7be817b8748df75eb6b7ced23d5b082b4602c1c6e"}, - {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:925f83e2e749cd7109985bc96835cd2fd814435d74f0d9a1d7c8506166e97592"}, - {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:57d2ff6b96886aaec2aa4721f7c8e890a8b43b5c4ae4f3737a0733b55cd82daa"}, - {file = "zstd-1.5.7.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:8cd516ba02e0f9e6df1b4a6dc0cd5e66ac6eeb55b15833a70d529aa32eddaa91"}, - {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_14_x86_64.whl", hash = "sha256:9f6ea980866f43ff7ef5e41eac54b94f9159b9807f32f691b02ca381b50b76af"}, - {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_4_i686.whl", hash = "sha256:3e650ed68b655d55556099aa62f168a352396139a879a94312322a1d02502491"}, - {file = "zstd-1.5.7.3-cp35-cp35m-win32.whl", hash = "sha256:da88b288a2844f04713df89a514dd9dc0e925ee63e119c845aef14ccbcc9183e"}, - {file = "zstd-1.5.7.3-cp35-cp35m-win_amd64.whl", hash = "sha256:96c949e8508f2d4dced3444a3bfb99d51653ac6f28ef0aa1561f5758adc8afed"}, - {file = "zstd-1.5.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7509b11b5f8313e87cce16269e222f89e7e49b51f1e6a3e7454b7c7b599d3211"}, - {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fb8aafd47ba73ff50a7994668dbec5c97f26ddcd28c03242d8f8b4138d8c723c"}, - {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:586efc62d7e93d52d0b3951ef48a4b5181866152061bda1bef49f7ea85ec0d7f"}, - {file = "zstd-1.5.7.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5030d51631a09a0d7b3e47f928b6234bd78ce8b897a255fc1146e8cf772a8f4d"}, - {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_14_x86_64.whl", hash = "sha256:a8d1ee9faa89b21ff03ae3fe8d969e850c60b8c3f8a1389fa585c10eddaa2bb4"}, - {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_4_i686.whl", hash = "sha256:4504ba7a9ddd1919e919f81d3ec541313e6826f1f3cad8e3a7ebe29a3ae5cda6"}, - {file = "zstd-1.5.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:aca7d1fef13f412168ac524307586f0d57f96a89bd7e0620b2f60df3b0066c8d"}, - {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:12d2925424d02add2f835c7549106151ece9eae262e96aee34af5d84178ba824"}, - {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:30512cce4108b26ede395ac521c0997c340bd19f177a1c0260bbffcb64861d30"}, - {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_i686.whl", hash = "sha256:2e6caf5f3084e6473a6dfd15285c47122ba92f4fb97ecfca855adf415603532a"}, - {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_x86_64.whl", hash = "sha256:927c95b991e81f39b02e42c9b391f2b3569e6dbe29d7fc2dce6ca778475c0934"}, - {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2174fd7f588b2eb95a402c3d40f4676370eb50292362a0995295084b8f5d521e"}, - {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:3b05817bfdfc395999b6b3c9ea4f7c05e91bceafc3fc819906d5f0445afa4335"}, - {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:c67f0fcf4348343d25ecd35a44d33b6d31814e9ab3ee8676039de809579905a4"}, - {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:40195c0056841aad6553172963adecf31b6ae1fdb9778d657ce9a2493d1791ee"}, - {file = "zstd-1.5.7.3-cp37-cp37m-win32.whl", hash = "sha256:b6ac3ae562758184fc1570399ea9d269163b488dbb0c4a44701e89f61ca6d1d6"}, - {file = "zstd-1.5.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e9f059d9c9f6f13ae78bfa9778755462b3ea53e4a5185941169422dd97c9fd22"}, - {file = "zstd-1.5.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:99e92b97c97d83e403615c12b644e8616fc7e8a8b4fa0c0558bcb9980baf5c92"}, - {file = "zstd-1.5.7.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a6b4ff0d5704994eb0d7ba2ea0b25acd749bb78a1c325289a8cba7651f0cbbff"}, - {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:edf4b595ab29a980f6f60fa71c64ab029d9ced97fb9c7c9ae555fe1159d8379d"}, - {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3cd48ec1dce8a8a06a3978225b20f28b7764e4191c436277e0abc60539e040da"}, - {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_i686.whl", hash = "sha256:1380ecc510a3885fad326863a7f42b3391560b471aeea60b04f9c1ece439b198"}, - {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_x86_64.whl", hash = "sha256:5fdff5190698e6d48a3facb58085a6c33b62be610f40e80299d975dbc75b32c8"}, - {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:595d6495e96744fa5c9b78f38e8379f9eebfb97ae4f7ecc2639af4fd51459e07"}, - {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9bc3d6b7f2dec391b7539a0f43deb07bca1d68867082a07a286c2237f16390fd"}, - {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b8e62d533281946100c023a1168bd8935db6452bdd0f0b776afe8e80255e74c3"}, - {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3a5dcc7ddcd56f131bee612b5feadd9b65e3996c0f4c6a485e2b2f20e7a324de"}, - {file = "zstd-1.5.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dbb497482dd63abe72a209345dbafa52817bd484c1d08139da080c14b1dadc7b"}, - {file = "zstd-1.5.7.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a599489d4e7e794981536521ee5dcfa61b0a641996409669b9aba5400b5cff83"}, - {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4a7ec28ca27fc347d7325eeb06d66cd2649846d5bfe77b18beed38d1870dd876"}, - {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:703481b41e5b3d33cd4e6a0b7116e8bc33a712aba1526d5fcad3e4303dd70fa1"}, - {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_i686.whl", hash = "sha256:61b0707c090d59ba879eac4b475562c5b9c1b375d0419d78fb398f156037f7df"}, - {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_x86_64.whl", hash = "sha256:7090ac97b14dea2969ba1ed427b38efe137efcdf556dc8740d3e035b04cbc8b4"}, - {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5204bf9f3f2936ee3a28bfe43a57b78f88439c1777197295a0661d6de38caa80"}, - {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:431d4fecf764c305f29c1b9117d0d2ec5eb5523fc81516f1ee82509cb3b8e088"}, - {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2f213a32ab5e90bf165717f05fc1e3c214eeca7b6a33311e2397d89879c2f87"}, - {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f87d617dac84b571bb74dc9d6905c66906dca982143adbe8e497ba2ce888cca"}, - {file = "zstd-1.5.7.3-cp39-cp39-win32.whl", hash = "sha256:9511957b5b8b5c0d4e737dff3a330a445a44005e09278bb8c799a76eb7f99d90"}, - {file = "zstd-1.5.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:9389848cc8297199b0fe2cd2985e5944f611ed518aa508136065ea0159051904"}, - {file = "zstd-1.5.7.3-cp39-cp39-win_arm64.whl", hash = "sha256:0cdf00f53cd38ce1f9edc79f68727150b9e65f4b33a3e8b59d94d0886cf43dbf"}, - {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:c5ac39836233356d32d0fe3d2f9525373c47c19f75fde68c16cf2293b7648b86"}, - {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:62fe5b560f389fdb40384a1711b7737bd9e27861f248cb89f19fed90a4cf0830"}, - {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux_2_14_x86_64.whl", hash = "sha256:55fb8ac423800811f8b0c896b9617ecc91a1d4da15f66fb42ba162bfa5aa5a2d"}, - {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b9ec4d5ba8c170d3fdf21ae5da3c15eaea2beef9c419a5f3274a6f9e03c412a"}, - {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7ab69fc4d90eeb64b98a567751f8e48373f4bcf301597fca344b8e8342e1d5e"}, - {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da70f0918bf739bc75d7770410c9b94ea0dcb6f02d7ef70598b464bd5fcb193a"}, - {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dd5c069d0409284f1963b0b6b119f21b1da9e22a503e88933eb0696249d87d3"}, - {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46ca4a075f36f118e2ce07ba07d9ece7aeda193cea6f50b82aaee635df7b5fc2"}, - {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:4a521cb7615fc61bfe9514bea182e224894b5987fc7843b6d6da20a61206ef24"}, - {file = "zstd-1.5.7.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71ea22c953a164f34eb4b8c2c3b97eaa22da6a75296ea80b3ba4473187f15046"}, - {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d"}, - {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd"}, - {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143"}, - {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188"}, - {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719"}, - {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079"}, - {file = "zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a"}, - {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:781ec2644a3ce84c1cc19b0e057e1e8ea45260a8871eb6524614be75c9b432b9"}, - {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:ab74f37f2832d4a7c89d877ed9a70b1ef988fc2353678a122427039eb1dc6e36"}, - {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:521a3072fedcce025515d99242e346318d1815789033b7c0108796e151c42deb"}, - {file = "zstd-1.5.7.3-pp36-pypy36_pp73-win32.whl", hash = "sha256:94d404fd56765ff2952053cb2f6f980b88e3384a71af147c3ede9f6c6bea32d6"}, - {file = "zstd-1.5.7.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:33f7e24d626938234c3c33df1988b79846628cf08dfab216bb19f85e7fcad65b"}, - {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c0c84fd4a87f28b8bed01cbaf128d33dfa209f03df2890dbc8c01e17a109c2d4"}, - {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0e334e45becf5a4844c8d64593eb358585e1553a7355f2172c865efc639ac051"}, - {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:15523e289509d7792418edb8c255cc1dacc65cda000428424c988208a682b8be"}, - {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2924befc3cb1a2310e1c03bd93469a2de8f0703e8805fe1f40367fbc2cece472"}, - {file = "zstd-1.5.7.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:173680156dbe959c80d72a1f15ef2034fd414b9d1ee507df152e416bc37665ef"}, - {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31d66b73a9861ee61bc6486fb9d1d33eabc86e506e49a210f30a91a241b8e643"}, - {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:a820a67491c1cf7a66698478a28b7d2517b0ae2e2775d834ca4f2624ba859e72"}, - {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c385f92c37f4275d477388e46af8941580d7eeaad4c524c8f9aa50d016acbc7e"}, - {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:cecce78a3d639a3c439b1e355791e0f1ddbe8ed63d94f34c7973e92d384e6fc0"}, - {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e769fc830f5e2079612a27d6540e4147cd8dc8beacfaf73a48152f30a191e979"}, - {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:37a6750c25b561b05110313fdde4acd51246075a317e1c7a2491c96d2d863282"}, - {file = "zstd-1.5.7.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0e95265e22f07cea6675baab762c9c4577a40d47824b01e0dcdf1a18b46aa041"}, - {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:878d859a7e1ebc078e0a575c05bcf3b0682b77cabd65bdbdd5e93c137ff1799b"}, - {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7efcf83189be9d842b9392ffd821b317cbd9447a49c590659abd3311e82c1676"}, - {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:a75dfdbca7dc01e7b35ca9b22e5b9792037b1515857e67b34bd737b213e49432"}, - {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5235dde49df717e5ca58f689e110bf1c4ed578170ab59e77f8a7a5055e4d8c07"}, - {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f876acad51d2184269ee6fd7e4c4aad9b7a0eca174d7d8db981ea079b57cbaf4"}, - {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2920e90ef200c7b2cbc73b4271c2271abf6195877b813ede0b5b76289e32fc8e"}, - {file = "zstd-1.5.7.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1f6dd0f2845a9817f0d0920eb0efd2d8a0168b71b8d8c85d2655d9d997f127ba"}, - {file = "zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495"}, -] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.11,<3.13" -content-hash = "167d4549788b8bc8bb7772b9a81ade1eab73d8f354251a8d6af4901223cc7f67" diff --git a/api/pyproject.toml b/api/pyproject.toml index 45e7899f7a..5d55a6bcf4 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,24 @@ -[build-system] -build-backend = "poetry.core.masonry.api" -requires = ["poetry-core"] +[dependency-groups] +dev = [ + "bandit==1.7.9", + "coverage==7.5.4", + "django-silk==5.3.2", + "docker==7.1.0", + "filelock==3.20.3", + "freezegun==1.5.1", + "mypy==1.10.1", + "pylint==3.2.5", + "pytest==9.0.3", + "pytest-cov==5.0.0", + "pytest-django==4.8.0", + "pytest-env==1.1.3", + "pytest-randomly==3.15.0", + "pytest-xdist==3.6.1", + "ruff==0.15.11", + "tqdm==4.67.1", + "vulture==2.14", + "prek==0.3.9" +] [project] authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] @@ -23,26 +41,29 @@ dependencies = [ "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", "defusedxml==0.7.1", - "gunicorn==23.0.0", - "lxml==5.3.2", + "django-eventstream==5.3.3", + "gunicorn==26.0.0", + "uvloop==0.22.1", + "lxml==6.1.0", "prowler @ git+https://github.com/prowler-cloud/prowler.git@master", "psycopg2-binary==2.9.9", "pytest-celery[redis] (==1.3.0)", "sentry-sdk[django] (==2.56.0)", "uuid6==2024.7.10", "openai (==1.109.1)", - "xmlsec==1.3.14", + "xmlsec==1.3.17", "h2 (==4.3.0)", "markdown (==3.10.2)", "drf-simple-apikey (==2.2.1)", "matplotlib (==3.10.8)", "reportlab (==4.4.10)", "neo4j (==6.1.0)", - "cartography (==0.132.0)", + "cartography (==0.138.1)", "gevent (==25.9.1)", "werkzeug (==3.1.7)", "sqlparse (==0.5.5)", - "fonttools (==4.62.1)" + "fonttools (==4.62.1)", + "uvicorn-worker (==0.4.0)", ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" @@ -50,28 +71,417 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.24.0" +version = "1.33.0" -[project.scripts] -celery = "src.backend.config.settings.celery" +# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml). +# target-version tracks this project's lowest supported Python. +[tool.ruff] +src = ["src"] +target-version = "py311" -[tool.poetry.group.dev.dependencies] -bandit = "1.7.9" -coverage = "7.5.4" -django-silk = "5.3.2" -docker = "7.1.0" -filelock = "3.20.3" -freezegun = "1.5.1" -marshmallow = "==3.26.2" -mypy = "1.10.1" -pylint = "3.2.5" -pytest = "8.2.2" -pytest-cov = "5.0.0" -pytest-django = "4.8.0" -pytest-env = "1.1.3" -pytest-randomly = "3.15.0" -pytest-xdist = "3.6.1" -ruff = "0.5.0" -safety = "3.7.0" -tqdm = "4.67.1" -vulture = "2.14" +[tool.ruff.lint] +# Defaults (E4/E7/E9, F) plus import sorting, modern-syntax upgrades, and +# comprehension lints — all mechanically auto-fixable. flake8-bugbear (B) is a +# good next step but needs manual cleanup (e.g. B904 raise-from), so it is left +# out of the shared baseline for now. +extend-select = [ + "I", # isort — import ordering (prek's isort hook covers only the SDK) + "UP", # pyupgrade — modern syntax for the min supported Python + "C4" # flake8-comprehensions +] + +[tool.uv] +# Transitive pins matching master to avoid silent drift; bump deliberately. +constraint-dependencies = [ + "about-time==4.2.1", + "adal==1.2.7", + "aioboto3==15.5.0", + "aiobotocore==2.25.1", + "aiofiles==24.1.0", + "aiohappyeyeballs==2.6.1", + "aiohttp==3.14.0", + "aioitertools==0.13.0", + "aiosignal==1.4.0", + "alibabacloud-actiontrail20200706==2.4.1", + "alibabacloud-credentials==1.0.3", + "alibabacloud-credentials-api==1.0.0", + "alibabacloud-cs20151215==6.1.0", + "alibabacloud-darabonba-array==0.1.0", + "alibabacloud-darabonba-encode-util==0.0.2", + "alibabacloud-darabonba-map==0.0.1", + "alibabacloud-darabonba-signature-util==0.0.4", + "alibabacloud-darabonba-string==0.0.4", + "alibabacloud-darabonba-time==0.0.1", + "alibabacloud-ecs20140526==7.2.5", + "alibabacloud-endpoint-util==0.0.4", + "alibabacloud-gateway-oss==0.0.17", + "alibabacloud-gateway-oss-util==0.0.3", + "alibabacloud-gateway-sls==0.4.0", + "alibabacloud-gateway-sls-util==0.4.0", + "alibabacloud-gateway-spi==0.0.3", + "alibabacloud-openapi-util==0.2.4", + "alibabacloud-oss-util==0.0.6", + "alibabacloud-oss20190517==1.0.6", + "alibabacloud-ram20150501==1.2.0", + "alibabacloud-rds20140815==12.0.0", + "alibabacloud-sas20181203==6.1.0", + "alibabacloud-sls20201230==5.9.0", + "alibabacloud-sts20150401==1.1.6", + "alibabacloud-tea==0.4.3", + "alibabacloud-tea-openapi==0.4.4", + "alibabacloud-tea-util==0.3.14", + "alibabacloud-tea-xml==0.0.3", + "alibabacloud-vpc20160428==6.13.0", + "alive-progress==3.3.0", + "aliyun-log-fastpb==0.2.0", + "amqp==5.3.1", + "annotated-types==0.7.0", + "anyio==4.12.1", + "applicationinsights==0.11.10", + "apscheduler==3.11.2", + "argcomplete==3.5.3", + "asgiref==3.11.0", + "astroid==3.2.4", + "async-timeout==5.0.1", + "attrs==25.4.0", + "authlib==1.6.12", + "autopep8==2.3.2", + "azure-cli-core==2.83.0", + "azure-cli-telemetry==1.1.0", + "azure-common==1.1.28", + "azure-core==1.38.1", + "azure-identity==1.21.0", + "azure-keyvault-certificates==4.10.0", + "azure-keyvault-keys==4.10.0", + "azure-keyvault-secrets==4.10.0", + "azure-mgmt-apimanagement==5.0.0", + "azure-mgmt-applicationinsights==4.1.0", + "azure-mgmt-authorization==4.0.0", + "azure-mgmt-compute==34.0.0", + "azure-mgmt-containerinstance==10.1.0", + "azure-mgmt-containerregistry==12.0.0", + "azure-mgmt-containerservice==34.1.0", + "azure-mgmt-core==1.6.0", + "azure-mgmt-cosmosdb==9.7.0", + "azure-mgmt-databricks==2.0.0", + "azure-mgmt-datafactory==9.2.0", + "azure-mgmt-eventgrid==10.4.0", + "azure-mgmt-eventhub==11.2.0", + "azure-mgmt-keyvault==10.3.1", + "azure-mgmt-loganalytics==12.0.0", + "azure-mgmt-logic==10.0.0", + "azure-mgmt-monitor==6.0.2", + "azure-mgmt-network==28.1.0", + "azure-mgmt-postgresqlflexibleservers==1.1.0", + "azure-mgmt-rdbms==10.1.0", + "azure-mgmt-recoveryservices==3.1.0", + "azure-mgmt-recoveryservicesbackup==9.2.0", + "azure-mgmt-resource==24.0.0", + "azure-mgmt-search==9.1.0", + "azure-mgmt-security==7.0.0", + "azure-mgmt-sql==3.0.1", + "azure-mgmt-storage==22.1.1", + "azure-mgmt-subscription==3.1.1", + "azure-mgmt-synapse==2.0.0", + "azure-mgmt-web==8.0.0", + "azure-monitor-query==2.0.0", + "azure-storage-blob==12.24.1", + "azure-synapse-artifacts==0.21.0", + "backoff==2.2.1", + "bandit==1.7.9", + "billiard==4.2.4", + "blinker==1.9.0", + "boto3==1.40.61", + "botocore==1.40.61", + "cartography==0.138.1", + "celery==5.6.2", + "certifi==2026.1.4", + "cffi==2.0.0", + "charset-normalizer==3.4.4", + "circuitbreaker==2.1.3", + "click==8.3.1", + "click-didyoumean==0.3.1", + "click-plugins==1.1.1.2", + "click-repl==0.3.0", + "cloudflare==4.3.1", + "colorama==0.4.6", + "contextlib2==21.6.0", + "contourpy==1.3.3", + "coverage==7.5.4", + "cron-descriptor==1.4.5", + "crowdstrike-falconpy==1.6.0", + "cryptography==46.0.7", + "cycler==0.12.1", + "darabonba-core==1.0.5", + "dash==3.1.1", + "dash-bootstrap-components==2.0.3", + "debugpy==1.8.20", + "decorator==5.2.1", + "defusedxml==0.7.1", + "dill==0.4.1", + "distro==1.9.0", + "dj-rest-auth==7.0.1", + "django==5.1.15", + "django-allauth==65.15.0", + "django-celery-beat==2.9.0", + "django-celery-results==2.6.0", + "django-cors-headers==4.4.0", + "django-environ==0.11.2", + "django-eventstream==5.3.3", + "django-filter==24.3", + "django-guid==3.5.0", + "django-postgres-extra==2.0.9", + "django-silk==5.3.2", + "django-timezone-field==7.2.1", + "djangorestframework==3.15.2", + "djangorestframework-jsonapi==7.0.2", + "djangorestframework-simplejwt==5.5.1", + "dnspython==2.8.0", + "docker==7.1.0", + "dogpile-cache==1.5.0", + "dparse==0.6.4", + "drf-extensions==0.8.0", + "drf-nested-routers==0.95.0", + "drf-simple-apikey==2.2.1", + "drf-spectacular==0.27.2", + "drf-spectacular-jsonapi==0.5.1", + "dulwich==1.2.5", + "duo-client==5.5.0", + "durationpy==0.10", + "email-validator==2.2.0", + "execnet==2.1.2", + "filelock==3.20.3", + "flask==3.1.3", + "fonttools==4.62.1", + "freezegun==1.5.1", + "frozenlist==1.8.0", + "gevent==25.9.1", + "google-api-core==2.29.0", + "google-api-python-client==2.163.0", + "google-auth==2.48.0", + "google-auth-httplib2==0.2.0", + "google-cloud-access-context-manager==0.3.0", + "google-cloud-asset==4.2.0", + "google-cloud-org-policy==1.16.0", + "google-cloud-os-config==1.23.0", + "google-cloud-resource-manager==1.16.0", + "googleapis-common-protos==1.72.0", + "gprof2dot==2025.4.14", + "graphemeu==0.7.2", + "greenlet==3.3.1", + "grpc-google-iam-v1==0.14.3", + "grpcio==1.76.0", + "grpcio-status==1.76.0", + "gunicorn==26.0.0", + "h11==0.16.0", + "h2==4.3.0", + "hpack==4.1.0", + "httpcore==1.0.9", + "httplib2==0.31.2", + "httpx==0.28.1", + "humanfriendly==10.0", + "hyperframe==6.1.0", + "iamdata==0.1.202605131", + "idna==3.15", + "importlib-metadata==8.7.1", + "inflection==0.5.1", + "iniconfig==2.3.0", + "iso8601==2.1.0", + "isodate==0.7.2", + "isort==5.13.2", + "itsdangerous==2.2.0", + "jinja2==3.1.6", + "jiter==0.13.0", + "jmespath==1.1.0", + "joblib==1.5.3", + "jsonpatch==1.33", + "jsonpickle==4.1.1", + "jsonpointer==3.0.0", + "jsonschema==4.23.0", + "jsonschema-specifications==2025.9.1", + "keystoneauth1==5.13.0", + "kingfisher-bin==1.104.0", + "kiwisolver==1.4.9", + "knack==0.11.0", + "kombu==5.6.2", + "kubernetes==32.0.1", + "lxml==6.1.0", + "lz4==4.4.5", + "markdown==3.10.2", + "markdown-it-py==4.0.0", + "markupsafe==3.0.3", + "marshmallow==4.3.0", + "matplotlib==3.10.8", + "mccabe==0.7.0", + "mdurl==0.1.2", + "microsoft-kiota-abstractions==1.9.9", + "microsoft-kiota-authentication-azure==1.9.9", + "microsoft-kiota-http==1.9.9", + "microsoft-kiota-serialization-form==1.9.9", + "microsoft-kiota-serialization-json==1.9.9", + "microsoft-kiota-serialization-multipart==1.9.9", + "microsoft-kiota-serialization-text==1.9.9", + "microsoft-security-utilities-secret-masker==1.0.0b4", + "msal==1.35.0b1", + "msal-extensions==1.2.0", + "msgraph-core==1.3.8", + "msgraph-sdk==1.55.0", + "msrest==0.7.1", + "msrestazure==0.6.4.post1", + "multidict==6.7.1", + "mypy==1.10.1", + "mypy-extensions==1.1.0", + "narwhals==2.16.0", + "neo4j==6.1.0", + "nest-asyncio==1.6.0", + "nltk==3.9.4", + "numpy==2.2.6", + "oauthlib==3.3.1", + "oci==2.169.0", + "openai==1.109.1", + "openstacksdk==4.2.0", + "opentelemetry-api==1.39.1", + "opentelemetry-sdk==1.39.1", + "opentelemetry-semantic-conventions==0.60b1", + "os-service-types==1.8.2", + "packageurl-python==0.17.6", + "packaging==26.0", + "pagerduty==6.1.0", + "pandas==2.2.3", + "pbr==7.0.3", + "pillow==12.2.0", + "pkginfo==1.12.1.2", + "platformdirs==4.5.1", + "plotly==6.5.2", + "pluggy==1.6.0", + "policyuniverse==1.5.1.20231109", + "portalocker==2.10.1", + "prek==0.3.9", + "prompt-toolkit==3.0.52", + "propcache==0.4.1", + "proto-plus==1.27.0", + "protobuf==6.33.5", + "psutil==7.2.2", + "psycopg2-binary==2.9.9", + "py-deviceid==0.1.1", + "py-iam-expand==0.3.0", + "py-ocsf-models==0.8.1", + "pyasn1==0.6.3", + "pyasn1-modules==0.4.2", + "pycodestyle==2.14.0", + "pycparser==3.0", + "pydantic==2.12.5", + "pydantic-core==2.41.5", + "pygithub==2.8.0", + "pygments==2.20.0", + "pyjwt==2.13.0", + "pylint==3.2.5", + "pymsalruntime==0.18.1", + "pynacl==1.6.2", + "pyopenssl==26.0.0", + "pyparsing==3.3.2", + "pyreadline3==3.5.4", + "pysocks==1.7.1", + "pytest==9.0.3", + "pytest-celery==1.3.0", + "pytest-cov==5.0.0", + "pytest-django==4.8.0", + "pytest-docker-tools==3.1.9", + "pytest-env==1.1.3", + "pytest-randomly==3.15.0", + "pytest-xdist==3.6.1", + "python-crontab==3.3.0", + "python-dateutil==2.9.0.post0", + "python-digitalocean==1.17.0", + "python3-saml==1.16.0", + "pytz==2025.1", + "pywin32==311", + "pyyaml==6.0.3", + "redis==7.1.0", + "referencing==0.37.0", + "regex==2026.1.15", + "reportlab==4.4.10", + "requests==2.33.1", + "requests-file==3.0.1", + "requests-oauthlib==2.0.0", + "requestsexceptions==1.4.0", + "retrying==1.4.2", + "rich==14.3.2", + "rpds-py==0.30.0", + "rsa==4.9.1", + "ruamel-yaml==0.19.1", + "ruff==0.15.11", + "s3transfer==0.14.0", + "scaleway==2.10.3", + "scaleway-core==2.10.3", + "schema==0.7.5", + "sentry-sdk==2.56.0", + "setuptools==80.10.2", + "shellingham==1.5.4", + "shodan==1.31.0", + "six==1.17.0", + "slack-sdk==3.39.0", + "sniffio==1.3.1", + "sqlparse==0.5.5", + "statsd==4.0.1", + "std-uritemplate==2.0.8", + "stevedore==5.6.0", + "tabulate==0.9.0", + "tenacity==9.1.2", + "tldextract==5.3.1", + "tomlkit==0.14.0", + "tqdm==4.67.1", + "typer==0.21.1", + "types-aiobotocore-ecr==3.1.1", + "typing-extensions==4.15.0", + "typing-inspection==0.4.2", + "tzdata==2025.3", + "tzlocal==5.3.1", + "uritemplate==4.2.0", + "urllib3==2.7.0", + "uuid6==2024.7.10", + "uvicorn==0.49.0", + "uvloop==0.22.1", + "vine==5.1.0", + "vulture==2.14", + "wcwidth==0.5.3", + "websocket-client==1.9.0", + "werkzeug==3.1.7", + "workos==6.0.8", + "wrapt==1.17.3", + "xlsxwriter==3.2.9", + "xmlsec==1.3.17", + "xmltodict==1.0.2", + "yarl==1.22.0", + "zipp==3.23.0", + "zope-event==6.1", + "zope-interface==8.2", + "zstd==1.5.7.3" +] +# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0. +# Attack Paths does not ingest Okta today, so override the Cartography +# dependency to the Prowler pin. +# +# prowler@master needs azure-mgmt-containerservice==34.1.0, but cartography +# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not +# ingest Azure today, so override the Cartography dependency to the Prowler pin. +# +# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies]. +# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires +# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the +# SDK's hard pin; override it to the patched, kiota-aligned version. +# +# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies]. +# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0 +# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these +# against the SDK's hard pins, so override them to the patched versions until the SDK +# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an +# override replaces the whole requirement; bare pyjwt would drop it from the consumers +# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive. +override-dependencies = [ + "okta==3.4.2", + "azure-mgmt-containerservice==34.1.0", + "microsoft-kiota-abstractions==1.9.9", + "dulwich==1.2.5", + "pyjwt[crypto]==2.13.0" +] diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index e09dc972b4..cbd6795731 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -1,9 +1,15 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter -from django.db import transaction - from api.db_router import MainRouter from api.db_utils import rls_transaction -from api.models import Membership, Role, Tenant, User, UserRoleRelationship +from api.models import ( + Membership, + Role, + SAMLConfiguration, + Tenant, + User, + UserRoleRelationship, +) +from django.db import transaction class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): @@ -18,7 +24,42 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): # Link existing accounts with the same email address email = sociallogin.account.extra_data.get("email") if sociallogin.provider.id == "saml": + # For SAML, the asserted NameID email cannot be trusted on its own: + # any tenant can claim any email domain in its SAML configuration. To + # prevent cross-tenant account takeover (GHSA-h8m9-jgf8-vwvp), only link + # the incoming SAML session to an existing account when (1) the email + # domain matches the tenant whose ACS endpoint is being used and (2) the + # existing user is already a member of that tenant. email = sociallogin.user.email + if not email: + return + + domain = email.rsplit("@", 1)[-1].lower() + resolver_match = getattr(request, "resolver_match", None) + organization_slug = ( + (resolver_match.kwargs or {}).get("organization_slug", "") + if resolver_match + else "" + ).lower() + # The ACS endpoint is scoped per email domain; reject mismatches so an + # attacker cannot replay an assertion through another tenant's endpoint. + if organization_slug != domain: + return + + try: + saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get( + email_domain=domain + ) + except SAMLConfiguration.DoesNotExist: + return + + existing_user = self.get_user_by_email(email) + if existing_user and existing_user.is_member_of_tenant( + str(saml_config.tenant_id) + ): + sociallogin.connect(request, existing_user) + return + if email: existing_user = self.get_user_by_email(email) if existing_user: diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index 543c10ab88..1aa0f8f54f 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -28,9 +28,10 @@ class ApiConfig(AppConfig): name = "api" def ready(self): - from api import schema_extensions # noqa: F401 - from api import signals # noqa: F401 - from api.attack_paths import database as graph_database + from api import ( + schema_extensions, # noqa: F401 + signals, # noqa: F401 + ) # Generate required cryptographic keys if not present, but only if: # `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app @@ -41,38 +42,6 @@ class ApiConfig(AppConfig): ): self._ensure_crypto_keys() - # Commands that don't need Neo4j - SKIP_NEO4J_DJANGO_COMMANDS = [ - "makemigrations", - "migrate", - "pgpartition", - "check", - "help", - "showmigrations", - "check_and_fix_socialaccount_sites_migration", - ] - - # Skip Neo4j initialization during tests, some Django commands, and Celery - if getattr(settings, "TESTING", False) or ( - len(sys.argv) > 1 - and ( - ( - "manage.py" in sys.argv[0] - and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS - ) - or "celery" in sys.argv[0] - ) - ): - logger.info( - "Skipping Neo4j initialization because tests, some Django commands or Celery" - ) - - else: - graph_database.init_driver() - - # Neo4j driver is initialized at API startup (see api.attack_paths.database) - # It remains lazy for Celery workers and selected Django commands - def _ensure_crypto_keys(self): """ Orchestrator method that ensures all required cryptographic keys are present. diff --git a/api/src/backend/api/attack_paths/__init__.py b/api/src/backend/api/attack_paths/__init__.py index b2917e1d86..fc41fb63c1 100644 --- a/api/src/backend/api/attack_paths/__init__.py +++ b/api/src/backend/api/attack_paths/__init__.py @@ -5,7 +5,6 @@ from api.attack_paths.queries import ( get_query_by_id, ) - __all__ = [ "AttackPathsQueryDefinition", "AttackPathsQueryParameterDefinition", diff --git a/api/src/backend/api/attack_paths/cypher_sanitizer.py b/api/src/backend/api/attack_paths/cypher_sanitizer.py index 3772b4cbef..7d7c93c680 100644 --- a/api/src/backend/api/attack_paths/cypher_sanitizer.py +++ b/api/src/backend/api/attack_paths/cypher_sanitizer.py @@ -4,10 +4,10 @@ Cypher sanitizer for custom (user-supplied) Attack Paths queries. Two responsibilities: 1. **Validation** - reject queries containing SSRF or dangerous procedure - patterns (defense-in-depth; the primary control is ``neo4j.READ_ACCESS``). + patterns (defense-in-depth; the primary control is `neo4j.READ_ACCESS`). 2. **Provider-scoped label injection** - inject a dynamic - ``_Provider_{uuid}`` label into every node pattern so the database can + `_Provider_{uuid}` label into every node pattern so the database can use its native label index for provider isolation. Label-injection pipeline: @@ -22,18 +22,16 @@ Label-injection pipeline: import re from rest_framework.exceptions import ValidationError - from tasks.jobs.attack_paths.config import get_provider_label - # Step 1 - String / comment protection -# Single combined regex: strings first, then line comments. +# Single combined regex: strings first, then line comments # The regex engine finds the leftmost match, so a string like 'https://prowler.com' -# is consumed as a string before the // inside it can match as a comment. +# is consumed as a string before the // inside it can match as a comment _PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*") # Step 2 - Clause splitting -# OPTIONAL MATCH must come before MATCH to avoid partial matching. +# `OPTIONAL MATCH` must come before `MATCH` to avoid partial matching _CLAUSE_RE = re.compile( r"\b(OPTIONAL\s+MATCH|MATCH|WHERE|RETURN|WITH|ORDER\s+BY" r"|SKIP|LIMIT|UNION|UNWIND|CALL)\b", @@ -41,10 +39,10 @@ _CLAUSE_RE = re.compile( ) # Pass A - Labeled node patterns (all segments) -# Matches node patterns that have at least one :Label. -# (? str: return work -# --------------------------------------------------------------------------- # Validation -# --------------------------------------------------------------------------- # Patterns that indicate SSRF or dangerous procedure calls # Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS` diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index f8e20b659e..6148b42bcc 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -1,232 +1,32 @@ -import atexit -import logging -import threading -from contextlib import contextmanager -from typing import Any, Iterator +"""Backwards-compatible facade over the ingest and sink modules. + +Historically this module owned a single Neo4j driver used for both the +cartography temp database and the per-tenant sink database. The port to AWS +Neptune split those roles: the cartography ingest (temp) database is always +Neo4j and lives in `api.attack_paths.ingest`; the sink is configurable +(Neo4j or Neptune) and lives in `api.attack_paths.sink`. This shim preserves +the public API that `tasks/` and `api/v1/views.py` already depend on, and +dispatches to the right module by database-name prefix. + +A database name starting with `db-tmp-scan-` is a cartography temp DB and +routes to ingest. Everything else routes to the configured sink. +""" + +from contextlib import AbstractContextManager +from typing import Any from uuid import UUID -import neo4j -import neo4j.exceptions +import neo4j # noqa: F401 - kept for tests that patch api.attack_paths.database.neo4j +from api.attack_paths import ingest +from api.attack_paths import sink as sink_module from config.env import env -from django.conf import settings -from tasks.jobs.attack_paths.config import ( - BATCH_SIZE, - PROVIDER_RESOURCE_LABEL, - get_provider_label, +from django.conf import ( + settings, # noqa: F401 - kept for tests that patch ...database.settings ) -from api.attack_paths.retryable_session import RetryableSession - -# Without this Celery goes crazy with Neo4j logging -logging.getLogger("neo4j").setLevel(logging.ERROR) -logging.getLogger("neo4j").propagate = False - -SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( - "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 -) -READ_QUERY_TIMEOUT_SECONDS = env.int( - "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 -) MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250) -READ_EXCEPTION_CODES = [ - "Neo.ClientError.Statement.AccessMode", - "Neo.ClientError.Procedure.ProcedureNotFound", -] -CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." -# Module-level process-wide driver singleton -_driver: neo4j.Driver | None = None -_lock = threading.Lock() - -# Base Neo4j functions - - -def get_uri() -> str: - host = settings.DATABASES["neo4j"]["HOST"] - port = settings.DATABASES["neo4j"]["PORT"] - return f"bolt://{host}:{port}" - - -def init_driver() -> neo4j.Driver: - global _driver - if _driver is not None: - return _driver - - with _lock: - if _driver is None: - uri = get_uri() - config = settings.DATABASES["neo4j"] - - _driver = neo4j.GraphDatabase.driver( - uri, - auth=(config["USER"], config["PASSWORD"]), - keep_alive=True, - max_connection_lifetime=7200, - connection_acquisition_timeout=120, - max_connection_pool_size=50, - ) - _driver.verify_connectivity() - - # Register cleanup handler (only runs once since we're inside the _driver is None block) - atexit.register(close_driver) - - return _driver - - -def get_driver() -> neo4j.Driver: - return init_driver() - - -def close_driver() -> None: # TODO: Use it - global _driver - with _lock: - if _driver is not None: - try: - _driver.close() - - finally: - _driver = None - - -@contextmanager -def get_session( - database: str | None = None, default_access_mode: str | None = None -) -> Iterator[RetryableSession]: - session_wrapper: RetryableSession | None = None - - try: - session_wrapper = RetryableSession( - session_factory=lambda: get_driver().session( - database=database, default_access_mode=default_access_mode - ), - max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, - ) - yield session_wrapper - - except neo4j.exceptions.Neo4jError as exc: - if ( - default_access_mode == neo4j.READ_ACCESS - and exc.code - and exc.code in READ_EXCEPTION_CODES - ): - message = "Read query not allowed" - code = READ_EXCEPTION_CODES[0] - raise WriteQueryNotAllowedException(message=message, code=code) - - message = exc.message if exc.message is not None else str(exc) - - if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): - raise ClientStatementException(message=message, code=exc.code) - - raise GraphDatabaseQueryException(message=message, code=exc.code) - - finally: - if session_wrapper is not None: - session_wrapper.close() - - -def execute_read_query( - database: str, - cypher: str, - parameters: dict[str, Any] | None = None, -) -> neo4j.graph.Graph: - with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session: - - def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph: - result = tx.run( - cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS - ) - return result.graph() - - return session.execute_read(_run) - - -def create_database(database: str) -> None: - query = "CREATE DATABASE $database IF NOT EXISTS" - parameters = {"database": database} - - with get_session() as session: - session.run(query, parameters) - - -def drop_database(database: str) -> None: - query = f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA" - - with get_session() as session: - session.run(query) - - -def drop_subgraph(database: str, provider_id: str) -> int: - """ - Delete all nodes for a provider from the tenant database. - - Uses batched deletion to avoid memory issues with large graphs. - Silently returns 0 if the database doesn't exist. - """ - provider_label = get_provider_label(provider_id) - deleted_nodes = 0 - - try: - with get_session(database) as session: - deleted_count = 1 - while deleted_count > 0: - result = session.run( - f""" - MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) - WITH n LIMIT $batch_size - DETACH DELETE n - RETURN COUNT(n) AS deleted_nodes_count - """, - {"batch_size": BATCH_SIZE}, - ) - deleted_count = result.single().get("deleted_nodes_count", 0) - deleted_nodes += deleted_count - - except GraphDatabaseQueryException as exc: - if exc.code == "Neo.ClientError.Database.DatabaseNotFound": - return 0 - raise - - return deleted_nodes - - -def has_provider_data(database: str, provider_id: str) -> bool: - """ - Check if any ProviderResource node exists for this provider. - - Returns `False` if the database doesn't exist. - """ - provider_label = get_provider_label(provider_id) - query = f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1" - - try: - with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session: - result = session.run(query) - return result.single() is not None - - except GraphDatabaseQueryException as exc: - if exc.code == "Neo.ClientError.Database.DatabaseNotFound": - return False - raise - - -def clear_cache(database: str) -> None: - query = "CALL db.clearQueryCaches()" - - try: - with get_session(database) as session: - session.run(query) - - except GraphDatabaseQueryException as exc: - logging.warning(f"Failed to clear query cache for database `{database}`: {exc}") - - -# Neo4j functions related to Prowler + Cartography - - -def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str: - prefix = "tmp-scan" if temporary else "tenant" - return f"db-{prefix}-{str(entity_id).lower()}" +TEMP_DB_PREFIX = "db-tmp-scan-" # Exceptions @@ -241,7 +41,6 @@ class GraphDatabaseQueryException(Exception): def __str__(self) -> str: if self.code: return f"{self.code}: {self.message}" - return self.message @@ -251,3 +50,152 @@ class WriteQueryNotAllowedException(GraphDatabaseQueryException): class ClientStatementException(GraphDatabaseQueryException): pass + + +# Routing + + +def _is_ingest_database(database: str | None) -> bool: + return bool(database) and database.startswith(TEMP_DB_PREFIX) + + +# Driver lifecycle + + +def init_driver() -> Any: + """Initialize the configured sink backend. + + The ingest driver (Neo4j for cartography temp DBs) stays lazy: it is + only initialized when a temp-DB operation actually runs, which never + happens on API pods. + """ + return sink_module.init() + + +def close_driver() -> None: + """Close every driver held by this process.""" + sink_module.close() + ingest.close_driver() + + +def get_driver() -> neo4j.Driver: + """Return the sink backend's underlying driver. + + Only meaningful for the Neo4j sink (where the backend has a single Neo4j + driver). On Neptune this returns the writer driver. Kept for tests and + legacy call-sites; prefer `get_session` for new code. + """ + backend = sink_module.get_backend() + + # Neo4jSink exposes get_driver(); NeptuneSink exposes get_writer() + if hasattr(backend, "get_driver"): + return backend.get_driver() + + if hasattr(backend, "get_writer"): + return backend.get_writer() + + raise RuntimeError("Active sink backend does not expose a driver handle") + + +def verify_connectivity() -> None: + """Raise if the configured graph database is unreachable on the API read path. + + Backend-agnostic entry point for the readiness probe: Neo4j verifies its + driver, Neptune verifies the reader endpoint. + """ + sink_module.get_backend().verify_connectivity() + + +def get_uri() -> str: + """Return the sink URI. Retained for backwards compatibility.""" + if settings.ATTACK_PATHS_SINK_DATABASE == "neptune": + cfg = settings.DATABASES["neptune"] + return f"bolt+s://{cfg['WRITER_ENDPOINT']}:{cfg['PORT']}" + + cfg = settings.DATABASES["neo4j"] + return f"bolt://{cfg['HOST']}:{cfg['PORT']}" + + +def get_ingest_uri() -> str: + """Neo4j URI for the cartography temp (ingest) database, which is always + Neo4j regardless of the configured sink.""" + return ingest.get_uri() + + +# Session API + + +def get_session( + database: str | None = None, + default_access_mode: str | None = None, +) -> AbstractContextManager: + """Return a session against the right backend. + + - `database` names starting with `db-tmp-scan-` always go to ingest. + - No database name → ingest (used for CREATE / DROP DATABASE admin ops). + - Any other name → sink. + """ + if _is_ingest_database(database) or database is None: + return ingest.get_session( + database=database, default_access_mode=default_access_mode + ) + + return sink_module.get_backend().get_session( + database=database, default_access_mode=default_access_mode + ) + + +def execute_read_query( + database: str, + cypher: str, + parameters: dict[str, Any] | None = None, +) -> neo4j.graph.Graph: + """Read-only query against the sink.""" + return sink_module.get_backend().execute_read_query(database, cypher, parameters) + + +def create_database(database: str) -> None: + """Create a database. Temp DBs always land on ingest (Neo4j). + + On the Neo4j sink, tenant DBs also route to ingest because both drivers + connect to the same Neo4j cluster. On the Neptune sink, tenant DB creates + are no-ops. + """ + if _is_ingest_database(database): + ingest.create_database(database) + return + + sink_module.get_backend().create_database(database) + + +def drop_database(database: str) -> None: + """Drop a database. Mirrors `create_database` routing.""" + if _is_ingest_database(database): + ingest.drop_database(database) + return + + sink_module.get_backend().drop_database(database) + + +def drop_subgraph(database: str, provider_id: str) -> int: + return sink_module.get_backend().drop_subgraph(database, provider_id) + + +def has_provider_data(database: str, provider_id: str) -> bool: + return sink_module.get_backend().has_provider_data(database, provider_id) + + +def clear_cache(database: str) -> None: + if _is_ingest_database(database): + ingest.clear_cache(database) + return + + sink_module.get_backend().clear_cache(database) + + +# Name helper + + +def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str: + prefix = "tmp-scan" if temporary else "tenant" + return f"db-{prefix}-{str(entity_id).lower()}" diff --git a/api/src/backend/api/attack_paths/ingest/__init__.py b/api/src/backend/api/attack_paths/ingest/__init__.py new file mode 100644 index 0000000000..5833b8b373 --- /dev/null +++ b/api/src/backend/api/attack_paths/ingest/__init__.py @@ -0,0 +1,29 @@ +"""Cartography ingest layer. + +Public surface for the per-scan Neo4j temp database driver. Implementation +lives in `api.attack_paths.ingest.driver`. +""" + +from api.attack_paths.ingest.driver import ( + clear_cache, + close_driver, + create_database, + drop_database, + get_driver, + get_session, + get_uri, + init_driver, + run_cypher, +) + +__all__ = [ + "clear_cache", + "close_driver", + "create_database", + "drop_database", + "get_driver", + "get_session", + "get_uri", + "init_driver", + "run_cypher", +] diff --git a/api/src/backend/api/attack_paths/ingest/driver.py b/api/src/backend/api/attack_paths/ingest/driver.py new file mode 100644 index 0000000000..1b05c721e7 --- /dev/null +++ b/api/src/backend/api/attack_paths/ingest/driver.py @@ -0,0 +1,187 @@ +"""Cartography ingest driver: per-scan throw-away Neo4j database. + +Cartography writes each scan's graph into a throw-away Neo4j database named +`db-tmp-scan-{scan_uuid}`. This is always Neo4j, regardless of the configured +sink: Neptune is single-database and cannot host per-scan throw-away +databases. This module owns the Neo4j driver used for those temp DBs and the +admin ops they need (CREATE / DROP DATABASE). +""" + +import atexit +import logging +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +import neo4j +import neo4j.exceptions +from api.attack_paths.retryable_session import RetryableSession +from config.env import env +from django.conf import settings + +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( + "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 +) +CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) +# TCP connect timeout, ordered below the acquisition timeout so an unreachable +# host can't pin a worker on a temp-DB op longer than this. +CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5) +MAX_CONNECTION_LIFETIME = env.int("NEO4J_MAX_CONNECTION_LIFETIME", default=7200) +MAX_CONNECTION_POOL_SIZE = env.int("NEO4J_MAX_CONNECTION_POOL_SIZE", default=50) + +_driver: neo4j.Driver | None = None +_lock = threading.Lock() + + +def _neo4j_config() -> dict: + return settings.DATABASES["neo4j"] + + +def get_uri() -> str: + """Bolt URI for the Neo4j temp (ingest) database. Always Neo4j.""" + config = _neo4j_config() + host = config["HOST"] + port = config["PORT"] + if not host or not port: + raise RuntimeError( + "NEO4J_HOST / NEO4J_PORT must be set to use the attack-paths " + "temp database. Workers require Neo4j env even when the sink is Neptune." + ) + + return f"bolt://{host}:{port}" + + +def init_driver() -> neo4j.Driver: + """Initialize the temp-database Neo4j driver. Idempotent.""" + global _driver + if _driver is not None: + return _driver + + with _lock: + if _driver is None: + config = _neo4j_config() + _driver = neo4j.GraphDatabase.driver( + get_uri(), + auth=(config["USER"], config["PASSWORD"]), + keep_alive=True, + max_connection_lifetime=MAX_CONNECTION_LIFETIME, + connection_timeout=CONNECTION_TIMEOUT, + connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, + max_connection_pool_size=MAX_CONNECTION_POOL_SIZE, + ) + # Best-effort connectivity check: a Neo4j that is down at boot must + # not crash the worker. The driver reconnects lazily on first use. + try: + _driver.verify_connectivity() + + except Exception: + logging.warning( + "Neo4j temp-database unreachable at init; continuing with a " + "lazily-reconnecting driver", + exc_info=True, + ) + + atexit.register(close_driver) + + return _driver + + +def get_driver() -> neo4j.Driver: + return init_driver() + + +def close_driver() -> None: + global _driver + with _lock: + if _driver is not None: + try: + _driver.close() + finally: + _driver = None + + +@contextmanager +def get_session( + database: str | None = None, + default_access_mode: str | None = None, +) -> Iterator[RetryableSession]: + """Session against the Neo4j temp-database cluster. Used for temp DB sessions + and for admin operations (CREATE / DROP DATABASE) when `database` is None.""" + from api.attack_paths.database import ( + ClientStatementException, + GraphDatabaseQueryException, + WriteQueryNotAllowedException, + ) + + READ_EXCEPTION_CODES = [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", + ] + CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." + + session_wrapper: RetryableSession | None = None + try: + session_wrapper = RetryableSession( + session_factory=lambda: get_driver().session( + database=database, default_access_mode=default_access_mode + ), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + if ( + default_access_mode == neo4j.READ_ACCESS + and exc.code + and exc.code in READ_EXCEPTION_CODES + ): + raise WriteQueryNotAllowedException( + message="Read query not allowed", code=READ_EXCEPTION_CODES[0] + ) + + message = exc.message if exc.message is not None else str(exc) + if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): + raise ClientStatementException(message=message, code=exc.code) + raise GraphDatabaseQueryException(message=message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + +def create_database(database: str) -> None: + """Create a database on the Neo4j cluster. Used for temp scan DBs.""" + with get_session() as session: + session.run("CREATE DATABASE $database IF NOT EXISTS", {"database": database}) + + +def drop_database(database: str) -> None: + """Drop a database on the Neo4j cluster. Used for temp scan DBs.""" + with get_session() as session: + session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA") + + +def clear_cache(database: str) -> None: + """Best-effort cache clear for a Neo4j database.""" + from api.attack_paths.database import GraphDatabaseQueryException + + try: + with get_session(database) as session: + session.run("CALL db.clearQueryCaches()") + + except GraphDatabaseQueryException as exc: + logging.warning(f"Failed to clear query cache for database `{database}`: {exc}") + + +def run_cypher( + database: str | None, + cypher: str, + parameters: dict[str, Any] | None = None, +) -> Any: + """Execute Cypher directly without the context manager. Thin helper.""" + with get_session(database) as session: + return session.run(cypher, parameters or {}) diff --git a/api/src/backend/api/attack_paths/queries/__init__.py b/api/src/backend/api/attack_paths/queries/__init__.py index c5e6ab0393..aa90ba6878 100644 --- a/api/src/backend/api/attack_paths/queries/__init__.py +++ b/api/src/backend/api/attack_paths/queries/__init__.py @@ -1,12 +1,11 @@ -from api.attack_paths.queries.types import ( - AttackPathsQueryDefinition, - AttackPathsQueryParameterDefinition, -) from api.attack_paths.queries.registry import ( get_queries_for_provider, get_query_by_id, ) - +from api.attack_paths.queries.types import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) __all__ = [ "AttackPathsQueryDefinition", diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index f50935c49f..fa42854156 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -5,9 +5,7 @@ from api.attack_paths.queries.types import ( ) from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL - # Custom Attack Path Queries -# -------------------------- AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( id="aws-internet-exposed-ec2-sensitive-s3-access", @@ -23,14 +21,18 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( WHERE ec2.exposed_internet = true AND ipi.toport = 22 - MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name) - AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*') + MATCH path_role = (r:AWSRole)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value CONTAINS s3.name + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) STARTS WITH 's3:listbucket' + OR toLower(act.value) STARTS WITH 's3:getobject' MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + WITH DISTINCT path_s3, path_ec2, path_role, path_assume_role, internet, can_access WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access UNWIND paths AS p @@ -38,7 +40,7 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -60,7 +62,6 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( # Basic Resource Queries -# ---------------------- AWS_RDS_INSTANCES = AttackPathsQueryDefinition( id="aws-rds-instances", @@ -77,7 +78,7 @@ AWS_RDS_INSTANCES = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -100,7 +101,7 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -123,7 +124,7 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -137,17 +138,18 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( description="Find IAM policy statements that allow all actions via '*' within the selected account.", provider="aws", cypher=f""" - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(x IN stmt.action WHERE x = '*') + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE act.value = '*' + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]->(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -161,17 +163,18 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.", provider="aws", cypher=f""" - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE act.value = 'iam:DeletePolicy' + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -185,17 +188,18 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( description="Find IAM policy statements that allow actions containing 'create' within the selected account.", provider="aws", cypher=f""" - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = "Allow" - AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) CONTAINS 'create' + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -204,7 +208,6 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( # Network Exposure Queries -# ------------------------ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( id="aws-ec2-instances-internet-exposed", @@ -224,7 +227,7 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -250,7 +253,7 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -275,7 +278,7 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -300,7 +303,7 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -328,7 +331,7 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, @@ -344,7 +347,6 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( # Privilege Escalation Queries (based on pathfinding.cloud research) # https://github.com/DataDog/pathfinding.cloud -# ------------------------------------------------------------------- # APPRUNNER-001 AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( @@ -359,31 +361,27 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find apprunner:CreateService permission - MATCH (principal)--(apprunner_policy:AWSPolicy)--(stmt_apprunner:AWSPolicyStatement) - WHERE stmt_apprunner.effect = 'Allow' - AND any(action IN stmt_apprunner.action WHERE - toLower(action) = 'apprunner:createservice' - OR toLower(action) = 'apprunner:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(apprunner_policy:AWSPolicy)-[:STATEMENT]->(stmt_apprunner:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_apprunner)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['apprunner:*', 'apprunner:createservice'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust App Runner tasks service (can be passed to App Runner) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -391,7 +389,7 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -411,25 +409,23 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with apprunner:UpdateService permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) - WHERE stmt_update.effect = 'Allow' - AND any(action IN stmt_update.action WHERE - toLower(action) = 'apprunner:updateservice' - OR toLower(action) = 'apprunner:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(update_policy:AWSPolicy)-[:STATEMENT]->(stmt_update:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_update)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['apprunner:*', 'apprunner:updateservice'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find existing App Runner services with roles attached (potential targets) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -449,49 +445,41 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find bedrock-agentcore:CreateCodeInterpreter permission - MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement) - WHERE stmt_bedrock.effect = 'Allow' - AND any(action IN stmt_bedrock.action WHERE - toLower(action) = 'bedrock-agentcore:createcodeinterpreter' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(bedrock_policy:AWSPolicy)-[:STATEMENT]->(stmt_bedrock:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_bedrock)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:createcodeinterpreter'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find bedrock-agentcore:StartCodeInterpreterSession permission - MATCH (principal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) - WHERE stmt_session.effect = 'Allow' - AND any(action IN stmt_session.action WHERE - toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(session_policy:AWSPolicy)-[:STATEMENT]->(stmt_session:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_session)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:startcodeinterpretersession'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find bedrock-agentcore:InvokeCodeInterpreter permission - MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) - WHERE stmt_invoke.effect = 'Allow' - AND any(action IN stmt_invoke.action WHERE - toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_invoke)-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:invokecodeinterpreter'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust Bedrock service (can be passed to Bedrock) - MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + // Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -499,7 +487,7 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -519,34 +507,30 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with bedrock-agentcore:StartCodeInterpreterSession permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) - WHERE stmt_session.effect = 'Allow' - AND any(action IN stmt_session.action WHERE - toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(session_policy:AWSPolicy)-[:STATEMENT]->(stmt_session:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_session)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:startcodeinterpretersession'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find bedrock-agentcore:InvokeCodeInterpreter permission - MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) - WHERE stmt_invoke.effect = 'Allow' - AND any(action IN stmt_invoke.action WHERE - toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' - OR toLower(action) = 'bedrock-agentcore:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_invoke)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:invokecodeinterpreter'] + OR act2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths - // Find roles that trust Bedrock service (already attached to existing code interpreters) - MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}}) + // Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -566,31 +550,27 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:CreateStack permission - MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) - WHERE stmt_cfn.effect = 'Allow' - AND any(action IN stmt_cfn.action WHERE - toLower(action) = 'cloudformation:createstack' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:createstack'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CloudFormation service (can be passed to CloudFormation) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -598,7 +578,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -618,25 +598,23 @@ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with cloudformation:UpdateStack permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) - WHERE stmt_update.effect = 'Allow' - AND any(action IN stmt_update.action WHERE - toLower(action) = 'cloudformation:updatestack' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(update_policy:AWSPolicy)-[:STATEMENT]->(stmt_update:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_update)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['cloudformation:*', 'cloudformation:updatestack'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CloudFormation service (already attached to existing stacks) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -656,40 +634,34 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:CreateStackSet permission - MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) - WHERE stmt_cfn.effect = 'Allow' - AND any(action IN stmt_cfn.action WHERE - toLower(action) = 'cloudformation:createstackset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:createstackset'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:CreateStackInstances permission - MATCH (principal)--(cfn_instances_policy:AWSPolicy)--(stmt_cfn_instances:AWSPolicyStatement) - WHERE stmt_cfn_instances.effect = 'Allow' - AND any(action IN stmt_cfn_instances.action WHERE - toLower(action) = 'cloudformation:createstackinstances' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_instances_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn_instances:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn_instances)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['cloudformation:*', 'cloudformation:createstackinstances'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CloudFormation service (can be passed as execution role) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -697,7 +669,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -717,31 +689,27 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find cloudformation:UpdateStackSet permission - MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) - WHERE stmt_cfn.effect = 'Allow' - AND any(action IN stmt_cfn.action WHERE - toLower(action) = 'cloudformation:updatestackset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:updatestackset'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CloudFormation service (can be passed as execution role) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -749,7 +717,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -769,34 +737,30 @@ AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with cloudformation:CreateChangeSet permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'cloudformation:createchangeset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['cloudformation:*', 'cloudformation:createchangeset'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find cloudformation:ExecuteChangeSet permission - MATCH (principal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) - WHERE stmt_exec.effect = 'Allow' - AND any(action IN stmt_exec.action WHERE - toLower(action) = 'cloudformation:executechangeset' - OR toLower(action) = 'cloudformation:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(exec_policy:AWSPolicy)-[:STATEMENT]->(stmt_exec:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_exec)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:executechangeset'] + OR act2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CloudFormation service (already attached to existing stacks) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -816,40 +780,34 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:CreateProject permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'codebuild:createproject' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['codebuild:*', 'codebuild:createproject'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:StartBuild permission - MATCH (principal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) - WHERE stmt_build.effect = 'Allow' - AND any(action IN stmt_build.action WHERE - toLower(action) = 'codebuild:startbuild' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_build)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['codebuild:*', 'codebuild:startbuild'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CodeBuild service (can be passed to CodeBuild) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -857,7 +815,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -877,25 +835,23 @@ AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with codebuild:StartBuild permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) - WHERE stmt_build.effect = 'Allow' - AND any(action IN stmt_build.action WHERE - toLower(action) = 'codebuild:startbuild' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_build)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['codebuild:*', 'codebuild:startbuild'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CodeBuild service (already attached to existing projects) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -915,25 +871,23 @@ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with codebuild:StartBuildBatch permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) - WHERE stmt_build.effect = 'Allow' - AND any(action IN stmt_build.action WHERE - toLower(action) = 'codebuild:startbuildbatch' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_build)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['codebuild:*', 'codebuild:startbuildbatch'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust CodeBuild service (already attached to existing projects) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -953,40 +907,34 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:CreateProject permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'codebuild:createproject' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['codebuild:*', 'codebuild:createproject'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find codebuild:StartBuildBatch permission - MATCH (principal)--(batch_policy:AWSPolicy)--(stmt_batch:AWSPolicyStatement) - WHERE stmt_batch.effect = 'Allow' - AND any(action IN stmt_batch.action WHERE - toLower(action) = 'codebuild:startbuildbatch' - OR toLower(action) = 'codebuild:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(batch_policy:AWSPolicy)-[:STATEMENT]->(stmt_batch:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_batch)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['codebuild:*', 'codebuild:startbuildbatch'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust CodeBuild service (can be passed to CodeBuild) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -994,7 +942,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1014,50 +962,42 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find datapipeline:CreatePipeline permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'datapipeline:createpipeline' - OR toLower(action) = 'datapipeline:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['datapipeline:*', 'datapipeline:createpipeline'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find datapipeline:PutPipelineDefinition permission - MATCH (principal)--(put_policy:AWSPolicy)--(stmt_put:AWSPolicyStatement) - WHERE stmt_put.effect = 'Allow' - AND any(action IN stmt_put.action WHERE - toLower(action) = 'datapipeline:putpipelinedefinition' - OR toLower(action) = 'datapipeline:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(put_policy:AWSPolicy)-[:STATEMENT]->(stmt_put:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_put)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['datapipeline:*', 'datapipeline:putpipelinedefinition'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find datapipeline:ActivatePipeline permission - MATCH (principal)--(activate_policy:AWSPolicy)--(stmt_activate:AWSPolicyStatement) - WHERE stmt_activate.effect = 'Allow' - AND any(action IN stmt_activate.action WHERE - toLower(action) = 'datapipeline:activatepipeline' - OR toLower(action) = 'datapipeline:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(activate_policy:AWSPolicy)-[:STATEMENT]->(stmt_activate:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_activate)-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['datapipeline:*', 'datapipeline:activatepipeline'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Data Pipeline or EMR service (can be passed to DataPipeline) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted_principal:AWSPrincipal) WHERE trusted_principal.arn IN ['datapipeline.amazonaws.com', 'elasticmapreduce.amazonaws.com'] - AND any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1065,7 +1005,7 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1085,31 +1025,27 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find ec2:RunInstances permission - MATCH (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) - WHERE stmt_ec2.effect = 'Allow' - AND any(action IN stmt_ec2.action WHERE - toLower(action) = 'ec2:runinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(ec2_policy:AWSPolicy)-[:STATEMENT]->(stmt_ec2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_ec2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:runinstances'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust EC2 service (can be passed to EC2) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1117,7 +1053,7 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1137,43 +1073,37 @@ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ec2:ModifyInstanceAttribute permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) - WHERE stmt_modify.effect = 'Allow' - AND any(action IN stmt_modify.action WHERE - toLower(action) = 'ec2:modifyinstanceattribute' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(modify_policy:AWSPolicy)-[:STATEMENT]->(stmt_modify:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_modify)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ec2:*', 'ec2:modifyinstanceattribute'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find ec2:StopInstances permission (can be same or different policy) - MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement) - WHERE stmt_stop.effect = 'Allow' - AND any(action IN stmt_stop.action WHERE - toLower(action) = 'ec2:stopinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(stop_policy:AWSPolicy)-[:STATEMENT]->(stmt_stop:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_stop)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:stopinstances'] + OR act2.value = '*' + WITH DISTINCT aws, principal, path_principal // Find ec2:StartInstances permission (can be same or different policy) - MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement) - WHERE stmt_start.effect = 'Allow' - AND any(action IN stmt_start.action WHERE - toLower(action) = 'ec2:startinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(start_policy:AWSPolicy)-[:STATEMENT]->(stmt_start:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_start)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['ec2:*', 'ec2:startinstances'] + OR act3.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with instance profiles (potential targets) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1193,31 +1123,27 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find ec2:RequestSpotInstances permission - MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement) - WHERE stmt_spot.effect = 'Allow' - AND any(action IN stmt_spot.action WHERE - toLower(action) = 'ec2:requestspotinstances' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(spot_policy:AWSPolicy)-[:STATEMENT]->(stmt_spot:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_spot)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:requestspotinstances'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust EC2 service (can be passed to EC2 spot instances) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1225,7 +1151,7 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1245,34 +1171,30 @@ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ec2:CreateLaunchTemplateVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'ec2:createlaunchtemplateversion' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ec2:*', 'ec2:createlaunchtemplateversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal // Find ec2:ModifyLaunchTemplate permission - MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) - WHERE stmt_modify.effect = 'Allow' - AND any(action IN stmt_modify.action WHERE - toLower(action) = 'ec2:modifylaunchtemplate' - OR toLower(action) = 'ec2:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(modify_policy:AWSPolicy)-[:STATEMENT]->(stmt_modify:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_modify)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ec2:*', 'ec2:modifylaunchtemplate'] + OR act2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find launch templates in the account (potential targets) MATCH path_target = (aws)--(template:LaunchTemplate) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1292,25 +1214,23 @@ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ec2-instance-connect:SendSSHPublicKey permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(connect_policy:AWSPolicy)--(stmt_connect:AWSPolicyStatement) - WHERE stmt_connect.effect = 'Allow' - AND any(action IN stmt_connect.action WHERE - toLower(action) = 'ec2-instance-connect:sendsshpublickey' - OR toLower(action) = 'ec2-instance-connect:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(connect_policy:AWSPolicy)-[:STATEMENT]->(stmt_connect:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_connect)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ec2-instance-connect:*', 'ec2-instance-connect:sendsshpublickey'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1329,58 +1249,46 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( link="https://pathfinding.cloud/paths/ecs-001", ), cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:CreateCluster permission - MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) - WHERE stmt_cluster.effect = 'Allow' - AND any(action IN stmt_cluster.action WHERE - toLower(action) = 'ecs:createcluster' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateCluster (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:createcluster'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:CreateService permission - MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) - WHERE stmt_service.effect = 'Allow' - AND any(action IN stmt_service.action WHERE - toLower(action) = 'ecs:createservice' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateService (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a4:AWSPolicyStatementActionItem) + WHERE toLower(a4.value) IN ['ecs:*', 'ecs:createservice'] + OR a4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1399,58 +1307,48 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' - // Find ecs:CreateCluster permission - MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) - WHERE stmt_cluster.effect = 'Allow' - AND any(action IN stmt_cluster.action WHERE - toLower(action) = 'ecs:createcluster' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Collapse: one row per (passrole chain), independent of how many action items matched + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateCluster exists on the principal -> collapse back to one row + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:createcluster'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RunTask permission - MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) - WHERE stmt_runtask.effect = 'Allow' - AND any(action IN stmt_runtask.action WHERE - toLower(action) = 'ecs:runtask' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition exists on the principal + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s3:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Gate: ecs:RunTask exists on the principal + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s4:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a4:AWSPolicyStatementActionItem) + WHERE toLower(a4.value) IN ['ecs:*', 'ecs:runtask'] + OR a4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Target: a role that trusts ECS tasks and that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1469,49 +1367,40 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefin ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:CreateService permission - MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) - WHERE stmt_service.effect = 'Allow' - AND any(action IN stmt_service.action WHERE - toLower(action) = 'ecs:createservice' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:CreateService (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:createservice'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1530,49 +1419,40 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RunTask permission - MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) - WHERE stmt_runtask.effect = 'Allow' - AND any(action IN stmt_runtask.action WHERE - toLower(action) = 'ecs:runtask' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RunTask (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:runtask'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1591,49 +1471,40 @@ AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinitio ), provider="aws", cypher=f""" - // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PassRole permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:RegisterTaskDefinition permission - MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) - WHERE stmt_taskdef.effect = 'Allow' - AND any(action IN stmt_taskdef.action WHERE - toLower(action) = 'ecs:registertaskdefinition' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:RegisterTaskDefinition (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition'] + OR a2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find ecs:StartTask permission - MATCH (principal)--(starttask_policy:AWSPolicy)--(stmt_starttask:AWSPolicyStatement) - WHERE stmt_starttask.effect = 'Allow' - AND any(action IN stmt_starttask.action WHERE - toLower(action) = 'ecs:starttask' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:StartTask (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem) + WHERE toLower(a3.value) IN ['ecs:*', 'ecs:starttask'] + OR a3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal - // Find roles that trust ECS tasks service (can be passed to ECS tasks) + // Target: a role trusting ECS tasks that the passrole resource can target MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1652,35 +1523,29 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with ecs:ExecuteCommand permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) - WHERE stmt_exec.effect = 'Allow' - AND any(action IN stmt_exec.action WHERE - toLower(action) = 'ecs:executecommand' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Find principals with ecs:ExecuteCommand permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(exec_policy:AWSPolicy)-[:STATEMENT]->(stmt_exec:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_exec)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ecs:*', 'ecs:executecommand'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal - // Find ecs:DescribeTasks permission (required by AWS CLI to get container runtime ID) - MATCH (principal)--(describe_policy:AWSPolicy)--(stmt_describe:AWSPolicyStatement) - WHERE stmt_describe.effect = 'Allow' - AND any(action IN stmt_describe.action WHERE - toLower(action) = 'ecs:describetasks' - OR toLower(action) = 'ecs:*' - OR action = '*' - ) + // Gate: ecs:DescribeTasks (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem) + WHERE toLower(a2.value) IN ['ecs:*', 'ecs:describetasks'] + OR a2.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths - // Find roles that trust ECS tasks service (already attached to running tasks) + // Target: roles already attached to running tasks (trust ECS tasks service) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1700,31 +1565,27 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateDevEndpoint permission - MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement) - WHERE stmt_glue.effect = 'Allow' - AND any(action IN stmt_glue.action WHERE - toLower(action) = 'glue:createdevendpoint' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(glue_policy:AWSPolicy)-[:STATEMENT]->(stmt_glue:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_glue)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:createdevendpoint'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1732,7 +1593,7 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1752,25 +1613,23 @@ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with glue:UpdateDevEndpoint permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'glue:updatedevendpoint' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['glue:*', 'glue:updatedevendpoint'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find roles that trust Glue service (already attached to existing dev endpoints) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1790,40 +1649,34 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateJob permission - MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) - WHERE stmt_createjob.effect = 'Allow' - AND any(action IN stmt_createjob.action WHERE - toLower(action) = 'glue:createjob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(createjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_createjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_createjob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:createjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:StartJobRun permission - MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) - WHERE stmt_startjob.effect = 'Allow' - AND any(action IN stmt_startjob.action WHERE - toLower(action) = 'glue:startjobrun' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(startjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_startjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_startjob)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:startjobrun'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1831,7 +1684,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1851,40 +1704,34 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateJob permission - MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) - WHERE stmt_createjob.effect = 'Allow' - AND any(action IN stmt_createjob.action WHERE - toLower(action) = 'glue:createjob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(createjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_createjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_createjob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:createjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateTrigger permission - MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) - WHERE stmt_trigger.effect = 'Allow' - AND any(action IN stmt_trigger.action WHERE - toLower(action) = 'glue:createtrigger' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(trigger_policy:AWSPolicy)-[:STATEMENT]->(stmt_trigger:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_trigger)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:createtrigger'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1892,7 +1739,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1912,40 +1759,34 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:UpdateJob permission - MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) - WHERE stmt_updatejob.effect = 'Allow' - AND any(action IN stmt_updatejob.action WHERE - toLower(action) = 'glue:updatejob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(updatejob_policy:AWSPolicy)-[:STATEMENT]->(stmt_updatejob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_updatejob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:updatejob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:StartJobRun permission - MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) - WHERE stmt_startjob.effect = 'Allow' - AND any(action IN stmt_startjob.action WHERE - toLower(action) = 'glue:startjobrun' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(startjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_startjob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_startjob)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:startjobrun'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -1953,7 +1794,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -1973,40 +1814,34 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:UpdateJob permission - MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) - WHERE stmt_updatejob.effect = 'Allow' - AND any(action IN stmt_updatejob.action WHERE - toLower(action) = 'glue:updatejob' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(updatejob_policy:AWSPolicy)-[:STATEMENT]->(stmt_updatejob:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_updatejob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:updatejob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find glue:CreateTrigger permission - MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) - WHERE stmt_trigger.effect = 'Allow' - AND any(action IN stmt_trigger.action WHERE - toLower(action) = 'glue:createtrigger' - OR toLower(action) = 'glue:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(trigger_policy:AWSPolicy)-[:STATEMENT]->(stmt_trigger:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_trigger)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:createtrigger'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Glue service (can be passed to Glue jobs) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2014,7 +1849,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2034,22 +1869,20 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreatePolicyVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createpolicyversion' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find customer-managed policies attached to the same principal that can be overwritten MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2057,7 +1890,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2077,22 +1910,20 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreateAccessKey permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createaccesskey'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target users that the principal can create access keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2100,7 +1931,7 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2119,45 +1950,39 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:CreateAccessKey permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:CreateAccessKey permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createaccesskey'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:DeleteAccessKey permission - MATCH (principal)--(delete_policy:AWSPolicy)--(stmt_delete:AWSPolicyStatement) - WHERE stmt_delete.effect = 'Allow' - AND any(action IN stmt_delete.action WHERE - toLower(action) = 'iam:deleteaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:DeleteAccessKey permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:deleteaccesskey'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target users that the principal can rotate access keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) - AND any(resource IN stmt_delete.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_user.name + OR target_user.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2177,22 +2002,20 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreateLoginProfile permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createloginprofile' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createloginprofile'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target users that the principal can create login profiles for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2200,7 +2023,7 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2220,19 +2043,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find roles with iam:PutRolePolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR role.arn CONTAINS resource - OR resource CONTAINS role.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS role.name + OR role.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2240,7 +2060,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2260,22 +2080,20 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:UpdateLoginProfile permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:updateloginprofile' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:updateloginprofile'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target users that the principal can update login profiles for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2283,7 +2101,7 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2303,19 +2121,16 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:PutUserPolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR user.arn CONTAINS resource - OR resource CONTAINS user.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putuserpolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS user.name + OR user.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2323,7 +2138,7 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2343,19 +2158,16 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:AttachUserPolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR user.arn CONTAINS resource - OR resource CONTAINS user.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachuserpolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS user.name + OR user.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2363,7 +2175,7 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2383,19 +2195,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find roles with iam:AttachRolePolicy permission scoped to themselves - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) - AND any(resource IN stmt.resource WHERE - resource = '*' - OR role.arn CONTAINS resource - OR resource CONTAINS role.name - ) + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS role.name + OR role.arn CONTAINS res.value + WITH DISTINCT path WITH collect(path) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2403,7 +2212,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2423,22 +2232,20 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:AttachGroupPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachgrouppolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachgrouppolicy'] + OR act.value = '*' + WITH DISTINCT aws, user, stmt, path_principal // Find groups the user is a member of and can attach policies to - MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_group.arn CONTAINS resource - OR resource CONTAINS target_group.name - ) + MATCH path_target = (aws)--(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_group.name + OR target_group.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2446,7 +2253,7 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2466,22 +2273,20 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:PutGroupPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putgrouppolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putgrouppolicy'] + OR act.value = '*' + WITH DISTINCT aws, user, stmt, path_principal // Find groups the user is a member of and can put policies on - MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_group.arn CONTAINS resource - OR resource CONTAINS target_group.name - ) + MATCH path_target = (aws)--(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_group.name + OR target_group.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2489,7 +2294,7 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2508,31 +2313,30 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:UpdateAssumeRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:UpdateAssumeRolePolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act.value = '*' - // Find target roles whose trust policy can be modified + // Collapse the action-item fan-out: one row per (statement chain), not per matching action + WITH DISTINCT aws, stmt, path_principal + + // Find target roles whose trust policy this statement's resource can target MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2552,22 +2356,20 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:AddUserToGroup permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:addusertogroup' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:addusertogroup'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target groups the principal can add users to - MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_group.arn CONTAINS resource - OR resource CONTAINS target_group.name - ) + MATCH path_target = (aws)--(target_group:AWSGroup) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_group.name + OR target_group.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2575,7 +2377,7 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2595,22 +2397,20 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:AttachRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume and attach policies to MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2618,7 +2418,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2637,45 +2437,39 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio ), provider="aws", cypher=f""" - // Find principals with iam:AttachUserPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:AttachUserPolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachuserpolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:CreateAccessKey permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:CreateAccessKey permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:createaccesskey'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target users the principal can attach policies to and create keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_user.name + OR target_user.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2695,23 +2489,21 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:CreatePolicyVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createpolicyversion' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume that have customer-managed policies the principal can modify MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) MATCH (target_role)--(target_policy:AWSPolicy) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2719,7 +2511,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2739,22 +2531,20 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PutRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume and put inline policies on MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -2762,7 +2552,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2781,45 +2571,39 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PutUserPolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putuserpolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PutUserPolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putuserpolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:CreateAccessKey permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:createaccesskey' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:CreateAccessKey permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:createaccesskey'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target users the principal can put policies on and create keys for MATCH path_target = (aws)--(target_user:AWSUser) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_user.arn CONTAINS resource - OR resource CONTAINS target_user.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_user.name + OR target_user.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_user.name + OR target_user.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2838,45 +2622,39 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti ), provider="aws", cypher=f""" - // Find principals with iam:AttachRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:attachrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:AttachRolePolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:UpdateAssumeRolePolicy permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target roles the principal can attach policies to and update trust policy for MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_role.name + OR target_role.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2895,46 +2673,40 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin ), provider="aws", cypher=f""" - // Find principals with iam:CreatePolicyVersion permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:createpolicyversion' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:CreatePolicyVersion permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:UpdateAssumeRolePolicy permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target roles with customer-managed policies the principal can modify and update trust policy for MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt2.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) - MATCH (target_role)--(target_policy:AWSPolicy) + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_role.name + OR target_role.arn CONTAINS res2.value + MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -2953,45 +2725,39 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with iam:PutRolePolicy permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:putrolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find principals with iam:PutRolePolicy permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find iam:UpdateAssumeRolePolicy permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'iam:updateassumerolepolicy' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find target roles the principal can put inline policies on and update trust policy for MATCH path_target = (aws)--(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS target_role.name + OR target_role.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3011,40 +2777,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateFunction permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'lambda:createfunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:InvokeFunction permission - MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) - WHERE stmt_invoke.effect = 'Allow' - AND any(action IN stmt_invoke.action WHERE - toLower(action) = 'lambda:invokefunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_invoke)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['lambda:*', 'lambda:invokefunction'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Lambda service (can be passed to Lambda) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3052,7 +2812,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3072,40 +2832,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateFunction permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'lambda:createfunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateEventSourceMapping permission - MATCH (principal)--(event_policy:AWSPolicy)--(stmt_event:AWSPolicyStatement) - WHERE stmt_event.effect = 'Allow' - AND any(action IN stmt_event.action WHERE - toLower(action) = 'lambda:createeventsourcemapping' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(event_policy:AWSPolicy)-[:STATEMENT]->(stmt_event:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_event)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['lambda:*', 'lambda:createeventsourcemapping'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Lambda service (can be passed to Lambda) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3113,7 +2867,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3133,22 +2887,20 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with lambda:UpdateFunctionCode permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'lambda:updatefunctioncode' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find existing Lambda functions with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) + MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3156,7 +2908,7 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3175,45 +2927,39 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with lambda:UpdateFunctionCode permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'lambda:updatefunctioncode' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find principals with lambda:UpdateFunctionCode permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find lambda:InvokeFunction permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'lambda:invokefunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find lambda:InvokeFunction permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:invokefunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find existing Lambda functions with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) + MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3232,45 +2978,39 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinit ), provider="aws", cypher=f""" - // Find principals with lambda:UpdateFunctionCode permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'lambda:updatefunctioncode' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find principals with lambda:UpdateFunctionCode permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find lambda:AddPermission permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'lambda:addpermission' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + // Find lambda:AddPermission permission (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:addpermission'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, stmt2, path_principal // Find existing Lambda functions with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) - AND any(resource IN stmt2.resource WHERE - resource = '*' - OR lambda_fn.arn CONTAINS resource - OR resource CONTAINS lambda_fn.name - ) + MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res.value + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + OR res2.value CONTAINS lambda_fn.name + OR lambda_fn.arn CONTAINS res2.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3290,40 +3030,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:CreateFunction permission - MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) - WHERE stmt_create.effect = 'Allow' - AND any(action IN stmt_create.action WHERE - toLower(action) = 'lambda:createfunction' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find lambda:AddPermission permission - MATCH (principal)--(perm_policy:AWSPolicy)--(stmt_perm:AWSPolicyStatement) - WHERE stmt_perm.effect = 'Allow' - AND any(action IN stmt_perm.action WHERE - toLower(action) = 'lambda:addpermission' - OR toLower(action) = 'lambda:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(perm_policy:AWSPolicy)-[:STATEMENT]->(stmt_perm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_perm)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['lambda:*', 'lambda:addpermission'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust Lambda service (can be passed to Lambda) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3331,7 +3065,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3351,31 +3085,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find sagemaker:CreateNotebookInstance permission - MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) - WHERE stmt_sm.effect = 'Allow' - AND any(action IN stmt_sm.action WHERE - toLower(action) = 'sagemaker:createnotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createnotebookinstance'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust SageMaker service (can be passed to SageMaker) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3383,7 +3113,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3403,31 +3133,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find sagemaker:CreateTrainingJob permission - MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) - WHERE stmt_sm.effect = 'Allow' - AND any(action IN stmt_sm.action WHERE - toLower(action) = 'sagemaker:createtrainingjob' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createtrainingjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust SageMaker service (can be passed to SageMaker) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3435,7 +3161,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3455,31 +3181,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio provider="aws", cypher=f""" // Find principals with iam:PassRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) - WHERE stmt_passrole.effect = 'Allow' - AND any(action IN stmt_passrole.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find sagemaker:CreateProcessingJob permission - MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) - WHERE stmt_sm.effect = 'Allow' - AND any(action IN stmt_sm.action WHERE - toLower(action) = 'sagemaker:createprocessingjob' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createprocessingjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal // Find roles that trust SageMaker service (can be passed to SageMaker) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) - WHERE any(resource IN stmt_passrole.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3487,7 +3209,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3507,22 +3229,20 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with sagemaker:CreatePresignedNotebookInstanceUrl permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'sagemaker:createpresignednotebookinstanceurl' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sagemaker:*', 'sagemaker:createpresignednotebookinstanceurl'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find existing SageMaker notebook instances with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR notebook.arn CONTAINS resource - OR resource CONTAINS notebook.notebook_instance_name - ) + MATCH path_target = (aws)--(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS notebook.notebook_instance_name + OR notebook.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3530,7 +3250,7 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3549,58 +3269,46 @@ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( ), provider="aws", cypher=f""" - // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'sagemaker:createnotebookinstancelifecycleconfig' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission (this IS path_principal) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sagemaker:*', 'sagemaker:createnotebookinstancelifecycleconfig'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal - // Find sagemaker:UpdateNotebookInstance permission - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = 'sagemaker:updatenotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Gate: sagemaker:UpdateNotebookInstance (keep stmt2: its resource is checked) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:updatenotebookinstance'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt2, path_principal - // Find sagemaker:StopNotebookInstance permission - MATCH (principal)--(policy3:AWSPolicy)--(stmt3:AWSPolicyStatement) - WHERE stmt3.effect = 'Allow' - AND any(action IN stmt3.action WHERE - toLower(action) = 'sagemaker:stopnotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Gate: sagemaker:StopNotebookInstance (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['sagemaker:*', 'sagemaker:stopnotebookinstance'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt2, path_principal - // Find sagemaker:StartNotebookInstance permission - MATCH (principal)--(policy4:AWSPolicy)--(stmt4:AWSPolicyStatement) - WHERE stmt4.effect = 'Allow' - AND any(action IN stmt4.action WHERE - toLower(action) = 'sagemaker:startnotebookinstance' - OR toLower(action) = 'sagemaker:*' - OR action = '*' - ) + // Gate: sagemaker:StartNotebookInstance (existence only) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['sagemaker:*', 'sagemaker:startnotebookinstance'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt2, path_principal // Find existing SageMaker notebook instances with execution roles - MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) - WHERE any(resource IN stmt2.resource WHERE - resource = '*' - OR notebook.arn CONTAINS resource - OR resource CONTAINS notebook.notebook_instance_name - ) + MATCH path_target = (aws)--(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + MATCH (stmt2)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS notebook.notebook_instance_name + OR notebook.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3620,25 +3328,23 @@ AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ssm:StartSession permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'ssm:startsession' - OR toLower(action) = 'ssm:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ssm:*', 'ssm:startsession'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3658,25 +3364,23 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with ssm:SendCommand permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'ssm:sendcommand' - OR toLower(action) = 'ssm:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['ssm:*', 'ssm:sendcommand'] + OR act.value = '*' + WITH aws, collect(DISTINCT path_principal) AS principal_paths // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - WITH collect(path_principal) + collect(path_target) AS paths + WITH principal_paths + collect(DISTINCT path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3696,22 +3400,20 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with sts:AssumeRole permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'sts:assumerole' - OR toLower(action) = 'sts:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sts:*', 'sts:assumerole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal // Find target roles the principal can assume (bidirectional trust via Cartography) MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) - WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n @@ -3719,7 +3421,7 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -3727,7 +3429,6 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( ) # AWS Queries List -# ---------------- AWS_QUERIES: list[AttackPathsQueryDefinition] = [ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS, diff --git a/api/src/backend/api/attack_paths/queries/aws_deprecated.py b/api/src/backend/api/attack_paths/queries/aws_deprecated.py new file mode 100644 index 0000000000..b94c329202 --- /dev/null +++ b/api/src/backend/api/attack_paths/queries/aws_deprecated.py @@ -0,0 +1,3819 @@ +# TODO: drop after Neptune cutover +# +# Pre-cutover query catalog for AWS scans whose graph data was written under +# the previous schema, where list-typed policy properties were serialised as +# comma-delimited strings on the parent node. The registry routes scans with +# `is_migrated=False` to this module; all other scans use `aws.py`. Both +# files expose the same query IDs and parameter shapes so the API surface +# stays uniform across the cutover window. This file is deleted, along with +# `AttackPathsScan.is_migrated`, once the legacy data is fully drained. +from api.attack_paths.queries.types import ( + AttackPathsQueryAttribution, + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL + +# Custom Attack Path Queries +# -------------------------- + +AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( + id="aws-internet-exposed-ec2-sensitive-s3-access", + name="Internet-Exposed EC2 with Sensitive S3 Access", + short_description="Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets.", + description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.", + provider="aws", + cypher=f""" + MATCH path_s3 = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)--(t:AWSTag) + WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value) + + MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound) + WHERE ec2.exposed_internet = true + AND ipi.toport = 22 + + MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name) + AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*') + + MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + + WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) AS paths, + head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="tag_key", + label="Tag key", + description="Tag key to filter the S3 bucket, e.g. DataClassification.", + placeholder="DataClassification", + ), + AttackPathsQueryParameterDefinition( + name="tag_value", + label="Tag value", + description="Tag value to filter the S3 bucket, e.g. Sensitive.", + placeholder="Sensitive", + ), + ], +) + + +# Basic Resource Queries +# ---------------------- + +AWS_RDS_INSTANCES = AttackPathsQueryDefinition( + id="aws-rds-instances", + name="RDS Instances Inventory", + short_description="List all provisioned RDS database instances in the account.", + description="List the selected AWS account alongside the RDS instances it owns.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( + id="aws-rds-unencrypted-storage", + name="Unencrypted RDS Instances", + short_description="Find RDS instances with storage encryption disabled.", + description="Find RDS instances with storage encryption disabled within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) + WHERE rds.storage_encrypted = false + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( + id="aws-s3-anonymous-access-buckets", + name="S3 Buckets with Anonymous Access", + short_description="Find S3 buckets that allow anonymous access.", + description="Find S3 buckets that allow anonymous access within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket) + WHERE s3.anonymous_access = true + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-all-actions", + name="IAM Statements Allowing All Actions", + short_description="Find IAM policy statements that allow all actions via wildcard (*).", + description="Find IAM policy statements that allow all actions via '*' within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = '*') + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-delete-policy", + name="IAM Statements Allowing Policy Deletion", + short_description="Find IAM policy statements that allow iam:DeletePolicy.", + description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-create-actions", + name="IAM Statements Allowing Create Actions", + short_description="Find IAM policy statements that allow any create action.", + description="Find IAM policy statements that allow actions containing 'create' within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = "Allow" + AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + + +# Network Exposure Queries +# ------------------------ + +AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-ec2-instances-internet-exposed", + name="Internet-Exposed EC2 Instances", + short_description="Find EC2 instances flagged as exposed to the internet.", + description="Find EC2 instances flagged as exposed to the internet within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance) + WHERE ec2.exposed_internet = true + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( + id="aws-security-groups-open-internet-facing", + name="Open Security Groups on Internet-Facing Resources", + short_description="Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0.", + description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange) + WHERE ec2.exposed_internet = true + AND ir.range = "0.0.0.0/0" + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-classic-elb-internet-exposed", + name="Internet-Exposed Classic Load Balancers", + short_description="Find Classic Load Balancers exposed to the internet with their listeners.", + description="Find Classic Load Balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener) + WHERE elb.exposed_internet = true + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elb) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-elbv2-internet-exposed", + name="Internet-Exposed ALB/NLB Load Balancers", + short_description="Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners.", + description="Find ELBv2 load balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener) + WHERE elbv2.exposed_internet = true + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elbv2) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( + id="aws-public-ip-resource-lookup", + name="Resource Lookup by Public IP", + short_description="Find the AWS resource associated with a given public IP address.", + description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x)-[q]-(y) + WHERE (x:EC2PrivateIp AND x.public_ip = $ip) + OR (x:EC2Instance AND x.publicipaddress = $ip) + OR (x:NetworkInterface AND x.public_ip = $ip) + OR (x:ElasticIPAddress AND x.public_ip = $ip) + + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(x) + + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="ip", + label="IP address", + description="Public IP address, e.g. 192.0.2.0.", + placeholder="192.0.2.0", + ), + ], +) + +# Privilege Escalation Queries (based on pathfinding.cloud research) +# https://github.com/DataDog/pathfinding.cloud +# ------------------------------------------------------------------- + +# APPRUNNER-001 +AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( + id="aws-apprunner-privesc-passrole-create-service", + name="App Runner Service Creation with Privileged Role (APPRUNNER-001)", + short_description="Create an App Runner service with a privileged IAM role to gain its permissions.", + description="Detect principals who can pass IAM roles and create App Runner services. This allows creating a service with a privileged role attached, gaining that role's permissions via StartCommand execution, a container web shell, or a malicious apprunner.yaml configuration.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - APPRUNNER-001 - iam:PassRole + apprunner:CreateService", + link="https://pathfinding.cloud/paths/apprunner-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find apprunner:CreateService permission + MATCH (principal)--(apprunner_policy:AWSPolicy)--(stmt_apprunner:AWSPolicyStatement) + WHERE stmt_apprunner.effect = 'Allow' + AND any(action IN stmt_apprunner.action WHERE + toLower(action) = 'apprunner:createservice' + OR toLower(action) = 'apprunner:*' + OR action = '*' + ) + + // Find roles that trust App Runner tasks service (can be passed to App Runner) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# APPRUNNER-002 +AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( + id="aws-apprunner-privesc-update-service", + name="App Runner Service Update for Role Access (APPRUNNER-002)", + short_description="Update an existing App Runner service to leverage its already-attached privileged role.", + description="Detect principals who can update existing App Runner services. This allows modifying a service's configuration to execute arbitrary code with the service's already-attached IAM role, without requiring iam:PassRole. Exploitation methods include injecting a malicious StartCommand, updating to a container image with a web shell, or pointing to a repository with a malicious apprunner.yaml file.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - APPRUNNER-002 - apprunner:UpdateService", + link="https://pathfinding.cloud/paths/apprunner-002", + ), + provider="aws", + cypher=f""" + // Find principals with apprunner:UpdateService permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) + WHERE stmt_update.effect = 'Allow' + AND any(action IN stmt_update.action WHERE + toLower(action) = 'apprunner:updateservice' + OR toLower(action) = 'apprunner:*' + OR action = '*' + ) + + // Find existing App Runner services with roles attached (potential targets) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# BEDROCK-001 +AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( + id="aws-bedrock-privesc-passrole-code-interpreter", + name="Bedrock Code Interpreter with Privileged Role (BEDROCK-001)", + short_description="Create a Bedrock AgentCore Code Interpreter with a privileged role attached.", + description="Detect principals who can pass IAM roles and create Bedrock AgentCore Code Interpreters. This allows creating a code interpreter with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BEDROCK-001 - iam:PassRole + bedrock-agentcore:CreateCodeInterpreter + bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter", + link="https://pathfinding.cloud/paths/bedrock-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find bedrock-agentcore:CreateCodeInterpreter permission + MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement) + WHERE stmt_bedrock.effect = 'Allow' + AND any(action IN stmt_bedrock.action WHERE + toLower(action) = 'bedrock-agentcore:createcodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:StartCodeInterpreterSession permission + MATCH (principal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) + WHERE stmt_session.effect = 'Allow' + AND any(action IN stmt_session.action WHERE + toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:InvokeCodeInterpreter permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# BEDROCK-002 +AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( + id="aws-bedrock-privesc-invoke-code-interpreter", + name="Bedrock Code Interpreter Session Hijacking (BEDROCK-002)", + short_description="Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials.", + description="Detect principals who can start sessions and invoke code on existing Bedrock AgentCore code interpreters. This allows executing arbitrary Python code within an interpreter that has a privileged role attached, gaining that role's credentials via the MicroVM Metadata Service without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BEDROCK-002 - bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter", + link="https://pathfinding.cloud/paths/bedrock-002", + ), + provider="aws", + cypher=f""" + // Find principals with bedrock-agentcore:StartCodeInterpreterSession permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) + WHERE stmt_session.effect = 'Allow' + AND any(action IN stmt_session.action WHERE + toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:InvokeCodeInterpreter permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-001 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-create-stack", + name="CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)", + short_description="Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources.", + description="Detect principals who can pass IAM roles and create CloudFormation stacks. This allows launching a stack with a malicious template that executes with the passed role's permissions, enabling creation of resources like IAM users, Lambda functions, or EC2 instances controlled by the attacker.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-001 - iam:PassRole + cloudformation:CreateStack", + link="https://pathfinding.cloud/paths/cloudformation-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:CreateStack permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:createstack' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed to CloudFormation) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-002 +AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-update-stack", + name="CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)", + short_description="Update an existing CloudFormation stack to leverage its already-attached privileged service role.", + description="Detect principals who can update existing CloudFormation stacks. This allows modifying a stack's template to add new resources (such as IAM roles with admin access) that are created with the stack's already-attached service role permissions, without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-002 - cloudformation:UpdateStack", + link="https://pathfinding.cloud/paths/cloudformation-002", + ), + provider="aws", + cypher=f""" + // Find principals with cloudformation:UpdateStack permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) + WHERE stmt_update.effect = 'Allow' + AND any(action IN stmt_update.action WHERE + toLower(action) = 'cloudformation:updatestack' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (already attached to existing stacks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-003 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-create-stackset", + name="CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)", + short_description="Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts.", + description="Detect principals who can pass IAM roles, create CloudFormation StackSets, and deploy stack instances. This allows creating a StackSet with a malicious template and a privileged execution role, then deploying instances that create resources (such as IAM roles with admin access) using that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-003 - iam:PassRole + cloudformation:CreateStackSet + cloudformation:CreateStackInstances", + link="https://pathfinding.cloud/paths/cloudformation-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:CreateStackSet permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:createstackset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find cloudformation:CreateStackInstances permission + MATCH (principal)--(cfn_instances_policy:AWSPolicy)--(stmt_cfn_instances:AWSPolicyStatement) + WHERE stmt_cfn_instances.effect = 'Allow' + AND any(action IN stmt_cfn_instances.action WHERE + toLower(action) = 'cloudformation:createstackinstances' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed as execution role) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-004 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-update-stackset", + name="CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)", + short_description="Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role.", + description="Detect principals who can pass IAM roles and update CloudFormation StackSets. This allows modifying an existing StackSet's template to add resources (such as IAM roles with admin access) that are provisioned by the StackSet's privileged execution role across target accounts.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-004 - iam:PassRole + cloudformation:UpdateStackSet", + link="https://pathfinding.cloud/paths/cloudformation-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:UpdateStackSet permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:updatestackset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed as execution role) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-005 +AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-changeset", + name="CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)", + short_description="Create and execute a change set on an existing stack to leverage its privileged service role.", + description="Detect principals who can create and execute CloudFormation change sets. This allows modifying an existing stack's template through a staged change set, inheriting the stack's already-attached service role permissions to provision arbitrary resources without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-005 - cloudformation:CreateChangeSet + cloudformation:ExecuteChangeSet", + link="https://pathfinding.cloud/paths/cloudformation-005", + ), + provider="aws", + cypher=f""" + // Find principals with cloudformation:CreateChangeSet permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'cloudformation:createchangeset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find cloudformation:ExecuteChangeSet permission + MATCH (principal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) + WHERE stmt_exec.effect = 'Allow' + AND any(action IN stmt_exec.action WHERE + toLower(action) = 'cloudformation:executechangeset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (already attached to existing stacks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-001 +AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-passrole-create-project", + name="CodeBuild Project Creation with Privileged Role (CODEBUILD-001)", + short_description="Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec.", + description="Detect principals who can pass IAM roles, create CodeBuild projects, and start builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious buildspec, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-001 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuild", + link="https://pathfinding.cloud/paths/codebuild-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find codebuild:CreateProject permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'codebuild:createproject' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find codebuild:StartBuild permission + MATCH (principal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuild' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (can be passed to CodeBuild) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-002 +AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-start-build", + name="CodeBuild Buildspec Override for Role Access (CODEBUILD-002)", + short_description="Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.", + description="Detect principals who can start builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-002 - codebuild:StartBuild", + link="https://pathfinding.cloud/paths/codebuild-002", + ), + provider="aws", + cypher=f""" + // Find principals with codebuild:StartBuild permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuild' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (already attached to existing projects) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-003 +AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-start-build-batch", + name="CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)", + short_description="Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.", + description="Detect principals who can start batch builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-003 - codebuild:StartBuildBatch", + link="https://pathfinding.cloud/paths/codebuild-003", + ), + provider="aws", + cypher=f""" + // Find principals with codebuild:StartBuildBatch permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuildbatch' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (already attached to existing projects) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-004 +AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-passrole-create-project-batch", + name="CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)", + short_description="Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec.", + description="Detect principals who can pass IAM roles, create CodeBuild projects, and start batch builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious batch buildspec, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-004 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuildBatch", + link="https://pathfinding.cloud/paths/codebuild-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find codebuild:CreateProject permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'codebuild:createproject' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find codebuild:StartBuildBatch permission + MATCH (principal)--(batch_policy:AWSPolicy)--(stmt_batch:AWSPolicyStatement) + WHERE stmt_batch.effect = 'Allow' + AND any(action IN stmt_batch.action WHERE + toLower(action) = 'codebuild:startbuildbatch' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (can be passed to CodeBuild) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# DATAPIPELINE-001 +AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( + id="aws-datapipeline-privesc-passrole-create-pipeline", + name="Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)", + short_description="Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure.", + description="Detect principals who can pass IAM roles, create Data Pipelines, define pipeline objects, and activate them. This allows creating a pipeline with a privileged role attached and executing arbitrary commands on the provisioned EC2 instances or EMR clusters, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - DATAPIPELINE-001 - iam:PassRole + datapipeline:CreatePipeline + datapipeline:PutPipelineDefinition + datapipeline:ActivatePipeline", + link="https://pathfinding.cloud/paths/datapipeline-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find datapipeline:CreatePipeline permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'datapipeline:createpipeline' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find datapipeline:PutPipelineDefinition permission + MATCH (principal)--(put_policy:AWSPolicy)--(stmt_put:AWSPolicyStatement) + WHERE stmt_put.effect = 'Allow' + AND any(action IN stmt_put.action WHERE + toLower(action) = 'datapipeline:putpipelinedefinition' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find datapipeline:ActivatePipeline permission + MATCH (principal)--(activate_policy:AWSPolicy)--(stmt_activate:AWSPolicyStatement) + WHERE stmt_activate.effect = 'Allow' + AND any(action IN stmt_activate.action WHERE + toLower(action) = 'datapipeline:activatepipeline' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find roles that trust Data Pipeline or EMR service (can be passed to DataPipeline) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted_principal:AWSPrincipal) + WHERE trusted_principal.arn IN ['datapipeline.amazonaws.com', 'elasticmapreduce.amazonaws.com'] + AND any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-001 +AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-iam", + name="EC2 Instance Launch with Privileged Role (EC2-001)", + short_description="Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS.", + description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances", + link="https://pathfinding.cloud/paths/ec2-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ec2:RunInstances permission + MATCH (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) + WHERE stmt_ec2.effect = 'Allow' + AND any(action IN stmt_ec2.action WHERE + toLower(action) = 'ec2:runinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-002 +AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-modify-instance-attribute", + name="EC2 Role Hijacking via UserData Injection (EC2-002)", + short_description="Inject malicious scripts into EC2 instance userData to gain the attached role's permissions.", + description="Detect principals who can modify EC2 instance userData, stop, and start instances. This allows injecting malicious scripts that execute on instance restart, gaining the permissions of the instance's attached IAM role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-002 - ec2:ModifyInstanceAttribute + ec2:StopInstances + ec2:StartInstances", + link="https://pathfinding.cloud/paths/ec2-002", + ), + provider="aws", + cypher=f""" + // Find principals with ec2:ModifyInstanceAttribute permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifyinstanceattribute' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:StopInstances permission (can be same or different policy) + MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement) + WHERE stmt_stop.effect = 'Allow' + AND any(action IN stmt_stop.action WHERE + toLower(action) = 'ec2:stopinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:StartInstances permission (can be same or different policy) + MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement) + WHERE stmt_start.effect = 'Allow' + AND any(action IN stmt_start.action WHERE + toLower(action) = 'ec2:startinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find EC2 instances with instance profiles (potential targets) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-003 +AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-spot-instances", + name="Spot Instance Launch with Privileged Role (EC2-003)", + short_description="Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS.", + description="Detect principals who can pass IAM roles and request EC2 Spot Instances. This allows launching a spot instance with a privileged role attached, gaining that role's permissions via the instance metadata service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-003 - iam:PassRole + ec2:RequestSpotInstances", + link="https://pathfinding.cloud/paths/ec2-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ec2:RequestSpotInstances permission + MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement) + WHERE stmt_spot.effect = 'Allow' + AND any(action IN stmt_spot.action WHERE + toLower(action) = 'ec2:requestspotinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2 spot instances) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-004 +AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-launch-template", + name="Launch Template Poisoning for Role Access (EC2-004)", + short_description="Inject malicious userData into launch templates that reference privileged roles, no PassRole needed.", + description="Detect principals who can create new launch template versions and modify launch templates. This allows injecting malicious user data into existing templates that already reference privileged IAM roles, without requiring iam:PassRole permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-004 - ec2:CreateLaunchTemplateVersion + ec2:ModifyLaunchTemplate", + link="https://pathfinding.cloud/paths/ec2-004", + ), + provider="aws", + cypher=f""" + // Find principals with ec2:CreateLaunchTemplateVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'ec2:createlaunchtemplateversion' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:ModifyLaunchTemplate permission + MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifylaunchtemplate' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find launch templates in the account (potential targets) + MATCH path_target = (aws)--(template:LaunchTemplate) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2INSTANCECONNECT-003 +AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition( + id="aws-ec2instanceconnect-privesc-send-ssh-public-key", + name="EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)", + short_description="Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS.", + description="Detect principals who can send SSH public keys via EC2 Instance Connect. This allows establishing an SSH session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2INSTANCECONNECT-003 - ec2-instance-connect:SendSSHPublicKey", + link="https://pathfinding.cloud/paths/ec2instanceconnect-003", + ), + provider="aws", + cypher=f""" + // Find principals with ec2-instance-connect:SendSSHPublicKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(connect_policy:AWSPolicy)--(stmt_connect:AWSPolicyStatement) + WHERE stmt_connect.effect = 'Allow' + AND any(action IN stmt_connect.action WHERE + toLower(action) = 'ec2-instance-connect:sendsshpublickey' + OR toLower(action) = 'ec2-instance-connect:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-001 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service", + name="ECS Service Creation with Privileged Role (ECS-001 - New Cluster)", + short_description="Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and create services. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container.", + provider="aws", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-001 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-001", + ), + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-002 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task", + name="ECS Task Execution with Privileged Role (ECS-002 - New Cluster)", + short_description="Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and run tasks. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container. Unlike ecs:CreateService, ecs:RunTask executes the task once without creating a persistent service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-002 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-003 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service-existing-cluster", + name="ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)", + short_description="Deploy a Fargate service with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and create services on existing clusters. Unlike ECS-001, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and launches it as a Fargate service, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-003 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-004 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task-existing-cluster", + name="ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)", + short_description="Run a one-off Fargate task with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and run tasks on existing clusters. Unlike ECS-002, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and runs it as a one-off Fargate task, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-004 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-005 +AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-start-task-existing-cluster", + name="ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)", + short_description="Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and start tasks on existing EC2 container instances. Unlike ecs:RunTask which works with both EC2 and Fargate, ecs:StartTask is specific to EC2 launch types and requires specifying an existing container instance ARN. The attacker registers a task definition with a privileged role and starts it on a container instance, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-005 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:StartTask", + link="https://pathfinding.cloud/paths/ecs-005", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:StartTask permission + MATCH (principal)--(starttask_policy:AWSPolicy)--(stmt_starttask:AWSPolicyStatement) + WHERE stmt_starttask.effect = 'Allow' + AND any(action IN stmt_starttask.action WHERE + toLower(action) = 'ecs:starttask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-006 +AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( + id="aws-ecs-privesc-execute-command", + name="ECS Exec Container Hijacking for Role Credentials (ECS-006)", + short_description="Shell into a running ECS container via ECS Exec to steal the attached task role's credentials.", + description="Detect principals who can execute commands in running ECS containers and describe tasks. This allows establishing an interactive shell session in a container where ECS Exec is enabled, then retrieving the task role's temporary credentials from the container metadata service, without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-006 - ecs:ExecuteCommand + ecs:DescribeTasks", + link="https://pathfinding.cloud/paths/ecs-006", + ), + provider="aws", + cypher=f""" + // Find principals with ecs:ExecuteCommand permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) + WHERE stmt_exec.effect = 'Allow' + AND any(action IN stmt_exec.action WHERE + toLower(action) = 'ecs:executecommand' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:DescribeTasks permission (required by AWS CLI to get container runtime ID) + MATCH (principal)--(describe_policy:AWSPolicy)--(stmt_describe:AWSPolicyStatement) + WHERE stmt_describe.effect = 'Allow' + AND any(action IN stmt_describe.action WHERE + toLower(action) = 'ecs:describetasks' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (already attached to running tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-001 +AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-dev-endpoint", + name="Glue Dev Endpoint with Privileged Role (GLUE-001)", + short_description="Create a Glue development endpoint with a privileged role attached to gain its permissions.", + description="Detect principals who can pass IAM roles and create Glue development endpoints. This allows creating a dev endpoint with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-001 - iam:PassRole + glue:CreateDevEndpoint", + link="https://pathfinding.cloud/paths/glue-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateDevEndpoint permission + MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement) + WHERE stmt_glue.effect = 'Allow' + AND any(action IN stmt_glue.action WHERE + toLower(action) = 'glue:createdevendpoint' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-002 +AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition( + id="aws-glue-privesc-update-dev-endpoint", + name="Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)", + short_description="Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials.", + description="Detect principals who can update Glue development endpoints. This allows adding an attacker-controlled SSH public key to an existing dev endpoint that already has a privileged role attached, then SSHing into it to steal the role's temporary credentials without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-002 - glue:UpdateDevEndpoint", + link="https://pathfinding.cloud/paths/glue-002", + ), + provider="aws", + cypher=f""" + // Find principals with glue:UpdateDevEndpoint permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'glue:updatedevendpoint' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (already attached to existing dev endpoints) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-003 +AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-create-job", + name="Glue Job Creation with Privileged Role (GLUE-003)", + short_description="Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions.", + description="Detect principals who can pass IAM roles, create Glue jobs, and start job runs. This allows creating a Python shell job with a privileged role attached and executing arbitrary code that modifies IAM permissions, a cost-effective alternative to Glue development endpoints.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-003 - iam:PassRole + glue:CreateJob + glue:StartJobRun", + link="https://pathfinding.cloud/paths/glue-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateJob permission + MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) + WHERE stmt_createjob.effect = 'Allow' + AND any(action IN stmt_createjob.action WHERE + toLower(action) = 'glue:createjob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:StartJobRun permission + MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) + WHERE stmt_startjob.effect = 'Allow' + AND any(action IN stmt_startjob.action WHERE + toLower(action) = 'glue:startjobrun' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-004 +AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-create-job-trigger", + name="Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)", + short_description="Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code.", + description="Detect principals who can pass IAM roles, create Glue jobs, and create triggers with automatic activation. Unlike manual execution via StartJobRun, this creates a persistent attack by scheduling the job to run repeatedly, making it harder to detect and remediate.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-004 - iam:PassRole + glue:CreateJob + glue:CreateTrigger", + link="https://pathfinding.cloud/paths/glue-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateJob permission + MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) + WHERE stmt_createjob.effect = 'Allow' + AND any(action IN stmt_createjob.action WHERE + toLower(action) = 'glue:createjob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:CreateTrigger permission + MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) + WHERE stmt_trigger.effect = 'Allow' + AND any(action IN stmt_trigger.action WHERE + toLower(action) = 'glue:createtrigger' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-005 +AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-update-job", + name="Glue Job Hijacking via Update with Privileged Role (GLUE-005)", + short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions.", + description="Detect principals who can pass IAM roles, update existing Glue jobs, and start job runs. This allows modifying an existing job's role and script to execute arbitrary code with elevated privileges, a stealthier variant of job creation since it reuses existing infrastructure rather than creating new resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-005 - iam:PassRole + glue:UpdateJob + glue:StartJobRun", + link="https://pathfinding.cloud/paths/glue-005", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:UpdateJob permission + MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) + WHERE stmt_updatejob.effect = 'Allow' + AND any(action IN stmt_updatejob.action WHERE + toLower(action) = 'glue:updatejob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:StartJobRun permission + MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) + WHERE stmt_startjob.effect = 'Allow' + AND any(action IN stmt_startjob.action WHERE + toLower(action) = 'glue:startjobrun' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-006 +AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-update-job-trigger", + name="Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)", + short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution.", + description="Detect principals who can pass IAM roles, update existing Glue jobs, and create triggers with automatic activation. This combines the stealth of modifying existing infrastructure with the persistence of scheduled automation, creating a recurring backdoor that re-executes even after remediation attempts.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-006 - iam:PassRole + glue:UpdateJob + glue:CreateTrigger", + link="https://pathfinding.cloud/paths/glue-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:UpdateJob permission + MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) + WHERE stmt_updatejob.effect = 'Allow' + AND any(action IN stmt_updatejob.action WHERE + toLower(action) = 'glue:updatejob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:CreateTrigger permission + MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) + WHERE stmt_trigger.effect = 'Allow' + AND any(action IN stmt_trigger.action WHERE + toLower(action) = 'glue:createtrigger' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-001 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version", + name="Policy Version Override for Self-Escalation (IAM-001)", + short_description="Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges.", + description="Detect principals who can create new policy versions. If a customer-managed policy is already attached to a principal and that principal has iam:CreatePolicyVersion on that policy, they can replace its contents with a fully permissive policy and set it as the default, gaining immediate administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-001 - iam:CreatePolicyVersion", + link="https://pathfinding.cloud/paths/iam-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find customer-managed policies attached to the same principal that can be overwritten + MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-002 +AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-access-key", + name="Access Key Creation for Lateral Movement (IAM-002)", + short_description="Create access keys for other IAM users to gain their permissions and move laterally across the account.", + description="Detect principals who can create access keys for other IAM users. This allows generating new credentials for any target user within the resource scope, immediately gaining that user's permissions without needing their password or existing keys.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-002 - iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateAccessKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can create access keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-003 +AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-delete-create-access-key", + name="Access Key Rotation Attack for Lateral Movement (IAM-003)", + short_description="Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions.", + description="Detect principals who can both delete and create access keys for other IAM users. This variation of IAM-002 handles the scenario where a target user already has the maximum of two access keys by first deleting one, then creating a replacement under the attacker's control.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-003 - iam:CreateAccessKey + iam:DeleteAccessKey", + link="https://pathfinding.cloud/paths/iam-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateAccessKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:DeleteAccessKey permission + MATCH (principal)--(delete_policy:AWSPolicy)--(stmt_delete:AWSPolicyStatement) + WHERE stmt_delete.effect = 'Allow' + AND any(action IN stmt_delete.action WHERE + toLower(action) = 'iam:deleteaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can rotate access keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt_delete.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-004 +AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-login-profile", + name="Console Login Profile Creation for Lateral Movement (IAM-004)", + short_description="Create console login profiles for other IAM users to access the AWS Console with their permissions.", + description="Detect principals who can create console login profiles for other IAM users. By setting a known password on a target user that lacks a login profile, the attacker gains AWS Console access with that user's permissions without needing their existing credentials.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-004 - iam:CreateLoginProfile", + link="https://pathfinding.cloud/paths/iam-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateLoginProfile permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createloginprofile' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can create login profiles for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-005 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy", + name="Inline Policy Injection for Self-Escalation (IAM-005)", + short_description="Attach an inline policy with administrative permissions to your own role, instantly escalating privileges.", + description="Detect roles that can use iam:PutRolePolicy on themselves. A role with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-005 - iam:PutRolePolicy", + link="https://pathfinding.cloud/paths/iam-005", + ), + provider="aws", + cypher=f""" + // Find roles with iam:PutRolePolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR role.arn CONTAINS resource + OR resource CONTAINS role.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-006 +AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( + id="aws-iam-privesc-update-login-profile", + name="Console Password Override for Lateral Movement (IAM-006)", + short_description="Change the console password of other IAM users to log in as them and gain their permissions.", + description="Detect principals who can update console login profiles for other IAM users. By resetting a target user's password, the attacker gains AWS Console access with that user's permissions. Unlike IAM-004, this targets users who already have a login profile configured.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-006 - iam:UpdateLoginProfile", + link="https://pathfinding.cloud/paths/iam-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:UpdateLoginProfile permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:updateloginprofile' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can update login profiles for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-007 +AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-user-policy", + name="Inline Policy Injection on User for Self-Escalation (IAM-007)", + short_description="Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges.", + description="Detect IAM users that can use iam:PutUserPolicy on themselves. A user with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource. This is the user equivalent of IAM-005 (PutRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-007 - iam:PutUserPolicy", + link="https://pathfinding.cloud/paths/iam-007", + ), + provider="aws", + cypher=f""" + // Find users with iam:PutUserPolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR user.arn CONTAINS resource + OR resource CONTAINS user.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-008 +AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-user-policy", + name="Managed Policy Attachment on User for Self-Escalation (IAM-008)", + short_description="Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges.", + description="Detect IAM users that can use iam:AttachUserPolicy on themselves. A user with this permission can attach any existing managed policy, including AdministratorAccess, to themselves without needing to modify or assume any other resource. Unlike IAM-007 (PutUserPolicy), this requires an existing managed policy with elevated permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-008 - iam:AttachUserPolicy", + link="https://pathfinding.cloud/paths/iam-008", + ), + provider="aws", + cypher=f""" + // Find users with iam:AttachUserPolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR user.arn CONTAINS resource + OR resource CONTAINS user.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-009 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy", + name="Managed Policy Attachment on Role for Self-Escalation (IAM-009)", + short_description="Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges.", + description="Detect IAM roles that can use iam:AttachRolePolicy on themselves. A role with this permission can attach any existing managed policy, including AdministratorAccess, to itself without needing to modify or assume any other resource. Unlike IAM-005 (PutRolePolicy), this requires an existing managed policy with elevated permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-009 - iam:AttachRolePolicy", + link="https://pathfinding.cloud/paths/iam-009", + ), + provider="aws", + cypher=f""" + // Find roles with iam:AttachRolePolicy permission scoped to themselves + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR role.arn CONTAINS resource + OR resource CONTAINS role.name + ) + + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-010 +AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-group-policy", + name="Managed Policy Attachment on Group for Self-Escalation (IAM-010)", + short_description="Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members.", + description="Detect IAM users that can use iam:AttachGroupPolicy on a group they are a member of. A user with this permission can attach any existing managed policy, including AdministratorAccess, to a group they belong to, immediately escalating privileges for all group members.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-010 - iam:AttachGroupPolicy", + link="https://pathfinding.cloud/paths/iam-010", + ), + provider="aws", + cypher=f""" + // Find users with iam:AttachGroupPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachgrouppolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find groups the user is a member of and can attach policies to + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-011 +AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-group-policy", + name="Inline Policy Injection on Group for Self-Escalation (IAM-011)", + short_description="Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members.", + description="Detect IAM users that can use iam:PutGroupPolicy on a group they are a member of. A user with this permission can attach an inline policy granting any permissions to a group they belong to, immediately escalating privileges for all group members. Unlike IAM-010, this does not require an existing managed policy.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-011 - iam:PutGroupPolicy", + link="https://pathfinding.cloud/paths/iam-011", + ), + provider="aws", + cypher=f""" + // Find users with iam:PutGroupPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putgrouppolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find groups the user is a member of and can put policies on + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-012 +AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-update-assume-role-policy", + name="Trust Policy Hijacking for Role Assumption (IAM-012)", + short_description="Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions.", + description="Detect principals who can update the assume role policy (trust policy) of other IAM roles. By modifying a target role's trust policy to trust the attacker's principal, the attacker can then assume the role and gain all its permissions, including potential administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-012 - iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-012", + ), + provider="aws", + cypher=f""" + // Find principals with iam:UpdateAssumeRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles whose trust policy can be modified + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-013 +AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( + id="aws-iam-privesc-add-user-to-group", + name="Group Membership Hijacking for Privilege Escalation (IAM-013)", + short_description="Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group.", + description="Detect principals who can add users to IAM groups. By adding themselves to a group with elevated permissions such as AdministratorAccess, the attacker immediately inherits all policies attached to that group. The level of access gained depends on the permissions of the target group.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-013 - iam:AddUserToGroup", + link="https://pathfinding.cloud/paths/iam-013", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AddUserToGroup permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:addusertogroup' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target groups the principal can add users to + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-014 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-assume-role", + name="Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)", + short_description="Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges.", + description="Detect principals who can attach managed policies to a different IAM role and also assume that role. By attaching AdministratorAccess to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-009 for lateral movement where the principal targets another assumable role instead of their own.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-014 - iam:AttachRolePolicy + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-014", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume and attach policies to + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-015 +AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-user-policy-create-access-key", + name="Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)", + short_description="Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges.", + description="Detect principals who can attach managed policies to another IAM user and also create access keys for that user. By attaching AdministratorAccess to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-008 (AttachUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-015 - iam:AttachUserPolicy + iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-015", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachUserPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:CreateAccessKey permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users the principal can attach policies to and create keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-016 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version-assume-role", + name="Policy Version Override with Role Assumption for Lateral Movement (IAM-016)", + short_description="Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access.", + description="Detect principals who can create new versions of customer-managed policies attached to other roles and also assume those roles. By creating a new policy version with administrative permissions on a policy attached to a target role, then assuming that role, the attacker gains full administrative access. This is a variation of IAM-001 for lateral movement where the modified policy is attached to an assumable role rather than the attacker's own principal.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-016 - iam:CreatePolicyVersion + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-016", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume that have customer-managed policies the principal can modify + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + MATCH (target_role)--(target_policy:AWSPolicy) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-017 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy-assume-role", + name="Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)", + short_description="Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges.", + description="Detect principals who can add inline policies to a different IAM role and also assume that role. By adding an inline policy granting administrative permissions to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-005 for lateral movement where the principal targets another assumable role instead of their own.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-017 - iam:PutRolePolicy + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-017", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume and put inline policies on + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-018 +AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-user-policy-create-access-key", + name="Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)", + short_description="Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges.", + description="Detect principals who can add inline policies to another IAM user and also create access keys for that user. By adding an administrative inline policy to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-007 (PutUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-018 - iam:PutUserPolicy + iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-018", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutUserPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:CreateAccessKey permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users the principal can put policies on and create keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-019 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-update-assume-role", + name="Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)", + short_description="Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.", + description="Detect principals who can attach managed policies to an IAM role and also update that role's trust policy. By attaching AdministratorAccess and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-009 (AttachRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-019 - iam:AttachRolePolicy + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-019", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can attach policies to and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-020 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version-update-assume-role", + name="Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)", + short_description="Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access.", + description="Detect principals who can create new versions of customer-managed policies attached to roles and also update those roles' trust policies. By creating an administrative policy version and modifying the trust policy to allow the attacker, the principal can assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-001 (CreatePolicyVersion) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-020 - iam:CreatePolicyVersion + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-020", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles with customer-managed policies the principal can modify and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + MATCH (target_role)--(target_policy:AWSPolicy) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-021 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy-update-assume-role", + name="Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)", + short_description="Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.", + description="Detect principals who can add inline policies to an IAM role and also update that role's trust policy. By adding an administrative inline policy and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-005 (PutRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-021 - iam:PutRolePolicy + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-021", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can put inline policies on and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-001 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function", + name="Lambda Function Creation with Privileged Role (LAMBDA-001)", + short_description="Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and invoke them. By passing a privileged role to a new Lambda function and invoking it, the attacker executes code with the role's permissions, gaining access to any resources the role can access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-001 - iam:PassRole + lambda:CreateFunction + lambda:InvokeFunction", + link="https://pathfinding.cloud/paths/lambda-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:InvokeFunction permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'lambda:invokefunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-002 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function-event-source", + name="Lambda Function Creation with Event Source Trigger (LAMBDA-002)", + short_description="Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and configure event source mappings to trigger them. By passing a privileged role to a new Lambda function and creating an event source mapping (DynamoDB stream, Kinesis, SQS), the attacker executes code with elevated privileges without needing to invoke the function directly.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-002 - iam:PassRole + lambda:CreateFunction + lambda:CreateEventSourceMapping", + link="https://pathfinding.cloud/paths/lambda-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:CreateEventSourceMapping permission + MATCH (principal)--(event_policy:AWSPolicy)--(stmt_event:AWSPolicyStatement) + WHERE stmt_event.effect = 'Allow' + AND any(action IN stmt_event.action WHERE + toLower(action) = 'lambda:createeventsourcemapping' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-003 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code", + name="Lambda Function Code Injection (LAMBDA-003)", + short_description="Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions.", + description="Detect principals who can update the code of existing Lambda functions. By replacing a Lambda function's code with malicious code, the attacker executes arbitrary commands with the privileges of the function's execution role when it is next invoked, either manually or via automatic triggers.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-003 - lambda:UpdateFunctionCode", + link="https://pathfinding.cloud/paths/lambda-003", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-004 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code-invoke", + name="Lambda Function Code Injection with Direct Invocation (LAMBDA-004)", + short_description="Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions.", + description="Detect principals who can update the code of existing Lambda functions and invoke them. By replacing a Lambda function's code with malicious code and invoking it directly, the attacker executes arbitrary commands with the privileges of the function's execution role immediately, without waiting for automatic triggers.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-004 - lambda:UpdateFunctionCode + lambda:InvokeFunction", + link="https://pathfinding.cloud/paths/lambda-004", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:InvokeFunction permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'lambda:invokefunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-005 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code-add-permission", + name="Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)", + short_description="Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role.", + description="Detect principals who can update the code of existing Lambda functions and add permissions to their resource-based policies. By replacing a Lambda function's code and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with the function's execution role without needing lambda:InvokeFunction as an IAM permission.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-005 - lambda:UpdateFunctionCode + lambda:AddPermission", + link="https://pathfinding.cloud/paths/lambda-005", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:AddPermission permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'lambda:addpermission' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-006 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function-add-permission", + name="Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)", + short_description="Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and add permissions to their resource-based policies. By passing a privileged role to a new Lambda function and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with elevated privileges without needing lambda:InvokeFunction as an IAM permission.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-006 - iam:PassRole + lambda:CreateFunction + lambda:AddPermission", + link="https://pathfinding.cloud/paths/lambda-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:AddPermission permission + MATCH (principal)--(perm_policy:AWSPolicy)--(stmt_perm:AWSPolicyStatement) + WHERE stmt_perm.effect = 'Allow' + AND any(action IN stmt_perm.action WHERE + toLower(action) = 'lambda:addpermission' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-001 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-notebook", + name="SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)", + short_description="Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment.", + description="Detect principals who can create SageMaker notebook instances with privileged IAM roles. By passing a privileged role to a new notebook instance, the attacker gains shell access through the Jupyter environment and can execute arbitrary commands with the role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-001 - iam:PassRole + sagemaker:CreateNotebookInstance", + link="https://pathfinding.cloud/paths/sagemaker-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateNotebookInstance permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-002 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-training-job", + name="SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)", + short_description="Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions.", + description="Detect principals who can create SageMaker training jobs with privileged IAM roles. By passing a privileged role to a new training job with a malicious training script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-002 - iam:PassRole + sagemaker:CreateTrainingJob", + link="https://pathfinding.cloud/paths/sagemaker-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateTrainingJob permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createtrainingjob' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-003 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-processing-job", + name="SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)", + short_description="Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions.", + description="Detect principals who can create SageMaker processing jobs with privileged IAM roles. By passing a privileged role to a new processing job with a malicious script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-003 - iam:PassRole + sagemaker:CreateProcessingJob", + link="https://pathfinding.cloud/paths/sagemaker-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateProcessingJob permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createprocessingjob' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-004 +AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-presigned-notebook-url", + name="SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)", + short_description="Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions.", + description="Detect principals who can generate presigned URLs to access existing SageMaker notebook instances. By accessing the Jupyter environment via a presigned URL, the attacker can execute arbitrary code with the permissions of the notebook's execution role without creating any new resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-004 - sagemaker:CreatePresignedNotebookInstanceUrl", + link="https://pathfinding.cloud/paths/sagemaker-004", + ), + provider="aws", + cypher=f""" + // Find principals with sagemaker:CreatePresignedNotebookInstanceUrl permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sagemaker:createpresignednotebookinstanceurl' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find existing SageMaker notebook instances with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR notebook.arn CONTAINS resource + OR resource CONTAINS notebook.notebook_instance_name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-005 +AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-lifecycle-config-notebook", + name="SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)", + short_description="Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup.", + description="Detect principals who can inject malicious lifecycle configurations into existing SageMaker notebook instances. By stopping a notebook, attaching a malicious lifecycle config, and restarting it, the attacker executes arbitrary code with the notebook's execution role permissions during startup.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-005 - sagemaker:CreateNotebookInstanceLifecycleConfig + sagemaker:StopNotebookInstance + sagemaker:UpdateNotebookInstance + sagemaker:StartNotebookInstance", + link="https://pathfinding.cloud/paths/sagemaker-005", + ), + provider="aws", + cypher=f""" + // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sagemaker:createnotebookinstancelifecycleconfig' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:UpdateNotebookInstance permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'sagemaker:updatenotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:StopNotebookInstance permission + MATCH (principal)--(policy3:AWSPolicy)--(stmt3:AWSPolicyStatement) + WHERE stmt3.effect = 'Allow' + AND any(action IN stmt3.action WHERE + toLower(action) = 'sagemaker:stopnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:StartNotebookInstance permission + MATCH (principal)--(policy4:AWSPolicy)--(stmt4:AWSPolicyStatement) + WHERE stmt4.effect = 'Allow' + AND any(action IN stmt4.action WHERE + toLower(action) = 'sagemaker:startnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find existing SageMaker notebook instances with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + WHERE any(resource IN stmt2.resource WHERE + resource = '*' + OR notebook.arn CONTAINS resource + OR resource CONTAINS notebook.notebook_instance_name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSM-001 +AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( + id="aws-ssm-privesc-start-session", + name="SSM Session Access for EC2 Role Credentials (SSM-001)", + short_description="Start an SSM session on an EC2 instance to access its attached role credentials through IMDS.", + description="Detect principals who can start SSM sessions on EC2 instances. This allows establishing a shell session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSM-001 - ssm:StartSession", + link="https://pathfinding.cloud/paths/ssm-001", + ), + provider="aws", + cypher=f""" + // Find principals with ssm:StartSession permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'ssm:startsession' + OR toLower(action) = 'ssm:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSM-002 +AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( + id="aws-ssm-privesc-send-command", + name="SSM Send Command for EC2 Role Credentials (SSM-002)", + short_description="Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS.", + description="Detect principals who can send SSM commands to EC2 instances. This allows executing arbitrary commands on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSM-002 - ssm:SendCommand", + link="https://pathfinding.cloud/paths/ssm-002", + ), + provider="aws", + cypher=f""" + // Find principals with ssm:SendCommand permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'ssm:sendcommand' + OR toLower(action) = 'ssm:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# STS-001 +AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-sts-privesc-assume-role", + name="Role Assumption for Privilege Escalation (STS-001)", + short_description="Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role.", + description="Detect principals who can assume other IAM roles via sts:AssumeRole. When a principal has sts:AssumeRole permission and the target role's trust policy allows the principal to assume it (bidirectional trust), the attacker gains all permissions of the target role. This enables privilege escalation when the target role has higher privileges than the starting principal.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - STS-001 - sts:AssumeRole", + link="https://pathfinding.cloud/paths/sts-001", + ), + provider="aws", + cypher=f""" + // Find principals with sts:AssumeRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sts:assumerole' + OR toLower(action) = 'sts:*' + OR action = '*' + ) + + // Find target roles the principal can assume (bidirectional trust via Cartography) + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# AWS Queries List +# ---------------- + +AWS_DEPRECATED_QUERIES: list[AttackPathsQueryDefinition] = [ + AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS, + AWS_RDS_INSTANCES, + AWS_RDS_UNENCRYPTED_STORAGE, + AWS_S3_ANONYMOUS_ACCESS_BUCKETS, + AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS, + AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY, + AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS, + AWS_EC2_INSTANCES_INTERNET_EXPOSED, + AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING, + AWS_CLASSIC_ELB_INTERNET_EXPOSED, + AWS_ELBV2_INTERNET_EXPOSED, + AWS_PUBLIC_IP_RESOURCE_LOOKUP, + AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE, + AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE, + AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER, + AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK, + AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET, + AWS_CLOUDFORMATION_PRIVESC_CHANGESET, + AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT, + AWS_CODEBUILD_PRIVESC_START_BUILD, + AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH, + AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH, + AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE, + AWS_EC2_PRIVESC_PASSROLE_IAM, + AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE, + AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES, + AWS_EC2_PRIVESC_LAUNCH_TEMPLATE, + AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_EXECUTE_COMMAND, + AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT, + AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT, + AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB, + AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER, + AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB, + AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION, + AWS_IAM_PRIVESC_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY, + AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE, + AWS_IAM_PRIVESC_PUT_USER_POLICY, + AWS_IAM_PRIVESC_ATTACH_USER_POLICY, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY, + AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY, + AWS_IAM_PRIVESC_PUT_GROUP_POLICY, + AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY, + AWS_IAM_PRIVESC_ADD_USER_TO_GROUP, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE, + AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB, + AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL, + AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK, + AWS_SSM_PRIVESC_START_SESSION, + AWS_SSM_PRIVESC_SEND_COMMAND, + AWS_STS_PRIVESC_ASSUME_ROLE, +] diff --git a/api/src/backend/api/attack_paths/queries/registry.py b/api/src/backend/api/attack_paths/queries/registry.py index c683b2cb80..358b1d6aed 100644 --- a/api/src/backend/api/attack_paths/queries/registry.py +++ b/api/src/backend/api/attack_paths/queries/registry.py @@ -1,13 +1,14 @@ -from api.attack_paths.queries.types import AttackPathsQueryDefinition from api.attack_paths.queries.aws import AWS_QUERIES +# TODO: drop after Neptune cutover +from api.attack_paths.queries.aws_deprecated import AWS_DEPRECATED_QUERIES +from api.attack_paths.queries.types import AttackPathsQueryDefinition -# Query definitions organized by provider +# Query definitions for scans synced with the current schema. _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { "aws": AWS_QUERIES, } -# Flat lookup by query ID for O(1) access _QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { definition.id: definition for definitions in _QUERY_DEFINITIONS.values() @@ -15,11 +16,45 @@ _QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { } -def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]: - """Get all attack path queries for a specific provider.""" - return _QUERY_DEFINITIONS.get(provider, []) +# TODO: drop after Neptune cutover +# +# Query definitions for pre-cutover scans (`AttackPathsScan.is_migrated=False`) +# whose graph data was written under the previous schema. Both maps expose the +# same query IDs so the API contract is identical regardless of which set is +# routed to. +_DEPRECATED_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { + "aws": AWS_DEPRECATED_QUERIES, +} + +_DEPRECATED_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { + definition.id: definition + for definitions in _DEPRECATED_QUERY_DEFINITIONS.values() + for definition in definitions +} -def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None: - """Get a specific attack path query by its ID.""" - return _QUERIES_BY_ID.get(query_id) +def get_queries_for_provider( + provider: str, + is_migrated: bool = True, +) -> list[AttackPathsQueryDefinition]: + """Get all attack path queries for a provider. + + `is_migrated` selects the catalog: True for scans synced with the current + schema, False for pre-cutover scans still using the legacy graph shape. + # TODO: drop the `is_migrated` parameter after Neptune cutover + """ + catalog = _QUERY_DEFINITIONS if is_migrated else _DEPRECATED_QUERY_DEFINITIONS + return catalog.get(provider, []) + + +def get_query_by_id( + query_id: str, + is_migrated: bool = True, +) -> AttackPathsQueryDefinition | None: + """Get a specific attack path query by ID. + + `is_migrated` selects the catalog (see `get_queries_for_provider`). + # TODO: drop the `is_migrated` parameter after Neptune cutover + """ + by_id = _QUERIES_BY_ID if is_migrated else _DEPRECATED_QUERIES_BY_ID + return by_id.get(query_id) diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py index 8723fe3ec9..16f0d9e31a 100644 --- a/api/src/backend/api/attack_paths/retryable_session.py +++ b/api/src/backend/api/attack_paths/retryable_session.py @@ -1,5 +1,4 @@ import logging - from collections.abc import Callable from typing import Any diff --git a/api/src/backend/api/attack_paths/sink/__init__.py b/api/src/backend/api/attack_paths/sink/__init__.py new file mode 100644 index 0000000000..b90fd6e442 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/__init__.py @@ -0,0 +1,28 @@ +"""Attack-paths sink database layer. + +The sink is the persistent store where attack-paths graphs live after a scan +finishes. Currently selectable between Neo4j (OSS / local dev default) and +AWS Neptune (hosted dev/staging/prod). Backend is picked by the +`ATTACK_PATHS_SINK_DATABASE` setting at process init. + +This package exposes the public factory API; the implementation lives in +`api.attack_paths.sink.factory`. +""" + +from api.attack_paths.sink.factory import ( + SinkBackend, + close, + get_backend, + get_backend_for_name, + get_backend_for_scan, + init, +) + +__all__ = [ + "SinkBackend", + "close", + "get_backend", + "get_backend_for_name", + "get_backend_for_scan", + "init", +] diff --git a/api/src/backend/api/attack_paths/sink/base.py b/api/src/backend/api/attack_paths/sink/base.py new file mode 100644 index 0000000000..0ba4737f5e --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/base.py @@ -0,0 +1,92 @@ +"""Protocol every sink backend must implement.""" + +from contextlib import AbstractContextManager +from typing import Any, Protocol + +import neo4j + + +class SinkDatabase(Protocol): + """Contract for the persistent attack-paths graph store. + + The `database` argument is an opaque identifier passed through from the + legacy `database.py` API surface. On Neo4j it is the per-tenant database + name (e.g. `db-tenant-{uuid}`). On Neptune it is ignored (the cluster + has a single graph, and isolation is label-based). + """ + + def init(self) -> None: ... + + def close(self) -> None: ... + + def verify_connectivity(self) -> None: + """Raise if the backend the API read path uses is unreachable. + + Neo4j verifies its single driver. Neptune verifies the reader + driver (the endpoint the API serves reads from); on single-endpoint + clusters the reader aliases the writer, so that path is covered too. + Used by the readiness probe; must not block longer than the caller's + probe budget. + """ + ... + + def get_session( + self, + database: str | None = None, + default_access_mode: str | None = None, + ) -> AbstractContextManager: ... + + def execute_read_query( + self, + database: str, + cypher: str, + parameters: dict[str, Any] | None = None, + ) -> neo4j.graph.Graph: ... + + def create_database(self, database: str) -> None: ... + + def drop_database(self, database: str) -> None: ... + + def drop_subgraph(self, database: str, provider_id: str) -> int: ... + + def has_provider_data(self, database: str, provider_id: str) -> bool: ... + + def clear_cache(self, database: str) -> None: ... + + def ensure_sync_indexes(self, database: str) -> None: + """Create any index needed for the sync write path. + + Called once at the start of each provider sync; must be idempotent. + Neo4j creates a `_provider_element_id` index on `_ProviderResource`; + Neptune is a no-op (its `~id` lookup needs no index). + """ + ... + + def write_nodes( + self, + database: str, + labels: str, + rows: list[dict[str, Any]], + ) -> None: + """Upsert a batch of nodes into the sink. + + `labels` is a pre-rendered Cypher label string ready to drop after + the node variable (e.g. `` `AWSUser`:`_ProviderResource`:`_Tenant_x` ``). + Each row carries `provider_element_id` and `props`. + """ + ... + + def write_relationships( + self, + database: str, + rel_type: str, + provider_id: str, + rows: list[dict[str, Any]], + ) -> None: + """Upsert a batch of relationships into the sink. + + Each row carries `start_element_id`, `end_element_id`, + `provider_element_id` and `props`. `rel_type` is the relationship + type (already a valid Cypher identifier). + """ + ... diff --git a/api/src/backend/api/attack_paths/sink/factory.py b/api/src/backend/api/attack_paths/sink/factory.py new file mode 100644 index 0000000000..ad2116fa40 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/factory.py @@ -0,0 +1,134 @@ +"""Sink backend factory and process-wide handle cache. + +Picks the active backend from `settings.ATTACK_PATHS_SINK_DATABASE` at first +use, holds the active backend plus any secondary backends needed to serve +scans written under the previous configuration, and tears them all down on +process shutdown. Imported via `from api.attack_paths import sink as +sink_module`. +""" + +import threading +from enum import StrEnum, auto + +from api.attack_paths.sink.base import SinkDatabase +from api.models import AttackPathsScan +from django.conf import settings + +# Backend names + + +class SinkBackend(StrEnum): + NEO4J = auto() + NEPTUNE = auto() + + +# Backend cache + +_backend: SinkDatabase | None = None +_secondary_backends: dict[SinkBackend, SinkDatabase] = {} +_lock = threading.Lock() + + +def _resolve_setting() -> SinkBackend: + raw = settings.ATTACK_PATHS_SINK_DATABASE.lower() + try: + return SinkBackend(raw) + + except ValueError: + valid = sorted(b.value for b in SinkBackend) + raise RuntimeError( + f"ATTACK_PATHS_SINK_DATABASE must be one of {valid}; got {raw!r}" + ) + + +def _build_backend(name: SinkBackend) -> SinkDatabase: + if name is SinkBackend.NEO4J: + from api.attack_paths.sink.neo4j import Neo4jSink + + return Neo4jSink() + + if name is SinkBackend.NEPTUNE: + from api.attack_paths.sink.neptune import NeptuneSink + + return NeptuneSink() + + raise RuntimeError(f"Unknown sink backend {name!r}") + + +# Lifecycle + + +def init(name: SinkBackend | str | None = None) -> SinkDatabase: + """Initialize the configured sink backend. Idempotent.""" + global _backend + if _backend is not None: + return _backend + + with _lock: + if _backend is None: + resolved = SinkBackend(name) if name else _resolve_setting() + backend = _build_backend(resolved) + backend.init() + _backend = backend + + return _backend + + +def close() -> None: + """Close the active backend and every cached secondary backend.""" + global _backend + with _lock: + backends = [ + b for b in (_backend, *_secondary_backends.values()) if b is not None + ] + _backend = None + _secondary_backends.clear() + + for backend in backends: + try: + backend.close() + + except Exception: # pragma: no cover - best-effort + pass + + +def get_backend() -> SinkDatabase: + """Return the active sink. Initializes on first call.""" + return init() + + +# Per-scan routing + + +def get_backend_for_scan(scan: AttackPathsScan) -> SinkDatabase: + """Route reads by the sink that stores this scan's graph.""" + raw_backend = getattr(scan, "sink_backend", SinkBackend.NEO4J.value) + if not isinstance(raw_backend, str): + raw_backend = SinkBackend.NEO4J.value + return get_backend_for_name(raw_backend) + + +def get_backend_for_name(name: SinkBackend | str) -> SinkDatabase: + """Return the backend named by persisted scan metadata.""" + resolved = SinkBackend(name) + if resolved is _resolve_setting(): + return get_backend() + + return _build_backend_cached(resolved) + + +def _build_backend_cached(name: SinkBackend) -> SinkDatabase: + # TODO: drop after Neptune cutover + # Needed only during cutover to serve Neo4j-written scans from a Neptune- + # configured API pod (and vice versa). Once every scan is on Neptune, + # `get_backend_for_scan` becomes a one-liner returning `get_backend()`. + if name in _secondary_backends: + return _secondary_backends[name] + + with _lock: + if name not in _secondary_backends: + backend = _build_backend(name) + backend.init() + _secondary_backends[name] = backend + + return _secondary_backends[name] diff --git a/api/src/backend/api/attack_paths/sink/neo4j.py b/api/src/backend/api/attack_paths/sink/neo4j.py new file mode 100644 index 0000000000..f8446afab3 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/neo4j.py @@ -0,0 +1,454 @@ +"""Neo4j sink implementation. + +Owns a Neo4j driver independent from the staging driver. On OSS and local dev +this is the only sink; on hosted deployments it runs only as a legacy read +path while phase-1 drains tenant DBs. +""" + +import atexit +import logging +import threading +import time +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager +from typing import Any + +import neo4j +import neo4j.exceptions +from api.attack_paths.retryable_session import RetryableSession +from api.attack_paths.sink.base import SinkDatabase +from config.env import env +from django.conf import settings + +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +logger = logging.getLogger(__name__) + +SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( + "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 +) +READ_QUERY_TIMEOUT_SECONDS = env.int( + "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 +) +CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) +# TCP connect timeout, ordered below the acquisition timeout so an unreachable +# host can't pin a request or the readiness probe longer than this. +CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5) +MAX_CONNECTION_LIFETIME = env.int("NEO4J_MAX_CONNECTION_LIFETIME", default=7200) +MAX_CONNECTION_POOL_SIZE = env.int("NEO4J_MAX_CONNECTION_POOL_SIZE", default=50) + +READ_EXCEPTION_CODES = [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", +] +CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." +DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound" + + +class Neo4jSink(SinkDatabase): + """Neo4j-backed sink. Multi-database cluster; tenant isolation is physical.""" + + def __init__(self) -> None: + self._driver: neo4j.Driver | None = None + self._lock = threading.Lock() + self._atexit_registered = False + + # Driver + + def _config(self) -> dict: + return settings.DATABASES["neo4j"] + + def _uri(self) -> str: + cfg = self._config() + host = cfg["HOST"] + port = cfg["PORT"] + if not host or not port: + raise RuntimeError( + "NEO4J_HOST / NEO4J_PORT must be set when ATTACK_PATHS_SINK_DATABASE=neo4j" + ) + return f"bolt://{host}:{port}" + + def init(self) -> neo4j.Driver: + if self._driver is not None: + return self._driver + with self._lock: + if self._driver is None: + cfg = self._config() + self._driver = neo4j.GraphDatabase.driver( + self._uri(), + auth=(cfg["USER"], cfg["PASSWORD"]), + keep_alive=True, + max_connection_lifetime=MAX_CONNECTION_LIFETIME, + connection_timeout=CONNECTION_TIMEOUT, + connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, + max_connection_pool_size=MAX_CONNECTION_POOL_SIZE, + ) + # Eager connectivity check is best-effort: + # A Neo4j that is down at boot must not crash the process, same degradation model as Postgres + # The driver reconnects lazily on first use + # /health/ready surfaces the outage until it recovers + try: + self._driver.verify_connectivity() + + except Exception: + logger.warning( + "Neo4j sink unreachable at init; continuing with a lazily-reconnecting driver", + exc_info=True, + ) + + if not self._atexit_registered: + atexit.register(self.close) + self._atexit_registered = True + return self._driver + + def _get_driver(self) -> neo4j.Driver: + return self.init() + + def verify_connectivity(self) -> None: + self._get_driver().verify_connectivity() + + def close(self) -> None: + with self._lock: + if self._driver is not None: + try: + self._driver.close() + finally: + self._driver = None + + # Sessions + + @contextmanager + def get_session( + self, + database: str | None = None, + default_access_mode: str | None = None, + ) -> Iterator[RetryableSession]: + from api.attack_paths.database import ( + ClientStatementException, + GraphDatabaseQueryException, + WriteQueryNotAllowedException, + ) + + session_wrapper: RetryableSession | None = None + try: + session_wrapper = RetryableSession( + session_factory=lambda: self._get_driver().session( + database=database, default_access_mode=default_access_mode + ), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + if ( + default_access_mode == neo4j.READ_ACCESS + and exc.code + and exc.code in READ_EXCEPTION_CODES + ): + raise WriteQueryNotAllowedException( + message="Read query not allowed", code=READ_EXCEPTION_CODES[0] + ) + + message = exc.message if exc.message is not None else str(exc) + if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): + raise ClientStatementException(message=message, code=exc.code) + raise GraphDatabaseQueryException(message=message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + # Operations + + def execute_read_query( + self, + database: str, + cypher: str, + parameters: dict[str, Any] | None = None, + ) -> neo4j.graph.Graph: + with self.get_session( + database, default_access_mode=neo4j.READ_ACCESS + ) as session: + + def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph: + result = tx.run( + cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS + ) + return result.graph() + + return session.execute_read(_run) + + def create_database(self, database: str) -> None: + with self.get_session() as session: + session.run( + "CREATE DATABASE $database IF NOT EXISTS", {"database": database} + ) + + def drop_database(self, database: str) -> None: + with self.get_session() as session: + session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA") + + def drop_subgraph(self, database: str, provider_id: str) -> int: + """Delete all nodes for a provider from a tenant database, batched. + + Deletes relationships then nodes in batches (not `DETACH DELETE`) so a + dense provider's graph cannot exceed Neo4j's transaction memory limit. + Silently returns 0 if the database doesn't exist. + """ + from api.attack_paths.database import GraphDatabaseQueryException + from tasks.jobs.attack_paths.config import ( + BATCH_SIZE, + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + deleted_nodes = 0 + deleted_relationships = 0 + relationship_batches = 0 + node_batches = 0 + drop_t0 = time.perf_counter() + + logger.info( + "Dropping provider graph from Neo4j sink database %s " + "(provider=%s, provider_label=%s)", + database, + provider_id, + provider_label, + ) + + try: + logger.info( + "Opening Neo4j sink session for provider graph drop " + "(database=%s, provider=%s)", + database, + provider_id, + ) + with self.get_session(database) as session: + logger.info( + "Opened Neo4j sink session for provider graph drop " + "(database=%s, provider=%s)", + database, + provider_id, + ) + # Phase 1: delete relationships incident to provider nodes in + # batches. The undirected pattern matches an edge between two + # provider nodes from both ends, so `DISTINCT r` dedupes it to + # delete a full batch of unique relationships each round. + deleted_count = 1 + while deleted_count > 0: + next_batch = relationship_batches + 1 + logger.info( + "Deleting relationship batch from Neo4j sink database %s " + "(provider=%s, batch=%s, total_rels=%s, elapsed=%.3fs)", + database, + provider_id, + next_batch, + deleted_relationships, + time.perf_counter() - drop_t0, + ) + result = session.run( + f""" + MATCH (:`{provider_label}`)-[r]-() + WITH DISTINCT r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, + {"batch_size": BATCH_SIZE}, + ) + deleted_count = result.single().get("deleted_rels_count", 0) + if deleted_count > 0: + relationship_batches += 1 + deleted_relationships += deleted_count + logger.info( + "Deleted relationship batch from Neo4j sink database %s " + "(provider=%s, batch=%s, deleted_rels=%s, " + "total_rels=%s, elapsed=%.3fs)", + database, + provider_id, + relationship_batches, + deleted_count, + deleted_relationships, + time.perf_counter() - drop_t0, + ) + + # Phase 2: delete the now relationship-free nodes in batches. + deleted_count = 1 + while deleted_count > 0: + next_batch = node_batches + 1 + logger.info( + "Deleting node batch from Neo4j sink database %s " + "(provider=%s, batch=%s, total_nodes=%s, elapsed=%.3fs)", + database, + provider_id, + next_batch, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + result = session.run( + f""" + MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) + WITH n LIMIT $batch_size + DELETE n + RETURN COUNT(n) AS deleted_nodes_count + """, + {"batch_size": BATCH_SIZE}, + ) + deleted_count = result.single().get("deleted_nodes_count", 0) + if deleted_count > 0: + node_batches += 1 + deleted_nodes += deleted_count + logger.info( + "Deleted node batch from Neo4j sink database %s " + "(provider=%s, batch=%s, deleted_nodes=%s, " + "total_nodes=%s, elapsed=%.3fs)", + database, + provider_id, + node_batches, + deleted_count, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + + except GraphDatabaseQueryException as exc: + if exc.code == DATABASE_NOT_FOUND_CODE: + logger.info( + "Skipped provider graph drop from Neo4j sink database %s " + "(provider=%s, reason=database_not_found, elapsed=%.3fs)", + database, + provider_id, + time.perf_counter() - drop_t0, + ) + return 0 + raise + + logger.info( + "Finished dropping provider graph from Neo4j sink database %s " + "(provider=%s, relationship_batches=%s, deleted_rels=%s, " + "node_batches=%s, deleted_nodes=%s, elapsed=%.3fs)", + database, + provider_id, + relationship_batches, + deleted_relationships, + node_batches, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + return deleted_nodes + + def has_provider_data(self, database: str, provider_id: str) -> bool: + from api.attack_paths.database import GraphDatabaseQueryException + from tasks.jobs.attack_paths.config import ( + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + query = ( + f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1" + ) + try: + with self.get_session( + database, default_access_mode=neo4j.READ_ACCESS + ) as session: + result = session.run(query) + return result.single() is not None + + except GraphDatabaseQueryException as exc: + if exc.code == DATABASE_NOT_FOUND_CODE: + return False + raise + + def clear_cache(self, database: str) -> None: + from api.attack_paths.database import GraphDatabaseQueryException + + try: + with self.get_session(database) as session: + session.run("CALL db.clearQueryCaches()") + except GraphDatabaseQueryException as exc: + logger.warning( + f"Failed to clear query cache for database `{database}`: {exc}" + ) + + # Sync write path + + def ensure_sync_indexes(self, database: str) -> None: + """Create the `_provider_element_id` lookup index on `_ProviderResource`. + + Every synced node carries the `_ProviderResource` label, so a single + index covers both node-upserts and relationship endpoint MATCHes. + Without this index the rel sync degrades to a label scan per row and + large provider syncs become unworkable. + """ + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + ) + + query = ( + f"CREATE INDEX provider_element_id_idx IF NOT EXISTS " + f"FOR (n:`{PROVIDER_RESOURCE_LABEL}`) " + f"ON (n.`{PROVIDER_ELEMENT_ID_PROPERTY}`)" + ) + with self.get_session(database) as session: + session.run(query).consume() + + def write_nodes( + self, + database: str, + labels: str, + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + ) + + query = f""" + UNWIND $rows AS row + MERGE (n:`{PROVIDER_RESOURCE_LABEL}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}) + SET n:{labels} + SET n += row.props + """ + with self.get_session(database) as session: + session.run(query, {"rows": rows}).consume() + + def write_relationships( + self, + database: str, + rel_type: str, + provider_id: str, + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + query = f""" + UNWIND $rows AS row + MATCH (s:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.start_element_id}}) + MATCH (t:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.end_element_id}}) + MERGE (s)-[r:`{rel_type}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}]->(t) + SET r += row.props + """ + with self.get_session(database) as session: + session.run(query, {"rows": rows}).consume() + + # For compatibility with test harnesses that patch the concrete driver + def get_driver(self) -> neo4j.Driver: + return self._get_driver() + + +# Helper for tests / external callers that want a writer session specifically +def get_read_session( + sink: Neo4jSink, database: str +) -> AbstractContextManager[RetryableSession]: + return sink.get_session(database, default_access_mode=neo4j.READ_ACCESS) diff --git a/api/src/backend/api/attack_paths/sink/neptune.py b/api/src/backend/api/attack_paths/sink/neptune.py new file mode 100644 index 0000000000..ad20d080b8 --- /dev/null +++ b/api/src/backend/api/attack_paths/sink/neptune.py @@ -0,0 +1,524 @@ +"""AWS Neptune sink implementation. + +Dual Bolt drivers: one against the writer endpoint for workers, one against +the reader endpoint for the API read path. If `NEPTUNE_READER_ENDPOINT` is +unset the reader falls back to the writer driver so single-node clusters work. + +Neptune is single-database. The `database` argument on the SinkDatabase +protocol is ignored; tenant / provider isolation is enforced by labels that +the sync step already writes on every node (see tasks/jobs/attack_paths/sync.py). + +SigV4 auth lives at the bottom of this file as `neptune_auth_provider`. The +neo4j driver invokes the returned callable on each token refresh. +""" + +import atexit +import datetime +import json +import logging +import threading +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any +from urllib.parse import urlsplit + +import neo4j +import neo4j.exceptions +from api.attack_paths.retryable_session import RetryableSession +from api.attack_paths.sink.base import SinkDatabase +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.session import Session as BotoSession +from config.env import env +from django.conf import settings +from neo4j.auth_management import AuthManagers, ExpiringAuth + +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +logger = logging.getLogger(__name__) + +SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( + "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 +) +READ_QUERY_TIMEOUT_SECONDS = env.int( + "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 +) +# Neptune serverless cold-start can be >30s; give the driver room +CONN_ACQUISITION_TIMEOUT = env.int("NEPTUNE_CONN_ACQUISITION_TIMEOUT", default=60) +# TCP connect timeout, ordered below the acquisition timeout so an unreachable +# endpoint can't pin a request or the readiness probe longer than this. Kept +# generous: cold-start delays query execution, not the socket connect. +CONNECTION_TIMEOUT = env.int("NEPTUNE_CONNECTION_TIMEOUT", default=10) +# Roll connections hourly so SigV4 rotations and cert refreshes don't strand long-lived pool entries +MAX_CONNECTION_LIFETIME = env.int("NEPTUNE_MAX_CONNECTION_LIFETIME", default=3600) +MAX_CONNECTION_POOL_SIZE = env.int("NEPTUNE_MAX_CONNECTION_POOL_SIZE", default=50) + +READ_EXCEPTION_CODES = [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", +] +CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." + +# Refresh 60s before the 5-minute SigV4 window closes +SIGV4_TOKEN_LIFETIME_MINUTES = 4 + + +class NeptuneSink(SinkDatabase): + """Neptune-backed sink. Single database; isolation is label-based.""" + + def __init__(self) -> None: + self._writer: neo4j.Driver | None = None + self._reader: neo4j.Driver | None = None + self._lock = threading.Lock() + self._atexit_registered = False + + # Config + + def _config(self) -> dict: + return settings.DATABASES["neptune"] + + def _bolt_uri(self, endpoint: str, port: str) -> str: + return f"bolt+s://{endpoint}:{port}" + + def _https_url(self, endpoint: str, port: str) -> str: + return f"https://{endpoint}:{port}" + + def _build_driver(self, endpoint: str) -> neo4j.Driver: + cfg = self._config() + port = cfg["PORT"] + region = cfg["REGION"] + if not endpoint or not region: + raise RuntimeError( + "NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set when " + "ATTACK_PATHS_SINK_DATABASE=neptune" + ) + return neo4j.GraphDatabase.driver( + self._bolt_uri(endpoint, port), + auth=AuthManagers.bearer( + neptune_auth_provider(region, self._https_url(endpoint, port)) + ), + keep_alive=True, + max_connection_lifetime=MAX_CONNECTION_LIFETIME, + connection_timeout=CONNECTION_TIMEOUT, + connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, + max_connection_pool_size=MAX_CONNECTION_POOL_SIZE, + max_transaction_retry_time=0, + ) + + # Lifecycle + + def init(self) -> None: + if self._writer is not None: + return + with self._lock: + if self._writer is None: + cfg = self._config() + writer_endpoint = cfg["WRITER_ENDPOINT"] + reader_endpoint = cfg["READER_ENDPOINT"] or writer_endpoint + + # Eager connectivity checks are best-effort + # A Neptune that is down at boot must not crash the process, same degradation model as Postgres + # Drivers reconnect lazily on first use + # /health/ready surfaces the outage until it recovers + self._writer = self._build_driver(writer_endpoint) + self._verify_best_effort(self._writer, "writer") + + if reader_endpoint == writer_endpoint: + self._reader = self._writer + + else: + self._reader = self._build_driver(reader_endpoint) + self._verify_best_effort(self._reader, "reader") + + if not self._atexit_registered: + atexit.register(self.close) + self._atexit_registered = True + + def close(self) -> None: + with self._lock: + # `Driver.close()` is idempotent, so closing the same driver twice + # (when reader aliases writer on single-endpoint configs) is safe + for driver in (self._reader, self._writer): + if driver is None: + continue + try: + driver.close() + except Exception: # pragma: no cover - best-effort + pass + self._writer = None + self._reader = None + + # Sessions + + def _get_writer(self) -> neo4j.Driver: + self.init() + assert self._writer is not None + return self._writer + + def _get_reader(self) -> neo4j.Driver: + self.init() + assert self._reader is not None + return self._reader + + @staticmethod + def _verify_best_effort(driver: neo4j.Driver, role: str) -> None: + try: + driver.verify_connectivity() + + except Exception: + logger.warning( + "Neptune %s endpoint unreachable at init; continuing with a lazily-reconnecting driver", + role, + exc_info=True, + ) + + def verify_connectivity(self) -> None: + # The API read path uses the reader driver + # On single-endpoint clusters it aliases the writer, so this also covers the writer + # A writer-only outage is a workers' concern (no HTTP probe there) and deliberately does not fail API readiness + self._get_reader().verify_connectivity() + + @contextmanager + def get_session( + self, + database: str | None = None, # noqa: ARG002 - ignored on Neptune + default_access_mode: str | None = None, + ) -> Iterator[RetryableSession]: + from api.attack_paths.database import ( + ClientStatementException, + GraphDatabaseQueryException, + WriteQueryNotAllowedException, + ) + + driver = ( + self._get_reader() + if default_access_mode == neo4j.READ_ACCESS + else self._get_writer() + ) + + session_wrapper: RetryableSession | None = None + try: + session_wrapper = RetryableSession( + session_factory=lambda: driver.session( + default_access_mode=default_access_mode + ), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + if ( + default_access_mode == neo4j.READ_ACCESS + and exc.code + and exc.code in READ_EXCEPTION_CODES + ): + raise WriteQueryNotAllowedException( + message="Read query not allowed", code=READ_EXCEPTION_CODES[0] + ) + + message = exc.message if exc.message is not None else str(exc) + if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX): + raise ClientStatementException(message=message, code=exc.code) + raise GraphDatabaseQueryException(message=message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + # Operations + + def execute_read_query( + self, + database: str, # noqa: ARG002 - ignored on Neptune + cypher: str, + parameters: dict[str, Any] | None = None, + ) -> neo4j.graph.Graph: + with self.get_session(default_access_mode=neo4j.READ_ACCESS) as session: + + def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph: + result = tx.run( + cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS + ) + return result.graph() + + return session.execute_read(_run) + + def create_database(self, database: str) -> None: # noqa: ARG002 + # Neptune clusters are single-database; there is nothing to create. + return None + + def drop_database(self, database: str) -> None: # noqa: ARG002 + # Neptune clusters are single-database; there is nothing to drop. + return None + + def drop_subgraph(self, database: str, provider_id: str) -> int: # noqa: ARG002 + """Delete a provider's subgraph in two bounded phases. + + Neptune write transactions are capped at ~2 minutes. A naive + `DETACH DELETE` on a label-scanned batch grows unbounded with graph + density (one node can drag thousands of relationships into the same + transaction). Instead: + + 1. Delete relationships incident to provider nodes, one fixed-size + batch per transaction. + 2. Delete the now-orphaned nodes, one fixed-size batch per transaction. + + Each transaction does work proportional to `batch_size`, never to the + graph's branching factor. + """ + from tasks.jobs.attack_paths.config import ( + BATCH_SIZE, + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + deleted_relationships = 0 + relationship_batches = 0 + node_batches = 0 + drop_t0 = time.perf_counter() + + logger.info( + "Dropping provider graph from Neptune sink " + "(provider=%s, provider_label=%s)", + provider_id, + provider_label, + ) + + logger.info( + "Opening Neptune writer session for provider graph drop (provider=%s)", + provider_id, + ) + with self.get_session() as session: + logger.info( + "Opened Neptune writer session for provider graph drop (provider=%s)", + provider_id, + ) + while True: + next_batch = relationship_batches + 1 + logger.info( + "Deleting relationship batch from Neptune sink " + "(provider=%s, batch=%s, total_rels=%s, elapsed=%.3fs)", + provider_id, + next_batch, + deleted_relationships, + time.perf_counter() - drop_t0, + ) + result = session.run( + f""" + MATCH (:`{provider_label}`)-[r]-() + WITH DISTINCT r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, + {"batch_size": BATCH_SIZE}, + ) + record = result.single() + deleted_rels = (record["deleted_rels_count"] if record else 0) or 0 + if deleted_rels == 0: + break + relationship_batches += 1 + deleted_relationships += deleted_rels + logger.info( + "Deleted relationship batch from Neptune sink " + "(provider=%s, batch=%s, deleted_rels=%s, total_rels=%s, " + "elapsed=%.3fs)", + provider_id, + relationship_batches, + deleted_rels, + deleted_relationships, + time.perf_counter() - drop_t0, + ) + + deleted_nodes = 0 + while True: + next_batch = node_batches + 1 + logger.info( + "Deleting node batch from Neptune sink " + "(provider=%s, batch=%s, total_nodes=%s, elapsed=%.3fs)", + provider_id, + next_batch, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + result = session.run( + f""" + MATCH (n:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}`) + WITH n LIMIT $batch_size + DELETE n + RETURN COUNT(n) AS deleted_nodes_count + """, + {"batch_size": BATCH_SIZE}, + ) + record = result.single() + deleted = (record["deleted_nodes_count"] if record else 0) or 0 + if deleted == 0: + break + node_batches += 1 + deleted_nodes += deleted + logger.info( + "Deleted node batch from Neptune sink " + "(provider=%s, batch=%s, deleted_nodes=%s, total_nodes=%s, " + "elapsed=%.3fs)", + provider_id, + node_batches, + deleted, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + + logger.info( + "Finished dropping provider graph from Neptune sink " + "(provider=%s, relationship_batches=%s, deleted_rels=%s, " + "node_batches=%s, deleted_nodes=%s, elapsed=%.3fs)", + provider_id, + relationship_batches, + deleted_relationships, + node_batches, + deleted_nodes, + time.perf_counter() - drop_t0, + ) + return deleted_nodes + + def has_provider_data(self, database: str, provider_id: str) -> bool: # noqa: ARG002 + from tasks.jobs.attack_paths.config import ( + PROVIDER_RESOURCE_LABEL, + get_provider_label, + ) + + provider_label = get_provider_label(provider_id) + query = ( + f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1" + ) + with self.get_session(default_access_mode=neo4j.READ_ACCESS) as session: + result = session.run(query) + return result.single() is not None + + def clear_cache(self, database: str) -> None: # noqa: ARG002 + # Neptune has no user-facing cache-clear procedure; no-op. + return None + + # Sync write path + + def ensure_sync_indexes(self, database: str) -> None: # noqa: ARG002 + # Neptune routes node and relationship lookups through `~id`, which is the cluster's primary key + # No additional index is needed or supported + return None + + def write_nodes( + self, + database: str, # noqa: ARG002 + labels: str, + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + PROVIDER_RESOURCE_LABEL, + ) + + # MERGE on `~id` is the documented and engine-optimized idempotent + # upsert pattern for Neptune openCypher. The label inside the MERGE + # matters: Neptune assigns a default `vertex` label to any node + # created without an explicit one, so we pin `_ProviderResource` + # (which every synced node carries anyway) at MERGE-time. Additional + # labels are added after + # + # We also write `_provider_element_id` as a regular property so + # non-sync code (drop_subgraph, query helpers) keeps a stable contract + # that doesn't know about `~id` + query = f""" + UNWIND $rows AS row + MERGE (n:`{PROVIDER_RESOURCE_LABEL}` {{`~id`: row.provider_element_id}}) + SET n:{labels} + SET n += row.props + SET n.`{PROVIDER_ELEMENT_ID_PROPERTY}` = row.provider_element_id + """ + with self.get_session() as session: + session.run(query, {"rows": rows}).consume() + + def write_relationships( + self, + database: str, # noqa: ARG002 + rel_type: str, + provider_id: str, # noqa: ARG002 - encoded in start/end `~id` already + rows: list[dict[str, Any]], + ) -> None: + if not rows: + return + from tasks.jobs.attack_paths.config import PROVIDER_ELEMENT_ID_PROPERTY + + # `id(n) = $value` is Neptune's parameterized fast path; both endpoint + # MATCHes resolve in O(1) via the system `~id`, so per-row work stays + # bounded regardless of batch size + query = f""" + UNWIND $rows AS row + MATCH (s) WHERE id(s) = row.start_element_id + MATCH (e) WHERE id(e) = row.end_element_id + MERGE (s)-[r:`{rel_type}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}]->(e) + SET r += row.props + """ + with self.get_session() as session: + session.run(query, {"rows": rows}).consume() + + # Test helpers + + def get_writer(self) -> neo4j.Driver: + return self._get_writer() + + def get_reader(self) -> neo4j.Driver: + return self._get_reader() + + +# SigV4 auth provider + + +class _NeptuneAuthToken(neo4j.Auth): + """Neo4j Auth backed by a SigV4-signed GET to `/opencypher`.""" + + def __init__(self, region: str, url: str) -> None: + session = BotoSession() + credentials = session.get_credentials() + if credentials is None: + raise RuntimeError( + "No AWS credentials available for Neptune SigV4 signing. " + "Ensure the boto3 credential chain can resolve." + ) + credentials = credentials.get_frozen_credentials() + + request = AWSRequest(method="GET", url=url + "/opencypher") + # SigV4 canonical Host must carry the real `host:port` + # Neptune runs on a non-default port (8182), so `.hostname` would drop it and break signing + request.headers.add_header("Host", urlsplit(url).netloc) + SigV4Auth(credentials, "neptune-db", region).add_auth(request) + + auth_obj = { + header: request.headers[header] + for header in ( + "Authorization", + "X-Amz-Date", + "X-Amz-Security-Token", + "Host", + ) + if header in request.headers + } + auth_obj["HttpMethod"] = "GET" + + super().__init__("basic", "username", json.dumps(auth_obj)) + + +def neptune_auth_provider(region: str, https_url: str) -> Callable[[], ExpiringAuth]: + """Return a callable the neo4j driver can invoke to refresh credentials.""" + + def _provider() -> ExpiringAuth: + token = _NeptuneAuthToken(region, https_url) + expires_at = ( + datetime.datetime.now(datetime.UTC) + + datetime.timedelta(minutes=SIGV4_TOKEN_LIFETIME_MINUTES) + ).timestamp() + return ExpiringAuth(auth=token, expires_at=expires_at) + + return _provider diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py index 201527885e..d1b351f454 100644 --- a/api/src/backend/api/attack_paths/views_helpers.py +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -1,12 +1,11 @@ import logging - -from typing import Any, Iterable +from collections.abc import Iterable +from typing import Any import neo4j - -from rest_framework.exceptions import APIException, PermissionDenied, ValidationError - -from api.attack_paths import database as graph_database, AttackPathsQueryDefinition +from api.attack_paths import AttackPathsQueryDefinition +from api.attack_paths import database as graph_database +from api.attack_paths import sink as sink_module from api.attack_paths.cypher_sanitizer import ( inject_provider_label, validate_custom_query, @@ -16,7 +15,10 @@ from api.attack_paths.queries.schema import ( RAW_SCHEMA_URL, get_cartography_schema_query, ) +from api.models import AttackPathsScan from config.custom_logging import BackendLogger +from config.env import env +from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from tasks.jobs.attack_paths.config import ( INTERNAL_LABELS, INTERNAL_PROPERTIES, @@ -27,6 +29,10 @@ from tasks.jobs.attack_paths.config import ( logger = logging.getLogger(BackendLogger.API) +def _custom_query_timeout_ms() -> int: + return env.int("ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30) * 1000 + + # Predefined query helpers @@ -103,13 +109,13 @@ def execute_query( definition: AttackPathsQueryDefinition, parameters: dict[str, Any], provider_id: str, + scan: AttackPathsScan, ) -> dict[str, Any]: try: - graph = graph_database.execute_read_query( - database=database_name, - cypher=definition.cypher, - parameters=parameters, - ) + # TODO: drop after Neptune cutover + # Route reads by the scan row's recorded sink, not by current settings. + backend = sink_module.get_backend_for_scan(scan) + graph = backend.execute_read_query(database_name, definition.cypher, parameters) return _serialize_graph(graph, provider_id) except graph_database.WriteQueryNotAllowedException: @@ -143,22 +149,31 @@ def execute_custom_query( database_name: str, cypher: str, provider_id: str, + scan: AttackPathsScan, ) -> dict[str, Any]: # Defense-in-depth for custom queries: - # 1. neo4j.READ_ACCESS — prevents mutations at the driver level - # 2. inject_provider_label() — regex-based label injection scopes node patterns - # 3. _serialize_graph() — post-query filter drops nodes without the provider label + # 1. `neo4j.READ_ACCESS` — prevents mutations at the driver level + # 2. `inject_provider_label()` — regex-based label injection scopes node patterns + # 3. `_serialize_graph()` — post-query filter drops nodes without the provider label + # 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune — server-side runaway cutoff # # Layer 2 is best-effort (regex can't fully parse Cypher); # layer 3 is the safety net that guarantees provider isolation. validate_custom_query(cypher) cypher = inject_provider_label(cypher, provider_id) + # TODO: drop after Neptune cutover + backend = sink_module.get_backend_for_scan(scan) + + # Neptune enforces a cluster-level query timeout; prepending the hint + # makes the limit explicit and matches the client-side read timeout. + # Applies only when the scan's graph lives in Neptune. + if getattr(scan, "sink_backend", None) == "neptune": + timeout_ms = _custom_query_timeout_ms() + cypher = f"USING QUERY:TIMEOUTMILLISECONDS {timeout_ms}\n{cypher}" + try: - graph = graph_database.execute_read_query( - database=database_name, - cypher=cypher, - ) + graph = backend.execute_read_query(database_name, cypher, None) serialized = _serialize_graph(graph, provider_id) return _truncate_graph(serialized) @@ -181,10 +196,11 @@ def execute_custom_query( def get_cartography_schema( - database_name: str, provider_id: str + database_name: str, provider_id: str, scan: AttackPathsScan ) -> dict[str, str] | None: try: - with graph_database.get_session( + backend = sink_module.get_backend_for_scan(scan) + with backend.get_session( database_name, default_access_mode=neo4j.READ_ACCESS ) as session: result = session.run(get_cartography_schema_query(provider_id)) diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py index 499e290bb7..755bd64e39 100644 --- a/api/src/backend/api/authentication.py +++ b/api/src/backend/api/authentication.py @@ -1,18 +1,19 @@ -from typing import Optional, Tuple +from math import isfinite from uuid import UUID +from api.db_router import MainRouter +from api.models import TenantAPIKey, TenantAPIKeyManager from cryptography.fernet import InvalidToken +from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth from drf_simple_apikey.crypto import get_crypto +from drf_simple_apikey.settings import package_settings from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed from rest_framework.request import Request from rest_framework_simplejwt.authentication import JWTAuthentication -from api.db_router import MainRouter -from api.models import TenantAPIKey, TenantAPIKeyManager - class TenantAPIKeyAuthentication(BaseAPIKeyAuth): model = TenantAPIKey @@ -23,18 +24,49 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): def _authenticate_credentials(self, request, key): """ Override to use admin connection, bypassing RLS during authentication. - Delegates to parent after temporarily routing model queries to admin DB. """ - # Temporarily point the model's manager to admin database - original_objects = self.model.objects - self.model.objects = self.model.objects.using(MainRouter.admin_db) + try: + payload = self.key_crypto.decrypt(key) + except ValueError: + raise AuthenticationFailed("Invalid API Key.") + + if not isinstance(payload, dict): + raise AuthenticationFailed("Invalid API Key.") + + payload_pk = payload.get("_pk") + payload_exp = payload.get("_exp") + if ( + not isinstance(payload_pk, str) + or isinstance(payload_exp, bool) + or not isinstance(payload_exp, (int, float)) + or not isfinite(payload_exp) + ): + raise AuthenticationFailed("Invalid API Key.") try: - # Call parent method which will now use admin database - return super()._authenticate_credentials(request, key) - finally: - # Restore original manager - self.model.objects = original_objects + api_key_pk = UUID(payload_pk) + except ValueError: + raise AuthenticationFailed("Invalid API Key.") + + if payload_exp < timezone.now().timestamp(): + raise AuthenticationFailed("API Key has already expired.") + + try: + api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk) + except ObjectDoesNotExist: + raise AuthenticationFailed("No entity matching this api key.") + + if api_key.revoked: + raise AuthenticationFailed("This API Key has been revoked.") + + client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER) + if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips: + raise AuthenticationFailed("Access denied from blacklisted IP.") + + if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips: + raise AuthenticationFailed("Access restricted to specific IP addresses.") + + return api_key.entity, key def authenticate(self, request: Request): prefixed_key = self.get_key(request) @@ -81,7 +113,7 @@ class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication): jwt_auth = JWTAuthentication() api_key_auth = TenantAPIKeyAuthentication() - def authenticate(self, request: Request) -> Optional[Tuple[object, dict]]: + def authenticate(self, request: Request) -> tuple[object, dict] | None: auth_header = request.headers.get("Authorization", "") # Prioritize JWT authentication if both are present @@ -93,3 +125,30 @@ class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication): # Default fallback return self.jwt_auth.authenticate(request) + + +class SSEAuthentication(CombinedJWTOrAPIKeyAuthentication): + """JWT/API-Key auth that also accepts `?access_token=`. + + Browser `EventSource` is the only widely available SSE client API + and it cannot set the `Authorization` header (its constructor takes + only a URL and `withCredentials`). To keep browser SSE clients on + the same auth stack as the rest of the API, SSE endpoints additionally + accept a JWT via the `?access_token=` query parameter — the + standard parameter name defined in RFC 6750 Section 2.3 for bearer tokens. + """ + + def authenticate(self, request: Request): + auth_header = request.headers.get("Authorization", "") + if auth_header: + return super().authenticate(request) + + raw_token = request.query_params.get("access_token") + if not raw_token: + # No header and no query token — let the default path raise + # the canonical AuthenticationFailed via the parent class. + return super().authenticate(request) + + validated_token = self.jwt_auth.get_validated_token(raw_token) + user = self.jwt_auth.get_user(validated_token) + return user, validated_token diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index b14cc39529..e8dd728cb9 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -1,3 +1,9 @@ +from api.authentication import CombinedJWTOrAPIKeyAuthentication +from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias +from api.db_utils import POSTGRES_USER_VAR, rls_transaction +from api.filters import CustomDjangoFilterBackend +from api.models import Role, UserRoleRelationship +from api.rbac.permissions import HasPermissions from django.conf import settings from django.db import transaction from rest_framework import permissions @@ -8,13 +14,6 @@ from rest_framework.response import Response from rest_framework_json_api import filters from rest_framework_json_api.views import ModelViewSet -from api.authentication import CombinedJWTOrAPIKeyAuthentication -from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias -from api.db_utils import POSTGRES_USER_VAR, rls_transaction -from api.filters import CustomDjangoFilterBackend -from api.models import Role, UserRoleRelationship -from api.rbac.permissions import HasPermissions - class BaseViewSet(ModelViewSet): authentication_classes = [CombinedJWTOrAPIKeyAuthentication] diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 1705ed2e8f..854c7d8ba6 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,12 +1,26 @@ +import logging +import threading 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.compliance_models import ( + get_bulk_compliance_frameworks_universal, +) from prowler.lib.check.models import CheckMetadata +logger = logging.getLogger(__name__) + AVAILABLE_COMPLIANCE_FRAMEWORKS = {} +# Per-process readiness flags for the background compliance warm-up. +# `STARTED` is set as soon as warming begins (only happens under Gunicorn via +# the post_fork hook); `WARMED` is set when it finishes. The attributes +# endpoint checks both: it returns 503 only while warming is in progress. +# Under `runserver` warming never runs, so `STARTED` stays clear and the +# endpoint keeps lazy-loading as before. +COMPLIANCE_WARMING_STARTED = threading.Event() +COMPLIANCE_WARMED = threading.Event() + class LazyComplianceTemplate(Mapping): """Lazy-load compliance templates per provider on first access.""" @@ -95,25 +109,22 @@ 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 framework identifiers available 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. + Includes both per-provider frameworks and universal top-level frameworks + (e.g. ``dora_2022_2554``, ``csa_ccm_4.0``). Args: - provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve - available compliance frameworks (e.g., "aws", "azure", "gcp", "m365"). + provider_type (Provider.ProviderChoices): The cloud provider type + (e.g., "aws", "azure", "gcp", "m365"). Returns: - list[str]: A list of framework identifiers (e.g., "cis_1.4_aws", "mitre_attack_azure") available - for the given provider. + list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora_2022_2554"). """ 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( + get_bulk_compliance_frameworks_universal(provider_type).keys() ) return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] @@ -140,18 +151,14 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> """ Retrieve the Prowler compliance data for a specified provider type. - This function fetches the compliance frameworks and their associated - requirements for the given cloud provider. - Args: provider_type (Provider.ProviderChoices): The provider type (e.g., 'aws', 'azure') for which to retrieve compliance data. Returns: - dict: A dictionary mapping compliance framework names to their respective - Compliance objects for the specified provider. + dict: Mapping of framework name to `ComplianceFramework` for the provider. """ - return Compliance.get_bulk(provider_type) + return get_bulk_compliance_frameworks_universal(provider_type) def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]: @@ -180,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None: PROWLER_CHECKS._cache[provider_type] = checks +def warm_compliance_caches( + provider_types: Iterable[str] | None = None, +) -> list[str]: + """ + Eagerly populate the per-process compliance caches at server startup. + + Moves the cold-cache catalog load off the request thread so the first + request does not trip the Gunicorn worker timeout. Reads only on-disk + metadata (no database access). Each provider is warmed in isolation; + failures are logged and fall back to lazy loading. + + Args: + provider_types (Iterable[str] | None): Subset to warm. Defaults to all. + + Returns: + list[str]: Provider types that could not be warmed. + """ + if provider_types is None: + provider_types = Provider.ProviderChoices.values + provider_types = list(provider_types) + + COMPLIANCE_WARMING_STARTED.set() + logger.info("Compliance cache warm-up started for providers: %s", provider_types) + + failed = [] + for provider_type in provider_types: + try: + get_compliance_frameworks(provider_type) + _ensure_provider_loaded(provider_type) + # Prowler check loading may sys.exit (SystemExit, not Exception). + except (Exception, SystemExit): + logger.warning( + "Failed to warm compliance caches for provider '%s'; " + "loading lazily on first request", + provider_type, + exc_info=True, + ) + failed.append(provider_type) + + # Mark as warmed even when some providers failed: a failed provider falls + # back to a single-provider lazy load, which stays under the worker timeout. + COMPLIANCE_WARMED.set() + logger.info( + "Compliance cache warm-up finished (providers warmed: %d, failed: %s)", + len(provider_types) - len(failed), + failed, + ) + return failed + + def load_prowler_checks( prowler_compliance, provider_types: Iterable[str] | None = None ): @@ -210,8 +267,8 @@ def load_prowler_checks( for compliance_name, compliance_data in prowler_compliance.get( provider_type, {} ).items(): - for requirement in compliance_data.Requirements: - for check in requirement.Checks: + for requirement in compliance_data.requirements: + for check in requirement.checks.get(provider_type, []): try: checks[provider_type][check].add(compliance_name) except KeyError: @@ -291,24 +348,40 @@ def generate_compliance_overview_template( requirements_status = {"passed": 0, "failed": 0, "manual": 0} total_requirements = 0 - for requirement in compliance_data.Requirements: + for requirement in compliance_data.requirements: total_requirements += 1 - total_checks = len(requirement.Checks) - checks_dict = {check: None for check in requirement.Checks} + provider_check_list = list(requirement.checks.get(provider_type, [])) + total_checks = len(provider_check_list) + checks_dict = dict.fromkeys(provider_check_list) req_status_val = "MANUAL" if total_checks == 0 else "PASS" + # MITRE attrs are wrapped under `_raw_attributes` by the + # universal adapter — unwrap so consumers see the flat list. + requirement_attributes = requirement.attributes + if ( + isinstance(requirement_attributes, dict) + and "_raw_attributes" in requirement_attributes + ): + attributes_payload = list(requirement_attributes["_raw_attributes"]) + elif isinstance(requirement_attributes, dict): + attributes_payload = ( + [dict(requirement_attributes)] if requirement_attributes else [] + ) + else: + attributes_payload = [ + dict(attribute) for attribute in requirement_attributes + ] + # Build requirement dictionary requirement_dict = { - "name": requirement.Name or requirement.Id, - "description": requirement.Description, - "tactics": getattr(requirement, "Tactics", []), - "subtechniques": getattr(requirement, "SubTechniques", []), - "platforms": getattr(requirement, "Platforms", []), - "technique_url": getattr(requirement, "TechniqueURL", ""), - "attributes": [ - dict(attribute) for attribute in requirement.Attributes - ], + "name": requirement.name or requirement.id, + "description": requirement.description, + "tactics": requirement.tactics or [], + "subtechniques": requirement.sub_techniques or [], + "platforms": requirement.platforms or [], + "technique_url": requirement.technique_url or "", + "attributes": attributes_payload, "checks": checks_dict, "checks_status": { "pass": 0, @@ -326,15 +399,15 @@ def generate_compliance_overview_template( requirements_status["passed"] += 1 # Add requirement to compliance requirements - compliance_requirements[requirement.Id] = requirement_dict + compliance_requirements[requirement.id] = requirement_dict # Build compliance dictionary compliance_dict = { - "framework": compliance_data.Framework, - "name": compliance_data.Name, - "version": compliance_data.Version, + "framework": compliance_data.framework, + "name": compliance_data.name, + "version": compliance_data.version, "provider": provider_type, - "description": compliance_data.Description, + "description": compliance_data.description, "requirements": compliance_requirements, "requirements_status": requirements_status, "total_requirements": total_requirements, diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index e3b11d7084..2c378f2ea8 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -3,8 +3,14 @@ import secrets import time import uuid from contextlib import contextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta +from api.db_router import ( + READ_REPLICA_ALIAS, + get_read_db_alias, + reset_read_db_alias, + set_read_db_alias, +) from celery.utils.log import get_task_logger from config.env import env from django.conf import settings @@ -22,13 +28,6 @@ from psycopg2 import sql as psycopg2_sql from psycopg2.extensions import AsIs, new_type, register_adapter, register_type from rest_framework_json_api.serializers import ValidationError -from api.db_router import ( - READ_REPLICA_ALIAS, - get_read_db_alias, - reset_read_db_alias, - set_read_db_alias, -) - logger = get_task_logger(__name__) DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test" @@ -170,7 +169,7 @@ def one_week_from_now(): """ Return a datetime object with a date one week from now. """ - return datetime.now(timezone.utc) + timedelta(days=7) + return datetime.now(UTC) + timedelta(days=7) def generate_random_token(length: int = 14, symbols: str | None = None) -> str: @@ -405,10 +404,10 @@ def _should_create_index_on_partition( # Unknown month abbreviation, include it to be safe return True - partition_date = datetime(year, month, 1, tzinfo=timezone.utc) + partition_date = datetime(year, month, 1, tzinfo=UTC) # Get current month start - now = datetime.now(timezone.utc) + now = datetime.now(UTC) current_month_start = now.replace( day=1, hour=0, minute=0, second=0, microsecond=0 ) diff --git a/api/src/backend/api/decorators.py b/api/src/backend/api/decorators.py index f9b165ef20..a055b2252f 100644 --- a/api/src/backend/api/decorators.py +++ b/api/src/backend/api/decorators.py @@ -1,14 +1,13 @@ import uuid from functools import wraps -from django.core.exceptions import ObjectDoesNotExist -from django.db import DatabaseError, connection, transaction -from rest_framework_json_api.serializers import ValidationError - from api.db_router import READ_REPLICA_ALIAS from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction from api.exceptions import ProviderDeletedException from api.models import Provider, Scan +from django.core.exceptions import ObjectDoesNotExist +from django.db import DatabaseError, connection, transaction +from rest_framework_json_api.serializers import ValidationError def set_tenant(func=None, *, keep_tenant=False): diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 78f8c64c7d..4f6f26c2ea 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException): ) +class ComplianceWarmingError(APIException): + """Compliance catalog is still warming (503 Service Unavailable). + + Returned by the compliance attributes endpoint while the per-process + catalog warm-up is in progress, so the request thread never triggers the + slow cold load that would trip the Gunicorn worker timeout. + """ + + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = ( + "Compliance data is still loading. Please try again in a few seconds." + ) + default_code = "compliance_warming" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + class UpstreamInternalError(APIException): """Unexpected error communicating with provider (500 Internal Server Error). diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index a496a0bf67..740556329c 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -1,19 +1,4 @@ -from datetime import date, datetime, timedelta, timezone - -from dateutil.parser import parse -from django.conf import settings -from django.db.models import F, Q -from django_filters.rest_framework import ( - BaseInFilter, - BooleanFilter, - CharFilter, - ChoiceFilter, - DateFilter, - FilterSet, - UUIDFilter, -) -from rest_framework_json_api.django_filters.backends import DjangoFilterBackend -from rest_framework_json_api.serializers import ValidationError +from datetime import UTC, date, datetime, timedelta from api.constants import SEVERITY_ORDER from api.db_utils import ( @@ -68,6 +53,20 @@ from api.uuid_utils import ( uuid7_start, ) from api.v1.serializers import TaskBase +from dateutil.parser import parse +from django.conf import settings +from django.db.models import F, Q +from django_filters.rest_framework import ( + BaseInFilter, + BooleanFilter, + CharFilter, + ChoiceFilter, + DateFilter, + FilterSet, + UUIDFilter, +) +from rest_framework_json_api.django_filters.backends import DjangoFilterBackend +from rest_framework_json_api.serializers import ValidationError class CustomDjangoFilterBackend(DjangoFilterBackend): @@ -102,7 +101,7 @@ class BaseProviderFilter(FilterSet): """ Abstract base filter for models with direct FK to Provider. - Provides standard provider_id and provider_type filters. + Provides standard provider_id, provider_type, and provider_groups filters. Subclasses must define Meta.model. """ @@ -116,6 +115,16 @@ class BaseProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: abstract = True @@ -126,7 +135,7 @@ class BaseScanProviderFilter(FilterSet): """ Abstract base filter for models with FK to Scan (and Scan has FK to Provider). - Provides standard provider_id and provider_type filters via scan relationship. + Provides standard provider_id, provider_type, and provider_groups filters via scan relationship. Subclasses must define Meta.model. """ @@ -140,6 +149,16 @@ class BaseScanProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: abstract = True @@ -160,6 +179,16 @@ class CommonFindingFilters(FilterSet): provider_type__in = ChoiceInFilter( choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider" ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) provider_uid = CharFilter(field_name="scan__provider__uid", lookup_expr="exact") provider_uid__in = CharInFilter(field_name="scan__provider__uid", lookup_expr="in") provider_uid__icontains = CharFilter( @@ -330,6 +359,7 @@ class MembershipFilter(FilterSet): model = Membership fields = { "tenant": ["exact"], + "user": ["exact"], "role": ["exact"], "date_joined": ["date", "gte", "lte"], } @@ -369,6 +399,12 @@ class ProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider_groups__id", lookup_expr="exact", distinct=True + ) + provider_groups__in = UUIDInFilter( + field_name="provider_groups__id", lookup_expr="in", distinct=True + ) class Meta: model = Provider @@ -394,6 +430,16 @@ class ProviderRelationshipFilterSet(FilterSet): provider_type__in = ChoiceInFilter( choices=Provider.ProviderChoices.choices, field_name="provider__provider" ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) provider_uid = CharFilter(field_name="provider__uid", lookup_expr="exact") provider_uid__in = CharInFilter(field_name="provider__uid", lookup_expr="in") provider_uid__icontains = CharFilter( @@ -551,12 +597,12 @@ class ResourceFilter(ProviderRelationshipFilterSet): gte_date = ( parse(self.data.get("updated_at__gte")).date() if self.data.get("updated_at__gte") - else datetime.now(timezone.utc).date() + else datetime.now(UTC).date() ) lte_date = ( parse(self.data.get("updated_at__lte")).date() if self.data.get("updated_at__lte") - else datetime.now(timezone.utc).date() + else datetime.now(UTC).date() ) if abs(lte_date - gte_date) > timedelta( @@ -701,9 +747,9 @@ class FindingFilter(CommonFindingFilters): lte_date = cleaned.get("inserted_at__lte") or exact_date if gte_date is None: - gte_date = datetime.now(timezone.utc).date() + gte_date = datetime.now(UTC).date() if lte_date is None: - lte_date = datetime.now(timezone.utc).date() + lte_date = datetime.now(UTC).date() if abs(lte_date - gte_date) > timedelta( days=settings.FINDINGS_MAX_DAYS_IN_RANGE @@ -797,7 +843,7 @@ class FindingFilter(CommonFindingFilters): def maybe_date_to_datetime(value): dt = value if isinstance(value, date): - dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc) + dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC) return dt @@ -886,9 +932,9 @@ class FindingGroupFilter(CommonFindingFilters): lte_date = cleaned.get("inserted_at__lte") or exact_date if gte_date is None: - gte_date = datetime.now(timezone.utc).date() + gte_date = datetime.now(UTC).date() if lte_date is None: - lte_date = datetime.now(timezone.utc).date() + lte_date = datetime.now(UTC).date() if abs(lte_date - gte_date) > timedelta( days=settings.FINDINGS_MAX_DAYS_IN_RANGE @@ -930,7 +976,7 @@ class FindingGroupFilter(CommonFindingFilters): """Convert date to datetime if needed.""" dt = value if isinstance(value, date): - dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc) + dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC) return dt @@ -1000,6 +1046,16 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): field_name="provider__provider", choices=Provider.ProviderChoices.choices ) provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in") + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: model = FindingGroupDailySummary @@ -1034,9 +1090,9 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): lte_date = cleaned.get("inserted_at__lte") or exact_date if gte_date is None: - gte_date = datetime.now(timezone.utc).date() + gte_date = datetime.now(UTC).date() if lte_date is None: - lte_date = datetime.now(timezone.utc).date() + lte_date = datetime.now(UTC).date() if abs(lte_date - gte_date) > timedelta( days=settings.FINDINGS_MAX_DAYS_IN_RANGE @@ -1075,7 +1131,7 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): def _maybe_date_to_datetime(value): dt = value if isinstance(value, date): - dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc) + dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC) return dt @@ -1100,6 +1156,16 @@ class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): field_name="provider__provider", choices=Provider.ProviderChoices.choices ) provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in") + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: model = FindingGroupDailySummary @@ -1115,13 +1181,14 @@ class FindingGroupAggregatedComputedFilter(FilterSet): STATUS_CHOICES = ( ("FAIL", "Fail"), ("PASS", "Pass"), - ("MUTED", "Muted"), + ("MANUAL", "Manual"), ) status = ChoiceFilter(method="filter_status", choices=STATUS_CHOICES) status__in = CharInFilter(method="filter_status_in", lookup_expr="in") severity = ChoiceFilter(method="filter_severity", choices=SeverityChoices) severity__in = CharInFilter(method="filter_severity_in", lookup_expr="in") + muted = BooleanFilter(field_name="muted") include_muted = BooleanFilter(method="filter_include_muted") def filter_status(self, queryset, name, value): @@ -1198,7 +1265,7 @@ class FindingGroupAggregatedComputedFilter(FilterSet): if value is True: return queryset # include_muted=false: exclude fully-muted groups - return queryset.exclude(fail_count=0, pass_count=0, muted_count__gt=0) + return queryset.exclude(muted=True) class ProviderSecretFilter(FilterSet): @@ -1278,12 +1345,19 @@ class RoleFilter(FilterSet): } -class ComplianceOverviewFilter(FilterSet): +class ComplianceOverviewFilter(BaseScanProviderFilter): + """ + Keep provider filters in the schema while runtime filtering resolves scans first. + + Compliance overview provider filters are applied to the latest completed scans + in the viewset, then this filterset handles the remaining compliance fields. + """ + inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - scan_id = UUIDFilter(field_name="scan_id", required=True) + scan_id = UUIDFilter(field_name="scan_id") region = CharFilter(field_name="region") - class Meta: + class Meta(BaseScanProviderFilter.Meta): model = ComplianceRequirementOverview fields = { "inserted_at": ["date", "gte", "lte"], @@ -1304,6 +1378,16 @@ class ScanSummaryFilter(FilterSet): provider_type__in = ChoiceInFilter( field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) region = CharFilter(field_name="region") class Meta: @@ -1327,6 +1411,16 @@ class DailySeveritySummaryFilter(FilterSet): provider_type__in = ChoiceInFilter( field_name="provider__provider", choices=Provider.ProviderChoices.choices ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) date_from = DateFilter(method="filter_noop") date_to = DateFilter(method="filter_noop") @@ -1583,6 +1677,16 @@ class ThreatScoreSnapshotFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact") compliance_id__in = CharInFilter(field_name="compliance_id", lookup_expr="in") @@ -1626,6 +1730,16 @@ class ResourceGroupOverviewFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) resource_group = CharFilter(field_name="resource_group", lookup_expr="exact") resource_group__in = CharInFilter(field_name="resource_group", lookup_expr="in") diff --git a/api/src/backend/api/health.py b/api/src/backend/api/health.py new file mode 100644 index 0000000000..cca3bcef72 --- /dev/null +++ b/api/src/backend/api/health.py @@ -0,0 +1,289 @@ +"""Liveness and readiness endpoints following the IETF Health Check Response +Format (draft-inadarei-api-health-check-06). + +Liveness reports only process status. Readiness verifies that PostgreSQL, +Valkey and the attack-paths graph store (Neo4j or Neptune, per +``ATTACK_PATHS_SINK_DATABASE``) are reachable and returns per-dependency +detail when any of them is unreachable. +""" + +from __future__ import annotations + +import logging +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeoutError +from contextlib import suppress +from datetime import UTC, datetime +from typing import Any + +import redis +from config.version import API_VERSION, RELEASE_ID +from django.conf import settings +from django.db import connections +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.renderers import JSONRenderer +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +logger = logging.getLogger(__name__) + +SERVICE_ID = "prowler-api" +SERVICE_DESCRIPTION = "Prowler API" + +# Status vocabulary from the IETF draft (section 3.1). +STATUS_PASS = "pass" +STATUS_FAIL = "fail" +STATUS_WARN = "warn" + +# Short socket timeout so a stuck Valkey cannot stall the probe. +VALKEY_PROBE_TIMEOUT_SECONDS = 2 + +# Probe-scoped budget for the graph database. +# ``Driver.verify_connectivity()`` takes no timeout; its only bound is the +# driver-level ``connection_acquisition_timeout`` (60s on Neptune). The +# probe needs its own budget, independent of the workload driver, so a +# graph-database outage cannot pin a worker thread (and the readiness lock) +# for a minute. +GRAPH_DB_PROBE_TIMEOUT_SECONDS = 5 + +# Bounded pool that enforces ``GRAPH_DB_PROBE_TIMEOUT_SECONDS``. If the +# graph database is unreachable the probe call blocks until the driver's +# own acquisition timeout fires; we abandon the future after the budget and +# report ``fail``. Orphaned tasks are capped by ``max_workers`` plus the 3s +# readiness cache plus the per-IP throttle, so they cannot pile up: worst +# case during a graph-database outage is every readiness call failing fast +# in ``GRAPH_DB_PROBE_TIMEOUT_SECONDS`` with at most 2 background threads +# stuck for <= the driver acquisition timeout. +_graph_db_probe_executor = ThreadPoolExecutor( + max_workers=2, thread_name_prefix="health-graph-db-probe" +) + +# Brief cache window so high-frequency probes (ALB target groups, scrapers) +# do not stampede the actual dependency checks. +CACHE_CONTROL_HEADER = "max-age=3, must-revalidate" + +# In-process readiness cache. Caps real dependency hits to roughly +# (gunicorn workers / TTL) per second regardless of incoming RPS or the +# source-IP distribution. Kept in sync with the Cache-Control max-age. +# Access is guarded by a lock so concurrent readers do not race on the +# read-decide-write cycle of the double-checked locking pattern below. +READINESS_CACHE_TTL_SECONDS = 3.0 +_readiness_cache: tuple[float, dict[str, Any], int] | None = None +_readiness_cache_lock = threading.Lock() + + +class HealthJSONRenderer(JSONRenderer): + """Emits responses with the ``application/health+json`` content type.""" + + media_type = "application/health+json" + format = "health" + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _measure(name: str, check_fn) -> tuple[dict[str, Any], float]: + """Time ``check_fn`` and return ``(result, elapsed_ms)``. + + ``check_fn`` returns ``None`` on success or raises on failure. The full + exception is logged for operator diagnostics under ``name``; the + response payload intentionally omits the error detail to avoid leaking + infrastructure information (DNS names, ports, credentials, certificate + chains) to anonymous clients. + """ + started = time.perf_counter() + try: + check_fn() + except Exception: + elapsed_ms = (time.perf_counter() - started) * 1000 + logger.warning("Health probe '%s' failed", name, exc_info=True) + return ({"status": STATUS_FAIL}, elapsed_ms) + elapsed_ms = (time.perf_counter() - started) * 1000 + return ({"status": STATUS_PASS}, elapsed_ms) + + +def _probe_postgres() -> None: + with connections["default"].cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + + +def _probe_valkey() -> None: + client = redis.Redis.from_url( + settings.CELERY_BROKER_URL, + socket_connect_timeout=VALKEY_PROBE_TIMEOUT_SECONDS, + socket_timeout=VALKEY_PROBE_TIMEOUT_SECONDS, + ) + try: + if not client.ping(): + raise RuntimeError("PING did not return PONG") + finally: + # Best-effort cleanup: a failure releasing the socket (e.g. broken + # connection, half-closed by the server) must not mask the probe + # result. Narrowed to the exception types redis-py and the stdlib + # socket layer can raise on close. + with suppress(redis.RedisError, OSError): + client.close() + + +def _graph_db_component_id() -> str: + """Return the active graph database name for the ``componentId`` field.""" + return settings.ATTACK_PATHS_SINK_DATABASE.strip().lower() + + +def _probe_graph_db() -> None: + # Lazy import: avoids pulling attack_paths into the boot import graph + from api.attack_paths.database import verify_connectivity + + future = _graph_db_probe_executor.submit(verify_connectivity) + try: + future.result(timeout=GRAPH_DB_PROBE_TIMEOUT_SECONDS) + except FuturesTimeoutError as exc: + # Do not wait for the abandoned task; it ends when the driver's own acquisition timeout fires + future.cancel() + raise TimeoutError( + f"graph-db probe exceeded {GRAPH_DB_PROBE_TIMEOUT_SECONDS}s" + ) from exc + + +def _build_check_entry( + component_id: str, + component_type: str, + result: dict[str, Any], + elapsed_ms: float, +) -> dict[str, Any]: + entry: dict[str, Any] = { + "componentId": component_id, + "componentType": component_type, + "observedValue": round(elapsed_ms, 2), + "observedUnit": "ms", + "status": result["status"], + "time": _now_iso(), + } + if "output" in result: + entry["output"] = result["output"] + return entry + + +def _aggregate_status(check_entries: list[dict[str, Any]]) -> str: + statuses = {entry["status"] for entry in check_entries} + if STATUS_FAIL in statuses: + return STATUS_FAIL + if STATUS_WARN in statuses: + return STATUS_WARN + return STATUS_PASS + + +def _base_payload(overall_status: str) -> dict[str, Any]: + return { + "status": overall_status, + "version": API_VERSION, + "releaseId": RELEASE_ID, + "serviceId": SERVICE_ID, + "description": SERVICE_DESCRIPTION, + } + + +def _readiness_payload() -> tuple[dict[str, Any], int]: + global _readiness_cache + + # Lock-free fast path: a stale snapshot still satisfies the freshness + # check correctly because we re-check after acquiring the lock below. + snapshot = _readiness_cache + if ( + snapshot is not None + and time.monotonic() - snapshot[0] < READINESS_CACHE_TTL_SECONDS + ): + return snapshot[1], snapshot[2] + + with _readiness_cache_lock: + # Double-checked locking: another thread may have refreshed while + # we were waiting on the lock. + snapshot = _readiness_cache + if ( + snapshot is not None + and time.monotonic() - snapshot[0] < READINESS_CACHE_TTL_SECONDS + ): + return snapshot[1], snapshot[2] + + graph_db_component_id = _graph_db_component_id() + + postgres_result, postgres_ms = _measure("postgres", _probe_postgres) + valkey_result, valkey_ms = _measure("valkey", _probe_valkey) + graph_db_result, graph_db_ms = _measure(graph_db_component_id, _probe_graph_db) + + entries = [ + _build_check_entry("postgres", "datastore", postgres_result, postgres_ms), + _build_check_entry("valkey", "datastore", valkey_result, valkey_ms), + _build_check_entry( + graph_db_component_id, "datastore", graph_db_result, graph_db_ms + ), + ] + overall = _aggregate_status(entries) + + payload = _base_payload(overall) + payload["checks"] = { + "postgres:responseTime": [entries[0]], + "valkey:responseTime": [entries[1]], + "graphdb:responseTime": [entries[2]], + } + + http_status = ( + status.HTTP_503_SERVICE_UNAVAILABLE + if overall == STATUS_FAIL + else status.HTTP_200_OK + ) + _readiness_cache = (time.monotonic(), payload, http_status) + return payload, http_status + + +def _health_response(payload: dict[str, Any], http_status: int) -> Response: + response = Response(payload, status=http_status) + response["Cache-Control"] = CACHE_CONTROL_HEADER + return response + + +@extend_schema(exclude=True) +class LivenessView(APIView): + """Liveness probe. Always 200 when the process can serve requests. + + Dependencies are intentionally not consulted: a failing liveness probe + triggers a container restart, which must not happen for transient + dependency outages. Throttled per-IP so the endpoint cannot be used as + a cheap availability oracle for the process. + """ + + authentication_classes: list = [] + permission_classes: list = [] + renderer_classes = [HealthJSONRenderer] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "health-live" + + def get(self, _request, *_args, **_kwargs): + return _health_response(_base_payload(STATUS_PASS), status.HTTP_200_OK) + + +@extend_schema(exclude=True) +class ReadinessView(APIView): + """Readiness probe. + + Returns 200 when PostgreSQL, Valkey and the attack-paths graph store + all respond, or 503 with per-dependency detail when any of them is + unreachable. Per-IP throttle plus the short in-process result cache cap + the real dependency hits regardless of inbound traffic shape. + """ + + authentication_classes: list = [] + permission_classes: list = [] + renderer_classes = [HealthJSONRenderer] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "health-ready" + + def get(self, _request, *_args, **_kwargs): + payload, http_status = _readiness_payload() + return _health_response(payload, http_status) diff --git a/api/src/backend/api/management/commands/findings.py b/api/src/backend/api/management/commands/findings.py index e62f8f8e8e..f5348dbfc6 100644 --- a/api/src/backend/api/management/commands/findings.py +++ b/api/src/backend/api/management/commands/findings.py @@ -1,11 +1,8 @@ import random -from datetime import datetime, timezone +from datetime import UTC, datetime from math import ceil from uuid import uuid4 -from django.core.management.base import BaseCommand -from tqdm import tqdm - from api.db_utils import rls_transaction from api.models import ( Finding, @@ -16,7 +13,9 @@ from api.models import ( Scan, StatusChoices, ) +from django.core.management.base import BaseCommand from prowler.lib.check.models import CheckMetadata +from tqdm import tqdm class Command(BaseCommand): @@ -116,7 +115,7 @@ class Command(BaseCommand): trigger="manual", state="executing", progress=0, - started_at=datetime.now(timezone.utc), + started_at=datetime.now(UTC), ) scan_state = "completed" @@ -272,10 +271,8 @@ class Command(BaseCommand): self.stdout.write(self.style.ERROR(f"Failed to populate test data: {e}")) scan_state = "failed" finally: - scan.completed_at = datetime.now(timezone.utc) - scan.duration = int( - (datetime.now(timezone.utc) - scan.started_at).total_seconds() - ) + scan.completed_at = datetime.now(UTC) + scan.duration = int((datetime.now(UTC) - scan.started_at).total_seconds()) scan.progress = 100 scan.state = scan_state scan.unique_resource_count = num_resources diff --git a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py new file mode 100644 index 0000000000..7d84e29dde --- /dev/null +++ b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py @@ -0,0 +1,58 @@ +from django.core.management.base import BaseCommand +from tasks.jobs.orphan_recovery import reconcile_orphans + + +class Command(BaseCommand): + help = ( + "Recover orphaned allowlisted Celery tasks whose worker is gone and mark " + "other stale task results terminal. Single-flight via a Postgres advisory lock." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--grace-minutes", + type=int, + default=2, + help="Skip tasks started within this window (worker may still register).", + ) + parser.add_argument( + "--max-attempts", + type=int, + default=3, + help="Give up re-running a task after this many recovery attempts; it is then left terminal instead of re-enqueued.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Detect and report orphans without revoking or re-enqueuing.", + ) + + def handle(self, *args, **options): + result = reconcile_orphans( + grace_minutes=options["grace_minutes"], + max_attempts=options["max_attempts"], + dry_run=options["dry_run"], + ) + + if not result.get("acquired"): + self.stdout.write("Reconcile skipped: another run holds the lock.") + return + + if result.get("enabled") is False: + message = ( + "Task recovery is disabled (DJANGO_TASK_RECOVERY_ENABLED is off); " + "no orphans were recovered." + ) + if result.get("attack_paths") is not None: + message += " Attack-paths stale cleanup still ran." + self.stdout.write(message) + return + + self.stdout.write( + self.style.SUCCESS( + "Orphan reconcile complete: " + f"recovered={len(result.get('recovered', []))} " + f"failed={len(result.get('failed', []))} " + f"skipped(in-flight)={len(result.get('skipped', []))}" + ) + ) diff --git a/api/src/backend/api/middleware.py b/api/src/backend/api/middleware.py index 63f2fc630b..82ae8dcf67 100644 --- a/api/src/backend/api/middleware.py +++ b/api/src/backend/api/middleware.py @@ -2,6 +2,31 @@ import logging import time from config.custom_logging import BackendLogger +from django.core.handlers.asgi import ASGIRequest +from django.db import connections + + +class CloseDBConnectionsMiddleware: + """ + Close request-scoped DB connections at the end of each ASGI request. + + Under the ASGI worker, connections opened by sync views are not released + by Django's normal request-boundary cleanup, so they accumulate idle until + Postgres runs out of slots. Only ASGI requests are handled; the sync WSGI + test client manages its own connections and must be left alone. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + return self.get_response(request) + finally: + if isinstance(request, ASGIRequest): + for conn in connections.all(initialized_only=True): + if not conn.in_atomic_block: + conn.close_if_unusable_or_obsolete() def extract_auth_info(request) -> dict: diff --git a/api/src/backend/api/migrations/0001_initial.py b/api/src/backend/api/migrations/0001_initial.py index 6288cf4093..d4f931622f 100644 --- a/api/src/backend/api/migrations/0001_initial.py +++ b/api/src/backend/api/migrations/0001_initial.py @@ -1,26 +1,13 @@ import uuid from functools import partial +import api.rls import django.contrib.auth.models import django.contrib.postgres.indexes import django.contrib.postgres.search import django.core.validators import django.db.models.deletion import django.utils.timezone -from django.conf import settings -from django.db import migrations, models -from psqlextra.backend.migrations.operations.add_default_partition import ( - PostgresAddDefaultPartition, -) -from psqlextra.backend.migrations.operations.create_partitioned_model import ( - PostgresCreatePartitionedModel, -) -from psqlextra.manager.manager import PostgresManager -from psqlextra.models.partitioned import PostgresPartitionedModel -from psqlextra.types import PostgresPartitioningMethod -from uuid6 import uuid7 - -import api.rls from api.db_utils import ( DB_PROWLER_PASSWORD, DB_PROWLER_USER, @@ -53,6 +40,18 @@ from api.models import ( StateChoices, StatusChoices, ) +from django.conf import settings +from django.db import migrations, models +from psqlextra.backend.migrations.operations.add_default_partition import ( + PostgresAddDefaultPartition, +) +from psqlextra.backend.migrations.operations.create_partitioned_model import ( + PostgresCreatePartitionedModel, +) +from psqlextra.manager.manager import PostgresManager +from psqlextra.models.partitioned import PostgresPartitionedModel +from psqlextra.types import PostgresPartitioningMethod +from uuid6 import uuid7 DB_NAME = settings.DATABASES["default"]["NAME"] diff --git a/api/src/backend/api/migrations/0002_token_migrations.py b/api/src/backend/api/migrations/0002_token_migrations.py index 754403c62f..c7ba732fa3 100644 --- a/api/src/backend/api/migrations/0002_token_migrations.py +++ b/api/src/backend/api/migrations/0002_token_migrations.py @@ -1,8 +1,7 @@ +from api.db_utils import DB_PROWLER_USER from django.conf import settings from django.db import migrations -from api.db_utils import DB_PROWLER_USER - DB_NAME = settings.DATABASES["default"]["NAME"] diff --git a/api/src/backend/api/migrations/0004_rbac.py b/api/src/backend/api/migrations/0004_rbac.py index 4453b1b588..efac385041 100644 --- a/api/src/backend/api/migrations/0004_rbac.py +++ b/api/src/backend/api/migrations/0004_rbac.py @@ -2,12 +2,11 @@ import uuid +import api.rls import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py b/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py index 0392145063..5ab8978b31 100644 --- a/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py +++ b/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py @@ -1,6 +1,5 @@ -from django.db import migrations - from api.db_router import MainRouter +from django.db import migrations def create_admin_role(apps, schema_editor): diff --git a/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py b/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py index 7f059ea2b8..d6e8bb7adb 100644 --- a/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py +++ b/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py @@ -1,12 +1,11 @@ import json -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import django.db.models.deletion -from django.db import migrations, models -from django_celery_beat.models import PeriodicTask - from api.db_utils import rls_transaction from api.models import Scan, StateChoices +from django.db import migrations, models +from django_celery_beat.models import PeriodicTask def migrate_daily_scheduled_scan_tasks(apps, schema_editor): @@ -17,11 +16,11 @@ def migrate_daily_scheduled_scan_tasks(apps, schema_editor): tenant_id = task_kwargs["tenant_id"] provider_id = task_kwargs["provider_id"] - current_time = datetime.now(timezone.utc) + current_time = datetime.now(UTC) scheduled_time_today = datetime.combine( current_time.date(), daily_scheduled_scan_task.start_time.time(), - tzinfo=timezone.utc, + tzinfo=UTC, ) if current_time < scheduled_time_today: diff --git a/api/src/backend/api/migrations/0013_integrations_enum.py b/api/src/backend/api/migrations/0013_integrations_enum.py index 524ecbbb3d..7f2905b844 100644 --- a/api/src/backend/api/migrations/0013_integrations_enum.py +++ b/api/src/backend/api/migrations/0013_integrations_enum.py @@ -2,10 +2,9 @@ from functools import partial -from django.db import migrations - from api.db_utils import IntegrationTypeEnum, PostgresEnumMigration, register_enum from api.models import Integration +from django.db import migrations IntegrationTypeEnumMigration = PostgresEnumMigration( enum_name="integration_type", diff --git a/api/src/backend/api/migrations/0014_integrations.py b/api/src/backend/api/migrations/0014_integrations.py index 2fb3d76880..1b63c1fc8c 100644 --- a/api/src/backend/api/migrations/0014_integrations.py +++ b/api/src/backend/api/migrations/0014_integrations.py @@ -2,12 +2,11 @@ import uuid -import django.db.models.deletion -from django.db import migrations, models - import api.db_utils import api.rls +import django.db.models.deletion from api.rls import RowLevelSecurityConstraint +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0015_finding_muted.py b/api/src/backend/api/migrations/0015_finding_muted.py index 3cb20f871b..5bc408cb44 100644 --- a/api/src/backend/api/migrations/0015_finding_muted.py +++ b/api/src/backend/api/migrations/0015_finding_muted.py @@ -1,8 +1,7 @@ # Generated by Django 5.1.5 on 2025-03-25 11:29 -from django.db import migrations, models - import api.db_utils +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0017_m365_provider.py b/api/src/backend/api/migrations/0017_m365_provider.py index 62817560c5..7e9face021 100644 --- a/api/src/backend/api/migrations/0017_m365_provider.py +++ b/api/src/backend/api/migrations/0017_m365_provider.py @@ -1,8 +1,7 @@ # Generated by Django 5.1.7 on 2025-04-16 08:47 -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0018_resource_scan_summaries.py b/api/src/backend/api/migrations/0018_resource_scan_summaries.py index e9e2ffbe69..0e402cd7c6 100644 --- a/api/src/backend/api/migrations/0018_resource_scan_summaries.py +++ b/api/src/backend/api/migrations/0018_resource_scan_summaries.py @@ -2,12 +2,11 @@ import uuid +import api.rls import django.db.models.deletion import uuid6 from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py b/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py index eef7e10b99..544d9dee01 100644 --- a/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py +++ b/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py @@ -1,8 +1,7 @@ from functools import partial -from django.db import migrations - from api.db_utils import create_index_on_partitions, drop_index_on_partitions +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py b/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py index d0e237453e..0bec532752 100644 --- a/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py +++ b/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py @@ -1,8 +1,7 @@ from functools import partial -from django.db import migrations - from api.db_utils import create_index_on_partitions, drop_index_on_partitions +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py index 82bbb136a5..831e1137d4 100644 --- a/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py +++ b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py @@ -2,12 +2,11 @@ import uuid -import django.db.models.deletion -from django.db import migrations, models - import api.db_utils import api.rls +import django.db.models.deletion from api.rls import RowLevelSecurityConstraint +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0028_findings_check_index_partitions.py b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py index ad61f3004f..5fc1c9dc84 100644 --- a/api/src/backend/api/migrations/0028_findings_check_index_partitions.py +++ b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py @@ -1,8 +1,7 @@ from functools import partial -from django.db import migrations - from api.db_utils import create_index_on_partitions, drop_index_on_partitions +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0030_lighthouseconfiguration.py b/api/src/backend/api/migrations/0030_lighthouseconfiguration.py index b12b8cac01..f00a7a9ae6 100644 --- a/api/src/backend/api/migrations/0030_lighthouseconfiguration.py +++ b/api/src/backend/api/migrations/0030_lighthouseconfiguration.py @@ -2,12 +2,11 @@ import uuid +import api.rls import django.core.validators import django.db.models.deletion from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0032_saml.py b/api/src/backend/api/migrations/0032_saml.py index f1481e104b..7fe71179d5 100644 --- a/api/src/backend/api/migrations/0032_saml.py +++ b/api/src/backend/api/migrations/0032_saml.py @@ -2,13 +2,12 @@ import uuid +import api.db_utils +import api.rls import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import api.db_utils -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0033_processors_enum.py b/api/src/backend/api/migrations/0033_processors_enum.py index 7dbad72241..8a4fdef08f 100644 --- a/api/src/backend/api/migrations/0033_processors_enum.py +++ b/api/src/backend/api/migrations/0033_processors_enum.py @@ -2,10 +2,9 @@ from functools import partial -from django.db import migrations - from api.db_utils import PostgresEnumMigration, ProcessorTypeEnum, register_enum from api.models import Processor +from django.db import migrations ProcessorTypeEnumMigration = PostgresEnumMigration( enum_name="processor_type", diff --git a/api/src/backend/api/migrations/0034_processors.py b/api/src/backend/api/migrations/0034_processors.py index 3df4eaf53b..00efd0a283 100644 --- a/api/src/backend/api/migrations/0034_processors.py +++ b/api/src/backend/api/migrations/0034_processors.py @@ -2,12 +2,11 @@ import uuid -import django.db.models.deletion -from django.db import migrations, models - import api.db_utils import api.rls +import django.db.models.deletion from api.rls import RowLevelSecurityConstraint +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py b/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py index cd360eb7e4..e346a63bb7 100644 --- a/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py +++ b/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py @@ -1,8 +1,7 @@ from functools import partial -from django.db import migrations - from api.db_utils import create_index_on_partitions, drop_index_on_partitions +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py b/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py index 431d656376..5fd165d26c 100644 --- a/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py +++ b/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py @@ -1,8 +1,7 @@ from functools import partial -from django.db import migrations - from api.db_utils import create_index_on_partitions, drop_index_on_partitions +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0043_github_provider.py b/api/src/backend/api/migrations/0043_github_provider.py index 3607ce4af3..3a54bfbcc6 100644 --- a/api/src/backend/api/migrations/0043_github_provider.py +++ b/api/src/backend/api/migrations/0043_github_provider.py @@ -1,8 +1,7 @@ # Generated by Django 5.1.7 on 2025-07-09 14:44 -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0048_api_key.py b/api/src/backend/api/migrations/0048_api_key.py index c3142ecda1..a66448325b 100644 --- a/api/src/backend/api/migrations/0048_api_key.py +++ b/api/src/backend/api/migrations/0048_api_key.py @@ -2,15 +2,14 @@ import uuid +import api.db_utils +import api.rls import django.core.validators import django.db.models.deletion import drf_simple_apikey.models from django.conf import settings from django.db import migrations, models -import api.db_utils -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py b/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py index 99a9353327..c236f9efea 100644 --- a/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py +++ b/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py @@ -4,15 +4,14 @@ import json import logging import uuid +import api.rls import django.db.models.deletion +from api.db_router import MainRouter from config.custom_logging import BackendLogger from cryptography.fernet import Fernet from django.conf import settings from django.db import migrations, models -import api.rls -from api.db_router import MainRouter - logger = logging.getLogger(BackendLogger.API) diff --git a/api/src/backend/api/migrations/0051_oraclecloud_provider.py b/api/src/backend/api/migrations/0051_oraclecloud_provider.py index 022c022ea6..5688d0a764 100644 --- a/api/src/backend/api/migrations/0051_oraclecloud_provider.py +++ b/api/src/backend/api/migrations/0051_oraclecloud_provider.py @@ -1,8 +1,7 @@ # Generated by Django 5.1.7 on 2025-10-14 00:00 -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0052_mute_rules.py b/api/src/backend/api/migrations/0052_mute_rules.py index 56a3ff516f..358402321b 100644 --- a/api/src/backend/api/migrations/0052_mute_rules.py +++ b/api/src/backend/api/migrations/0052_mute_rules.py @@ -2,14 +2,13 @@ import uuid +import api.rls import django.contrib.postgres.fields import django.core.validators import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0054_iac_provider.py b/api/src/backend/api/migrations/0054_iac_provider.py index 03c29e33b6..05350c251f 100644 --- a/api/src/backend/api/migrations/0054_iac_provider.py +++ b/api/src/backend/api/migrations/0054_iac_provider.py @@ -1,8 +1,7 @@ # Generated by Django 5.1.10 on 2025-09-09 09:25 -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0055_mongodbatlas_provider.py b/api/src/backend/api/migrations/0055_mongodbatlas_provider.py index d250a0bffd..1703decdb4 100644 --- a/api/src/backend/api/migrations/0055_mongodbatlas_provider.py +++ b/api/src/backend/api/migrations/0055_mongodbatlas_provider.py @@ -1,8 +1,7 @@ # Generated by Django 5.1.13 on 2025-11-05 08:37 -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0057_threatscoresnapshot.py b/api/src/backend/api/migrations/0057_threatscoresnapshot.py index ee3530a5b6..171b94c2d5 100644 --- a/api/src/backend/api/migrations/0057_threatscoresnapshot.py +++ b/api/src/backend/api/migrations/0057_threatscoresnapshot.py @@ -2,11 +2,10 @@ import uuid +import api.rls import django.db.models.deletion from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0059_compliance_overview_summary.py b/api/src/backend/api/migrations/0059_compliance_overview_summary.py index d2d57a34ae..06873abef1 100644 --- a/api/src/backend/api/migrations/0059_compliance_overview_summary.py +++ b/api/src/backend/api/migrations/0059_compliance_overview_summary.py @@ -2,11 +2,10 @@ import uuid +import api.rls import django.db.models.deletion from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0060_attack_surface_overview.py b/api/src/backend/api/migrations/0060_attack_surface_overview.py index 8007d49a70..a93cf5b38f 100644 --- a/api/src/backend/api/migrations/0060_attack_surface_overview.py +++ b/api/src/backend/api/migrations/0060_attack_surface_overview.py @@ -2,11 +2,10 @@ import uuid +import api.rls import django.db.models.deletion from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0061_daily_severity_summary.py b/api/src/backend/api/migrations/0061_daily_severity_summary.py index 7e4074cf7f..3aa89133bd 100644 --- a/api/src/backend/api/migrations/0061_daily_severity_summary.py +++ b/api/src/backend/api/migrations/0061_daily_severity_summary.py @@ -2,11 +2,10 @@ import uuid +import api.rls import django.db.models.deletion from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py b/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py index f893ac4305..92e53bc4ad 100644 --- a/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py +++ b/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py @@ -1,10 +1,9 @@ # Generated by Django 5.1.14 on 2025-12-10 -from django.db import migrations -from tasks.tasks import backfill_daily_severity_summaries_task - from api.db_router import MainRouter from api.rls import Tenant +from django.db import migrations +from tasks.tasks import backfill_daily_severity_summaries_task def trigger_backfill_task(apps, schema_editor): diff --git a/api/src/backend/api/migrations/0063_scan_category_summary.py b/api/src/backend/api/migrations/0063_scan_category_summary.py index 6ee67bf4db..25ca790c8d 100644 --- a/api/src/backend/api/migrations/0063_scan_category_summary.py +++ b/api/src/backend/api/migrations/0063_scan_category_summary.py @@ -1,10 +1,9 @@ import uuid -import django.db.models.deletion -from django.db import migrations, models - import api.db_utils import api.rls +import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0065_alibabacloud_provider.py b/api/src/backend/api/migrations/0065_alibabacloud_provider.py index 6ad542b643..d9f4250304 100644 --- a/api/src/backend/api/migrations/0065_alibabacloud_provider.py +++ b/api/src/backend/api/migrations/0065_alibabacloud_provider.py @@ -1,8 +1,7 @@ # Generated by Django migration for Alibaba Cloud provider support -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0066_provider_compliance_score.py b/api/src/backend/api/migrations/0066_provider_compliance_score.py index f9a6483e4f..2649d8fbdf 100644 --- a/api/src/backend/api/migrations/0066_provider_compliance_score.py +++ b/api/src/backend/api/migrations/0066_provider_compliance_score.py @@ -1,10 +1,9 @@ import uuid -import django.db.models.deletion -from django.db import migrations, models - import api.db_utils import api.rls +import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0067_tenant_compliance_summary.py b/api/src/backend/api/migrations/0067_tenant_compliance_summary.py index bd753ca575..92973320bc 100644 --- a/api/src/backend/api/migrations/0067_tenant_compliance_summary.py +++ b/api/src/backend/api/migrations/0067_tenant_compliance_summary.py @@ -1,10 +1,9 @@ import uuid +import api.rls import django.db.models.deletion from django.db import migrations, models -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py index 932a2a6c85..c13ada78e2 100644 --- a/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py +++ b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py @@ -1,10 +1,9 @@ import uuid -import django.db.models.deletion -from django.db import migrations, models - import api.db_utils import api.rls +import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0070_attack_paths_scan.py b/api/src/backend/api/migrations/0070_attack_paths_scan.py index 3e63d3353b..557b04a9ce 100644 --- a/api/src/backend/api/migrations/0070_attack_paths_scan.py +++ b/api/src/backend/api/migrations/0070_attack_paths_scan.py @@ -1,12 +1,10 @@ # Generated by Django 5.1.13 on 2025-11-06 16:20 +import api.rls import django.db.models.deletion - from django.db import migrations, models from uuid6 import uuid7 -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py index 671fdf5ef6..06f04f2734 100644 --- a/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py +++ b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py @@ -1,8 +1,7 @@ from functools import partial -from django.db import migrations - from api.db_utils import create_index_on_partitions, drop_index_on_partitions +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0075_cloudflare_provider.py b/api/src/backend/api/migrations/0075_cloudflare_provider.py index 28fdbdb2a9..dcfffe83c6 100644 --- a/api/src/backend/api/migrations/0075_cloudflare_provider.py +++ b/api/src/backend/api/migrations/0075_cloudflare_provider.py @@ -1,8 +1,7 @@ # Generated by Django migration for Cloudflare provider support -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0076_openstack_provider.py b/api/src/backend/api/migrations/0076_openstack_provider.py index 9cc80707ea..680cc4310a 100644 --- a/api/src/backend/api/migrations/0076_openstack_provider.py +++ b/api/src/backend/api/migrations/0076_openstack_provider.py @@ -1,8 +1,7 @@ # Generated by Django migration for OpenStack provider support -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py b/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py index f780059bd2..542b117a22 100644 --- a/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py +++ b/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py @@ -2,9 +2,8 @@ # on different database connections, causing a deadlock when combined with RunPython # in the same migration. -from django.db import migrations - from api.db_router import MainRouter +from django.db import migrations def backfill_graph_data_ready(apps, schema_editor): diff --git a/api/src/backend/api/migrations/0081_finding_group_daily_summary.py b/api/src/backend/api/migrations/0081_finding_group_daily_summary.py index 31c09c464f..e4685cea5f 100644 --- a/api/src/backend/api/migrations/0081_finding_group_daily_summary.py +++ b/api/src/backend/api/migrations/0081_finding_group_daily_summary.py @@ -2,14 +2,13 @@ import uuid +import api.rls import django.db.models.deletion from django.contrib.postgres.indexes import GinIndex, OpClass from django.db import migrations, models from django.db.models.functions import Upper from django.utils import timezone -import api.rls - class Migration(migrations.Migration): dependencies = [ diff --git a/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py b/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py index 38cc07f43d..ef3e9c49a9 100644 --- a/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py +++ b/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py @@ -1,10 +1,9 @@ # Generated by Django 5.1.14 on 2026-02-02 -from django.db import migrations -from tasks.tasks import backfill_finding_group_summaries_task - from api.db_router import MainRouter from api.rls import Tenant +from django.db import migrations +from tasks.tasks import backfill_finding_group_summaries_task def trigger_backfill_task(apps, schema_editor): diff --git a/api/src/backend/api/migrations/0083_image_provider.py b/api/src/backend/api/migrations/0083_image_provider.py index 936fae2219..6f2b5a9d6b 100644 --- a/api/src/backend/api/migrations/0083_image_provider.py +++ b/api/src/backend/api/migrations/0083_image_provider.py @@ -1,6 +1,5 @@ -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0084_googleworkspace_provider.py b/api/src/backend/api/migrations/0084_googleworkspace_provider.py index eb704bb6b5..e7971e2568 100644 --- a/api/src/backend/api/migrations/0084_googleworkspace_provider.py +++ b/api/src/backend/api/migrations/0084_googleworkspace_provider.py @@ -1,6 +1,5 @@ -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py b/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py index 1c550f283a..2bf7bf2fb4 100644 --- a/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py +++ b/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py @@ -1,6 +1,5 @@ from django.db import migrations - TASK_NAME = "attack-paths-cleanup-stale-scans" INTERVAL_HOURS = 1 diff --git a/api/src/backend/api/migrations/0087_vercel_provider.py b/api/src/backend/api/migrations/0087_vercel_provider.py index 84a07b3194..92063fb6da 100644 --- a/api/src/backend/api/migrations/0087_vercel_provider.py +++ b/api/src/backend/api/migrations/0087_vercel_provider.py @@ -1,6 +1,5 @@ -from django.db import migrations - import api.db_utils +from django.db import migrations class Migration(migrations.Migration): diff --git a/api/src/backend/api/migrations/0088_finding_group_status_muted_fields.py b/api/src/backend/api/migrations/0088_finding_group_status_muted_fields.py new file mode 100644 index 0000000000..ff3b981435 --- /dev/null +++ b/api/src/backend/api/migrations/0088_finding_group_status_muted_fields.py @@ -0,0 +1,95 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0087_vercel_provider"), + ] + + operations = [ + migrations.AddField( + model_name="findinggroupdailysummary", + name="manual_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="pass_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="fail_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="manual_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="muted", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="new_fail_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="new_fail_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="new_pass_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="new_pass_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="new_manual_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="new_manual_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="changed_fail_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="changed_fail_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="changed_pass_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="changed_pass_muted_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="changed_manual_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="findinggroupdailysummary", + name="changed_manual_muted_count", + field=models.IntegerField(default=0), + ), + ] diff --git a/api/src/backend/api/migrations/0089_backfill_finding_group_status_muted.py b/api/src/backend/api/migrations/0089_backfill_finding_group_status_muted.py new file mode 100644 index 0000000000..3df6b4b167 --- /dev/null +++ b/api/src/backend/api/migrations/0089_backfill_finding_group_status_muted.py @@ -0,0 +1,30 @@ +from api.db_router import MainRouter +from api.rls import Tenant +from django.db import migrations +from tasks.tasks import backfill_finding_group_summaries_task + + +def trigger_backfill_task(apps, schema_editor): + """ + Re-dispatch the finding-group backfill task for every tenant so the new + `manual_count` and `muted` columns added in 0088 get populated from the + last 10 days of completed scans. + + The aggregator (`aggregate_finding_group_summaries`) recomputes every + column on each call, so it back-populates the new fields without touching + the existing ones beyond a normal upsert. + """ + tenant_ids = Tenant.objects.using(MainRouter.admin_db).values_list("id", flat=True) + + for tenant_id in tenant_ids: + backfill_finding_group_summaries_task.delay(tenant_id=str(tenant_id), days=10) + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0088_finding_group_status_muted_fields"), + ] + + operations = [ + migrations.RunPython(trigger_backfill_task, migrations.RunPython.noop), + ] diff --git a/api/src/backend/api/migrations/0090_attack_paths_cleanup_priority.py b/api/src/backend/api/migrations/0090_attack_paths_cleanup_priority.py new file mode 100644 index 0000000000..5ef8529b08 --- /dev/null +++ b/api/src/backend/api/migrations/0090_attack_paths_cleanup_priority.py @@ -0,0 +1,23 @@ +from django.db import migrations + +TASK_NAME = "attack-paths-cleanup-stale-scans" + + +def set_cleanup_priority(apps, schema_editor): + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + PeriodicTask.objects.filter(name=TASK_NAME).update(priority=0) + + +def unset_cleanup_priority(apps, schema_editor): + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + PeriodicTask.objects.filter(name=TASK_NAME).update(priority=None) + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0089_backfill_finding_group_status_muted"), + ] + + operations = [ + migrations.RunPython(set_cleanup_priority, unset_cleanup_priority), + ] diff --git a/api/src/backend/api/migrations/0091_findings_arrays_gin_index_partitions.py b/api/src/backend/api/migrations/0091_findings_arrays_gin_index_partitions.py new file mode 100644 index 0000000000..6c4e978b28 --- /dev/null +++ b/api/src/backend/api/migrations/0091_findings_arrays_gin_index_partitions.py @@ -0,0 +1,30 @@ +from functools import partial + +from api.db_utils import create_index_on_partitions, drop_index_on_partitions +from django.db import migrations + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0090_attack_paths_cleanup_priority"), + ] + + operations = [ + migrations.RunPython( + partial( + create_index_on_partitions, + parent_table="findings", + index_name="gin_find_arrays_idx", + columns="categories, resource_services, resource_regions, resource_types", + method="GIN", + all_partitions=True, + ), + reverse_code=partial( + drop_index_on_partitions, + parent_table="findings", + index_name="gin_find_arrays_idx", + ), + ) + ] diff --git a/api/src/backend/api/migrations/0092_findings_arrays_gin_index_parent.py b/api/src/backend/api/migrations/0092_findings_arrays_gin_index_parent.py new file mode 100644 index 0000000000..fe49ae6f10 --- /dev/null +++ b/api/src/backend/api/migrations/0092_findings_arrays_gin_index_parent.py @@ -0,0 +1,73 @@ +import django.contrib.postgres.indexes +from django.db import migrations + +INDEX_NAME = "gin_find_arrays_idx" +PARENT_TABLE = "findings" + + +def create_parent_and_attach(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + # Idempotent: the parent index may already exist if it was created + # manually on an environment before this migration ran. + cursor.execute( + f"CREATE INDEX IF NOT EXISTS {INDEX_NAME} ON ONLY {PARENT_TABLE} " + f"USING gin (categories, resource_services, resource_regions, resource_types)" + ) + cursor.execute( + "SELECT inhrelid::regclass::text " + "FROM pg_inherits " + "WHERE inhparent = %s::regclass", + [PARENT_TABLE], + ) + for (partition,) in cursor.fetchall(): + child_idx = f"{partition.replace('.', '_')}_{INDEX_NAME}" + # ALTER INDEX ... ATTACH PARTITION has no IF NOT ATTACHED clause, + # so check pg_inherits first to keep the migration re-runnable. + cursor.execute( + """ + SELECT 1 + FROM pg_inherits i + JOIN pg_class p ON p.oid = i.inhparent + JOIN pg_class c ON c.oid = i.inhrelid + WHERE p.relname = %s AND c.relname = %s + """, + [INDEX_NAME, child_idx], + ) + if cursor.fetchone() is None: + cursor.execute(f"ALTER INDEX {INDEX_NAME} ATTACH PARTITION {child_idx}") + + +def drop_parent_index(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute(f"DROP INDEX IF EXISTS {INDEX_NAME}") + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0091_findings_arrays_gin_index_partitions"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AddIndex( + model_name="finding", + index=django.contrib.postgres.indexes.GinIndex( + fields=[ + "categories", + "resource_services", + "resource_regions", + "resource_types", + ], + name=INDEX_NAME, + ), + ), + ], + database_operations=[ + migrations.RunPython( + create_parent_and_attach, + reverse_code=drop_parent_index, + ), + ], + ), + ] diff --git a/api/src/backend/api/migrations/0093_okta_provider.py b/api/src/backend/api/migrations/0093_okta_provider.py new file mode 100644 index 0000000000..cc28c45fdb --- /dev/null +++ b/api/src/backend/api/migrations/0093_okta_provider.py @@ -0,0 +1,40 @@ +import api.db_utils +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0092_findings_arrays_gin_index_parent"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ("openstack", "OpenStack"), + ("image", "Image"), + ("googleworkspace", "Google Workspace"), + ("vercel", "Vercel"), + ("okta", "Okta"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'okta';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py new file mode 100644 index 0000000000..6ba2fa758a --- /dev/null +++ b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py @@ -0,0 +1,48 @@ +from django.db import migrations + +TASK_NAME = "reconcile-orphan-tasks" +INTERVAL_MINUTES = 2 + + +def create_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + schedule, _ = IntervalSchedule.objects.get_or_create( + every=INTERVAL_MINUTES, + period="minutes", + ) + + PeriodicTask.objects.update_or_create( + name=TASK_NAME, + defaults={ + "task": TASK_NAME, + "interval": schedule, + "enabled": True, + }, + ) + + +def delete_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + PeriodicTask.objects.filter(name=TASK_NAME).delete() + + # Clean up the schedule if no other task references it + IntervalSchedule.objects.filter( + every=INTERVAL_MINUTES, + period="minutes", + periodictask__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0093_okta_provider"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_periodic_task, delete_periodic_task), + ] diff --git a/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py b/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py new file mode 100644 index 0000000000..75b3e2cac7 --- /dev/null +++ b/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0095_reconcile_orphan_tasks_periodic_task"), + ] + + operations = [ + migrations.AddField( + model_name="attackpathsscan", + name="is_migrated", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="attackpathsscan", + name="sink_backend", + field=models.CharField( + choices=[("neo4j", "Neo4j"), ("neptune", "Neptune")], + default="neo4j", + max_length=16, + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 5e0880be08..c2beba97b4 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1,37 +1,11 @@ import json import logging import re -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from uuid import UUID, uuid4 import defusedxml from allauth.socialaccount.models import SocialApp -from config.custom_logging import BackendLogger -from config.settings.social_login import SOCIALACCOUNT_PROVIDERS -from cryptography.fernet import Fernet, InvalidToken -from defusedxml import ElementTree as ET -from django.conf import settings -from django.contrib.auth.models import AbstractBaseUser -from django.contrib.postgres.fields import ArrayField -from django.contrib.postgres.indexes import GinIndex, OpClass -from django.contrib.postgres.search import SearchVector, SearchVectorField -from django.contrib.sites.models import Site -from django.core.exceptions import ValidationError -from django.core.validators import MinLengthValidator -from django.db import models -from django.db.models import Q -from django.db.models.functions import Upper -from django.utils import timezone as django_timezone -from django.utils.translation import gettext_lazy as _ -from django_celery_beat.models import PeriodicTask -from django_celery_results.models import TaskResult -from drf_simple_apikey.crypto import get_crypto -from drf_simple_apikey.models import AbstractAPIKey, AbstractAPIKeyManager -from psqlextra.manager import PostgresManager -from psqlextra.models import PostgresPartitionedModel -from psqlextra.types import PostgresPartitioningMethod -from uuid6 import uuid7 - from api.db_router import MainRouter from api.db_utils import ( CustomUserManager, @@ -58,7 +32,32 @@ from api.rls import ( RowLevelSecurityProtectedModel, Tenant, ) +from config.custom_logging import BackendLogger +from config.settings.social_login import SOCIALACCOUNT_PROVIDERS +from cryptography.fernet import Fernet, InvalidToken +from defusedxml import ElementTree as ET +from django.conf import settings +from django.contrib.auth.models import AbstractBaseUser +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex, OpClass +from django.contrib.postgres.search import SearchVector, SearchVectorField +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError +from django.core.validators import MinLengthValidator +from django.db import models +from django.db.models import Q +from django.db.models.functions import Upper +from django.utils import timezone as django_timezone +from django.utils.translation import gettext_lazy as _ +from django_celery_beat.models import PeriodicTask +from django_celery_results.models import TaskResult +from drf_simple_apikey.crypto import get_crypto +from drf_simple_apikey.models import AbstractAPIKey, AbstractAPIKeyManager from prowler.lib.check.models import Severity +from psqlextra.manager import PostgresManager +from psqlextra.models import PostgresPartitionedModel +from psqlextra.types import PostgresPartitioningMethod +from uuid6 import uuid7 fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode()) @@ -296,6 +295,7 @@ class Provider(RowLevelSecurityProtectedModel): IMAGE = "image", _("Image") GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace") VERCEL = "vercel", _("Vercel") + OKTA = "okta", _("Okta") @staticmethod def validate_aws_uid(value): @@ -354,6 +354,26 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_okta_uid(value): + if not re.match( + r"^[a-z0-9][a-z0-9-]*\.(" + r"okta\.com|oktapreview\.com|okta-emea\.com|" + r"okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com" + r")$", + value, + ): + raise ModelValidationError( + detail=( + "Okta provider ID must be a valid Okta-managed org domain " + "(e.g., acme.okta.com, also .oktapreview.com / .okta-emea.com " + "/ .okta-gov.com / .okta.mil / .okta-miltest.com / " + ".trex-govcloud.com), without scheme or path." + ), + code="okta-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_kubernetes_uid(value): if not re.match( @@ -480,6 +500,12 @@ class Provider(RowLevelSecurityProtectedModel): def clean(self): super().clean() + if self.provider == self.ProviderChoices.OKTA and self.uid: + # Mirror the SDK, which lowercases the org domain before connecting. + # Without this the API would reject Acme.okta.com even though the + # SDK would accept it, and stored uids could disagree with the + # authenticated org domain. + self.uid = self.uid.strip().lower() getattr(self, f"validate_{self.provider}_uid")(self.uid) def save(self, *args, **kwargs): @@ -595,10 +621,40 @@ class Scan(RowLevelSecurityProtectedModel): objects = ActiveProviderManager() all_objects = models.Manager() + _SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None + + @classmethod + def get_scoping_scanner_arg_keys(cls) -> tuple[str, ...]: + """Return the scanner_args keys that mark a scan as scoped. + + Derived from ``prowler.lib.scan.scan.Scan.__init__`` so the API stays + in sync with whatever the SDK actually accepts as filters. Cached at + class level — the signature is stable for the process lifetime. + """ + if cls._SCOPING_SCANNER_ARG_KEYS_CACHE is None: + import inspect + + from prowler.lib.scan.scan import Scan as ProwlerScan + + params = inspect.signature(ProwlerScan.__init__).parameters + cls._SCOPING_SCANNER_ARG_KEYS_CACHE = tuple( + name for name in params if name not in ("self", "provider") + ) + return cls._SCOPING_SCANNER_ARG_KEYS_CACHE + class TriggerChoices(models.TextChoices): SCHEDULED = "scheduled", _("Scheduled") MANUAL = "manual", _("Manual") + # Trigger values for scans that ran the SDK end-to-end. Imported scans (or + # any future trigger) are intentionally NOT in this set — they may carry + # only a partial slice of resources, so post-scan logic that depends on a + # full-scope sweep (e.g. resetting ephemeral resource findings) must skip + # them by default. + LIVE_SCAN_TRIGGERS = frozenset( + (TriggerChoices.SCHEDULED.value, TriggerChoices.MANUAL.value) + ) + id = models.UUIDField(primary_key=True, default=uuid7, editable=False) name = models.CharField( blank=True, null=True, max_length=100, validators=[MinLengthValidator(3)] @@ -681,8 +737,30 @@ class Scan(RowLevelSecurityProtectedModel): class JSONAPIMeta: resource_name = "scans" + def is_full_scope(self) -> bool: + """Return True if this scan ran with no scoping filters at all. + + Used to gate post-scan operations (such as resetting the + failed_findings_count of resources missing from the scan) that are only + safe when the scan covered every check, service, and category. Imported + scans are NOT full-scope by definition — they may carry only a partial + slice of resources, so they're rejected via ``trigger`` even before the + scanner_args check. + """ + if self.trigger not in self.LIVE_SCAN_TRIGGERS: + return False + scanner_args = self.scanner_args or {} + for key in self.get_scoping_scanner_arg_keys(): + if scanner_args.get(key): + return False + return True + class AttackPathsScan(RowLevelSecurityProtectedModel): + class SinkBackendChoices(models.TextChoices): + NEO4J = "neo4j", "Neo4j" + NEPTUNE = "neptune", "Neptune" + objects = ActiveProviderManager() all_objects = models.Manager() @@ -731,6 +809,18 @@ class AttackPathsScan(RowLevelSecurityProtectedModel): ) ingestion_exceptions = models.JSONField(default=dict, null=True, blank=True) + # True when the scan was synced with the current schema (list-typed + # properties materialised as child item nodes). False for pre-cutover scans + # still using the previous graph shape. Query catalog selection uses this + # flag; physical read routing uses sink_backend below. + # TODO: drop after Neptune cutover + is_migrated = models.BooleanField(default=False) + sink_backend = models.CharField( + choices=SinkBackendChoices.choices, + default=SinkBackendChoices.NEO4J, + max_length=16, + ) + class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "attack_paths_scans" @@ -898,7 +988,6 @@ class Resource(RowLevelSecurityProtectedModel): OpClass(Upper("name"), name="gin_trgm_ops"), name="res_name_trgm_idx", ), - GinIndex(fields=["text_search"], name="gin_resources_search_idx"), models.Index(fields=["tenant_id", "id"], name="resources_tenant_id_idx"), models.Index( fields=["tenant_id", "provider_id"], @@ -1104,6 +1193,15 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): fields=["tenant_id", "scan_id", "check_id"], name="find_tenant_scan_check_idx", ), + GinIndex( + fields=[ + "categories", + "resource_services", + "resource_regions", + "resource_types", + ], + name="gin_find_arrays_idx", + ), ] class JSONAPIMeta: @@ -1344,8 +1442,8 @@ class Role(RowLevelSecurityProtectedModel): @classmethod def filter_by_permission_state(cls, queryset, value): - q_all_true = Q(**{field: True for field in cls.PERMISSION_FIELDS}) - q_all_false = Q(**{field: False for field in cls.PERMISSION_FIELDS}) + q_all_true = Q(**dict.fromkeys(cls.PERMISSION_FIELDS, True)) + q_all_false = Q(**dict.fromkeys(cls.PERMISSION_FIELDS, False)) if value == PermissionChoices.UNLIMITED: return queryset.filter(q_all_true) @@ -1748,15 +1846,45 @@ class FindingGroupDailySummary(RowLevelSecurityProtectedModel): # Severity stored as integer for MAX aggregation (5=critical, 4=high, etc.) severity_order = models.SmallIntegerField(default=1) - # Finding counts + # Finding counts (inclusive of muted findings; use the `muted` flag to + # tell whether the group has any actionable findings). pass_count = models.IntegerField(default=0) fail_count = models.IntegerField(default=0) + manual_count = models.IntegerField(default=0) muted_count = models.IntegerField(default=0) - # Delta counts + # Status counts restricted to muted findings, so clients can isolate the + # muted half of each status (e.g. `pass_count - pass_muted_count` gives the + # actionable PASS findings). + pass_muted_count = models.IntegerField(default=0) + fail_muted_count = models.IntegerField(default=0) + manual_muted_count = models.IntegerField(default=0) + + # Whether every finding for this (provider, check, day) is muted. + muted = models.BooleanField(default=False) + + # Delta counts (non-muted, kept for convenience and as a "total" view). new_count = models.IntegerField(default=0) changed_count = models.IntegerField(default=0) + # Delta breakdown by (status, muted) so clients can answer questions like + # "how many new failing findings appeared in this scan?" without scanning + # the underlying findings table. Mirrors the existing pass/fail/manual + # naming, with `_muted_count` siblings tracking the muted half of each + # bucket explicitly. + new_fail_count = models.IntegerField(default=0) + new_fail_muted_count = models.IntegerField(default=0) + new_pass_count = models.IntegerField(default=0) + new_pass_muted_count = models.IntegerField(default=0) + new_manual_count = models.IntegerField(default=0) + new_manual_muted_count = models.IntegerField(default=0) + changed_fail_count = models.IntegerField(default=0) + changed_fail_muted_count = models.IntegerField(default=0) + changed_pass_count = models.IntegerField(default=0) + changed_pass_muted_count = models.IntegerField(default=0) + changed_manual_count = models.IntegerField(default=0) + changed_manual_muted_count = models.IntegerField(default=0) + # Resource counts resources_fail = models.IntegerField(default=0) resources_total = models.IntegerField(default=0) @@ -1898,11 +2026,11 @@ class SAMLToken(models.Model): def save(self, *args, **kwargs): if not self.expires_at: - self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=15) + self.expires_at = datetime.now(UTC) + timedelta(seconds=15) super().save(*args, **kwargs) def is_expired(self) -> bool: - return datetime.now(timezone.utc) >= self.expires_at + return datetime.now(UTC) >= self.expires_at class SAMLDomainIndex(models.Model): diff --git a/api/src/backend/api/partitions.py b/api/src/backend/api/partitions.py index 92390ffdec..8903c4504b 100644 --- a/api/src/backend/api/partitions.py +++ b/api/src/backend/api/partitions.py @@ -1,21 +1,20 @@ -from datetime import datetime, timezone -from typing import Generator, Optional - -from dateutil.relativedelta import relativedelta -from django.conf import settings -from psqlextra.partitioning import ( - PostgresPartitioningManager, - PostgresRangePartition, - PostgresRangePartitioningStrategy, - PostgresTimePartitionSize, - PostgresPartitioningError, -) -from psqlextra.partitioning.config import PostgresPartitioningConfig -from uuid6 import UUID +from collections.abc import Generator +from datetime import UTC, datetime from api.models import Finding, ResourceFindingMapping from api.rls import RowLevelSecurityConstraint from api.uuid_utils import datetime_to_uuid7 +from dateutil.relativedelta import relativedelta +from django.conf import settings +from psqlextra.partitioning import ( + PostgresPartitioningError, + PostgresPartitioningManager, + PostgresRangePartition, + PostgresRangePartitioningStrategy, + PostgresTimePartitionSize, +) +from psqlextra.partitioning.config import PostgresPartitioningConfig +from uuid6 import UUID class PostgresUUIDv7RangePartition(PostgresRangePartition): @@ -24,7 +23,7 @@ class PostgresUUIDv7RangePartition(PostgresRangePartition): from_values: UUID, to_values: UUID, size: PostgresTimePartitionSize, - name_format: Optional[str] = None, + name_format: str | None = None, **kwargs, ) -> None: self.from_values = from_values @@ -38,9 +37,7 @@ class PostgresUUIDv7RangePartition(PostgresRangePartition): start_timestamp_ms = self.from_values.time - self.start_datetime = datetime.fromtimestamp( - start_timestamp_ms / 1000, timezone.utc - ) + self.start_datetime = datetime.fromtimestamp(start_timestamp_ms / 1000, UTC) def name(self) -> str: if not self.name_format: @@ -82,8 +79,8 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy): size: PostgresTimePartitionSize, count: int, start_date: datetime = None, - max_age: Optional[relativedelta] = None, - name_format: Optional[str] = None, + max_age: relativedelta | None = None, + name_format: str | None = None, **kwargs, ) -> None: self.start_date = start_date.replace( @@ -151,7 +148,7 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy): Returns: datetime: A `datetime` object representing the start of the current month in UTC. """ - return datetime.now(timezone.utc).replace( + return datetime.now(UTC).replace( day=1, hour=0, minute=0, second=0, microsecond=0 ) @@ -171,7 +168,7 @@ manager = PostgresPartitioningManager( PostgresPartitioningConfig( model=Finding, strategy=PostgresUUIDv7PartitioningStrategy( - start_date=datetime.now(timezone.utc), + start_date=datetime.now(UTC), size=PostgresTimePartitionSize( months=settings.FINDINGS_TABLE_PARTITION_MONTHS ), @@ -187,7 +184,7 @@ manager = PostgresPartitioningManager( PostgresPartitioningConfig( model=ResourceFindingMapping, strategy=PostgresUUIDv7PartitioningStrategy( - start_date=datetime.now(timezone.utc), + start_date=datetime.now(UTC), size=PostgresTimePartitionSize( months=settings.FINDINGS_TABLE_PARTITION_MONTHS ), diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index cfbabf6c0b..ef0475fefb 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -1,11 +1,10 @@ from enum import Enum -from django.db.models import QuerySet -from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import BasePermission - from api.db_router import MainRouter from api.models import Provider, Role, User +from django.db.models import QuerySet +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import BasePermission class Permissions(Enum): diff --git a/api/src/backend/api/renderers.py b/api/src/backend/api/renderers.py index 77349540ce..e0fafac3c4 100644 --- a/api/src/backend/api/renderers.py +++ b/api/src/backend/api/renderers.py @@ -1,10 +1,9 @@ from contextlib import nullcontext +from api.db_utils import rls_transaction from rest_framework.renderers import BaseRenderer from rest_framework_json_api.renderers import JSONRenderer -from api.db_utils import rls_transaction - class PlainTextRenderer(BaseRenderer): media_type = "text/plain" diff --git a/api/src/backend/api/rls.py b/api/src/backend/api/rls.py index 285b06a974..9e4754c842 100644 --- a/api/src/backend/api/rls.py +++ b/api/src/backend/api/rls.py @@ -1,12 +1,11 @@ from typing import Any from uuid import uuid4 +from api.db_utils import DB_USER, POSTGRES_TENANT_VAR from django.core.exceptions import ValidationError from django.db import DEFAULT_DB_ALIAS, models from django.db.backends.ddl_references import Statement, Table -from api.db_utils import DB_USER, POSTGRES_TENANT_VAR - class Tenant(models.Model): """ diff --git a/api/src/backend/api/signals.py b/api/src/backend/api/signals.py index 7bca0da0a6..790779f087 100644 --- a/api/src/backend/api/signals.py +++ b/api/src/backend/api/signals.py @@ -1,10 +1,3 @@ -from celery import states -from celery.signals import before_task_publish -from config.celery import celery_app -from django.db.models.signals import post_delete, pre_delete -from django.dispatch import receiver -from django_celery_results.backends.database import DatabaseBackend - from api.db_utils import delete_related_daily_task from api.models import ( LighthouseProviderConfiguration, @@ -14,6 +7,12 @@ from api.models import ( TenantAPIKey, User, ) +from celery import states +from celery.signals import before_task_publish +from config.celery import celery_app +from django.db.models.signals import post_delete, pre_delete +from django.dispatch import receiver +from django_celery_results.backends.database import DatabaseBackend def create_task_result_on_publish(sender=None, headers=None, **kwargs): # noqa: F841 diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 7819f8010c..767a3aaf78 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.24.0 + version: 1.33.0 description: |- Prowler API specification. @@ -356,7 +356,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -373,6 +373,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -389,13 +390,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -412,6 +414,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -430,6 +433,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -1267,20 +1271,50 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[check_id] schema: @@ -1298,6 +1332,36 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational - in: query name: filter[inserted_at] schema: @@ -1320,6 +1384,44 @@ paths: type: string format: date description: Maximum date range is 7 days. + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -1339,7 +1441,6 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -1356,6 +1457,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -1372,8 +1474,61 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + - vercel + - okta + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] schema: type: array items: @@ -1381,12 +1536,166 @@ paths: description: Multiple values may be separated by commas. explode: false style: form - - name: filter[search] - required: false - in: query - description: A search term. + - in: query + name: filter[region] schema: type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date - name: page[number] required: false in: query @@ -1420,6 +1729,8 @@ paths: - -severity - status - -status + - muted + - -muted - impacted_providers - -impacted_providers - resources_fail @@ -1430,12 +1741,44 @@ paths: - -pass_count - fail_count - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count - muted_count - -muted_count - new_count - -new_count - changed_count - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count - first_seen_at - -first_seen_at - last_seen_at @@ -1476,20 +1819,431 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_id] + schema: + type: string + - in: query + name: filter[check_id__icontains] + schema: + type: string + - in: query + name: filter[check_id__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date + description: Maximum date range is 7 days. + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date + description: Maximum date range is 7 days. + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + - vercel + - okta + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + - vercel + - okta + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date - in: path name: id schema: @@ -1497,6 +2251,84 @@ paths: format: uuid description: A UUID string identifying this finding group daily summary. required: true + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - check_id + - -check_id + - check_title + - -check_title + - check_description + - -check_description + - severity + - -severity + - status + - -status + - muted + - -muted + - impacted_providers + - -impacted_providers + - resources_fail + - -resources_fail + - resources_total + - -resources_total + - pass_count + - -pass_count + - fail_count + - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count + - muted_count + - -muted_count + - new_count + - -new_count + - changed_count + - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count + - first_seen_at + - -first_seen_at + - last_seen_at + - -last_seen_at + - failing_since + - -failing_since + explode: false tags: - Finding Groups security: @@ -1531,14 +2363,31 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since @@ -1583,20 +2432,487 @@ paths: - check_description - severity - status + - muted - impacted_providers - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count - first_seen_at - last_seen_at - failing_since description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_id] + schema: + type: string + - in: query + name: filter[check_id__icontains] + schema: + type: string + - in: query + name: filter[check_id__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + - vercel + - okta + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + - vercel + - okta + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - check_id + - -check_id + - check_title + - -check_title + - check_description + - -check_description + - severity + - -severity + - status + - -status + - muted + - -muted + - impacted_providers + - -impacted_providers + - resources_fail + - -resources_fail + - resources_total + - -resources_total + - pass_count + - -pass_count + - fail_count + - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count + - muted_count + - -muted_count + - new_count + - -new_count + - changed_count + - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count + - first_seen_at + - -first_seen_at + - last_seen_at + - -last_seen_at + - failing_since + - -failing_since + explode: false tags: - Finding Groups security: @@ -1817,7 +3133,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -1834,6 +3150,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -1850,13 +3167,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -1873,6 +3191,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -1891,6 +3210,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -2423,7 +3743,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2440,6 +3760,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2456,13 +3777,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2479,6 +3801,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -2497,6 +3820,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -2937,7 +4261,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2954,6 +4278,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2970,13 +4295,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -2993,6 +4319,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3011,6 +4338,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3449,7 +4777,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -3466,6 +4794,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3482,13 +4811,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -3505,6 +4835,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3523,6 +4854,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3949,7 +5281,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -3966,6 +5298,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3982,13 +5315,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -4005,6 +5339,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -4023,6 +5358,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -4436,8 +5772,16 @@ paths: /api/v1/integrations/{integration_pk}/jira/dispatches: post: operationId: integrations_jira_dispatches_create - description: Send a set of filtered findings to the given integration. At least - one finding filter must be provided. + description: |- + Send a set of filtered findings to the given integration. At least one finding filter must be provided. + + ## Known Limitations + + ### Issue Types with Required Custom Fields + + Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not currently populate when creating work items. If a selected issue type enforces required fields beyond the standard set (e.g., "Team", "Epic Name"), the work item creation will fail. + + To avoid this, select an issue type that does not require additional custom fields - **Task**, **Bug**, or **Story** typically work without restrictions. If unsure which issue types are available for a project, Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding. summary: Send findings to a Jira integration parameters: - in: query @@ -4498,6 +5842,47 @@ paths: task_args: null metadata: null description: '' + /api/v1/integrations/{integration_pk}/jira/issue_types: + get: + operationId: integrations_jira_issue_types_retrieve + description: Fetch the available issue types from Jira for a given project key + and update the integration configuration. + summary: Get available issue types for a Jira project + parameters: + - in: query + name: fields[jira-issue-types] + schema: + type: array + items: + type: string + enum: + - project_key + - issue_types + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: integration_pk + schema: + type: string + required: true + - in: query + name: project_key + schema: + type: string + description: The Jira project key to fetch issue types for. + required: true + tags: + - Integration + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/IntegrationJiraIssueTypesResponse' + description: '' /api/v1/integrations/{id}: get: operationId: integrations_retrieve @@ -5790,7 +7175,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -5807,6 +7192,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -5823,13 +7209,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -5846,6 +7233,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -5864,6 +7252,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -5969,7 +7358,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -5986,6 +7375,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -6002,13 +7392,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6025,6 +7416,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -6043,6 +7435,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -6154,6 +7547,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -6170,6 +7564,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -6192,6 +7587,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -6210,6 +7606,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -6336,7 +7733,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6353,6 +7750,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -6369,13 +7767,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6392,6 +7791,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -6410,6 +7810,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -6549,7 +7950,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6566,6 +7967,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -6582,13 +7984,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -6605,6 +8008,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -6623,6 +8027,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -6773,6 +8178,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -6789,6 +8195,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -6811,6 +8218,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -6829,6 +8237,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7004,7 +8413,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7021,6 +8430,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7037,13 +8447,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7060,6 +8471,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7078,6 +8490,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7182,7 +8595,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7199,6 +8612,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7215,13 +8629,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7238,6 +8653,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7256,6 +8672,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7384,7 +8801,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7401,6 +8818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7417,13 +8835,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -7440,6 +8859,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7458,6 +8878,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8227,7 +9648,7 @@ paths: name: filter[provider] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8244,6 +9665,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8260,13 +9682,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8283,6 +9706,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8301,13 +9725,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8324,6 +9749,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8340,13 +9766,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -8363,6 +9790,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8381,6 +9809,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -9034,7 +10463,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9051,6 +10480,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9067,13 +10497,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9090,6 +10521,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9108,6 +10540,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9585,7 +11018,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9602,6 +11035,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9618,13 +11052,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9641,6 +11076,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9659,6 +11095,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9949,7 +11386,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -9966,6 +11403,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9982,13 +11420,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -10005,6 +11444,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -10023,6 +11463,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -10319,7 +11760,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -10336,6 +11777,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10352,13 +11794,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -10375,6 +11818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -10393,6 +11837,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11135,6 +12580,21 @@ paths: schema: type: string format: date + - in: query + name: filter[id] + schema: + type: string + format: uuid + - in: query + name: filter[id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[inserted_at] schema: @@ -11199,7 +12659,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -11216,6 +12676,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11232,13 +12693,14 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 enum: - alibabacloud - aws @@ -11255,6 +12717,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11273,6 +12736,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11567,6 +13031,73 @@ paths: schema: $ref: '#/components/schemas/ScanUpdateResponse' description: '' + /api/v1/scans/{id}/cis: + get: + operationId: scans_cis_retrieve + description: Download the CIS Benchmark compliance report as a PDF file. When + a provider ships multiple CIS versions, the report is generated for the highest + available version. + summary: Retrieve CIS Benchmark compliance report + parameters: + - in: query + name: fields[scans] + schema: + type: array + items: + type: string + enum: + - name + - trigger + - state + - unique_resource_count + - progress + - duration + - provider + - task + - inserted_at + - started_at + - completed_at + - scheduled_at + - next_scan_at + - processor + - url + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: PDF file containing the CIS compliance report + '202': + description: The task is in progress + '401': + description: API key missing or user not Authenticated + '403': + description: There is a problem with credentials + '404': + description: The scan has no CIS reports, or the CIS report generation task + has not started yet /api/v1/scans/{id}/compliance/{name}: get: operationId: scans_compliance_retrieve @@ -11606,8 +13137,59 @@ paths: responses: '200': description: CSV file containing the compliance report + '202': + description: The task is in progress + '403': + description: There is a problem with credentials '404': - description: Compliance report not found + description: Compliance report not found, or the scan has no reports yet + /api/v1/scans/{id}/compliance/{name}/ocsf: + get: + operationId: scans_compliance_ocsf_retrieve + description: Download a specific compliance report as an OCSF JSON file. Only + universal frameworks that declare an output configuration produce this artifact + (currently 'dora' and 'csa_ccm_4.0'); any other framework returns 404. + summary: Retrieve compliance report as OCSF JSON + parameters: + - in: query + name: fields[scan-reports] + schema: + type: array + items: + type: string + enum: + - id + - name + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this scan. + required: true + - in: path + name: name + schema: + type: string + description: The compliance report name, like 'dora' + required: true + tags: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: OCSF JSON file containing the compliance report + '202': + description: The task is in progress + '403': + description: There is a problem with credentials + '404': + description: Compliance report not found, the framework does not provide + an OCSF export, or the scan has no reports yet /api/v1/scans/{id}/csa: get: operationId: scans_csa_retrieve @@ -12381,12 +13963,53 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[date_joined] + schema: + type: string + format: date + - in: query + name: filter[date_joined__date] + schema: + type: string + format: date + - in: query + name: filter[date_joined__gte] + schema: + type: string + format: date-time + - in: query + name: filter[date_joined__lte] + schema: + type: string + format: date-time + - in: query + name: filter[role] + schema: + type: string + x-spec-enum-id: 12c359ee5dc51001 + enum: + - member + - owner + description: |- + * `owner` - Owner + * `member` - Member - name: filter[search] required: false in: query description: A search term. schema: type: string + - in: query + name: filter[tenant] + schema: + type: string + format: uuid + - in: query + name: filter[user] + schema: + type: string + format: uuid - name: page[number] required: false in: query @@ -12440,9 +14063,12 @@ paths: /api/v1/tenants/{tenant_pk}/memberships/{id}: delete: operationId: tenants_memberships_destroy - description: Delete the membership details of users in a tenant. You need to - be one of the owners to delete a membership that is not yours. If you are - the last owner of a tenant, you cannot delete your own membership. + description: 'Delete a user''s membership from a tenant. This action: (1) removes + the membership, (2) revokes all refresh tokens for the expelled user, (3) + removes their role grants for this tenant, (4) cleans up orphaned roles, and + (5) deletes the user account if this was their last membership. You must be + a tenant owner to delete another user''s membership. The last owner of a tenant + cannot delete their own membership.' summary: Delete tenant memberships parameters: - in: path @@ -13265,6 +14891,11 @@ paths: schema: type: string format: uuid + - in: query + name: filter[user] + schema: + type: string + format: uuid - name: page[number] required: false in: query @@ -13460,6 +15091,7 @@ components: query: type: string minLength: 1 + maxLength: 10000 required: - query required: @@ -14329,6 +15961,8 @@ components: type: string status: type: string + muted: + type: boolean impacted_providers: type: array items: @@ -14341,12 +15975,44 @@ components: type: integer fail_count: type: integer + manual_count: + type: integer + pass_muted_count: + type: integer + fail_muted_count: + type: integer + manual_muted_count: + type: integer muted_count: type: integer new_count: type: integer changed_count: type: integer + new_fail_count: + type: integer + new_fail_muted_count: + type: integer + new_pass_count: + type: integer + new_pass_muted_count: + type: integer + new_manual_count: + type: integer + new_manual_muted_count: + type: integer + changed_fail_count: + type: integer + changed_fail_muted_count: + type: integer + changed_pass_count: + type: integer + changed_pass_muted_count: + type: integer + changed_manual_count: + type: integer + changed_manual_muted_count: + type: integer first_seen_at: type: string format: date-time @@ -14364,13 +16030,30 @@ components: - check_id - severity - status + - muted - resources_fail - resources_total - pass_count - fail_count + - manual_count + - pass_muted_count + - fail_muted_count + - manual_muted_count - muted_count - new_count - changed_count + - new_fail_count + - new_fail_muted_count + - new_pass_count + - new_pass_muted_count + - new_manual_count + - new_manual_muted_count + - changed_fail_count + - changed_fail_muted_count + - changed_pass_count + - changed_pass_muted_count + - changed_manual_count + - changed_manual_muted_count FindingGroupResponse: type: object properties: @@ -14972,16 +16655,46 @@ components: type: string minLength: 1 issue_type: - enum: - - Task type: string - description: '* `Task` - Task' - x-spec-enum-id: b527b0cec62087c1 + minLength: 1 required: - project_key - issue_type required: - data + IntegrationJiraIssueTypes: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - jira-issue-types + id: {} + attributes: + type: object + properties: + project_key: + type: string + readOnly: true + issue_types: + type: array + items: + type: string + readOnly: true + IntegrationJiraIssueTypesResponse: + type: object + properties: + data: + $ref: '#/components/schemas/IntegrationJiraIssueTypes' + required: + - data IntegrationResponse: type: object properties: @@ -18553,6 +20266,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -19565,6 +21295,7 @@ components: - image - googleworkspace - vercel + - okta type: string description: |- * `aws` - AWS @@ -19582,7 +21313,8 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel - x-spec-enum-id: c0d56cad8ab9abe5 + * `okta` - Okta + x-spec-enum-id: 91f917e0c3ab97e8 uid: type: string title: Unique identifier for the provider, set by the provider @@ -19703,8 +21435,9 @@ components: - image - googleworkspace - vercel + - okta type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 description: |- Type of provider to create. @@ -19723,6 +21456,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta uid: type: string title: Unique identifier for the provider, set by the provider @@ -19775,8 +21509,9 @@ components: - image - googleworkspace - vercel + - okta type: string - x-spec-enum-id: c0d56cad8ab9abe5 + x-spec-enum-id: 91f917e0c3ab97e8 description: |- Type of provider to create. @@ -19795,6 +21530,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta uid: type: string minLength: 3 @@ -20644,6 +22380,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -21069,6 +22822,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -21504,6 +23274,23 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Okta OAuth Credentials + properties: + okta_client_id: + type: string + description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + okta_private_key: + type: string + description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + okta_scopes: + type: array + items: + type: string + description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + required: + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: diff --git a/api/src/backend/api/sse/__init__.py b/api/src/backend/api/sse/__init__.py new file mode 100644 index 0000000000..dd31d16430 --- /dev/null +++ b/api/src/backend/api/sse/__init__.py @@ -0,0 +1,13 @@ +"""Platform Server-Sent Events (SSE) infrastructure. + +Wires `django-eventstream` into the API: a base viewset features +subclass to expose an SSE endpoint +(:class:`api.sse.base_views.BaseSSEViewSet`), the channel manager that +enforces the tenant gate (:class:`api.sse.channelmanager.SSEChannelManager`), +and the channel-name helpers (:func:`api.sse.utils.make_channel_name`). +""" + +from api.sse.base_views import BaseSSEViewSet +from api.sse.utils import make_channel_name + +__all__ = ["BaseSSEViewSet", "make_channel_name"] diff --git a/api/src/backend/api/sse/base_views.py b/api/src/backend/api/sse/base_views.py new file mode 100644 index 0000000000..c4a24540ee --- /dev/null +++ b/api/src/backend/api/sse/base_views.py @@ -0,0 +1,46 @@ +"""Base view class for SSE endpoints.""" + +from api.authentication import SSEAuthentication +from api.base_views import BaseRLSViewSet +from django_eventstream.renderers import SSEEventRenderer +from django_eventstream.views import events + + +class BaseSSEViewSet(BaseRLSViewSet): + """Base class for platform SSE endpoints. + + Subclasses override method `get_channels` to declare the channel + names the connection should subscribe to — the same way a regular + DRF viewset overrides method `get_queryset`. The channel manager + reads the result from `request.sse_channels`; there is no other + coupling between platform and feature. + """ + + authentication_classes = [SSEAuthentication] + # Pin the SSE renderer so content negotiation accepts the browser's + # `Accept: text/event-stream`. + renderer_classes = [SSEEventRenderer] + + def get_channels(self) -> set[str]: + """Return the channels this connection subscribes to. + + Implementations MUST raise the relevant DRF exceptions + (`NotAuthenticated`, `PermissionDenied`, `NotFound`) when + authorization fails. Returning an empty set would surface as + django-eventstream's "No channels specified" which masks the + real cause. + """ + raise NotImplementedError + + def get_queryset(self): + # Most SSE viewsets only need `get_channels` and never call + # `get_queryset` (the SSE list path bypasses serialization + # entirely). Subclasses that perform their own queryset lookup + # inside `get_channels` should override; the default raises + # the same error a missing override on a ModelViewSet would. + raise NotImplementedError + + def list(self, request, *_args, **kwargs): + """Resolve channels under the regular DRF stack and stream.""" + request.sse_channels = self.get_channels() + return events(request, **kwargs) diff --git a/api/src/backend/api/sse/channelmanager.py b/api/src/backend/api/sse/channelmanager.py new file mode 100644 index 0000000000..9190d4ab16 --- /dev/null +++ b/api/src/backend/api/sse/channelmanager.py @@ -0,0 +1,74 @@ +"""Channel manager that wires `django-eventstream` to platform SSE views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from api.sse.utils import tenant_id_from_channel +from django_eventstream.channelmanager import DefaultChannelManager +from rest_framework.request import Request + +if TYPE_CHECKING: + from api.models import User + + +class SSEChannelManager(DefaultChannelManager): + """Connect `django-eventstream` to the platform's SSE viewsets.""" + + def get_channels_for_request(self, request: Request, view_kwargs: dict) -> set[str]: # noqa: vulture + """Return the request's channels scoped to the active JWT tenant. + + Args: + request: The authenticated DRF request, carrying `tenant_id` + (set by `BaseRLSViewSet`) and `sse_channels` (set by + `BaseSSEViewSet.list`). + view_kwargs: URL keyword arguments from django-eventstream; + unused because channels are resolved on the request. + + Returns: + The subset of `request.sse_channels` whose embedded tenant + matches the active request tenant. + """ + try: + request_tenant_id = UUID(str(getattr(request, "tenant_id", None))) + except (TypeError, ValueError): + return set() + return { + channel + for channel in getattr(request, "sse_channels", set()) + if tenant_id_from_channel(channel) == request_tenant_id + } + + def can_read_channel(self, user: User | None, channel: str) -> bool: + """Re-verify tenant membership once the stream is established. + + Args: + user: The connection's authenticated `User`, or `None` for an + anonymous connection — django-eventstream passes `None` + rather than an `AnonymousUser`. + channel: The channel name being read, in the canonical + `::` format. + + Returns: + `True` only when `user` is authenticated and a member of the + tenant embedded in `channel`; `False` otherwise, including for + anonymous connections and malformed channel names. + """ + if user is None or not user.is_authenticated: + return False + tenant_id = tenant_id_from_channel(channel) + if tenant_id is None: + return False + return user.is_member_of_tenant(tenant_id) + + def is_channel_reliable(self, channel: str) -> bool: + """Report whether the channel keeps a server-side replay buffer. + + Args: + channel: The channel name being queried. + + Returns: + `False`, unconditionally. Replay storage is not configured + """ + return False diff --git a/api/src/backend/api/sse/utils.py b/api/src/backend/api/sse/utils.py new file mode 100644 index 0000000000..a30ed26311 --- /dev/null +++ b/api/src/backend/api/sse/utils.py @@ -0,0 +1,51 @@ +"""Channel-name convention shared by SSE publishers, consumers, and the +channel manager. The format is `::`. +""" + +from __future__ import annotations + +import uuid + +CHANNEL_SEPARATOR = ":" + + +def make_channel_name( + prefix: str, + tenant_id: str | uuid.UUID, + resource_id: str | uuid.UUID, +) -> str: + """Build the canonical channel name for a resource. + + Args: + prefix: Feature-owned prefix (e.g. `"lighthouse-session"`). + tenant_id: Tenant the resource belongs to. + resource_id: Resource identifier within the tenant. + + Raises: + ValueError: If any segment contains `CHANNEL_SEPARATOR`, which + would break the `::` contract + and let a crafted name smuggle extra segments past the parser. + """ + segments = (str(prefix), str(tenant_id), str(resource_id)) + if any(CHANNEL_SEPARATOR in segment for segment in segments): + raise ValueError( + f"Channel segments must not contain '{CHANNEL_SEPARATOR}': {segments!r}" + ) + return CHANNEL_SEPARATOR.join(segments) + + +def tenant_id_from_channel(channel: str) -> uuid.UUID | None: + """Return the tenant UUID embedded in *channel*, or `None` if + *channel* does not follow the platform convention. + + A `None` result MUST be treated by callers as "not authorized" or + a malformed channel cannot be safely read. + """ + segments = channel.split(CHANNEL_SEPARATOR) + if len(segments) != 3: + # Reject non-canonical names + return None + try: + return uuid.UUID(segments[1]) + except ValueError: + return None diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 061c2efac0..4d1c40fe23 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -1,15 +1,14 @@ import time -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest +from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header from django.urls import reverse from drf_simple_apikey.crypto import get_crypto from rest_framework.test import APIClient -from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship - @pytest.mark.django_db def test_basic_authentication(): @@ -468,7 +467,7 @@ class TestAPIKeyErrors: name="Expired Key", tenant_id=tenants_fixture[0].id, entity=create_test_user, - expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + expiry_date=datetime.now(UTC) - timedelta(days=1), ) api_key_headers = get_api_key_header(raw_key) @@ -500,7 +499,7 @@ class TestAPIKeyErrors: # Create a valid-looking key with non-existent UUID crypto = get_crypto() fake_uuid = str(uuid4()) - fake_expiry = (datetime.now(timezone.utc) + timedelta(days=30)).timestamp() + fake_expiry = (datetime.now(UTC) + timedelta(days=30)).timestamp() payload = {"_pk": fake_uuid, "_exp": fake_expiry} encrypted_payload = crypto.generate(payload) @@ -723,7 +722,7 @@ class TestAPIKeyLifecycle: assert created_data["attributes"]["revoked"] is False # Create API key with expiry - future_expiry = (datetime.now(timezone.utc) + timedelta(days=90)).isoformat() + future_expiry = (datetime.now(UTC) + timedelta(days=90)).isoformat() create_with_expiry_response = client.post( reverse("api-key-list"), data={ @@ -927,9 +926,9 @@ class TestAPIKeyLifecycle: auth_response = client.get(reverse("provider-list"), headers=api_key_headers) # Must return 401 Unauthorized, not 500 Internal Server Error - assert ( - auth_response.status_code == 401 - ), f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}" + assert auth_response.status_code == 401, ( + f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}" + ) # Verify error message is present response_json = auth_response.json() @@ -1267,7 +1266,7 @@ class TestAPIKeyRLSBypass: name="Expired Test Key", tenant_id=tenant.id, entity=create_test_user, - expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + expiry_date=datetime.now(UTC) - timedelta(days=1), ) api_key_headers = get_api_key_header(raw_key) diff --git a/api/src/backend/api/tests/integration/test_providers.py b/api/src/backend/api/tests/integration/test_providers.py index 9c91ad2c07..e797160a15 100644 --- a/api/src/backend/api/tests/integration/test_providers.py +++ b/api/src/backend/api/tests/integration/test_providers.py @@ -1,12 +1,11 @@ from unittest.mock import Mock, patch import pytest +from api.models import Provider from conftest import get_api_tokens, get_authorization_header from django.urls import reverse from rest_framework.test import APIClient -from api.models import Provider - @patch("api.v1.views.Task.objects.get") @patch("api.v1.views.delete_provider_task.delay") diff --git a/api/src/backend/api/tests/integration/test_rls_transaction.py b/api/src/backend/api/tests/integration/test_rls_transaction.py index 6731b39d71..bd46871586 100644 --- a/api/src/backend/api/tests/integration/test_rls_transaction.py +++ b/api/src/backend/api/tests/integration/test_rls_transaction.py @@ -1,11 +1,10 @@ """Tests for rls_transaction retry and fallback logic.""" import pytest +from api.db_utils import rls_transaction from django.db import DEFAULT_DB_ALIAS from rest_framework_json_api.serializers import ValidationError -from api.db_utils import rls_transaction - @pytest.mark.django_db class TestRLSTransaction: diff --git a/api/src/backend/api/tests/integration/test_tenants.py b/api/src/backend/api/tests/integration/test_tenants.py index e14226164a..4d5dd8a523 100644 --- a/api/src/backend/api/tests/integration/test_tenants.py +++ b/api/src/backend/api/tests/integration/test_tenants.py @@ -1,10 +1,9 @@ from unittest.mock import patch import pytest +from conftest import TEST_PASSWORD, TEST_USER, get_api_tokens, get_authorization_header from django.urls import reverse -from conftest import TEST_USER, TEST_PASSWORD, get_api_tokens, get_authorization_header - @patch("api.v1.views.schedule_provider_scan") @pytest.mark.django_db diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 22b44b3506..91d3bb054a 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -1,13 +1,52 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from allauth.socialaccount.models import SocialLogin +from api.adapters import ProwlerSocialAccountAdapter +from api.db_router import MainRouter +from api.models import SAMLConfiguration from django.contrib.auth import get_user_model -from api.adapters import ProwlerSocialAccountAdapter - User = get_user_model() +# Minimal, well-formed IdP metadata accepted by SAMLConfiguration._parse_metadata. +VALID_METADATA = """ + + + + + + FAKECERTDATA + + + + + + +""" + + +def _saml_request(rf, organization_slug): + """Build an ACS request whose resolver_match carries the organization slug, + mirroring how Django populates it after routing the SAML ACS URL.""" + request = rf.post(f"/api/v1/accounts/saml/{organization_slug}/acs/finish/") + request.resolver_match = SimpleNamespace( + kwargs={"organization_slug": organization_slug} + ) + return request + + +def _saml_sociallogin(user): + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.provider = MagicMock() + sociallogin.provider.id = "saml" + sociallogin.account.extra_data = {} + sociallogin.user = user + sociallogin.connect = MagicMock() + return sociallogin + @pytest.mark.django_db class TestProwlerSocialAccountAdapter: @@ -20,26 +59,99 @@ class TestProwlerSocialAccountAdapter: adapter = ProwlerSocialAccountAdapter() assert adapter.get_user_by_email("notfound@example.com") is None - def test_pre_social_login_links_existing_user(self, create_test_user, rf): + def test_pre_social_login_links_member_of_saml_tenant( + self, create_test_user, tenants_fixture, rf + ): + """A SAML login links to an existing account only when that user is + already a member of the tenant that owns the asserted email domain.""" adapter = ProwlerSocialAccountAdapter() + # create_test_user (dev@prowler.com) is a member of tenant1. + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) - sociallogin = MagicMock(spec=SocialLogin) - sociallogin.account = MagicMock() - sociallogin.provider = MagicMock() - sociallogin.provider.id = "saml" - sociallogin.account.extra_data = {} - sociallogin.user = create_test_user - sociallogin.connect = MagicMock() - - adapter.pre_social_login(rf.get("/"), sociallogin) + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) call_args = sociallogin.connect.call_args assert call_args is not None - - called_request, called_user = call_args[0] - assert called_request.path == "/" + _, called_user = call_args[0] assert called_user.email == create_test_user.email + def test_pre_social_login_blocks_cross_tenant_takeover( + self, create_test_user, tenants_fixture, rf + ): + """GHSA-h8m9-jgf8-vwvp: an attacker tenant that claims the victim's + email domain must NOT be able to link to the victim's account, because + the victim is not a member of the attacker's tenant.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + # tenant3 is the attacker tenant; create_test_user is NOT a member of it. + attacker_tenant = tenants_fixture[2] + assert not create_test_user.is_member_of_tenant(str(attacker_tenant.id)) + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=attacker_tenant, + ) + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_domain_slug_mismatch( + self, create_test_user, tenants_fixture, rf + ): + """The asserted email domain must match the ACS endpoint's slug, so an + assertion cannot be replayed through a different tenant's endpoint.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) + + sociallogin = _saml_sociallogin(create_test_user) + # Slug points at a different domain than the asserted email. + adapter.pre_social_login(_saml_request(rf, "attacker.com"), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_when_no_saml_config( + self, create_test_user, tenants_fixture, rf + ): + """No SAML configuration for the domain means nothing to link against.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_without_resolver_match( + self, create_test_user, tenants_fixture, rf + ): + """Fail closed: if the request has no resolver_match we cannot bind the + assertion to a tenant, so no linking happens.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(rf.post("/"), sociallogin) + + sociallogin.connect.assert_not_called() + def test_pre_social_login_no_link_if_email_missing(self, rf): adapter = ProwlerSocialAccountAdapter() @@ -47,14 +159,35 @@ class TestProwlerSocialAccountAdapter: sociallogin.account = MagicMock() sociallogin.provider = MagicMock() sociallogin.user = MagicMock() + sociallogin.user.email = "" sociallogin.provider.id = "saml" sociallogin.account.extra_data = {} sociallogin.connect = MagicMock() - adapter.pre_social_login(rf.get("/"), sociallogin) + adapter.pre_social_login(_saml_request(rf, "prowler.com"), sociallogin) sociallogin.connect.assert_not_called() + def test_pre_social_login_non_saml_links_by_email(self, create_test_user, rf): + """Non-SAML providers (e.g. Google/GitHub) still link to an existing + local account by email; the tenant binding only applies to SAML.""" + adapter = ProwlerSocialAccountAdapter() + + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.provider = MagicMock() + sociallogin.provider.id = "google" + sociallogin.account.extra_data = {"email": create_test_user.email} + sociallogin.user = create_test_user + sociallogin.connect = MagicMock() + + adapter.pre_social_login(rf.get("/"), sociallogin) + + call_args = sociallogin.connect.call_args + assert call_args is not None + _, called_user = call_args[0] + assert called_user.email == create_test_user.email + def test_save_user_saml_sets_session_flag(self, rf): adapter = ProwlerSocialAccountAdapter() request = rf.get("/") diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 5889b4e2cb..93934df674 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -4,11 +4,9 @@ import types from pathlib import Path from unittest.mock import MagicMock, patch -import pytest -from django.conf import settings - import api import api.apps as api_apps_module +import pytest from api.apps import ( PRIVATE_KEY_FILE, PUBLIC_KEY_FILE, @@ -16,6 +14,7 @@ from api.apps import ( VERIFYING_KEY_ENV, ApiConfig, ) +from django.conf import settings @pytest.fixture(autouse=True) @@ -182,23 +181,19 @@ def _make_app(): return ApiConfig("api", api) -def test_ready_initializes_driver_for_api_process(monkeypatch): +@pytest.mark.parametrize( + "argv", + [ + ["gunicorn"], + ["celery", "-A", "api"], + ["manage.py", "migrate"], + ], + ids=["api", "celery", "manage_py"], +) +def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv): + """ready() must never contact Neo4j; the driver is created lazily on first use.""" config = _make_app() - _set_argv(monkeypatch, ["gunicorn"]) - _set_testing(monkeypatch, False) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_called_once() - - -def test_ready_skips_driver_for_celery(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["celery", "-A", "api"]) + _set_argv(monkeypatch, argv) _set_testing(monkeypatch, False) with ( @@ -208,31 +203,3 @@ def test_ready_skips_driver_for_celery(monkeypatch): config.ready() init_driver.assert_not_called() - - -def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["manage.py", "migrate"]) - _set_testing(monkeypatch, False) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_not_called() - - -def test_ready_skips_driver_when_testing(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["gunicorn"]) - _set_testing(monkeypatch, True) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_not_called() diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py index 019b6aa1f2..77bc01d255 100644 --- a/api/src/backend/api/tests/test_attack_paths.py +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -1,14 +1,12 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch -import pytest import neo4j import neo4j.exceptions - -from rest_framework.exceptions import APIException, PermissionDenied, ValidationError - +import pytest from api.attack_paths import database as graph_database from api.attack_paths import views_helpers +from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from tasks.jobs.attack_paths.config import ( PROVIDER_ELEMENT_ID_PROPERTY, get_provider_label, @@ -94,7 +92,9 @@ def test_prepare_parameters_validates_cast( def test_execute_query_serializes_graph( - attack_paths_query_definition_factory, attack_paths_graph_stub_classes + attack_paths_query_definition_factory, + attack_paths_graph_stub_classes, + sink_backend_stub, ): definition = attack_paths_query_definition_factory( id="aws-rds", @@ -137,18 +137,17 @@ def test_execute_query_serializes_graph( database_name = "db-tenant-test-tenant-id" - with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - return_value=graph_result, - ) as mock_execute_read_query: - result = views_helpers.execute_query( - database_name, definition, parameters, provider_id=provider_id - ) + sink_backend_stub.execute_read_query.return_value = graph_result + result = views_helpers.execute_query( + database_name, + definition, + parameters, + provider_id=provider_id, + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) - mock_execute_read_query.assert_called_once_with( - database=database_name, - cypher=definition.cypher, - parameters=parameters, + sink_backend_stub.execute_read_query.assert_called_once_with( + database_name, definition.cypher, parameters ) assert result["nodes"][0]["id"] == "node-1" assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value" @@ -157,6 +156,7 @@ def test_execute_query_serializes_graph( def test_execute_query_wraps_graph_errors( attack_paths_query_definition_factory, + sink_backend_stub, ): definition = attack_paths_query_definition_factory( id="aws-rds", @@ -169,16 +169,17 @@ def test_execute_query_wraps_graph_errors( database_name = "db-tenant-test-tenant-id" parameters = {"provider_uid": "123"} - with ( - patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.GraphDatabaseQueryException("boom"), - ), - patch("api.attack_paths.views_helpers.logger") as mock_logger, - ): + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.GraphDatabaseQueryException("boom") + ) + with patch("api.attack_paths.views_helpers.logger") as mock_logger: with pytest.raises(APIException): views_helpers.execute_query( - database_name, definition, parameters, provider_id="test-provider-123" + database_name, + definition, + parameters, + provider_id="test-provider-123", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), ) mock_logger.error.assert_called_once() @@ -186,6 +187,7 @@ def test_execute_query_wraps_graph_errors( def test_execute_query_raises_permission_denied_on_read_only( attack_paths_query_definition_factory, + sink_backend_stub, ): definition = attack_paths_query_definition_factory( id="aws-rds", @@ -198,17 +200,20 @@ def test_execute_query_raises_permission_denied_on_read_only( database_name = "db-tenant-test-tenant-id" parameters = {"provider_uid": "123"} - with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.WriteQueryNotAllowedException( + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.WriteQueryNotAllowedException( message="Read query not allowed", code="Neo.ClientError.Statement.AccessMode", - ), - ): - with pytest.raises(PermissionDenied): - views_helpers.execute_query( - database_name, definition, parameters, provider_id="test-provider-123" - ) + ) + ) + with pytest.raises(PermissionDenied): + views_helpers.execute_query( + database_name, + definition, + parameters, + provider_id="test-provider-123", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_classes): @@ -442,6 +447,7 @@ def test_normalize_custom_query_payload_passthrough_for_flat_dict(): def test_execute_custom_query_serializes_graph( attack_paths_graph_stub_classes, + sink_backend_stub, ): provider_id = "test-provider-123" plabel = get_provider_label(provider_id) @@ -455,50 +461,73 @@ def test_execute_custom_query_serializes_graph( graph_result.nodes = [node_1, node_2] graph_result.relationships = [relationship] - with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - return_value=graph_result, - ) as mock_execute: - result = views_helpers.execute_custom_query( - "db-tenant-test", "MATCH (n) RETURN n", provider_id - ) + sink_backend_stub.execute_read_query.return_value = graph_result + result = views_helpers.execute_custom_query( + "db-tenant-test", + "MATCH (n) RETURN n", + provider_id, + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) - mock_execute.assert_called_once() - call_kwargs = mock_execute.call_args[1] - assert call_kwargs["database"] == "db-tenant-test" + sink_backend_stub.execute_read_query.assert_called_once() + call_args = sink_backend_stub.execute_read_query.call_args[0] + assert call_args[0] == "db-tenant-test" # The cypher is rewritten with the provider label injection - assert plabel in call_kwargs["cypher"] + assert plabel in call_args[1] assert len(result["nodes"]) == 2 assert result["relationships"][0]["label"] == "OWNS" assert result["truncated"] is False assert result["total_nodes"] == 2 -def test_execute_custom_query_raises_permission_denied_on_write(): +def test_execute_custom_query_adds_timeout_for_neptune_scan(sink_backend_stub): + graph_result = MagicMock() + graph_result.nodes = [] + graph_result.relationships = [] + sink_backend_stub.execute_read_query.return_value = graph_result + with patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.WriteQueryNotAllowedException( + "api.attack_paths.views_helpers.sink_module.get_backend_for_scan", + return_value=sink_backend_stub, + ): + views_helpers.execute_custom_query( + "db-tenant-test", + "MATCH (n) RETURN n", + "provider-1", + scan=MagicMock(is_migrated=True, sink_backend="neptune"), + ) + + cypher = sink_backend_stub.execute_read_query.call_args[0][1] + assert cypher.startswith("USING QUERY:TIMEOUTMILLISECONDS") + + +def test_execute_custom_query_raises_permission_denied_on_write(sink_backend_stub): + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.WriteQueryNotAllowedException( message="Read query not allowed", code="Neo.ClientError.Statement.AccessMode", - ), - ): - with pytest.raises(PermissionDenied): - views_helpers.execute_custom_query( - "db-tenant-test", "CREATE (n) RETURN n", "provider-1" - ) + ) + ) + with pytest.raises(PermissionDenied): + views_helpers.execute_custom_query( + "db-tenant-test", + "CREATE (n) RETURN n", + "provider-1", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), + ) -def test_execute_custom_query_wraps_graph_errors(): - with ( - patch( - "api.attack_paths.views_helpers.graph_database.execute_read_query", - side_effect=graph_database.GraphDatabaseQueryException("boom"), - ), - patch("api.attack_paths.views_helpers.logger") as mock_logger, - ): +def test_execute_custom_query_wraps_graph_errors(sink_backend_stub): + sink_backend_stub.execute_read_query.side_effect = ( + graph_database.GraphDatabaseQueryException("boom") + ) + with patch("api.attack_paths.views_helpers.logger") as mock_logger: with pytest.raises(APIException): views_helpers.execute_custom_query( - "db-tenant-test", "MATCH (n) RETURN n", "provider-1" + "db-tenant-test", + "MATCH (n) RETURN n", + "provider-1", + scan=MagicMock(is_migrated=False, sink_backend="neo4j"), ) mock_logger.error.assert_called_once() @@ -563,13 +592,33 @@ def test_truncate_graph_empty_graph(): @pytest.fixture def mock_neo4j_session(): - """Mock the Neo4j driver so execute_read_query uses a fake session.""" + """Install a Neo4jSink with a mocked Bolt driver into the sink factory. + + The yielded mock is the `neo4j.Session` that the Neo4jSink will obtain via + `driver.session(...)`. Tests configure `mock_neo4j_session.execute_read` + return values / side effects to exercise the read-mode error translation + path on the real `Neo4jSink.execute_read_query` and `get_session` code. + """ + from api.attack_paths.sink import factory + from api.attack_paths.sink.neo4j import Neo4jSink + mock_session = MagicMock(spec=neo4j.Session) mock_driver = MagicMock(spec=neo4j.Driver) mock_driver.session.return_value = mock_session - with patch("api.attack_paths.database.get_driver", return_value=mock_driver): + sink = Neo4jSink() + sink._driver = mock_driver + + previous_backend = factory._backend + previous_secondary = dict(factory._secondary_backends) + factory._backend = sink + factory._secondary_backends.clear() + try: yield mock_session + finally: + factory._backend = previous_backend + factory._secondary_backends.clear() + factory._secondary_backends.update(previous_secondary) def test_execute_read_query_succeeds_with_select(mock_neo4j_session): @@ -665,16 +714,20 @@ def test_execute_read_query_rejects_apoc_real_create(mock_neo4j_session, cypher) @pytest.fixture def mock_schema_session(): - """Mock get_session for cartography schema tests.""" + """Mock the routed sink backend session for cartography schema tests.""" mock_result = MagicMock() mock_session = MagicMock() mock_session.run.return_value = mock_result + mock_backend = MagicMock() with patch( - "api.attack_paths.views_helpers.graph_database.get_session" - ) as mock_get_session: - mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) - mock_get_session.return_value.__exit__ = MagicMock(return_value=False) + "api.attack_paths.views_helpers.sink_module.get_backend_for_scan", + return_value=mock_backend, + ): + mock_backend.get_session.return_value.__enter__ = MagicMock( + return_value=mock_session + ) + mock_backend.get_session.return_value.__exit__ = MagicMock(return_value=False) yield mock_session, mock_result @@ -685,7 +738,9 @@ def test_get_cartography_schema_returns_urls(mock_schema_session): "module_version": "0.129.0", } - result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + result = views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) mock_session.run.assert_called_once() assert result["id"] == "aws-0.129.0" @@ -701,7 +756,9 @@ def test_get_cartography_schema_returns_none_when_no_data(mock_schema_session): _, mock_result = mock_schema_session mock_result.single.return_value = None - result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + result = views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) assert result is None @@ -723,21 +780,29 @@ def test_get_cartography_schema_extracts_provider( "module_version": "1.0.0", } - result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + result = views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) assert result["id"] == f"{expected_provider}-1.0.0" assert result["provider"] == expected_provider def test_get_cartography_schema_wraps_database_error(): + mock_backend = MagicMock() + mock_backend.get_session.side_effect = graph_database.GraphDatabaseQueryException( + "boom" + ) with ( patch( - "api.attack_paths.views_helpers.graph_database.get_session", - side_effect=graph_database.GraphDatabaseQueryException("boom"), + "api.attack_paths.views_helpers.sink_module.get_backend_for_scan", + return_value=mock_backend, ), patch("api.attack_paths.views_helpers.logger") as mock_logger, ): with pytest.raises(APIException): - views_helpers.get_cartography_schema("db-tenant-test", "provider-123") + views_helpers.get_cartography_schema( + "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j") + ) mock_logger.error.assert_called_once() diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py index 7e07792a69..049f122335 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -1,519 +1,174 @@ -""" -Tests for Neo4j database lazy initialization. +"""Tests for the attack-paths database facade. -The Neo4j driver connects on first use by default. API processes may -eagerly initialize the driver during app startup, while Celery workers -remain lazy. These tests validate the database module behavior itself. +After the Neptune port, `api.attack_paths.database` is a thin routing shim +over `api.attack_paths.ingest` (cartography temp DB, always Neo4j) and +`api.attack_paths.sink` (configurable Neo4j or Neptune). The facade's +contract is routing by database-name prefix and the public exception +hierarchy; sink-internal behavior is exercised in `test_sink.py`. """ -import threading from unittest.mock import MagicMock, patch -import neo4j -import pytest +import api.attack_paths.database as db_module -class TestLazyInitialization: - """Test that Neo4j driver is initialized lazily on first use.""" - - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - - original_driver = db_module._driver - - db_module._driver = None - - yield - - db_module._driver = original_driver - - def test_driver_not_initialized_at_import(self): - """Driver should be None after module import (no eager connection).""" - import api.attack_paths.database as db_module - - assert db_module._driver is None - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_init_driver_creates_connection_on_first_call( - self, mock_driver_factory, mock_settings - ): - """init_driver() should create connection only when called.""" - import api.attack_paths.database as db_module - - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - assert db_module._driver is None - - result = db_module.init_driver() - - mock_driver_factory.assert_called_once() - mock_driver.verify_connectivity.assert_called_once() - assert result is mock_driver - assert db_module._driver is mock_driver - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_init_driver_returns_cached_driver_on_subsequent_calls( - self, mock_driver_factory, mock_settings - ): - """Subsequent calls should return cached driver without reconnecting.""" - import api.attack_paths.database as db_module - - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - first_result = db_module.init_driver() - second_result = db_module.init_driver() - third_result = db_module.init_driver() - - # Only one connection attempt - assert mock_driver_factory.call_count == 1 - assert mock_driver.verify_connectivity.call_count == 1 - - # All calls return same instance - assert first_result is second_result is third_result - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_get_driver_delegates_to_init_driver( - self, mock_driver_factory, mock_settings - ): - """get_driver() should use init_driver() for lazy initialization.""" - import api.attack_paths.database as db_module - - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - result = db_module.get_driver() - - assert result is mock_driver - mock_driver_factory.assert_called_once() - - -class TestAtexitRegistration: - """Test that atexit cleanup handler is registered correctly.""" - - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - - original_driver = db_module._driver - - db_module._driver = None - - yield - - db_module._driver = original_driver - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.atexit.register") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_atexit_registered_on_first_init( - self, mock_driver_factory, mock_atexit_register, mock_settings - ): - """atexit.register should be called on first initialization.""" - import api.attack_paths.database as db_module - - mock_driver_factory.return_value = MagicMock() - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - db_module.init_driver() - - mock_atexit_register.assert_called_once_with(db_module.close_driver) - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.atexit.register") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_atexit_registered_only_once( - self, mock_driver_factory, mock_atexit_register, mock_settings - ): - """atexit.register should only be called once across multiple inits. - - The double-checked locking on _driver ensures the atexit registration - block only executes once (when _driver is first created). - """ - import api.attack_paths.database as db_module - - mock_driver_factory.return_value = MagicMock() - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - db_module.init_driver() - db_module.init_driver() - db_module.init_driver() - - # Only registered once because subsequent calls hit the fast path - assert mock_atexit_register.call_count == 1 - - -class TestCloseDriver: - """Test driver cleanup functionality.""" - - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - - original_driver = db_module._driver - - db_module._driver = None - - yield - - db_module._driver = original_driver - - def test_close_driver_closes_and_clears_driver(self): - """close_driver() should close the driver and set it to None.""" - import api.attack_paths.database as db_module - - mock_driver = MagicMock() - db_module._driver = mock_driver - - db_module.close_driver() - - mock_driver.close.assert_called_once() - assert db_module._driver is None - - def test_close_driver_handles_none_driver(self): - """close_driver() should handle case where driver is None.""" - import api.attack_paths.database as db_module - - db_module._driver = None - - # Should not raise - db_module.close_driver() - - assert db_module._driver is None - - def test_close_driver_clears_driver_even_on_close_error(self): - """Driver should be cleared even if close() raises an exception.""" - import api.attack_paths.database as db_module - - mock_driver = MagicMock() - mock_driver.close.side_effect = Exception("Connection error") - db_module._driver = mock_driver - - with pytest.raises(Exception, match="Connection error"): - db_module.close_driver() - - # Driver should still be cleared - assert db_module._driver is None - - -class TestExecuteReadQuery: - """Test read query execution helper.""" - - def test_execute_read_query_calls_read_session_and_returns_result(self): - import api.attack_paths.database as db_module - - tx = MagicMock() - expected_graph = MagicMock() - run_result = MagicMock() - run_result.graph.return_value = expected_graph - tx.run.return_value = run_result - - session = MagicMock() - - def execute_read_side_effect(fn): - return fn(tx) - - session.execute_read.side_effect = execute_read_side_effect - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ) as mock_get_session: - result = db_module.execute_read_query( - "db-tenant-test-tenant-id", - "MATCH (n) RETURN n", - {"provider_uid": "123"}, - ) - - mock_get_session.assert_called_once_with( - "db-tenant-test-tenant-id", - default_access_mode=neo4j.READ_ACCESS, - ) - session.execute_read.assert_called_once() - tx.run.assert_called_once_with( - "MATCH (n) RETURN n", - {"provider_uid": "123"}, - timeout=db_module.READ_QUERY_TIMEOUT_SECONDS, - ) - run_result.graph.assert_called_once_with() - assert result is expected_graph - - def test_execute_read_query_defaults_parameters_to_empty_dict(self): - import api.attack_paths.database as db_module - - tx = MagicMock() - run_result = MagicMock() - run_result.graph.return_value = MagicMock() - tx.run.return_value = run_result - - session = MagicMock() - session.execute_read.side_effect = lambda fn: fn(tx) - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ): - db_module.execute_read_query( - "db-tenant-test-tenant-id", - "MATCH (n) RETURN n", - ) - - tx.run.assert_called_once_with( - "MATCH (n) RETURN n", - {}, - timeout=db_module.READ_QUERY_TIMEOUT_SECONDS, - ) - run_result.graph.assert_called_once_with() - - -class TestGetSessionReadOnly: - """Test that get_session translates Neo4j read-mode errors.""" - - @pytest.fixture(autouse=True) - def reset_module_state(self): - import api.attack_paths.database as db_module - - original_driver = db_module._driver - db_module._driver = None - yield - db_module._driver = original_driver - - @pytest.mark.parametrize( - "neo4j_code", - [ - "Neo.ClientError.Statement.AccessMode", - "Neo.ClientError.Procedure.ProcedureNotFound", - ], - ) - def test_get_session_raises_write_query_not_allowed(self, neo4j_code): - """Read-mode Neo4j errors should raise `WriteQueryNotAllowedException`.""" - import api.attack_paths.database as db_module - - mock_session = MagicMock() - neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j( - code=neo4j_code, - message="Write operations are not allowed", - ) - mock_session.run.side_effect = neo4j_error - - mock_driver = MagicMock() - mock_driver.session.return_value = mock_session - db_module._driver = mock_driver - - with pytest.raises(db_module.WriteQueryNotAllowedException): - with db_module.get_session( - default_access_mode=neo4j.READ_ACCESS - ) as session: - session.run("CREATE (n) RETURN n") - - def test_get_session_raises_generic_exception_for_other_errors(self): - """Non-read-mode Neo4j errors should raise GraphDatabaseQueryException.""" - import api.attack_paths.database as db_module - - mock_session = MagicMock() - neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j( - code="Neo.ClientError.Statement.SyntaxError", - message="Invalid syntax", - ) - mock_session.run.side_effect = neo4j_error - - mock_driver = MagicMock() - mock_driver.session.return_value = mock_session - db_module._driver = mock_driver - - with pytest.raises(db_module.GraphDatabaseQueryException): - with db_module.get_session( - default_access_mode=neo4j.READ_ACCESS - ) as session: - session.run("INVALID CYPHER") - - -class TestThreadSafety: - """Test thread-safe initialization.""" - - @pytest.fixture(autouse=True) - def reset_module_state(self): - """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - - original_driver = db_module._driver - - db_module._driver = None - - yield - - db_module._driver = original_driver - - @patch("api.attack_paths.database.settings") - @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") - def test_concurrent_init_creates_single_driver( - self, mock_driver_factory, mock_settings - ): - """Multiple threads calling init_driver() should create only one driver.""" - import api.attack_paths.database as db_module - - mock_driver = MagicMock() - mock_driver_factory.return_value = mock_driver - mock_settings.DATABASES = { - "neo4j": { - "HOST": "localhost", - "PORT": 7687, - "USER": "neo4j", - "PASSWORD": "password", - } - } - - results = [] - errors = [] - - def call_init(): - try: - result = db_module.init_driver() - results.append(result) - except Exception as e: - errors.append(e) - - threads = [threading.Thread(target=call_init) for _ in range(10)] - - for t in threads: - t.start() - for t in threads: - t.join() - - assert not errors, f"Threads raised errors: {errors}" - - # Only one driver created - assert mock_driver_factory.call_count == 1 - - # All threads got the same driver instance - assert all(r is mock_driver for r in results) - assert len(results) == 10 - - -class TestHasProviderData: - """Test has_provider_data helper for checking provider nodes in Neo4j.""" - - def test_returns_true_when_nodes_exist(self): - import api.attack_paths.database as db_module - - mock_session = MagicMock() - mock_result = MagicMock() - mock_result.single.return_value = MagicMock() # non-None record - mock_session.run.return_value = mock_result - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = mock_session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ): - assert db_module.has_provider_data("db-tenant-abc", "provider-123") is True - - mock_session.run.assert_called_once() - - def test_returns_false_when_no_nodes(self): - import api.attack_paths.database as db_module - - mock_session = MagicMock() - mock_result = MagicMock() - mock_result.single.return_value = None - mock_session.run.return_value = mock_result - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = mock_session - session_ctx.__exit__.return_value = False - - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ): - assert db_module.has_provider_data("db-tenant-abc", "provider-123") is False - - def test_returns_false_when_database_not_found(self): - import api.attack_paths.database as db_module - - session_ctx = MagicMock() - session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( - message="Database does not exist", - code="Neo.ClientError.Database.DatabaseNotFound", +class TestDatabaseNameHelper: + def test_tenant_name_lowercases_uuid(self): + assert ( + db_module.get_database_name("ABC-123", temporary=False) + == "db-tenant-abc-123" ) - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ): - assert ( - db_module.has_provider_data("db-tenant-gone", "provider-123") is False - ) - - def test_raises_on_other_errors(self): - import api.attack_paths.database as db_module - - session_ctx = MagicMock() - session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( - message="Connection refused", - code="Neo.TransientError.General.UnknownError", + def test_temporary_name_uses_tmp_scan_prefix(self): + assert ( + db_module.get_database_name("XYZ-789", temporary=True) + == "db-tmp-scan-xyz-789" ) - with patch( - "api.attack_paths.database.get_session", - return_value=session_ctx, - ): - with pytest.raises(db_module.GraphDatabaseQueryException): - db_module.has_provider_data("db-tenant-abc", "provider-123") + +class TestExceptionHierarchy: + """`tasks/` and `api/v1/views.py` import these from the facade.""" + + def test_write_query_is_graph_database_exception(self): + assert issubclass( + db_module.WriteQueryNotAllowedException, + db_module.GraphDatabaseQueryException, + ) + + def test_client_statement_is_graph_database_exception(self): + assert issubclass( + db_module.ClientStatementException, db_module.GraphDatabaseQueryException + ) + + def test_exception_str_includes_code_when_set(self): + exc = db_module.GraphDatabaseQueryException( + message="boom", code="Neo.ClientError.X.Y" + ) + assert str(exc) == "Neo.ClientError.X.Y: boom" + + def test_exception_str_falls_back_to_message_without_code(self): + exc = db_module.GraphDatabaseQueryException(message="boom") + assert str(exc) == "boom" + + +class TestExecuteReadQueryRoutes: + def test_execute_read_query_delegates_to_sink(self, sink_backend_stub): + sink_backend_stub.execute_read_query.return_value = "graph" + + result = db_module.execute_read_query( + "db-tenant-abc", "MATCH (n) RETURN n", {"provider_uid": "123"} + ) + + sink_backend_stub.execute_read_query.assert_called_once_with( + "db-tenant-abc", "MATCH (n) RETURN n", {"provider_uid": "123"} + ) + assert result == "graph" + + def test_execute_read_query_defaults_parameters_to_none(self, sink_backend_stub): + db_module.execute_read_query("db-tenant-abc", "MATCH (n) RETURN n") + + sink_backend_stub.execute_read_query.assert_called_once_with( + "db-tenant-abc", "MATCH (n) RETURN n", None + ) + + +class TestSinkOperationsDelegation: + def test_has_provider_data_delegates_to_sink(self, sink_backend_stub): + sink_backend_stub.has_provider_data.return_value = True + + assert db_module.has_provider_data("db-tenant-abc", "provider-123") is True + sink_backend_stub.has_provider_data.assert_called_once_with( + "db-tenant-abc", "provider-123" + ) + + def test_drop_subgraph_delegates_to_sink(self, sink_backend_stub): + sink_backend_stub.drop_subgraph.return_value = 42 + + assert db_module.drop_subgraph("db-tenant-abc", "provider-123") == 42 + sink_backend_stub.drop_subgraph.assert_called_once_with( + "db-tenant-abc", "provider-123" + ) + + +class TestRoutingByDatabasePrefix: + """`db-tmp-scan-*` and `None` route to ingest; everything else to sink.""" + + def test_create_database_routes_temp_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.create_database("db-tmp-scan-uuid-1") + + mock_ingest.create_database.assert_called_once_with("db-tmp-scan-uuid-1") + sink_backend_stub.create_database.assert_not_called() + + def test_create_database_routes_tenant_to_sink(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.create_database("db-tenant-abc") + + sink_backend_stub.create_database.assert_called_once_with("db-tenant-abc") + mock_ingest.create_database.assert_not_called() + + def test_drop_database_routes_temp_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.drop_database("db-tmp-scan-uuid-1") + + mock_ingest.drop_database.assert_called_once_with("db-tmp-scan-uuid-1") + sink_backend_stub.drop_database.assert_not_called() + + def test_drop_database_routes_tenant_to_sink(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.drop_database("db-tenant-abc") + + sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc") + mock_ingest.drop_database.assert_not_called() + + def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.clear_cache("db-tmp-scan-uuid-1") + + mock_ingest.clear_cache.assert_called_once_with("db-tmp-scan-uuid-1") + sink_backend_stub.clear_cache.assert_not_called() + + def test_clear_cache_routes_tenant_to_sink(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + db_module.clear_cache("db-tenant-abc") + + sink_backend_stub.clear_cache.assert_called_once_with("db-tenant-abc") + mock_ingest.clear_cache.assert_not_called() + + def test_get_session_routes_temp_to_ingest(self, sink_backend_stub): + sentinel = MagicMock() + with patch("api.attack_paths.database.ingest") as mock_ingest: + mock_ingest.get_session.return_value = sentinel + + result = db_module.get_session("db-tmp-scan-uuid-1") + + assert result is sentinel + mock_ingest.get_session.assert_called_once() + sink_backend_stub.get_session.assert_not_called() + + def test_get_session_routes_none_to_ingest(self, sink_backend_stub): + sentinel = MagicMock() + with patch("api.attack_paths.database.ingest") as mock_ingest: + mock_ingest.get_session.return_value = sentinel + + result = db_module.get_session(None) + + assert result is sentinel + sink_backend_stub.get_session.assert_not_called() + + def test_get_ingest_uri_delegates_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + mock_ingest.get_uri.return_value = "bolt://neo4j:7687" + + assert db_module.get_ingest_uri() == "bolt://neo4j:7687" + + mock_ingest.get_uri.assert_called_once_with() + + def test_get_session_routes_tenant_to_sink(self, sink_backend_stub): + sentinel = MagicMock() + sink_backend_stub.get_session.return_value = sentinel + with patch("api.attack_paths.database.ingest") as mock_ingest: + result = db_module.get_session("db-tenant-abc") + + assert result is sentinel + mock_ingest.get_session.assert_not_called() diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py index 6745c36e91..d05a55ce5a 100644 --- a/api/src/backend/api/tests/test_authentication.py +++ b/api/src/backend/api/tests/test_authentication.py @@ -1,15 +1,15 @@ import time -from datetime import datetime, timedelta, timezone -from unittest.mock import patch +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest -from django.test import RequestFactory -from rest_framework.exceptions import AuthenticationFailed - -from api.authentication import TenantAPIKeyAuthentication +from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication from api.db_router import MainRouter from api.models import TenantAPIKey +from django.db.models.query import QuerySet +from django.test import RequestFactory +from rest_framework.exceptions import AuthenticationFailed @pytest.mark.django_db @@ -65,6 +65,54 @@ class TestTenantAPIKeyAuthentication: # Verify the manager was restored assert TenantAPIKey.objects == original_manager + def test_authenticate_credentials_keeps_manager_during_lookup( + self, auth_backend, api_keys_fixture, request_factory + ): + """Authentication must not expose a QuerySet as the model manager.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + _, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1) + + original_get = QuerySet.get + manager_has_create_api_key = [] + + def observe_manager(queryset, *args, **kwargs): + manager_has_create_api_key.append( + hasattr(TenantAPIKey.objects, "create_api_key") + ) + return original_get(queryset, *args, **kwargs) + + request = request_factory.get("/") + + with patch.object(QuerySet, "get", observe_manager): + auth_backend._authenticate_credentials(request, encrypted_key) + + assert manager_has_create_api_key + assert all(manager_has_create_api_key) + + @pytest.mark.parametrize( + "payload", + [ + {"_pk": str(uuid4()), "_exp": "not-a-timestamp"}, + { + "_pk": "not-a-uuid", + "_exp": (datetime.now(UTC) + timedelta(days=1)).timestamp(), + }, + {"_pk": str(uuid4()), "_exp": True}, + ], + ) + def test_authenticate_credentials_rejects_malformed_payloads( + self, auth_backend, request_factory, payload + ): + """Malformed decrypted payloads fail as authentication errors.""" + request = request_factory.get("/") + encrypted_key = auth_backend.key_crypto.generate(payload) + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend._authenticate_credentials(request, encrypted_key) + + assert str(exc_info.value.detail) == "Invalid API Key." + def test_authenticate_credentials_restores_manager_on_exception( self, auth_backend, request_factory ): @@ -104,7 +152,7 @@ class TestTenantAPIKeyAuthentication: # Verify that last_used_at was updated api_key.refresh_from_db() assert api_key.last_used_at is not None - assert (datetime.now(timezone.utc) - api_key.last_used_at).seconds < 5 + assert (datetime.now(UTC) - api_key.last_used_at).seconds < 5 def test_authenticate_valid_api_key_uses_admin_database( self, auth_backend, api_keys_fixture, request_factory @@ -195,7 +243,7 @@ class TestTenantAPIKeyAuthentication: name="Expired API Key", tenant_id=tenant.id, entity=user, - expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + expiry_date=datetime.now(UTC) - timedelta(days=1), ) request = request_factory.get("/") @@ -217,7 +265,7 @@ class TestTenantAPIKeyAuthentication: # Manually create an encrypted key with a non-existent ID payload = { "_pk": non_existent_uuid, - "_exp": (datetime.now(timezone.utc) + timedelta(days=30)).timestamp(), + "_exp": (datetime.now(UTC) + timedelta(days=30)).timestamp(), } encrypted_key = auth_backend.key_crypto.generate(payload) fake_key = f"{api_key.prefix}.{encrypted_key}" @@ -368,7 +416,7 @@ class TestTenantAPIKeyAuthentication: name="Short-lived API Key", tenant_id=tenant.id, entity=user, - expiry_date=datetime.now(timezone.utc) + timedelta(seconds=1), + expiry_date=datetime.now(UTC) + timedelta(seconds=1), ) # Wait for the key to expire @@ -382,3 +430,62 @@ class TestTenantAPIKeyAuthentication: auth_backend.authenticate(request) assert str(exc_info.value.detail) == "API Key has already expired." + + +class TestSSEAuthentication: + """`SSEAuthentication` adds an `?access_token=` fallback for + browser `EventSource` clients while keeping the standard + `Authorization` header as the authoritative source.""" + + def test_header_present_delegates_to_super(self): + request = MagicMock() + request.headers = {"Authorization": "Bearer header-token"} + with patch.object( + SSEAuthentication.__bases__[0], "authenticate", return_value=("user", "tok") + ) as super_auth: + result = SSEAuthentication().authenticate(request) + super_auth.assert_called_once_with(request) + assert result == ("user", "tok") + + def test_no_header_no_query_token_delegates_to_super(self): + request = MagicMock() + request.headers = {} + request.query_params = {} + with patch.object( + SSEAuthentication.__bases__[0], "authenticate", return_value=None + ) as super_auth: + result = SSEAuthentication().authenticate(request) + super_auth.assert_called_once_with(request) + assert result is None + + def test_query_token_used_only_as_fallback(self): + request = MagicMock() + request.headers = {} + request.query_params = {"access_token": "query-jwt"} + + jwt_instance = MagicMock() + jwt_instance.get_validated_token.return_value = "validated" + jwt_instance.get_user.return_value = "query-user" + + with patch.object(SSEAuthentication, "jwt_auth", jwt_instance): + user, token = SSEAuthentication().authenticate(request) + + jwt_instance.get_validated_token.assert_called_once_with("query-jwt") + assert user == "query-user" + assert token == "validated" + + def test_query_token_invalid_raises_authentication_failed(self): + request = MagicMock() + request.headers = {} + request.query_params = {"access_token": "bad-token"} + + jwt_instance = MagicMock() + jwt_instance.get_validated_token.side_effect = AuthenticationFailed( + "Invalid token" + ) + + with patch.object(SSEAuthentication, "jwt_auth", jwt_instance): + with pytest.raises(AuthenticationFailed): + SSEAuthentication().authenticate(request) + + jwt_instance.get_validated_token.assert_called_once_with("bad-token") diff --git a/api/src/backend/api/tests/test_celery_settings.py b/api/src/backend/api/tests/test_celery_settings.py index d4010796ee..dcd8930edc 100644 --- a/api/src/backend/api/tests/test_celery_settings.py +++ b/api/src/backend/api/tests/test_celery_settings.py @@ -41,3 +41,30 @@ class TestBuildCeleryBrokerUrl: def test_invalid_scheme_raises_error(self): with pytest.raises(ValueError, match="Invalid VALKEY_SCHEME 'http'"): _build_celery_broker_url("http", "", "", "valkey", "6379", "0") + + +class TestCeleryWorkerConcurrency: + def _reimport_settings(self): + """Fresh import — importlib.reload() doesn't clear the module namespace, + so an attribute set by a prior test would leak into the unset case.""" + import sys + + sys.modules.pop("config.settings.celery", None) + import config.settings.celery as celery_settings + + return celery_settings + + def test_unset_leaves_setting_absent(self, monkeypatch): + monkeypatch.delenv("DJANGO_CELERY_WORKER_CONCURRENCY", raising=False) + mod = self._reimport_settings() + assert not hasattr(mod, "CELERY_WORKER_CONCURRENCY") + + def test_explicit_value_applied(self, monkeypatch): + monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "8") + mod = self._reimport_settings() + assert mod.CELERY_WORKER_CONCURRENCY == 8 + + def test_invalid_value_raises(self, monkeypatch): + monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "not-a-number") + with pytest.raises(ValueError): + self._reimport_settings() diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 8774f33787..d613a2538e 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -1,13 +1,20 @@ 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, + warm_compliance_caches, ) from api.models import Provider +from prowler.lib.check.compliance_models import ( + get_bulk_compliance_frameworks_universal, +) class TestCompliance: @@ -23,16 +30,16 @@ class TestCompliance: assert set(checks) == {"check1", "check2", "check3"} mock_check_metadata.get_bulk.assert_called_once_with(provider_type) - @patch("api.compliance.Compliance") - def test_get_prowler_provider_compliance(self, mock_compliance): + @patch("api.compliance.get_bulk_compliance_frameworks_universal") + def test_get_prowler_provider_compliance(self, mock_get_bulk): provider_type = Provider.ProviderChoices.AWS - mock_compliance.get_bulk.return_value = { + mock_get_bulk.return_value = { "compliance1": MagicMock(), "compliance2": MagicMock(), } compliance_data = get_prowler_provider_compliance(provider_type) - assert compliance_data == mock_compliance.get_bulk.return_value - mock_compliance.get_bulk.assert_called_once_with(provider_type) + assert compliance_data == mock_get_bulk.return_value + mock_get_bulk.assert_called_once_with(provider_type) @patch("api.compliance.get_prowler_provider_checks") @patch("api.models.Provider.ProviderChoices") @@ -46,9 +53,9 @@ class TestCompliance: prowler_compliance = { "aws": { "compliance1": MagicMock( - Requirements=[ + requirements=[ MagicMock( - Checks=["check1", "check2"], + checks={"aws": ["check1", "check2"]}, ), ], ), @@ -162,35 +169,38 @@ class TestCompliance: def test_generate_compliance_overview_template(self, mock_provider_choices): mock_provider_choices.values = ["aws"] + # ``name`` is a reserved MagicMock kwarg (it labels the mock for repr, + # it does NOT set a ``.name`` attribute), so it must be assigned + # explicitly after construction. requirement1 = MagicMock( - Id="requirement1", - Name="Requirement 1", - Description="Description of requirement 1", - Attributes=[], - Checks=["check1", "check2"], - Tactics=["tactic1"], - SubTechniques=["subtechnique1"], - Platforms=["platform1"], - TechniqueURL="https://example.com", + id="requirement1", + description="Description of requirement 1", + attributes=[], + checks={"aws": ["check1", "check2"]}, + tactics=["tactic1"], + sub_techniques=["subtechnique1"], + platforms=["platform1"], + technique_url="https://example.com", ) + requirement1.name = "Requirement 1" requirement2 = MagicMock( - Id="requirement2", - Name="Requirement 2", - Description="Description of requirement 2", - Attributes=[], - Checks=[], - Tactics=[], - SubTechniques=[], - Platforms=[], - TechniqueURL="", + id="requirement2", + description="Description of requirement 2", + attributes=[], + checks={"aws": []}, + tactics=[], + sub_techniques=[], + platforms=[], + technique_url="", ) + requirement2.name = "Requirement 2" compliance1 = MagicMock( - Requirements=[requirement1, requirement2], - Framework="Framework 1", - Version="1.0", - Description="Description of compliance1", - Name="Compliance 1", + requirements=[requirement1, requirement2], + framework="Framework 1", + version="1.0", + description="Description of compliance1", ) + compliance1.name = "Compliance 1" prowler_compliance = {"aws": {"compliance1": compliance1}} template = generate_compliance_overview_template(prowler_compliance) @@ -250,3 +260,156 @@ 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() + # The warming flags are module-global; clear them so they do not leak + # between tests that call warm_compliance_caches. + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() + try: + yield + finally: + compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() + compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous) + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() + + +class TestGetComplianceFrameworks: + def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache): + with patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk: + mock_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_get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_caches_result_per_provider(self, reset_compliance_cache): + with patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk: + mock_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_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 + ``get_bulk_compliance_frameworks_universal``. + + 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(get_bulk_compliance_frameworks_universal(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 get_bulk_compliance_frameworks_universal: " + f"{sorted(missing)}" + ) + + +class TestWarmComplianceCaches: + def test_warms_all_provider_types_by_default(self, reset_compliance_cache): + provider_types = list(Provider.ProviderChoices.values) + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches() + + warmed = {call.args[0] for call in mock_frameworks.call_args_list} + assert warmed == set(provider_types) + assert mock_frameworks.call_count == len(provider_types) + assert mock_ensure.call_count == len(provider_types) + + def test_warms_only_requested_provider_types(self, reset_compliance_cache): + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS) + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_populates_module_cache(self, reset_compliance_cache): + with ( + patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk, + patch("api.compliance._ensure_provider_loaded"), + ): + mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert ( + Provider.ProviderChoices.AWS + in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS + ) + + def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache): + """A failing provider (even on SystemExit) is isolated; others warm.""" + providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA] + + def fake_frameworks(provider_type): + if provider_type == Provider.ProviderChoices.OKTA: + raise SystemExit(1) + return [] + + with ( + patch( + "api.compliance.get_compliance_frameworks", side_effect=fake_frameworks + ), + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + failed = warm_compliance_caches(providers) + + assert failed == [Provider.ProviderChoices.OKTA] + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_sets_readiness_flags(self, reset_compliance_cache): + assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert not compliance_module.COMPLIANCE_WARMED.is_set() + + with ( + patch("api.compliance.get_compliance_frameworks"), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert compliance_module.COMPLIANCE_WARMED.is_set() + + def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache): + """A failed provider still leaves the caches flagged as warmed.""" + with ( + patch( + "api.compliance.get_compliance_frameworks", + side_effect=SystemExit(1), + ), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMED.is_set() diff --git a/api/src/backend/api/tests/test_cypher_sanitizer.py b/api/src/backend/api/tests/test_cypher_sanitizer.py index a54afcd8bb..6caca47a56 100644 --- a/api/src/backend/api/tests/test_cypher_sanitizer.py +++ b/api/src/backend/api/tests/test_cypher_sanitizer.py @@ -3,13 +3,11 @@ from unittest.mock import patch import pytest - -from rest_framework.exceptions import ValidationError - from api.attack_paths.cypher_sanitizer import ( inject_provider_label, validate_custom_query, ) +from rest_framework.exceptions import ValidationError PROVIDER_ID = "019c41ee-7df3-7dec-a684-d839f95619f8" LABEL = "_Provider_019c41ee7df37deca684d839f95619f8" @@ -202,9 +200,7 @@ class TestClauseSplitting: def test_multiple_match_clauses(self): cypher = ( - "MATCH (a:AWSAccount)--(b:AWSRole) " - "MATCH (b)--(c:AWSPolicy) " - "RETURN a, b, c" + "MATCH (a:AWSAccount)--(b:AWSRole) MATCH (b)--(c:AWSPolicy) RETURN a, b, c" ) result = _inject(cypher) assert f"(a:AWSAccount:{LABEL})" in result @@ -265,9 +261,7 @@ class TestRealWorldQueries: def test_custom_bare_query(self): cypher = ( - "MATCH (a)-[:HAS_POLICY]->(b)\n" - "WHERE a.name CONTAINS 'admin'\n" - "RETURN a, b" + "MATCH (a)-[:HAS_POLICY]->(b)\nWHERE a.name CONTAINS 'admin'\nRETURN a, b" ) result = _inject(cypher) assert f"(a:{LABEL})" in result @@ -344,9 +338,7 @@ class TestEdgeCases: assert f"(outer:AWSAccount:{LABEL})" in result def test_multiple_protected_regions(self): - cypher = ( - "MATCH (n:X {a: 'hello'}) " 'WHERE n.b = "world" ' "// comment\n" "RETURN n" - ) + cypher = "MATCH (n:X {a: 'hello'}) WHERE n.b = \"world\" // comment\nRETURN n" result = _inject(cypher) assert "'hello'" in result assert '"world"' in result diff --git a/api/src/backend/api/tests/test_database.py b/api/src/backend/api/tests/test_database.py index 46d3203414..8d328c6a91 100644 --- a/api/src/backend/api/tests/test_database.py +++ b/api/src/backend/api/tests/test_database.py @@ -1,12 +1,12 @@ -import pytest -from django.conf import settings -from django.db.migrations.recorder import MigrationRecorder -from django.db.utils import ConnectionRouter +from unittest.mock import patch +import pytest from api.db_router import MainRouter from api.rls import Tenant from config.django.base import DATABASE_ROUTERS as PROD_DATABASE_ROUTERS -from unittest.mock import patch +from django.conf import settings +from django.db.migrations.recorder import MigrationRecorder +from django.db.utils import ConnectionRouter @patch("api.db_router.MainRouter.admin_db", new="admin") diff --git a/api/src/backend/api/tests/test_db_connection_labels.py b/api/src/backend/api/tests/test_db_connection_labels.py new file mode 100644 index 0000000000..a39e7d9051 --- /dev/null +++ b/api/src/backend/api/tests/test_db_connection_labels.py @@ -0,0 +1,55 @@ +from config.django.base import label_postgres_connections + + +class TestLabelPostgresConnections: + def test_labels_postgres_and_skips_neo4j(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan") + databases = { + "default": {"ENGINE": "psqlextra.backend"}, + "neo4j": {"HOST": "neo4j", "PORT": "7687"}, + } + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "scan:default" + assert "OPTIONS" not in databases["neo4j"] + + def test_labels_plain_postgresql_backend(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "api") + databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}} + + label_postgres_connections(databases) + + assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas" + + def test_defaults_component_to_api_when_unset(self, monkeypatch): + monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "api:default" + + def test_preserves_existing_options(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker") + databases = { + "replica": { + "ENGINE": "psqlextra.backend", + "OPTIONS": {"sslmode": "require"}, + } + } + + label_postgres_connections(databases) + + assert databases["replica"]["OPTIONS"] == { + "sslmode": "require", + "application_name": "worker:replica", + } + + def test_truncates_application_name_to_63_bytes(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert len(databases["default"]["OPTIONS"]["application_name"]) == 63 diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py index 18935b9a3e..06b528b44a 100644 --- a/api/src/backend/api/tests/test_db_utils.py +++ b/api/src/backend/api/tests/test_db_utils.py @@ -1,14 +1,8 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from unittest.mock import MagicMock, patch import pytest -from django.conf import settings -from django.db import DEFAULT_DB_ALIAS, OperationalError -from freezegun import freeze_time -from psycopg2 import sql as psycopg2_sql -from rest_framework_json_api.serializers import ValidationError - from api.db_utils import ( POSTGRES_TENANT_VAR, PostgresEnumMigration, @@ -23,6 +17,11 @@ from api.db_utils import ( update_objects_in_batches, ) from api.models import Provider +from django.conf import settings +from django.db import DEFAULT_DB_ALIAS, OperationalError +from freezegun import freeze_time +from psycopg2 import sql as psycopg2_sql +from rest_framework_json_api.serializers import ValidationError @pytest.fixture @@ -94,18 +93,16 @@ class TestEnumToChoices: class TestOneWeekFromNow: def test_one_week_from_now(self): with patch("api.db_utils.datetime") as mock_datetime: - mock_datetime.now.return_value = datetime(2023, 1, 1, tzinfo=timezone.utc) - expected_result = datetime(2023, 1, 8, tzinfo=timezone.utc) + mock_datetime.now.return_value = datetime(2023, 1, 1, tzinfo=UTC) + expected_result = datetime(2023, 1, 8, tzinfo=UTC) result = one_week_from_now() assert result == expected_result def test_one_week_from_now_with_timezone(self): with patch("api.db_utils.datetime") as mock_datetime: - mock_datetime.now.return_value = datetime( - 2023, 6, 15, 12, 0, tzinfo=timezone.utc - ) - expected_result = datetime(2023, 6, 22, 12, 0, tzinfo=timezone.utc) + mock_datetime.now.return_value = datetime(2023, 6, 15, 12, 0, tzinfo=UTC) + expected_result = datetime(2023, 6, 22, 12, 0, tzinfo=UTC) result = one_week_from_now() assert result == expected_result @@ -939,9 +936,9 @@ class TestPostgresEnumMigration: mock_cursor.execute.assert_called_once() query_arg = mock_cursor.execute.call_args[0][0] - assert isinstance( - query_arg, psycopg2_sql.Composable - ), "create_enum_type must pass a psycopg2.sql.Composable, not a raw string." + assert isinstance(query_arg, psycopg2_sql.Composable), ( + "create_enum_type must pass a psycopg2.sql.Composable, not a raw string." + ) # Verify the composed SQL structure: CREATE TYPE AS ENUM () parts = query_arg.seq assert parts[0] == psycopg2_sql.SQL("CREATE TYPE ") @@ -962,9 +959,9 @@ class TestPostgresEnumMigration: mock_cursor.execute.assert_called_once() query_arg = mock_cursor.execute.call_args[0][0] - assert isinstance( - query_arg, psycopg2_sql.Composable - ), "drop_enum_type must pass a psycopg2.sql.Composable, not a raw string." + assert isinstance(query_arg, psycopg2_sql.Composable), ( + "drop_enum_type must pass a psycopg2.sql.Composable, not a raw string." + ) # Verify the composed SQL structure: DROP TYPE parts = query_arg.seq assert parts[0] == psycopg2_sql.SQL("DROP TYPE ") diff --git a/api/src/backend/api/tests/test_decorators.py b/api/src/backend/api/tests/test_decorators.py index 2d09a40734..25053a2258 100644 --- a/api/src/backend/api/tests/test_decorators.py +++ b/api/src/backend/api/tests/test_decorators.py @@ -2,12 +2,11 @@ import uuid from unittest.mock import call, patch import pytest -from django.core.exceptions import ObjectDoesNotExist -from django.db import DatabaseError, IntegrityError - from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY from api.decorators import handle_provider_deletion, set_tenant from api.exceptions import ProviderDeletedException +from django.core.exceptions import ObjectDoesNotExist +from django.db import DatabaseError, IntegrityError @pytest.mark.django_db diff --git a/api/src/backend/api/tests/test_health.py b/api/src/backend/api/tests/test_health.py new file mode 100644 index 0000000000..b7cd20f697 --- /dev/null +++ b/api/src/backend/api/tests/test_health.py @@ -0,0 +1,483 @@ +"""Tests for the health endpoints. + +Cover the IETF response envelope, status code mapping (200 / 503), the +``application/health+json`` media type and per-probe failure modes. +""" + +from unittest.mock import patch + +import pytest +from api import health +from config import version as config_version +from django.core.cache import cache +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +HEALTH_MEDIA_TYPE = "application/health+json" + + +@pytest.fixture(autouse=True) +def _reset_health_state(): + """Per-test isolation: clear throttle counters and the readiness cache. + + DRF's ScopedRateThrottle persists state in Django's cache; without + clearing it the throttle budget would be shared across tests and trip + midway through the suite. + """ + cache.clear() + health._readiness_cache = None + yield + cache.clear() + health._readiness_cache = None + + +@pytest.fixture +def api_client(): + return APIClient() + + +def _assert_health_envelope(body): + """Every health response must carry the RFC top-level descriptors.""" + assert body["version"] == config_version.API_VERSION + assert body["releaseId"] == config_version.RELEASE_ID + assert body["serviceId"] == health.SERVICE_ID + assert body["description"] == health.SERVICE_DESCRIPTION + + +class TestLivenessEndpoint: + def test_returns_200_with_pass_status(self, api_client): + response = api_client.get(reverse("health-live")) + + assert response.status_code == status.HTTP_200_OK + assert response["Content-Type"].startswith(HEALTH_MEDIA_TYPE) + assert response["Cache-Control"] == health.CACHE_CONTROL_HEADER + body = response.json() + assert body["status"] == "pass" + _assert_health_envelope(body) + + def test_does_not_require_authentication(self, api_client): + api_client.credentials() + + response = api_client.get(reverse("health-live")) + + assert response.status_code == status.HTTP_200_OK + + def test_does_not_run_dependency_checks(self, api_client): + with ( + patch("api.health._probe_postgres") as mock_pg, + patch("api.health._probe_valkey") as mock_vk, + patch("api.health._probe_graph_db") as mock_neo, + ): + response = api_client.get(reverse("health-live")) + + assert response.status_code == status.HTTP_200_OK + mock_pg.assert_not_called() + mock_vk.assert_not_called() + mock_neo.assert_not_called() + + +class TestReadinessEndpoint: + @staticmethod + def _patch_probes(): + return ( + patch("api.health._probe_postgres", return_value=None), + patch("api.health._probe_valkey", return_value=None), + patch("api.health._probe_graph_db", return_value=None), + ) + + def test_returns_200_and_pass_when_all_dependencies_healthy(self, api_client): + with ( + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_200_OK + assert response["Content-Type"].startswith(HEALTH_MEDIA_TYPE) + assert response["Cache-Control"] == health.CACHE_CONTROL_HEADER + + body = response.json() + _assert_health_envelope(body) + assert body["status"] == "pass" + + # Per RFC, `checks` values are arrays of one or more measurement + # objects. We use a single measurement per dependency. + assert set(body["checks"].keys()) == { + "postgres:responseTime", + "valkey:responseTime", + "graphdb:responseTime", + } + for key in body["checks"]: + entries = body["checks"][key] + assert isinstance(entries, list) and len(entries) == 1 + entry = entries[0] + assert entry["status"] == "pass" + assert entry["componentType"] == "datastore" + assert entry["observedUnit"] == "ms" + assert isinstance(entry["observedValue"], (int, float)) + assert entry["observedValue"] >= 0 + assert "time" in entry + # `output` must not leak when the check passed. + assert "output" not in entry + + @pytest.mark.parametrize("sink", ["neo4j", "neptune"]) + def test_graphdb_component_id_reflects_active_sink(self, api_client, sink): + from django.test import override_settings + + with ( + override_settings(ATTACK_PATHS_SINK_DATABASE=sink), + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_200_OK + entry = response.json()["checks"]["graphdb:responseTime"][0] + # Stable key, but the concrete store is named in componentId. + assert entry["componentId"] == sink + + def test_returns_503_and_fail_when_postgres_is_down(self, api_client): + with ( + patch( + "api.health._probe_postgres", + side_effect=RuntimeError("connection refused"), + ), + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + body = response.json() + assert body["status"] == "fail" + pg_entry = body["checks"]["postgres:responseTime"][0] + assert pg_entry["status"] == "fail" + # Exception detail is never echoed in the response, only logged. + assert "output" not in pg_entry + assert body["checks"]["valkey:responseTime"][0]["status"] == "pass" + assert body["checks"]["graphdb:responseTime"][0]["status"] == "pass" + + def test_returns_503_and_fail_when_valkey_is_down(self, api_client): + with ( + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey", side_effect=ConnectionError("timeout")), + patch("api.health._probe_graph_db"), + ): + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + body = response.json() + assert body["status"] == "fail" + vk_entry = body["checks"]["valkey:responseTime"][0] + assert vk_entry["status"] == "fail" + assert "output" not in vk_entry + + def test_returns_503_and_fail_when_graph_db_is_down(self, api_client): + with ( + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey"), + patch( + "api.health._probe_graph_db", + side_effect=RuntimeError("ServiceUnavailable"), + ), + ): + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + body = response.json() + assert body["status"] == "fail" + graph_db_entry = body["checks"]["graphdb:responseTime"][0] + assert graph_db_entry["status"] == "fail" + assert "output" not in graph_db_entry + + def test_reports_all_failures_simultaneously(self, api_client): + with ( + patch("api.health._probe_postgres", side_effect=RuntimeError("pg down")), + patch("api.health._probe_valkey", side_effect=RuntimeError("vk down")), + patch("api.health._probe_graph_db", side_effect=RuntimeError("neo down")), + ): + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + body = response.json() + assert body["status"] == "fail" + for key in ( + "postgres:responseTime", + "valkey:responseTime", + "graphdb:responseTime", + ): + entry = body["checks"][key][0] + assert entry["status"] == "fail" + # No dependency-specific error string leaks into the payload. + assert "output" not in entry + + def test_does_not_leak_exception_detail_on_failure(self, api_client): + # Sanity check: an exception message resembling infra detail + # (host, port, credentials) must not surface in the response under + # any field. + sensitive = ( + "connection to server at " + '"postgres-rw.prod.svc.cluster.local" (10.0.0.5), port 5432 ' + 'failed: FATAL: password authentication failed for user "prowler_user"' + ) + with ( + patch("api.health._probe_postgres", side_effect=RuntimeError(sensitive)), + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + response = api_client.get(reverse("health-ready")) + + body = response.json() + assert "output" not in body["checks"]["postgres:responseTime"][0] + payload_text = response.content.decode() + for token in ( + "postgres-rw", + "10.0.0.5", + "5432", + "prowler_user", + "password authentication failed", + ): + assert token not in payload_text + + def test_does_not_require_authentication(self, api_client): + with ( + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + api_client.credentials() + response = api_client.get(reverse("health-ready")) + + assert response.status_code == status.HTTP_200_OK + + +class TestReadinessCache: + """In-process cache caps the rate at which real probes hit the deps.""" + + def test_result_is_cached_for_ttl_seconds(self, api_client): + with ( + patch("api.health._probe_postgres") as pg, + patch("api.health._probe_valkey") as vk, + patch("api.health._probe_graph_db") as neo, + ): + r1 = api_client.get(reverse("health-ready")) + r2 = api_client.get(reverse("health-ready")) + + assert r1.status_code == status.HTTP_200_OK + assert r2.status_code == status.HTTP_200_OK + # Second request must not trigger fresh dep checks within the TTL. + assert pg.call_count == 1 + assert vk.call_count == 1 + assert neo.call_count == 1 + # The cached payload is returned verbatim (same timestamps too). + assert r1.json() == r2.json() + + def test_re_probes_after_cache_ttl_expires(self, api_client): + with ( + patch("api.health._probe_postgres") as pg, + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + api_client.get(reverse("health-ready")) + assert pg.call_count == 1 + + # Rewind the cached timestamp past the TTL so the next request + # is forced to recompute. + cached_ts, payload, http_status_code = health._readiness_cache + health._readiness_cache = ( + cached_ts - health.READINESS_CACHE_TTL_SECONDS - 0.1, + payload, + http_status_code, + ) + api_client.get(reverse("health-ready")) + + assert pg.call_count == 2 + + def test_cache_persists_a_failing_result(self, api_client): + # A failing readiness result is cached too; this is intentional so + # an attacker spamming the endpoint during an outage cannot amplify + # the dependency load. + with ( + patch("api.health._probe_postgres", side_effect=RuntimeError("down")) as pg, + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + ): + r1 = api_client.get(reverse("health-ready")) + r2 = api_client.get(reverse("health-ready")) + + assert r1.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert r2.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert pg.call_count == 1 + + +class TestRateLimiting: + """The endpoints are unauthenticated and exposed; per-IP throttle caps + naive single-source floods.""" + + def test_live_blocks_after_budget_exhausted(self, api_client): + # Shrink the budget to 3 req per window so the test stays fast and + # deterministic. parse_rate runs once per throttle instance and + # each request gets a fresh instance, so this patch propagates. + from rest_framework.throttling import ScopedRateThrottle + + with patch.object(ScopedRateThrottle, "parse_rate", return_value=(3, 60)): + statuses = [ + api_client.get(reverse("health-live")).status_code for _ in range(4) + ] + + assert statuses[:3] == [status.HTTP_200_OK] * 3 + assert statuses[3] == status.HTTP_429_TOO_MANY_REQUESTS + + def test_ready_blocks_after_budget_exhausted(self, api_client): + from rest_framework.throttling import ScopedRateThrottle + + with ( + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey"), + patch("api.health._probe_graph_db"), + patch.object(ScopedRateThrottle, "parse_rate", return_value=(2, 60)), + ): + statuses = [ + api_client.get(reverse("health-ready")).status_code for _ in range(3) + ] + + assert statuses[:2] == [status.HTTP_200_OK] * 2 + assert statuses[2] == status.HTTP_429_TOO_MANY_REQUESTS + + +class TestProbeImplementations: + """Smoke tests for each probe primitive.""" + + @pytest.mark.django_db + def test_postgres_probe_succeeds_against_real_db(self): + assert health._probe_postgres() is None + + def test_postgres_probe_propagates_db_errors(self): + class _BoomCursor: + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def execute(self, *_args, **_kwargs): + raise RuntimeError("boom") + + def fetchone(self): # pragma: no cover - never reached + return None + + with patch("api.health.connections") as mock_connections: + mock_connections.__getitem__.return_value.cursor.return_value = ( + _BoomCursor() + ) + with pytest.raises(RuntimeError, match="boom"): + health._probe_postgres() + + def test_valkey_probe_succeeds_when_ping_returns_true(self): + with patch("api.health.redis.Redis.from_url") as mock_from_url: + mock_from_url.return_value.ping.return_value = True + assert health._probe_valkey() is None + + def test_valkey_probe_raises_when_ping_returns_false(self): + with patch("api.health.redis.Redis.from_url") as mock_from_url: + mock_from_url.return_value.ping.return_value = False + with pytest.raises(RuntimeError, match="PING"): + health._probe_valkey() + + def test_valkey_probe_propagates_connection_errors(self): + with patch("api.health.redis.Redis.from_url") as mock_from_url: + mock_from_url.return_value.ping.side_effect = ConnectionError("nope") + with pytest.raises(ConnectionError, match="nope"): + health._probe_valkey() + + def test_valkey_probe_suppresses_redis_error_on_close(self): + # A redis-py-level failure releasing the socket must not mask a + # successful PING (best-effort cleanup contract). + import redis as redis_pkg + + with patch("api.health.redis.Redis.from_url") as mock_from_url: + client = mock_from_url.return_value + client.ping.return_value = True + client.close.side_effect = redis_pkg.RedisError("connection reset") + + assert health._probe_valkey() is None + + client.close.assert_called_once_with() + + def test_valkey_probe_suppresses_oserror_on_close(self): + # Socket-layer failures (OSError family) on close are also part of + # the swallowed scope. + with patch("api.health.redis.Redis.from_url") as mock_from_url: + client = mock_from_url.return_value + client.ping.return_value = True + client.close.side_effect = OSError("EBADF") + + assert health._probe_valkey() is None + + client.close.assert_called_once_with() + + def test_valkey_probe_lets_unexpected_close_errors_propagate(self): + # The suppress() is deliberately narrow: anything outside + # (redis.RedisError, OSError) must surface so it is not silently + # hidden. + with patch("api.health.redis.Redis.from_url") as mock_from_url: + client = mock_from_url.return_value + client.ping.return_value = True + client.close.side_effect = RuntimeError("bug") + + with pytest.raises(RuntimeError, match="bug"): + health._probe_valkey() + + def test_graph_db_probe_calls_verify_connectivity(self): + with patch("api.attack_paths.database.verify_connectivity") as mock_verify: + mock_verify.return_value = None + assert health._probe_graph_db() is None + mock_verify.assert_called_once_with() + + def test_graph_db_probe_propagates_errors(self): + with patch( + "api.attack_paths.database.verify_connectivity", + side_effect=RuntimeError("unreachable"), + ): + with pytest.raises(RuntimeError, match="unreachable"): + health._probe_graph_db() + + def test_graph_db_probe_times_out_when_check_exceeds_budget(self): + # A sink whose connectivity check blocks past the probe budget must + # surface as a failure fast, not pin the request thread for the + # driver's full acquisition timeout. + import time as _time + + def _hang() -> None: + _time.sleep(2) + + with ( + patch("api.health.GRAPH_DB_PROBE_TIMEOUT_SECONDS", 0.2), + patch( + "api.attack_paths.database.verify_connectivity", + side_effect=_hang, + ), + ): + started = _time.perf_counter() + with pytest.raises(TimeoutError): + health._probe_graph_db() + elapsed = _time.perf_counter() - started + + assert elapsed < health.GRAPH_DB_PROBE_TIMEOUT_SECONDS + 1 + + +class TestStatusAggregation: + def test_pass_when_all_checks_pass(self): + entries = [{"status": "pass"}, {"status": "pass"}] + assert health._aggregate_status(entries) == "pass" + + def test_warn_when_any_check_warns_and_none_fail(self): + entries = [{"status": "pass"}, {"status": "warn"}] + assert health._aggregate_status(entries) == "warn" + + def test_fail_when_any_check_fails(self): + entries = [{"status": "pass"}, {"status": "warn"}, {"status": "fail"}] + assert health._aggregate_status(entries) == "fail" diff --git a/api/src/backend/api/tests/test_middleware.py b/api/src/backend/api/tests/test_middleware.py index 07165987de..08136dd68f 100644 --- a/api/src/backend/api/tests/test_middleware.py +++ b/api/src/backend/api/tests/test_middleware.py @@ -1,11 +1,10 @@ from unittest.mock import MagicMock, patch import pytest +from api.middleware import APILoggingMiddleware from django.http import HttpResponse from django.test import RequestFactory -from api.middleware import APILoggingMiddleware - @pytest.mark.django_db @patch("logging.getLogger") diff --git a/api/src/backend/api/tests/test_mixins.py b/api/src/backend/api/tests/test_mixins.py index 7daf9d5ff6..b90bcf4480 100644 --- a/api/src/backend/api/tests/test_mixins.py +++ b/api/src/backend/api/tests/test_mixins.py @@ -2,10 +2,6 @@ import json from uuid import uuid4 import pytest -from django_celery_results.models import TaskResult -from rest_framework import status -from rest_framework.response import Response - from api.exceptions import ( TaskFailedException, TaskInProgressException, @@ -14,6 +10,9 @@ from api.exceptions import ( from api.models import Task, User from api.rls import Tenant from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin +from django_celery_results.models import TaskResult +from rest_framework import status +from rest_framework.response import Response @pytest.mark.django_db diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index b8b7f61dd1..3ec823b89a 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -1,10 +1,7 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest from allauth.socialaccount.models import SocialApp -from django.core.exceptions import ValidationError -from django.db import IntegrityError - from api.db_router import MainRouter from api.models import ( ProviderComplianceScore, @@ -16,6 +13,8 @@ from api.models import ( StatusChoices, TenantComplianceSummary, ) +from django.core.exceptions import ValidationError +from django.db import IntegrityError @pytest.mark.django_db @@ -376,7 +375,7 @@ class TestProviderComplianceScoreModel: def test_create_provider_compliance_score(self, providers_fixture, scans_fixture): provider = providers_fixture[0] scan = scans_fixture[0] - scan.completed_at = datetime.now(timezone.utc) + scan.completed_at = datetime.now(UTC) scan.save() score = ProviderComplianceScore.objects.create( @@ -398,7 +397,7 @@ class TestProviderComplianceScoreModel: ): provider = providers_fixture[0] scan = scans_fixture[0] - scan.completed_at = datetime.now(timezone.utc) + scan.completed_at = datetime.now(UTC) scan.save() ProviderComplianceScore.objects.create( @@ -427,12 +426,12 @@ class TestProviderComplianceScoreModel: ): provider1, provider2, *_ = providers_fixture scan1 = scans_fixture[0] - scan1.completed_at = datetime.now(timezone.utc) + scan1.completed_at = datetime.now(UTC) scan1.save() scan2 = scans_fixture[2] scan2.state = StateChoices.COMPLETED - scan2.completed_at = datetime.now(timezone.utc) + scan2.completed_at = datetime.now(UTC) scan2.save() score1 = ProviderComplianceScore.objects.create( diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index 684a9e44b9..4f787b1f5a 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -2,10 +2,6 @@ import json from unittest.mock import ANY, Mock, patch import pytest -from conftest import TEST_PASSWORD, TODAY -from django.urls import reverse -from rest_framework import status - from api.models import ( Membership, ProviderGroup, @@ -16,6 +12,9 @@ from api.models import ( UserRoleRelationship, ) from api.v1.serializers import TokenSerializer +from conftest import TEST_PASSWORD, TODAY +from django.urls import reverse +from rest_framework import status @pytest.mark.django_db diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 5810a97b63..ea01075934 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -1,8 +1,7 @@ import pytest -from rest_framework.exceptions import ValidationError - from api.v1.serializer_utils.integrations import S3ConfigSerializer from api.v1.serializers import ImageProviderSecret +from rest_framework.exceptions import ValidationError class TestS3ConfigSerializer: diff --git a/api/src/backend/api/tests/test_sink.py b/api/src/backend/api/tests/test_sink.py new file mode 100644 index 0000000000..64c69cbed1 --- /dev/null +++ b/api/src/backend/api/tests/test_sink.py @@ -0,0 +1,626 @@ +"""Tests for the attack-paths sink factory and Neo4j sink. + +The sink module picks a backend per ``settings.ATTACK_PATHS_SINK_DATABASE``. +Neo4j is the default and preserves today's behavior; Neptune is opt-in and +builds dual writer/reader Bolt drivers. +""" + +import json +from importlib import import_module +from unittest.mock import MagicMock, patch + +import pytest + +# Prime patch-target resolution. `api.attack_paths.sink/__init__.py` doesn't +# eagerly import these submodules (they're loaded on demand inside the +# factory), so `mock.patch("api.attack_paths.sink..…")` would fail with +# AttributeError on first call. Importing here registers them as attributes +# of the package before any decorator runs. +import_module("api.attack_paths.sink.neo4j") +import_module("api.attack_paths.sink.neptune") + + +@pytest.fixture(autouse=True) +def reset_sink_state(): + """Reset the module-level backend singletons around each test. + + The cache lives in `api.attack_paths.sink.factory`, not on the package. + """ + from api.attack_paths.sink import factory + + original_backend = factory._backend + original_secondary = dict(factory._secondary_backends) + factory._backend = None + factory._secondary_backends.clear() + yield + factory._backend = original_backend + factory._secondary_backends.clear() + factory._secondary_backends.update(original_secondary) + + +class TestSinkFactory: + def test_default_resolves_to_neo4j(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + assert factory._resolve_setting() == "neo4j" + + def test_neptune_resolves_correctly(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + assert factory._resolve_setting() == "neptune" + + def test_invalid_value_raises(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "foo" + with pytest.raises(RuntimeError, match="ATTACK_PATHS_SINK_DATABASE"): + factory._resolve_setting() + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_init_builds_neo4j_backend_by_default(self, mock_driver, settings): + from api.attack_paths import sink as sink_module + from api.attack_paths.sink.neo4j import Neo4jSink + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + mock_driver.return_value = MagicMock() + + backend = sink_module.init() + + assert isinstance(backend, Neo4jSink) + mock_driver.assert_called_once() + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_init_builds_neptune_backend( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths import sink as sink_module + from api.attack_paths.sink.neptune import NeptuneSink + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "reader.example", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + mock_driver.return_value = MagicMock() + mock_auth_provider.return_value = lambda: None + + backend = sink_module.init() + + assert isinstance(backend, NeptuneSink) + # Writer + reader endpoints both trigger driver construction + assert mock_driver.call_count == 2 + writer_uri = mock_driver.call_args_list[0][0][0] + reader_uri = mock_driver.call_args_list[1][0][0] + assert writer_uri == "bolt+s://writer.example:8182" + assert reader_uri == "bolt+s://reader.example:8182" + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_neptune_reader_falls_back_to_writer( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths import sink as sink_module + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + mock_driver.return_value = MagicMock() + mock_auth_provider.return_value = lambda: None + + sink_module.init() + + # Only one driver call — reader aliases writer + assert mock_driver.call_count == 1 + + +class TestGetBackendForScan: + """``get_backend_for_scan`` routes by the row's recorded sink backend.""" + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_legacy_scan_in_neo4j_process_uses_active_backend( + self, mock_driver, settings + ): + from api.attack_paths import sink as sink_module + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + mock_driver.return_value = MagicMock() + + scan = MagicMock(sink_backend="neo4j") + backend = sink_module.get_backend_for_scan(scan) + + assert backend is sink_module.get_backend() + + def test_neptune_scan_on_neo4j_process_uses_neptune_secondary(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + active_neo4j = MagicMock(name="neo4j-active") + factory._backend = active_neo4j + + secondary_neptune = MagicMock(name="neptune-secondary") + with patch.object(factory, "_build_backend", return_value=secondary_neptune): + scan = MagicMock(sink_backend="neptune") + backend = factory.get_backend_for_scan(scan) + + assert backend is secondary_neptune + assert backend is not active_neo4j + + +def _session_ctx(session: MagicMock) -> MagicMock: + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=session) + ctx.__exit__ = MagicMock(return_value=False) + return ctx + + +class TestNeo4jSinkSyncWrites: + def test_ensure_sync_indexes_runs_create_index_idempotent(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.return_value = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.ensure_sync_indexes("db-tenant-x") + + query = session.run.call_args.args[0] + assert "CREATE INDEX" in query + assert "IF NOT EXISTS" in query + assert "`_ProviderResource`" in query + assert "`_provider_element_id`" in query + + def test_write_nodes_skips_empty_batch(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + with patch.object(sink, "get_session") as get_session: + sink.write_nodes("db-tenant-x", "`AWSUser`", []) + get_session.assert_not_called() + + def test_write_nodes_merges_on_provider_resource_label(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_nodes( + "db-tenant-x", + "`AWSUser`:`_ProviderResource`", + [{"provider_element_id": "p:e", "props": {"k": "v"}}], + ) + + query, params = session.run.call_args.args + assert "MERGE (n:`_ProviderResource`" in query + assert "`_provider_element_id`: row.provider_element_id" in query + assert "SET n:`AWSUser`:`_ProviderResource`" in query + assert params == {"rows": [{"provider_element_id": "p:e", "props": {"k": "v"}}]} + + def test_write_relationships_scopes_endpoints_by_provider_label(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + provider_id = "00000000-0000-0000-0000-000000000abc" + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_relationships( + "db-tenant-x", + "RESOURCE", + provider_id, + [ + { + "start_element_id": "s", + "end_element_id": "e", + "provider_element_id": "pe", + "props": {}, + } + ], + ) + + query = session.run.call_args.args[0] + assert ":`_Provider_00000000000000000000000000000abc`" in query + assert ":RESOURCE" in query.replace("`", "") + assert "MERGE (s)-[r:`RESOURCE`" in query + + +class TestNeptuneSinkSyncWrites: + def test_ensure_sync_indexes_is_noop(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + with patch.object(sink, "get_session") as get_session: + sink.ensure_sync_indexes("ignored") + get_session.assert_not_called() + + def test_write_nodes_merges_on_neptune_id_with_provider_resource_label(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_nodes( + "ignored", + "`AWSUser`", + [{"provider_element_id": "p:e", "props": {"k": "v"}}], + ) + + query = session.run.call_args.args[0] + # Neptune assigns a default `vertex` label to any unlabeled node, + # so the MERGE must pin a real label at creation time. + assert "MERGE (n:`_ProviderResource` {`~id`: row.provider_element_id})" in query + assert "SET n:`AWSUser`" in query + assert "SET n.`_provider_element_id` = row.provider_element_id" in query + + def test_write_relationships_matches_endpoints_by_id(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + sink.write_relationships( + "ignored", + "RESOURCE", + "provider-1", + [ + { + "start_element_id": "s", + "end_element_id": "e", + "provider_element_id": "pe", + "props": {}, + } + ], + ) + + query = session.run.call_args.args[0] + assert "MATCH (s) WHERE id(s) = row.start_element_id" in query + assert "MATCH (e) WHERE id(e) = row.end_element_id" in query + assert "MERGE (s)-[r:`RESOURCE`" in query + + +class TestNeptuneSinkDropSubgraph: + def test_drop_subgraph_deletes_rels_before_nodes_in_bounded_batches(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + + rel_record_first = MagicMock() + rel_record_first.__getitem__ = lambda _self, key: 50 + rel_record_drain = MagicMock() + rel_record_drain.__getitem__ = lambda _self, key: 0 + node_record_first = MagicMock() + node_record_first.__getitem__ = lambda _self, key: 10 + node_record_drain = MagicMock() + node_record_drain.__getitem__ = lambda _self, key: 0 + + run_results = [ + MagicMock(single=MagicMock(return_value=rel_record_first)), + MagicMock(single=MagicMock(return_value=rel_record_drain)), + MagicMock(single=MagicMock(return_value=node_record_first)), + MagicMock(single=MagicMock(return_value=node_record_drain)), + ] + session.run.side_effect = run_results + + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + deleted = sink.drop_subgraph("ignored", "provider-1") + + assert deleted == 10 + first_query = session.run.call_args_list[0].args[0] + assert "DELETE r" in first_query + assert "DETACH DELETE" not in first_query + # DISTINCT avoids double-counting relationships matched from both ends. + assert "DISTINCT r" in first_query + third_query = session.run.call_args_list[2].args[0] + assert "DELETE n" in third_query + + +class TestNeo4jSinkDropSubgraph: + """Neo4j drop deletes relationships then nodes in batches (no ``DETACH DELETE``).""" + + def test_drop_subgraph_deletes_rels_before_nodes_in_bounded_batches(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + + rel_first = MagicMock() + rel_first.get = lambda key, default=0: 50 + rel_drain = MagicMock() + rel_drain.get = lambda key, default=0: 0 + node_first = MagicMock() + node_first.get = lambda key, default=0: 10 + node_drain = MagicMock() + node_drain.get = lambda key, default=0: 0 + session.run.side_effect = [ + MagicMock(single=MagicMock(return_value=rel_first)), + MagicMock(single=MagicMock(return_value=rel_drain)), + MagicMock(single=MagicMock(return_value=node_first)), + MagicMock(single=MagicMock(return_value=node_drain)), + ] + + provider_id = "00000000-0000-0000-0000-000000000abc" + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + deleted = sink.drop_subgraph("db-tenant-x", provider_id) + + # Only phase-2 node counts contribute to the return value. + assert deleted == 10 + assert session.run.call_count == 4 + + queries = [call.args[0] for call in session.run.call_args_list] + # Regression guard: the memory blow-up was caused by DETACH DELETE. + assert all("DETACH DELETE" not in query for query in queries) + + first_query = queries[0] + assert "DELETE r" in first_query + # DISTINCT avoids double-counting relationships matched from both ends. + assert "DISTINCT r" in first_query + assert ":`_Provider_00000000000000000000000000000abc`" in first_query + + assert "DELETE n" in queries[2] + + # Relationships must be fully drained before nodes are deleted. + first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q) + last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q) + assert last_rel < first_node + + def test_drop_subgraph_returns_zero_when_database_does_not_exist(self): + from api.attack_paths.database import GraphDatabaseQueryException + from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.side_effect = GraphDatabaseQueryException( + message="db missing", code=DATABASE_NOT_FOUND_CODE + ) + + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + deleted = sink.drop_subgraph("db-tenant-missing", "provider-1") + + assert deleted == 0 + + +class TestSinkHasProviderData: + """``has_provider_data`` is the read-path probe used by API views.""" + + def test_neo4j_returns_true_when_provider_node_exists(self): + from api.attack_paths.sink.neo4j import Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.return_value.single.return_value = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + present = sink.has_provider_data( + "db-tenant-x", "00000000-0000-0000-0000-000000000abc" + ) + + assert present is True + query = session.run.call_args.args[0] + assert ":`_Provider_00000000000000000000000000000abc`" in query + + def test_neo4j_returns_false_when_database_does_not_exist(self): + from api.attack_paths.database import GraphDatabaseQueryException + from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink + + sink = Neo4jSink() + session = MagicMock() + session.run.side_effect = GraphDatabaseQueryException( + message="db missing", code=DATABASE_NOT_FOUND_CODE + ) + + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + present = sink.has_provider_data("db-tenant-missing", "provider-1") + + assert present is False + + def test_neptune_returns_true_when_provider_node_exists(self): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + session = MagicMock() + session.run.return_value.single.return_value = MagicMock() + with patch.object(sink, "get_session", return_value=_session_ctx(session)): + present = sink.has_provider_data("ignored", "provider-1") + + assert present is True + + +class TestGetBackendForScanCutover: + """``get_backend_for_scan`` keeps old-sink scans queryable after cutover.""" + + def test_legacy_scan_on_neptune_process_uses_neo4j_secondary(self, settings): + from api.attack_paths.sink import factory + + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + active_neptune = MagicMock(name="neptune-active") + factory._backend = active_neptune + + secondary_neo4j = MagicMock(name="neo4j-secondary") + with patch.object(factory, "_build_backend", return_value=secondary_neo4j): + scan = MagicMock(sink_backend="neo4j") + backend = factory.get_backend_for_scan(scan) + + assert backend is secondary_neo4j + assert backend is not active_neptune + + +class TestSinkVerifyConnectivity: + """The readiness probe calls ``verify_connectivity`` through the shim. + + Neo4j checks its single driver; Neptune checks the reader (the API read + path), which on single-endpoint clusters aliases the writer. + """ + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_neo4j_verifies_its_driver(self, mock_driver, settings): + from api.attack_paths.sink.neo4j import Neo4jSink + + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + driver = MagicMock() + mock_driver.return_value = driver + + sink = Neo4jSink() + sink.init() + driver.verify_connectivity.reset_mock() # ignore the eager init check + sink.verify_connectivity() + + driver.verify_connectivity.assert_called_once_with() + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_neptune_verifies_reader_not_writer( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths.sink.neptune import NeptuneSink + + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "reader.example", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + writer, reader = MagicMock(name="writer"), MagicMock(name="reader") + mock_driver.side_effect = [writer, reader] + mock_auth_provider.return_value = lambda: None + + sink = NeptuneSink() + sink.init() + writer.verify_connectivity.reset_mock() + reader.verify_connectivity.reset_mock() + + sink.verify_connectivity() + + reader.verify_connectivity.assert_called_once_with() + writer.verify_connectivity.assert_not_called() + + +class TestSinkInitToleratesUnreachableSink: + """Init must not crash the process when the sink is down at boot. + + Same degradation model as Postgres: the driver is retained and + reconnects lazily; /health/ready surfaces the outage until it recovers. + """ + + @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") + def test_neo4j_init_continues_when_verify_fails(self, mock_driver, settings): + from api.attack_paths.sink.neo4j import Neo4jSink + + settings.DATABASES = { + **settings.DATABASES, + "neo4j": { + "HOST": "localhost", + "PORT": "7687", + "USER": "neo4j", + "PASSWORD": "pw", + }, + } + driver = MagicMock() + driver.verify_connectivity.side_effect = RuntimeError("unreachable") + mock_driver.return_value = driver + + sink = Neo4jSink() + # Must not raise. + assert sink.init() is driver + assert sink._driver is driver + + @patch("api.attack_paths.sink.neptune.neptune_auth_provider") + @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver") + def test_neptune_init_continues_when_verify_fails( + self, mock_driver, mock_auth_provider, settings + ): + from api.attack_paths.sink.neptune import NeptuneSink + + settings.DATABASES = { + **settings.DATABASES, + "neptune": { + "WRITER_ENDPOINT": "writer.example", + "READER_ENDPOINT": "reader.example", + "PORT": "8182", + "REGION": "eu-west-1", + }, + } + driver = MagicMock() + driver.verify_connectivity.side_effect = RuntimeError("unreachable") + mock_driver.return_value = driver + mock_auth_provider.return_value = lambda: None + + sink = NeptuneSink() + # Must not raise; both drivers retained. + sink.init() + assert sink._writer is not None + assert sink._reader is not None + + +class TestNeptuneAdminNoOps: + """Neptune is single-database; admin DDL has no work to do.""" + + @pytest.mark.parametrize("method", ["create_database", "drop_database"]) + def test_admin_ops_return_none_without_touching_a_session(self, method): + from api.attack_paths.sink.neptune import NeptuneSink + + sink = NeptuneSink() + with patch.object(sink, "get_session") as get_session: + assert getattr(sink, method)("ignored") is None + get_session.assert_not_called() + + +class TestNeptuneAuthToken: + """SigV4 signing for the Neptune Bolt endpoint.""" + + @patch("api.attack_paths.sink.neptune.SigV4Auth") + @patch("api.attack_paths.sink.neptune.BotoSession") + def test_host_header_includes_non_default_port(self, mock_boto, mock_sigv4): + # Neptune runs on 8182; the SigV4 canonical Host must keep the port or + # the signature is rejected. + from api.attack_paths.sink.neptune import _NeptuneAuthToken + + credentials = MagicMock() + credentials.get_frozen_credentials.return_value = MagicMock() + mock_boto.return_value.get_credentials.return_value = credentials + + token = _NeptuneAuthToken("eu-west-1", "https://writer.example:8182") + + auth_obj = json.loads(token.credentials) + assert auth_obj["Host"] == "writer.example:8182" diff --git a/api/src/backend/api/tests/test_sse.py b/api/src/backend/api/tests/test_sse.py new file mode 100644 index 0000000000..e6fdf21130 --- /dev/null +++ b/api/src/backend/api/tests/test_sse.py @@ -0,0 +1,190 @@ +"""Tests for the platform SSE infrastructure (``api.sse``). + +Cover the two security-critical platform pieces — the channel-name +convention (:mod:`api.sse.utils`) and the tenant gate enforced by +:class:`api.sse.channelmanager.SSEChannelManager`. The SSE authentication +class lives in :mod:`api.authentication` with the rest of the auth stack, +so its tests live in ``test_authentication.py``. Per-feature SSE endpoints +add their own tests on top of these. +""" + +import uuid +from unittest.mock import MagicMock + +import pytest +from api.sse.base_views import BaseSSEViewSet +from api.sse.channelmanager import SSEChannelManager +from api.sse.utils import make_channel_name, tenant_id_from_channel +from django.http import StreamingHttpResponse +from rest_framework.test import APIRequestFactory, force_authenticate + + +class TestMakeChannel: + def test_round_trips_tenant_id(self): + tenant_id = uuid.uuid4() + channel = make_channel_name("lighthouse-session", tenant_id, uuid.uuid4()) + assert tenant_id_from_channel(channel) == tenant_id + + def test_accepts_str_arguments(self): + tenant_id = uuid.uuid4() + channel = make_channel_name("lighthouse-session", str(tenant_id), "resource-1") + assert channel == f"lighthouse-session:{tenant_id}:resource-1" + + def test_prefix_with_hyphen_is_not_split(self): + # Prefixes contain hyphens but never colons, so the tenant id is + # always the second colon-separated segment. + tenant_id = uuid.uuid4() + channel = make_channel_name("a-long-hyphenated-prefix", tenant_id, "res") + assert tenant_id_from_channel(channel) == tenant_id + + @pytest.mark.parametrize( + "prefix, tenant_id, resource_id", + [ + ("evil:prefix", uuid.uuid4(), "res"), + ("prefix", uuid.uuid4(), "res:extra"), + ("prefix", "tenant:smuggled", "res"), + ], + ) + def test_rejects_separator_injection(self, prefix, tenant_id, resource_id): + # A colon in any segment would let a crafted name smuggle extra + # segments past the parser, so construction must fail loudly. + with pytest.raises(ValueError): + make_channel_name(prefix, tenant_id, resource_id) + + +class TestTenantIdFromChannel: + def test_returns_none_for_too_few_segments(self): + assert tenant_id_from_channel("prefix:only") is None + assert tenant_id_from_channel("garbage") is None + + def test_returns_none_for_too_many_segments(self): + # A valid tenant UUID in position 1 must not authorize a + # non-canonical name that carries extra segments. + tenant_id = uuid.uuid4() + assert tenant_id_from_channel(f"prefix:{tenant_id}:resource:extra") is None + + def test_returns_none_for_non_uuid_tenant_segment(self): + assert tenant_id_from_channel("prefix:not-a-uuid:resource") is None + + def test_parses_valid_channel(self): + tenant_id = uuid.uuid4() + assert tenant_id_from_channel(f"prefix:{tenant_id}:resource") == tenant_id + + +@pytest.mark.django_db +class TestSSEChannelManager: + def test_member_can_read_own_tenant_channel( + self, create_test_user, tenants_fixture + ): + tenant = tenants_fixture[0] + channel = make_channel_name("lighthouse-session", tenant.id, uuid.uuid4()) + assert SSEChannelManager().can_read_channel(create_test_user, channel) + + def test_non_member_cannot_read_other_tenant_channel( + self, create_test_user, tenants_fixture + ): + # create_test_user is a member of tenant1 and tenant2 but not tenant3. + foreign_tenant = tenants_fixture[2] + channel = make_channel_name( + "lighthouse-session", foreign_tenant.id, uuid.uuid4() + ) + assert not SSEChannelManager().can_read_channel(create_test_user, channel) + + def test_anonymous_user_is_rejected(self, tenants_fixture): + channel = make_channel_name( + "lighthouse-session", tenants_fixture[0].id, uuid.uuid4() + ) + assert not SSEChannelManager().can_read_channel(None, channel) + + anon = MagicMock(is_authenticated=False) + assert not SSEChannelManager().can_read_channel(anon, channel) + + def test_malformed_channel_is_rejected(self, create_test_user, tenants_fixture): + assert not SSEChannelManager().can_read_channel(create_test_user, "garbage") + + def test_get_channels_for_request_returns_active_tenant_channels(self): + tenant_id = uuid.uuid4() + own = make_channel_name("prefix", tenant_id, "resource") + request = MagicMock() + request.tenant_id = str(tenant_id) + request.sse_channels = {own} + assert SSEChannelManager().get_channels_for_request(request, {}) == {own} + + def test_get_channels_for_request_drops_other_tenant_channels(self): + # Fail-closed: a channel for a tenant other than the active JWT + # tenant is dropped before reaching django-eventstream, even if the + # viewset mistakenly stashed it. This is the primary tenant gate that + # binds authorization to request.tenant_id, not just membership. + active_tenant = uuid.uuid4() + own = make_channel_name("prefix", active_tenant, "resource") + foreign = make_channel_name("prefix", uuid.uuid4(), "resource") + request = MagicMock() + request.tenant_id = str(active_tenant) + request.sse_channels = {own, foreign} + assert SSEChannelManager().get_channels_for_request(request, {}) == {own} + + def test_get_channels_for_request_drops_malformed_channels(self): + request = MagicMock() + request.tenant_id = str(uuid.uuid4()) + request.sse_channels = {"garbage", "prefix:not-a-uuid:resource"} + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_get_channels_for_request_without_tenant_returns_empty(self): + # No active tenant on the request (auth/RLS never ran) → fail closed, + # regardless of any channels stashed on it. + request = MagicMock(spec=[]) + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_get_channels_for_request_defaults_to_empty(self): + # A request that never went through BaseSSEViewSet.list has no + # sse_channels attribute; the manager must not raise. + request = object() + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_channel_is_not_reliable(self): + # v1 ships without server-side replay storage. + assert ( + SSEChannelManager().is_channel_reliable("prefix:tenant:resource") is False + ) + + +@pytest.mark.django_db +class TestBaseSSEViewSet: + """End-to-end check that the base viewset opens a stream. + + ``BaseSSEViewSet.list`` hands the DRF ``Request`` straight to + django-eventstream's ``events()``, which is written for a plain + Django request. This drives a real request through the full DRF + stack (authentication, RLS, content negotiation, channel manager) + and asserts the result is an SSE stream, so the DRF/Django request + mismatch cannot regress silently. + """ + + def test_list_opens_event_stream(self, create_test_user, tenants_fixture): + tenant = tenants_fixture[0] + channel = make_channel_name("test-sse", tenant.id, uuid.uuid4()) + seen_tenant_ids = [] + + class _StreamingSSEViewSet(BaseSSEViewSet): + def get_channels(self): + # Reached only after dispatch/initial ran, so the RLS + # tenant context is already on the request. + seen_tenant_ids.append(self.request.tenant_id) + return {channel} + + request = APIRequestFactory().get("/api/v1/test-sse/stream") + force_authenticate( + request, user=create_test_user, token={"tenant_id": str(tenant.id)} + ) + + view = _StreamingSSEViewSet.as_view({"get": "list"}) + response = view(request) + + # A StreamingHttpResponse (not the plain HttpResponse used for SSE + # error envelopes) means events() accepted the DRF request, the + # channel manager handed it a non-empty channel set, and the + # stream was opened end to end. + assert isinstance(response, StreamingHttpResponse) + assert response.status_code == 200 + assert response["Content-Type"] == "text/event-stream" + assert seen_tenant_ids == [str(tenant.id)] diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 86da5567da..935a15c4f3 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -1,9 +1,7 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch import pytest -from rest_framework.exceptions import NotFound, ValidationError - from api.db_router import MainRouter from api.exceptions import InvitationTokenExpiredException from api.models import Integration, Invitation, Provider @@ -31,9 +29,11 @@ from prowler.providers.image.image_provider import ImageProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider +from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider +from rest_framework.exceptions import NotFound, ValidationError class TestMergeDicts: @@ -130,6 +130,7 @@ class TestReturnProwlerProvider: (Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider), (Provider.ProviderChoices.IMAGE.value, ImageProvider), (Provider.ProviderChoices.VERCEL.value, VercelProvider), + (Provider.ProviderChoices.OKTA.value, OktaProvider), ], ) def test_return_prowler_provider(self, provider_type, expected_provider): @@ -238,6 +239,31 @@ class TestProwlerProviderConnectionTest: raise_on_exception=False, ) + @patch("api.utils.return_prowler_provider") + def test_prowler_provider_connection_test_okta_provider( + self, mock_return_prowler_provider + ): + """Test connection test for Okta provider passes org domain and provider_id.""" + provider = MagicMock() + provider.uid = "acme.okta.com" + provider.provider = Provider.ProviderChoices.OKTA.value + provider.secret.secret = { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": ["okta.policies.read"], + } + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + okta_client_id="0oa123456789abcdef", + okta_private_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + okta_scopes=["okta.policies.read"], + okta_org_domain="acme.okta.com", + provider_id="acme.okta.com", + raise_on_exception=False, + ) + @patch("api.utils.return_prowler_provider") def test_prowler_provider_connection_test_image_provider_no_creds( self, mock_return_prowler_provider @@ -308,6 +334,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.VERCEL.value, {"team_id": "provider_uid"}, ), + ( + Provider.ProviderChoices.OKTA.value, + {"okta_org_domain": "provider_uid"}, + ), ], ) def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs): @@ -326,6 +356,30 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result + def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set( + self, + ): + secret_dict = { + "user": "ocid1.user.oc1..fake", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + "tenancy": "ocid1.tenancy.oc1..fake", + "region": "us-ashburn-1", + "pass_phrase": "fake-passphrase", + } + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret = secret_mock + provider.uid = "ocid1.tenancy.oc1..fake" + + result = get_prowler_provider_kwargs(provider) + + expected_result = {**secret_dict, "region": {"us-ashburn-1"}} + assert result == expected_result + def test_get_prowler_provider_kwargs_with_mutelist(self): provider_uid = "provider_uid" secret_dict = {"key": "value"} @@ -568,7 +622,7 @@ class TestValidateInvitation: invitation = MagicMock(spec=Invitation) invitation.token = "VALID_TOKEN" invitation.email = "user@example.com" - invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=1) + invitation.expires_at = datetime.now(UTC) + timedelta(days=1) invitation.state = Invitation.State.PENDING invitation.tenant = MagicMock() return invitation @@ -616,7 +670,7 @@ class TestValidateInvitation: ) def test_invitation_expired(self, invitation): - expired_time = datetime.now(timezone.utc) - timedelta(days=1) + expired_time = datetime.now(UTC) - timedelta(days=1) invitation.expires_at = expired_time with ( @@ -625,7 +679,7 @@ class TestValidateInvitation: ): mock_db = mock_using.return_value mock_db.get.return_value = invitation - mock_datetime.now.return_value = datetime.now(timezone.utc) + mock_datetime.now.return_value = datetime.now(UTC) with pytest.raises(InvitationTokenExpiredException): validate_invitation("VALID_TOKEN", "user@example.com") @@ -670,7 +724,7 @@ class TestValidateInvitation: invitation = MagicMock(spec=Invitation) invitation.token = "VALID_TOKEN" invitation.email = uppercase_email - invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=1) + invitation.expires_at = datetime.now(UTC) + timedelta(days=1) invitation.state = Invitation.State.PENDING invitation.tenant = MagicMock() diff --git a/api/src/backend/api/tests/test_uuid_utils.py b/api/src/backend/api/tests/test_uuid_utils.py index e202d087f3..a69d71cee9 100644 --- a/api/src/backend/api/tests/test_uuid_utils.py +++ b/api/src/backend/api/tests/test_uuid_utils.py @@ -1,23 +1,22 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import uuid4 import pytest +from api.uuid_utils import ( + datetime_from_uuid7, + datetime_to_uuid7, + transform_into_uuid7, + uuid7_end, + uuid7_range, + uuid7_start, +) from dateutil.relativedelta import relativedelta from rest_framework_json_api.serializers import ValidationError from uuid6 import UUID -from api.uuid_utils import ( - transform_into_uuid7, - datetime_to_uuid7, - datetime_from_uuid7, - uuid7_start, - uuid7_end, - uuid7_range, -) - def test_transform_into_uuid7_valid(): - uuid_v7 = datetime_to_uuid7(datetime.now(timezone.utc)) + uuid_v7 = datetime_to_uuid7(datetime.now(UTC)) transformed_uuid = transform_into_uuid7(uuid_v7) assert transformed_uuid == UUID(hex=uuid_v7.hex.upper()) assert transformed_uuid.version == 7 @@ -33,8 +32,8 @@ def test_transform_into_uuid7_invalid_version(): @pytest.mark.parametrize( "input_datetime", [ - datetime(2024, 9, 11, 7, 20, 27, tzinfo=timezone.utc), - datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + datetime(2024, 9, 11, 7, 20, 27, tzinfo=UTC), + datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC), ], ) def test_datetime_to_uuid7(input_datetime): @@ -48,8 +47,8 @@ def test_datetime_to_uuid7(input_datetime): @pytest.mark.parametrize( "input_datetime", [ - datetime(2024, 9, 11, 7, 20, 27, tzinfo=timezone.utc), - datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + datetime(2024, 9, 11, 7, 20, 27, tzinfo=UTC), + datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC), ], ) def test_datetime_from_uuid7(input_datetime): @@ -65,7 +64,7 @@ def test_datetime_from_uuid7_invalid(): def test_uuid7_start(): - dt = datetime.now(timezone.utc) + dt = datetime.now(UTC) uuid = datetime_to_uuid7(dt) start_uuid = uuid7_start(uuid) expected_dt = dt.replace(hour=0, minute=0, second=0, microsecond=0) @@ -76,7 +75,7 @@ def test_uuid7_start(): @pytest.mark.parametrize("months_offset", [0, 1, 10, 30, 60]) def test_uuid7_end(months_offset): - dt = datetime.now(timezone.utc) + dt = datetime.now(UTC) uuid = datetime_to_uuid7(dt) end_uuid = uuid7_end(uuid, months_offset) expected_dt = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) @@ -87,7 +86,7 @@ def test_uuid7_end(months_offset): def test_uuid7_range(): - dt_now = datetime.now(timezone.utc) + dt_now = datetime.now(UTC) uuid_list = [ datetime_to_uuid7(dt_now), datetime_to_uuid7(dt_now.replace(year=2023)), diff --git a/api/src/backend/api/tests/test_version.py b/api/src/backend/api/tests/test_version.py new file mode 100644 index 0000000000..ee5d167736 --- /dev/null +++ b/api/src/backend/api/tests/test_version.py @@ -0,0 +1,40 @@ +"""Drift checks for the API version constants. + +Guarantee that ``config.version`` always reflects the canonical +``[project].version`` declared in ``api/pyproject.toml``. +""" + +import tomllib +from pathlib import Path + +import pytest +from config import version as config_version + + +@pytest.fixture(scope="module") +def pyproject_data(): + here = Path(__file__).resolve() + for directory in here.parents: + candidate = directory / "pyproject.toml" + if not candidate.is_file(): + continue + with candidate.open("rb") as f: + data = tomllib.load(f) + if data.get("project", {}).get("name") == "prowler-api": + return data + raise AssertionError("api/pyproject.toml not reachable from the test runner") + + +def test_release_id_matches_pyproject(pyproject_data): + assert config_version.RELEASE_ID == pyproject_data["project"]["version"] + + +def test_api_version_is_major_of_release_id(): + assert config_version.API_VERSION == config_version.RELEASE_ID.split(".", 1)[0] + assert config_version.API_VERSION.isdigit() + + +def test_api_version_matches_v1_url_prefix(): + # The public contract version surfaced in the health payload must match + # the URL namespace the API is published under. + assert config_version.API_VERSION == "1" diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 3fcd515f6a..d0f2f3f7c3 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3,7 +3,7 @@ import io import json import os import tempfile -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta from decimal import Decimal from pathlib import Path from types import SimpleNamespace @@ -15,24 +15,6 @@ import jwt import pytest from allauth.account.models import EmailAddress from allauth.socialaccount.models import SocialAccount, SocialApp -from botocore.exceptions import ClientError, NoCredentialsError -from conftest import ( - API_JSON_CONTENT_TYPE, - TEST_PASSWORD, - TEST_USER, - TODAY, - today_after_n_days, -) -from django.conf import settings -from django.db.models import Count -from django.http import JsonResponse -from django.test import RequestFactory -from django.urls import reverse -from django_celery_results.models import TaskResult -from rest_framework import status -from rest_framework.exceptions import PermissionDenied -from rest_framework.response import Response - from api.attack_paths import ( AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition, @@ -47,6 +29,7 @@ from api.models import ( Finding, Integration, Invitation, + InvitationRoleRelationship, LighthouseProviderConfiguration, LighthouseProviderModels, LighthouseTenantConfiguration, @@ -57,6 +40,8 @@ from api.models import ( ProviderGroupMembership, ProviderSecret, Resource, + ResourceFindingMapping, + ResourceTag, Role, RoleProviderGroupRelationship, SAMLConfiguration, @@ -74,8 +59,32 @@ from api.models import ( from api.rls import Tenant from api.v1.serializers import TokenSerializer from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView +from botocore.exceptions import ClientError, NoCredentialsError +from conftest import ( + API_JSON_CONTENT_TYPE, + TEST_PASSWORD, + TEST_USER, + TODAY, + today_after_n_days, +) +from django.conf import settings +from django.db import connection +from django.db.models import Count +from django.http import JsonResponse +from django.test import RequestFactory +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from django_celery_results.models import TaskResult from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework_simplejwt.token_blacklist.models import ( + BlacklistedToken, + OutstandingToken, +) +from rest_framework_simplejwt.tokens import RefreshToken class TestViewSet: @@ -745,6 +754,39 @@ class TestTenantViewSet: # Test user + 2 extra users for tenant 2 assert len(response.json()["data"]) == 3 + def test_tenants_list_memberships_filter_by_user( + self, authenticated_client, tenants_fixture, extra_users + ): + _, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + response = authenticated_client.get( + reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}), + {"filter[user]": str(user3.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == str(membership3.id) + + def test_tenants_list_memberships_filter_by_user_no_match( + self, authenticated_client, tenants_fixture, extra_users + ): + _, tenant2, _ = tenants_fixture + unrelated_user = User.objects.create_user( + name="unrelated", + password=TEST_PASSWORD, + email="unrelated@gmail.com", + ) + + response = authenticated_client.get( + reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}), + {"filter[user]": str(unrelated_user.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + def test_tenants_list_memberships_as_member( self, authenticated_client, tenants_fixture, extra_users ): @@ -802,6 +844,7 @@ class TestTenantViewSet: ): _, tenant2, _ = tenants_fixture user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER) + user_id = user_membership.user_id response = authenticated_client.delete( reverse( "tenant-membership-detail", @@ -810,6 +853,127 @@ class TestTenantViewSet: ) assert response.status_code == status.HTTP_403_FORBIDDEN assert Membership.objects.filter(id=user_membership.id).exists() + assert User.objects.filter(id=user_id).exists() + + def test_expel_user_deletes_account_if_last_membership( + self, authenticated_client, tenants_fixture, extra_users + ): + # TEST_USER is OWNER of tenant2; user3 is MEMBER only in tenant2 + _, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + assert Membership.objects.filter(user=user3).count() == 1 + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Membership.objects.filter(id=membership3.id).exists() + assert not User.objects.filter(id=user3.id).exists() + + def test_expel_user_blacklists_refresh_tokens( + self, authenticated_client, tenants_fixture, extra_users + ): + _, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + # Issue two refresh tokens to simulate active sessions + RefreshToken.for_user(user3) + RefreshToken.for_user(user3) + outstanding_ids = list( + OutstandingToken.objects.filter(user=user3).values_list("id", flat=True) + ) + assert len(outstanding_ids) == 2 + assert not BlacklistedToken.objects.filter( + token_id__in=outstanding_ids + ).exists() + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert ( + BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 2 + ) + + def test_expel_user_blacklists_refresh_tokens_is_idempotent( + self, authenticated_client, tenants_fixture, extra_users + ): + # Regression test for the bulk blacklisting path: if one of the + # user's refresh tokens is already blacklisted when the expel + # endpoint runs, the remaining tokens must still be blacklisted + # and the already-blacklisted one must not be duplicated. + tenant1, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + # Keep the user alive after the expel so the assertions below can + # still query OutstandingToken by user_id. + Membership.objects.create( + user=user3, + tenant=tenant1, + role=Membership.RoleChoices.MEMBER, + ) + + RefreshToken.for_user(user3) + RefreshToken.for_user(user3) + outstanding_ids = list( + OutstandingToken.objects.filter(user=user3).values_list("id", flat=True) + ) + assert len(outstanding_ids) == 2 + + # Pre-blacklist one of the two tokens to simulate a prior revocation. + BlacklistedToken.objects.create(token_id=outstanding_ids[0]) + assert ( + BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 1 + ) + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + blacklisted = BlacklistedToken.objects.filter(token_id__in=outstanding_ids) + assert blacklisted.count() == 2 + assert set(blacklisted.values_list("token_id", flat=True)) == set( + outstanding_ids + ) + + def test_expel_user_keeps_account_if_has_other_memberships( + self, authenticated_client, tenants_fixture, extra_users + ): + tenant1, tenant2, _ = tenants_fixture + _, user3_membership = extra_users + user3, membership3 = user3_membership + + # Give user3 an additional membership in tenant1 so they are not orphaned + other_membership = Membership.objects.create( + user=user3, + tenant=tenant1, + role=Membership.RoleChoices.MEMBER, + ) + + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership3.id}, + ) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Membership.objects.filter(id=membership3.id).exists() + assert User.objects.filter(id=user3.id).exists() + assert Membership.objects.filter(id=other_membership.id).exists() def test_tenants_delete_another_membership_as_owner( self, authenticated_client, tenants_fixture, extra_users @@ -881,6 +1045,128 @@ class TestTenantViewSet: assert response.status_code == status.HTTP_404_NOT_FOUND assert Membership.objects.filter(id=other_membership.id).exists() + def test_delete_membership_cleans_up_orphaned_role_grants( + self, authenticated_client, tenants_fixture + ): + """Test that deleting a membership removes UserRoleRelationship records + for that tenant while preserving grants in other tenants.""" + tenant1, tenant2, _ = tenants_fixture + + # Create a user with memberships in both tenants + user = User.objects.create_user( + name="Multi-tenant User", + password=TEST_PASSWORD, + email="multitenant@test.com", + ) + + # Create memberships in both tenants + Membership.objects.create( + user=user, tenant=tenant1, role=Membership.RoleChoices.MEMBER + ) + membership2 = Membership.objects.create( + user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER + ) + + # Create roles in both tenants + role1 = Role.objects.create( + name="Test Role 1", tenant=tenant1, manage_providers=True + ) + role2 = Role.objects.create( + name="Test Role 2", tenant=tenant2, manage_scans=True + ) + + # Create user role relationships for both tenants + UserRoleRelationship.objects.create(user=user, role=role1, tenant=tenant1) + UserRoleRelationship.objects.create(user=user, role=role2, tenant=tenant2) + + # Verify initial state + assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists() + assert UserRoleRelationship.objects.filter(user=user, tenant=tenant2).exists() + assert Role.objects.filter(id=role1.id).exists() + assert Role.objects.filter(id=role2.id).exists() + + # Delete membership from tenant2 (authenticated user is owner of tenant2) + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership2.id}, + ) + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify the membership was deleted + assert not Membership.objects.filter(id=membership2.id).exists() + + # Verify UserRoleRelationship for tenant2 was deleted + assert not UserRoleRelationship.objects.filter( + user=user, tenant=tenant2 + ).exists() + + # Verify UserRoleRelationship for tenant1 is preserved + assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists() + + # Verify orphaned role2 was deleted (no more user or invitation relationships) + assert not Role.objects.filter(id=role2.id).exists() + + # Verify role1 is preserved (still has user relationship) + assert Role.objects.filter(id=role1.id).exists() + + # Verify the user still exists (has other memberships) + assert User.objects.filter(id=user.id).exists() + + def test_delete_membership_preserves_role_with_invitation_relationship( + self, authenticated_client, tenants_fixture + ): + """Test that roles are not deleted if they have invitation relationships.""" + _, tenant2, _ = tenants_fixture + + # Create a user with membership + user = User.objects.create_user( + name="Test User", password=TEST_PASSWORD, email="testuser@test.com" + ) + membership = Membership.objects.create( + user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER + ) + + # Create a role and user relationship + role = Role.objects.create( + name="Shared Role", tenant=tenant2, manage_providers=True + ) + UserRoleRelationship.objects.create(user=user, role=role, tenant=tenant2) + + # Create an invitation with the same role + invitation = Invitation.objects.create(email="pending@test.com", tenant=tenant2) + InvitationRoleRelationship.objects.create( + invitation=invitation, role=role, tenant=tenant2 + ) + + # Verify initial state + assert UserRoleRelationship.objects.filter(user=user, role=role).exists() + assert InvitationRoleRelationship.objects.filter( + invitation=invitation, role=role + ).exists() + assert Role.objects.filter(id=role.id).exists() + + # Delete the membership + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": tenant2.id, "pk": membership.id}, + ) + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify UserRoleRelationship was deleted + assert not UserRoleRelationship.objects.filter(user=user, role=role).exists() + + # Verify role is preserved because invitation relationship exists + assert Role.objects.filter(id=role.id).exists() + assert InvitationRoleRelationship.objects.filter( + invitation=invitation, role=role + ).exists() + def test_tenants_list_no_permissions( self, authenticated_client_no_permissions_rbac, tenants_fixture ): @@ -1124,6 +1410,42 @@ class TestProviderViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_providers_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("provider-list"), {"filter[provider_groups]": str(group1.id)} + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert [item["id"] for item in data] == [str(provider1.id)] + + response = authenticated_client.get( + reverse("provider-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + provider_ids = {item["id"] for item in response.json()["data"]} + assert provider_ids == {str(provider1.id), str(provider2.id)} + assert len(response.json()["data"]) == 2 + def test_providers_disable_pagination( self, authenticated_client, providers_fixture, tenants_fixture ): @@ -1185,9 +1507,9 @@ class TestProviderViewSet: included_data = response.json()["included"] for expected_type in expected_resources: - assert any( - d.get("type") == expected_type for d in included_data - ), f"Expected type '{expected_type}' not found in included data" + assert any(d.get("type") == expected_type for d in included_data), ( + f"Expected type '{expected_type}' not found in included data" + ) def test_providers_retrieve(self, authenticated_client, providers_fixture): provider1, *_ = providers_fixture @@ -1341,6 +1663,21 @@ class TestProviderViewSet: "uid": "C12", "alias": "Google Workspace Minimum Length", }, + { + "provider": "okta", + "uid": "acme.okta.com", + "alias": "Okta Org", + }, + { + "provider": "okta", + "uid": "agency.okta-gov.com", + "alias": "Okta Gov Org", + }, + { + "provider": "okta", + "uid": "agency.okta.mil", + "alias": "Okta Mil Org", + }, ] ), ) @@ -1859,6 +2196,24 @@ class TestProviderViewSet: "googleworkspace-uid", "uid", ), + ( + { + "provider": "okta", + "uid": "https://acme.okta.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), + ( + { + "provider": "okta", + "uid": "acme.example.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), ] ), ) @@ -1879,6 +2234,25 @@ class TestProviderViewSet: == f"/data/attributes/{error_pointer}" ) + @pytest.mark.parametrize( + "input_uid,stored_uid", + [ + ("Acme.okta.com", "acme.okta.com"), + (" ACME.OKTA.COM ", "acme.okta.com"), + ("Agency.Okta-Gov.com", "agency.okta-gov.com"), + ], + ) + def test_providers_create_okta_uid_normalized( + self, authenticated_client, input_uid, stored_uid + ): + response = authenticated_client.post( + reverse("provider-list"), + data={"provider": "okta", "uid": input_uid, "alias": "Okta"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.get().uid == stored_uid + def test_providers_partial_update(self, authenticated_client, providers_fixture): provider1, *_ = providers_fixture new_alias = "This is the new name" @@ -2036,17 +2410,17 @@ class TestProviderViewSet: ), ("alias", "aws_testing_1", 1), ("alias.icontains", "aws", 2), - ("inserted_at", TODAY, 13), + ("inserted_at", TODAY, 14), ( "inserted_at.gte", "2024-01-01", - 13, + 14, ), ("inserted_at.lte", "2024-01-01", 0), ( "updated_at.gte", "2024-01-01", - 13, + 14, ), ("updated_at.lte", "2024-01-01", 0), ] @@ -2679,6 +3053,19 @@ class TestProviderSecretViewSet: "api_token": "fake-vercel-api-token-for-testing", }, ), + # Okta with inline private key credentials + ( + Provider.ProviderChoices.OKTA.value, + ProviderSecret.TypeChoices.STATIC, + { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": [ + "okta.policies.read", + "okta.groups.read", + ], + }, + ), ], ) def test_provider_secrets_create_valid( @@ -2791,6 +3178,46 @@ class TestProviderSecretViewSet: == f"/data/attributes/{error_pointer}" ) + def test_provider_secrets_invalid_create_okta_missing_private_key( + self, + providers_fixture, + authenticated_client, + ): + okta_provider = next( + provider + for provider in providers_fixture + if provider.provider == Provider.ProviderChoices.OKTA.value + ) + data = { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "Okta Secret", + "secret_type": ProviderSecret.TypeChoices.STATIC, + "secret": { + "okta_client_id": "0oa123456789abcdef", + }, + }, + "relationships": { + "provider": { + "data": {"type": "providers", "id": str(okta_provider.id)} + } + }, + } + } + + response = authenticated_client.post( + reverse("providersecret-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"][0]["code"] == "required" + assert response.json()["errors"][0]["source"]["pointer"] == ( + "/data/attributes/secret/okta_private_key" + ) + def test_provider_secrets_partial_update( self, authenticated_client, provider_secret_fixture ): @@ -3323,6 +3750,41 @@ class TestScanViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count + def test_scans_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + scans_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + scan1, scan2, *_ = scans_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan1.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan1.provider, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan2.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("scan-list"), {"filter[provider_groups]": str(group1.id)} + ) + assert response.status_code == status.HTTP_200_OK + assert {item["id"] for item in response.json()["data"]} == {str(scan1.id)} + + response = authenticated_client.get( + reverse("scan-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + scan_ids = {item["id"] for item in response.json()["data"]} + assert scan_ids == {str(scan1.id), str(scan2.id), str(scans_fixture[2].id)} + assert len(response.json()["data"]) == 3 + @pytest.mark.parametrize( "filter_name", [ @@ -3467,16 +3929,20 @@ class TestScanViewSet: scan.output_location = "dummy" scan.save() - dummy_task = Task.objects.create(tenant_id=scan.tenant_id) - dummy_task.id = "dummy-task-id" - dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING} + task_result = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) + dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING} - with ( - patch("api.v1.views.Task.objects.get", return_value=dummy_task), - patch( - "api.v1.views.TaskSerializer", - return_value=type("DummySerializer", (), {"data": dummy_task_data}), - ), + with patch( + "api.v1.views.TaskSerializer", + return_value=type("DummySerializer", (), {"data": dummy_task_data}), ): url = reverse("scan-report", kwargs={"pk": scan.id}) response = authenticated_client.get(url) @@ -3557,9 +4023,14 @@ class TestScanViewSet: "prowler-output-123_threatscore_report.pdf", ) + presigned_url = ( + "https://test-bucket.s3.amazonaws.com/" + "tenant-id/scan-id/threatscore/prowler-output-123_threatscore_report.pdf" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300" + ) mock_s3_client = Mock() mock_s3_client.list_objects_v2.return_value = {"Contents": [{"Key": pdf_key}]} - mock_s3_client.get_object.return_value = {"Body": io.BytesIO(b"pdf-bytes")} + mock_s3_client.generate_presigned_url.return_value = presigned_url mock_env_str.return_value = bucket mock_get_s3_client.return_value = mock_s3_client @@ -3568,19 +4039,26 @@ class TestScanViewSet: url = reverse("scan-threatscore", kwargs={"pk": scan.id}) response = authenticated_client.get(url) - assert response.status_code == status.HTTP_200_OK - assert response["Content-Type"] == "application/pdf" - assert response["Content-Disposition"].endswith( - '"prowler-output-123_threatscore_report.pdf"' - ) - assert response.content == b"pdf-bytes" + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == presigned_url mock_s3_client.list_objects_v2.assert_called_once() - mock_s3_client.get_object.assert_called_once_with(Bucket=bucket, Key=pdf_key) + mock_s3_client.generate_presigned_url.assert_called_once_with( + "get_object", + Params={ + "Bucket": bucket, + "Key": pdf_key, + "ResponseContentDisposition": ( + 'attachment; filename="prowler-output-123_threatscore_report.pdf"' + ), + "ResponseContentType": "application/pdf", + }, + ExpiresIn=300, + ) def test_report_s3_success(self, authenticated_client, scans_fixture, monkeypatch): """ - When output_location is an S3 URL and the S3 client returns the file successfully, - the view should return the ZIP file with HTTP 200 and proper headers. + When output_location is an S3 URL and the object exists, + the view should return a 302 redirect to a presigned S3 URL. """ scan = scans_fixture[0] bucket = "test-bucket" @@ -3594,22 +4072,33 @@ class TestScanViewSet: type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(), ) + presigned_url = ( + "https://test-bucket.s3.amazonaws.com/report.zip" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300" + ) + class FakeS3Client: - def get_object(self, Bucket, Key): + def head_object(self, Bucket, Key): assert Bucket == bucket assert Key == key - return {"Body": io.BytesIO(b"s3 zip content")} + return {} + + def generate_presigned_url(self, ClientMethod, Params, ExpiresIn): + assert ClientMethod == "get_object" + assert Params["Bucket"] == bucket + assert Params["Key"] == key + assert Params["ResponseContentDisposition"] == ( + 'attachment; filename="report.zip"' + ) + assert ExpiresIn == 300 + return presigned_url monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client()) url = reverse("scan-report", kwargs={"pk": scan.id}) response = authenticated_client.get(url) - assert response.status_code == 200 - expected_filename = os.path.basename("report.zip") - content_disposition = response.get("Content-Disposition") - assert content_disposition.startswith('attachment; filename="') - assert f'filename="{expected_filename}"' in content_disposition - assert response.content == b"s3 zip content" + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == presigned_url def test_report_s3_success_no_local_files( self, authenticated_client, scans_fixture, monkeypatch @@ -3748,23 +4237,113 @@ class TestScanViewSet: ) match_key = "path/compliance/mitre_attack_aws.csv" + presigned_url = ( + "https://test-bucket.s3.amazonaws.com/path/compliance/mitre_attack_aws.csv" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300" + ) class FakeS3Client: def list_objects_v2(self, Bucket, Prefix): return {"Contents": [{"Key": match_key}]} - def get_object(self, Bucket, Key): - return {"Body": io.BytesIO(b"ignored")} + def generate_presigned_url(self, ClientMethod, Params, ExpiresIn): + assert ClientMethod == "get_object" + assert Params["Key"] == match_key + assert Params["ResponseContentDisposition"] == ( + 'attachment; filename="mitre_attack_aws.csv"' + ) + assert ExpiresIn == 300 + return presigned_url monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client()) framework = match_key.split("/")[-1].split(".")[0] url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework}) resp = authenticated_client.get(url) - assert resp.status_code == status.HTTP_200_OK - cd = resp["Content-Disposition"] - assert cd.startswith('attachment; filename="') - assert cd.endswith('filename="mitre_attack_aws.csv"') + assert resp.status_code == status.HTTP_302_FOUND + assert resp["Location"] == presigned_url + + def test_compliance_s3_returns_latest_match( + self, authenticated_client, scans_fixture, monkeypatch + ): + """When several files match, the most recently modified one is served.""" + scan = scans_fixture[0] + bucket = "bucket" + scan.output_location = f"s3://{bucket}/path/scan.zip" + scan.state = StateChoices.COMPLETED + scan.save() + + monkeypatch.setattr( + "api.v1.views.env", + type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(), + ) + + old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv" + latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv" + + class FakeS3Client: + def list_objects_v2(self, Bucket, Prefix): + return { + "Contents": [ + { + "Key": old_key, + "LastModified": datetime(2024, 1, 1, tzinfo=UTC), + }, + { + "Key": latest_key, + "LastModified": datetime(2024, 2, 2, tzinfo=UTC), + }, + ] + } + + def generate_presigned_url(self, ClientMethod, Params, ExpiresIn): + assert Params["Key"] == latest_key + return "https://test-bucket.s3.amazonaws.com/latest" + + monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client()) + + url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_302_FOUND + assert resp["Location"].endswith("/latest") + + def test_compliance_local_returns_latest_match( + self, authenticated_client, scans_fixture, monkeypatch + ): + """The local branch serves the most recently modified matching file.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + + with tempfile.TemporaryDirectory() as tmp: + comp_dir = Path(tmp) / "reports" / "compliance" + comp_dir.mkdir(parents=True, exist_ok=True) + + old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv" + old_file.write_bytes(b"old") + latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv" + latest_file.write_bytes(b"latest") + # Make `latest_file` newer regardless of creation order. + os.utime(old_file, (1_700_000_000, 1_700_000_000)) + os.utime(latest_file, (1_700_000_100, 1_700_000_100)) + + scan.output_location = str(Path(tmp) / "reports" / "scan.zip") + scan.save() + + monkeypatch.setattr( + glob, + "glob", + lambda p: [str(old_file), str(latest_file)], + ) + + url = reverse( + "scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"} + ) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_200_OK + assert resp.content == b"latest" + assert resp["Content-Disposition"].endswith( + f'filename="{latest_file.name}"' + ) def test_compliance_s3_not_found( self, authenticated_client, scans_fixture, monkeypatch @@ -3829,18 +4408,69 @@ class TestScanViewSet: assert cd.startswith('attachment; filename="') assert cd.endswith(f'filename="{fname.name}"') - @patch("api.v1.views.Task.objects.get") + def test_cis_no_output(self, authenticated_client, scans_fixture): + """CIS PDF endpoint must 404 when the scan has no output_location.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + scan.output_location = "" + scan.save() + + url = reverse("scan-cis", kwargs={"pk": scan.id}) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_404_NOT_FOUND + assert ( + resp.json()["errors"]["detail"] + == "The scan has no reports, or the CIS report generation task has not started yet." + ) + + def test_cis_local_file(self, authenticated_client, scans_fixture, monkeypatch): + """CIS PDF endpoint must serve the latest generated PDF.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + base = tmp_path / "reports" + cis_dir = base / "cis" + cis_dir.mkdir(parents=True, exist_ok=True) + fname = cis_dir / "prowler-output-aws-20260101000000_cis_report.pdf" + fname.write_bytes(b"%PDF-1.4 fake pdf") + + scan.output_location = str(base / "scan.zip") + scan.save() + + monkeypatch.setattr( + glob, + "glob", + lambda p: [str(fname)] if p.endswith("*_cis_report.pdf") else [], + ) + + url = reverse("scan-cis", kwargs={"pk": scan.id}) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_200_OK + assert resp["Content-Type"] == "application/pdf" + cd = resp["Content-Disposition"] + assert cd.startswith('attachment; filename="') + assert cd.endswith(f'filename="{fname.name}"') + @patch("api.v1.views.TaskSerializer") def test__get_task_status_returns_none_if_task_not_executing( - self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture + self, mock_task_serializer, authenticated_client, scans_fixture ): scan = scans_fixture[0] scan.state = StateChoices.COMPLETED scan.output_location = "dummy" scan.save() - task = Task.objects.create(tenant_id=scan.tenant_id) - mock_task_get.return_value = task + task_result = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) mock_task_serializer.return_value.data = { "id": str(task.id), "state": StateChoices.COMPLETED, @@ -3861,6 +4491,7 @@ class TestScanViewSet: scan.save() task_result = TaskResult.objects.create( + task_id=str(uuid4()), task_name="scan-report", task_kwargs={"scan_id": str(scan.id)}, ) @@ -3881,6 +4512,51 @@ class TestScanViewSet: assert response.status_code == status.HTTP_202_ACCEPTED assert response.data["id"] == str(task.id) + @patch("api.v1.views.TaskSerializer") + def test__get_task_status_returns_latest_task( + self, mock_task_serializer, authenticated_client, scans_fixture + ): + """With several scan-report tasks for the scan, the most recent is used.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + scan.output_location = "dummy" + scan.save() + + old_task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ), + ) + new_task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ), + ) + # `inserted_at` is `auto_now_add`, and within the test transaction the DB + # `now()` is constant, so force distinct timestamps to make order_by stable. + base = datetime(2024, 1, 1, tzinfo=UTC) + Task.objects.filter(pk=old_task.pk).update(inserted_at=base) + Task.objects.filter(pk=new_task.pk).update( + inserted_at=base + timedelta(hours=1) + ) + + mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace( + data={"id": str(instance.id), "state": StateChoices.EXECUTING} + ) + + url = reverse("scan-report", kwargs={"pk": scan.id}) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert str(new_task.id) in response["Content-Location"] + assert str(old_task.id) not in response["Content-Location"] + @patch("api.v1.views.get_s3_client") @patch("api.v1.views.sentry_sdk.capture_exception") def test_compliance_list_objects_client_error( @@ -3922,8 +4598,8 @@ class TestScanViewSet: scan.save() fake_client = MagicMock() - fake_client.get_object.side_effect = ClientError( - {"Error": {"Code": "NoSuchKey"}}, "GetObject" + fake_client.head_object.side_effect = ClientError( + {"Error": {"Code": "NoSuchKey"}}, "HeadObject" ) mock_get_s3_client.return_value = fake_client @@ -3946,8 +4622,8 @@ class TestScanViewSet: scan.save() fake_client = MagicMock() - fake_client.get_object.side_effect = ClientError( - {"Error": {"Code": "AccessDenied"}}, "GetObject" + fake_client.head_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied"}}, "HeadObject" ) mock_get_s3_client.return_value = fake_client @@ -4078,6 +4754,64 @@ class TestAttackPathsScanViewSet: assert first_attributes["provider_type"] == provider.provider assert first_attributes["provider_uid"] == provider.uid + def test_attack_paths_scans_list_prefers_active_sink_scan_on_rollback( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + settings, + ): + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + provider = providers_fixture[0] + + neo4j_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neo4j", + ) + neptune_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neptune", + ) + + response = authenticated_client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + ids = {item["id"] for item in response.json()["data"]} + assert str(neo4j_scan.id) in ids + assert str(neptune_scan.id) not in ids + + def test_attack_paths_scans_list_falls_back_when_active_sink_has_no_scan( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + settings, + ): + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + provider = providers_fixture[0] + + legacy_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neo4j", + ) + + response = authenticated_client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + ids = {item["id"] for item in response.json()["data"]} + assert str(legacy_scan.id) in ids + def test_attack_paths_scans_list_respects_provider_group_visibility( self, authenticated_client_no_permissions_rbac, @@ -4198,7 +4932,8 @@ class TestAttackPathsScanViewSet: ) assert response.status_code == status.HTTP_200_OK - mock_get_queries.assert_called_once_with(provider.provider) + # TODO: drop the is_migrated argument after Neptune cutover + mock_get_queries.assert_called_once_with(provider.provider, is_migrated=False) payload = response.json()["data"] assert len(payload) == 1 assert payload[0]["id"] == "aws-rds" @@ -4287,7 +5022,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.attack_paths_views_helpers.execute_query", return_value=graph_payload, ) as mock_execute, - patch("api.v1.views.graph_database.clear_cache") as mock_clear_cache, ): response = authenticated_client.post( reverse( @@ -4299,7 +5033,8 @@ class TestAttackPathsScanViewSet: ) assert response.status_code == status.HTTP_200_OK - mock_get_query.assert_called_once_with("aws-rds") + # TODO: drop the is_migrated argument after Neptune cutover + mock_get_query.assert_called_once_with("aws-rds", is_migrated=False) mock_get_db_name.assert_called_once_with(attack_paths_scan.provider.tenant_id) provider_id = str(attack_paths_scan.provider_id) mock_prepare.assert_called_once_with( @@ -4313,8 +5048,8 @@ class TestAttackPathsScanViewSet: query_definition, prepared_parameters, provider_id, + scan=attack_paths_scan, ) - mock_clear_cache.assert_called_once_with(expected_db_name) result = response.json()["data"] attributes = result["attributes"] assert attributes["nodes"] == graph_payload["nodes"] @@ -4369,7 +5104,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.attack_paths_views_helpers.execute_query", return_value=graph_payload, ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4453,7 +5187,6 @@ class TestAttackPathsScanViewSet: "truncated": False, }, ), - patch("api.v1.views.graph_database.clear_cache"), patch( "api.v1.views.graph_database.get_database_name", return_value="db-test" ), @@ -4508,7 +5241,6 @@ class TestAttackPathsScanViewSet: "truncated": False, }, ), - patch("api.v1.views.graph_database.clear_cache"), patch( "api.v1.views.graph_database.get_database_name", return_value="db-test" ), @@ -4588,7 +5320,6 @@ class TestAttackPathsScanViewSet: "truncated": False, }, ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4654,7 +5385,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.graph_database.get_database_name", return_value="db-test", ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4670,6 +5400,7 @@ class TestAttackPathsScanViewSet: "db-test", "MATCH (n) RETURN n", str(attack_paths_scan.provider_id), + scan=attack_paths_scan, ) attributes = response.json()["data"]["attributes"] assert len(attributes["nodes"]) == 1 @@ -4711,7 +5442,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.graph_database.get_database_name", return_value="db-test", ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4758,7 +5488,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.graph_database.get_database_name", return_value="db-test", ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -5109,9 +5838,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.graph_database.get_database_name", return_value="db-test", ), - patch( - "api.v1.views.graph_database.clear_cache", - ), ): for i in range(11): response = authenticated_client.post( @@ -5120,13 +5846,13 @@ class TestAttackPathsScanViewSet: content_type=API_JSON_CONTENT_TYPE, ) if i < 10: - assert ( - response.status_code == status.HTTP_200_OK - ), f"Request {i + 1} should succeed with 200 OK, got {response.status_code}" + assert response.status_code == status.HTTP_200_OK, ( + f"Request {i + 1} should succeed with 200 OK, got {response.status_code}" + ) else: - assert ( - response.status_code == status.HTTP_429_TOO_MANY_REQUESTS - ), f"Request {i + 1} should be throttled" + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS, ( + f"Request {i + 1} should be throttled" + ) # -- Timeout simulation ------------------------------------------------------- @@ -5211,9 +5937,10 @@ class TestAttackPathsScanViewSet: ) assert response.status_code == status.HTTP_200_OK - mock_get_schema.assert_called_once_with( - "db-test", str(attack_paths_scan.provider_id) - ) + mock_get_schema.assert_called_once() + schema_args = mock_get_schema.call_args[0] + assert schema_args[:2] == ("db-test", str(attack_paths_scan.provider_id)) + assert schema_args[2].id == attack_paths_scan.id attributes = response.json()["data"]["attributes"] assert attributes["provider"] == "aws" assert attributes["cartography_version"] == "0.129.0" @@ -5329,9 +6056,9 @@ class TestResourceViewSet: included_data = response.json()["included"] for expected_type in expected_resources: - assert any( - d.get("type") == expected_type for d in included_data - ), f"Expected type '{expected_type}' not found in included data" + assert any(d.get("type") == expected_type for d in included_data), ( + f"Expected type '{expected_type}' not found in included data" + ) @pytest.mark.parametrize( "filter_name, filter_value, expected_count", @@ -5402,6 +6129,49 @@ class TestResourceViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count + def test_resource_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + resources_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + resource1, resource2, resource3, *_ = resources_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource1.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource1.provider, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource3.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("resource-list"), + {"filter[updated_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + assert {item["id"] for item in response.json()["data"]} == { + str(resource1.id), + str(resource2.id), + } + + response = authenticated_client.get( + reverse("resource-list"), + { + "filter[updated_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + resource_ids = {item["id"] for item in response.json()["data"]} + assert resource_ids == {str(resource1.id), str(resource2.id), str(resource3.id)} + assert len(response.json()["data"]) == 3 + def test_resource_filter_by_scan_id( self, authenticated_client, resources_fixture, scans_fixture ): @@ -5880,9 +6650,9 @@ class TestResourceViewSet: (e for e in errors if e["source"]["parameter"] == expected_invalid_param), None, ) - assert ( - error is not None - ), f"Expected error for parameter '{expected_invalid_param}'" + assert error is not None, ( + f"Expected error for parameter '{expected_invalid_param}'" + ) assert error["code"] == "invalid" assert error["status"] == "400" # Must be string per JSON:API spec assert expected_invalid_param in error["detail"] @@ -6271,9 +7041,8 @@ class TestResourceViewSet: This ensures the endpoint follows API conventions where missing authentication returns 401 Unauthorized, not 404 Not Found. """ - from rest_framework.test import APIClient - from api.models import Resource + from rest_framework.test import APIClient aws_provider = providers_fixture[0] # AWS provider from fixture @@ -6346,9 +7115,8 @@ class TestResourceViewSet: This ensures authentication errors are properly distinguished from resource not found errors. """ - from rest_framework.test import APIClient - from api.models import Resource + from rest_framework.test import APIClient aws_provider = providers_fixture[0] @@ -6366,9 +7134,8 @@ class TestResourceViewSet: tenant = tenants_fixture[0] expired_payload = { "token_type": "access", - "exp": datetime.now(timezone.utc) - - timedelta(hours=1), # Expired 1 hour ago - "iat": datetime.now(timezone.utc) - timedelta(hours=2), + "exp": datetime.now(UTC) - timedelta(hours=1), # Expired 1 hour ago + "iat": datetime.now(UTC) - timedelta(hours=2), "jti": str(uuid4()), "user_id": str(uuid4()), "tenant_id": str(tenant.id), @@ -6393,9 +7160,8 @@ class TestResourceViewSet: Malformed or invalid tokens should return 401 Unauthorized, not 404 Not Found. """ - from rest_framework.test import APIClient - from api.models import Resource + from rest_framework.test import APIClient aws_provider = providers_fixture[0] @@ -6414,16 +7180,16 @@ class TestResourceViewSet: # Test with completely malformed token client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token") response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) - assert ( - response.status_code == status.HTTP_401_UNAUTHORIZED - ), f"Expected 401 for malformed token but got {response.status_code}" + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"Expected 401 for malformed token but got {response.status_code}" + ) # Test with empty bearer token client.credentials(HTTP_AUTHORIZATION="Bearer ") response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) - assert ( - response.status_code == status.HTTP_401_UNAUTHORIZED - ), f"Expected 401 for empty bearer token but got {response.status_code}" + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"Expected 401 for empty bearer token but got {response.status_code}" + ) @pytest.mark.django_db @@ -6463,6 +7229,80 @@ class TestFindingViewSet: == findings_fixture[0].status ) + def test_findings_list_resource_tags_no_n_plus_one( + self, authenticated_client, findings_fixture + ): + """Listing findings must load every resource's tags in a constant + number of queries, no matter how many findings/resources are returned. + + This guards ``FindingViewSet._optimize_tags_loading`` against + regressions that would reintroduce one extra query per resource (the + N+1 the prefetch was added to remove). + """ + scan = findings_fixture[0].scan + tenant_id = findings_fixture[0].tenant_id + provider = scan.provider + + def _create_finding_with_tagged_resource(index): + resource = Resource.objects.create( + tenant_id=tenant_id, + provider=provider, + uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}", + name=f"N+1 Instance {index}", + region="us-east-1", + service="ec2", + type="prowler-test", + ) + resource.upsert_or_delete_tags( + [ + ResourceTag.objects.create( + tenant_id=tenant_id, + key=f"key-{index}", + value=f"value-{index}", + ) + ] + ) + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"n_plus_one_finding_{index}", + scan=scan, + status=Status.FAIL, + status_extended="n+1 status", + impact=Severity.medium, + severity=Severity.medium, + check_id="test_check_id", + check_metadata={"CheckId": "test_check_id", "servicename": "ec2"}, + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + return finding + + params = {"filter[inserted_at]": TODAY, "include": "resources"} + + # Baseline: the two findings provided by the fixture. + with CaptureQueriesContext(connection) as baseline: + response = authenticated_client.get(reverse("finding-list"), params) + assert response.status_code == status.HTTP_200_OK + + # Add more findings, each with its own resource carrying tags. + extra_findings = 5 + for index in range(extra_findings): + _create_finding_with_tagged_resource(index) + + with CaptureQueriesContext(connection) as scaled: + response = authenticated_client.get(reverse("finding-list"), params) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == len(findings_fixture) + extra_findings + + # The query count must not grow with the number of findings/resources. + assert len(scaled.captured_queries) == len(baseline.captured_queries), ( + "Resource tags are not being prefetched: " + f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} " + f"findings vs {len(scaled.captured_queries)} for " + f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression " + "in FindingViewSet._optimize_tags_loading." + ) + @pytest.mark.parametrize( "include_values, expected_resources", [ @@ -6484,9 +7324,9 @@ class TestFindingViewSet: included_data = response.json()["included"] for expected_type in expected_resources: - assert any( - d.get("type") == expected_type for d in included_data - ), f"Expected type '{expected_type}' not found in included data" + assert any(d.get("type") == expected_type for d in included_data), ( + f"Expected type '{expected_type}' not found in included data" + ) @pytest.mark.parametrize( "filter_name, filter_value, expected_count", @@ -6640,6 +7480,40 @@ class TestFindingViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 + def test_finding_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + findings_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + finding1, finding2, *_ = findings_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=finding1.scan.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=finding1.scan.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-list"), + {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[inserted_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + @pytest.mark.parametrize( "filter_name", ( @@ -6705,6 +7579,32 @@ class TestFindingViewSet: "id" ] == str(finding_1.resources.first().id) + def test_findings_retrieve_include_resource_metadata( + self, authenticated_client, findings_fixture + ): + finding_1, *_ = findings_fixture + resource = finding_1.resources.first() + resource.metadata = '{"VulnerabilityID": "CVE-2026-0001"}' + resource.details = "Python 3.12 base image" + resource.save() + + response = authenticated_client.get( + reverse("finding-detail", kwargs={"pk": finding_1.id}), + {"include": "resources"}, + ) + assert response.status_code == status.HTTP_200_OK + + included_resource = next( + item + for item in response.json()["included"] + if item["type"] == "resources" and item["id"] == str(resource.id) + ) + assert ( + included_resource["attributes"]["metadata"] + == '{"VulnerabilityID": "CVE-2026-0001"}' + ) + assert included_resource["attributes"]["details"] == "Python 3.12 base image" + def test_findings_invalid_retrieve(self, authenticated_client): response = authenticated_client.get( reverse("finding-detail", kwargs={"pk": "random_id"}), @@ -7025,9 +7925,9 @@ class TestJWTFields: reverse("token-obtain"), data, format="json" ) - assert ( - response.status_code == status.HTTP_200_OK - ), f"Unexpected status code: {response.status_code}" + assert response.status_code == status.HTTP_200_OK, ( + f"Unexpected status code: {response.status_code}" + ) access_token = response.data["attributes"]["access"] payload = jwt.decode(access_token, options={"verify_signature": False}) @@ -7041,28 +7941,28 @@ class TestJWTFields: # Verify expected fields for field in expected_fields: assert field in payload, f"The field '{field}' is not in the JWT" - assert ( - payload[field] == expected_fields[field] - ), f"The value of '{field}' does not match" + assert payload[field] == expected_fields[field], ( + f"The value of '{field}' does not match" + ) # Verify time fields are integers for time_field in ["exp", "iat", "nbf"]: assert time_field in payload, f"The field '{time_field}' is not in the JWT" - assert isinstance( - payload[time_field], int - ), f"The field '{time_field}' is not an integer" + assert isinstance(payload[time_field], int), ( + f"The field '{time_field}' is not an integer" + ) # Verify identification fields are non-empty strings for id_field in ["jti", "sub", "tenant_id"]: assert id_field in payload, f"The field '{id_field}' is not in the JWT" - assert ( - isinstance(payload[id_field], str) and payload[id_field] - ), f"The field '{id_field}' is not a valid string" + assert isinstance(payload[id_field], str) and payload[id_field], ( + f"The field '{id_field}' is not a valid string" + ) @pytest.mark.django_db class TestInvitationViewSet: - TOMORROW = datetime.now(timezone.utc) + timedelta(days=1, hours=1) + TOMORROW = datetime.now(UTC) + timedelta(days=1, hours=1) TOMORROW_ISO = TOMORROW.isoformat() def test_invitations_list(self, authenticated_client, invitations_fixture): @@ -7185,9 +8085,7 @@ class TestInvitationViewSet: "type": "invitations", "attributes": { "email": "thisisarandomemail@prowler.com", - "expires_at": ( - datetime.now(timezone.utc) + timedelta(hours=23) - ).isoformat(), + "expires_at": (datetime.now(UTC) + timedelta(hours=23)).isoformat(), }, } } @@ -7214,7 +8112,7 @@ class TestInvitationViewSet: invitation, *_ = invitations_fixture role1, role2, *_ = roles_fixture new_email = "new_email@prowler.com" - new_expires_at = datetime.now(timezone.utc) + timedelta(days=7) + new_expires_at = datetime.now(UTC) + timedelta(days=7) new_expires_at_iso = new_expires_at.isoformat() data = { "data": { @@ -7301,9 +8199,7 @@ class TestInvitationViewSet: "id": str(invitation.id), "type": "invitations", "attributes": { - "expires_at": ( - datetime.now(timezone.utc) + timedelta(hours=23) - ).isoformat(), + "expires_at": (datetime.now(UTC) + timedelta(hours=23)).isoformat(), }, } } @@ -7476,7 +8372,7 @@ class TestInvitationViewSet: self, authenticated_client, invitations_fixture ): invitation, *_ = invitations_fixture - invitation.expires_at = datetime.now(timezone.utc) - timedelta(days=1) + invitation.expires_at = datetime.now(UTC) - timedelta(days=1) invitation.email = TEST_USER invitation.save() @@ -7495,7 +8391,7 @@ class TestInvitationViewSet: ): new_email = "new_email@prowler.com" invitation, *_ = invitations_fixture - invitation.expires_at = datetime.now(timezone.utc) - timedelta(days=1) + invitation.expires_at = datetime.now(UTC) - timedelta(days=1) invitation.email = new_email invitation.save() @@ -8584,6 +9480,118 @@ class TestComplianceOverviewViewSet: with patch("api.v1.views.backfill_compliance_summaries_task.delay") as mock: yield mock + def _create_completed_scan(self, provider, name): + return Scan.objects.create( + name=name, + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=provider.tenant_id, + started_at=datetime.now(UTC), + completed_at=datetime.now(UTC), + ) + + def _create_requirement( + self, + scan, + requirement_id, + status_choice, + region="eu-west-1", + compliance_id="cis_1.4_aws", + ): + passed = 1 if status_choice == StatusChoices.PASS else 0 + total = 1 if status_choice != StatusChoices.MANUAL else 0 + return ComplianceRequirementOverview.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + compliance_id=compliance_id, + framework="CIS-1.4-AWS", + version="1.4", + description="CIS AWS Foundations Benchmark v1.4.0", + region=region, + requirement_id=requirement_id, + requirement_status=status_choice, + passed_checks=passed, + failed_checks=0 + if status_choice in (StatusChoices.PASS, StatusChoices.MANUAL) + else 1, + total_checks=total, + passed_findings=passed, + total_findings=total, + ) + + def _create_compliance_summary( + self, + scan, + *, + passed, + failed, + manual=0, + compliance_id="cis_1.4_aws", + ): + return ComplianceOverviewSummary.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + compliance_id=compliance_id, + requirements_passed=passed, + requirements_failed=failed, + requirements_manual=manual, + total_requirements=passed + failed + manual, + ) + + def _overview_attrs_by_id(self, response): + assert response.status_code == status.HTTP_200_OK + return {item["id"]: item["attributes"] for item in response.json()["data"]} + + def _prepare_latest_compliance_data(self, providers_fixture): + provider1, provider2, provider3, *_ = providers_fixture + old_scan = self._create_completed_scan(provider1, "old aws compliance scan") + latest_scan1 = self._create_completed_scan( + provider1, "latest aws compliance scan 1" + ) + latest_scan2 = self._create_completed_scan( + provider2, "latest aws compliance scan 2" + ) + latest_gcp_scan = self._create_completed_scan( + provider3, "latest gcp compliance scan" + ) + + self._create_requirement(old_scan, "1.1", StatusChoices.FAIL) + self._create_requirement(old_scan, "1.2", StatusChoices.FAIL) + self._create_compliance_summary(old_scan, passed=0, failed=2) + + self._create_requirement( + latest_scan1, "1.1", StatusChoices.PASS, region="eu-west-1" + ) + self._create_requirement( + latest_scan1, "1.2", StatusChoices.PASS, region="eu-west-1" + ) + self._create_compliance_summary(latest_scan1, passed=2, failed=0) + + self._create_requirement( + latest_scan2, "1.1", StatusChoices.FAIL, region="us-east-1" + ) + self._create_requirement( + latest_scan2, "1.2", StatusChoices.PASS, region="us-east-1" + ) + self._create_compliance_summary(latest_scan2, passed=1, failed=1) + + self._create_requirement( + latest_gcp_scan, + "gcp-1.1", + StatusChoices.FAIL, + region="europe-west1", + compliance_id="cis_1.3_gcp", + ) + self._create_compliance_summary( + latest_gcp_scan, + passed=0, + failed=1, + compliance_id="cis_1.3_gcp", + ) + + return old_scan, latest_scan1, latest_scan2, latest_gcp_scan + def test_compliance_overview_list_none( self, authenticated_client, @@ -8731,6 +9739,283 @@ class TestComplianceOverviewViewSet: assert len(response.json()["data"]) >= 1 mock_backfill_task.assert_not_called() + def test_compliance_overview_provider_id_filter_uses_latest_scan( + self, + authenticated_client, + providers_fixture, + mock_backfill_task, + ): + _, latest_scan, *_ = self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_id]": str(latest_scan.provider_id)}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0 + assert "cis_1.3_gcp" not in attrs_by_id + mock_backfill_task.assert_not_called() + + def test_compliance_overview_provider_id_in_filter_aggregates_latest_scans( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + { + "filter[provider_id__in]": ( + f"{latest_scan1.provider_id},{latest_scan2.provider_id}" + ) + }, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + assert "cis_1.3_gcp" not in attrs_by_id + + def test_compliance_overview_provider_type_filter_uses_latest_scans( + self, + authenticated_client, + providers_fixture, + ): + self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_type]": Provider.ProviderChoices.AWS.value}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + assert "cis_1.3_gcp" not in attrs_by_id + + def test_compliance_overview_provider_groups_filters_use_latest_scans( + self, + authenticated_client, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider=provider1, + provider_group=group1, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider=provider2, + provider_group=group2, + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_groups]": str(group1.id)}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0 + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + + def _assert_latest_provider_scan_task_response( + self, + authenticated_client, + endpoint, + scan, + query_params=None, + ): + query_params = {**(query_params or {})} + if not any(key.startswith("filter[provider_") for key in query_params): + query_params = { + "filter[provider_id]": str(scan.provider_id), + **query_params, + } + + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.return_value = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + + response = authenticated_client.get(reverse(endpoint), query_params) + + assert response.status_code == status.HTTP_202_ACCEPTED + mock_task_response.assert_called_once() + _, kwargs = mock_task_response.call_args + assert kwargs["task_name"] == "scan-compliance-overviews" + assert str(kwargs["task_kwargs"]["tenant_id"]) == str(scan.tenant_id) + assert str(kwargs["task_kwargs"]["scan_id"]) == str(scan.id) + assert kwargs["raise_on_not_found"] is False + + def test_compliance_overview_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance data" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-list", + scan, + ) + + def test_compliance_overview_provider_filter_returns_running_task_for_partial_data( + self, + authenticated_client, + providers_fixture, + ): + provider_with_data, provider_without_data, *_ = providers_fixture + scan_with_data = self._create_completed_scan( + provider_with_data, "latest scan with compliance data" + ) + scan_without_data = self._create_completed_scan( + provider_without_data, "latest scan without partial compliance data" + ) + self._create_requirement(scan_with_data, "1.1", StatusChoices.PASS) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-list", + scan_without_data, + { + "filter[provider_id__in]": ( + f"{provider_with_data.id},{provider_without_data.id}" + ) + }, + ) + + def test_compliance_overview_provider_filter_empty_response_uses_scan_data_presence( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan with filtered compliance data" + ) + self._create_requirement(scan, "1.1", StatusChoices.PASS, region="eu-west-1") + + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.return_value = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + { + "filter[provider_id]": str(scan.provider_id), + "filter[region]": "us-east-1", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + mock_task_response.assert_not_called() + + def test_compliance_overview_metadata_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance metadata" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-metadata", + scan, + ) + + def test_compliance_overview_requirements_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance requirements" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-requirements", + scan, + {"filter[compliance_id]": "cis_1.4_aws"}, + ) + + def test_compliance_overview_metadata_accepts_provider_filters( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan, *_ = self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-metadata"), + {"filter[provider_id]": str(latest_scan.provider_id)}, + ) + + assert response.status_code == status.HTTP_200_OK + regions = response.json()["data"]["attributes"]["regions"] + assert regions == ["eu-west-1"] + + def test_compliance_overview_requirements_accepts_provider_filters( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + { + "filter[provider_id__in]": ( + f"{latest_scan1.provider_id},{latest_scan2.provider_id}" + ), + "filter[compliance_id]": "cis_1.4_aws", + }, + ) + + assert response.status_code == status.HTTP_200_OK + requirements_by_id = { + item["id"]: item["attributes"] for item in response.json()["data"] + } + assert requirements_by_id["1.1"]["status"] == "FAIL" + assert requirements_by_id["1.2"]["status"] == "PASS" + def test_compliance_overview_metadata( self, authenticated_client, compliance_requirements_overviews_fixture ): @@ -8866,6 +10151,198 @@ class TestComplianceOverviewViewSet: assert "platforms" in attributes["attributes"]["technique_details"] assert "technique_url" in attributes["attributes"]["technique_details"] + # Guard against the `_raw_attributes` wrapper leaking through — + # the UI reads metadata[i].Category / .AWSService directly. + metadata = attributes["attributes"]["metadata"] + assert isinstance(metadata, list) and len(metadata) > 0 + first_attr = metadata[0] + assert isinstance(first_attr, dict) + assert "_raw_attributes" not in first_attr + assert "Category" in first_attr + assert "AWSService" in first_attr + + def test_compliance_overview_attributes_resolves_provider_from_scan( + self, authenticated_client, tenants_fixture, providers_fixture + ): + # csa_ccm_4.0 is a multi-provider universal framework: a single + # compliance_id whose requirements expose different checks per provider. + # Passing a scan must return the check IDs for that scan's provider, + # otherwise the endpoint defaults to the first provider that declares the + # framework and azure/gcp requirements end up with check IDs that match + # no findings. + tenant = tenants_fixture[0] + gcp_provider = providers_fixture[2] + azure_provider = providers_fixture[4] + assert gcp_provider.provider == Provider.ProviderChoices.GCP.value + assert azure_provider.provider == Provider.ProviderChoices.AZURE.value + + now = datetime.now(UTC) + gcp_scan = Scan.objects.create( + name="gcp scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + azure_scan = Scan.objects.create( + name="azure scan", + provider=azure_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id=None): + params = {"filter[compliance_id]": "csa_ccm_4.0"} + if scan_id is not None: + params["filter[scan_id]"] = str(scan_id) + return authenticated_client.get( + reverse("complianceoverview-attributes"), params + ) + + def collect_check_ids(scan_id=None): + response = request_attributes(scan_id) + assert response.status_code == status.HTTP_200_OK + check_ids = set() + for item in response.json()["data"]: + check_ids.update(item["attributes"]["attributes"]["check_ids"]) + return check_ids + + gcp_check_ids = collect_check_ids(gcp_scan.id) + azure_check_ids = collect_check_ids(azure_scan.id) + + # Each scan resolves to its own provider's checks, and they differ. + assert gcp_check_ids + assert azure_check_ids + assert gcp_check_ids != azure_check_ids + + # The returned check IDs belong to the SDK's per-provider definition. + from api.compliance import get_prowler_provider_compliance + + def expected_check_ids(provider_type): + framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"] + expected = set() + for requirement in framework.requirements: + expected.update(requirement.checks.get(provider_type, [])) + return expected + + assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value) + assert azure_check_ids <= expected_check_ids( + Provider.ProviderChoices.AZURE.value + ) + + # An explicit scan_id is authoritative: a non-existent scan must fail + # closed with 404 instead of silently falling back to another provider. + missing_response = request_attributes("00000000-0000-0000-0000-000000000000") + assert missing_response.status_code == status.HTTP_404_NOT_FOUND + + # A malformed scan_id is rejected with 404 as well. + malformed_response = request_attributes("not-a-uuid") + assert malformed_response.status_code == status.HTTP_404_NOT_FOUND + + # An empty value (filter[scan_id]=) must not fall back to the legacy + # provider picker: the explicit (if blank) selector fails closed. + empty_response = request_attributes("") + assert empty_response.status_code == status.HTTP_404_NOT_FOUND + + # A scan belonging to another tenant is not visible (RLS), so it must + # return 404 rather than leaking the fallback provider's check IDs. + other_tenant = Tenant.objects.create(name="Other Compliance Tenant") + foreign_provider = Provider.objects.create( + provider="gcp", + uid="foreign-gcp-test", + alias="foreign_gcp", + tenant_id=other_tenant.id, + ) + foreign_scan = Scan.objects.create( + name="foreign scan", + provider=foreign_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=other_tenant.id, + started_at=now, + completed_at=now, + ) + foreign_response = request_attributes(foreign_scan.id) + assert foreign_response.status_code == status.HTTP_404_NOT_FOUND + + def test_compliance_overview_attributes_scan_scoped_by_provider_group( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + ): + # A user with limited visibility (no UNLIMITED_VISIBILITY) must only be + # able to resolve scans for providers in its provider groups. Tenant RLS + # alone is not enough here: both scans belong to the same tenant, so the + # endpoint has to scope the scan lookup by provider group, otherwise a + # restricted user could read another provider's compliance metadata. + client = authenticated_client_no_permissions_rbac + limited_user = client.user + membership = Membership.objects.filter(user=limited_user).first() + tenant = membership.tenant + + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + assert allowed_provider.provider == Provider.ProviderChoices.GCP.value + assert denied_provider.provider == Provider.ProviderChoices.AZURE.value + + provider_group = ProviderGroup.objects.create( + name="limited-compliance-group", + tenant_id=tenant.id, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_user.roles.first(), + provider_group=provider_group, + ) + + now = datetime.now(UTC) + allowed_scan = Scan.objects.create( + name="allowed scan", + provider=allowed_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + denied_scan = Scan.objects.create( + name="denied scan", + provider=denied_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id): + return client.get( + reverse("complianceoverview-attributes"), + { + "filter[compliance_id]": "csa_ccm_4.0", + "filter[scan_id]": str(scan_id), + }, + ) + + # The scan in the user's provider group resolves normally. + assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK + + # The scan outside the user's provider group is invisible, so it fails + # closed with 404 instead of leaking the other provider's check IDs. + assert ( + request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND + ) + def test_compliance_overview_attributes_missing_compliance_id( self, authenticated_client ): @@ -8874,6 +10351,39 @@ class TestComplianceOverviewViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_compliance_overview_attributes_503_while_warming( + self, authenticated_client + ): + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.set() + COMPLIANCE_WARMED.clear() + try: + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + finally: + COMPLIANCE_WARMING_STARTED.clear() + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert response.json()["errors"][0]["code"] == "compliance_warming" + + def test_compliance_overview_attributes_serves_when_warming_not_started( + self, authenticated_client + ): + # Dev fallback: under runserver warming never runs, so the guard must + # not refuse — the endpoint lazily loads and serves as before. + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.clear() + COMPLIANCE_WARMED.clear() + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + def test_compliance_overview_task_management_integration( self, authenticated_client, compliance_requirements_overviews_fixture ): @@ -9112,8 +10622,42 @@ class TestOverviewViewSet: for entry in grouped_data: assert "findings" not in entry["attributes"] + def test_overview_providers_count_applies_limited_visibility( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + client = authenticated_client_no_permissions_rbac + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + provider_group = provider_groups_fixture[0] + + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=client.user.roles.first(), + provider_group=provider_group, + ) + + response = client.get(reverse("overview-providers-count")) + + assert response.status_code == status.HTTP_200_OK + aggregated = { + entry["id"]: entry["attributes"]["count"] + for entry in response.json()["data"] + } + assert aggregated == {allowed_provider.provider: 1} + assert denied_provider.provider not in aggregated + def _create_scan(self, tenant, provider, name, started_at=None): - scan_started = started_at or datetime.now(timezone.utc) - timedelta(hours=1) + scan_started = started_at or datetime.now(UTC) - timedelta(hours=1) return Scan.objects.create( tenant=tenant, provider=provider, @@ -9256,8 +10800,8 @@ class TestOverviewViewSet: failed_findings=35, ) - older_inserted = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc) - newer_inserted = datetime(2025, 1, 2, 12, 0, tzinfo=timezone.utc) + older_inserted = datetime(2025, 1, 1, 12, 0, tzinfo=UTC) + newer_inserted = datetime(2025, 1, 2, 12, 0, tzinfo=UTC) ThreatScoreSnapshot.objects.filter(id=snapshot1.id).update( inserted_at=older_inserted ) @@ -9659,6 +11203,87 @@ class TestOverviewViewSet: assert combined_attributes["muted"] == 3 assert combined_attributes["total"] == 14 + def test_overview_findings_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + scan1 = Scan.objects.create( + name="scan-provider-group-one", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="scan-provider-group-two", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan1, + check_id="check-provider-group-one", + service="service-a", + severity="high", + region="region-a", + _pass=5, + fail=1, + muted=2, + total=8, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan2, + check_id="check-provider-group-two", + service="service-b", + severity="medium", + region="region-b", + _pass=2, + fail=3, + muted=1, + total=6, + ) + + response = authenticated_client.get( + reverse("overview-findings"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert attributes["pass"] == 5 + assert attributes["fail"] == 1 + assert attributes["muted"] == 2 + assert attributes["total"] == 8 + + response = authenticated_client.get( + reverse("overview-findings"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert attributes["pass"] == 7 + assert attributes["fail"] == 4 + assert attributes["muted"] == 3 + assert attributes["total"] == 14 + def test_overview_findings_severity_provider_id_in_filter( self, authenticated_client, tenants_fixture, providers_fixture ): @@ -9795,7 +11420,7 @@ class TestOverviewViewSet: trigger=Scan.TriggerChoices.MANUAL, state=StateChoices.COMPLETED, tenant=tenant, - completed_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), ) # Create scan for day 3 @@ -9805,7 +11430,7 @@ class TestOverviewViewSet: trigger=Scan.TriggerChoices.MANUAL, state=StateChoices.COMPLETED, tenant=tenant, - completed_at=datetime(2024, 1, 3, 12, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2024, 1, 3, 12, 0, 0, tzinfo=UTC), ) # Create DailySeveritySummary for day 1 @@ -9878,7 +11503,7 @@ class TestOverviewViewSet: trigger=Scan.TriggerChoices.MANUAL, state=StateChoices.COMPLETED, tenant=tenant, - completed_at=datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2024, 2, 1, 12, 0, 0, tzinfo=UTC), ) scan2 = Scan.objects.create( name="severity-over-time-scan-p2", @@ -9886,7 +11511,7 @@ class TestOverviewViewSet: trigger=Scan.TriggerChoices.MANUAL, state=StateChoices.COMPLETED, tenant=tenant, - completed_at=datetime(2024, 2, 1, 14, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2024, 2, 1, 14, 0, 0, tzinfo=UTC), ) # Create DailySeveritySummary for provider1 @@ -9950,7 +11575,7 @@ class TestOverviewViewSet: trigger=Scan.TriggerChoices.MANUAL, state=StateChoices.COMPLETED, tenant=tenant, - completed_at=datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2024, 3, 1, 12, 0, 0, tzinfo=UTC), ) scan2 = Scan.objects.create( name="severity-over-time-filter-scan-p2", @@ -9958,7 +11583,7 @@ class TestOverviewViewSet: trigger=Scan.TriggerChoices.MANUAL, state=StateChoices.COMPLETED, tenant=tenant, - completed_at=datetime(2024, 3, 1, 14, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2024, 3, 1, 14, 0, 0, tzinfo=UTC), ) # Provider 1 - critical=100 @@ -10427,9 +12052,21 @@ class TestOverviewViewSet: @pytest.mark.parametrize( "filter_key,filter_value_fn,expected_total,expected_failed", [ - ("filter[provider_id]", lambda p1, _: str(p1.id), 10, 5), + ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5), ("filter[provider_type]", lambda *_: "aws", 10, 5), ("filter[provider_type__in]", lambda *_: "aws,gcp", 30, 20), + ( + "filter[provider_groups]", + lambda p1, _, group1, __: str(group1.id), + 10, + 5, + ), + ( + "filter[provider_groups__in]", + lambda p1, _, group1, group2: f"{group1.id},{group2.id}", + 30, + 20, + ), ], ) def test_overview_categories_filters( @@ -10437,6 +12074,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_category_summary, filter_key, filter_value_fn, @@ -10445,6 +12083,16 @@ class TestOverviewViewSet: ): tenant = tenants_fixture[0] provider1, _, gcp_provider, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=gcp_provider, provider_group=group2 + ) scan1 = Scan.objects.create( name="categories-scan-1", @@ -10470,7 +12118,7 @@ class TestOverviewViewSet: response = authenticated_client.get( reverse("overview-categories"), - {filter_key: filter_value_fn(provider1, gcp_provider)}, + {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)}, ) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -10644,10 +12292,22 @@ class TestOverviewViewSet: @pytest.mark.parametrize( "filter_key,filter_value_fn,expected_total,expected_failed", [ - ("filter[provider_id]", lambda p1, p2: str(p1.id), 10, 5), - ("filter[provider_id__in]", lambda p1, p2: f"{p1.id},{p2.id}", 25, 12), - ("filter[provider_type]", lambda p1, p2: "aws", 10, 5), - ("filter[provider_type__in]", lambda p1, p2: "aws,gcp", 25, 12), + ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5), + ("filter[provider_id__in]", lambda p1, p2, *_: f"{p1.id},{p2.id}", 25, 12), + ("filter[provider_type]", lambda *_: "aws", 10, 5), + ("filter[provider_type__in]", lambda *_: "aws,gcp", 25, 12), + ( + "filter[provider_groups]", + lambda p1, p2, group1, group2: str(group1.id), + 10, + 5, + ), + ( + "filter[provider_groups__in]", + lambda p1, p2, group1, group2: f"{group1.id},{group2.id}", + 25, + 12, + ), ], ) def test_overview_groups_provider_filters( @@ -10655,6 +12315,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_resource_group_summary, filter_key, filter_value_fn, @@ -10664,6 +12325,16 @@ class TestOverviewViewSet: tenant = tenants_fixture[0] provider1 = providers_fixture[0] # AWS gcp_provider = providers_fixture[2] # GCP + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=gcp_provider, provider_group=group2 + ) scan1 = Scan.objects.create( name="aws-rg-scan", @@ -10689,7 +12360,7 @@ class TestOverviewViewSet: response = authenticated_client.get( reverse("overview-resource-groups"), - {filter_key: filter_value_fn(provider1, gcp_provider)}, + {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)}, ) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -10864,6 +12535,49 @@ class TestOverviewViewSet: data = response.json()["data"] assert len(data) >= 1 + def test_compliance_watchlist_provider_groups_filter( + self, + authenticated_client, + provider_compliance_scores_fixture, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("overview-compliance-watchlist"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + by_id = {item["id"]: item["attributes"] for item in data} + assert by_id["aws_cis_2.0"]["requirements_passed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + + response = authenticated_client.get( + reverse("overview-compliance-watchlist"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + by_id = {item["id"]: item["attributes"] for item in data} + assert by_id["aws_cis_2.0"]["requirements_passed"] == 0 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 2 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + def test_compliance_watchlist_empty_result(self, authenticated_client): response = authenticated_client.get(reverse("overview-compliance-watchlist")) assert response.status_code == status.HTTP_200_OK @@ -10998,9 +12712,9 @@ class TestIntegrationViewSet: included_data = response.json()["included"] for expected_type in expected_resources: - assert any( - d.get("type") == expected_type for d in included_data - ), f"Expected type '{expected_type}' not found in included data" + assert any(d.get("type") == expected_type for d in included_data), ( + f"Expected type '{expected_type}' not found in included data" + ) @pytest.mark.parametrize( "integration_type, configuration, credentials", @@ -11665,7 +13379,7 @@ class TestSAMLTokenValidation: saml_token = SAMLToken.objects.create( token=valid_token_data, user=user, - expires_at=datetime.now(timezone.utc) + timedelta(seconds=10), + expires_at=datetime.now(UTC) + timedelta(seconds=10), ) url = reverse("token-saml") @@ -11691,7 +13405,7 @@ class TestSAMLTokenValidation: saml_token = SAMLToken.objects.create( token=expired_token_data, user=user, - expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), + expires_at=datetime.now(UTC) - timedelta(seconds=1), ) url = reverse("token-saml") @@ -11710,7 +13424,7 @@ class TestSAMLTokenValidation: saml_token = SAMLToken.objects.create( token=token_data, user=user, - expires_at=datetime.now(timezone.utc) + timedelta(seconds=10), + expires_at=datetime.now(UTC) + timedelta(seconds=10), ) url = reverse("token-saml") @@ -11921,12 +13635,14 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], + "userType": ["platform_team"], }, ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -11946,18 +13662,23 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace( tenant_id=tenants_fixture[0].id ) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenants_fixture[0] + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -11976,12 +13697,21 @@ class TestTenantFinishACSView: assert user.name == "John Doe" assert user.company_name == "testing_company" - role = Role.objects.using(MainRouter.admin_db).get(name="no_permissions") + role = Role.objects.using(MainRouter.admin_db).get( + name="platform_team", tenant=tenants_fixture[0] + ) assert role.tenant == tenants_fixture[0] + assert not role.manage_users + assert not role.manage_account + assert not role.manage_billing + assert not role.manage_providers + assert not role.manage_integrations + assert not role.manage_scans + assert role.unlimited_visibility assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, tenant_id=tenants_fixture[0].id) + .filter(user=user, role=role, tenant_id=tenants_fixture[0].id) .exists() ) @@ -11996,6 +13726,81 @@ class TestTenantFinishACSView: user.company_name = original_company user.save() + def test_dispatch_rejects_assertion_email_domain_that_differs_from_slug( + self, tenants_fixture, saml_setup, monkeypatch + ): + monkeypatch.setenv("AUTH_URL", "http://localhost") + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + victim_tenant = tenants_fixture[0] + attacker_tenant = tenants_fixture[1] + attacker_domain = "attacker.com" + + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=attacker_domain, + metadata_xml=""" + + + + + + TEST + + + + + + + """, + tenant=attacker_tenant, + ) + user = User.objects.using(MainRouter.admin_db).create( + email=f"intruder@{saml_setup['domain']}", name="Intruder" + ) + social_account = SocialAccount( + user=user, + provider="ATTACKER", + extra_data={ + "firstName": ["Mallory"], + "lastName": ["Example"], + }, + ) + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": attacker_domain}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", + provider_id="ATTACKER", + client_id=attacker_domain, + name="Attacker App", + settings={}, + ) + mock_sa_get.return_value = social_account + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug=attacker_domain) + + assert response.status_code == 302 + assert "sso_saml_failed=true" in response.url + assert not ( + Membership.objects.using(MainRouter.admin_db) + .filter(user=user, tenant=victim_tenant) + .exists() + ) + assert ( + not SAMLToken.objects.using(MainRouter.admin_db).filter(user=user).exists() + ) + def test_rollback_saml_user_when_error_occurs(self, users_fixture, monkeypatch): """Test that a user is properly deleted when created during SAML flow and an error occurs""" monkeypatch.setenv("AUTH_URL", "http://localhost") @@ -12034,7 +13839,7 @@ class TestTenantFinishACSView: assert response.status_code == 302 assert "sso_saml_failed=true" in response.url - def test_dispatch_skips_role_mapping_when_single_manage_account_user( + def test_dispatch_keeps_existing_roles_when_usertype_missing( self, create_test_user, tenants_fixture, @@ -12043,7 +13848,7 @@ class TestTenantFinishACSView: settings, monkeypatch, ): - """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role""" + """Test that roles are left untouched when the IdP does not send userType""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] @@ -12052,6 +13857,7 @@ class TestTenantFinishACSView: UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) + roles_before = Role.objects.using(MainRouter.admin_db).count() social_account = SocialAccount( user=user, @@ -12060,12 +13866,13 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], # This should be ignored }, ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -12085,37 +13892,194 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 - # Verify the admin role is still assigned (not changed to no_permissions) + # Verify the existing role assignment was not modified assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(user=user, role=admin_role, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT created in the database - assert ( - not Role.objects.using(MainRouter.admin_db) - .filter(name="no_permissions", tenant=tenant) + # Verify no new role was created + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + + def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing( + self, + create_test_user, + tenants_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a user without roles gets none assigned when userType is missing""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + roles_before = Role.objects.using(MainRouter.admin_db).count() + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + }, + ) + + request = RequestFactory().get( + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug=saml_setup["domain"]) + + assert response.status_code == 302 + + # Verify no role was created or assigned + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT assigned to the user - assert not ( + # Membership is still created so the user belongs to the tenant + assert ( + Membership.objects.using(MainRouter.admin_db) + .filter(user=user, tenant=tenant) + .exists() + ) + + def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a new read-only role is neither created nor assigned if it would remove the last MANAGE_ACCOUNT user""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + admin_role = admin_role_fixture + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": ["brand_new_role"], + }, + ) + + request = RequestFactory().get( + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug=saml_setup["domain"]) + + assert response.status_code == 302 + + # The admin role is still assigned and the new role was not created + assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, role__name="no_permissions", tenant_id=tenant.id) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + assert ( + not Role.objects.using(MainRouter.admin_db) + .filter(name="brand_new_role", tenant=tenant) .exists() ) @@ -12152,7 +14116,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -12172,16 +14138,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -12236,7 +14207,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -12256,16 +14229,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -12319,7 +14297,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = non_admin_user request.session = {} @@ -12339,16 +14319,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = non_admin_user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -12437,9 +14422,9 @@ class TestLighthouseConfigViewSet: ) # Check that API key is masked with asterisks only masked_api_key = data["attributes"]["api_key"] - assert all( - c == "*" for c in masked_api_key - ), "API key should contain only asterisks" + assert all(c == "*" for c in masked_api_key), ( + "API key should contain only asterisks" + ) @pytest.mark.parametrize( "field_name, invalid_value", @@ -15442,6 +17427,12 @@ class TestFindingGroupViewSet: assert attrs["fail_count"] == 0 assert attrs["resources_total"] == 1 assert attrs["resources_fail"] == 0 + # check_title / check_description are resolved post-pagination from the + # summary table, not from the finding's check_metadata. + assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs" + assert ( + attrs["check_description"] == "EC2 instances should use private IPs only." + ) def test_finding_groups_status_pass_when_no_fail( self, authenticated_client, finding_groups_fixture @@ -15457,10 +17448,16 @@ class TestFindingGroupViewSet: # iam_password_policy has only PASS findings assert data[0]["attributes"]["status"] == "PASS" - def test_finding_groups_status_muted_all( + def test_finding_groups_fully_muted_group_is_pass( self, authenticated_client, finding_groups_fixture ): - """Test that MUTED status returned when all findings are muted.""" + """A fully-muted group reports status=PASS and muted=True. + + rds_encryption has 2 muted FAIL findings. Muted findings are treated + as resolved/accepted, so the group is no longer actionable and its + status must be PASS. The `muted` flag is True because every finding + in the group is muted. + """ response = authenticated_client.get( reverse("finding-group-list"), {"filter[inserted_at]": TODAY, "filter[check_id]": "rds_encryption"}, @@ -15468,8 +17465,98 @@ class TestFindingGroupViewSet: assert response.status_code == status.HTTP_200_OK data = response.json()["data"] assert len(data) == 1 - # rds_encryption has all muted findings - assert data[0]["attributes"]["status"] == "MUTED" + attrs = data[0]["attributes"] + assert attrs["status"] == "PASS" + assert attrs["muted"] is True + assert attrs["fail_count"] == 0 + assert attrs["fail_muted_count"] == 2 + assert attrs["pass_muted_count"] == 0 + assert attrs["manual_muted_count"] == 0 + assert attrs["muted_count"] == 2 + # Sanity: the per-status muted counts must add up to muted_count. + assert ( + attrs["pass_muted_count"] + + attrs["fail_muted_count"] + + attrs["manual_muted_count"] + == attrs["muted_count"] + ) + + def test_finding_groups_status_ignores_muted_failures( + self, + authenticated_client, + tenants_fixture, + scans_fixture, + resources_fixture, + ): + """Muted FAIL findings must not drive the aggregated status. + + When a group mixes one non-muted PASS with one muted FAIL, the + actionable outcome is PASS: there are no unmuted failures left. The + aggregated `status` must reflect that (not FAIL), while `muted` + stays False because the group still has a non-muted finding. + """ + tenant = tenants_fixture[0] + scan1, *_ = scans_fixture + resource1, *_ = resources_fixture + + pass_finding = Finding.objects.create( + tenant_id=tenant.id, + uid="fg_mixed_muted_pass", + scan=scan1, + delta=None, + status=Status.PASS, + severity=Severity.low, + impact=Severity.low, + check_id="mixed_muted_check", + check_metadata={ + "CheckId": "mixed_muted_check", + "checktitle": "Mixed muted check", + "Description": "Fixture for muted status aggregation.", + }, + first_seen_at="2024-01-11T00:00:00Z", + muted=False, + ) + pass_finding.add_resources([resource1]) + + fail_muted_finding = Finding.objects.create( + tenant_id=tenant.id, + uid="fg_mixed_muted_fail", + scan=scan1, + delta=None, + status=Status.FAIL, + severity=Severity.high, + impact=Severity.high, + check_id="mixed_muted_check", + check_metadata={ + "CheckId": "mixed_muted_check", + "checktitle": "Mixed muted check", + "Description": "Fixture for muted status aggregation.", + }, + first_seen_at="2024-01-12T00:00:00Z", + muted=True, + ) + fail_muted_finding.add_resources([resource1]) + + # filter[region] forces finding-level aggregation so we exercise the + # raw-findings path without touching the daily summary fixture. + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[check_id]": "mixed_muted_check", + "filter[region]": "us-east-1", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + attrs = data[0]["attributes"] + assert attrs["status"] == "PASS" + assert attrs["muted"] is False + assert attrs["pass_count"] == 1 + assert attrs["fail_count"] == 0 + assert attrs["fail_muted_count"] == 1 + assert attrs["muted_count"] == 1 def test_finding_groups_status_filter( self, authenticated_client, finding_groups_fixture @@ -15914,6 +18001,44 @@ class TestFindingGroupViewSet: # All fixture findings are from AWS provider assert len(response.json()["data"]) == 5 + def test_finding_groups_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_check_id_filter( self, authenticated_client, finding_groups_fixture ): @@ -15961,7 +18086,7 @@ class TestFindingGroupViewSet: "extra_filters", [ {}, - {"filter[muted]": "include"}, + {"filter[delta]": "new"}, ], ids=["summary_path", "finding_level_path"], ) @@ -15979,7 +18104,8 @@ class TestFindingGroupViewSet: Parametrized to cover both aggregation paths: - summary_path: default, uses _CheckTitleToCheckIdMixin on summaries - - finding_level_path: filter[muted]=include forces CommonFindingFilters + - finding_level_path: filter[delta]=new forces _aggregate_findings via + CommonFindingFilters (delta is finding-level, not summary-level) """ params = { "filter[inserted_at]": TODAY, @@ -16022,6 +18148,36 @@ class TestFindingGroupViewSet: # s3_bucket_public_access has 2 findings with 2 different resources assert len(data) == 2 + def test_resources_id_matches_resource_id_for_mapped_findings( + self, authenticated_client, finding_groups_fixture + ): + """Findings with a resource expose the resource id as row id (hot path contract).""" + response = authenticated_client.get( + reverse( + "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"} + ), + {"filter[inserted_at]": TODAY}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data, "expected resources in response" + + resource_ids = set( + ResourceFindingMapping.objects.filter( + finding__check_id="s3_bucket_public_access", + ).values_list("resource_id", flat=True) + ) + finding_ids = set( + Finding.objects.filter( + check_id="s3_bucket_public_access", + ).values_list("id", flat=True) + ) + + returned_ids = {item["id"] for item in data} + assert returned_ids <= {str(rid) for rid in resource_ids} + assert returned_ids.isdisjoint({str(fid) for fid in finding_ids}) + def test_resources_fields(self, authenticated_client, finding_groups_fixture): """Test resource fields (uid, name, service, region, type) have valid values.""" response = authenticated_client.get( @@ -16057,9 +18213,9 @@ class TestFindingGroupViewSet: assert len(data) == 2 for item in data: resource = item["attributes"]["resource"] - assert ( - resource["resource_group"] == "storage" - ), "resource_group must be 'storage'" + assert resource["resource_group"] == "storage", ( + "resource_group must be 'storage'" + ) def test_resources_name_icontains( self, authenticated_client, finding_groups_fixture @@ -16373,12 +18529,12 @@ class TestFindingGroupViewSet: assert response_p1.status_code == status.HTTP_200_OK p1_check_ids = {item["id"] for item in response_p1.json()["data"]} # Provider1 has scan1 with 4 checks - assert ( - len(p1_check_ids) == 4 - ), f"Provider1 should have 4 checks, got {len(p1_check_ids)}" - assert ( - "cloudtrail_enabled" not in p1_check_ids - ), "cloudtrail_enabled should NOT be in provider1" + assert len(p1_check_ids) == 4, ( + f"Provider1 should have 4 checks, got {len(p1_check_ids)}" + ) + assert "cloudtrail_enabled" not in p1_check_ids, ( + "cloudtrail_enabled should NOT be in provider1" + ) # Get finding groups for provider2 only response_p2 = authenticated_client.get( @@ -16388,12 +18544,12 @@ class TestFindingGroupViewSet: assert response_p2.status_code == status.HTTP_200_OK p2_check_ids = {item["id"] for item in response_p2.json()["data"]} # Provider2 has scan2 with 1 check - assert ( - len(p2_check_ids) == 1 - ), f"Provider2 should have 1 check, got {len(p2_check_ids)}" - assert ( - "cloudtrail_enabled" in p2_check_ids - ), "cloudtrail_enabled should be in provider2" + assert len(p2_check_ids) == 1, ( + f"Provider2 should have 1 check, got {len(p2_check_ids)}" + ) + assert "cloudtrail_enabled" in p2_check_ids, ( + "cloudtrail_enabled should be in provider2" + ) # Test provider_type filter actually filters data def test_finding_groups_provider_type_filter_actually_filters( @@ -16416,9 +18572,9 @@ class TestFindingGroupViewSet: {"filter[inserted_at]": TODAY, "filter[provider_type]": "gcp"}, ) assert response_gcp.status_code == status.HTTP_200_OK - assert ( - len(response_gcp.json()["data"]) == 0 - ), "GCP filter should return 0 results" + assert len(response_gcp.json()["data"]) == 0, ( + "GCP filter should return 0 results" + ) def test_finding_groups_pagination( self, authenticated_client, finding_groups_fixture @@ -16556,6 +18712,12 @@ class TestFindingGroupViewSet: assert attrs["fail_count"] == 0 assert attrs["resources_total"] == 1 assert attrs["resources_fail"] == 0 + # check_title / check_description are resolved post-pagination from the + # summary table, not from the finding's check_metadata. + assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs" + assert ( + attrs["check_description"] == "EC2 instances should use private IPs only." + ) def test_finding_groups_latest_status_in_filter( self, authenticated_client, finding_groups_fixture @@ -16678,7 +18840,7 @@ class TestFindingGroupViewSet: provider=provider1, state=StateChoices.COMPLETED, trigger=Scan.TriggerChoices.MANUAL, - completed_at=datetime.now(timezone.utc), + completed_at=datetime.now(UTC), ) latest_scan_provider2 = Scan.objects.create( @@ -16686,7 +18848,7 @@ class TestFindingGroupViewSet: provider=provider2, state=StateChoices.COMPLETED, trigger=Scan.TriggerChoices.MANUAL, - completed_at=datetime.now(timezone.utc), + completed_at=datetime.now(UTC), ) older_scan_provider1 = Scan.objects.create( @@ -16694,7 +18856,7 @@ class TestFindingGroupViewSet: provider=provider1, state=StateChoices.COMPLETED, trigger=Scan.TriggerChoices.MANUAL, - completed_at=datetime.now(timezone.utc) - timedelta(days=1), + completed_at=datetime.now(UTC) - timedelta(days=1), ) # Older scan — these should be excluded from /latest @@ -16708,7 +18870,7 @@ class TestFindingGroupViewSet: impact="high", check_id=check_id, check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, - first_seen_at=datetime.now(timezone.utc) - timedelta(days=2), + first_seen_at=datetime.now(UTC) - timedelta(days=2), muted=False, ) @@ -16723,7 +18885,7 @@ class TestFindingGroupViewSet: impact="high", check_id=check_id, check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, - first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1), + first_seen_at=datetime.now(UTC) - timedelta(hours=1), muted=False, ) latest_p1_pass.add_resources([resource1]) @@ -16738,7 +18900,7 @@ class TestFindingGroupViewSet: impact="high", check_id=check_id, check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, - first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1), + first_seen_at=datetime.now(UTC) - timedelta(hours=1), muted=False, ) latest_p1_fail.add_resources([resource2]) @@ -16754,7 +18916,7 @@ class TestFindingGroupViewSet: impact="high", check_id=check_id, check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, - first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1), + first_seen_at=datetime.now(UTC) - timedelta(hours=1), muted=False, ) latest_p2.add_resources([resource3]) @@ -16787,6 +18949,41 @@ class TestFindingGroupViewSet: # All providers in fixture are AWS assert len(data) == 5 + def test_finding_groups_latest_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_latest_check_id_filter( self, authenticated_client, finding_groups_fixture ): @@ -16813,18 +19010,20 @@ class TestFindingGroupViewSet: check_ids = [item["id"] for item in data] assert check_ids == sorted(check_ids) - def test_finding_groups_latest_sort_by_check_title( + def test_finding_groups_latest_sort_by_check_title_not_supported( self, authenticated_client, finding_groups_fixture ): - """Test /latest supports sorting by check_title.""" + """check_title is not a sortable field for finding groups. + + Titles live in the TOASTed check_metadata blob and are resolved after + pagination from the summary table, so they cannot drive DB-level + ordering. Requesting that sort is rejected. + """ response = authenticated_client.get( reverse("finding-group-latest"), {"sort": "check_title"}, ) - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - check_titles = [item["attributes"]["check_title"] for item in data] - assert check_titles == sorted(check_titles) + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.parametrize( "endpoint_name", ["finding-group-list", "finding-group-latest"] @@ -16851,6 +19050,39 @@ class TestFindingGroupViewSet: data = response.json()["data"] assert len(data) > 0 + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_sort_by_delta( + self, + authenticated_client, + finding_groups_fixture, + endpoint_name, + ): + """Sort by delta orders by new_count then changed_count (lexicographic).""" + params = {"sort": "-delta"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + + def delta_key(item): + attrs = item["attributes"] + return (attrs.get("new_count", 0), attrs.get("changed_count", 0)) + + desc_keys = [delta_key(item) for item in data] + assert desc_keys == sorted(desc_keys, reverse=True) + + # Ascending order produces the inverse arrangement + params["sort"] = "delta" + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + asc_keys = [delta_key(item) for item in response.json()["data"]] + assert asc_keys == sorted(asc_keys) + def test_finding_groups_latest_ignores_date_filters( self, authenticated_client, finding_groups_fixture ): @@ -16864,3 +19096,446 @@ class TestFindingGroupViewSet: data = response.json()["data"] # Should still return data, not filtered by the old date assert len(data) == 5 + + def test_finding_groups_status_choices_no_muted( + self, authenticated_client, finding_groups_fixture + ): + """Every returned group must have status ∈ {FAIL, PASS, MANUAL}.""" + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY}, + ) + assert response.status_code == status.HTTP_200_OK + statuses = {item["attributes"]["status"] for item in response.json()["data"]} + assert statuses, "fixture should produce at least one group" + assert statuses <= {"FAIL", "PASS", "MANUAL"} + assert "MUTED" not in statuses + + def test_finding_groups_serializer_exposes_muted_and_manual_count( + self, authenticated_client, finding_groups_fixture + ): + """The /finding-groups payload must expose `muted`, `manual_count` and + the per-status muted siblings (`pass_muted_count`/`fail_muted_count`/ + `manual_muted_count`).""" + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[check_id]": "iam_password_policy"}, + ) + assert response.status_code == status.HTTP_200_OK + attrs = response.json()["data"][0]["attributes"] + assert "muted" in attrs and isinstance(attrs["muted"], bool) + assert "manual_count" in attrs and isinstance(attrs["manual_count"], int) + assert attrs["muted"] is False # iam_password_policy has only non-muted PASS + assert attrs["manual_count"] == 0 + assert attrs["pass_muted_count"] == 0 + assert attrs["fail_muted_count"] == 0 + assert attrs["manual_muted_count"] == 0 + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_filter_status_muted_is_rejected( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """`filter[status]=MUTED` is no longer a valid status value.""" + params = {"filter[status]": "MUTED"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_filter_muted_true( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """`filter[muted]=true` returns only fully-muted groups.""" + params = {"filter[muted]": "true"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + check_ids = {item["id"] for item in data} + # Only rds_encryption is fully muted in the fixture + assert check_ids == {"rds_encryption"} + assert all(item["attributes"]["muted"] is True for item in data) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_filter_muted_false( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """`filter[muted]=false` returns only groups with actionable findings.""" + params = {"filter[muted]": "false"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + check_ids = {item["id"] for item in data} + assert "rds_encryption" not in check_ids + assert check_ids == { + "s3_bucket_public_access", + "ec2_instance_public_ip", + "iam_password_policy", + "cloudtrail_enabled", + } + assert all(item["attributes"]["muted"] is False for item in data) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_sort_by_status( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """sort=status orders by aggregated status (FAIL > PASS > MANUAL).""" + priority = {"FAIL": 3, "PASS": 2, "MANUAL": 1} + params = {"sort": "-status"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data, "fixture should produce groups" + + desc_keys = [priority[item["attributes"]["status"]] for item in data] + assert desc_keys == sorted(desc_keys, reverse=True) + + params["sort"] = "status" + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + asc_keys = [ + priority[item["attributes"]["status"]] for item in response.json()["data"] + ] + assert asc_keys == sorted(asc_keys) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_sort_by_muted( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """sort=muted orders by the boolean muted attribute.""" + # Need include_muted=true so the fully-muted group is part of the result + params = {"sort": "-muted", "filter[include_muted]": "true"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data, "fixture should produce groups" + + muted_values = [item["attributes"]["muted"] for item in data] + # Descending boolean: True (1) before False (0) + assert muted_values == sorted(muted_values, reverse=True) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + @pytest.mark.parametrize( + "sort_field", + [ + "pass_muted_count", + "fail_muted_count", + "manual_muted_count", + "new_fail_count", + "new_fail_muted_count", + "new_pass_count", + "new_pass_muted_count", + "new_manual_count", + "new_manual_muted_count", + "changed_fail_count", + "changed_fail_muted_count", + "changed_pass_count", + "changed_pass_muted_count", + "changed_manual_count", + "changed_manual_muted_count", + ], + ) + def test_finding_groups_sort_by_counter_fields( + self, + authenticated_client, + finding_groups_fixture, + endpoint_name, + sort_field, + ): + """All counter fields are accepted as sort parameters (asc and desc).""" + params = {"sort": f"-{sort_field}"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + + desc_values = [item["attributes"][sort_field] for item in data] + assert desc_values == sorted(desc_values, reverse=True) + + params["sort"] = sort_field + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + asc_values = [ + item["attributes"][sort_field] for item in response.json()["data"] + ] + assert asc_values == sorted(asc_values) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_delta_status_breakdown( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """`new_*` and `changed_*` counters split by status and mute state. + + s3_bucket_public_access has 1 new FAIL and 1 changed FAIL (both + non-muted) so the breakdown must reflect exactly that and the totals + must equal the sum of the buckets. + """ + params = {"filter[check_id]": "s3_bucket_public_access"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + attrs = data[0]["attributes"] + + assert attrs["new_fail_count"] == 1 + assert attrs["new_fail_muted_count"] == 0 + assert attrs["new_pass_count"] == 0 + assert attrs["new_pass_muted_count"] == 0 + assert attrs["new_manual_count"] == 0 + assert attrs["new_manual_muted_count"] == 0 + assert attrs["changed_fail_count"] == 1 + assert attrs["changed_fail_muted_count"] == 0 + assert attrs["changed_pass_count"] == 0 + assert attrs["changed_pass_muted_count"] == 0 + assert attrs["changed_manual_count"] == 0 + assert attrs["changed_manual_muted_count"] == 0 + + new_total = ( + attrs["new_fail_count"] + + attrs["new_fail_muted_count"] + + attrs["new_pass_count"] + + attrs["new_pass_muted_count"] + + attrs["new_manual_count"] + + attrs["new_manual_muted_count"] + ) + changed_total = ( + attrs["changed_fail_count"] + + attrs["changed_fail_muted_count"] + + attrs["changed_pass_count"] + + attrs["changed_pass_muted_count"] + + attrs["changed_manual_count"] + + attrs["changed_manual_muted_count"] + ) + # The non-muted variants of the breakdown must sum to the legacy + # totals (new_count/changed_count are stored as non-muted). + assert ( + attrs["new_fail_count"] + + attrs["new_pass_count"] + + attrs["new_manual_count"] + == attrs["new_count"] + ) + assert ( + attrs["changed_fail_count"] + + attrs["changed_pass_count"] + + attrs["changed_manual_count"] + == attrs["changed_count"] + ) + # And the *full* breakdown (including the muted halves) is exposed + # so clients can also count muted-only deltas without losing data. + assert new_total >= attrs["new_count"] + assert changed_total >= attrs["changed_count"] + + def test_finding_groups_resources_serializer_exposes_muted( + self, authenticated_client, finding_groups_fixture + ): + """The /finding-groups//resources payload must expose `muted`.""" + response = authenticated_client.get( + reverse( + "finding-group-resources", + kwargs={"pk": "rds_encryption"}, + ), + {"filter[inserted_at]": TODAY}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data, "rds_encryption should expose its resources" + for item in data: + attrs = item["attributes"] + assert "muted" in attrs and isinstance(attrs["muted"], bool) + # rds_encryption has all muted findings + assert attrs["muted"] is True + # Status reflects the underlying check outcome (FAIL), not MUTED + assert attrs["status"] == "FAIL" + + def test_finding_groups_resources_exposes_finding_id( + self, authenticated_client, finding_groups_fixture + ): + """The /resources payload exposes the most recent matching finding_id. + + rds_encryption has 2 findings, one per resource. Each resource row must + report the UUID of its corresponding Finding (UUIDv7 ordering means + Max(finding__id) resolves to the latest snapshot in time). + """ + response = authenticated_client.get( + reverse( + "finding-group-resources", + kwargs={"pk": "rds_encryption"}, + ), + {"filter[inserted_at]": TODAY}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data, "rds_encryption should expose its resources" + + rds_finding_ids = { + str(f.id) for f in finding_groups_fixture if f.check_id == "rds_encryption" + } + assert rds_finding_ids, "fixture sanity" + + for item in data: + attrs = item["attributes"] + assert "finding_id" in attrs + assert attrs["finding_id"] in rds_finding_ids + + def test_finding_groups_latest_resources_exposes_finding_id( + self, authenticated_client, finding_groups_fixture + ): + """The /latest/.../resources payload also exposes finding_id.""" + response = authenticated_client.get( + reverse( + "finding-group-latest_resources", + kwargs={"check_id": "rds_encryption"}, + ), + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data, "rds_encryption should expose its resources via /latest" + + rds_finding_ids = { + str(f.id) for f in finding_groups_fixture if f.check_id == "rds_encryption" + } + for item in data: + attrs = item["attributes"] + assert "finding_id" in attrs + assert attrs["finding_id"] in rds_finding_ids + + def test_latest_resources_picks_scan_by_completed_at_when_overlap( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + resources_fixture, + ): + """Overlapping scans on the same provider must resolve to the scan + with the latest completed_at, matching the /latest summary path and + the daily-summary upsert (keyed on midnight(completed_at)). Picking + by inserted_at here produced /resources and /latest reading from + different scans and reporting diverging delta/new counts. + """ + tenant = tenants_fixture[0] + provider = providers_fixture[0] + resource = resources_fixture[0] + check_id = "overlap_regression_check" + + t0 = datetime.now(UTC) - timedelta(hours=5) + t1 = t0 + timedelta(hours=1) + t1_end = t1 + timedelta(minutes=30) + t2 = t0 + timedelta(hours=4) + + scan_long = Scan.objects.create( + name="long overlap scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=t0, + completed_at=t2, + ) + scan_short = Scan.objects.create( + name="short overlap scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=t1, + completed_at=t1_end, + ) + # inserted_at is auto_now_add so override with .update() to recreate + # the overlap shape: short scan inserted later but completed earlier. + Scan.all_objects.filter(pk=scan_long.pk).update(inserted_at=t0) + Scan.all_objects.filter(pk=scan_short.pk).update(inserted_at=t1) + scan_long.refresh_from_db() + scan_short.refresh_from_db() + + assert scan_short.inserted_at > scan_long.inserted_at + assert scan_long.completed_at > scan_short.completed_at + + long_finding = Finding.objects.create( + tenant_id=tenant.id, + uid=f"{check_id}_long", + scan=scan_long, + delta=None, + status=Status.FAIL, + status_extended="long scan finding", + impact=Severity.high, + impact_extended="high", + severity=Severity.high, + raw_result={"status": Status.FAIL, "severity": Severity.high}, + check_id=check_id, + check_metadata={ + "CheckId": check_id, + "checktitle": "Overlap regression", + "Description": "Overlapping scan regression.", + }, + first_seen_at=t0, + muted=False, + ) + long_finding.add_resources([resource]) + + short_finding = Finding.objects.create( + tenant_id=tenant.id, + uid=f"{check_id}_short", + scan=scan_short, + delta="new", + status=Status.FAIL, + status_extended="short scan finding", + impact=Severity.high, + impact_extended="high", + severity=Severity.high, + raw_result={"status": Status.FAIL, "severity": Severity.high}, + check_id=check_id, + check_metadata={ + "CheckId": check_id, + "checktitle": "Overlap regression", + "Description": "Overlapping scan regression.", + }, + first_seen_at=t1, + muted=False, + ) + short_finding.add_resources([resource]) + + response = authenticated_client.get( + reverse( + "finding-group-latest_resources", + kwargs={"check_id": check_id}, + ), + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + attrs = data[0]["attributes"] + assert attrs["finding_id"] == str(long_finding.id) + assert attrs["delta"] is None diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index b80d54b08a..ce1dc0f10d 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -1,22 +1,21 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import TYPE_CHECKING from allauth.socialaccount.providers.oauth2.client import OAuth2Client -from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import Subquery -from rest_framework.exceptions import NotFound, ValidationError - from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import InvitationTokenExpiredException from api.models import Integration, Invitation, Processor, Provider, Resource from api.v1.serializers import FindingMetadataSerializer +from django.contrib.postgres.aggregates import ArrayAgg +from django.db.models import Subquery from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError from prowler.providers.aws.lib.s3.s3 import S3 from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub from prowler.providers.common.models import Connection +from rest_framework.exceptions import NotFound, ValidationError if TYPE_CHECKING: from prowler.providers.alibabacloud.alibabacloud_provider import ( @@ -37,6 +36,7 @@ if TYPE_CHECKING: from prowler.providers.mongodbatlas.mongodbatlas_provider import ( MongodbatlasProvider, ) + from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider @@ -93,6 +93,7 @@ def return_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -181,6 +182,10 @@ def return_prowler_provider( from prowler.providers.vercel.vercel_provider import VercelProvider prowler_provider = VercelProvider + case Provider.ProviderChoices.OKTA.value: + from prowler.providers.okta.okta_provider import OktaProvider + + prowler_provider = OktaProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -237,6 +242,12 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "filter_accounts": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + if isinstance(prowler_provider_kwargs.get("region"), str): + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "region": {prowler_provider_kwargs["region"]}, + } elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated # in the provider itself, so it's not needed here. @@ -246,6 +257,11 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "team_id": provider.uid, } + elif provider.provider == Provider.ProviderChoices.OKTA.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + } elif provider.provider == Provider.ProviderChoices.IMAGE.value: # Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or # a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest"). @@ -290,6 +306,7 @@ def initialize_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -351,6 +368,14 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: "raise_on_exception": False, } return prowler_provider.test_connection(**vercel_kwargs) + elif provider.provider == Provider.ProviderChoices.OKTA.value: + okta_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + "provider_id": provider.uid, + "raise_on_exception": False, + } + return prowler_provider.test_connection(**okta_kwargs) elif provider.provider == Provider.ProviderChoices.IMAGE.value: image_kwargs = { "image": provider.uid, @@ -416,8 +441,8 @@ def prowler_integration_connection_test(integration: Integration) -> Connection: # Only save regions if connection is successful if connection.is_connected: - regions_status = {r: True for r in connection.enabled_regions} - regions_status.update({r: False for r in connection.disabled_regions}) + regions_status = dict.fromkeys(connection.enabled_regions, True) + regions_status.update(dict.fromkeys(connection.disabled_regions, False)) # Save regions information in the integration configuration integration.configuration["regions"] = regions_status @@ -499,7 +524,7 @@ def validate_invitation( raise ValidationError({"invitation_token": "Invalid invitation code."}) # Check if the invitation has expired - if invitation.expires_at < datetime.now(timezone.utc): + if invitation.expires_at < datetime.now(UTC): invitation.state = Invitation.State.EXPIRED invitation.save(using=MainRouter.admin_db) raise InvitationTokenExpiredException() @@ -570,6 +595,6 @@ def initialize_prowler_integration(integration: Integration) -> Jira: with rls_transaction(str(integration.tenant_id)): integration.configuration["projects"] = {} integration.connected = False - integration.connection_last_checked_at = datetime.now(tz=timezone.utc) + integration.connection_last_checked_at = datetime.now(tz=UTC) integration.save() raise jira_auth_error diff --git a/api/src/backend/api/uuid_utils.py b/api/src/backend/api/uuid_utils.py index b1f33432ff..4b2c01d8b0 100644 --- a/api/src/backend/api/uuid_utils.py +++ b/api/src/backend/api/uuid_utils.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from random import getrandbits from dateutil.relativedelta import relativedelta @@ -81,7 +81,7 @@ def datetime_from_uuid7(uuid7: UUID) -> datetime: A datetime object representing the timestamp encoded in the UUIDv7. """ timestamp_ms = uuid7.time - return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc) + return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC) def uuid7_start(uuid_obj: UUID) -> UUID: diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py index e1a5d3470f..7645c92f4c 100644 --- a/api/src/backend/api/v1/mixins.py +++ b/api/src/backend/api/v1/mixins.py @@ -1,15 +1,18 @@ -from django.urls import reverse -from django_celery_results.models import TaskResult -from rest_framework import status -from rest_framework.response import Response +import uuid from api.exceptions import ( TaskFailedException, TaskInProgressException, TaskNotFoundException, ) -from api.models import StateChoices, Task +from api.models import Provider, StateChoices, Task from api.v1.serializers import TaskSerializer +from django.http import QueryDict +from django.urls import reverse +from django_celery_results.models import TaskResult +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response class DisablePaginationMixin: @@ -74,6 +77,162 @@ class PaginateByPkMixin: return self.get_paginated_response(serialized) +class JsonApiFilterMixin: + """Shared helpers for manually applying django-filter to JSON:API params.""" + + jsonapi_filter_replace_dots = False + + def _normalize_jsonapi_params( + self, + query_params, + exclude_keys=None, + replace_dots=None, + ): + exclude_keys = exclude_keys or set() + if replace_dots is None: + replace_dots = self.jsonapi_filter_replace_dots + + normalized = QueryDict(mutable=True) + for key, values in query_params.lists(): + normalized_key = ( + key[7:-1] if key.startswith("filter[") and key.endswith("]") else key + ) + if replace_dots: + normalized_key = normalized_key.replace(".", "__") + if normalized_key not in exclude_keys: + normalized.setlist(normalized_key, values) + return normalized + + def _apply_filterset( + self, + queryset, + filterset_class, + exclude_keys=None, + replace_dots=None, + ): + normalized_params = self._normalize_jsonapi_params( + self.request.query_params, + exclude_keys=set(exclude_keys or []), + replace_dots=replace_dots, + ) + filterset = filterset_class(normalized_params, queryset=queryset) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + return filterset.qs + + +class ProviderFilterParamsMixin(JsonApiFilterMixin): + """Shared extraction of provider filters from JSON:API query params.""" + + PROVIDER_FILTER_KEYS = frozenset( + { + "provider_id", + "provider_id__in", + "provider_type", + "provider_type__in", + "provider_groups", + "provider_groups__in", + } + ) + PROVIDER_FILTER_DOT_ALIAS_KEYS = frozenset( + { + "provider_id.in", + "provider_type.in", + "provider_groups.in", + } + ) + PROVIDER_FILTER_QUERY_KEYS = PROVIDER_FILTER_KEYS | PROVIDER_FILTER_DOT_ALIAS_KEYS + + def _csv_filter_values(self, value): + return [item.strip() for item in value.split(",") if item.strip()] + + def _validate_uuid_filter_values(self, field_name, values): + try: + for value in values: + uuid.UUID(str(value)) + except (TypeError, ValueError, AttributeError): + raise ValidationError({field_name: ["Enter a valid UUID."]}) + + def _has_provider_filters(self, include_dot_aliases=False): + provider_filter_keys = ( + self.PROVIDER_FILTER_QUERY_KEYS + if include_dot_aliases + else self.PROVIDER_FILTER_KEYS + ) + return any( + self.request.query_params.get(f"filter[{key}]") + for key in provider_filter_keys + ) + + def _extract_provider_filters_from_params( + self, + *, + validate_uuids=False, + include_dot_aliases=False, + ): + params = self.request.query_params + filters = {} + valid_provider_types = { + choice[0] for choice in Provider.ProviderChoices.choices + } + + provider_id = params.get("filter[provider_id]") + if provider_id: + if validate_uuids: + self._validate_uuid_filter_values("provider_id", [provider_id]) + filters["provider_id"] = provider_id + + provider_id_in = params.get("filter[provider_id__in]") + if include_dot_aliases: + provider_id_in = provider_id_in or params.get("filter[provider_id.in]") + if provider_id_in: + values = self._csv_filter_values(provider_id_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_id__in", values) + filters["provider_id__in"] = values + + provider_type = params.get("filter[provider_type]") + if provider_type: + if provider_type not in valid_provider_types: + raise ValidationError( + {"provider_type": f"Invalid choice: {provider_type}"} + ) + filters["provider__provider"] = provider_type + + provider_type_in = params.get("filter[provider_type__in]") + if include_dot_aliases: + provider_type_in = provider_type_in or params.get( + "filter[provider_type.in]" + ) + if provider_type_in: + values = self._csv_filter_values(provider_type_in) + invalid = [value for value in values if value not in valid_provider_types] + if invalid: + raise ValidationError( + {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} + ) + filters["provider__provider__in"] = values + + provider_groups = params.get("filter[provider_groups]") + if provider_groups: + if validate_uuids: + self._validate_uuid_filter_values("provider_groups", [provider_groups]) + filters["provider__provider_groups__id"] = provider_groups + + provider_groups_in = params.get("filter[provider_groups__in]") + if include_dot_aliases: + provider_groups_in = provider_groups_in or params.get( + "filter[provider_groups.in]" + ) + if provider_groups_in: + values = self._csv_filter_values(provider_groups_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_groups__in", values) + filters["provider__provider_groups__id__in"] = values + + return filters + + class TaskManagementMixin: """ Mixin to manage task status checking. diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index aaa0f4aa31..a77de9c237 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -1,11 +1,10 @@ import os import re +from api.v1.serializer_utils.base import BaseValidateSerializer from drf_spectacular.utils import extend_schema_field from rest_framework_json_api import serializers -from api.v1.serializer_utils.base import BaseValidateSerializer - class S3ConfigSerializer(BaseValidateSerializer): bucket_name = serializers.CharField() diff --git a/api/src/backend/api/v1/serializer_utils/processors.py b/api/src/backend/api/v1/serializer_utils/processors.py index 4022f3f2bc..ee53aa8ccf 100644 --- a/api/src/backend/api/v1/serializer_utils/processors.py +++ b/api/src/backend/api/v1/serializer_utils/processors.py @@ -1,7 +1,5 @@ -from drf_spectacular.utils import extend_schema_field - from api.v1.serializer_utils.base import YamlOrJsonField - +from drf_spectacular.utils import extend_schema_field from prowler.lib.mutelist.mutelist import mutelist_schema diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index f8d67f0e3b..0b8b4eacf4 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -404,6 +404,26 @@ from rest_framework_json_api import serializers }, "required": ["clouds_yaml_content", "clouds_yaml_cloud"], }, + { + "type": "object", + "title": "Okta OAuth Credentials", + "properties": { + "okta_client_id": { + "type": "string", + "description": "Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.", + }, + "okta_private_key": { + "type": "string", + "description": "PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.", + }, + "okta_scopes": { + "type": "array", + "items": {"type": "string"}, + "description": "OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.", + }, + }, + "required": ["okta_client_id", "okta_private_key"], + }, { "type": "object", "title": "Vercel API Token", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 185a8e047a..1d160b4048 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1,23 +1,6 @@ import base64 import json -from datetime import datetime, timedelta, timezone - -from django.conf import settings -from django.contrib.auth import authenticate -from django.contrib.auth.models import update_last_login -from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import IntegrityError -from drf_spectacular.utils import extend_schema_field -from jwt.exceptions import InvalidKeyError -from rest_framework.reverse import reverse -from rest_framework.validators import UniqueTogetherValidator -from rest_framework_json_api import serializers -from rest_framework_json_api.relations import SerializerMethodResourceRelatedField -from rest_framework_json_api.serializers import ValidationError -from rest_framework_simplejwt.exceptions import TokenError -from rest_framework_simplejwt.serializers import TokenObtainPairSerializer -from rest_framework_simplejwt.tokens import RefreshToken +from datetime import UTC, datetime, timedelta from api.db_router import MainRouter from api.exceptions import ConflictException @@ -72,7 +55,23 @@ from api.v1.serializer_utils.lighthouse import ( ) from api.v1.serializer_utils.processors import ProcessorConfigField from api.v1.serializer_utils.providers import ProviderSecretField +from django.conf import settings +from django.contrib.auth import authenticate +from django.contrib.auth.models import update_last_login +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError +from drf_spectacular.utils import extend_schema_field +from jwt.exceptions import InvalidKeyError from prowler.lib.mutelist.mutelist import Mutelist +from rest_framework.reverse import reverse +from rest_framework.validators import UniqueTogetherValidator +from rest_framework_json_api import serializers +from rest_framework_json_api.relations import SerializerMethodResourceRelatedField +from rest_framework_json_api.serializers import ValidationError +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.tokens import RefreshToken # Base @@ -1397,6 +1396,7 @@ class ResourceIncludeSerializer(RLSSerializer): "service", "type_", "tags", + "metadata", "details", "partition", ] @@ -1404,6 +1404,7 @@ class ResourceIncludeSerializer(RLSSerializer): "id": {"read_only": True}, "inserted_at": {"read_only": True}, "updated_at": {"read_only": True}, + "metadata": {"read_only": True}, "details": {"read_only": True}, "partition": {"read_only": True}, } @@ -1543,6 +1544,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): serializer = GCPProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GOOGLEWORKSPACE.value: serializer = GoogleWorkspaceProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.OKTA.value: + serializer = OktaProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GITHUB.value: serializer = GithubProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.IAC.value: @@ -1688,6 +1691,15 @@ class GoogleWorkspaceProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class OktaProviderSecret(serializers.Serializer): + okta_client_id = serializers.CharField() + okta_private_key = serializers.CharField() + okta_scopes = serializers.ListField(child=serializers.CharField(), required=False) + + class Meta: + resource_name = "provider-secrets" + + class MongoDBAtlasProviderSecret(serializers.Serializer): atlas_public_key = serializers.CharField() atlas_private_key = serializers.CharField() @@ -1968,7 +1980,7 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer): return value def validate_expires_at(self, value): - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if value and value < now + timedelta(hours=24): raise ValidationError( "Expiry date must be at least 24 hours in the future." @@ -4185,6 +4197,7 @@ class FindingGroupSerializer(BaseSerializerV1): check_description = serializers.CharField(required=False, allow_null=True) severity = serializers.CharField() status = serializers.CharField() + muted = serializers.BooleanField() impacted_providers = serializers.ListField( child=serializers.CharField(), required=False ) @@ -4192,9 +4205,25 @@ class FindingGroupSerializer(BaseSerializerV1): resources_total = serializers.IntegerField() pass_count = serializers.IntegerField() fail_count = serializers.IntegerField() + manual_count = serializers.IntegerField() + pass_muted_count = serializers.IntegerField() + fail_muted_count = serializers.IntegerField() + manual_muted_count = serializers.IntegerField() muted_count = serializers.IntegerField() new_count = serializers.IntegerField() changed_count = serializers.IntegerField() + new_fail_count = serializers.IntegerField() + new_fail_muted_count = serializers.IntegerField() + new_pass_count = serializers.IntegerField() + new_pass_muted_count = serializers.IntegerField() + new_manual_count = serializers.IntegerField() + new_manual_muted_count = serializers.IntegerField() + changed_fail_count = serializers.IntegerField() + changed_fail_muted_count = serializers.IntegerField() + changed_pass_count = serializers.IntegerField() + changed_pass_muted_count = serializers.IntegerField() + changed_manual_count = serializers.IntegerField() + changed_manual_muted_count = serializers.IntegerField() first_seen_at = serializers.DateTimeField(required=False, allow_null=True) last_seen_at = serializers.DateTimeField(required=False, allow_null=True) failing_since = serializers.DateTimeField(required=False, allow_null=True) @@ -4208,14 +4237,18 @@ class FindingGroupResourceSerializer(BaseSerializerV1): Serializer for Finding Group Resources - resources within a finding group. Returns individual resources with their current status, severity, - and timing information. + and timing information. Orphan findings (without any resource) expose the + finding id as `id` so the row stays identifiable in the UI. """ - id = serializers.UUIDField(source="resource_id") + id = serializers.UUIDField(source="row_id") resource = serializers.SerializerMethodField() provider = serializers.SerializerMethodField() + finding_id = serializers.UUIDField() status = serializers.CharField() severity = serializers.CharField() + muted = serializers.BooleanField() + delta = serializers.CharField(required=False, allow_null=True) first_seen_at = serializers.DateTimeField(required=False, allow_null=True) last_seen_at = serializers.DateTimeField(required=False, allow_null=True) muted_reason = serializers.CharField(required=False, allow_null=True) diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index 533106d0e4..b53fe1c817 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -1,10 +1,4 @@ from allauth.socialaccount.providers.saml.views import ACSView, MetadataView, SLSView -from django.http import JsonResponse -from django.urls import include, path -from django.views.decorators.csrf import csrf_exempt -from drf_spectacular.views import SpectacularRedocView -from rest_framework_nested import routers - from api.v1.views import ( AttackPathsScanViewSet, ComplianceOverviewViewSet, @@ -49,6 +43,11 @@ from api.v1.views import ( UserRoleRelationshipView, UserViewSet, ) +from django.http import JsonResponse +from django.urls import include, path +from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.views import SpectacularRedocView +from rest_framework_nested import routers # This helper view is used to block any endpoints that should not be available diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index cf4f752052..b488525a0a 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -4,9 +4,10 @@ import json import logging import os import time +import uuid from collections import defaultdict from copy import deepcopy -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from urllib.parse import urljoin @@ -15,102 +16,22 @@ from allauth.socialaccount.models import SocialAccount, SocialApp from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView -from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError -from celery import chain -from celery.result import AsyncResult -from config.custom_logging import BackendLogger -from config.env import env -from config.settings.social_login import ( - GITHUB_OAUTH_CALLBACK_URL, - GOOGLE_OAUTH_CALLBACK_URL, -) -from dj_rest_auth.registration.views import SocialLoginView -from django.conf import settings as django_settings -from django.contrib.postgres.aggregates import ArrayAgg, StringAgg -from django.contrib.postgres.search import SearchQuery -from django.db import transaction -from django.db.models import ( - Case, - CharField, - Count, - DecimalField, - ExpressionWrapper, - F, - IntegerField, - Max, - Min, - Prefetch, - Q, - QuerySet, - Subquery, - Sum, - Value, - When, - Window, -) -from django.db.models.fields.json import KeyTextTransform -from django.db.models.functions import Cast, Coalesce, RowNumber -from django.http import HttpResponse, QueryDict -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.dateparse import parse_date -from django.utils.decorators import method_decorator -from django.views.decorators.cache import cache_control -from django_celery_beat.models import PeriodicTask -from drf_spectacular.settings import spectacular_settings -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import ( - OpenApiParameter, - OpenApiResponse, - extend_schema, - extend_schema_view, -) -from drf_spectacular.views import SpectacularAPIView -from drf_spectacular_jsonapi.schemas.openapi import JsonApiAutoSchema -from rest_framework import permissions, status -from rest_framework.decorators import action -from rest_framework.exceptions import ( - MethodNotAllowed, - NotFound, - PermissionDenied, - ValidationError, -) -from rest_framework.generics import GenericAPIView, get_object_or_404 -from rest_framework.permissions import SAFE_METHODS -from rest_framework_json_api import filters as jsonapi_filters -from rest_framework_json_api.views import RelationshipView, Response -from rest_framework_simplejwt.exceptions import InvalidToken, TokenError -from tasks.beat import schedule_provider_scan -from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils -from tasks.jobs.export import get_s3_client -from tasks.tasks import ( - backfill_compliance_summaries_task, - backfill_scan_resource_summaries_task, - check_integration_connection_task, - check_lighthouse_connection_task, - check_lighthouse_provider_connection_task, - check_provider_connection_task, - delete_provider_task, - delete_tenant_task, - jira_integration_task, - mute_historical_findings_task, - perform_scan_task, - reaggregate_all_finding_group_summaries_task, - refresh_lighthouse_provider_models_task, -) - from api.attack_paths import database as graph_database from api.attack_paths import get_queries_for_provider, get_query_by_id from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( + COMPLIANCE_WARMED, + COMPLIANCE_WARMING_STARTED, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, + get_prowler_provider_compliance, ) from api.constants import SEVERITY_ORDER from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import ( + ComplianceWarmingError, TaskFailedException, UpstreamAccessDeniedError, UpstreamAuthenticationError, @@ -166,6 +87,7 @@ from api.models import ( FindingGroupDailySummary, Integration, Invitation, + InvitationRoleRelationship, LighthouseConfiguration, LighthouseProviderConfiguration, LighthouseProviderModels, @@ -212,7 +134,13 @@ from api.utils import ( validate_invitation, ) from api.uuid_utils import datetime_to_uuid7, uuid7_start -from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin +from api.v1.mixins import ( + DisablePaginationMixin, + JsonApiFilterMixin, + PaginateByPkMixin, + ProviderFilterParamsMixin, + TaskManagementMixin, +) from api.v1.serializers import ( AttackPathsCartographySchemaSerializer, AttackPathsCustomQueryRunRequestSerializer, @@ -306,6 +234,64 @@ from api.v1.serializers import ( UserSerializer, UserUpdateSerializer, ) +from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError +from celery import chain, states +from celery.result import AsyncResult +from config.custom_logging import BackendLogger +from config.env import env +from config.settings.social_login import ( + GITHUB_OAUTH_CALLBACK_URL, + GOOGLE_OAUTH_CALLBACK_URL, +) +from config.version import RELEASE_ID +from dj_rest_auth.registration.views import SocialLoginView +from django.conf import settings as django_settings +from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg +from django.contrib.postgres.search import SearchQuery +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.db.models import ( + BooleanField, + Case, + CharField, + Count, + DecimalField, + Exists, + ExpressionWrapper, + F, + IntegerField, + Max, + Min, + OuterRef, + Prefetch, + Q, + QuerySet, + Subquery, + Sum, + Value, + When, + Window, +) +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Cast, Coalesce, RowNumber +from django.http import HttpResponse, HttpResponseBase, HttpResponseRedirect, QueryDict +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.dateparse import parse_date +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control +from django_celery_beat.models import PeriodicTask +from django_celery_results.models import TaskResult +from drf_spectacular.settings import spectacular_settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +from drf_spectacular.views import SpectacularAPIView +from drf_spectacular_jsonapi.schemas.openapi import JsonApiAutoSchema from prowler.providers.aws.exceptions.exceptions import ( AWSAssumeRoleError, AWSCredentialsError, @@ -313,6 +299,41 @@ from prowler.providers.aws.exceptions.exceptions import ( from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import ( CloudTrailTimeline, ) +from rest_framework import permissions, status +from rest_framework.decorators import action +from rest_framework.exceptions import ( + MethodNotAllowed, + NotFound, + PermissionDenied, + ValidationError, +) +from rest_framework.generics import GenericAPIView, get_object_or_404 +from rest_framework.permissions import SAFE_METHODS +from rest_framework_json_api import filters as jsonapi_filters +from rest_framework_json_api.views import RelationshipView, Response +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.token_blacklist.models import ( + BlacklistedToken, + OutstandingToken, +) +from tasks.beat import schedule_provider_scan +from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils +from tasks.jobs.export import get_s3_client +from tasks.tasks import ( + backfill_compliance_summaries_task, + backfill_scan_resource_summaries_task, + check_integration_connection_task, + check_lighthouse_connection_task, + check_lighthouse_provider_connection_task, + check_provider_connection_task, + delete_provider_task, + delete_tenant_task, + jira_integration_task, + mute_historical_findings_task, + perform_scan_task, + reaggregate_all_finding_group_summaries_task, + refresh_lighthouse_provider_models_task, +) logger = logging.getLogger(BackendLogger.API) @@ -414,7 +435,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.24.0" + spectacular_settings.VERSION = RELEASE_ID spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -745,7 +766,10 @@ class TenantFinishACSView(FinishACSView): try: check = SAMLDomainIndex.objects.get(email_domain=organization_slug) with rls_transaction(str(check.tenant_id)): - SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id)) + saml_config = SAMLConfiguration.objects.select_related("tenant").get( + tenant_id=str(check.tenant_id) + ) + tenant = saml_config.tenant social_app = SocialApp.objects.get( provider="saml", client_id=organization_slug ) @@ -765,6 +789,15 @@ class TenantFinishACSView(FinishACSView): callback_url = env.str("AUTH_URL") return redirect(f"{callback_url}?sso_saml_failed=true") + requested_domain = organization_slug.lower() + configured_domain = saml_config.email_domain.lower() + email_domain = user.email.rsplit("@", 1)[-1].lower() + if configured_domain != requested_domain or email_domain != configured_domain: + logger.error("SAML email domain does not match requested organization") + self._rollback_saml_user(request) + callback_url = env.str("AUTH_URL") + return redirect(f"{callback_url}?sso_saml_failed=true") + extra = social_account.extra_data user.first_name = ( extra.get("firstName", [""])[0] if extra.get("firstName") else "" @@ -778,67 +811,70 @@ class TenantFinishACSView(FinishACSView): user.name = "N/A" user.save() - email_domain = user.email.split("@")[-1] - tenant = ( - SAMLConfiguration.objects.using(MainRouter.admin_db) - .get(email_domain=email_domain) - .tenant - ) - + # Only remap roles when the IdP provides a userType attribute. + # Without it, the user's current roles are left untouched. role_name = ( - extra.get("userType", ["no_permissions"])[0].strip() - if extra.get("userType") - else "no_permissions" + extra.get("userType", [""])[0].strip() if extra.get("userType") else "" ) - role = ( - Role.objects.using(MainRouter.admin_db) - .filter(name=role_name, tenant=tenant) - .first() - ) - - # Only skip mapping if it would remove the last MANAGE_ACCOUNT user - remaining_manage_account_users = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id) - .exclude(user_id=user_id) - .values("user") - .distinct() - .count() - ) - user_has_manage_account = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id) - .exists() - ) - role_manage_account = role.manage_account if role else False - would_remove_last_manage_account = ( - user_has_manage_account - and remaining_manage_account_users == 0 - and not role_manage_account - ) - - if not would_remove_last_manage_account: - if role is None: - role = Role.objects.using(MainRouter.admin_db).create( - name=role_name, - tenant=tenant, - manage_users=False, - manage_account=False, - manage_billing=False, - manage_providers=False, - manage_integrations=False, - manage_scans=False, - unlimited_visibility=False, + if role_name: + with transaction.atomic(using=MainRouter.admin_db): + role = ( + Role.objects.using(MainRouter.admin_db) + .filter(name=role_name, tenant=tenant) + .first() ) - UserRoleRelationship.objects.using(MainRouter.admin_db).filter( - user=user, - tenant_id=tenant.id, - ).delete() - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=user, - role=role, - tenant_id=tenant.id, - ) + + # Only skip mapping if it would remove the last MANAGE_ACCOUNT user + remaining_manage_account_users = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role__manage_account=True, tenant_id=tenant.id) + .exclude(user_id=user_id) + .values("user") + .distinct() + .count() + ) + user_has_manage_account = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter( + role__manage_account=True, + tenant_id=tenant.id, + user_id=user_id, + ) + .exists() + ) + role_manage_account = role.manage_account if role else False + would_remove_last_manage_account = ( + user_has_manage_account + and remaining_manage_account_users == 0 + and not role_manage_account + ) + + if not would_remove_last_manage_account: + if role is None: + # Roles auto-created from userType get read-only access: + # visibility over all providers, no management permissions + role, _ = Role.objects.using(MainRouter.admin_db).get_or_create( + name=role_name, + tenant=tenant, + defaults={ + "manage_users": False, + "manage_account": False, + "manage_billing": False, + "manage_providers": False, + "manage_integrations": False, + "manage_scans": False, + "unlimited_visibility": True, + }, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).filter( + user=user, + tenant_id=tenant.id, + ).delete() + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, + ) membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( user=user, tenant=tenant, @@ -1327,9 +1363,11 @@ class MembershipViewSet(BaseTenantViewset): ), destroy=extend_schema( summary="Delete tenant memberships", - description="Delete the membership details of users in a tenant. You need to be one of the owners to delete a " - "membership that is not yours. If you are the last owner of a tenant, you cannot delete your own " - "membership.", + description="Delete a user's membership from a tenant. This action: (1) removes the membership, " + "(2) revokes all refresh tokens for the expelled user, (3) removes their role grants for this tenant, " + "(4) cleans up orphaned roles, and (5) deletes the user account if this was their last membership. " + "You must be a tenant owner to delete another user's membership. The last owner of a tenant cannot " + "delete their own membership.", tags=["Tenant"], ), ) @@ -1338,6 +1376,7 @@ class TenantMembersViewSet(BaseTenantViewset): http_method_names = ["get", "delete"] serializer_class = MembershipSerializer queryset = Membership.objects.none() + filterset_class = MembershipFilter # Authorization is handled by get_requesting_membership (owner/member checks), # not by RBAC, since the target tenant differs from the JWT tenant. required_permissions = [] @@ -1395,7 +1434,84 @@ class TenantMembersViewSet(BaseTenantViewset): "You do not have permission to delete this membership." ) - membership_to_delete.delete() + user_to_check_id = membership_to_delete.user_id + tenant_id = membership_to_delete.tenant_id + # All writes run on the admin connection so that the uncommitted + # membership delete is visible to the subsequent "other memberships" + # check. Splitting the delete and the check across the default + # (prowler_user, RLS) and admin connections caused the admin side to + # miss the just-deleted row and leave the User row orphaned. + with transaction.atomic(using=MainRouter.admin_db): + Membership.objects.using(MainRouter.admin_db).filter( + id=membership_to_delete.id + ).delete() + + # Remove role grants for this user in this tenant to prevent + # orphaned permissions that could allow access after expulsion + deleted_role_relationships = UserRoleRelationship.objects.using( + MainRouter.admin_db + ).filter(user_id=user_to_check_id, tenant_id=tenant_id) + + # Collect role IDs that might become orphaned after deletion + role_ids_to_check = list( + deleted_role_relationships.values_list("role_id", flat=True) + ) + + # Delete the user role relationships for this tenant + deleted_role_relationships.delete() + + # Clean up orphaned roles that have no remaining user or invitation relationships + if role_ids_to_check: + for role_id in role_ids_to_check: + has_user_relationships = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role_id=role_id) + .exists() + ) + + has_invitation_relationships = ( + InvitationRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role_id=role_id) + .exists() + ) + + if not has_user_relationships and not has_invitation_relationships: + Role.objects.using(MainRouter.admin_db).filter( + id=role_id + ).delete() + + # Revoke any refresh tokens the expelled user still holds so they + # cannot mint fresh access tokens. This must happen before the + # User row is deleted, because OutstandingToken.user is + # on_delete=SET_NULL in djangorestframework-simplejwt 5.5.1 + # (see rest_framework_simplejwt/token_blacklist/models.py): once + # the user row is gone, user_id becomes NULL and we can no longer + # look up that user's outstanding tokens. Access tokens already + # issued remain valid until SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] + # expires. + outstanding_token_ids = list( + OutstandingToken.objects.using(MainRouter.admin_db) + .filter(user_id=user_to_check_id) + .values_list("id", flat=True) + ) + if outstanding_token_ids: + BlacklistedToken.objects.using(MainRouter.admin_db).bulk_create( + [ + BlacklistedToken(token_id=token_id) + for token_id in outstanding_token_ids + ], + ignore_conflicts=True, + ) + + has_other_memberships = ( + Membership.objects.using(MainRouter.admin_db) + .filter(user_id=user_to_check_id) + .exists() + ) + if not has_other_memberships: + User.objects.using(MainRouter.admin_db).filter( + id=user_to_check_id + ).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1758,7 +1874,42 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): 200: OpenApiResponse( description="CSV file containing the compliance report" ), - 404: OpenApiResponse(description="Compliance report not found"), + 202: OpenApiResponse(description="The task is in progress"), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="Compliance report not found, or the scan has no reports yet" + ), + }, + request=None, + ), + compliance_ocsf=extend_schema( + tags=["Scan"], + summary="Retrieve compliance report as OCSF JSON", + description=( + "Download a specific compliance report as an OCSF JSON file. " + "Only universal frameworks that declare an output configuration " + "produce this artifact (currently 'dora_2022_2554', 'csa_ccm_4.0' " + "and 'cis_controls_8.1'); any other framework returns 404." + ), + parameters=[ + OpenApiParameter( + name="name", + type=str, + location=OpenApiParameter.PATH, + required=True, + description="The compliance report name, like 'dora_2022_2554'", + ), + ], + responses={ + 200: OpenApiResponse( + description="OCSF JSON file containing the compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="Compliance report not found, the framework does " + "not provide an OCSF export, or the scan has no reports yet" + ), }, request=None, ), @@ -1838,6 +1989,27 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): ), }, ), + cis=extend_schema( + tags=["Scan"], + summary="Retrieve CIS Benchmark compliance report", + description="Download the CIS Benchmark compliance report as a PDF file. " + "When a provider ships multiple CIS versions, the report is generated " + "for the highest available version.", + request=None, + responses={ + 200: OpenApiResponse( + description="PDF file containing the CIS compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 401: OpenApiResponse( + description="API key missing or user not Authenticated" + ), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="The scan has no CIS reports, or the CIS report generation task has not started yet" + ), + }, + ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") @@ -1880,32 +2052,23 @@ class ScanViewSet(BaseRLSViewSet): return queryset.select_related("provider", "task") def get_serializer_class(self): - if self.action == "create": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanCreateSerializer - elif self.action == "partial_update": + if self.action == "partial_update": return ScanUpdateSerializer - elif self.action == "report": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanReportSerializer - elif self.action == "compliance": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanComplianceReportSerializer - elif self.action == "threatscore": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "ens": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "nis2": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "csa": + + action_defaults = { + "create": ScanCreateSerializer, + "report": ScanReportSerializer, + "compliance": ScanComplianceReportSerializer, + "compliance_ocsf": ScanComplianceReportSerializer, + } + response_only_actions = {"threatscore", "ens", "nis2", "csa", "cis"} + + if self.action in action_defaults or self.action in response_only_actions: if hasattr(self, "response_serializer_class"): return self.response_serializer_class + if self.action in action_defaults: + return action_defaults[self.action] + return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -1944,12 +2107,17 @@ class ScanViewSet(BaseRLSViewSet): if scan_instance.state == StateChoices.EXECUTING and scan_instance.task: task = scan_instance.task else: - try: - task = Task.objects.get( + # A scan can have several `scan-report` tasks (e.g. re-runs); take the + # most recent one. `.first()` also avoids `MultipleObjectsReturned`. + task = ( + Task.objects.filter( task_runner_task__task_name="scan-report", task_runner_task__task_kwargs__contains=str(scan_instance.id), ) - except Task.DoesNotExist: + .order_by("-inserted_at") + .first() + ) + if task is None: return None self.response_serializer_class = TaskSerializer @@ -1968,24 +2136,38 @@ class ScanViewSet(BaseRLSViewSet): }, ) - def _load_file(self, path_pattern, s3=False, bucket=None, list_objects=False): + def _load_file( + self, + path_pattern, + s3=False, + bucket=None, + list_objects=False, + content_type=None, + ): """ - Loads a binary file (e.g., ZIP or CSV) and returns its content and filename. + Resolve a report file location and return the bytes (filesystem) or a redirect (S3). Depending on the input parameters, this method supports loading: - - From S3 using a direct key. - - From S3 by listing objects under a prefix and matching suffix. - - From the local filesystem using glob pattern matching. + - From S3 using a direct key, returns a 302 to a short-lived presigned URL. + - From S3 by listing objects under a prefix and matching suffix, returns a 302 to a short-lived presigned URL. + - From the local filesystem using glob pattern matching, returns the file bytes. + + The S3 branch never streams bytes through the worker; this prevents gunicorn + worker timeouts on large reports. Args: path_pattern (str): The key or glob pattern representing the file location. s3 (bool, optional): Whether the file is stored in S3. Defaults to False. bucket (str, optional): The name of the S3 bucket, required if `s3=True`. Defaults to None. list_objects (bool, optional): If True and `s3=True`, list objects by prefix to find the file. Defaults to False. + content_type (str, optional): On the S3 branch, forwarded as `ResponseContentType` + so the presigned download advertises the same Content-Type the API used to send. + Ignored on the filesystem branch. Returns: - tuple[bytes, str]: A tuple containing the file content as bytes and the filename if successful. - Response: A DRF `Response` object with an appropriate status and error detail if an error occurs. + tuple[bytes, str]: For the filesystem branch, the file content and filename. + HttpResponseRedirect: For the S3 branch on success, a 302 redirect to a presigned `GetObject` URL. + Response: For any error path, a DRF `Response` with an appropriate status and detail. """ if s3: try: @@ -2010,47 +2192,72 @@ class ScanViewSet(BaseRLSViewSet): status=status.HTTP_502_BAD_GATEWAY, ) contents = resp.get("Contents", []) - keys = [] + matches = [] for obj in contents: key = obj["Key"] key_basename = os.path.basename(key) if any(ch in suffix for ch in ("*", "?", "[")): if fnmatch.fnmatch(key_basename, suffix): - keys.append(key) + matches.append(obj) elif key_basename == suffix: - keys.append(key) + matches.append(obj) elif key.endswith(suffix): # Backward compatibility if suffix already includes directories - keys.append(key) - if not keys: + matches.append(obj) + if not matches: return Response( { "detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'." }, status=status.HTTP_404_NOT_FOUND, ) - # path_pattern here is prefix, but in compliance we build correct suffix check before - key = keys[0] + # Return the most recently modified match (latest report) when + # several files share the prefix/suffix. `list_objects_v2` always + # returns `LastModified`; the fallback keeps ordering deterministic + # if it is ever absent. + key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[ + "Key" + ] else: - # path_pattern is exact key + # path_pattern is exact key; HEAD before presigning to preserve the 404 contract. key = path_pattern - try: - s3_obj = client.get_object(Bucket=bucket, Key=key) - except ClientError as e: - code = e.response.get("Error", {}).get("Code") - if code == "NoSuchKey": + try: + client.head_object(Bucket=bucket, Key=key) + except ClientError as e: + code = e.response.get("Error", {}).get("Code") + if code in ("NoSuchKey", "404"): + return Response( + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) return Response( - { - "detail": "The scan has no reports, or the report generation task has not started yet." - }, - status=status.HTTP_404_NOT_FOUND, + {"detail": "There is a problem with credentials."}, + status=status.HTTP_403_FORBIDDEN, ) - return Response( - {"detail": "There is a problem with credentials."}, - status=status.HTTP_403_FORBIDDEN, - ) - content = s3_obj["Body"].read() + filename = os.path.basename(key) + # escape quotes and strip CR/LF so a malformed key cannot break out of the header + safe_filename = ( + filename.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\r", "") + .replace("\n", "") + ) + params = { + "Bucket": bucket, + "Key": key, + "ResponseContentDisposition": f'attachment; filename="{safe_filename}"', + } + if content_type: + params["ResponseContentType"] = content_type + url = client.generate_presigned_url( + "get_object", + Params=params, + ExpiresIn=300, + ) + return HttpResponseRedirect(url) else: files = glob.glob(path_pattern) if not files: @@ -2060,7 +2267,9 @@ class ScanViewSet(BaseRLSViewSet): }, status=status.HTTP_404_NOT_FOUND, ) - filepath = files[0] + # Return the most recently modified match (latest report) when the + # pattern resolves to several files. + filepath = max(files, key=os.path.getmtime) with open(filepath, "rb") as f: content = f.read() filename = os.path.basename(filepath) @@ -2093,31 +2302,31 @@ class ScanViewSet(BaseRLSViewSet): bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") loader = self._load_file( - key_prefix, s3=True, bucket=bucket, list_objects=False + key_prefix, + s3=True, + bucket=bucket, + list_objects=False, + content_type="application/x-zip-compressed", ) else: loader = self._load_file(scan.output_location, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader return self._serve_file(content, filename, "application/x-zip-compressed") - @action( - detail=True, - methods=["get"], - url_path="compliance/(?P[^/]+)", - url_name="compliance", - ) - def compliance(self, request, pk=None, name=None): - scan = self.get_object() - if name not in get_compliance_frameworks(scan.provider.provider): - return Response( - {"detail": f"Compliance '{name}' not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + def _serve_compliance_artifact(self, scan, name, file_extension, content_type): + """Resolve and serve a per-framework compliance artifact from disk/S3. + Shared by the CSV and OCSF compliance download actions. Both are + path-based (no query params) on purpose: ``get_object`` runs + ``filter_queryset``, which triggers JSON:API's + ``QueryParameterValidationFilter`` and 400s on any non-JSON:API + query param, so a ``?format=`` / ``?type=`` selector is not viable + here — the format is encoded in the route instead. + """ running_resp = self._get_task_status(scan) if running_resp: return running_resp @@ -2134,19 +2343,111 @@ class ScanViewSet(BaseRLSViewSet): bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") prefix = os.path.join( - os.path.dirname(key_prefix), "compliance", f"{name}.csv" + os.path.dirname(key_prefix), "compliance", f"{name}.{file_extension}" + ) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type=content_type, ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) else: base = os.path.dirname(scan.output_location) - pattern = os.path.join(base, "compliance", f"*_{name}.csv") + pattern = os.path.join(base, "compliance", f"*_{name}.{file_extension}") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader - return self._serve_file(content, filename, "text/csv") + return self._serve_file(content, filename, content_type) + + @action( + detail=True, + methods=["get"], + url_path="compliance/(?P[^/]+)", + url_name="compliance", + ) + def compliance(self, request, pk=None, name=None): + scan = self.get_object() + if name not in get_compliance_frameworks(scan.provider.provider): + return Response( + {"detail": f"Compliance '{name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + return self._serve_compliance_artifact(scan, name, "csv", "text/csv") + + @action( + detail=True, + methods=["get"], + url_path="compliance/(?P[^/]+)/ocsf", + url_name="compliance-ocsf", + ) + def compliance_ocsf(self, request, pk=None, name=None): + scan = self.get_object() + if name not in get_compliance_frameworks(scan.provider.provider): + return Response( + {"detail": f"Compliance '{name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + universal_bulk = get_prowler_provider_compliance(scan.provider.provider) + framework_obj = universal_bulk.get(name) + if not (framework_obj and getattr(framework_obj, "outputs", None)): + return Response( + {"detail": f"Compliance '{name}' does not provide an OCSF export."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return self._serve_compliance_artifact( + scan, name, "ocsf.json", "application/json" + ) + + @action( + detail=True, + methods=["get"], + url_name="cis", + ) + def cis(self, request, pk=None): + scan = self.get_object() + running_resp = self._get_task_status(scan) + if running_resp: + return running_resp + + if not scan.output_location: + return Response( + { + "detail": "The scan has no reports, or the CIS report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) + + if scan.output_location.startswith("s3://"): + bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") + key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") + prefix = os.path.join( + os.path.dirname(key_prefix), + "cis", + "*_cis_report.pdf", + ) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) + else: + base = os.path.dirname(scan.output_location) + pattern = os.path.join(base, "cis", "*_cis_report.pdf") + loader = self._load_file(pattern, s3=False) + + if isinstance(loader, HttpResponseBase): + return loader + + content, filename = loader + return self._serve_file(content, filename, "application/pdf") @action( detail=True, @@ -2176,13 +2477,19 @@ class ScanViewSet(BaseRLSViewSet): "threatscore", "*_threatscore_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "threatscore", "*_threatscore_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2216,13 +2523,19 @@ class ScanViewSet(BaseRLSViewSet): "ens", "*_ens_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "ens", "*_ens_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2255,13 +2568,19 @@ class ScanViewSet(BaseRLSViewSet): "nis2", "*_nis2_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "nis2", "*_nis2_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2294,13 +2613,19 @@ class ScanViewSet(BaseRLSViewSet): "csa", "*_csa_report.pdf", ) - loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + loader = self._load_file( + prefix, + s3=True, + bucket=bucket, + list_objects=True, + content_type="application/pdf", + ) else: base = os.path.dirname(scan.output_location) pattern = os.path.join(base, "csa", "*_csa_report.pdf") loader = self._load_file(pattern, s3=False) - if isinstance(loader, Response): + if isinstance(loader, HttpResponseBase): return loader content, filename = loader @@ -2309,28 +2634,45 @@ class ScanViewSet(BaseRLSViewSet): def create(self, request, *args, **kwargs): input_serializer = self.get_serializer(data=request.data) input_serializer.is_valid(raise_exception=True) + + # Broker publish is deferred to on_commit so the worker cannot read + # Scan before BaseRLSViewSet's dispatch-wide atomic commits. + pre_task_id = str(uuid.uuid4()) + with transaction.atomic(): scan = input_serializer.save() - with transaction.atomic(): - task = perform_scan_task.apply_async( - kwargs={ - "tenant_id": self.request.tenant_id, - "scan_id": str(scan.id), - "provider_id": str(scan.provider_id), - # Disabled for now - # checks_to_execute=scan.scanner_args.get("checks_to_execute") - }, + scan.task_id = pre_task_id + scan.save(update_fields=["task_id"]) + + attack_paths_db_utils.create_attack_paths_scan( + tenant_id=self.request.tenant_id, + scan_id=str(scan.id), + provider_id=str(scan.provider_id), ) - attack_paths_db_utils.create_attack_paths_scan( - tenant_id=self.request.tenant_id, - scan_id=str(scan.id), - provider_id=str(scan.provider_id), - ) + task_result, _ = TaskResult.objects.get_or_create( + task_id=pre_task_id, + defaults={"status": states.PENDING, "task_name": "scan-perform"}, + ) + prowler_task, _ = Task.objects.update_or_create( + id=pre_task_id, + tenant_id=self.request.tenant_id, + defaults={"task_runner_task": task_result}, + ) - prowler_task = Task.objects.get(id=task.id) - scan.task_id = task.id - scan.save(update_fields=["task_id"]) + scan_kwargs = { + "tenant_id": self.request.tenant_id, + "scan_id": str(scan.id), + "provider_id": str(scan.provider_id), + # Disabled for now + # checks_to_execute=scan.scanner_args.get("checks_to_execute") + } + + transaction.on_commit( + lambda: perform_scan_task.apply_async( + kwargs=scan_kwargs, task_id=pre_task_id + ) + ) self.response_serializer_class = TaskSerializer output_serializer = self.get_serializer(prowler_task) @@ -2534,13 +2876,22 @@ class AttackPathsScanViewSet(BaseRLSViewSet): def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) + active_sink_backend = django_settings.ATTACK_PATHS_SINK_DATABASE latest_per_provider = queryset.annotate( + active_sink_rank=Case( + When(sink_backend=active_sink_backend, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ), latest_scan_rank=Window( expression=RowNumber(), partition_by=[F("provider_id")], - order_by=[F("inserted_at").desc()], - ) + order_by=[ + F("active_sink_rank").asc(), + F("inserted_at").desc(), + ], + ), ).filter(latest_scan_rank=1) page = self.paginate_queryset(latest_per_provider) @@ -2567,7 +2918,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet): ) def attack_paths_queries(self, request, pk=None): attack_paths_scan = self.get_object() - queries = get_queries_for_provider(attack_paths_scan.provider.provider) + # TODO: drop the is_migrated argument after Neptune cutover + queries = get_queries_for_provider( + attack_paths_scan.provider.provider, + is_migrated=attack_paths_scan.is_migrated, + ) if not queries: return Response( @@ -2600,7 +2955,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet): serializer = AttackPathsQueryRunRequestSerializer(data=payload) serializer.is_valid(raise_exception=True) - query_definition = get_query_by_id(serializer.validated_data["id"]) + # TODO: drop the is_migrated argument after Neptune cutover + query_definition = get_query_by_id( + serializer.validated_data["id"], + is_migrated=attack_paths_scan.is_migrated, + ) if ( query_definition is None or query_definition.provider != attack_paths_scan.provider.provider @@ -2626,9 +2985,9 @@ class AttackPathsScanViewSet(BaseRLSViewSet): query_definition, parameters, provider_id, + scan=attack_paths_scan, ) query_duration = time.monotonic() - start - graph_database.clear_cache(database_name) result_nodes = len(graph.get("nodes", [])) result_relationships = len(graph.get("relationships", [])) @@ -2694,9 +3053,9 @@ class AttackPathsScanViewSet(BaseRLSViewSet): database_name, serializer.validated_data["query"], provider_id, + scan=attack_paths_scan, ) query_duration = time.monotonic() - start - graph_database.clear_cache(database_name) query_length = len(serializer.validated_data["query"]) result_nodes = len(graph.get("nodes", [])) @@ -2751,7 +3110,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet): provider_id = str(attack_paths_scan.provider_id) schema = attack_paths_views_helpers.get_cartography_schema( - database_name, provider_id + database_name, provider_id, attack_paths_scan ) if not schema: return Response( @@ -3009,9 +3368,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): date_filters = {} if exact: date = parse_date(exact) - datetime_start = datetime.combine( - date, datetime.min.time(), tzinfo=timezone.utc - ) + datetime_start = datetime.combine(date, datetime.min.time(), tzinfo=UTC) datetime_end = datetime_start + timedelta(days=1) date_filters["scan_id__gte"] = uuid7_start( datetime_to_uuid7(datetime_start) @@ -3023,7 +3380,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): if gte: date_start = parse_date(gte) datetime_start = datetime.combine( - date_start, datetime.min.time(), tzinfo=timezone.utc + date_start, datetime.min.time(), tzinfo=UTC ) date_filters["scan_id__gte"] = uuid7_start( datetime_to_uuid7(datetime_start) @@ -3033,7 +3390,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): datetime_end = datetime.combine( date_end + timedelta(days=1), datetime.min.time(), - tzinfo=timezone.utc, + tzinfo=UTC, ) date_filters["scan_id__lt"] = uuid7_start( datetime_to_uuid7(datetime_end) @@ -3077,7 +3434,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): groups__isnull=False, ).values_list("groups", flat=True) groups = sorted( - set(g for groups_list in all_groups if groups_list for g in groups_list) + {g for groups_list in all_groups if groups_list for g in groups_list} ) result = { @@ -3147,7 +3504,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): groups__isnull=False, ).values_list("groups", flat=True) groups = sorted( - set(g for groups_list in all_groups if groups_list for g in groups_list) + {g for groups_list in all_groups if groups_list for g in groups_list} ) result = { @@ -3506,6 +3863,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): return queryset return super().filter_queryset(queryset) + def _optimize_tags_loading(self, queryset): + """Prefetch resource tags to avoid N+1 queries when serializing findings""" + return queryset.prefetch_related( + Prefetch( + "resources__tags", + queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id), + to_attr="prefetched_tags", + ) + ) + def list(self, request, *args, **kwargs): filtered_queryset = self.filter_queryset(self.get_queryset()) return self.paginate_by_pk( @@ -3564,9 +3931,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): date_filters = {} if exact: date = parse_date(exact) - datetime_start = datetime.combine( - date, datetime.min.time(), tzinfo=timezone.utc - ) + datetime_start = datetime.combine(date, datetime.min.time(), tzinfo=UTC) datetime_end = datetime_start + timedelta(days=1) date_filters["scan_id__gte"] = uuid7_start( datetime_to_uuid7(datetime_start) @@ -3578,7 +3943,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): if gte: date_start = parse_date(gte) datetime_start = datetime.combine( - date_start, datetime.min.time(), tzinfo=timezone.utc + date_start, datetime.min.time(), tzinfo=UTC ) date_filters["scan_id__gte"] = uuid7_start( datetime_to_uuid7(datetime_start) @@ -3588,7 +3953,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): datetime_end = datetime.combine( date_end + timedelta(days=1), datetime.min.time(), - tzinfo=timezone.utc, + tzinfo=UTC, ) date_filters["scan_id__lt"] = uuid7_start( datetime_to_uuid7(datetime_end) @@ -4216,15 +4581,19 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @extend_schema_view( list=extend_schema( tags=["Compliance Overview"], - summary="List compliance overviews for a scan", - description="Retrieve an overview of all the compliance in a given scan.", + summary="List compliance overviews", + description=( + "Retrieve compliance overview data for a scan. When provider filters " + "are provided, the endpoint uses the latest completed scan for each " + "matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4239,19 +4608,23 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), metadata=extend_schema( tags=["Compliance Overview"], summary="Retrieve metadata values from compliance overviews", - description="Fetch unique metadata values from a set of compliance overviews. This is useful for dynamic " - "filtering.", + description=( + "Fetch unique metadata values from compliance overviews. This is useful " + "for dynamic filtering. When provider filters are provided, metadata is " + "computed from the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4266,19 +4639,24 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), requirements=extend_schema( tags=["Compliance Overview"], - summary="List compliance requirements overview for a scan", - description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance " - "framework. This endpoint provides requirement-level details and aggregates status across regions.", + summary="List compliance requirements overview", + description=( + "Retrieve a detailed overview of compliance requirements, grouped by " + "compliance framework. This endpoint provides requirement-level details " + "and aggregates status across regions. When provider filters are provided, " + "the endpoint uses the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), OpenApiParameter( name="filter[compliance_id]", @@ -4315,6 +4693,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): location=OpenApiParameter.QUERY, description="Compliance framework ID to get attributes for.", ), + OpenApiParameter( + name="filter[scan_id]", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Scan ID used to resolve the provider for " + "multi-provider universal frameworks (e.g. CSA CCM), so " + "the returned check IDs match the scan's provider. When omitted, " + "the first provider that declares the framework is used.", + ), ], responses={ 200: OpenApiResponse( @@ -4327,7 +4715,10 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="requirements") @method_decorator(CACHE_DECORATOR, name="attributes") -class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): +class ComplianceOverviewViewSet( + ProviderFilterParamsMixin, BaseRLSViewSet, TaskManagementMixin +): + jsonapi_filter_replace_dots = True pagination_class = ComplianceOverviewPagination queryset = ComplianceRequirementOverview.objects.all() serializer_class = ComplianceOverviewSerializer @@ -4341,28 +4732,22 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): required_permissions = [] def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return ComplianceRequirementOverview.objects.none() + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) - if unlimited_visibility: - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id - ) - ) - else: - providers = Provider.objects.filter( - provider_groups__in=role.provider_groups.all() - ).distinct() - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id, scan__provider__in=providers - ) - ) + base_queryset = ComplianceRequirementOverview.objects.filter( + tenant_id=self.request.tenant_id + ) - return base_queryset + if unlimited_visibility: + return base_queryset + + return base_queryset.filter(scan__provider__in=get_providers(role)) def get_serializer_class(self): if hasattr(self, "response_serializer_class"): @@ -4400,6 +4785,72 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return summaries + def _validate_scan_selection(self, scan_id, has_provider_filters): + if scan_id and has_provider_filters: + raise ValidationError( + [ + { + "detail": "Use either filter[scan_id] or provider filters.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "invalid", + } + ] + ) + + if scan_id: + self._validate_uuid_filter_values("scan_id", [scan_id]) + return + + if has_provider_filters: + return + + raise ValidationError( + [ + { + "detail": "This query parameter is required unless a provider filter is provided.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "required", + } + ] + ) + + def _latest_scan_ids_for_provider_filters(self): + role = get_role(self.request.user, self.request.tenant_id) + scans = Scan.all_objects.filter( + tenant_id=self.request.tenant_id, + state=StateChoices.COMPLETED, + ) + + if not getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scans = scans.filter(provider__in=get_providers(role)) + + provider_filters = self._extract_provider_filters_from_params( + validate_uuids=True, + include_dot_aliases=True, + ) + if provider_filters: + scans = scans.filter(**provider_filters) + + return list( + scans.order_by("provider_id", "-inserted_at") + .distinct("provider_id") + .values_list("id", flat=True) + ) + + def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None): + if latest_scan_ids is None: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + # Provider filters stay on the filterset for OpenAPI docs, but runtime + # filtering happens on Scan first so compliance queries use scan IDs. + return self._apply_filterset( + queryset, + self.filterset_class, + exclude_keys=self.PROVIDER_FILTER_KEYS | {"scan_id"}, + ) + def _get_compliance_template(self, *, provider=None, scan_id=None): """Return the compliance template for the given provider or scan.""" if provider is None and scan_id is not None: @@ -4515,6 +4966,36 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + def _task_response_for_latest_provider_scans(self, latest_scan_ids): + for scan_id in latest_scan_ids: + task_response = self._task_response_if_running(str(scan_id)) + if task_response: + return task_response + return None + + def _latest_provider_scan_ids_without_data(self, latest_scan_ids): + data_presence_queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + scan_ids_with_data = { + str(scan_id) + for scan_id in data_presence_queryset.values_list( + "scan_id", flat=True + ).distinct() + } + return [ + scan_id + for scan_id in latest_scan_ids + if str(scan_id) not in scan_ids_with_data + ] + + def _task_response_for_latest_provider_scans_without_data( + self, + latest_scan_ids, + ): + scan_ids_to_check = self._latest_provider_scan_ids_without_data( + latest_scan_ids, + ) + return self._task_response_for_latest_provider_scans(scan_ids_to_check) + def _list_with_region_filter(self, scan_id, region_filter): """ Fall back to detailed ComplianceRequirementOverview query when region filter is applied. @@ -4555,8 +5036,25 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return Response(data) + def _list_with_latest_provider_filters(self): + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans(latest_scan_ids) + data = self._aggregate_compliance_overview(queryset) + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + if task_response: + return task_response + + return Response(data) + def list(self, request, *args, **kwargs): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + if has_provider_filters: + return self._list_with_latest_provider_filters() # Specific scan requested - use optimized summaries with region support region_filter = request.query_params.get( @@ -4602,33 +5100,34 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="metadata") def metadata(self, request): scan_id = request.query_params.get("filter[scan_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids ) + else: + queryset = self._apply_filterset(self.get_queryset(), self.filterset_class) + regions = list( - self.get_queryset() - .filter(scan_id=scan_id) - .values_list("region", flat=True) - .order_by("region") - .distinct() + queryset.values_list("region", flat=True).order_by("region").distinct() ) result = {"regions": regions} - if regions: - serializer = self.get_serializer(data=result) - serializer.is_valid(raise_exception=True) - return Response(serializer.data, status=status.HTTP_200_OK) + task_response = None + if has_provider_filters: + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + elif not regions: + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response - task_response = self._task_response_if_running(scan_id) - if task_response: + if has_provider_filters and task_response: return task_response serializer = self.get_serializer(data=result) @@ -4638,19 +5137,10 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="requirements") def requirements(self, request): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) compliance_id = request.query_params.get("filter[compliance_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] - ) + self._validate_scan_selection(scan_id, has_provider_filters) if not compliance_id: raise ValidationError( @@ -4663,7 +5153,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): } ] ) - filtered_queryset = self.filter_queryset(self.get_queryset()) + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + filtered_queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids + ) + else: + filtered_queryset = self._apply_filterset( + self.get_queryset(), self.filterset_class + ) all_requirements = filtered_queryset.values( "requirement_id", @@ -4722,17 +5221,33 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): requirements_summary, many=True ) + task_response = None + if has_provider_filters: + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + elif not requirements_summary: + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response + + if has_provider_filters and task_response: + return task_response + if requirements_summary: return Response(serializer.data, status=status.HTTP_200_OK) - task_response = self._task_response_if_running(scan_id) - if task_response: - return task_response - return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_name="attributes") def attributes(self, request): + # While the background warm-up is in progress, refuse immediately + # instead of falling through to the slow cold load on the request + # thread (which would trip the Gunicorn worker timeout). `is_set()` is + # a non-blocking flag read, so this never touches the loader. + if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set(): + raise ComplianceWarmingError() + compliance_id = request.query_params.get("filter[compliance_id]") if not compliance_id: raise ValidationError( @@ -4748,7 +5263,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): provider_type = None - # If we couldn't determine from database, try each provider type + # When a scan is provided, resolve the provider from it. Multi-provider + # universal frameworks (e.g. CSA CCM) share a single compliance_id + # across providers but expose different checks per provider, so the + # metadata (and therefore the check IDs the UI uses to fetch findings) + # must be returned for the scan's provider. Without this, the endpoint + # falls back to the first provider that declares the framework and + # returns its check IDs, leaving azure/gcp/... requirements with no + # matching findings. + scan_id = request.query_params.get("filter[scan_id]") + if "filter[scan_id]" in request.query_params: + # An explicit scan_id is authoritative: fail closed instead of + # falling back to another provider. Otherwise an invalid, empty + # (filter[scan_id]=) or inaccessible scan would silently return the + # first provider's check IDs, recreating the multi-provider mismatch + # this endpoint fixes. + if not scan_id: + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + # Tenant isolation is already enforced by Postgres RLS on the + # connection (see BaseRLSViewSet). Scope the lookup by provider + # group as well so a user with limited visibility can't resolve + # another provider's scan and read its compliance metadata, mirroring + # the RBAC scoping get_queryset() applies to the rest of the ViewSet. + role = get_role(request.user, request.tenant_id) + if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id) + else: + scan_queryset = Scan.objects.filter(provider__in=get_providers(role)) + + try: + scan = scan_queryset.select_related("provider").get(id=scan_id) + except (Scan.DoesNotExist, DjangoValidationError, ValueError): + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + provider_type = scan.provider.provider + if compliance_id not in get_compliance_frameworks(provider_type): + raise NotFound( + detail=( + f"Compliance framework '{compliance_id}' is not " + f"available for scan '{scan_id}'." + ) + ) + + # Fall back to the first provider that declares the framework. Keeps the + # endpoint working for provider-agnostic callers that omit the scan. if not provider_type: for pt in Provider.ProviderChoices.values: if compliance_id in get_compliance_frameworks(pt): @@ -4916,7 +5475,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ), ) @method_decorator(CACHE_DECORATOR, name="list") -class OverviewViewSet(BaseRLSViewSet): +class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet): queryset = ScanSummary.objects.all() http_method_names = ["get"] ordering = ["-inserted_at"] @@ -5033,18 +5592,6 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id=tenant_id, scan_id__in=latest_scan_ids ) - def _normalize_jsonapi_params(self, query_params, exclude_keys=None): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - exclude_keys = exclude_keys or set() - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - if normalized_key not in exclude_keys: - normalized.setlist(normalized_key, values) - return normalized - def _ensure_allowed_providers(self): """Populate allowed providers for RBAC-aware queries once per request.""" if getattr(self, "_providers_initialized", False): @@ -5064,15 +5611,6 @@ class OverviewViewSet(BaseRLSViewSet): return queryset.filter(**provider_filter) return queryset - def _apply_filterset(self, queryset, filterset_class, exclude_keys=None): - normalized_params = self._normalize_jsonapi_params( - self.request.query_params, exclude_keys=set(exclude_keys or []) - ) - filterset = filterset_class(normalized_params, queryset=queryset) - if not filterset.is_valid(): - raise ValidationError(filterset.errors) - return filterset.qs - def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None): provider_filter = self._get_provider_filter() queryset = Scan.all_objects.filter( @@ -5086,40 +5624,6 @@ class OverviewViewSet(BaseRLSViewSet): .values_list("id", flat=True) ) - def _extract_provider_filters_from_params(self): - """Extract and validate provider filters from query params.""" - params = self.request.query_params - filters = {} - valid_provider_types = {c[0] for c in Provider.ProviderChoices.choices} - - provider_id = params.get("filter[provider_id]") - if provider_id: - filters["provider_id"] = provider_id - - provider_id_in = params.get("filter[provider_id__in]") - if provider_id_in: - filters["provider_id__in"] = provider_id_in.split(",") - - provider_type = params.get("filter[provider_type]") - if provider_type: - if provider_type not in valid_provider_types: - raise ValidationError( - {"provider_type": f"Invalid choice: {provider_type}"} - ) - filters["provider__provider"] = provider_type - - provider_type_in = params.get("filter[provider_type__in]") - if provider_type_in: - types = provider_type_in.split(",") - invalid = [t for t in types if t not in valid_provider_types] - if invalid: - raise ValidationError( - {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} - ) - filters["provider__provider__in"] = types - - return filters - @action(detail=False, methods=["get"], url_name="providers") def providers(self, request): tenant_id = self.request.tenant_id @@ -5190,15 +5694,11 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id = self.request.tenant_id providers_qs = Provider.objects.filter(tenant_id=tenant_id) + self._ensure_allowed_providers() if hasattr(self, "allowed_providers"): - allowed_ids = list(self.allowed_providers.values_list("id", flat=True)) - if not allowed_ids: - overview = [] - return Response( - self.get_serializer(overview, many=True).data, - status=status.HTTP_200_OK, - ) - providers_qs = providers_qs.filter(id__in=allowed_ids) + providers_qs = providers_qs.filter( + id__in=self.allowed_providers.values("id") + ) overview = ( providers_qs.values("provider") @@ -5414,29 +5914,41 @@ class OverviewViewSet(BaseRLSViewSet): description="Retrieve a specific snapshot by ID. If not provided, returns latest snapshots.", ), OpenApiParameter( - name="provider_id", + name="filter[provider_id]", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, description="Filter by specific provider ID", ), OpenApiParameter( - name="provider_id__in", + name="filter[provider_id__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider IDs (comma-separated UUIDs)", ), OpenApiParameter( - name="provider_type", + name="filter[provider_type]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by provider type (aws, azure, gcp, etc.)", ), OpenApiParameter( - name="provider_type__in", + name="filter[provider_type__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider types (comma-separated)", ), + OpenApiParameter( + name="filter[provider_groups]", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Filter by provider group ID", + ), + OpenApiParameter( + name="filter[provider_groups__in]", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by multiple provider group IDs (comma-separated UUIDs)", + ), ], ) @action(detail=False, methods=["get"], url_name="threatscore") @@ -5778,6 +6290,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, CategoryOverviewFilter, exclude_keys=provider_filter_keys @@ -5847,6 +6361,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, @@ -6897,7 +7413,7 @@ SEVERITY_ORDER_REVERSE = {v: k for k, v in SEVERITY_ORDER.items()} ), retrieve=extend_schema(exclude=True), ) -class FindingGroupViewSet(BaseRLSViewSet): +class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet): """ ViewSet for Finding Groups - aggregates findings by check_id. @@ -6913,6 +7429,7 @@ class FindingGroupViewSet(BaseRLSViewSet): queryset = FindingGroupDailySummary.objects.all() serializer_class = FindingGroupSerializer filterset_class = FindingGroupFilter + jsonapi_filter_replace_dots = True filter_backends = [ jsonapi_filters.QueryParameterValidationFilter, jsonapi_filters.OrderingFilter, @@ -6963,18 +7480,6 @@ class FindingGroupViewSet(BaseRLSViewSet): return queryset - def _normalize_jsonapi_params(self, query_params): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - # Convert JSON:API dot notation to Django double underscore - normalized_key = normalized_key.replace(".", "__") - normalized.setlist(normalized_key, values) - return normalized - @extend_schema(exclude=True) def retrieve(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET") @@ -7078,9 +7583,29 @@ class FindingGroupViewSet(BaseRLSViewSet): severity_order=Max("severity_order"), pass_count=Sum("pass_count"), fail_count=Sum("fail_count"), + manual_count=Sum("manual_count"), + pass_muted_count=Sum("pass_muted_count"), + fail_muted_count=Sum("fail_muted_count"), + manual_muted_count=Sum("manual_muted_count"), muted_count=Sum("muted_count"), + # The group is muted only if every contributing daily summary is + # itself fully muted. BoolAnd returns False as soon as one row has + # at least one actionable finding. + muted=BoolAnd("muted"), new_count=Sum("new_count"), changed_count=Sum("changed_count"), + new_fail_count=Sum("new_fail_count"), + new_fail_muted_count=Sum("new_fail_muted_count"), + new_pass_count=Sum("new_pass_count"), + new_pass_muted_count=Sum("new_pass_muted_count"), + new_manual_count=Sum("new_manual_count"), + new_manual_muted_count=Sum("new_manual_muted_count"), + changed_fail_count=Sum("changed_fail_count"), + changed_fail_muted_count=Sum("changed_fail_muted_count"), + changed_pass_count=Sum("changed_pass_count"), + changed_pass_muted_count=Sum("changed_pass_muted_count"), + changed_manual_count=Sum("changed_manual_count"), + changed_manual_muted_count=Sum("changed_manual_muted_count"), resources_total=Sum("resources_total"), resources_fail=Sum("resources_fail"), impacted_providers_str=StringAgg( @@ -7106,39 +7631,94 @@ class FindingGroupViewSet(BaseRLSViewSet): output_field=IntegerField(), ) - return queryset.values("check_id").annotate( - severity_order=Max(severity_case), - pass_count=Count("id", filter=Q(status="PASS", muted=False)), - fail_count=Count("id", filter=Q(status="FAIL", muted=False)), - muted_count=Count("id", filter=Q(muted=True)), - new_count=Count("id", filter=Q(delta="new", muted=False)), - changed_count=Count("id", filter=Q(delta="changed", muted=False)), - resources_total=Count("resources__id", distinct=True), - resources_fail=Count( - "resources__id", - distinct=True, - filter=Q(status="FAIL", muted=False), - ), - impacted_providers_str=StringAgg( - Cast("scan__provider__provider", CharField()), - delimiter=",", - distinct=True, - default="", - ), - agg_first_seen_at=Min("first_seen_at"), - agg_last_seen_at=Max("inserted_at"), - agg_failing_since=Min( - "first_seen_at", filter=Q(status="FAIL", muted=False) - ), - check_title=Coalesce( - Max(KeyTextTransform("checktitle", "check_metadata")), - Max(KeyTextTransform("CheckTitle", "check_metadata")), - Max(KeyTextTransform("Checktitle", "check_metadata")), - ), - check_description=Coalesce( - Max(KeyTextTransform("description", "check_metadata")), - Max(KeyTextTransform("Description", "check_metadata")), - ), + # `check_title` / `check_description` are intentionally NOT resolved + # here. They live in the large JSONB `check_metadata` blob (TOASTed), + # so reading them per finding row is very expensive, and pulling them + # in via a correlated subquery makes Django add the subquery to GROUP + # BY, which re-evaluates it once per input row. They are identical for + # every finding of a `check_id`, so `_post_process_aggregation` fills + # them from the summary table's plain columns in a single batched + # lookup scoped to the paginated page. + + # `pass_count`, `fail_count` and `manual_count` only count non-muted + # findings. Muted findings are tracked separately via the + # `*_muted_count` fields. + return ( + queryset.values("check_id") + .annotate( + severity_order=Max(severity_case), + pass_count=Count("id", filter=Q(status="PASS", muted=False)), + fail_count=Count("id", filter=Q(status="FAIL", muted=False)), + manual_count=Count("id", filter=Q(status="MANUAL", muted=False)), + pass_muted_count=Count("id", filter=Q(status="PASS", muted=True)), + fail_muted_count=Count("id", filter=Q(status="FAIL", muted=True)), + manual_muted_count=Count("id", filter=Q(status="MANUAL", muted=True)), + muted_count=Count("id", filter=Q(muted=True)), + nonmuted_count=Count("id", filter=Q(muted=False)), + new_count=Count("id", filter=Q(delta="new", muted=False)), + changed_count=Count("id", filter=Q(delta="changed", muted=False)), + new_fail_count=Count( + "id", filter=Q(delta="new", status="FAIL", muted=False) + ), + new_fail_muted_count=Count( + "id", filter=Q(delta="new", status="FAIL", muted=True) + ), + new_pass_count=Count( + "id", filter=Q(delta="new", status="PASS", muted=False) + ), + new_pass_muted_count=Count( + "id", filter=Q(delta="new", status="PASS", muted=True) + ), + new_manual_count=Count( + "id", filter=Q(delta="new", status="MANUAL", muted=False) + ), + new_manual_muted_count=Count( + "id", filter=Q(delta="new", status="MANUAL", muted=True) + ), + changed_fail_count=Count( + "id", filter=Q(delta="changed", status="FAIL", muted=False) + ), + changed_fail_muted_count=Count( + "id", filter=Q(delta="changed", status="FAIL", muted=True) + ), + changed_pass_count=Count( + "id", filter=Q(delta="changed", status="PASS", muted=False) + ), + changed_pass_muted_count=Count( + "id", filter=Q(delta="changed", status="PASS", muted=True) + ), + changed_manual_count=Count( + "id", filter=Q(delta="changed", status="MANUAL", muted=False) + ), + changed_manual_muted_count=Count( + "id", filter=Q(delta="changed", status="MANUAL", muted=True) + ), + resources_total=Count("resources__id", distinct=True), + resources_fail=Count( + "resources__id", + distinct=True, + filter=Q(status="FAIL", muted=False), + ), + impacted_providers_str=StringAgg( + Cast("scan__provider__provider", CharField()), + delimiter=",", + distinct=True, + default="", + ), + agg_first_seen_at=Min("first_seen_at"), + agg_last_seen_at=Max("inserted_at"), + agg_failing_since=Min( + "first_seen_at", filter=Q(status="FAIL", muted=False) + ), + ) + .annotate( + # Group is muted only if it has zero non-muted findings. + muted=Case( + When(nonmuted_count=0, then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ), + ) ) def _split_computed_aggregate_filters( @@ -7150,6 +7730,7 @@ class FindingGroupViewSet(BaseRLSViewSet): "status__in", "severity", "severity__in", + "muted", "include_muted", } finding_params = QueryDict(mutable=True) @@ -7165,14 +7746,17 @@ class FindingGroupViewSet(BaseRLSViewSet): def _get_latest_findings_per_provider(self, filtered_queryset): """Keep only findings from each provider's most recent completed scan.""" - latest_scan_ids = ( + # Materialize to a literal IN list. Left as a subquery, Postgres can't + # estimate the match count and picks a serial nested loop on + # resource_finding_mappings when one scan dominates findings + latest_scan_ids = list( Scan.objects.filter( tenant_id=self.request.tenant_id, state=StateChoices.COMPLETED, ) .order_by("provider_id", "-completed_at", "-inserted_at") .distinct("provider_id") - .values("id") + .values_list("id", flat=True) ) return filtered_queryset.filter(scan_id__in=latest_scan_ids) @@ -7181,11 +7765,41 @@ class FindingGroupViewSet(BaseRLSViewSet): Post-process aggregation results to add computed fields. - Converts severity integer back to string - - Computes aggregated status (FAIL > PASS > MUTED) + - Computes aggregated status (FAIL > PASS > MANUAL); the orthogonal + ``muted`` boolean is already on the row from the SQL aggregation - Converts provider string to list + - Fills check_title / check_description for the findings path """ + rows = list(aggregated_data) + + # The findings-aggregation path omits check_title / check_description + # (they sit in TOASTed JSONB; see _aggregate_findings). Fill them from + # the summary table's plain columns in one query scoped to this page. + # The summary-aggregation path already carries them, so skip it there. + if rows and "check_title" not in rows[0]: + check_ids = [row["check_id"] for row in rows] + role = get_role(self.request.user, self.request.tenant_id) + summaries = FindingGroupDailySummary.objects.filter( + tenant_id=self.request.tenant_id, + check_id__in=check_ids, + ) + # Scope to the user's providers, mirroring get_queryset(), so titles + # are read only from providers the user can see. + if not role.unlimited_visibility: + summaries = summaries.filter(provider__in=get_providers(role)) + metadata_by_check = { + item["check_id"]: item + for item in summaries.order_by("check_id", "-inserted_at") + .distinct("check_id") + .values("check_id", "check_title", "check_description") + } + for row in rows: + metadata = metadata_by_check.get(row["check_id"], {}) + row["check_title"] = metadata.get("check_title") + row["check_description"] = metadata.get("check_description") + results = [] - for row in aggregated_data: + for row in rows: # Convert severity order back to string severity_order = row.get("severity_order", 1) row["severity"] = SEVERITY_ORDER_REVERSE.get( @@ -7199,13 +7813,25 @@ class FindingGroupViewSet(BaseRLSViewSet): if "agg_failing_since" in row: row["failing_since"] = row.pop("agg_failing_since") - # Compute aggregated status + # Drop the helper count we use to derive `muted` in the + # finding-level aggregation path. + row.pop("nonmuted_count", None) + + # Muted findings are treated as resolved/accepted, so they do not + # contribute to a failing status. A group is FAIL only when there + # is at least one non-muted FAIL; otherwise any pass (muted or + # not) or any muted fail makes the group PASS. Only groups whose + # findings are exclusively MANUAL fall through to MANUAL. if row.get("fail_count", 0) > 0: row["status"] = "FAIL" - elif row.get("pass_count", 0) > 0: + elif ( + row.get("pass_count", 0) > 0 + or row.get("pass_muted_count", 0) > 0 + or row.get("fail_muted_count", 0) > 0 + ): row["status"] = "PASS" else: - row["status"] = "MUTED" + row["status"] = "MANUAL" # Convert provider string to list providers_str = row.pop("impacted_providers_str", "") or "" @@ -7219,13 +7845,31 @@ class FindingGroupViewSet(BaseRLSViewSet): _FINDING_GROUP_SORT_MAP = { "check_id": "check_id", - "check_title": "check_title", "severity": "severity_order", + "status": "status_order", + "muted": "muted", + "delta": "delta_order", "fail_count": "fail_count", "pass_count": "pass_count", + "manual_count": "manual_count", "muted_count": "muted_count", + "pass_muted_count": "pass_muted_count", + "fail_muted_count": "fail_muted_count", + "manual_muted_count": "manual_muted_count", "new_count": "new_count", + "new_fail_count": "new_fail_count", + "new_fail_muted_count": "new_fail_muted_count", + "new_pass_count": "new_pass_count", + "new_pass_muted_count": "new_pass_muted_count", + "new_manual_count": "new_manual_count", + "new_manual_muted_count": "new_manual_muted_count", "changed_count": "changed_count", + "changed_fail_count": "changed_fail_count", + "changed_fail_muted_count": "changed_fail_muted_count", + "changed_pass_count": "changed_pass_count", + "changed_pass_muted_count": "changed_pass_muted_count", + "changed_manual_count": "changed_manual_count", + "changed_manual_muted_count": "changed_manual_muted_count", "resources_total": "resources_total", "resources_fail": "resources_fail", "first_seen_at": "agg_first_seen_at", @@ -7236,6 +7880,7 @@ class FindingGroupViewSet(BaseRLSViewSet): _RESOURCE_SORT_MAP = { "status": "status_order", "severity": "severity_order", + "delta": "delta_order", "first_seen_at": "first_seen_at", "last_seen_at": "last_seen_at", "resource.uid": "resource_uid", @@ -7276,7 +7921,7 @@ class FindingGroupViewSet(BaseRLSViewSet): return ordering def _apply_aggregated_computed_filters(self, queryset, computed_params: QueryDict): - """Apply computed filters (status/severity) on aggregated finding-group rows.""" + """Apply computed filters (status/severity/muted) on aggregated finding-group rows.""" if not computed_params: return queryset @@ -7285,14 +7930,18 @@ class FindingGroupViewSet(BaseRLSViewSet): aggregated_status=Case( When(fail_count__gt=0, then=Value("FAIL")), When(pass_count__gt=0, then=Value("PASS")), - default=Value("MUTED"), + When(pass_muted_count__gt=0, then=Value("PASS")), + When(fail_muted_count__gt=0, then=Value("PASS")), + default=Value("MANUAL"), output_field=CharField(), ) ) - # Exclude fully-muted groups by default unless include_muted is set - if "include_muted" not in computed_params: - queryset = queryset.exclude(fail_count=0, pass_count=0, muted_count__gt=0) + # Exclude fully-muted groups by default unless the caller has opted in + # via either `include_muted` or an explicit `muted` filter (the latter + # gives the caller direct control over the column). + if "include_muted" not in computed_params and "muted" not in computed_params: + queryset = queryset.exclude(muted=True) filterset = FindingGroupAggregatedComputedFilter( computed_params, queryset=queryset @@ -7302,6 +7951,25 @@ class FindingGroupViewSet(BaseRLSViewSet): return filterset.qs + def _resolve_finding_ids(self, filtered_queryset): + """ + Materialize and request-cache the finding_ids list used to anchor + RFM lookups. + + Turning `finding_id__in=Subquery(findings_qs)` into `finding_id__in= + [uuid, ...]` nudges PostgreSQL out of a Merge Semi Join that ends up + reading hundreds of thousands of RFM index entries just to post- + filter tenant_id. Caching on the ViewSet instance (one instance per + request) avoids duplicating the findings round-trip when several + helpers build different RFM querysets from the same filtered set. + """ + cached = getattr(self, "_finding_ids_cache", None) + if cached is not None and cached[0] is filtered_queryset: + return cached[1] + finding_ids = list(filtered_queryset.order_by().values_list("id", flat=True)) + self._finding_ids_cache = (filtered_queryset, finding_ids) + return finding_ids + def _build_resource_mapping_queryset( self, filtered_queryset, resource_ids=None, tenant_id: str | None = None ): @@ -7311,10 +7979,10 @@ class FindingGroupViewSet(BaseRLSViewSet): Starting from ResourceFindingMapping avoids scanning all mappings before applying check_id/date filters on findings. """ - finding_ids = filtered_queryset.order_by().values("id") + finding_ids = self._resolve_finding_ids(filtered_queryset) mapping_queryset = ResourceFindingMapping.objects.filter( - finding_id__in=Subquery(finding_ids) + finding_id__in=finding_ids ) if tenant_id: mapping_queryset = mapping_queryset.filter(tenant_id=tenant_id) @@ -7347,18 +8015,14 @@ class FindingGroupViewSet(BaseRLSViewSet): provider_type=Max("resource__provider__provider"), provider_uid=Max("resource__provider__uid"), provider_alias=Max("resource__provider__alias"), + # status_order considers ALL findings (muted or not) so it + # surfaces FAIL/PASS/MANUAL based on the underlying check + # outcome. Whether the resource is actionable is signalled by + # the orthogonal `muted` flag below. status_order=Max( Case( - When( - finding__status="FAIL", - finding__muted=False, - then=Value(3), - ), - When( - finding__status="PASS", - finding__muted=False, - then=Value(2), - ), + When(finding__status="FAIL", then=Value(3)), + When(finding__status="PASS", then=Value(2)), default=Value(1), output_field=IntegerField(), ) @@ -7372,8 +8036,26 @@ class FindingGroupViewSet(BaseRLSViewSet): output_field=IntegerField(), ) ), + delta_order=Max( + Case( + When( + finding__delta="new", + finding__muted=False, + then=Value(2), + ), + When( + finding__delta="changed", + finding__muted=False, + then=Value(1), + ), + default=Value(0), + output_field=IntegerField(), + ) + ), first_seen_at=Min("finding__first_seen_at"), last_seen_at=Max("finding__inserted_at"), + # True only if every finding for this resource+check is muted. + muted=BoolAnd("finding__muted"), # Max() on muted_reason / check_metadata is safe because # all findings for the same resource+check share identical # values (mute rules and metadata are applied per-check). @@ -7381,6 +8063,12 @@ class FindingGroupViewSet(BaseRLSViewSet): resource_group=Max( KeyTextTransform("resourcegroup", "finding__check_metadata") ), + # Most recent matching Finding for this (resource, check): + # Finding.id is a UUIDv7 (time-ordered in its high 48 bits). + # Cast to text first because PostgreSQL has no built-in + # `max(uuid)` aggregate; on the canonical lowercase form a + # lexicographic Max() still resolves to the latest snapshot. + finding_id=Max(Cast("finding__id", output_field=CharField())), ) .filter(resource_id__isnull=False) ) @@ -7389,8 +8077,8 @@ class FindingGroupViewSet(BaseRLSViewSet): _RESOURCE_SORT_ANNOTATIONS = { "status_order": lambda: Max( Case( - When(finding__status="FAIL", finding__muted=False, then=Value(3)), - When(finding__status="PASS", finding__muted=False, then=Value(2)), + When(finding__status="FAIL", then=Value(3)), + When(finding__status="PASS", then=Value(2)), default=Value(1), output_field=IntegerField(), ) @@ -7404,6 +8092,22 @@ class FindingGroupViewSet(BaseRLSViewSet): output_field=IntegerField(), ) ), + "delta_order": lambda: Max( + Case( + When( + finding__delta="new", + finding__muted=False, + then=Value(2), + ), + When( + finding__delta="changed", + finding__muted=False, + then=Value(1), + ), + default=Value(0), + output_field=IntegerField(), + ) + ), "first_seen_at": lambda: Min("finding__first_seen_at"), "last_seen_at": lambda: Max("finding__inserted_at"), "resource_uid": lambda: Max("resource__uid"), @@ -7437,6 +8141,53 @@ class FindingGroupViewSet(BaseRLSViewSet): .order_by(*ordering) ) + def _orphan_findings_queryset(self, filtered_queryset, finding_ids=None): + """Findings in the filtered set with no ResourceFindingMapping entries.""" + orphan_qs = filtered_queryset.filter( + ~Exists(ResourceFindingMapping.objects.filter(finding_id=OuterRef("pk"))) + ) + if finding_ids is not None: + orphan_qs = orphan_qs.filter(id__in=finding_ids) + return orphan_qs + + def _has_orphan_findings(self, filtered_queryset) -> bool: + """Return True if any finding in the filtered set has no resource mapping.""" + return self._orphan_findings_queryset(filtered_queryset).exists() + + def _orphan_aggregation_values(self, orphan_queryset): + """Raw rows for orphan findings; resource payload synthesized from metadata. + + check_metadata is stored with lowercase keys (see + `prowler.lib.outputs.finding.Finding.get_metadata`) and + `Finding.resource_groups` is already denormalized at ingest time. + """ + return orphan_queryset.annotate( + _provider_type=F("scan__provider__provider"), + _provider_uid=F("scan__provider__uid"), + _provider_alias=F("scan__provider__alias"), + _svc=KeyTextTransform("servicename", "check_metadata"), + _region=KeyTextTransform("region", "check_metadata"), + _rtype=KeyTextTransform("resourcetype", "check_metadata"), + _rgroup=F("resource_groups"), + ).values( + "id", + "uid", + "status", + "severity", + "delta", + "muted", + "muted_reason", + "first_seen_at", + "inserted_at", + "_provider_type", + "_provider_uid", + "_provider_alias", + "_svc", + "_region", + "_rtype", + "_rgroup", + ) + def _post_process_resources(self, resource_data): """Convert resource aggregation rows to API output.""" results = [] @@ -7448,11 +8199,23 @@ class FindingGroupViewSet(BaseRLSViewSet): elif status_order == 2: status = "PASS" else: - status = "MUTED" + status = "MANUAL" + + delta_order = row.get("delta_order", 0) + if delta_order == 2: + delta = "new" + elif delta_order == 1: + delta = "changed" + else: + delta = None + + resource_id = row["resource_id"] + finding_id = str(row["finding_id"]) if row.get("finding_id") else None results.append( { - "resource_id": row["resource_id"], + "row_id": resource_id, + "resource_id": resource_id, "resource_uid": row["resource_uid"], "resource_name": row["resource_name"], "resource_service": row["resource_service"], @@ -7465,10 +8228,52 @@ class FindingGroupViewSet(BaseRLSViewSet): "severity": SEVERITY_ORDER_REVERSE.get( severity_order, "informational" ), + "delta": delta, "first_seen_at": row["first_seen_at"], "last_seen_at": row["last_seen_at"], + "muted": bool(row.get("muted", False)), "muted_reason": row.get("muted_reason"), "resource_group": row.get("resource_group", ""), + "finding_id": finding_id, + } + ) + + return results + + def _post_process_orphans(self, orphan_rows): + """Convert orphan finding rows into the same API shape as mapping rows.""" + results = [] + for row in orphan_rows: + status_val = row["status"] + status = status_val if status_val in ("FAIL", "PASS") else "MANUAL" + + muted = bool(row["muted"]) + delta_val = row.get("delta") + delta = delta_val if delta_val in ("new", "changed") and not muted else None + + finding_id = str(row["id"]) + + results.append( + { + "row_id": finding_id, + "resource_id": None, + "resource_uid": row["uid"], + "resource_name": row["uid"], + "resource_service": row["_svc"] or "", + "resource_region": row["_region"] or "", + "resource_type": row["_rtype"] or "", + "provider_type": row["_provider_type"], + "provider_uid": row["_provider_uid"], + "provider_alias": row["_provider_alias"], + "status": status, + "severity": row["severity"], + "delta": delta, + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["inserted_at"], + "muted": muted, + "muted_reason": row.get("muted_reason"), + "resource_group": row["_rgroup"] or "", + "finding_id": finding_id, } ) @@ -7529,7 +8334,27 @@ class FindingGroupViewSet(BaseRLSViewSet): sort_param, self._FINDING_GROUP_SORT_MAP ) if ordering: - aggregated_queryset = aggregated_queryset.order_by(*ordering) + if any(field.lstrip("-") == "status_order" for field in ordering): + aggregated_queryset = aggregated_queryset.annotate( + status_order=Case( + When(fail_count__gt=0, then=Value(3)), + When(pass_count__gt=0, then=Value(2)), + When(pass_muted_count__gt=0, then=Value(2)), + When(fail_muted_count__gt=0, then=Value(2)), + default=Value(1), + output_field=IntegerField(), + ) + ) + + expanded_ordering = [] + for field in ordering: + if field.lstrip("-") == "delta_order": + sign = "-" if field.startswith("-") else "" + expanded_ordering.append(f"{sign}new_count") + expanded_ordering.append(f"{sign}changed_count") + else: + expanded_ordering.append(field) + aggregated_queryset = aggregated_queryset.order_by(*expanded_ordering) else: aggregated_queryset = aggregated_queryset.order_by( "-fail_count", "-severity_order", "check_id" @@ -7554,41 +8379,65 @@ class FindingGroupViewSet(BaseRLSViewSet): def _paginated_resource_response( self, request, filtered_queryset, resource_ids, tenant_id ): - """Paginate and return resources. + """Paginate and return resources, appending orphan findings when present. - Without sort: paginate lightweight resource IDs first, aggregate only the page. - With sort: build a lightweight ordering subquery (resource_id + sort keys), - paginate that, then aggregate full details only for the page. + Hot path (no orphans, or resource filter applied): resources come from + ResourceFindingMapping aggregation. Untouched pre-existing behaviour. + + Orphan fallback: findings without a mapping (e.g. IaC) are appended + after mapping rows as synthesised resource-like rows so they remain + visible in the UI without paying the aggregation cost on the hot path. """ sort_param = request.query_params.get("sort") - + ordering = None if sort_param: - ordering = self._validate_sort_fields(sort_param, self._RESOURCE_SORT_MAP) - if ordering: - if "resource_id" not in {field.lstrip("-") for field in ordering}: - ordering.append("resource_id") + validated = self._validate_sort_fields(sort_param, self._RESOURCE_SORT_MAP) + ordering = validated if validated else None - # Phase 1: lightweight aggregation with only sort keys, paginate - ordering_qs = self._build_resource_ordering_queryset( - filtered_queryset, - resource_ids=resource_ids, - tenant_id=tenant_id, - ordering=ordering, - ) - page = self.paginate_queryset(ordering_qs) - if page is not None: - page_ids = [row["resource_id"] for row in page] - resource_data = self._build_resource_aggregation( - filtered_queryset, resource_ids=page_ids, tenant_id=tenant_id - ) - # Re-sort to match the page ordering - id_order = {rid: idx for idx, rid in enumerate(page_ids)} - results = self._post_process_resources(resource_data) - results.sort(key=lambda r: id_order.get(r["resource_id"], 0)) - serializer = FindingGroupResourceSerializer(results, many=True) - return self.get_paginated_response(serializer.data) + # Resource filters can only match findings with resources; skip orphan + # detection entirely when they are present. + if resource_ids is not None: + return self._mapping_paginated_response( + request, filtered_queryset, resource_ids, tenant_id, ordering + ) - page_ids = [row["resource_id"] for row in ordering_qs] + # Serve the mapping response directly and piggyback on the paginator + # count to detect orphan-only groups, instead of paying a separate + # has_mappings.exists() semi-join over ResourceFindingMapping on + # every non-IaC request. TODO: once the ephemeral resources strategy + # is decided, mixed groups should route to _combined_paginated_response. + response = self._mapping_paginated_response( + request, filtered_queryset, resource_ids, tenant_id, ordering + ) + + page = getattr(self.paginator, "page", None) + mapping_total = page.paginator.count if page is not None else None + if mapping_total == 0: + # Pure orphan group (e.g. IaC): synthesize resource-like rows. + return self._combined_paginated_response( + request, filtered_queryset, tenant_id, ordering + ) + + return response + + def _mapping_paginated_response( + self, request, filtered_queryset, resource_ids, tenant_id, ordering + ): + """Mapping-only paginated response (original fast path).""" + if ordering: + if "resource_id" not in {field.lstrip("-") for field in ordering}: + ordering.append("resource_id") + + # Phase 1: lightweight aggregation with only sort keys, paginate + ordering_qs = self._build_resource_ordering_queryset( + filtered_queryset, + resource_ids=resource_ids, + tenant_id=tenant_id, + ordering=ordering, + ) + page = self.paginate_queryset(ordering_qs) + if page is not None: + page_ids = [row["resource_id"] for row in page] resource_data = self._build_resource_aggregation( filtered_queryset, resource_ids=page_ids, tenant_id=tenant_id ) @@ -7596,10 +8445,18 @@ class FindingGroupViewSet(BaseRLSViewSet): results = self._post_process_resources(resource_data) results.sort(key=lambda r: id_order.get(r["resource_id"], 0)) serializer = FindingGroupResourceSerializer(results, many=True) - return Response(serializer.data) + return self.get_paginated_response(serializer.data) + + page_ids = [row["resource_id"] for row in ordering_qs] + resource_data = self._build_resource_aggregation( + filtered_queryset, resource_ids=page_ids, tenant_id=tenant_id + ) + id_order = {rid: idx for idx, rid in enumerate(page_ids)} + results = self._post_process_resources(resource_data) + results.sort(key=lambda r: id_order.get(r["resource_id"], 0)) + serializer = FindingGroupResourceSerializer(results, many=True) + return Response(serializer.data) - # No sort (or only empty sort fragments): paginate lightweight resource IDs - # first, aggregate only the page. mapping_qs = self._build_resource_mapping_queryset( filtered_queryset, resource_ids=resource_ids, tenant_id=tenant_id ) @@ -7627,6 +8484,95 @@ class FindingGroupViewSet(BaseRLSViewSet): serializer = FindingGroupResourceSerializer(results, many=True) return Response(serializer.data) + def _combined_paginated_response( + self, request, filtered_queryset, tenant_id, ordering + ): + """Mapping rows + orphan findings appended at end. + + Orphans sit after mapping rows regardless of sort. This keeps the + mapping-only code path intact for checks that have no orphans (the + common case) and avoids paying UNION/coalesce costs there. + """ + mapping_qs = self._build_resource_mapping_queryset( + filtered_queryset, resource_ids=None, tenant_id=tenant_id + ) + mapping_count = mapping_qs.values("resource_id").distinct().count() + + orphan_ids = list( + self._orphan_findings_queryset(filtered_queryset) + .order_by("id") + .values_list("id", flat=True) + ) + orphan_count = len(orphan_ids) + total = mapping_count + orphan_count + + # Paginate a simple [0..total) index sequence so DRF produces proper + # links/meta; then slice mapping / orphan sources accordingly. + page = self.paginate_queryset(range(total)) + page_indices = list(page) if page is not None else list(range(total)) + + mapping_indices = [i for i in page_indices if i < mapping_count] + orphan_positions = [ + i - mapping_count for i in page_indices if i >= mapping_count + ] + + mapping_results = [] + if mapping_indices: + start = mapping_indices[0] + stop = mapping_indices[-1] + 1 + if ordering: + ordering_fields = list(ordering) + if "resource_id" not in { + field.lstrip("-") for field in ordering_fields + }: + ordering_fields.append("resource_id") + ordered_qs = self._build_resource_ordering_queryset( + filtered_queryset, + resource_ids=None, + tenant_id=tenant_id, + ordering=ordering_fields, + ) + slice_rids = [row["resource_id"] for row in ordered_qs[start:stop]] + else: + slice_rids = list( + mapping_qs.values_list("resource_id", flat=True) + .distinct() + .order_by("resource_id")[start:stop] + ) + if slice_rids: + resource_data = self._build_resource_aggregation( + filtered_queryset, + resource_ids=slice_rids, + tenant_id=tenant_id, + ) + rows_by_rid = {row["resource_id"]: row for row in resource_data} + ordered_rows = [ + rows_by_rid[rid] for rid in slice_rids if rid in rows_by_rid + ] + mapping_results = self._post_process_resources(ordered_rows) + + orphan_results = [] + if orphan_positions: + slice_fids = [orphan_ids[pos] for pos in orphan_positions] + raw_rows = list( + self._orphan_aggregation_values( + self._orphan_findings_queryset( + filtered_queryset, finding_ids=slice_fids + ) + ) + ) + rows_by_fid = {row["id"]: row for row in raw_rows} + ordered_rows = [ + rows_by_fid[fid] for fid in slice_fids if fid in rows_by_fid + ] + orphan_results = self._post_process_orphans(ordered_rows) + + results = mapping_results + orphan_results + serializer = FindingGroupResourceSerializer(results, many=True) + if page is not None: + return self.get_paginated_response(serializer.data) + return Response(serializer.data) + def list(self, request, *args, **kwargs): """ List finding groups with aggregation and filtering. @@ -7652,9 +8598,10 @@ class FindingGroupViewSet(BaseRLSViewSet): This endpoint returns finding groups without requiring date filters, automatically using the latest available data per check_id. - All other filters (provider_id, provider_type, check_id) are still supported. + Provider, provider group, check, and computed filters are still supported. """, tags=["Finding Groups"], + filters=True, ) @action(detail=False, methods=["get"], url_name="latest") def latest(self, request): @@ -7756,10 +8703,13 @@ class FindingGroupViewSet(BaseRLSViewSet): tenant_id = request.tenant_id queryset = self._get_finding_queryset() - # Get latest completed scan for each provider + # Order by -completed_at (matching the /latest summary path and the + # daily summary upsert keyed on midnight(completed_at)) so that + # overlapping scans do not make /resources and /latest read from + # different scans and report diverging counts. latest_scan_ids = ( Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) - .order_by("provider_id", "-inserted_at") + .order_by("provider_id", "-completed_at", "-inserted_at") .distinct("provider_id") .values_list("id", flat=True) ) diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index aaa1b1c386..1a35a1a753 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -1,7 +1,6 @@ import warnings from celery import Celery, Task - from config.env import env # Suppress specific warnings from django-rest-auth: https://github.com/iMerica/dj-rest-auth/issues/684 @@ -17,13 +16,70 @@ celery_app.config_from_object("django.conf:settings", namespace="CELERY") celery_app.conf.update(result_extended=True, result_expires=None) celery_app.conf.broker_transport_options = { - "visibility_timeout": BROKER_VISIBILITY_TIMEOUT + "visibility_timeout": BROKER_VISIBILITY_TIMEOUT, + "queue_order_strategy": "priority", } +celery_app.conf.task_default_priority = 6 celery_app.conf.result_backend_transport_options = { "visibility_timeout": BROKER_VISIBILITY_TIMEOUT } celery_app.conf.visibility_timeout = BROKER_VISIBILITY_TIMEOUT +# Durable delivery: keep the message until the task finishes, so a worker killed +# mid-task (deploy/OOM/eviction) does not silently drop it. Reserve one task at a +# time so a crash exposes at most one extra reserved message. +celery_app.conf.task_acks_late = True +celery_app.conf.task_reject_on_worker_lost = True +celery_app.conf.worker_prefetch_multiplier = env.int( + "DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER", default=1 +) +# On SIGTERM, give the worker time to finish or re-queue in-flight tasks before +# it is forcefully killed (Celery 5.5+ soft shutdown). +celery_app.conf.worker_soft_shutdown_timeout = env.int( + "DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", default=60 +) +# Bound execution so a blocked task cannot pin a worker forever. Connection +# checks get a tight limit; scans and provider/tenant deletions can legitimately +# run for more than a day on large tenants, so they get a much higher cap. +# The default for every other task is set as the global limit, not as a "*" +# annotation: Celery applies the "*" entry AFTER the per-task one, so a "*" in +# task_annotations would silently overwrite every specific limit defined below. +_TASK_HARD_LIMIT = env.int("DJANGO_CELERY_TASK_TIME_LIMIT", default=6 * 60 * 60) +_TASK_SOFT_LIMIT = env.int( + "DJANGO_CELERY_TASK_SOFT_TIME_LIMIT", default=_TASK_HARD_LIMIT - 600 +) +_LONG_TASK_HARD_LIMIT = env.int( + "DJANGO_CELERY_LONG_TASK_TIME_LIMIT", default=48 * 60 * 60 +) +_LONG_TASK_SOFT_LIMIT = env.int( + "DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT", default=_LONG_TASK_HARD_LIMIT - 600 +) +celery_app.conf.task_time_limit = _TASK_HARD_LIMIT +celery_app.conf.task_soft_time_limit = _TASK_SOFT_LIMIT +celery_app.conf.task_annotations = { + **{ + name: {"soft_time_limit": 60, "time_limit": 120} + for name in ( + "provider-connection-check", + "integration-connection-check", + "lighthouse-connection-check", + "lighthouse-provider-connection-check", + ) + }, + **{ + name: { + "soft_time_limit": _LONG_TASK_SOFT_LIMIT, + "time_limit": _LONG_TASK_HARD_LIMIT, + } + for name in ( + "scan-perform", + "scan-perform-scheduled", + "provider-deletion", + "tenant-deletion", + ) + }, +} + celery_app.autodiscover_tasks(["api"]) @@ -39,9 +95,8 @@ class RLSTask(Task): shadow=None, **options, ): - from django_celery_results.models import TaskResult - from api.models import Task as APITask + from django_celery_results.models import TaskResult result = super().apply_async( args=args, diff --git a/api/src/backend/config/custom_logging.py b/api/src/backend/config/custom_logging.py index fe2a090ca6..a601c8e2cd 100644 --- a/api/src/backend/config/custom_logging.py +++ b/api/src/backend/config/custom_logging.py @@ -2,7 +2,6 @@ import json import logging from enum import StrEnum - from config.env import env from django_guid.log_filters import CorrelationId diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 238825591f..75d5e6112e 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -3,6 +3,7 @@ from datetime import timedelta from config.custom_logging import LOGGING # noqa from config.env import BASE_DIR, env # noqa from config.settings.celery import * # noqa +from config.settings.eventstream import * # noqa from config.settings.partitions import * # noqa from config.settings.sentry import * # noqa from config.settings.social_login import * # noqa @@ -44,9 +45,11 @@ INSTALLED_APPS = [ "dj_rest_auth.registration", "rest_framework.authtoken", "drf_simple_apikey", + "django_eventstream", ] MIDDLEWARE = [ + "api.middleware.CloseDBConnectionsMiddleware", "django_guid.middleware.guid_middleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -118,6 +121,8 @@ REST_FRAMEWORK = { "attack-paths-custom-query": env( "DJANGO_THROTTLE_ATTACK_PATHS_CUSTOM_QUERY", default="10/min" ), + "health-live": env("DJANGO_THROTTLE_HEALTH_LIVE", default="120/min"), + "health-ready": env("DJANGO_THROTTLE_HEALTH_READY", default="60/min"), }, } @@ -134,6 +139,7 @@ SPECTACULAR_SETTINGS = { } WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" DJANGO_GUID = { "GUID_HEADER_NAME": "Transaction-ID", @@ -304,3 +310,37 @@ SESSION_COOKIE_SECURE = True ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h + +# Selects where the persistent attack-paths graph is stored. The scan +# temporary database is always Neo4j; only the sink is configurable. +# Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted). +ATTACK_PATHS_SINK_DATABASE = env.str("ATTACK_PATHS_SINK_DATABASE", default="neo4j") + +# Orphan task recovery feature flags. The master switch is OFF by default, so task +# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group +# toggles default to enabled, so once the master is on every group recovers unless a +# group is explicitly turned off. +TASK_RECOVERY_ENABLED = env.bool("DJANGO_TASK_RECOVERY_ENABLED", False) +TASK_RECOVERY_SUMMARIES_ENABLED = env.bool( + "DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED", True +) +TASK_RECOVERY_DELETIONS_ENABLED = env.bool( + "DJANGO_TASK_RECOVERY_DELETIONS_ENABLED", True +) + + +def label_postgres_connections(databases): + """Tag each Postgres connection with ``application_name=":"`` + so connections are attributable by component in ``pg_stat_activity`` (and any + tooling that surfaces ``application_name``). The component (api / worker / + scan / ...) is injected per process by the container entrypoint via + ``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the + process owns the connection. The neo4j entry is skipped (not a Postgres + backend). Postgres truncates ``application_name`` at 63 bytes. + """ + component = env.str("DJANGO_APP_COMPONENT", default="api") + for alias, config in databases.items(): + engine = config.get("ENGINE", "") + if engine.startswith("psqlextra") or "postgresql" in engine: + name = f"{component}:{alias}"[:63] + config.setdefault("OPTIONS", {})["application_name"] = name diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 9c83557b77..5b3871aa8b 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -50,10 +50,18 @@ DATABASES = { "USER": env.str("NEO4J_USER", "neo4j"), "PASSWORD": env.str("NEO4J_PASSWORD", "neo4j_password"), }, + "neptune": { + "WRITER_ENDPOINT": env.str("NEPTUNE_WRITER_ENDPOINT", ""), + "READER_ENDPOINT": env.str("NEPTUNE_READER_ENDPOINT", ""), + "PORT": env.str("NEPTUNE_PORT", "8182"), + "REGION": env.str("AWS_REGION", ""), + }, } DATABASES["default"] = DATABASES["prowler_user"] +label_postgres_connections(DATABASES) # noqa: F405 + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405 render_class for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405 diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index 91bd50d0d1..79d8993b10 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -49,12 +49,21 @@ DATABASES = { "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, + # TODO: drop after Neptune cutover just loosen defaults to `""` "neo4j": { "HOST": env.str("NEO4J_HOST"), "PORT": env.str("NEO4J_PORT"), "USER": env.str("NEO4J_USER"), "PASSWORD": env.str("NEO4J_PASSWORD"), }, + "neptune": { + "WRITER_ENDPOINT": env.str("NEPTUNE_WRITER_ENDPOINT", default=""), + "READER_ENDPOINT": env.str("NEPTUNE_READER_ENDPOINT", default=""), + "PORT": env.str("NEPTUNE_PORT", default="8182"), + "REGION": env.str("AWS_REGION", default=""), + }, } DATABASES["default"] = DATABASES["prowler_user"] + +label_postgres_connections(DATABASES) # noqa: F405 diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py index 75779f5a68..9951478bfd 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -34,3 +34,12 @@ DRF_API_KEY = { # JWT SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405 +# pyjwt >= 2.13.0 rejects an empty HMAC signing key, so HS256 tests need a real +# key (>= 32 bytes also avoids the InsecureKeyLengthWarning). Production uses RS256. +SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405 + "DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod" +) + +# Tests don't need secure password hashing; PBKDF2 (~hundreds of ms per call) +# dominates fixture setup time across every create_user()/check_password(). +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index a5b625874b..8b17ad669d 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -1,8 +1,10 @@ import logging import multiprocessing import os +import threading from config.env import env +from uvicorn_worker import UvicornWorker # Ensure the environment variable for Django settings is set os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production") @@ -11,11 +13,30 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production") import django # noqa: E402 django.setup() -from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402 + +from api.compliance import warm_compliance_caches # noqa: E402 from config.custom_logging import BackendLogger # noqa: E402 +from config.django.production import DEBUG # noqa: E402 +from config.django.production import LOGGING as DJANGO_LOGGERS # noqa: E402 BIND_ADDRESS = env("DJANGO_BIND_ADDRESS", default="127.0.0.1") -PORT = env("DJANGO_PORT", default=8000) +PORT = env("DJANGO_PORT", default=8080) + + +class ProwlerUvicornWorker(UvicornWorker): + CONFIG_KWARGS = { + # Keep-alive idle timeout. Must exceed the load balancer idle timeout. + "timeout_keep_alive": env.int("GUNICORN_KEEPALIVE", default=75), + "loop": "uvloop", + "lifespan": "off", # Django ASGIHandler doesn't handle lifespan scopes + } + + +# Required so SSE endpoints can keep the event loop alive while waiting for events +worker_class = env( + "DJANGO_WORKER_CLASS", + default="config.guniconf.ProwlerUvicornWorker", +) # Server settings bind = f"{BIND_ADDRESS}:{PORT}" @@ -23,6 +44,15 @@ bind = f"{BIND_ADDRESS}:{PORT}" workers = env.int("DJANGO_WORKERS", default=multiprocessing.cpu_count() * 2 + 1) reload = DEBUG +# Preload the application before forking workers in production: the app is +# imported once in the master and workers fork from it. In development, disable +# preload so the server restarts on code changes. +preload_app = not DEBUG + +# Worker timeout in seconds. Increased from the default 30s to handle requests +# that may take longer, such as complex API operations. +timeout = env.int("GUNICORN_TIMEOUT", default=120) + # Logging logconfig_dict = DJANGO_LOGGERS gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN) @@ -41,3 +71,46 @@ def on_reload(_): def when_ready(_): gunicorn_logger.info("Gunicorn server is ready") + + +def _warm_compliance_caches_in_background(): + """Warm compliance caches off the request path and log the outcome.""" + failed = warm_compliance_caches() + if failed: + gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed) + else: + gunicorn_logger.info("Compliance caches warmed") + + +def post_fork(_server, worker): + """Re-initialize attack-paths drivers and warm compliance caches per worker. + + Neo4j / Neptune drivers spawn background IO threads that do not survive + ``fork()``. When the gunicorn master runs with ``preload_app=True``, the + child inherits driver objects whose pool references dead threads and + hangs on the first ``pool.acquire`` call until the watchdog kills the + worker. Re-initializing per worker guarantees each child owns its own + live threads. See GUNICORN_WORKER_TIMEOUTS_ANALYSIS.md for detail. + + Compliance caches are then warmed in a background thread so the worker + becomes ready immediately. A request for a not-yet-warmed provider lazily + loads just that provider, which stays well under the worker timeout. + """ + from api.attack_paths import database as graph_database + + try: + graph_database.close_driver() + except Exception: # pragma: no cover - best-effort cleanup + gunicorn_logger.debug( + "Failed to close inherited Neo4j driver in post_fork for worker pid=%s", + worker.pid, + exc_info=True, + ) + graph_database.init_driver() + gunicorn_logger.info(f"Attack-paths drivers initialized for worker {worker.pid}") + + threading.Thread( + target=_warm_compliance_caches_in_background, + name="warm-compliance-caches", + daemon=True, + ).start() diff --git a/api/src/backend/config/settings/celery.py b/api/src/backend/config/settings/celery.py index b7030ebea4..b7105a548c 100644 --- a/api/src/backend/config/settings/celery.py +++ b/api/src/backend/config/settings/celery.py @@ -53,3 +53,8 @@ CELERY_TASK_TRACK_STARTED = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True CELERY_DEADLOCK_ATTEMPTS = env.int("DJANGO_CELERY_DEADLOCK_ATTEMPTS", default=5) + +# Opt-in override for Celery's prefork pool size. When unset, Celery falls back +# to its default (os.cpu_count()). +if "DJANGO_CELERY_WORKER_CONCURRENCY" in env.ENVIRON: + CELERY_WORKER_CONCURRENCY = env.int("DJANGO_CELERY_WORKER_CONCURRENCY") diff --git a/api/src/backend/config/settings/eventstream.py b/api/src/backend/config/settings/eventstream.py new file mode 100644 index 0000000000..470062050b --- /dev/null +++ b/api/src/backend/config/settings/eventstream.py @@ -0,0 +1,41 @@ +"""Server-Sent Events (SSE) configuration. + +Wires django-eventstream into the platform: Valkey Pub/Sub backend on a +dedicated DB (separate from the Celery broker), the platform channel +manager, and headers that match the existing CORS allowlist. +""" + +from config.env import env +from config.settings.celery import ( + VALKEY_HOST, + VALKEY_PASSWORD, + VALKEY_PORT, + VALKEY_SCHEME, + VALKEY_USERNAME, +) + +# Dedicated Valkey DB for the SSE Pub/Sub bus. Kept distinct from the +# Celery broker DB so a noisy broker can't shoulder out streaming +# traffic on the same keyspace. +EVENTSTREAM_VALKEY_DB = env.int("EVENTSTREAM_VALKEY_DB", default=2) + +EVENTSTREAM_REDIS: dict = { + "host": VALKEY_HOST, + "port": int(VALKEY_PORT), + "db": EVENTSTREAM_VALKEY_DB, +} +if VALKEY_PASSWORD: + EVENTSTREAM_REDIS["password"] = VALKEY_PASSWORD +if VALKEY_USERNAME: + EVENTSTREAM_REDIS["username"] = VALKEY_USERNAME +if VALKEY_SCHEME == "rediss": + EVENTSTREAM_REDIS["ssl"] = True + +# Platform channel manager — performs the per-feature authorization and +# rewrites the placeholder channel from the URL into the canonical +# tenant-scoped channel name. See ``api.sse.channelmanager``. +EVENTSTREAM_CHANNELMANAGER_CLASS = "api.sse.channelmanager.SSEChannelManager" + +# Headers a browser EventSource may legitimately send. Keep tight; the +# stream itself reads no body, so no permissive defaults. +EVENTSTREAM_ALLOW_HEADERS = "Cache-Control, Last-Event-ID" diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 65c6277817..5fd6e39cc9 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -1,5 +1,4 @@ import sentry_sdk - from config.env import env IGNORED_EXCEPTIONS = [ @@ -76,6 +75,8 @@ IGNORED_EXCEPTIONS = [ # PowerShell Errors in User Authentication "Microsoft Teams User Auth connection failed: Please check your permissions and try again.", "Exchange Online User Auth connection failed: Please check your permissions and try again.", + # ASGI: Client disconnected before the response finished (health-check probes on /health/live) + "RequestAborted", ] @@ -120,6 +121,7 @@ sentry_sdk.init( # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info before_send=before_send, send_default_pii=True, + traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02), _experiments={ # Set continuous_profiling_auto_start to True # to automatically start the profiler on when diff --git a/api/src/backend/config/urls.py b/api/src/backend/config/urls.py index c307bcc310..4e7a46e31c 100644 --- a/api/src/backend/config/urls.py +++ b/api/src/backend/config/urls.py @@ -1,5 +1,8 @@ +from api.health import LivenessView, ReadinessView from django.urls import include, path urlpatterns = [ path("api/v1/", include("api.v1.urls")), + path("health/live", LivenessView.as_view(), name="health-live"), + path("health/ready", ReadinessView.as_view(), name="health-ready"), ] diff --git a/api/src/backend/config/version.py b/api/src/backend/config/version.py new file mode 100644 index 0000000000..86f14c53e2 --- /dev/null +++ b/api/src/backend/config/version.py @@ -0,0 +1,41 @@ +"""Single source of truth for the API version. + +The semantic version is read once from ``api/pyproject.toml`` at module +import; consumers (health payload, OpenAPI schema) read the resulting +constants. Fails fast at boot if the file cannot be located, so a +packaging mistake surfaces immediately rather than serving stale data. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +_PROJECT_NAME = "prowler-api" + + +def _discover_release_id() -> str: + here = Path(__file__).resolve() + for directory in here.parents: + candidate = directory / "pyproject.toml" + if not candidate.is_file(): + continue + with candidate.open("rb") as f: + data = tomllib.load(f) + project = data.get("project") or {} + if project.get("name") != _PROJECT_NAME: + continue + version = project.get("version") + if not isinstance(version, str) or not version: + raise RuntimeError( + f"{candidate} declares an empty or invalid [project].version" + ) + return version + raise RuntimeError( + f"Could not locate the {_PROJECT_NAME} pyproject.toml from {here}" + ) + + +RELEASE_ID: str = _discover_release_id() +# Public contract major (e.g. "1"); matches the /api/v1/ namespace. +API_VERSION: str = RELEASE_ID.split(".", 1)[0] diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index ce1f24f638..b9154ddb51 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -1,23 +1,10 @@ import logging -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from allauth.socialaccount.models import SocialLogin -from django.conf import settings -from django.db import connection as django_connection -from django.db import connections as django_connections -from django.urls import reverse -from django_celery_results.models import TaskResult -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, -) - from api.attack_paths import ( AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition, @@ -60,8 +47,20 @@ from api.models import ( ) from api.rls import Tenant from api.v1.serializers import TokenSerializer +from django.conf import settings +from django.db import connection as django_connection +from django.db import connections as django_connections +from django.urls import reverse +from django_celery_results.models import TaskResult from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status +from rest_framework import status +from rest_framework.test import APIClient +from tasks.jobs.backfill import ( + aggregate_scan_category_summaries, + aggregate_scan_resource_group_summaries, + backfill_resource_scan_summaries, +) TODAY = str(datetime.today().date()) API_JSON_CONTENT_TYPE = "application/vnd.api+json" @@ -70,6 +69,107 @@ TEST_USER = "dev@prowler.com" TEST_PASSWORD = "testing_psswd" +def _install_compliance_catalog_test_cache() -> None: + """Memoize the heavy SDK catalog loaders for the whole test session. + + ``get_bulk_compliance_frameworks_universal`` re-reads and Pydantic-validates + ~100 compliance JSONs (≈20 MB) and ``CheckMetadata.get_bulk`` re-reads ~1k + check metadata files on *every* call. Production amortizes this through the + per-process lazy caches (``PROWLER_CHECKS`` / ``PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE``) + and ``warm_compliance_caches``, but the test suite parametrizes over every + provider and deliberately resets the API-level caches, so the same catalogs + were re-parsed dozens of times across the suite (≈3s/call locally, ≈19s under + coverage in CI). + + The catalog files are immutable during a run and callers treat the parsed + objects as read-only, so caching the result per provider is safe. This is the + test-only equivalent of an ``lru_cache`` on the SDK functions, without + changing SDK behavior in production. + + A second, lower-level cache memoizes ``load_compliance_framework_universal`` + **per file path**. ``get_bulk_compliance_frameworks_universal`` parses *every* + compliance JSON and only then filters by provider, so a per-provider cache + still re-parses all ~100 files on the first load of each provider. The + per-path cache makes the first provider parse the files once and every other + provider/test reuse the already-parsed ``ComplianceFramework`` objects (only + the cheap ``listdir`` + filtering re-runs). ``_load_jsons_from_dir`` calls + ``load_compliance_framework_universal`` as a module global, so patching the + attribute is picked up without touching the SDK. + + Installed at conftest import time (before test modules are collected) so that + even ``from ... import get_bulk_compliance_frameworks_universal`` bindings in + the test modules resolve to the cached wrapper. + """ + import prowler.lib.check.compliance_models as compliance_models + from prowler.lib.check.models import CheckMetadata + + original_bulk_frameworks = ( + compliance_models.get_bulk_compliance_frameworks_universal + ) + original_get_bulk = CheckMetadata.get_bulk + original_load = compliance_models.load_compliance_framework_universal + + def cached_bulk_frameworks(provider): + if provider not in _COMPLIANCE_FRAMEWORK_CACHE: + _COMPLIANCE_FRAMEWORK_CACHE[provider] = original_bulk_frameworks(provider) + return _COMPLIANCE_FRAMEWORK_CACHE[provider] + + def cached_get_bulk(provider): + if provider not in _COMPLIANCE_CHECKS_CACHE: + _COMPLIANCE_CHECKS_CACHE[provider] = original_get_bulk(provider) + return _COMPLIANCE_CHECKS_CACHE[provider] + + def cached_load(path): + if path not in _COMPLIANCE_PATH_CACHE: + _COMPLIANCE_PATH_CACHE[path] = original_load(path) + return _COMPLIANCE_PATH_CACHE[path] + + compliance_models.get_bulk_compliance_frameworks_universal = cached_bulk_frameworks + compliance_models.load_compliance_framework_universal = cached_load + CheckMetadata.get_bulk = staticmethod(cached_get_bulk) + + # ``api.compliance`` does ``from ... import get_bulk_compliance_frameworks_universal`` + # so it holds its own binding; patch it too in case it was imported first. + import api.compliance as api_compliance + + api_compliance.get_bulk_compliance_frameworks_universal = cached_bulk_frameworks + + +# Module-scoped so the ``_compliance_cache_guard`` fixture below can reset them. +# Keeping them out of ``_install_compliance_catalog_test_cache``'s local scope is +# what makes the caches resettable between tests; the wrappers above close over +# these names, and the original loaders stay referenced so patched behaviour is +# still honoured. +_COMPLIANCE_FRAMEWORK_CACHE: dict[str, dict] = {} +_COMPLIANCE_CHECKS_CACHE: dict[str, dict] = {} +_COMPLIANCE_PATH_CACHE: dict[str, object] = {} + + +_install_compliance_catalog_test_cache() + + +@pytest.fixture(autouse=True) +def _compliance_cache_guard(request): + """Reset the compliance catalog caches after any test that used ``monkeypatch``. + + The session-wide caches in ``_install_compliance_catalog_test_cache`` let the + read-only, parametrized compliance tests parse the ~100 catalog JSONs once + instead of dozens of times. A test that swaps a loader (or mutates a returned + object) could otherwise leak that state into later tests through the shared + dicts. Using ``monkeypatch`` as the opt-in signal keeps the full speed-up for + catalog-reading tests while giving patching tests a clean slate afterwards; + the next test simply repopulates the caches from disk. + """ + yield + if "monkeypatch" in request.fixturenames: + _COMPLIANCE_FRAMEWORK_CACHE.clear() + _COMPLIANCE_CHECKS_CACHE.clear() + _COMPLIANCE_PATH_CACHE.clear() + import api.compliance as api_compliance + + api_compliance.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() + + def today_after_n_days(n_days: int) -> str: return datetime.strftime( datetime.today().date() + timedelta(days=n_days), "%Y-%m-%d" @@ -468,7 +568,7 @@ def invitations_fixture(create_test_user, tenants_fixture): email="testing@prowler.com", state=Invitation.State.EXPIRED, token="TESTING1234568", - expires_at=datetime.now(timezone.utc) - timedelta(days=1), + expires_at=datetime.now(UTC) - timedelta(days=1), inviter=user, tenant=tenant, ) @@ -571,6 +671,12 @@ def providers_fixture(tenants_fixture): alias="vercel_testing", tenant_id=tenant.id, ) + provider14 = Provider.objects.create( + provider="okta", + uid="acme.okta.com", + alias="okta_testing", + tenant_id=tenant.id, + ) return ( provider1, @@ -586,6 +692,7 @@ def providers_fixture(tenants_fixture): provider11, provider12, provider13, + provider14, ) @@ -708,7 +815,7 @@ def scans_fixture(tenants_fixture, providers_fixture): tenant, *_ = tenants_fixture provider, provider2, *_ = providers_fixture - now = datetime.now(timezone.utc) + now = datetime.now(UTC) scan1 = Scan.objects.create( name="Scan 1", @@ -1445,8 +1552,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 @@ -1601,7 +1708,7 @@ def api_keys_fixture(tenants_fixture, create_test_user): name="Test API Key 2", tenant_id=tenant.id, entity=user, - expiry_date=datetime.now(timezone.utc) + timedelta(days=60), + expiry_date=datetime.now(UTC) + timedelta(days=60), ) # Revoked API key @@ -1714,6 +1821,36 @@ def attack_paths_query_definition_factory(): return _create +@pytest.fixture +def sink_backend_stub(): + """Install a stub `SinkDatabase` into the sink factory for the test's duration. + + The sink factory caches a process-wide backend and lazily initializes it + against `settings.DATABASES["neo4j"]` / `["neptune"]`. Tests that don't + want to stand up a real Bolt driver can yield this fixture's mock and + configure its return values directly: + + sink_backend_stub.execute_read_query.return_value = some_graph + + Both the active backend and the secondary-backend cache are restored on + teardown so tests stay isolated. + """ + from api.attack_paths.sink import factory + from api.attack_paths.sink.base import SinkDatabase + + stub = MagicMock(spec=SinkDatabase) + previous_backend = factory._backend + previous_secondary = dict(factory._secondary_backends) + factory._backend = stub + factory._secondary_backends.clear() + try: + yield stub + finally: + factory._backend = previous_backend + factory._secondary_backends.clear() + factory._secondary_backends.update(previous_secondary) + + @pytest.fixture def attack_paths_graph_stub_classes(): """Provide lightweight graph element stubs for Attack Paths serialization tests.""" @@ -1895,10 +2032,10 @@ def provider_compliance_scores_fixture( provider1, provider2, *_ = providers_fixture scan1, _, scan3 = scans_fixture - scan1.completed_at = datetime.now(timezone.utc) - timedelta(hours=1) + scan1.completed_at = datetime.now(UTC) - timedelta(hours=1) scan1.save() scan3.state = StateChoices.COMPLETED - scan3.completed_at = datetime.now(timezone.utc) + scan3.completed_at = datetime.now(UTC) scan3.save() scores = [ diff --git a/api/src/backend/tasks/assets/img/cis_logo.png b/api/src/backend/tasks/assets/img/cis_logo.png new file mode 100644 index 0000000000..7c1568da5e Binary files /dev/null and b/api/src/backend/tasks/assets/img/cis_logo.png differ diff --git a/api/src/backend/tasks/beat.py b/api/src/backend/tasks/beat.py index e9eb9c9309..017bec844a 100644 --- a/api/src/backend/tasks/beat.py +++ b/api/src/backend/tasks/beat.py @@ -1,13 +1,12 @@ import json -from datetime import datetime, timedelta, timezone - -from django_celery_beat.models import IntervalSchedule, PeriodicTask -from tasks.tasks import perform_scheduled_scan_task +from datetime import UTC, datetime, timedelta from api.db_utils import rls_transaction from api.exceptions import ConflictException from api.models import Provider, Scan, StateChoices +from django_celery_beat.models import IntervalSchedule, PeriodicTask from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils +from tasks.tasks import perform_scheduled_scan_task def schedule_provider_scan(provider_instance: Provider): @@ -37,7 +36,7 @@ def schedule_provider_scan(provider_instance: Provider): provider_id=provider_id, trigger=Scan.TriggerChoices.SCHEDULED, state=StateChoices.AVAILABLE, - scheduled_at=datetime.now(timezone.utc), + scheduled_at=datetime.now(UTC), ) attack_paths_db_utils.create_attack_paths_scan( @@ -58,7 +57,7 @@ def schedule_provider_scan(provider_instance: Provider): } ), one_off=False, - start_time=datetime.now(timezone.utc) + timedelta(hours=24), + start_time=datetime.now(UTC) + timedelta(hours=24), ) scheduled_scan.scheduler_task_id = periodic_task_instance.id scheduled_scan.save() diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py index e7f26b5173..15ecd86a19 100644 --- a/api/src/backend/tasks/jobs/attack_paths/aws.py +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -1,20 +1,22 @@ # Portions of this file are based on code from the Cartography project # (https://github.com/cartography-cncf/cartography), which is licensed under the Apache 2.0 License. +import time from typing import Any import aioboto3 import boto3 +import botocore import neo4j - +from api.models import ( + AttackPathsScan as ProwlerAPIAttackPathsScan, +) +from api.models import ( + Provider as ProwlerAPIProvider, +) from cartography.config import Config as CartographyConfig from cartography.intel import aws as cartography_aws from celery.utils.log import get_task_logger - -from api.models import ( - AttackPathsScan as ProwlerAPIAttackPathsScan, - Provider as ProwlerAPIProvider, -) from prowler.providers.common.provider import Provider as ProwlerSDKProvider from tasks.jobs.attack_paths import db_utils, utils @@ -33,7 +35,7 @@ def start_aws_ingestion( For the scan progress updates: - The caller of this function (`tasks.jobs.attack_paths.scan.run`) has set it to 2. - - When the control returns to the caller, it will be set to 95. + - When the control returns to the caller, it will be set to 93. """ # Initialize variables common to all jobs @@ -47,7 +49,7 @@ def start_aws_ingestion( } boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider) - regions: list[str] = list(prowler_sdk_provider._enabled_regions) + regions: list[str] = resolve_aws_regions(prowler_api_provider, prowler_sdk_provider) requested_syncs = list(cartography_aws.RESOURCE_FUNCTIONS.keys()) sync_args = cartography_aws._build_aws_sync_kwargs( @@ -72,13 +74,28 @@ def start_aws_ingestion( # Adding an extra field common_job_parameters["AWS_ID"] = prowler_api_provider.uid - cartography_aws._autodiscover_accounts( - neo4j_session, - boto3_session, - prowler_api_provider.uid, - cartography_config.update_tag, - common_job_parameters, - ) + # AWS Organizations account autodiscovery. Inlined from Cartography's removed + # `_autodiscover_accounts` (deleted in `0.137.0`), as `load_aws_accounts` is still public. + try: + org_client = boto3_session.client("organizations") + paginator = org_client.get_paginator("list_accounts") + discovered = [] + for page in paginator.paginate(): + discovered.extend(page["Accounts"]) + active_accounts = { + a["Name"]: a["Id"] for a in discovered if a["Status"] == "ACTIVE" + } + cartography_aws.organizations.load_aws_accounts( + neo4j_session, + active_accounts, + cartography_config.update_tag, + common_job_parameters, + ) + except botocore.exceptions.ClientError: + logger.warning( + f"Account {prowler_api_provider.uid} lacks permissions for AWS " + "Organizations autodiscovery." + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 4) failed_syncs = sync_aws_account( @@ -89,34 +106,50 @@ def start_aws_ingestion( logger.info( f"Syncing function permission_relationships for AWS account {prowler_api_provider.uid}" ) + t0 = time.perf_counter() cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"](**sync_args) + logger.info( + f"Synced function permission_relationships for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 88) if "resourcegroupstaggingapi" in requested_syncs: logger.info( f"Syncing function resourcegroupstaggingapi for AWS account {prowler_api_provider.uid}" ) + t0 = time.perf_counter() cartography_aws.RESOURCE_FUNCTIONS["resourcegroupstaggingapi"](**sync_args) + logger.info( + f"Synced function resourcegroupstaggingapi for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 89) logger.info( f"Syncing ec2_iaminstanceprofile scoped analysis for AWS account {prowler_api_provider.uid}" ) + t0 = time.perf_counter() cartography_aws.run_scoped_analysis_job( "aws_ec2_iaminstanceprofile.json", neo4j_session, common_job_parameters, ) + logger.info( + f"Synced ec2_iaminstanceprofile scoped analysis for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 90) logger.info( f"Syncing lambda_ecr analysis for AWS account {prowler_api_provider.uid}" ) + t0 = time.perf_counter() cartography_aws.run_analysis_job( "aws_lambda_ecr.json", neo4j_session, common_job_parameters, ) + logger.info( + f"Synced lambda_ecr analysis for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) if all( s in requested_syncs @@ -125,25 +158,34 @@ def start_aws_ingestion( logger.info( f"Syncing lb_container_exposure scoped analysis for AWS account {prowler_api_provider.uid}" ) + t0 = time.perf_counter() cartography_aws.run_scoped_analysis_job( "aws_lb_container_exposure.json", neo4j_session, common_job_parameters, ) + logger.info( + f"Synced lb_container_exposure scoped analysis for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) if all(s in requested_syncs for s in ["ec2:network_acls", "ec2:load_balancer_v2"]): logger.info( f"Syncing lb_nacl_direct scoped analysis for AWS account {prowler_api_provider.uid}" ) + t0 = time.perf_counter() cartography_aws.run_scoped_analysis_job( "aws_lb_nacl_direct.json", neo4j_session, common_job_parameters, ) + logger.info( + f"Synced lb_nacl_direct scoped analysis for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 91) logger.info(f"Syncing metadata for AWS account {prowler_api_provider.uid}") + t0 = time.perf_counter() cartography_aws.merge_module_sync_metadata( neo4j_session, group_type="AWSAccount", @@ -152,24 +194,23 @@ def start_aws_ingestion( update_tag=cartography_config.update_tag, stat_handler=cartography_aws.stat_handler, ) + logger.info( + f"Synced metadata for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 92) # Removing the added extra field del common_job_parameters["AWS_ID"] - logger.info(f"Syncing cleanup_job for AWS account {prowler_api_provider.uid}") - cartography_aws.run_cleanup_job( - "aws_post_ingestion_principals_cleanup.json", - neo4j_session, - common_job_parameters, - ) - db_utils.update_attack_paths_scan_progress(attack_paths_scan, 93) - logger.info(f"Syncing analysis for AWS account {prowler_api_provider.uid}") + t0 = time.perf_counter() cartography_aws._perform_aws_analysis( requested_syncs, neo4j_session, common_job_parameters ) - db_utils.update_attack_paths_scan_progress(attack_paths_scan, 94) + logger.info( + f"Synced analysis for AWS account {prowler_api_provider.uid} in {time.perf_counter() - t0:.3f}s" + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 93) return failed_syncs @@ -200,6 +241,48 @@ def get_boto3_session( return boto3_session +def resolve_aws_regions( + prowler_api_provider: ProwlerAPIProvider, + prowler_sdk_provider: ProwlerSDKProvider, +) -> list[str]: + """Resolve the regions to scan, falling back when `_enabled_regions` is `None`. + + The SDK silently sets `_enabled_regions` to `None` when `ec2:DescribeRegions` + fails (missing IAM permission, transient error). Without a fallback the + Cartography ingestion crashes with a non-actionable `TypeError`. Try the + user's `audited_regions` next, then the partition's static region list. + Excluded regions are honored on every branch. + """ + if prowler_sdk_provider._enabled_regions is not None: + regions = set(prowler_sdk_provider._enabled_regions) + + elif prowler_sdk_provider.identity.audited_regions: + regions = set(prowler_sdk_provider.identity.audited_regions) + + else: + partition = prowler_sdk_provider.identity.partition + try: + regions = prowler_sdk_provider.get_available_aws_service_regions( + "ec2", partition + ) + + except KeyError: + raise RuntimeError( + f"No region data available for partition {partition!r}; " + f"cannot determine regions to scan for " + f"{prowler_api_provider.uid}" + ) + + logger.warning( + f"Could not enumerate enabled regions for AWS account " + f"{prowler_api_provider.uid}; falling back to all regions in " + f"partition {partition!r}" + ) + + excluded = set(getattr(prowler_sdk_provider, "_excluded_regions", None) or ()) + return sorted(regions - excluded) + + def get_aioboto3_session(boto3_session: boto3.Session) -> aioboto3.Session: return aioboto3.Session(botocore_session=boto3_session._session) @@ -210,7 +293,7 @@ def sync_aws_account( sync_args: dict[str, Any], attack_paths_scan: ProwlerAPIAttackPathsScan, ) -> dict[str, str]: - current_progress = 4 # `cartography_aws._autodiscover_accounts` + current_progress = 4 # AWS Organizations account autodiscovery max_progress = ( 87 # `cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"]` - 1 ) @@ -234,6 +317,8 @@ def sync_aws_account( ) try: + func_t0 = time.perf_counter() + # `ecr:image_layers` uses `aioboto3_session` instead of `boto3_session` if func_name == "ecr:image_layers": cartography_aws.RESOURCE_FUNCTIONS[func_name]( @@ -257,7 +342,15 @@ def sync_aws_account( else: cartography_aws.RESOURCE_FUNCTIONS[func_name](**sync_args) + logger.info( + f"Synced function {func_name} for AWS account {prowler_api_provider.uid} in {time.perf_counter() - func_t0:.3f}s" + ) + except Exception as e: + logger.info( + f"Synced function {func_name} for AWS account {prowler_api_provider.uid} in {time.perf_counter() - func_t0:.3f}s (FAILED)" + ) + exception_message = utils.stringify_exception( e, f"Exception for AWS sync function: {func_name}" ) @@ -277,3 +370,16 @@ def sync_aws_account( ) return failed_syncs + + +def extract_short_uid(uid: str) -> str: + """Return the short identifier from an AWS ARN or resource ID. + + Supported inputs end in one of: + - `/` (e.g. `instance/i-xxx`) + - `:` (e.g. `function:name`) + - `` (e.g. `bucket-name` or `i-xxx`) + + If `uid` is already a short resource ID, it is returned unchanged. + """ + return uid.rsplit("/", 1)[-1].rsplit(":", 1)[-1] diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py index 1d81306bcc..83192f18d0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -1,45 +1,63 @@ -from datetime import datetime, timedelta, timezone - -from celery import current_app, states -from celery.utils.log import get_task_logger -from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES -from tasks.jobs.attack_paths.db_utils import ( - _mark_scan_finished, - recover_graph_data_ready, -) +from datetime import UTC, datetime, timedelta from api.attack_paths import database as graph_database from api.db_router import MainRouter from api.db_utils import rls_transaction from api.models import AttackPathsScan, StateChoices +from celery import states +from celery.utils.log import get_task_logger +from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES +from tasks.jobs.attack_paths.db_utils import ( + mark_scan_finished, + recover_graph_data_ready, +) +from tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive +from tasks.jobs.orphan_recovery import revoke_task as _revoke_task logger = get_task_logger(__name__) def cleanup_stale_attack_paths_scans() -> dict: """ - Find `EXECUTING` `AttackPathsScan` scans whose workers are dead or that have - exceeded the stale threshold, and mark them as `FAILED`. + Mark stale `AttackPathsScan` rows as `FAILED`. - Two-pass detection: + Covers two stuck-state scenarios: + 1. `EXECUTING` scans whose workers are dead, or that have exceeded the + stale threshold while alive. + 2. `SCHEDULED` scans that never made it to a worker — parent scan + crashed before dispatch, broker lost the message, etc. Detected by + age plus the parent `Scan` no longer being in flight. + """ + threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES) + now = datetime.now(tz=UTC) + cutoff = now - threshold + + cleaned_up: list[str] = [] + cleaned_up.extend(_cleanup_stale_executing_scans(cutoff)) + cleaned_up.extend(_cleanup_stale_scheduled_scans(cutoff)) + + logger.info( + f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up" + ) + return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up} + + +def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]: + """ + Two-pass detection for `EXECUTING` scans: 1. If `TaskResult.worker` exists, ping the worker. - Dead worker: cleanup immediately (any age). - Alive + past threshold: revoke the task, then cleanup. - Alive + within threshold: skip. 2. If no worker field: fall back to time-based heuristic only. """ - threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES) - now = datetime.now(tz=timezone.utc) - cutoff = now - threshold - - executing_scans = ( + executing_scans = list( AttackPathsScan.all_objects.using(MainRouter.admin_db) .filter(state=StateChoices.EXECUTING) .select_related("task__task_runner_task") ) # Cache worker liveness so each worker is pinged at most once - executing_scans = list(executing_scans) workers = { tr.worker for scan in executing_scans @@ -48,7 +66,7 @@ def cleanup_stale_attack_paths_scans() -> dict: } worker_alive = {w: _is_worker_alive(w) for w in workers} - cleaned_up = [] + cleaned_up: list[str] = [] for scan in executing_scans: task_result = ( @@ -65,13 +83,11 @@ def cleanup_stale_attack_paths_scans() -> dict: # Alive but stale — revoke before cleanup _revoke_task(task_result) - reason = ( - "Scan exceeded stale threshold — " "cleaned up by periodic task" - ) + reason = "Scan exceeded stale threshold — cleaned up by periodic task" else: reason = "Worker dead — cleaned up by periodic task" else: - # No worker recorded — time-based heuristic only + # No worker recorded, time-based heuristic only if scan.started_at and scan.started_at >= cutoff: continue reason = ( @@ -82,31 +98,57 @@ def cleanup_stale_attack_paths_scans() -> dict: if _cleanup_scan(scan, task_result, reason): cleaned_up.append(str(scan.id)) - logger.info( - f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up" - ) - return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up} + return cleaned_up -def _is_worker_alive(worker: str) -> bool: - """Ping a specific Celery worker. Returns `True` if it responds or on error.""" - try: - response = current_app.control.inspect(destination=[worker], timeout=1.0).ping() - return response is not None and worker in response - except Exception: - logger.exception(f"Failed to ping worker {worker}, treating as alive") - return True +def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]: + """ + Cleanup `SCHEDULED` scans that never reached a worker. + Detection: + - `state == SCHEDULED` + - `started_at < cutoff` + - parent `Scan` is no longer in flight (terminal state or missing). This + avoids cleaning up rows whose parent Prowler scan is legitimately still + running. -def _revoke_task(task_result) -> None: - """Send `SIGTERM` to a hung Celery task. Non-fatal on failure.""" - try: - current_app.control.revoke( - task_result.task_id, terminate=True, signal="SIGTERM" + For each match: revoke the queued task (best-effort; harmless if already + consumed), atomically flip to `FAILED`, and mark the `TaskResult`. The + temp Neo4j database is never created while `SCHEDULED`, so no drop is + needed. + """ + scheduled_scans = list( + AttackPathsScan.all_objects.using(MainRouter.admin_db) + .filter( + state=StateChoices.SCHEDULED, + started_at__lt=cutoff, ) - logger.info(f"Revoked task {task_result.task_id}") - except Exception: - logger.exception(f"Failed to revoke task {task_result.task_id}") + .select_related("task__task_runner_task", "scan") + ) + + cleaned_up: list[str] = [] + parent_terminal = ( + StateChoices.COMPLETED, + StateChoices.FAILED, + StateChoices.CANCELLED, + ) + + for scan in scheduled_scans: + parent_scan = scan.scan + if parent_scan is not None and parent_scan.state not in parent_terminal: + continue + + task_result = ( + getattr(scan.task, "task_runner_task", None) if scan.task else None + ) + if task_result: + _revoke_task(task_result, terminate=False) + + reason = "Scan never started — cleaned up by periodic task" + if _cleanup_scheduled_scan(scan, task_result, reason): + cleaned_up.append(str(scan.id)) + + return cleaned_up def _cleanup_scan(scan, task_result, reason: str) -> bool: @@ -118,35 +160,71 @@ def _cleanup_scan(scan, task_result, reason: str) -> bool: """ scan_id_str = str(scan.id) - # 1. Drop temp Neo4j database + # Drop temp Neo4j database tmp_db_name = graph_database.get_database_name(scan.id, temporary=True) try: graph_database.drop_database(tmp_db_name) except Exception: logger.exception(f"Failed to drop temp database {tmp_db_name}") - # 2. Lock row, verify still EXECUTING, mark FAILED — all atomic + fresh_scan = _finalize_failed_scan(scan, StateChoices.EXECUTING, reason) + if fresh_scan is None: + return False + + # Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock) + if task_result: + task_result.status = states.FAILURE + task_result.date_done = datetime.now(tz=UTC) + task_result.save(update_fields=["status", "date_done"]) + + recover_graph_data_ready(fresh_scan) + + logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}") + return True + + +def _cleanup_scheduled_scan(scan, task_result, reason: str) -> bool: + """ + Clean up a `SCHEDULED` scan that never reached a worker. + + Skips the temp Neo4j drop — the database is only created once the worker + enters `EXECUTING`, so dropping it here just produces noisy log output. + + Returns `True` if the scan was actually cleaned up, `False` if skipped. + """ + scan_id_str = str(scan.id) + + fresh_scan = _finalize_failed_scan(scan, StateChoices.SCHEDULED, reason) + if fresh_scan is None: + return False + + if task_result: + task_result.status = states.FAILURE + task_result.date_done = datetime.now(tz=UTC) + task_result.save(update_fields=["status", "date_done"]) + + logger.info(f"Cleaned up scheduled scan {scan_id_str}: {reason}") + return True + + +def _finalize_failed_scan(scan, expected_state: str, reason: str): + """ + Atomically lock the row, verify it's still in `expected_state`, and + mark it `FAILED`. Returns the locked row on success, `None` if the + row is gone or has already moved on. + """ + scan_id_str = str(scan.id) with rls_transaction(str(scan.tenant_id)): try: fresh_scan = AttackPathsScan.objects.select_for_update().get(id=scan.id) except AttackPathsScan.DoesNotExist: logger.warning(f"Scan {scan_id_str} no longer exists, skipping") - return False + return None - if fresh_scan.state != StateChoices.EXECUTING: + if fresh_scan.state != expected_state: logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping") - return False + return None - _mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason}) + mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason}) - # 3. Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock) - if task_result: - task_result.status = states.FAILURE - task_result.date_done = datetime.now(tz=timezone.utc) - task_result.save(update_fields=["status", "date_done"]) - - # 4. Recover graph_data_ready if provider data still exists - recover_graph_data_ready(fresh_scan) - - logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}") - return True + return fresh_scan diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py index 76dbdc1dc5..d8ed63a8fc 100644 --- a/api/src/backend/tasks/jobs/attack_paths/config.py +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -1,16 +1,21 @@ -from dataclasses import dataclass -from typing import Callable +from collections.abc import Callable from uuid import UUID from config.env import env -from tasks.jobs.attack_paths import aws +from tasks.jobs.attack_paths import provider_config as _provider_config + +# Re-export provider config objects so existing imports keep working. +AWS_CONFIG = _provider_config.AWS_CONFIG +NormalizedList = _provider_config.NormalizedList +PROVIDER_CONFIGS = _provider_config.PROVIDER_CONFIGS +ProviderConfig = _provider_config.ProviderConfig # Batch size for Neo4j write operations (resource labeling, cleanup) BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000) # Batch size for Postgres findings fetch (keyset pagination page size) -FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 500) +FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000) # Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page) -SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 250) +SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 1000) # Neo4j internal labels (Prowler-specific, not provider-specific) # - `Internet`: Singleton node representing external internet access for exposed-resource queries @@ -21,39 +26,12 @@ PROWLER_FINDING_LABEL = "ProwlerFinding" PROVIDER_RESOURCE_LABEL = "_ProviderResource" # Dynamic isolation labels that contain entity UUIDs and are added to every synced node during sync -# Format: _Tenant_{uuid_no_hyphens}, _Provider_{uuid_no_hyphens} +# Format: `_Tenant_{uuid_no_hyphens}`, `_Provider_{uuid_no_hyphens}` TENANT_LABEL_PREFIX = "_Tenant_" PROVIDER_LABEL_PREFIX = "_Provider_" DYNAMIC_ISOLATION_PREFIXES = [TENANT_LABEL_PREFIX, PROVIDER_LABEL_PREFIX] -@dataclass(frozen=True) -class ProviderConfig: - """Configuration for a cloud provider's Attack Paths integration.""" - - name: str - root_node_label: str # e.g., "AWSAccount" - uid_field: str # e.g., "arn" - # Label for resources connected to the account node, enabling indexed finding lookups. - resource_label: str # e.g., "_AWSResource" - ingestion_function: Callable - - -# Provider Configurations -# ----------------------- - -AWS_CONFIG = ProviderConfig( - name="aws", - root_node_label="AWSAccount", - uid_field="arn", - resource_label="_AWSResource", - ingestion_function=aws.start_aws_ingestion, -) - -PROVIDER_CONFIGS: dict[str, ProviderConfig] = { - "aws": AWS_CONFIG, -} - # Labels added by Prowler that should be filtered from API responses # Derived from provider configs + common internal labels INTERNAL_LABELS: list[str] = [ @@ -84,7 +62,6 @@ INTERNAL_PROPERTIES: list[str] = [ # Provider Config Accessors -# ------------------------- def is_provider_available(provider_type: str) -> bool: @@ -116,8 +93,22 @@ def get_provider_resource_label(provider_type: str) -> str: return config.resource_label if config else "_UnknownProviderResource" +def _identity_short_uid(uid: str) -> str: + """Fallback short-uid extractor for providers without a custom mapping.""" + return uid + + +def get_short_uid_extractor(provider_type: str) -> Callable[[str], str]: + """Get the short-uid extractor for a provider type. + + Returns an identity function when the provider is unknown, so callers can + rely on a callable always being returned. + """ + config = PROVIDER_CONFIGS.get(provider_type) + return config.short_uid_extractor if config else _identity_short_uid + + # Dynamic Isolation Label Helpers -# -------------------------------- def _normalize_uuid(value: str | UUID) -> str: diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py index 9df99df619..c444a62602 100644 --- a/api/src/backend/tasks/jobs/attack_paths/db_utils.py +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -1,15 +1,16 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any -from cartography.config import Config as CartographyConfig -from celery.utils.log import get_task_logger -from tasks.jobs.attack_paths.config import is_provider_available - from api.attack_paths import database as graph_database from api.db_utils import rls_transaction from api.models import AttackPathsScan as ProwlerAPIAttackPathsScan from api.models import Provider as ProwlerAPIProvider from api.models import StateChoices +from cartography.config import Config as CartographyConfig +from celery.utils.log import get_task_logger +from django.conf import settings +from django.db.models import Case, IntegerField, Value, When +from tasks.jobs.attack_paths.config import is_provider_available logger = get_task_logger(__name__) @@ -30,21 +31,43 @@ def create_attack_paths_scan( return None with rls_transaction(tenant_id): - # Inherit graph_data_ready from the previous scan for this provider, - # so queries remain available while the new scan runs. - previous_data_ready = ProwlerAPIAttackPathsScan.objects.filter( - tenant_id=tenant_id, - provider_id=provider_id, - graph_data_ready=True, - ).exists() + # Inherit metadata from the previous ready scan for this provider so + # queries remain available while the new scan runs. The new row only + # flips to the target sink after its own graph sync succeeds. + active_sink_backend = settings.ATTACK_PATHS_SINK_DATABASE + previous_ready = ( + ProwlerAPIAttackPathsScan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + graph_data_ready=True, + ) + .annotate( + active_sink_rank=Case( + When(sink_backend=active_sink_backend, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("active_sink_rank", "-inserted_at") + .first() + ) + previous_data_ready = previous_ready is not None + inherited_is_migrated = previous_ready.is_migrated if previous_ready else False + inherited_sink_backend = ( + previous_ready.sink_backend + if previous_ready + else ProwlerAPIAttackPathsScan.SinkBackendChoices.NEO4J + ) attack_paths_scan = ProwlerAPIAttackPathsScan.objects.create( tenant_id=tenant_id, provider_id=provider_id, scan_id=scan_id, state=StateChoices.SCHEDULED, - started_at=datetime.now(tz=timezone.utc), + started_at=datetime.now(tz=UTC), graph_data_ready=previous_data_ready, + is_migrated=inherited_is_migrated, + sink_backend=inherited_sink_backend, ) attack_paths_scan.save() @@ -67,34 +90,61 @@ def retrieve_attack_paths_scan( return None +def set_attack_paths_scan_task_id( + tenant_id: str, + scan_pk: str, + task_id: str, +) -> None: + """Persist the Celery `task_id` on the `AttackPathsScan` row. + + Called at dispatch time (when `apply_async` returns) so the row carries + the task id even while still `SCHEDULED`. This lets the periodic + cleanup revoke queued messages for scans that never reached a worker. + """ + with rls_transaction(tenant_id): + ProwlerAPIAttackPathsScan.objects.filter(id=scan_pk).update(task_id=task_id) + + def starting_attack_paths_scan( attack_paths_scan: ProwlerAPIAttackPathsScan, - task_id: str, cartography_config: CartographyConfig, -) -> None: +) -> bool: + """Flip the row from `SCHEDULED` to `EXECUTING` atomically. + + Returns `False` if the row is gone or has already moved past + `SCHEDULED` (e.g., periodic cleanup raced ahead and marked it + `FAILED` while the worker message was still in flight). + """ with rls_transaction(attack_paths_scan.tenant_id): - attack_paths_scan.task_id = task_id - attack_paths_scan.state = StateChoices.EXECUTING - attack_paths_scan.started_at = datetime.now(tz=timezone.utc) - attack_paths_scan.update_tag = cartography_config.update_tag + try: + locked = ProwlerAPIAttackPathsScan.objects.select_for_update().get( + id=attack_paths_scan.id + ) + except ProwlerAPIAttackPathsScan.DoesNotExist: + return False - attack_paths_scan.save( - update_fields=[ - "task_id", - "state", - "started_at", - "update_tag", - ] - ) + if locked.state != StateChoices.SCHEDULED: + return False + + locked.state = StateChoices.EXECUTING + locked.started_at = datetime.now(tz=UTC) + locked.update_tag = cartography_config.update_tag + locked.save(update_fields=["state", "started_at", "update_tag"]) + + # Keep the in-memory object the caller is holding in sync. + attack_paths_scan.state = locked.state + attack_paths_scan.started_at = locked.started_at + attack_paths_scan.update_tag = locked.update_tag + return True -def _mark_scan_finished( +def mark_scan_finished( attack_paths_scan: ProwlerAPIAttackPathsScan, state: StateChoices, ingestion_exceptions: dict[str, Any], ) -> None: """Set terminal fields on a scan. Caller must be inside a transaction.""" - now = datetime.now(tz=timezone.utc) + now = datetime.now(tz=UTC) duration = ( int((now - attack_paths_scan.started_at).total_seconds()) if attack_paths_scan.started_at @@ -122,7 +172,7 @@ def finish_attack_paths_scan( ingestion_exceptions: dict[str, Any], ) -> None: with rls_transaction(attack_paths_scan.tenant_id): - _mark_scan_finished(attack_paths_scan, state, ingestion_exceptions) + mark_scan_finished(attack_paths_scan, state, ingestion_exceptions) def update_attack_paths_scan_progress( @@ -143,19 +193,45 @@ def set_graph_data_ready( attack_paths_scan.save(update_fields=["graph_data_ready"]) +def set_scan_migrated( + attack_paths_scan: ProwlerAPIAttackPathsScan, + migrated: bool, + sink_backend: str | None = None, +) -> None: + """Mark the scan as written with the current (migrated) schema. + + Called after a successful sync so the read catalog and sink backend only + switch once the new graph is actually live. + + # TODO: drop after Neptune cutover + """ + with rls_transaction(attack_paths_scan.tenant_id): + attack_paths_scan.is_migrated = migrated + update_fields = ["is_migrated"] + if sink_backend is not None: + attack_paths_scan.sink_backend = sink_backend + update_fields.append("sink_backend") + attack_paths_scan.save(update_fields=update_fields) + + def set_provider_graph_data_ready( attack_paths_scan: ProwlerAPIAttackPathsScan, ready: bool, + sink_backend: str | None = None, ) -> None: """ - Set `graph_data_ready` for ALL scans of the same provider. + Set `graph_data_ready` for scans of the same provider in one sink. - Used before drop/sync so that older scan IDs cannot bypass the query gate while the graph is being replaced. + Used before drop/sync so that older scan IDs in the target sink cannot + bypass the query gate while that sink's graph is being replaced. Scans + preserved in another sink stay queryable for rollback. """ + target_sink_backend = sink_backend or attack_paths_scan.sink_backend with rls_transaction(attack_paths_scan.tenant_id): ProwlerAPIAttackPathsScan.objects.filter( tenant_id=attack_paths_scan.tenant_id, provider_id=attack_paths_scan.provider_id, + sink_backend=target_sink_backend, ).update(graph_data_ready=ready) attack_paths_scan.refresh_from_db(fields=["graph_data_ready"]) @@ -176,10 +252,15 @@ def recover_graph_data_ready( next successful scan) is a worse outcome for the user. """ try: + from api.attack_paths import sink as sink_module + tenant_db = graph_database.get_database_name(attack_paths_scan.tenant_id) - if graph_database.has_provider_data( - tenant_db, str(attack_paths_scan.provider_id) - ): + # TODO: drop after Neptune cutover + # Check the backend that actually holds this scan's data, not the + # currently configured sink, a stale `EXECUTING` scan from before a + # backend switch must still be recoverable + backend = sink_module.get_backend_for_scan(attack_paths_scan) + if backend.has_provider_data(tenant_db, str(attack_paths_scan.provider_id)): set_provider_graph_data_ready(attack_paths_scan, True) logger.info( f"Recovered `graph_data_ready` for provider {attack_paths_scan.provider_id}" @@ -221,6 +302,6 @@ def fail_attack_paths_scan( return if fresh.state in (StateChoices.COMPLETED, StateChoices.FAILED): return - _mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error}) + mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error}) recover_graph_data_ready(fresh) diff --git a/api/src/backend/tasks/jobs/attack_paths/findings.py b/api/src/backend/tasks/jobs/attack_paths/findings.py index 4a01b925d7..6cc7ddb2e0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/findings.py +++ b/api/src/backend/tasks/jobs/attack_paths/findings.py @@ -5,36 +5,35 @@ This module handles: - Adding resource labels to Cartography nodes for efficient lookups - Loading Prowler findings into the graph - Linking findings to resources -- Cleaning up stale findings """ from collections import defaultdict -from typing import Any, Generator +from collections.abc import Callable, Generator +from typing import Any from uuid import UUID import neo4j +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Finding as FindingModel +from api.models import Provider, ResourceFindingMapping from cartography.config import Config as CartographyConfig from celery.utils.log import get_task_logger +from prowler.config import config as ProwlerConfig from tasks.jobs.attack_paths.config import ( BATCH_SIZE, FINDINGS_BATCH_SIZE, get_node_uid_field, get_provider_resource_label, get_root_node_label, + get_short_uid_extractor, ) from tasks.jobs.attack_paths.queries import ( ADD_RESOURCE_LABEL_TEMPLATE, - CLEANUP_FINDINGS_TEMPLATE, INSERT_FINDING_TEMPLATE, render_cypher_template, ) -from api.db_router import READ_REPLICA_ALIAS -from api.db_utils import rls_transaction -from api.models import Finding as FindingModel -from api.models import Provider, ResourceFindingMapping -from prowler.config import config as ProwlerConfig - logger = get_task_logger(__name__) @@ -58,7 +57,9 @@ _DB_QUERY_FIELDS = [ ] -def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]: +def _to_neo4j_dict( + record: dict[str, Any], resource_uid: str, resource_short_uid: str +) -> dict[str, Any]: """Transform a Django `.values()` record into a `dict` ready for Neo4j ingestion.""" return { "id": str(record["id"]), @@ -76,11 +77,11 @@ def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]: "muted": record["muted"], "muted_reason": record["muted_reason"], "resource_uid": resource_uid, + "resource_short_uid": resource_short_uid, } # Public API -# ---------- def analysis( @@ -88,18 +89,21 @@ def analysis( prowler_api_provider: Provider, scan_id: str, config: CartographyConfig, -) -> None: +) -> tuple[int, int]: """ Main entry point for Prowler findings analysis. - Adds resource labels, loads findings, and cleans up stale data. + Adds resource labels and loads findings. + Returns (labeled_nodes, findings_loaded). """ - add_resource_label( + total_labeled = add_resource_label( neo4j_session, prowler_api_provider.provider, str(prowler_api_provider.uid) ) findings_data = stream_findings_with_resources(prowler_api_provider, scan_id) - load_findings(neo4j_session, findings_data, prowler_api_provider, config) - cleanup_findings(neo4j_session, prowler_api_provider, config) + total_loaded = load_findings( + neo4j_session, findings_data, prowler_api_provider, config + ) + return total_labeled, total_loaded def add_resource_label( @@ -149,12 +153,11 @@ def load_findings( findings_batches: Generator[list[dict[str, Any]], None, None], prowler_api_provider: Provider, config: CartographyConfig, -) -> None: +) -> int: """Load Prowler findings into the graph, linking them to resources.""" query = render_cypher_template( INSERT_FINDING_TEMPLATE, { - "__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider), "__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider), "__RESOURCE_LABEL__": get_provider_resource_label( prowler_api_provider.provider @@ -163,13 +166,14 @@ def load_findings( ) parameters = { - "provider_uid": str(prowler_api_provider.uid), "last_updated": config.update_tag, "prowler_version": ProwlerConfig.prowler_version, } batch_num = 0 total_records = 0 + edges_merged = 0 + edges_dropped = 0 for batch in findings_batches: batch_num += 1 batch_size = len(batch) @@ -178,35 +182,19 @@ def load_findings( parameters["findings_data"] = batch logger.info(f"Loading findings batch {batch_num} ({batch_size} records)") - neo4j_session.run(query, parameters) + summary = neo4j_session.run(query, parameters).single() + if summary is not None: + edges_merged += summary.get("merged_count", 0) + edges_dropped += summary.get("dropped_count", 0) - logger.info(f"Finished loading {total_records} records in {batch_num} batches") - - -def cleanup_findings( - neo4j_session: neo4j.Session, - prowler_api_provider: Provider, - config: CartographyConfig, -) -> None: - """Remove stale findings (classic Cartography behaviour).""" - parameters = { - "last_updated": config.update_tag, - "batch_size": BATCH_SIZE, - } - - batch = 1 - deleted_count = 1 - while deleted_count > 0: - logger.info(f"Cleaning findings batch {batch}") - - result = neo4j_session.run(CLEANUP_FINDINGS_TEMPLATE, parameters) - - deleted_count = result.single().get("deleted_findings_count", 0) - batch += 1 + logger.info( + f"Finished loading {total_records} records in {batch_num} batches " + f"(edges_merged={edges_merged}, edges_dropped={edges_dropped})" + ) + return total_records # Findings Streaming (Generator-based) -# ------------------------------------- def stream_findings_with_resources( @@ -226,8 +214,9 @@ def stream_findings_with_resources( ) tenant_id = prowler_api_provider.tenant_id + short_uid_extractor = get_short_uid_extractor(prowler_api_provider.provider) for batch in _paginate_findings(tenant_id, scan_id): - enriched = _enrich_batch_with_resources(batch, tenant_id) + enriched = _enrich_batch_with_resources(batch, tenant_id, short_uid_extractor) if enriched: yield enriched @@ -273,7 +262,9 @@ def _fetch_findings_batch( with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): # Use `all_objects` to get `Findings` even on soft-deleted `Providers` # But even the provider is already validated as active in this context - qs = FindingModel.all_objects.filter(scan_id=scan_id).order_by("id") + qs = FindingModel.all_objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).order_by("id") if after_id is not None: qs = qs.filter(id__gt=after_id) @@ -282,12 +273,12 @@ def _fetch_findings_batch( # Batch Enrichment -# ----------------- def _enrich_batch_with_resources( findings_batch: list[dict[str, Any]], tenant_id: str, + short_uid_extractor: Callable[[str], str], ) -> list[dict[str, Any]]: """ Enrich findings with their resource UIDs. @@ -299,7 +290,7 @@ def _enrich_batch_with_resources( resource_map = _build_finding_resource_map(finding_ids, tenant_id) return [ - _to_neo4j_dict(finding, resource_uid) + _to_neo4j_dict(finding, resource_uid, short_uid_extractor(resource_uid)) for finding in findings_batch for resource_uid in resource_map.get(finding["id"], []) ] diff --git a/api/src/backend/tasks/jobs/attack_paths/indexes.py b/api/src/backend/tasks/jobs/attack_paths/indexes.py index 94855e4082..50e8a12bcd 100644 --- a/api/src/backend/tasks/jobs/attack_paths/indexes.py +++ b/api/src/backend/tasks/jobs/attack_paths/indexes.py @@ -1,26 +1,24 @@ import neo4j - from cartography.client.core.tx import run_write_query +from cartography.intel import create_indexes as cartography_create_indexes from celery.utils.log import get_task_logger - from tasks.jobs.attack_paths.config import ( INTERNET_NODE_LABEL, - PROWLER_FINDING_LABEL, PROVIDER_ELEMENT_ID_PROPERTY, PROVIDER_RESOURCE_LABEL, + PROWLER_FINDING_LABEL, ) logger = get_task_logger(__name__) -# Indexes for Prowler findings and resource lookups +# Indexes for Prowler Findings and resource lookups FINDINGS_INDEX_STATEMENTS = [ # Resource indexes for Prowler Finding lookups "CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:_AWSResource) ON (n.arn);", "CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:_AWSResource) ON (n.id);", # Prowler Finding indexes f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);", - f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);", f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);", # Internet node index for MERGE lookups f"CREATE INDEX internet_id IF NOT EXISTS FOR (n:{INTERNET_NODE_LABEL}) ON (n.id);", @@ -33,14 +31,34 @@ SYNC_INDEX_STATEMENTS = [ def create_findings_indexes(neo4j_session: neo4j.Session) -> None: - """Create indexes for Prowler findings and resource lookups.""" + """Create indexes for Prowler findings and resource lookups. + + Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j + session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes + and rejects `CREATE INDEX`, so callers skip it for the Neptune sink. + """ logger.info("Creating indexes for Prowler Findings node types") for statement in FINDINGS_INDEX_STATEMENTS: run_write_query(neo4j_session, statement) +def create_cartography_indexes(neo4j_session: neo4j.Session, config) -> None: + """Create Cartography's standard indexes for the session's database. + + Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j + session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes + and rejects `CREATE INDEX`, so callers skip it for the Neptune sink. + """ + cartography_create_indexes.run(neo4j_session, config) + + def create_sync_indexes(neo4j_session: neo4j.Session) -> None: - """Create indexes for provider resource sync operations.""" + """Create indexes for provider resource sync operations. + + Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j + session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes + and rejects `CREATE INDEX`, so callers skip it for the Neptune sink. + """ logger.info("Ensuring ProviderResource indexes exist") for statement in SYNC_INDEX_STATEMENTS: neo4j_session.run(statement) diff --git a/api/src/backend/tasks/jobs/attack_paths/internet.py b/api/src/backend/tasks/jobs/attack_paths/internet.py index 83517bc903..4c7a61bd20 100644 --- a/api/src/backend/tasks/jobs/attack_paths/internet.py +++ b/api/src/backend/tasks/jobs/attack_paths/internet.py @@ -7,11 +7,9 @@ in the temporary scan database before sync. """ import neo4j - +from api.models import Provider from cartography.config import Config as CartographyConfig from celery.utils.log import get_task_logger - -from api.models import Provider from prowler.config import config as ProwlerConfig from tasks.jobs.attack_paths.config import get_root_node_label from tasks.jobs.attack_paths.queries import ( diff --git a/api/src/backend/tasks/jobs/attack_paths/provider_config.py b/api/src/backend/tasks/jobs/attack_paths/provider_config.py new file mode 100644 index 0000000000..7d834e6aff --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/provider_config.py @@ -0,0 +1,431 @@ +""" +Provider-level Attack Paths configuration. + +Each `ProviderConfig` carries the cloud provider's ingestion entry point and +the catalog of list-typed node properties (`normalized_lists`). The sync +layer reads this catalog and materialises each list element as a child node +connected to the parent by a typed edge, so queries traverse the graph +instead of working on serialised list values. Both Neo4j and Neptune sinks +write the same shape and queries are portable across them. +""" + +from collections.abc import Callable +from dataclasses import dataclass, field + +from tasks.jobs.attack_paths import aws + + +@dataclass(frozen=True) +class NormalizedList: + """Catalog entry for a list-typed node property. + + Describes how the sync layer materialises a parent node's list-typed + property as a set of child item nodes connected by a typed edge. + + Conventions (mechanical, do not invent): + - `child_label`: `Item` + e.g. AWSPolicyStatement.resource -> AWSPolicyStatementResourceItem + - `rel_type`: `HAS_` + e.g. resource -> HAS_RESOURCE + - child node property: + * `field_map = []` (scalar list, ~95% case) -> child stores `value: str` + * `field_map = [(src_key, child_field), ...]` (list of dicts, rare) + -> child stores those fields + """ + + source_label: str + source_property: str + child_label: str + rel_type: str + field_map: list[tuple[str, str]] = field(default_factory=list) + + def __post_init__(self) -> None: + if self.field_map: + child_fields = [dst for _, dst in self.field_map] + if "value" in child_fields: + raise ValueError( + f"NormalizedList {self.source_label}.{self.source_property}: " + "`value` is reserved for scalar mode; do not map a source key to it" + ) + src_keys = [src for src, _ in self.field_map] + if len(set(src_keys)) != len(src_keys): + raise ValueError( + f"NormalizedList {self.source_label}.{self.source_property}: " + "duplicate source key in field_map" + ) + if len(set(child_fields)) != len(child_fields): + raise ValueError( + f"NormalizedList {self.source_label}.{self.source_property}: " + "duplicate child field in field_map" + ) + + +@dataclass(frozen=True) +class ProviderConfig: + """Configuration for a cloud provider's Attack Paths integration.""" + + name: str + root_node_label: str # e.g., "AWSAccount" + uid_field: str # e.g., "arn" + # Label for resources connected to the account node, enabling indexed finding lookups + resource_label: str # e.g., "_AWSResource" + ingestion_function: Callable + # Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance) + short_uid_extractor: Callable[[str], str] + # List-typed properties to materialise as child nodes + edges at sync time. + # Mandatory (may be []). Without an entry here, a list-typed property falls + # back to comma-string flatten and emits a one-time warning. + normalized_lists: list[NormalizedList] + + +# AWS list-typed property catalog. +# One entry per Cartography node property whose runtime value is a list. The +# sync layer materialises each element as a `` node and links it +# to the parent with a `` edge; see the `NormalizedList` docstring +# above for the naming conventions. +AWS_NORMALIZED_LISTS: list[NormalizedList] = [ + # AWSPolicyStatement - the hot path driving the 53-query perf fix. + NormalizedList( + "AWSPolicyStatement", "action", "AWSPolicyStatementActionItem", "HAS_ACTION" + ), + NormalizedList( + "AWSPolicyStatement", + "notaction", + "AWSPolicyStatementNotactionItem", + "HAS_NOTACTION", + ), + NormalizedList( + "AWSPolicyStatement", + "resource", + "AWSPolicyStatementResourceItem", + "HAS_RESOURCE", + ), + NormalizedList( + "AWSPolicyStatement", + "notresource", + "AWSPolicyStatementNotresourceItem", + "HAS_NOTRESOURCE", + ), + # S3PolicyStatement - same shape as IAM policies; AWS allows list or string. + NormalizedList( + "S3PolicyStatement", "action", "S3PolicyStatementActionItem", "HAS_ACTION" + ), + NormalizedList( + "S3PolicyStatement", "resource", "S3PolicyStatementResourceItem", "HAS_RESOURCE" + ), + # IAM / Cognito / KMS / Secrets + NormalizedList( + "CognitoIdentityPool", "roles", "CognitoIdentityPoolRolesItem", "HAS_ROLES" + ), + NormalizedList( + "KMSKey", + "encryption_algorithms", + "KMSKeyEncryptionAlgorithmsItem", + "HAS_ENCRYPTION_ALGORITHMS", + ), + NormalizedList( + "KMSKey", + "signing_algorithms", + "KMSKeySigningAlgorithmsItem", + "HAS_SIGNING_ALGORITHMS", + ), + NormalizedList( + "KMSKey", + "anonymous_actions", + "KMSKeyAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + NormalizedList( + "KMSGrant", "operations", "KMSGrantOperationsItem", "HAS_OPERATIONS" + ), + NormalizedList( + "SecretsManagerSecretVersion", + "version_stages", + "SecretsManagerSecretVersionVersionStagesItem", + "HAS_VERSION_STAGES", + ), + NormalizedList( + "SecretsManagerSecretVersion", + "kms_key_ids", + "SecretsManagerSecretVersionKmsKeyIdsItem", + "HAS_KMS_KEY_IDS", + ), + NormalizedList( + "SecretsManagerSecretVersion", + "tags", + "SecretsManagerSecretVersionTagsItem", + "HAS_TAGS", + field_map=[("Key", "key"), ("Value", "value_")], + # `value` is reserved for scalar mode; map `Value` to `value_` to keep dict shape. + ), + # Lambda / Compute + NormalizedList( + "AWSLambda", "architectures", "AWSLambdaArchitecturesItem", "HAS_ARCHITECTURES" + ), + NormalizedList( + "AWSLambda", + "anonymous_actions", + "AWSLambdaAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + NormalizedList( + "CodeBuildProject", + "environment_variables", + "CodeBuildProjectEnvironmentVariablesItem", + "HAS_ENVIRONMENT_VARIABLES", + ), + # ECS family + NormalizedList( + "ECSCluster", + "capacity_providers", + "ECSClusterCapacityProvidersItem", + "HAS_CAPACITY_PROVIDERS", + ), + NormalizedList( + "ECSTaskDefinition", + "compatibilities", + "ECSTaskDefinitionCompatibilitiesItem", + "HAS_COMPATIBILITIES", + ), + NormalizedList( + "ECSTaskDefinition", + "requires_compatibilities", + "ECSTaskDefinitionRequiresCompatibilitiesItem", + "HAS_REQUIRES_COMPATIBILITIES", + ), + NormalizedList( + "ECSContainerDefinition", + "links", + "ECSContainerDefinitionLinksItem", + "HAS_LINKS", + ), + NormalizedList( + "ECSContainerDefinition", + "entry_point", + "ECSContainerDefinitionEntryPointItem", + "HAS_ENTRY_POINT", + ), + NormalizedList( + "ECSContainerDefinition", + "command", + "ECSContainerDefinitionCommandItem", + "HAS_COMMAND", + ), + NormalizedList( + "ECSContainerDefinition", + "dns_servers", + "ECSContainerDefinitionDnsServersItem", + "HAS_DNS_SERVERS", + ), + NormalizedList( + "ECSContainerDefinition", + "dns_search_domains", + "ECSContainerDefinitionDnsSearchDomainsItem", + "HAS_DNS_SEARCH_DOMAINS", + ), + NormalizedList( + "ECSContainerDefinition", + "docker_security_options", + "ECSContainerDefinitionDockerSecurityOptionsItem", + "HAS_DOCKER_SECURITY_OPTIONS", + ), + NormalizedList("ECSContainer", "gpu_ids", "ECSContainerGpuIdsItem", "HAS_GPU_IDS"), + # ECR + NormalizedList( + "ECRImage", "layer_diff_ids", "ECRImageLayerDiffIdsItem", "HAS_LAYER_DIFF_IDS" + ), + NormalizedList( + "ECRImage", + "child_image_digests", + "ECRImageChildImageDigestsItem", + "HAS_CHILD_IMAGE_DIGESTS", + ), + # EC2 / Networking + NormalizedList( + "EC2Instance", + "exposed_internet_type", + "EC2InstanceExposedInternetTypeItem", + "HAS_EXPOSED_INTERNET_TYPE", + ), + NormalizedList( + "AutoScalingGroup", + "exposed_internet_type", + "AutoScalingGroupExposedInternetTypeItem", + "HAS_EXPOSED_INTERNET_TYPE", + ), + NormalizedList( + "LaunchConfiguration", + "security_groups", + "LaunchConfigurationSecurityGroupsItem", + "HAS_SECURITY_GROUPS", + ), + NormalizedList( + "LaunchTemplateVersion", + "security_group_ids", + "LaunchTemplateVersionSecurityGroupIdsItem", + "HAS_SECURITY_GROUP_IDS", + ), + NormalizedList( + "LaunchTemplateVersion", + "security_groups", + "LaunchTemplateVersionSecurityGroupsItem", + "HAS_SECURITY_GROUPS", + ), + NormalizedList( + "AWSVpcEndpoint", + "route_table_ids", + "AWSVpcEndpointRouteTableIdsItem", + "HAS_ROUTE_TABLE_IDS", + ), + NormalizedList( + "AWSVpcEndpoint", + "network_interface_ids", + "AWSVpcEndpointNetworkInterfaceIdsItem", + "HAS_NETWORK_INTERFACE_IDS", + ), + NormalizedList( + "AWSVpcEndpoint", + "subnet_ids", + "AWSVpcEndpointSubnetIdsItem", + "HAS_SUBNET_IDS", + ), + NormalizedList( + "ELBListener", "policy_names", "ELBListenerPolicyNamesItem", "HAS_POLICY_NAMES" + ), + # CloudFront / Route53 / CloudWatch / CloudTrail + NormalizedList( + "CloudFrontDistribution", + "aliases", + "CloudFrontDistributionAliasesItem", + "HAS_ALIASES", + ), + NormalizedList( + "CloudFrontDistribution", + "geo_restriction_locations", + "CloudFrontDistributionGeoRestrictionLocationsItem", + "HAS_GEO_RESTRICTION_LOCATIONS", + ), + NormalizedList( + "CloudWatchLogGroup", + "inherited_properties", + "CloudWatchLogGroupInheritedPropertiesItem", + "HAS_INHERITED_PROPERTIES", + ), + # RDS / Storage + NormalizedList( + "RDSCluster", + "availability_zones", + "RDSClusterAvailabilityZonesItem", + "HAS_AVAILABILITY_ZONES", + ), + NormalizedList( + "RDSEventSubscription", + "event_categories", + "RDSEventSubscriptionEventCategoriesItem", + "HAS_EVENT_CATEGORIES", + ), + NormalizedList( + "RDSEventSubscription", + "source_ids", + "RDSEventSubscriptionSourceIdsItem", + "HAS_SOURCE_IDS", + ), + NormalizedList( + "S3Bucket", + "anonymous_actions", + "S3BucketAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + # Inspector / Config / SSM / ACM / APIGateway / Glue / SageMaker / Bedrock + NormalizedList( + "AWSInspectorFinding", + "referenceurls", + "AWSInspectorFindingReferenceurlsItem", + "HAS_REFERENCEURLS", + ), + NormalizedList( + "AWSInspectorFinding", + "relatedvulnerabilities", + "AWSInspectorFindingRelatedvulnerabilitiesItem", + "HAS_RELATEDVULNERABILITIES", + ), + NormalizedList( + "AWSInspectorFinding", + "vulnerablepackageids", + "AWSInspectorFindingVulnerablepackageidsItem", + "HAS_VULNERABLEPACKAGEIDS", + ), + NormalizedList( + "AWSConfigurationRecorder", + "recording_group_resource_types", + "AWSConfigurationRecorderRecordingGroupResourceTypesItem", + "HAS_RECORDING_GROUP_RESOURCE_TYPES", + ), + NormalizedList( + "AWSConfigRule", + "scope_compliance_resource_types", + "AWSConfigRuleScopeComplianceResourceTypesItem", + "HAS_SCOPE_COMPLIANCE_RESOURCE_TYPES", + ), + NormalizedList( + "AWSConfigRule", + "source_details", + "AWSConfigRuleSourceDetailsItem", + "HAS_SOURCE_DETAILS", + ), + NormalizedList( + "SSMInstancePatch", "cve_ids", "SSMInstancePatchCveIdsItem", "HAS_CVE_IDS" + ), + NormalizedList( + "ACMCertificate", "in_use_by", "ACMCertificateInUseByItem", "HAS_IN_USE_BY" + ), + NormalizedList( + "APIGatewayRestAPI", + "anonymous_actions", + "APIGatewayRestAPIAnonymousActionsItem", + "HAS_ANONYMOUS_ACTIONS", + ), + NormalizedList( + "GlueJob", "connections", "GlueJobConnectionsItem", "HAS_CONNECTIONS" + ), + NormalizedList( + "AWSBedrockFoundationModel", + "input_modalities", + "AWSBedrockFoundationModelInputModalitiesItem", + "HAS_INPUT_MODALITIES", + ), + NormalizedList( + "AWSBedrockFoundationModel", + "output_modalities", + "AWSBedrockFoundationModelOutputModalitiesItem", + "HAS_OUTPUT_MODALITIES", + ), + NormalizedList( + "AWSBedrockFoundationModel", + "customizations_supported", + "AWSBedrockFoundationModelCustomizationsSupportedItem", + "HAS_CUSTOMIZATIONS_SUPPORTED", + ), + NormalizedList( + "AWSBedrockFoundationModel", + "inference_types_supported", + "AWSBedrockFoundationModelInferenceTypesSupportedItem", + "HAS_INFERENCE_TYPES_SUPPORTED", + ), +] + + +AWS_CONFIG = ProviderConfig( + name="aws", + root_node_label="AWSAccount", + uid_field="arn", + resource_label="_AWSResource", + ingestion_function=aws.start_aws_ingestion, + short_uid_extractor=aws.extract_short_uid, + normalized_lists=AWS_NORMALIZED_LISTS, +) + + +PROVIDER_CONFIGS: dict[str, ProviderConfig] = { + "aws": AWS_CONFIG, +} diff --git a/api/src/backend/tasks/jobs/attack_paths/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py index 64bbc428f8..1166de17ed 100644 --- a/api/src/backend/tasks/jobs/attack_paths/queries.py +++ b/api/src/backend/tasks/jobs/attack_paths/queries.py @@ -2,8 +2,6 @@ from tasks.jobs.attack_paths.config import ( INTERNET_NODE_LABEL, PROWLER_FINDING_LABEL, - PROVIDER_ELEMENT_ID_PROPERTY, - PROVIDER_RESOURCE_LABEL, ) @@ -21,7 +19,6 @@ def render_cypher_template(template: str, replacements: dict[str, str]) -> str: # Findings queries (used by findings.py) -# --------------------------------------- ADD_RESOURCE_LABEL_TEMPLATE = """ MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r) @@ -32,67 +29,62 @@ ADD_RESOURCE_LABEL_TEMPLATE = """ """ INSERT_FINDING_TEMPLATE = f""" - MATCH (account:__ROOT_NODE_LABEL__ {{id: $provider_uid}}) UNWIND $findings_data AS finding_data - OPTIONAL MATCH (account)-->(resource_by_uid:__RESOURCE_LABEL__) - WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid - WITH account, finding_data, resource_by_uid - - OPTIONAL MATCH (account)-->(resource_by_id:__RESOURCE_LABEL__) + OPTIONAL MATCH (resource_by_uid:__RESOURCE_LABEL__ {{__NODE_UID_FIELD__: finding_data.resource_uid}}) + OPTIONAL MATCH (resource_by_id:__RESOURCE_LABEL__ {{id: finding_data.resource_uid}}) WHERE resource_by_uid IS NULL - AND resource_by_id.id = finding_data.resource_uid - WITH account, finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource - WHERE resource IS NOT NULL + OPTIONAL MATCH (resource_by_short:__RESOURCE_LABEL__ {{id: finding_data.resource_short_uid}}) + WHERE resource_by_uid IS NULL AND resource_by_id IS NULL + WITH finding_data, + resource_by_uid, + resource_by_id, + head(collect(resource_by_short)) AS resource_by_short + WITH finding_data, + COALESCE(resource_by_uid, resource_by_id, resource_by_short) AS resource - MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}}) - ON CREATE SET - finding.id = finding_data.id, - finding.uid = finding_data.uid, - finding.inserted_at = finding_data.inserted_at, - finding.updated_at = finding_data.updated_at, - finding.first_seen_at = finding_data.first_seen_at, - finding.scan_id = finding_data.scan_id, - finding.delta = finding_data.delta, - finding.status = finding_data.status, - finding.status_extended = finding_data.status_extended, - finding.severity = finding_data.severity, - finding.check_id = finding_data.check_id, - finding.check_title = finding_data.check_title, - finding.muted = finding_data.muted, - finding.muted_reason = finding_data.muted_reason, - finding.firstseen = timestamp(), - finding.lastupdated = $last_updated, - finding._module_name = 'cartography:prowler', - finding._module_version = $prowler_version - ON MATCH SET - finding.status = finding_data.status, - finding.status_extended = finding_data.status_extended, - finding.lastupdated = $last_updated + FOREACH (_ IN CASE WHEN resource IS NOT NULL THEN [1] ELSE [] END | + MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}}) + ON CREATE SET + finding.id = finding_data.id, + finding.uid = finding_data.uid, + finding.inserted_at = finding_data.inserted_at, + finding.updated_at = finding_data.updated_at, + finding.first_seen_at = finding_data.first_seen_at, + finding.scan_id = finding_data.scan_id, + finding.delta = finding_data.delta, + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.severity = finding_data.severity, + finding.check_id = finding_data.check_id, + finding.check_title = finding_data.check_title, + finding.muted = finding_data.muted, + finding.muted_reason = finding_data.muted_reason, + finding.firstseen = timestamp(), + finding.lastupdated = $last_updated, + finding._module_name = 'cartography:prowler', + finding._module_version = $prowler_version + ON MATCH SET + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.lastupdated = $last_updated + MERGE (resource)-[rel:HAS_FINDING]->(finding) + ON CREATE SET + rel.firstseen = timestamp(), + rel.lastupdated = $last_updated, + rel._module_name = 'cartography:prowler', + rel._module_version = $prowler_version + ON MATCH SET + rel.lastupdated = $last_updated + ) - MERGE (resource)-[rel:HAS_FINDING]->(finding) - ON CREATE SET - rel.firstseen = timestamp(), - rel.lastupdated = $last_updated, - rel._module_name = 'cartography:prowler', - rel._module_version = $prowler_version - ON MATCH SET - rel.lastupdated = $last_updated -""" + WITH sum(CASE WHEN resource IS NOT NULL THEN 1 ELSE 0 END) AS merged_count, + sum(CASE WHEN resource IS NULL THEN 1 ELSE 0 END) AS dropped_count -CLEANUP_FINDINGS_TEMPLATE = f""" - MATCH (finding:{PROWLER_FINDING_LABEL}) - WHERE finding.lastupdated < $last_updated - - WITH finding LIMIT $batch_size - - DETACH DELETE finding - - RETURN COUNT(finding) AS deleted_findings_count + RETURN merged_count, dropped_count """ # Internet queries (used by internet.py) -# --------------------------------------- CREATE_INTERNET_NODE = f""" MERGE (internet:{INTERNET_NODE_LABEL} {{id: 'Internet'}}) @@ -122,8 +114,8 @@ CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE = f""" RETURN COUNT(r) AS relationships_merged """ -# Sync queries (used by sync.py) -# ------------------------------- +# Sync queries (used by sync.py to fetch from the cartography temp Neo4j DB) +# The write side of sync lives in each sink (`api/attack_paths/sink/`). NODE_FETCH_QUERY = """ MATCH (n) @@ -147,17 +139,3 @@ RELATIONSHIPS_FETCH_QUERY = """ ORDER BY internal_id LIMIT $batch_size """ - -NODE_SYNC_TEMPLATE = f""" - UNWIND $rows AS row - MERGE (n:__NODE_LABELS__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}) - SET n += row.props -""" - -RELATIONSHIP_SYNC_TEMPLATE = f""" - UNWIND $rows AS row - MATCH (s:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.start_element_id}}) - MATCH (t:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.end_element_id}}) - MERGE (s)-[r:__REL_TYPE__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}]->(t) - SET r += row.props -""" diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py index a53a6a530f..13390f09fb 100644 --- a/api/src/backend/tasks/jobs/attack_paths/scan.py +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -39,8 +39,8 @@ Pipeline steps: 7. Sync the temp database into the tenant database: - Drop the old provider subgraph (matched by dynamic _Provider_{uuid} label). - graph_data_ready is set to False for all scans of this provider while - the swap happens so the API doesn't serve partial data. + graph_data_ready is set to False for scans of this provider in the + target sink while the swap happens so the API doesn't serve partial data. - Copy nodes and relationships in batches. Every synced node gets a _ProviderResource label and dynamic _Tenant_{uuid} / _Provider_{uuid} isolation labels, plus a _provider_element_id property for MERGE keys. @@ -57,19 +57,25 @@ import logging import time from typing import Any -from cartography.config import Config as CartographyConfig -from cartography.intel import analysis as cartography_analysis -from cartography.intel import create_indexes as cartography_create_indexes -from cartography.intel import ontology as cartography_ontology -from celery.utils.log import get_task_logger -from tasks.jobs.attack_paths import db_utils, findings, indexes, internet, sync, utils -from tasks.jobs.attack_paths.config import get_cartography_ingestion_function - from api.attack_paths import database as graph_database from api.db_utils import rls_transaction from api.models import Provider as ProwlerAPIProvider from api.models import StateChoices from api.utils import initialize_prowler_provider +from cartography.config import Config as CartographyConfig +from cartography.intel import analysis as cartography_analysis +from cartography.intel import ontology as cartography_ontology +from celery.utils.log import get_task_logger +from django.conf import settings +from tasks.jobs.attack_paths import ( + db_utils, + findings, + indexes, + internet, + sync, + utils, +) +from tasks.jobs.attack_paths.config import get_cartography_ingestion_function # Without this Celery goes crazy with Cartography logging logging.getLogger("cartography").setLevel(logging.ERROR) @@ -96,6 +102,19 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: ) attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id) + # Idempotency guard: cleanup may have flipped this row to a terminal state + # while the message was still in flight. Bail out before touching state + if attack_paths_scan and attack_paths_scan.state in ( + StateChoices.FAILED, + StateChoices.COMPLETED, + StateChoices.CANCELLED, + ): + logger.warning( + f"Attack Paths scan {attack_paths_scan.id} already in terminal " + f"state {attack_paths_scan.state}; skipping execution" + ) + return {} + # Checks before starting the scan if not cartography_ingestion_function: ingestion_exceptions = { @@ -113,12 +132,17 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: else: if not attack_paths_scan: + # Safety net for in-flight messages or direct task invocations; dispatcher normally pre-creates the row logger.warning( f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then" ) attack_paths_scan = db_utils.create_attack_paths_scan( tenant_id, scan_id, prowler_api_provider.id ) + if attack_paths_scan and task_id: + db_utils.set_attack_paths_scan_task_id( + tenant_id, attack_paths_scan.id, task_id + ) tmp_database_name = graph_database.get_database_name( attack_paths_scan.id, temporary=True @@ -126,10 +150,18 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: tenant_database_name = graph_database.get_database_name( prowler_api_provider.tenant_id ) + target_sink_backend = settings.ATTACK_PATHS_SINK_DATABASE + target_description = ( + f"tenant Neo4j database {tenant_database_name}" + if target_sink_backend == "neo4j" + else f"{target_sink_backend} sink" + ) # While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object tmp_cartography_config = CartographyConfig( - neo4j_uri=graph_database.get_uri(), + # The temp ingest database is always Neo4j, so use the ingest URI here + # rather than the sink URI (which points at Neptune when configured). + neo4j_uri=graph_database.get_ingest_uri(), neo4j_database=tmp_database_name, update_tag=int(time.time()), ) @@ -140,8 +172,19 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: ) # Starting the Attack Paths scan - db_utils.starting_attack_paths_scan( - attack_paths_scan, task_id, tenant_cartography_config + if not db_utils.starting_attack_paths_scan( + attack_paths_scan, tenant_cartography_config + ): + logger.warning( + f"Attack Paths scan {attack_paths_scan.id} no longer in SCHEDULED state; cleanup likely raced ahead" + ) + return {} + + scan_t0 = time.perf_counter() + logger.info( + f"Starting Attack Paths scan ({attack_paths_scan.id}) for " + f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id} " + f"(staging=Neo4j database {tmp_database_name}, target={target_description})" ) subgraph_dropped = False @@ -150,7 +193,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: try: logger.info( - f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}" + f"Creating staging Neo4j database {tmp_cartography_config.neo4j_database} " + f"for tenant {prowler_api_provider.tenant_id}" ) graph_database.create_database(tmp_cartography_config.neo4j_database) @@ -164,11 +208,14 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: tmp_cartography_config.neo4j_database ) as tmp_neo4j_session: # Indexes creation - cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config) + indexes.create_cartography_indexes( + tmp_neo4j_session, tmp_cartography_config + ) indexes.create_findings_indexes(tmp_neo4j_session) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2) # The real scan, where iterates over cloud services + t0 = time.perf_counter() ingestion_exceptions = utils.call_within_event_loop( cartography_ingestion_function, tmp_neo4j_session, @@ -177,83 +224,137 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: prowler_sdk_provider, attack_paths_scan, ) + logger.info( + f"Cartography ingestion completed in {time.perf_counter() - t0:.3f}s " + f"(failed_syncs={len(ingestion_exceptions)})" + ) # Post-processing: Just keeping it to be more Cartography compliant logger.info( f"Syncing Cartography ontology for AWS account {prowler_api_provider.uid}" ) cartography_ontology.run(tmp_neo4j_session, tmp_cartography_config) - db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 94) logger.info( f"Syncing Cartography analysis for AWS account {prowler_api_provider.uid}" ) cartography_analysis.run(tmp_neo4j_session, tmp_cartography_config) - db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95) - # Creating Internet node and CAN_ACCESS relationships + # Creating Internet node and `CAN_ACCESS` relationships logger.info( f"Creating Internet graph for AWS account {prowler_api_provider.uid}" ) internet.analysis( tmp_neo4j_session, prowler_api_provider, tmp_cartography_config ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96) # Adding Prowler Finding nodes and relationships logger.info( f"Syncing Prowler analysis for AWS account {prowler_api_provider.uid}" ) - findings.analysis( + t0 = time.perf_counter() + labeled_nodes, findings_loaded = findings.analysis( tmp_neo4j_session, prowler_api_provider, scan_id, tmp_cartography_config ) + logger.info( + f"Prowler analysis completed in {time.perf_counter() - t0:.3f}s " + f"(findings={findings_loaded}, labeled_nodes={labeled_nodes})" + ) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 97) logger.info( - f"Clearing Neo4j cache for database {tmp_cartography_config.neo4j_database}" + f"Clearing Neo4j cache for staging database {tmp_cartography_config.neo4j_database}" ) graph_database.clear_cache(tmp_cartography_config.neo4j_database) + t0 = time.perf_counter() logger.info( - f"Ensuring tenant database {tenant_database_name}, and its indexes, exists for tenant {prowler_api_provider.tenant_id}" + f"Preparing target {target_description} for tenant {prowler_api_provider.tenant_id}" ) graph_database.create_database(tenant_database_name) - with graph_database.get_session(tenant_database_name) as tenant_neo4j_session: - cartography_create_indexes.run( - tenant_neo4j_session, tenant_cartography_config - ) - indexes.create_findings_indexes(tenant_neo4j_session) - indexes.create_sync_indexes(tenant_neo4j_session) + # Sink-side index creation: Neptune auto-manages indexes and rejects + # `CREATE INDEX`, so only run it when the sink is Neo4j + # The temp ingest DB is always Neo4j and is always indexed above + if target_sink_backend != "neptune": + logger.info(f"Ensuring indexes exist for {target_description}") + with graph_database.get_session( + tenant_database_name + ) as tenant_neo4j_session: + indexes.create_cartography_indexes( + tenant_neo4j_session, tenant_cartography_config + ) + indexes.create_findings_indexes(tenant_neo4j_session) + indexes.create_sync_indexes(tenant_neo4j_session) + else: + logger.info("Skipping tenant database indexes for neptune sink") + logger.info( + f"Prepared target {target_description} in {time.perf_counter() - t0:.3f}s" + ) - logger.info(f"Deleting existing provider graph in {tenant_database_name}") - db_utils.set_provider_graph_data_ready(attack_paths_scan, False) + logger.info( + f"Deleting existing provider graph from {target_description} " + f"(tenant={prowler_api_provider.tenant_id}, provider={prowler_api_provider.id})" + ) + db_utils.set_provider_graph_data_ready( + attack_paths_scan, False, target_sink_backend + ) provider_gated = True - graph_database.drop_subgraph( + + t0 = time.perf_counter() + deleted_nodes = graph_database.drop_subgraph( database=tenant_database_name, provider_id=str(prowler_api_provider.id), ) + logger.info( + f"Deleted existing provider graph from {target_description} " + f"in {time.perf_counter() - t0:.3f}s (deleted_nodes={deleted_nodes})" + ) subgraph_dropped = True db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98) logger.info( - f"Syncing graph from {tmp_database_name} into {tenant_database_name}" + f"Syncing staging graph {tmp_database_name} into {target_description} " + f"for provider {prowler_api_provider.id} " + f"(tenant {prowler_api_provider.tenant_id}, " + f"type {prowler_api_provider.provider})" ) - sync.sync_graph( + t0 = time.perf_counter() + sync_result = sync.sync_graph( source_database=tmp_database_name, target_database=tenant_database_name, tenant_id=str(prowler_api_provider.tenant_id), provider_id=str(prowler_api_provider.id), + provider_type=prowler_api_provider.provider, + ) + elapsed = time.perf_counter() - t0 + total_nodes = sync_result["nodes"] + sync_result["child_nodes"] + elements = total_nodes + sync_result["relationships"] + rate = elements / elapsed if elapsed else 0 + logger.info( + f"Synced staging graph into {target_description} in {elapsed:.3f}s - " + f"nodes={total_nodes} (source={sync_result['nodes']}, " + f"items={sync_result['child_nodes']}), " + f"relationships={sync_result['relationships']} " + f"(structural={sync_result['structural_relationships']}, " + f"items={sync_result['item_relationships']}), " + f"~{rate:.0f} elem/s" ) sync_completed = True + # Flip metadata only now: the new schema is live in the target sink, so + # reads can switch to the current catalog/backend. The target-sink gate + # is already closed, so the switch is atomic from the API's view. + db_utils.set_scan_migrated(attack_paths_scan, True, target_sink_backend) db_utils.set_graph_data_ready(attack_paths_scan, True) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99) - logger.info(f"Clearing Neo4j cache for database {tenant_database_name}") - graph_database.clear_cache(tenant_database_name) - - logger.info( - f"Completed Cartography ({attack_paths_scan.id}) for " - f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}" - ) + if target_sink_backend == "neptune": + logger.info("Skipping cache clear for neptune sink") + else: + logger.info(f"Clearing Neo4j cache for target {target_description}") + graph_database.clear_cache(tenant_database_name) logger.info(f"Dropping temporary Neo4j database {tmp_database_name}") graph_database.drop_database(tmp_database_name) @@ -261,6 +362,10 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: db_utils.finish_attack_paths_scan( attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions ) + logger.info( + f"Attack Paths scan completed in {time.perf_counter() - scan_t0:.3f}s " + f"(state=completed, failed_syncs={len(ingestion_exceptions)})" + ) return ingestion_exceptions except Exception as e: @@ -268,14 +373,16 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: logger.exception(exception_message) ingestion_exceptions["global_error"] = exception_message - # Recover graph_data_ready based on how far the swap got. - # Partial drop (mid-batch failure) may leave `subgraph_dropped=False` - # with data partially deleted, so we prefer that over permanently blocked queries. + # Recover `graph_data_ready` based on how far the swap got + # Partial drop (mid-batch failure) may leave `subgraph_dropped=False` with data partially deleted, + # so we prefer that over permanently blocked queries try: if sync_completed: db_utils.set_graph_data_ready(attack_paths_scan, True) elif provider_gated and not subgraph_dropped: - db_utils.set_provider_graph_data_ready(attack_paths_scan, True) + db_utils.set_provider_graph_data_ready( + attack_paths_scan, True, target_sink_backend + ) except Exception: logger.error( diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py index 24ffa6cf48..7b73fa21e2 100644 --- a/api/src/backend/tasks/jobs/attack_paths/sync.py +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -1,40 +1,58 @@ """ Graph sync operations for Attack Paths. -This module handles syncing graph data from temporary scan databases -to the tenant database, adding provider isolation labels and properties. +Reads nodes and relationships out of the cartography temp database (always +Neo4j) and hands them to the configured sink (Neo4j or Neptune) in batches. +Backend-specific Cypher (MERGE shape, ID strategy, indexes) lives in each +sink; this module owns the source read loop, per-batch grouping, and the +list-property materialisation policy (see `NormalizedList`). + +Each list-typed node property that appears in the provider's +`normalized_lists` catalog becomes a set of child item nodes connected to +the parent by a typed edge. A list-typed property that is not in the +catalog is serialised to a comma-delimited string and emits a one-time +warning per (label, property), surfacing Cartography fields that should be +added to the catalog. """ +import json +import time from collections import defaultdict +from collections.abc import Iterator from typing import Any import neo4j +from api.attack_paths import database as graph_database +from api.attack_paths import sink as sink_module from celery.utils.log import get_task_logger from tasks.jobs.attack_paths.config import ( + PROVIDER_CONFIGS, PROVIDER_ISOLATION_PROPERTIES, PROVIDER_RESOURCE_LABEL, SYNC_BATCH_SIZE, + NormalizedList, get_provider_label, get_tenant_label, ) from tasks.jobs.attack_paths.queries import ( NODE_FETCH_QUERY, - NODE_SYNC_TEMPLATE, - RELATIONSHIP_SYNC_TEMPLATE, RELATIONSHIPS_FETCH_QUERY, - render_cypher_template, ) -from api.attack_paths import database as graph_database - logger = get_task_logger(__name__) +# (label, property) tuples for which we've already emitted the +# "unnormalised list" warning. Module-level so the warning fires once per +# process, not once per node. +_WARNED_UNNORMALIZED: set[tuple[str, str]] = set() + def sync_graph( source_database: str, target_database: str, tenant_id: str, provider_id: str, + provider_type: str, ) -> dict[str, int]: """ Sync all nodes and relationships from source to target database. @@ -44,25 +62,38 @@ def sync_graph( `target_database`: The tenant database `tenant_id`: The tenant ID for isolation `provider_id`: The provider ID for isolation + `provider_type`: Provider type key (e.g. "aws"), used to resolve the + `NormalizedList` catalog from `PROVIDER_CONFIGS`. Returns: - Dict with counts of synced nodes and relationships + Dict with counts of synced nodes, child item nodes, and relationships. """ - nodes_synced = sync_nodes( + sink = sink_module.get_backend() + sink.ensure_sync_indexes(target_database) + + normalized_lists = _resolve_normalized_lists(provider_type) + + node_result = sync_nodes( source_database, target_database, tenant_id, provider_id, + sink, + normalized_lists, ) relationships_synced = sync_relationships( source_database, target_database, provider_id, + sink, ) return { - "nodes": nodes_synced, - "relationships": relationships_synced, + "nodes": node_result["parents"], + "child_nodes": node_result["children"], + "relationships": relationships_synced + node_result["parent_child_rels"], + "structural_relationships": relationships_synced, + "item_relationships": node_result["parent_child_rels"], } @@ -71,21 +102,35 @@ def sync_nodes( target_database: str, tenant_id: str, provider_id: str, -) -> int: + sink: Any, + normalized_lists: list[NormalizedList], +) -> dict[str, int]: """ - Sync nodes from source to target database. + Sync nodes from source to target database, exploding catalogued list + properties into child nodes + parent->child edges. Adds `_ProviderResource` label and dynamic `_Tenant_{id}` and `_Provider_{id}` - isolation labels to all nodes. + isolation labels to all nodes (parents and children alike). Source and target sessions are opened sequentially per batch to avoid holding two Bolt connections simultaneously for the entire sync duration. """ + t0 = time.perf_counter() last_id = -1 - total_synced = 0 + parents_synced = 0 + children_synced = 0 + parent_child_rels = 0 + + catalog = _build_catalog_index(normalized_lists) + extra_labels = _build_extra_labels(tenant_id, provider_id) while True: - grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) + tb = time.perf_counter() + prev_children = children_synced + prev_rels = parent_child_rels + parent_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) + child_groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + rel_groups: dict[str, list[dict[str, Any]]] = defaultdict(list) batch_count = 0 with graph_database.get_session(source_database) as source_session: @@ -96,50 +141,75 @@ def sync_nodes( for record in result: batch_count += 1 last_id = record["internal_id"] - key, value = _node_to_sync_dict(record, provider_id) - grouped[key].append(value) + key, parent_dict, children, rels = _node_to_sync_dict( + record, provider_id, catalog + ) + parent_groups[key].append(parent_dict) + for child in children: + child_groups[child["_child_label"]].append(child["row"]) + for rel in rels: + rel_groups[rel["rel_type"]].append(rel["row"]) if batch_count == 0: break - with graph_database.get_session(target_database) as target_session: - for labels, batch in grouped.items(): - label_set = set(labels) - label_set.add(PROVIDER_RESOURCE_LABEL) - label_set.add(get_tenant_label(tenant_id)) - label_set.add(get_provider_label(provider_id)) - node_labels = ":".join(f"`{label}`" for label in sorted(label_set)) + for labels, batch in parent_groups.items(): + rendered_labels = _render_labels(labels, extra_labels) + for sink_batch in _iter_sink_batches(batch): + sink.write_nodes(target_database, rendered_labels, sink_batch) - query = render_cypher_template( - NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels} + for child_label, batch in child_groups.items(): + rendered_labels = _render_labels((child_label,), extra_labels) + for sink_batch in _iter_sink_batches(batch): + sink.write_nodes(target_database, rendered_labels, sink_batch) + children_synced += len(batch) + + for rel_type, batch in rel_groups.items(): + for sink_batch in _iter_sink_batches(batch): + sink.write_relationships( + target_database, rel_type, provider_id, sink_batch ) - target_session.run(query, {"rows": batch}) + parent_child_rels += len(batch) - total_synced += batch_count + parents_synced += batch_count + batch_dt = time.perf_counter() - tb + batch_elements = ( + batch_count + + (children_synced - prev_children) + + (parent_child_rels - prev_rels) + ) + rate = batch_elements / batch_dt if batch_dt else 0 logger.info( - f"Synced {total_synced} nodes from {source_database} to {target_database}" + f"[sync nodes] {parents_synced} source (+{children_synced} items, " + f"+{parent_child_rels} item rels) · batch {batch_dt:.1f}s · " + f"elapsed {time.perf_counter() - t0:.1f}s · ~{rate:.0f} elem/s" ) - return total_synced + return { + "parents": parents_synced, + "children": children_synced, + "parent_child_rels": parent_child_rels, + } def sync_relationships( source_database: str, target_database: str, provider_id: str, + sink: Any, ) -> int: """ Sync relationships from source to target database. - Matches source and target nodes by `_provider_element_id` in the tenant database. - Source and target sessions are opened sequentially per batch to avoid holding two Bolt connections simultaneously for the entire sync duration. """ + t0 = time.perf_counter() last_id = -1 total_synced = 0 while True: + tb = time.perf_counter() grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) batch_count = 0 @@ -157,32 +227,213 @@ def sync_relationships( if batch_count == 0: break - with graph_database.get_session(target_database) as target_session: - for rel_type, batch in grouped.items(): - query = render_cypher_template( - RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type} + for rel_type, batch in grouped.items(): + for sink_batch in _iter_sink_batches(batch): + sink.write_relationships( + target_database, rel_type, provider_id, sink_batch ) - target_session.run(query, {"rows": batch}) total_synced += batch_count + batch_dt = time.perf_counter() - tb + rate = batch_count / batch_dt if batch_dt else 0 logger.info( - f"Synced {total_synced} relationships from {source_database} to {target_database}" + f"[sync rels] {total_synced} structural · batch {batch_dt:.1f}s · " + f"elapsed {time.perf_counter() - t0:.1f}s · ~{rate:.0f}/s" ) return total_synced +def _iter_sink_batches( + rows: list[dict[str, Any]], + batch_size: int | None = None, +) -> Iterator[list[dict[str, Any]]]: + """Yield final sink write batches after source rows have been transformed.""" + batch_size = SYNC_BATCH_SIZE if batch_size is None else batch_size + if batch_size <= 0: + raise ValueError("Sink batch size must be greater than zero") + + for index in range(0, len(rows), batch_size): + yield rows[index : index + batch_size] + + def _node_to_sync_dict( - record: neo4j.Record, provider_id: str -) -> tuple[tuple[str, ...], dict[str, Any]]: - """Transform a source node record into a (grouping_key, sync_dict) pair.""" + record: neo4j.Record, + provider_id: str, + catalog: dict[tuple[str, str], NormalizedList], +) -> tuple[ + tuple[str, ...], + dict[str, Any], + list[dict[str, Any]], + list[dict[str, Any]], +]: + """Transform a source node record into a (grouping_key, sync_dict, children, rels) tuple. + + Catalogued list properties are popped from `props` and emitted as child + nodes + parent->child relationships. + """ props = dict(record["props"] or {}) _strip_internal_properties(props) labels = tuple(sorted(set(record["labels"] or []))) - return labels, { - "provider_element_id": f"{provider_id}:{record['element_id']}", + parent_element_id = f"{provider_id}:{record['element_id']}" + + children, rels = _explode_catalogued_lists( + labels, props, catalog, provider_id, parent_element_id + ) + + _normalize_sink_properties(props, labels) + + parent = { + "provider_element_id": parent_element_id, "props": props, } + return labels, parent, children, rels + + +def _explode_catalogued_lists( + labels: tuple[str, ...], + props: dict[str, Any], + catalog: dict[tuple[str, str], NormalizedList], + provider_id: str, + parent_element_id: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Pop catalogued list properties from `props` and produce child + rel emits. + + A node may carry multiple labels (e.g. `AWSPolicyStatement` plus + `_AWSResource`); we check each label for catalog matches independently. + Returns: + - children: list of {"_child_label": str, "row": } dicts. + - rels: list of {"rel_type": str, "row": } dicts. + """ + children: list[dict[str, Any]] = [] + rels: list[dict[str, Any]] = [] + + for label in labels: + for key in list(props.keys()): + spec = catalog.get((label, key)) + if spec is None: + continue + value = props.pop(key) + if value is None: + continue + if not isinstance(value, list): + # Catalogued but not actually a list this scan - fall back to + # the generic normaliser so we don't lose the value. + props[key] = value + continue + for item in value: + child_value_key, child_props = _build_child_props(spec, item) + if child_value_key is None: + continue + child_element_id = _build_child_id( + provider_id, spec.child_label, child_value_key + ) + children.append( + { + "_child_label": spec.child_label, + "row": { + "provider_element_id": child_element_id, + "props": child_props, + }, + } + ) + rels.append( + { + "rel_type": spec.rel_type, + "row": { + "start_element_id": parent_element_id, + "end_element_id": child_element_id, + "provider_element_id": ( + f"{parent_element_id}::{spec.rel_type}::" + f"{child_element_id}" + ), + "props": {}, + }, + } + ) + + return children, rels + + +def _build_child_props( + spec: NormalizedList, item: Any +) -> tuple[str | None, dict[str, Any]]: + """Translate one list element into a child node's prop dict. + + Returns (dedup_key, props). The dedup_key is what makes two child nodes + equal within (tenant, provider) - used to build `_provider_element_id`. + For scalar mode, the dedup key is the value itself. For dict mode it is + a stable concatenation of the mapped fields in `field_map` order. + """ + if not spec.field_map: + if isinstance(item, (dict, list)): + # Defensive: caller marked this list as scalar but elements are + # structured. Convert to a stable string so the value survives. + value_str = json.dumps(item, sort_keys=True, default=str) + else: + value_str = str(item) + return value_str, {"value": value_str} + + if not isinstance(item, dict): + # Catalogued as dict-shape but got a scalar. Skip - caller will see + # the value go missing and can fix the field_map. + return None, {} + + props: dict[str, Any] = {} + dedup_parts: list[str] = [] + for src_key, child_field in spec.field_map: + raw = item.get(src_key) + value_str = _to_sink_property_value(raw) if raw is not None else "" + props[child_field] = value_str + dedup_parts.append(f"{child_field}={value_str}") + return "::".join(dedup_parts), props + + +def _build_child_id(provider_id: str, child_label: str, value_key: str) -> str: + """Deterministic `_provider_element_id` for a list-item child node. + + Dedupes within (tenant, provider): multiple parents referencing the same + value share one child node via the existing MERGE-on-_provider_element_id + index in both sinks. + """ + return f"{provider_id}::{child_label}::{value_key}" + + +def _build_catalog_index( + normalized_lists: list[NormalizedList], +) -> dict[tuple[str, str], NormalizedList]: + """Index the catalog by (source_label, source_property) for O(1) lookup.""" + return { + (spec.source_label, spec.source_property): spec for spec in normalized_lists + } + + +def _build_extra_labels(tenant_id: str, provider_id: str) -> tuple[str, ...]: + return ( + PROVIDER_RESOURCE_LABEL, + get_tenant_label(tenant_id), + get_provider_label(provider_id), + ) + + +def _render_labels(base_labels: tuple[str, ...], extra_labels: tuple[str, ...]) -> str: + """Render the Cypher label string for a node-write batch.""" + label_set = set(base_labels) | set(extra_labels) + return ":".join(f"`{label}`" for label in sorted(label_set)) + + +def _resolve_normalized_lists(provider_type: str) -> list[NormalizedList]: + config = PROVIDER_CONFIGS.get(provider_type) + if config is None: + # Unknown provider: empty catalog. Any list-typed property will be + # serialised to a comma-delimited string with one warning per + # (label, property). + logger.warning( + "Provider type %s not in PROVIDER_CONFIGS; no normalized_lists active", + provider_type, + ) + return [] + return config.normalized_lists def _rel_to_sync_dict( @@ -191,7 +442,11 @@ def _rel_to_sync_dict( """Transform a source relationship record into a (grouping_key, sync_dict) pair.""" props = dict(record["props"] or {}) _strip_internal_properties(props) + # Relationship properties go through the same primitive coercion as + # nodes; catalog-driven materialisation applies to node properties only. + _normalize_sink_properties(props, labels=None) rel_type = record["rel_type"] + return rel_type, { "start_element_id": f"{provider_id}:{record['start_element_id']}", "end_element_id": f"{provider_id}:{record['end_element_id']}", @@ -204,3 +459,80 @@ def _strip_internal_properties(props: dict[str, Any]) -> None: """Remove provider isolation properties before the += spread in sync templates.""" for key in PROVIDER_ISOLATION_PROPERTIES: props.pop(key, None) + + +def _normalize_sink_properties( + props: dict[str, Any], labels: tuple[str, ...] | None +) -> None: + """Normalize property values to primitive Cypher literals for either sink. + + Attack-paths node and relationship properties are written as primitive + scalars regardless of the active sink (Neo4j or Neptune). The convention + is driven by Neptune's openCypher type restrictions, which reject list, + map, temporal and spatial property values, but it is applied uniformly + so that custom and predefined queries are portable across sinks without + runtime rewriting. + + Concretely: + - Temporal values (neo4j.time.{DateTime,Date,Time,Duration}) become + their ISO-8601 string representation. + - Spatial values (neo4j.spatial.Point and subclasses) become their + WKT-style string representation. + - Maps / dicts become a JSON-encoded string, read back with `CONTAINS` + substring checks inside queries. + - Lists become a comma-delimited string. Catalogued list properties + are materialised as child item nodes upstream in + `_explode_catalogued_lists` and never reach this point; any list + seen here is uncatalogued, so we log a one-time warning per + (label, property) to surface Cartography fields that should be + added to the catalog. + + `labels` is only used for the warning message; pass `None` for + relationship props (no label context). + """ + for key, value in list(props.items()): + if isinstance(value, list) and labels is not None: + _warn_unnormalized_list(labels, key) + props[key] = _to_sink_property_value(value) + + +def _warn_unnormalized_list(labels: tuple[str, ...], key: str) -> None: + """Warn once per (label, property), on the real label(s) only. + + Every synced node also carries internal isolation labels (`_AWSResource`, + `_ProviderResource`, `_Tenant_*`, `_Provider_*`); warning on those just + doubles the noise, so skip them and point at the actionable Cartography + label. Falls back to all labels if only internal ones are present. + """ + real_labels = [label for label in labels if not label.startswith("_")] + for label in real_labels or labels: + token = (label, key) + if token in _WARNED_UNNORMALIZED: + continue + _WARNED_UNNORMALIZED.add(token) + logger.warning( + "Unnormalized list property %s.%s reached sink as comma-string; " + "add a NormalizedList entry to the provider catalog to explode it", + label, + key, + ) + + +def _to_sink_property_value(value: Any) -> Any: + if hasattr(value, "iso_format") and callable(value.iso_format): + return value.iso_format() + + if type(value).__module__.startswith("neo4j.spatial"): + return str(value) + + if isinstance(value, dict): + # openCypher `SET` rejects map property values: encode as JSON so the structured payload + # survives the round-trip and is queryable with `CONTAINS` substring checks + return json.dumps(value, sort_keys=True, default=str) + + if isinstance(value, list): + # openCypher `SET` rejects list/array property values: encode as a + # delimited string read back with split() inside queries + return ",".join(str(_to_sink_property_value(v)) for v in value) + + return value diff --git a/api/src/backend/tasks/jobs/attack_paths/utils.py b/api/src/backend/tasks/jobs/attack_paths/utils.py index eef5670782..50d670bfd3 100644 --- a/api/src/backend/tasks/jobs/attack_paths/utils.py +++ b/api/src/backend/tasks/jobs/attack_paths/utils.py @@ -1,7 +1,6 @@ import asyncio import traceback - -from datetime import datetime, timezone +from datetime import UTC, datetime from celery.utils.log import get_task_logger @@ -10,7 +9,7 @@ logger = get_task_logger(__name__) def stringify_exception(exception: Exception, context: str) -> str: """Format an exception with timestamp and traceback for logging.""" - timestamp = datetime.now(tz=timezone.utc) + timestamp = datetime.now(tz=UTC) exception_traceback = traceback.TracebackException.from_exception(exception) traceback_string = "".join(exception_traceback.format()) return f"{timestamp} - {context}\n{traceback_string}" diff --git a/api/src/backend/tasks/jobs/backfill.py b/api/src/backend/tasks/jobs/backfill.py index ff43fb33b3..56cb626786 100644 --- a/api/src/backend/tasks/jobs/backfill.py +++ b/api/src/backend/tasks/jobs/backfill.py @@ -1,19 +1,6 @@ from collections import defaultdict from datetime import timedelta -from celery.utils.log import get_task_logger -from django.db.models import OuterRef, Subquery, Sum -from django.utils import timezone -from tasks.jobs.queries import ( - COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, - COMPLIANCE_UPSERT_TENANT_SUMMARY_ALL_SQL, -) -from tasks.jobs.scan import ( - aggregate_category_counts, - aggregate_finding_group_summaries, - aggregate_resource_group_counts, -) - from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import ( POSTGRES_TENANT_VAR, @@ -36,6 +23,18 @@ from api.models import ( ScanSummary, StateChoices, ) +from celery.utils.log import get_task_logger +from django.db.models import OuterRef, Subquery, Sum +from django.utils import timezone +from tasks.jobs.queries import ( + COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, + COMPLIANCE_UPSERT_TENANT_SUMMARY_ALL_SQL, +) +from tasks.jobs.scan import ( + aggregate_category_counts, + aggregate_finding_group_summaries, + aggregate_resource_group_counts, +) logger = get_task_logger(__name__) @@ -297,12 +296,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 +314,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 +334,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 +347,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 +388,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 +425,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 +443,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/connection.py b/api/src/backend/tasks/jobs/connection.py index d7068ebf3b..3ae20c96a0 100644 --- a/api/src/backend/tasks/jobs/connection.py +++ b/api/src/backend/tasks/jobs/connection.py @@ -1,13 +1,12 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime import openai -from celery.utils.log import get_task_logger - from api.models import Integration, LighthouseConfiguration, Provider from api.utils import ( prowler_integration_connection_test, prowler_provider_connection_test, ) +from celery.utils.log import get_task_logger logger = get_task_logger(__name__) @@ -38,7 +37,7 @@ def check_provider_connection(provider_id: str): raise e provider_instance.connected = connection_result.is_connected - provider_instance.connection_last_checked_at = datetime.now(tz=timezone.utc) + provider_instance.connection_last_checked_at = datetime.now(tz=UTC) provider_instance.save() connection_error = f"{connection_result.error}" if connection_result.error else None @@ -111,7 +110,7 @@ def check_integration_connection(integration_id: str): # Update integration connection status integration.connected = result.is_connected - integration.connection_last_checked_at = datetime.now(tz=timezone.utc) + integration.connection_last_checked_at = datetime.now(tz=UTC) integration.save() return { diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index f9ead01897..91e64610f7 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -1,11 +1,5 @@ -from celery.utils.log import get_task_logger -from django.db import DatabaseError -from tasks.jobs.queries import ( - COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL, - COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL, -) - from api.attack_paths import database as graph_database +from api.attack_paths import sink as sink_module from api.db_router import MainRouter from api.db_utils import batch_delete, rls_transaction from api.models import ( @@ -18,6 +12,12 @@ from api.models import ( ScanSummary, Tenant, ) +from celery.utils.log import get_task_logger +from django.db import DatabaseError +from tasks.jobs.queries import ( + COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL, + COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL, +) logger = get_task_logger(__name__) @@ -77,6 +77,12 @@ def delete_provider(tenant_id: str, pk: str): "id", flat=True ) ) + attack_paths_sink_backends = list( + AttackPathsScan.all_objects.filter(provider=instance) + .values_list("sink_backend", flat=True) + .distinct() + .order_by("sink_backend") + ) deletion_steps = [ ("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)), @@ -98,7 +104,13 @@ def delete_provider(tenant_id: str, pk: str): # Delete the Attack Paths' graph data related to the provider from the tenant database tenant_database_name = graph_database.get_database_name(tenant_id) try: - graph_database.drop_subgraph(tenant_database_name, str(pk)) + if attack_paths_sink_backends: + for sink_backend in attack_paths_sink_backends: + sink_module.get_backend_for_name(sink_backend).drop_subgraph( + tenant_database_name, str(pk) + ) + else: + graph_database.drop_subgraph(tenant_database_name, str(pk)) except graph_database.GraphDatabaseQueryException as gdb_error: logger.error(f"Error deleting Provider graph data: {gdb_error}") diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 4b8498f7e7..e658d6018c 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -4,12 +4,11 @@ import zipfile import boto3 import config.django.base as base +from api.db_utils import rls_transaction +from api.models import Scan from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError from celery.utils.log import get_task_logger from django.conf import settings - -from api.db_utils import rls_transaction -from api.models import Scan from prowler.config.config import ( csv_file_suffix, html_file_suffix, @@ -18,6 +17,9 @@ from prowler.config.config import ( set_output_timestamp, ) from prowler.lib.outputs.asff.asff import ASFF +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws import ( + ASDEssentialEightAWS, +) from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected import ( AWSWellArchitected, ) @@ -32,14 +34,13 @@ from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS +from prowler.lib.outputs.compliance.cis.cis_googleworkspace import GoogleWorkspaceCIS from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS -from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA -from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA -from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA -from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA -from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA +from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import ( + GoogleWorkspaceCISASCuBA, +) from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS @@ -56,6 +57,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -93,19 +97,18 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name.startswith("iso27001_"), AWSISO27001), (lambda name: name.startswith("kisa"), AWSKISAISMSP), (lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS), - (lambda name: name == "ccc_aws", CCC_AWS), + (lambda name: name.startswith("ccc_"), CCC_AWS), (lambda name: name.startswith("c5_"), AWSC5), - (lambda name: name.startswith("csa_"), AWSCSA), + (lambda name: name == "asd_essential_eight_aws", ASDEssentialEightAWS), ], "azure": [ (lambda name: name.startswith("cis_"), AzureCIS), (lambda name: name == "mitre_attack_azure", AzureMitreAttack), (lambda name: name.startswith("ens_"), AzureENS), (lambda name: name.startswith("iso27001_"), AzureISO27001), - (lambda name: name == "ccc_azure", CCC_Azure), + (lambda name: name.startswith("ccc_"), CCC_Azure), (lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure), (lambda name: name == "c5_azure", AzureC5), - (lambda name: name.startswith("csa_"), AzureCSA), ], "gcp": [ (lambda name: name.startswith("cis_"), GCPCIS), @@ -113,9 +116,8 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name.startswith("ens_"), GCPENS), (lambda name: name.startswith("iso27001_"), GCPISO27001), (lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP), - (lambda name: name == "ccc_gcp", CCC_GCP), + (lambda name: name.startswith("ccc_"), CCC_GCP), (lambda name: name == "c5_gcp", GCPC5), - (lambda name: name.startswith("csa_"), GCPCSA), ], "kubernetes": [ (lambda name: name.startswith("cis_"), KubernetesCIS), @@ -133,6 +135,10 @@ COMPLIANCE_CLASS_MAP = { "github": [ (lambda name: name.startswith("cis_"), GithubCIS), ], + "googleworkspace": [ + (lambda name: name.startswith("cis_"), GoogleWorkspaceCIS), + (lambda name: name.startswith("cisa_scuba_"), GoogleWorkspaceCISASCuBA), + ], "iac": [ # IaC provider doesn't have specific compliance frameworks yet # Trivy handles its own compliance checks @@ -140,16 +146,17 @@ COMPLIANCE_CLASS_MAP = { "image": [], "oraclecloud": [ (lambda name: name.startswith("cis_"), OracleCloudCIS), - (lambda name: name.startswith("csa_"), OracleCloudCSA), ], "alibabacloud": [ (lambda name: name.startswith("cis_"), AlibabaCloudCIS), - (lambda name: name.startswith("csa_"), AlibabaCloudCSA), ( lambda name: name == "prowler_threatscore_alibabacloud", ProwlerThreatScoreAlibaba, ), ], + "okta": [ + (lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG), + ], } diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 5ca94057da..25722686cc 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -2,15 +2,13 @@ import os import time from glob import glob -from celery.utils.log import get_task_logger -from config.django.base import DJANGO_FINDINGS_BATCH_SIZE -from django.db import OperationalError -from tasks.utils import batched - from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction from api.models import Finding, Integration, Provider from api.utils import initialize_prowler_integration, initialize_prowler_provider +from celery.utils.log import get_task_logger +from config.django.base import DJANGO_FINDINGS_BATCH_SIZE +from django.db import OperationalError from prowler.lib.outputs.asff.asff import ASFF from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.csv.csv import CSV @@ -24,6 +22,7 @@ from prowler.providers.aws.lib.security_hub.exceptions.exceptions import ( ) from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub from prowler.providers.common.models import Connection +from tasks.utils import batched logger = get_task_logger(__name__) diff --git a/api/src/backend/tasks/jobs/lighthouse_providers.py b/api/src/backend/tasks/jobs/lighthouse_providers.py index 29e36e5e57..0f28725e01 100644 --- a/api/src/backend/tasks/jobs/lighthouse_providers.py +++ b/api/src/backend/tasks/jobs/lighthouse_providers.py @@ -1,14 +1,11 @@ -from typing import Dict - import boto3 import openai +from api.models import LighthouseProviderConfiguration, LighthouseProviderModels from botocore import UNSIGNED from botocore.config import Config from botocore.exceptions import BotoCoreError, ClientError from celery.utils.log import get_task_logger -from api.models import LighthouseProviderConfiguration, LighthouseProviderModels - logger = get_task_logger(__name__) # OpenAI model prefixes to exclude from Lighthouse model selection. @@ -104,7 +101,7 @@ def _extract_openai_api_key( def _extract_openai_compatible_params( provider_cfg: LighthouseProviderConfiguration, -) -> Dict[str, str] | None: +) -> dict[str, str] | None: """ Extract base_url and api_key for OpenAI-compatible providers. """ @@ -122,7 +119,7 @@ def _extract_openai_compatible_params( def _extract_bedrock_credentials( provider_cfg: LighthouseProviderConfiguration, -) -> Dict[str, str] | None: +) -> dict[str, str] | None: """ Safely extract AWS Bedrock credentials from a provider configuration. @@ -177,7 +174,7 @@ def _extract_bedrock_credentials( def _create_bedrock_client( - bedrock_creds: Dict[str, str], service_name: str = "bedrock" + bedrock_creds: dict[str, str], service_name: str = "bedrock" ): """ Create a boto3 Bedrock client with the appropriate authentication method. @@ -221,7 +218,7 @@ def _create_bedrock_client( ) -def check_lighthouse_provider_connection(provider_config_id: str) -> Dict: +def check_lighthouse_provider_connection(provider_config_id: str) -> dict: """ Validate a Lighthouse provider configuration by calling the provider API and toggle its active state accordingly. @@ -314,7 +311,7 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> Dict: return {"connected": False, "error": error_message} -def _fetch_openai_models(api_key: str) -> Dict[str, str]: +def _fetch_openai_models(api_key: str) -> dict[str, str]: """ Fetch available models from OpenAI API. @@ -355,7 +352,7 @@ def _fetch_openai_models(api_key: str) -> Dict[str, str]: return filtered_models -def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict[str, str]: +def _fetch_openai_compatible_models(base_url: str, api_key: str) -> dict[str, str]: """ Fetch available models from an OpenAI-compatible API using the OpenAI SDK. @@ -367,7 +364,7 @@ def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict[str, st client = openai.OpenAI(api_key=api_key, base_url=base_url) models = client.models.list() - available_models: Dict[str, str] = {} + available_models: dict[str, str] = {} for model in models.data: model_id = model.id # Prefer provider-supplied human-friendly name when available @@ -462,7 +459,7 @@ def _extract_foundation_model_ids(profile_models: list) -> list[str]: def _build_inference_profile_map( bedrock_client, region: str -) -> Dict[str, tuple[str, str]]: +) -> dict[str, tuple[str, str]]: """ Build map of foundation_model_id -> best inference profile. @@ -472,7 +469,7 @@ def _build_inference_profile_map( Prefers region-matched profiles over others """ region_prefix = _get_region_prefix(region) - model_to_profile: Dict[str, tuple[str, str]] = {} + model_to_profile: dict[str, tuple[str, str]] = {} try: response = bedrock_client.list_inference_profiles() @@ -533,7 +530,7 @@ def _check_on_demand_availability(bedrock_client, model_id: str) -> bool: return False -def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]: +def _fetch_bedrock_models(bedrock_creds: dict[str, str]) -> dict[str, str]: """ Fetch available models from AWS Bedrock, preferring inference profiles over ON_DEMAND. @@ -560,7 +557,7 @@ def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]: foundation_response = bedrock_client.list_foundation_models() model_summaries = foundation_response.get("modelSummaries", []) - models_to_return: Dict[str, str] = {} + models_to_return: dict[str, str] = {} on_demand_models: set[str] = set() for model in model_summaries: @@ -585,7 +582,7 @@ def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]: models_to_return[model_id] = model_name on_demand_models.add(model_id) - available_models: Dict[str, str] = {} + available_models: dict[str, str] = {} for model_id, model_name in models_to_return.items(): if model_id in on_demand_models: @@ -597,7 +594,7 @@ def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]: return available_models -def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict: +def refresh_lighthouse_provider_models(provider_config_id: str) -> dict: """ Refresh the catalog of models for a Lighthouse provider configuration. @@ -619,7 +616,7 @@ def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict: LighthouseProviderConfiguration.DoesNotExist: If no configuration exists with the given ID. """ provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id) - fetched_models: Dict[str, str] = {} + fetched_models: dict[str, str] = {} try: if ( diff --git a/api/src/backend/tasks/jobs/muting.py b/api/src/backend/tasks/jobs/muting.py index 6ef4d127f5..12a32ac574 100644 --- a/api/src/backend/tasks/jobs/muting.py +++ b/api/src/backend/tasks/jobs/muting.py @@ -1,10 +1,9 @@ +from api.db_utils import rls_transaction +from api.models import Finding, MuteRule from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE from tasks.utils import batched -from api.db_utils import rls_transaction -from api.models import Finding, MuteRule - logger = get_task_logger(__name__) diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py new file mode 100644 index 0000000000..7211f1a1d5 --- /dev/null +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -0,0 +1,341 @@ +"""Detect and recover orphaned Celery tasks. + +A task is "orphaned" when its result row is non-terminal (STARTED/RECEIVED) but the +worker that was running it is gone (deploy, OOM, eviction). We tell a real orphan +from a still-running task by pinging the worker recorded on its `TaskResult`: + +- worker responds -> the task is in flight, leave it alone (never double-run); +- worker is gone -> real orphan: mark the stale result terminal (so pending/started + alerts clear), then re-enqueue the task from its stored name + kwargs. + +This recovers only allowlisted tasks with local, proven idempotency. Celery's +`result_extended=True` gives us the stored `task_name`/`task_kwargs`/`worker` once +the task starts, but external side-effect tasks are failed instead of blindly +re-run. A small recovery cap stops a task that repeatedly kills its worker from +looping forever. + +This is the shared engine behind both the periodic Beat watchdog and the +`reconcile_orphan_tasks` management command. +""" + +import ast +import json +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from celery import current_app, states +from celery.utils.log import get_task_logger +from django.db import connections + +logger = get_task_logger(__name__) + +# Arbitrary constant key for pg_try_advisory_lock so only one reconciliation +# runs at a time across replicas / the watchdog / the command. +ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow" + +# Non-terminal states that mean "a worker had this and may have died with it". +IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED) + +# Tasks with proven idempotency are eligible for auto re-enqueue, grouped so each +# group can be toggled independently by a feature flag (see config.django.base). +# Summaries clear and rewrite their own rows and deletions are idempotent. Tasks with +# external side effects are never eligible: integration-jira would create duplicate +# issues, integration-s3 rebuilds its upload from worker-local files that do not +# survive a crash, and report/Security Hub recovery is out of scope. +RECOVERY_TASK_GROUPS = { + "summaries": { + "scan-summary", + "scan-compliance-overviews", + "scan-provider-compliance-scores", + "scan-daily-severity", + "scan-finding-group-summaries", + "scan-reset-ephemeral-resources", + }, + "deletions": {"provider-deletion", "tenant-deletion"}, +} + + +def reenqueueable_tasks() -> set[str]: + """Task names eligible for auto re-enqueue, honoring the per-group feature flags. + + A group whose flag is disabled is dropped, so its orphaned tasks are marked + terminal instead of re-enqueued. + """ + from django.conf import settings + + group_enabled = { + "summaries": settings.TASK_RECOVERY_SUMMARIES_ENABLED, + "deletions": settings.TASK_RECOVERY_DELETIONS_ENABLED, + } + return { + task + for group, tasks in RECOVERY_TASK_GROUPS.items() + if group_enabled[group] + for task in tasks + } + + +# Tasks the watchdog ignores entirely (not even marked terminal): scan tasks are not +# auto-recovered, since re-running a scan is not safe to do automatically; attack-paths +# scans are handled by their own stale-cleanup (which also drops the temp Neo4j db); +# and the maintenance tasks must not self-recover (they run again on their own schedule). +_SKIP_RECOVERY = { + "scan-perform", + "scan-perform-scheduled", + "attack-paths-scan-perform", + "attack-paths-cleanup-stale-scans", + "reconcile-orphan-tasks", +} + + +@contextmanager +def advisory_lock(key: int = ORPHAN_RECOVERY_LOCK_KEY, using: str = "default"): + """Yield True if this session won a Postgres advisory lock, else False. + + Non-blocking: losers get False and should no-op. The lock is released on + exit (and implicitly if the session dies). + """ + with connections[using].cursor() as cursor: + cursor.execute("SELECT pg_try_advisory_lock(%s)", [key]) + acquired = bool(cursor.fetchone()[0]) + try: + yield acquired + finally: + if acquired: + cursor.execute("SELECT pg_advisory_unlock(%s)", [key]) + + +def is_worker_alive(worker: str, timeout: float = 1.0) -> bool: + """Ping a specific Celery worker. Returns True if it responds, or on error. + + Erring on the side of "alive" means an unreachable control bus never causes + a still-running task to be re-enqueued. + """ + try: + response = current_app.control.inspect( + destination=[worker], timeout=timeout + ).ping() + return response is not None and worker in response + except Exception: + logger.exception(f"Failed to ping worker {worker}, treating as alive") + return True + + +def revoke_task(task_result, terminate: bool = True) -> None: + """Revoke a Celery task by its TaskResult. Non-fatal on failure. + + terminate=True SIGTERMs the worker if the task is mid-execution; terminate=False + only marks the id revoked so any worker pulling the queued message discards it + (use before re-enqueuing, so a later broker redelivery of the stale message is + dropped). + """ + try: + kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {} + current_app.control.revoke(task_result.task_id, **kwargs) + logger.info(f"Revoked task {task_result.task_id}") + except Exception: + logger.exception(f"Failed to revoke task {task_result.task_id}") + + +def _decode_celery_field(value, default): + """Decode django-celery-results' stored task_args/task_kwargs to a Python object. + + The backend stores them as a (sometimes double-encoded) repr/JSON string. An + empty or missing field returns ``default``; a non-empty value that cannot be + decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with + the wrong arguments. + """ + obj = value + for _ in range(2): # values can be double-encoded (a string holding a repr) + if not isinstance(obj, str): + break + text = obj.strip() + if not text: + return default + parsed = None + for parser in (ast.literal_eval, json.loads): + try: + parsed = parser(text) + break + except (ValueError, SyntaxError, TypeError): + continue + if parsed is None: + raise ValueError(f"undecodable celery field: {text[:120]!r}") + obj = parsed + return default if obj is None else obj + + +def reconcile_orphans( + grace_minutes: int = 2, + max_attempts: int = 3, + window_hours: int = 6, + dry_run: bool = False, +) -> dict: + """Run the full orphan sweep under a single-flight advisory lock. + + Recovers any orphaned in-flight task and delegates attack-paths scans that + never reached a worker to their existing stale-cleanup. Returns a summary; + a no-op (lock not won) is reported too. + """ + with advisory_lock() as acquired: + if not acquired: + logger.info("Orphan reconcile skipped: another run holds the lock") + return {"acquired": False} + + from django.conf import settings + + if settings.TASK_RECOVERY_ENABLED: + # Populate the task registry so we can re-enqueue any task by name. + import tasks.tasks # noqa: F401 + + result = _reconcile_task_results( + grace_minutes=grace_minutes, + max_attempts=max_attempts, + window_hours=window_hours, + dry_run=dry_run, + ) + result["enabled"] = True + else: + logger.info("Orphan task recovery disabled by feature flag") + result = {"recovered": [], "failed": [], "skipped": [], "enabled": False} + + if not dry_run: + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + result["attack_paths"] = cleanup_stale_attack_paths_scans() + + return {"acquired": True, **result} + + +def _reconcile_task_results( + grace_minutes: int, max_attempts: int, window_hours: int, dry_run: bool +) -> dict: + from django_celery_results.models import TaskResult + + cutoff = datetime.now(tz=UTC) - timedelta(minutes=grace_minutes) + candidates = list( + TaskResult.objects.filter(status__in=IN_FLIGHT_STATES, date_created__lt=cutoff) + .exclude(worker__isnull=True) + .exclude(worker="") + .exclude(task_name__in=_SKIP_RECOVERY) + ) + + # Ping each distinct worker at most once. + worker_alive = {w: is_worker_alive(w) for w in {tr.worker for tr in candidates}} + + recovered, failed, skipped = [], [], [] + for task_result in candidates: + if worker_alive.get(task_result.worker, True): + skipped.append(task_result.task_id) # in flight, do not double-run + continue + if dry_run: + recovered.append(task_result.task_id) + continue + outcome = _recover_task(task_result, max_attempts, window_hours) + (recovered if outcome == "recovered" else failed).append(task_result.task_id) + + logger.info( + "Orphan reconcile: recovered=%d failed=%d skipped(in-flight)=%d", + len(recovered), + len(failed), + len(skipped), + ) + return {"recovered": recovered, "failed": failed, "skipped": skipped} + + +def _recovery_attempt_count(name: str, kwargs_repr, window_hours: int) -> int: + """Increment and return the recovery count for this (task, kwargs) within the + window. Backed by Valkey so it survives result-row churn (a worker processing + the revoke can blank the TaskResult fields). Fail-open if Valkey is down (the + broker being unreachable means nothing is running anyway). + """ + import hashlib + + from django.conf import settings + + try: + import redis + + client = redis.from_url(settings.CELERY_BROKER_URL) + signature = f"{name}|{kwargs_repr}".encode() + key = ( + "orphan-recovery:" + + hashlib.sha1(signature, usedforsecurity=False).hexdigest() + ) + count = client.incr(key) + if count == 1: + client.expire(key, max(1, window_hours) * 3600) + return int(count) + except Exception: + logger.exception("Recovery-attempt counter unavailable; allowing recovery") + return 1 + + +def _recover_task(task_result, max_attempts: int, window_hours: int) -> str: + """Recover one orphaned task. Returns 'recovered' or 'failed'.""" + # Capture name/args/kwargs now: revoking can let a worker blank the row. + name = task_result.task_name + args_repr = task_result.task_args + kwargs_repr = task_result.task_kwargs + now = datetime.now(tz=UTC) + + # Drop any future broker redelivery of the stale message. + revoke_task(task_result, terminate=False) + + # Mark the stale result terminal so "pending/started forever" alerts clear. + task_result.status = states.REVOKED + task_result.date_done = now + task_result.save(update_fields=["status", "date_done"]) + + if name not in reenqueueable_tasks(): + logger.warning( + "Orphan %s (%s) not re-enqueued: not allowlisted for auto recovery", + task_result.task_id, + name, + ) + return "failed" + + # Count the attempt only once the task is allowlisted, so a task sitting in a + # disabled group does not burn its recovery budget while the flag is off (and is + # not already over the cap the moment the group is re-enabled). + attempt = _recovery_attempt_count(name, kwargs_repr, window_hours) + if attempt > max_attempts: + logger.warning( + "Orphan %s (%s) not re-enqueued: recovery cap reached (%d/%d)", + task_result.task_id, + name, + attempt, + max_attempts, + ) + return "failed" + + task_obj = current_app.tasks.get(name) + if task_obj is None: + logger.error( + "Orphan %s: task %s not registered, cannot re-enqueue", + task_result.task_id, + name, + ) + return "failed" + + try: + args = _decode_celery_field(args_repr, []) + kwargs = _decode_celery_field(kwargs_repr, {}) + except ValueError: + logger.error( + "Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing", + task_result.task_id, + name, + ) + return "failed" + new_task_id = str(uuid4()) + task_obj.apply_async( + args=list(args) if isinstance(args, (list, tuple)) else [], + kwargs=kwargs if isinstance(kwargs, dict) else {}, + task_id=new_task_id, + ) + logger.info( + "Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id + ) + return "recovered" diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index a41a8d6292..b40516dadf 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -1,25 +1,425 @@ +import fcntl +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 +from api.db_router import READ_REPLICA_ALIAS, MainRouter +from api.db_utils import rls_transaction +from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot +from api.utils import initialize_prowler_provider from celery.utils.log import get_task_logger from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) +from prowler.lib.outputs.finding import Finding as FindingOutput from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3 from tasks.jobs.reports import ( FRAMEWORK_REGISTRY, + CISReportGenerator, CSAReportGenerator, ENSReportGenerator, NIS2ReportGenerator, ThreatScoreReportGenerator, ) 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_utils import rls_transaction -from api.models import Provider, ScanSummary, ThreatScoreSnapshot -from prowler.lib.outputs.finding import Finding as FindingOutput +from tasks.jobs.threatscore_utils import ( + _aggregate_requirement_statistics_from_database, + _get_compliance_check_ids, +) 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 +# component so malformed inputs like "cis_._aws" or "cis_5._aws" are rejected +# at the regex stage, rather than by a later ValueError fallback. +_CIS_VARIANT_RE = re.compile(r"^cis_(?P\d+(?:\.\d+)+)_(?P.+)$") + + +def _pick_latest_cis_variant(compliance_ids: Iterable[str]) -> str | None: + """Return the CIS compliance_id with the highest semantic version. + + CIS ships many variants per provider (e.g. cis_1.4_aws, ..., cis_6.0_aws). + A lexicographic sort is incorrect for version strings like ``1.10`` vs + ``1.2``; this helper parses the version into a tuple of ints so ``1.10`` + is correctly ordered after ``1.2``. Malformed names are skipped so a + broken JSON cannot crash the whole CIS pipeline. + + Args: + compliance_ids: Iterable of CIS compliance identifiers. Expected to + belong to a single provider (callers should pass the already + filtered keys from ``Compliance.get_bulk(provider_type)``). + + Returns: + The compliance_id with the highest parsed version, or ``None`` if no + well-formed CIS identifier was found. + """ + best_key: tuple[int, ...] | None = None + best_name: str | None = None + for name in compliance_ids: + match = _CIS_VARIANT_RE.match(name) + if not match: + continue + try: + key = tuple(int(part) for part in match.group("version").split(".")) + except ValueError: + # Defensive: the regex already guarantees numeric chunks, but we + # keep the guard so a future regex change cannot crash callers. + continue + if best_key is None or key > best_key: + best_key = key + best_name = name + 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( @@ -33,6 +433,7 @@ def generate_threatscore_report( provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, + prowler_provider=None, ) -> None: """ Generate a PDF compliance report based on Prowler ThreatScore framework. @@ -61,6 +462,7 @@ def generate_threatscore_report( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, only_failed=only_failed, ) @@ -75,6 +477,7 @@ def generate_ens_report( provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, + prowler_provider=None, ) -> None: """ Generate a PDF compliance report for ENS RD2022 framework. @@ -101,6 +504,7 @@ def generate_ens_report( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, include_manual=include_manual, ) @@ -116,6 +520,7 @@ def generate_nis2_report( provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, + prowler_provider=None, ) -> None: """ Generate a PDF compliance report for NIS2 Directive (EU) 2022/2555. @@ -143,6 +548,7 @@ def generate_nis2_report( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, only_failed=only_failed, include_manual=include_manual, ) @@ -159,6 +565,7 @@ def generate_csa_report( provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, + prowler_provider=None, ) -> None: """ Generate a PDF compliance report for CSA Cloud Controls Matrix (CCM) v4.0. @@ -166,7 +573,7 @@ def generate_csa_report( Args: tenant_id: The tenant ID for Row-Level Security context. scan_id: ID of the scan executed by Prowler. - compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0_aws"). + compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0"). output_path: Output PDF file path. provider_id: Provider ID for the scan. only_failed: If True, only include failed requirements in detailed section. @@ -186,6 +593,56 @@ def generate_csa_report( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, + only_failed=only_failed, + include_manual=include_manual, + ) + + +def generate_cis_report( + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + only_failed: bool = True, + include_manual: bool = False, + provider_obj: Provider | None = None, + requirement_statistics: dict[str, dict[str, int]] | None = None, + findings_cache: dict[str, list[FindingOutput]] | None = None, + prowler_provider=None, +) -> None: + """ + Generate a PDF compliance report for a specific CIS Benchmark variant. + + Unlike single-version frameworks (ENS, NIS2, CSA), CIS has multiple + variants per provider (e.g., cis_1.4_aws, cis_5.0_aws, cis_6.0_aws). This + wrapper is called once per variant, receiving the specific compliance_id. + + Args: + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the specific CIS variant (e.g., "cis_5.0_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + only_failed: If True, only include failed requirements in detailed section. + include_manual: If True, include manual requirements in detailed section. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. + """ + generator = CISReportGenerator(FRAMEWORK_REGISTRY["cis"]) + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + prowler_provider=prowler_provider, only_failed=only_failed, include_manual=include_manual, ) @@ -199,6 +656,7 @@ def generate_compliance_reports( generate_ens: bool = True, generate_nis2: bool = True, generate_csa: bool = True, + generate_cis: bool = True, only_failed_threatscore: bool = True, min_risk_level_threatscore: int = 4, include_manual_ens: bool = True, @@ -206,6 +664,8 @@ def generate_compliance_reports( only_failed_nis2: bool = True, only_failed_csa: bool = True, include_manual_csa: bool = False, + only_failed_cis: bool = True, + include_manual_cis: bool = False, ) -> dict[str, dict[str, bool | str]]: """ Generate multiple compliance reports with shared database queries. @@ -215,6 +675,13 @@ def generate_compliance_reports( - Aggregating requirement statistics once (shared across all reports) - Reusing compliance framework data when possible + For CIS a single PDF is produced per run: the one matching the highest + available CIS version for the scan's provider (picked dynamically from + ``Compliance.get_bulk`` via :func:`_pick_latest_cis_variant`). The + returned ``results["cis"]`` entry has the same flat shape as the other + single-version frameworks — the picked variant is an internal detail, + not surfaced in the result. + Args: tenant_id: The tenant ID for Row-Level Security context. scan_id: The ID of the scan to generate reports for. @@ -223,6 +690,8 @@ def generate_compliance_reports( generate_ens: Whether to generate ENS report. generate_nis2: Whether to generate NIS2 report. generate_csa: Whether to generate CSA CCM report. + generate_cis: Whether to generate a CIS Benchmark report for the + latest CIS version available for the provider. only_failed_threatscore: For ThreatScore, only include failed requirements. min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements. include_manual_ens: For ENS, include manual requirements. @@ -230,22 +699,39 @@ def generate_compliance_reports( only_failed_nis2: For NIS2, only include failed requirements. only_failed_csa: For CSA CCM, only include failed requirements. include_manual_csa: For CSA CCM, include manual requirements. + only_failed_cis: For CIS, only include failed requirements in detailed section. + include_manual_cis: For CIS, include manual requirements in detailed section. Returns: - Dictionary with results for each report type. + Dictionary with results for each report type. Every value has the + same flat shape: ``{"upload": bool, "path": str, "error"?: str}``. """ logger.info( "Generating compliance reports for scan %s with provider %s" - " (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s)", + " (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s, CIS: %s)", scan_id, provider_id, generate_threatscore, generate_ens, generate_nis2, generate_csa, + generate_cis, ) - results = {} + 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 with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): @@ -259,6 +745,8 @@ def generate_compliance_reports( results["nis2"] = {"upload": False, "path": ""} if generate_csa: results["csa"] = {"upload": False, "path": ""} + if generate_cis: + results["cis"] = {"upload": False, "path": ""} return results provider_obj = Provider.objects.get(id=provider_id) @@ -299,11 +787,49 @@ def generate_compliance_reports( results["csa"] = {"upload": False, "path": ""} generate_csa = False + # Load the framework definitions for this provider once. We use this map + # both to pick the latest CIS variant and to precompute the set of + # check_ids each framework consumes (for findings_cache eviction). + frameworks_bulk: dict = {} + try: + frameworks_bulk = Compliance.get_bulk(provider_type) + except Exception as e: + logger.error("Error loading compliance frameworks for %s: %s", provider_type, e) + # Fall through; individual frameworks will still try and fail + # gracefully if their compliance_id is missing. + + # 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 inspect + # the dynamically loaded framework map and pick the latest available CIS + # version, if any. + latest_cis: str | None = None + if generate_cis: + try: + 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 and not generate_ens and not generate_nis2 and not generate_csa + and not generate_cis ): return results @@ -315,42 +841,136 @@ def generate_compliance_reports( tenant_id, scan_id ) - # Create shared findings cache - findings_cache = {} + # Initialize the Prowler provider once for the whole report batch. Each + # generator used to re-init this in _load_compliance_data, paying the + # boto3/Azure-SDK construction cost 5 times per scan. The instance is + # only used by FindingOutput.transform_api_finding to enrich findings, + # so a single shared instance is correct. + logger.info("Initializing prowler_provider once for all reports (scan %s)", scan_id) + try: + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + prowler_provider = initialize_prowler_provider(provider_obj) + except Exception as init_error: + # If init fails the generators will fall back to lazy init in + # _load_compliance_data; we just log and continue. + logger.warning( + "Could not pre-initialize prowler_provider for scan %s: %s", + scan_id, + init_error, + ) + prowler_provider = None + + # Create shared findings cache up front so the eviction closure below + # can reference it. Defined BEFORE the closure to avoid the UnboundLocalError + # trap if an early-return is later inserted between the closure and its + # first use. + findings_cache: dict[str, list[FindingOutput]] = {} logger.info("Created shared findings cache for all reports") - # Generate output directories + # Precompute the set of check_ids each framework consumes. After a + # framework finishes, every check_id that no remaining framework still + # needs is evicted from findings_cache so the dict does not keep + # growing through the batch (PROWLER-1733). + pending_checks_by_framework: dict[str, set[str]] = {} + if generate_threatscore: + pending_checks_by_framework["threatscore"] = _get_compliance_check_ids( + frameworks_bulk.get(f"prowler_threatscore_{provider_type}") + ) + if generate_ens: + pending_checks_by_framework["ens"] = _get_compliance_check_ids( + frameworks_bulk.get(f"ens_rd2022_{provider_type}") + ) + if generate_nis2: + pending_checks_by_framework["nis2"] = _get_compliance_check_ids( + frameworks_bulk.get(f"nis2_{provider_type}") + ) + if generate_csa: + # csa_ccm_4.0 lives at the top level, not under compliance/{provider}/. + csa_framework = frameworks_bulk.get( + "csa_ccm_4.0" + ) or get_bulk_compliance_frameworks_universal(provider_type).get("csa_ccm_4.0") + pending_checks_by_framework["csa"] = _get_compliance_check_ids(csa_framework) + if generate_cis and latest_cis: + pending_checks_by_framework["cis"] = _get_compliance_check_ids( + frameworks_bulk.get(latest_cis) + ) + + def _evict_after_framework(done_key: str) -> int: + """Drop from findings_cache every check_id no pending framework still needs.""" + done = pending_checks_by_framework.pop(done_key, set()) + still_needed: set[str] = ( + set().union(*pending_checks_by_framework.values()) + if pending_checks_by_framework + else set() + ) + exclusive = done - still_needed + evicted = 0 + for cid in exclusive: + if findings_cache.pop(cid, None) is not None: + evicted += 1 + if evicted: + logger.info( + "Evicted %d exclusive check entries from findings_cache after %s " + "(remaining cache size: %d)", + evicted, + done_key, + len(findings_cache), + ) + # Release the lists' memory now instead of waiting for the next + # gc cycle; FindingOutput instances retain quite a bit of state. + gc.collect() + return evicted + + 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", - ) - 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": ""} @@ -362,10 +982,14 @@ def generate_compliance_reports( results["nis2"] = error_dict.copy() if generate_csa: results["csa"] = error_dict.copy() + if generate_cis: + results["cis"] = error_dict.copy() return results # 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( @@ -385,6 +1009,7 @@ def generate_compliance_reports( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, ) # Compute and store ThreatScore metrics snapshot @@ -462,11 +1087,19 @@ def generate_compliance_reports( logger.warning("ThreatScore report saved locally at %s", out_dir) except Exception as e: - logger.error("Error generating ThreatScore report: %s", e) + logger.exception( + "compliance_report_failed framework=threatscore scan_id=%s tenant_id=%s", + scan_id, + tenant_id, + ) results["threatscore"] = {"upload": False, "path": "", "error": str(e)} + _evict_after_framework("threatscore") + # 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) @@ -482,6 +1115,7 @@ def generate_compliance_reports( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, ) upload_uri_ens = _upload_to_s3( @@ -496,11 +1130,19 @@ def generate_compliance_reports( logger.warning("ENS report saved locally at %s", out_dir) except Exception as e: - logger.error("Error generating ENS report: %s", e) + logger.exception( + "compliance_report_failed framework=ens scan_id=%s tenant_id=%s", + scan_id, + tenant_id, + ) results["ens"] = {"upload": False, "path": "", "error": str(e)} + _evict_after_framework("ens") + # 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) @@ -517,6 +1159,7 @@ def generate_compliance_reports( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, ) upload_uri_nis2 = _upload_to_s3( @@ -531,12 +1174,20 @@ def generate_compliance_reports( logger.warning("NIS2 report saved locally at %s", out_dir) except Exception as e: - logger.error("Error generating NIS2 report: %s", e) + logger.exception( + "compliance_report_failed framework=nis2 scan_id=%s tenant_id=%s", + scan_id, + tenant_id, + ) results["nis2"] = {"upload": False, "path": "", "error": str(e)} + _evict_after_framework("nis2") + # Generate CSA CCM report if generate_csa: - compliance_id_csa = f"csa_ccm_4.0_{provider_type}" + generated_report_keys.append("csa") + csa_path = output_paths["csa"] + compliance_id_csa = "csa_ccm_4.0" pdf_path_csa = f"{csa_path}_csa_report.pdf" logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa) @@ -552,6 +1203,7 @@ def generate_compliance_reports( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, ) upload_uri_csa = _upload_to_s3( @@ -566,17 +1218,93 @@ def generate_compliance_reports( logger.warning("CSA CCM report saved locally at %s", out_dir) except Exception as e: - logger.error("Error generating CSA CCM report: %s", e) + logger.exception( + "compliance_report_failed framework=csa scan_id=%s tenant_id=%s", + scan_id, + tenant_id, + ) results["csa"] = {"upload": False, "path": "", "error": str(e)} - # Clean up temporary files if all reports were uploaded successfully - all_uploaded = all( - result.get("upload", False) - for result in results.values() - if result.get("upload") is not None + _evict_after_framework("csa") + + # 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. + 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: + 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, + prowler_provider=prowler_provider, + ) + + 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( + "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, + ) + + except Exception as e: + logger.exception( + "compliance_report_failed framework=cis variant=%s scan_id=%s tenant_id=%s", + latest_cis, + scan_id, + tenant_id, + ) + results["cis"] = { + "upload": False, + "path": "", + "error": str(e), + } + finally: + # Free ReportLab/matplotlib memory before moving on. CIS is + # always the last framework, so evicting its entries clears the + # cache entirely (subject to its check_ids set). + _evict_after_framework("cis") + gc.collect() + + # 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 ) - 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) @@ -595,6 +1323,7 @@ def generate_compliance_reports_job( generate_ens: bool = True, generate_nis2: bool = True, generate_csa: bool = True, + generate_cis: bool = True, ) -> dict[str, dict[str, bool | str]]: """ Celery task wrapper for generate_compliance_reports. @@ -607,9 +1336,12 @@ def generate_compliance_reports_job( generate_ens: Whether to generate ENS report. generate_nis2: Whether to generate NIS2 report. generate_csa: Whether to generate CSA CCM report. + generate_cis: Whether to generate the CIS Benchmark report for the + latest CIS version available for the provider. Returns: - Dictionary with results for each report type. + Dictionary with results for each report type. Every entry shares the + same flat ``{"upload", "path", "error"?}`` shape. """ return generate_compliance_reports( tenant_id=tenant_id, @@ -619,4 +1351,5 @@ def generate_compliance_reports_job( generate_ens=generate_ens, generate_nis2=generate_nis2, generate_csa=generate_csa, + generate_cis=generate_cis, ) diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py index 1fc475a467..a538416f59 100644 --- a/api/src/backend/tasks/jobs/reports/__init__.py +++ b/api/src/backend/tasks/jobs/reports/__init__.py @@ -17,6 +17,9 @@ from .charts import ( get_chart_color_for_percentage, ) +# Framework-specific generators +from .cis import CISReportGenerator + # Reusable components # Reusable components: Color helpers, Badge components, Risk component, # Table components, Section components @@ -31,10 +34,12 @@ from .components import ( create_section_header, create_status_badge, create_summary_table, + escape_html, get_color_for_compliance, get_color_for_risk_level, get_color_for_weight, get_status_color, + truncate_text, ) # Framework configuration: Main configuration, Color constants, ENS colors, @@ -90,8 +95,6 @@ from .config import ( FrameworkConfig, get_framework_config, ) - -# Framework-specific generators from .csa import CSAReportGenerator from .ens import ENSReportGenerator from .nis2 import NIS2ReportGenerator @@ -109,6 +112,7 @@ __all__ = [ "ENSReportGenerator", "NIS2ReportGenerator", "CSAReportGenerator", + "CISReportGenerator", # Configuration "FrameworkConfig", "FRAMEWORK_REGISTRY", @@ -182,6 +186,9 @@ __all__ = [ # Section components "create_section_header", "create_summary_table", + # Text helpers + "truncate_text", + "escape_html", # Chart functions "get_chart_color_for_percentage", "create_vertical_bar_chart", diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py index f348c6d0d2..f51319a846 100644 --- a/api/src/backend/tasks/jobs/reports/base.py +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -1,10 +1,23 @@ import gc import os +import resource as _resource_module +import time from abc import ABC, abstractmethod +from contextlib import contextmanager from dataclasses import dataclass, field +from types import SimpleNamespace from typing import Any +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Provider, StatusChoices +from api.utils import initialize_prowler_provider from celery.utils.log import get_task_logger +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) +from prowler.lib.outputs.finding import Finding as FindingOutput from reportlab.lib.enums import TA_CENTER from reportlab.lib.pagesizes import letter from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet @@ -19,13 +32,6 @@ from tasks.jobs.threatscore_utils import ( _load_findings_for_requirement_checks, ) -from api.db_router import READ_REPLICA_ALIAS -from api.db_utils import rls_transaction -from api.models import Provider, StatusChoices -from api.utils import initialize_prowler_provider -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.finding import Finding as FindingOutput - from .components import ( ColumnConfig, create_data_table, @@ -41,6 +47,7 @@ from .config import ( COLOR_LIGHT_BLUE, COLOR_LIGHTER_BLUE, COLOR_PROWLER_DARK_GREEN, + FINDINGS_TABLE_CHUNK_SIZE, PADDING_LARGE, PADDING_SMALL, FrameworkConfig, @@ -48,6 +55,46 @@ from .config import ( logger = get_task_logger(__name__) + +@contextmanager +def _log_phase(phase: str, **tags: Any): + """Log start/end timing and RSS deltas around a long-running task section. + + Generic helper: callers pass arbitrary ``key=value`` tags + (e.g. ``scan_id``, ``framework``, ``provider_id``) and they are + emitted as part of the structured log line, so Grafana/Datadog/ + CloudWatch queries can pivot by whichever dimension is relevant to + the task. ``getrusage`` returns KB on Linux and bytes on macOS; + the values are still useful in relative terms even though units + differ across platforms. + """ + tag_str = " ".join(f"{key}={value}" for key, value in tags.items()) + suffix = f" {tag_str}" if tag_str else "" + + start = time.perf_counter() + rss_before = _resource_module.getrusage(_resource_module.RUSAGE_SELF).ru_maxrss + logger.info("phase_start phase=%s%s rss_kb=%d", phase, suffix, rss_before) + try: + yield + except Exception: + elapsed = time.perf_counter() - start + logger.exception( + "phase_failed phase=%s%s elapsed_s=%.2f", phase, suffix, elapsed + ) + raise + else: + elapsed = time.perf_counter() - start + rss_after = _resource_module.getrusage(_resource_module.RUSAGE_SELF).ru_maxrss + logger.info( + "phase_end phase=%s%s elapsed_s=%.2f rss_kb=%d delta_rss_kb=%d", + phase, + suffix, + elapsed, + rss_after, + rss_after - rss_before, + ) + + # Register fonts (done once at module load) _fonts_registered: bool = False @@ -178,6 +225,46 @@ def get_requirement_metadata( return None +def _universal_attributes_to_list(attributes) -> list: + """Flatten a universal requirement's ``attributes`` into a list of objects + with attribute access. MITRE wraps its list under ``_raw_attributes``.""" + if isinstance(attributes, dict) and "_raw_attributes" in attributes: + entries = attributes.get("_raw_attributes") or [] + return [ + SimpleNamespace(**entry) for entry in entries if isinstance(entry, dict) + ] + if isinstance(attributes, dict): + return [SimpleNamespace(**attributes)] if attributes else [] + return list(attributes or []) + + +def _adapt_universal_to_legacy(framework, provider_type: str) -> SimpleNamespace: + """Expose a universal ``ComplianceFramework`` under the legacy ``Compliance`` + attribute names used by the PDF pipeline.""" + provider_key = (provider_type or "").lower() + requirements = [] + for requirement in framework.requirements: + checks_by_provider = ( + requirement.checks if isinstance(requirement.checks, dict) else {} + ) + requirements.append( + SimpleNamespace( + Id=requirement.id, + Description=requirement.description or "", + Checks=list(checks_by_provider.get(provider_key, [])), + Attributes=_universal_attributes_to_list(requirement.attributes), + ) + ) + return SimpleNamespace( + Framework=framework.framework, + Name=framework.name, + Version=framework.version or "", + Description=framework.description or "", + Provider=framework.provider or provider_type, + Requirements=requirements, + ) + + # ============================================================================= # PDF Styles Cache # ============================================================================= @@ -335,6 +422,7 @@ class BaseComplianceReportGenerator(ABC): provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, + prowler_provider: Any | None = None, **kwargs, ) -> None: """Generate the PDF compliance report. @@ -351,23 +439,35 @@ class BaseComplianceReportGenerator(ABC): provider_obj: Optional pre-fetched Provider object requirement_statistics: Optional pre-aggregated statistics findings_cache: Optional pre-loaded findings cache + prowler_provider: Optional pre-initialized Prowler provider. When + generating multiple reports for the same scan the master + function initializes this once and passes it in to avoid + re-running boto3/Azure-SDK setup per framework. **kwargs: Additional framework-specific arguments """ + framework = self.config.display_name logger.info( - "Generating %s report for scan %s", self.config.display_name, scan_id + "report_generation_start framework=%s scan_id=%s compliance_id=%s", + framework, + scan_id, + compliance_id, ) try: # 1. Load compliance data - data = self._load_compliance_data( - tenant_id=tenant_id, - scan_id=scan_id, - compliance_id=compliance_id, - provider_id=provider_id, - provider_obj=provider_obj, - requirement_statistics=requirement_statistics, - findings_cache=findings_cache, - ) + with _log_phase( + "load_compliance_data", scan_id=scan_id, framework=framework + ): + data = self._load_compliance_data( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + prowler_provider=prowler_provider, + ) # 2. Create PDF document doc = self._create_document(output_path, data) @@ -377,37 +477,54 @@ class BaseComplianceReportGenerator(ABC): elements = [] # Cover page (lightweight) - elements.extend(self.create_cover_page(data)) - elements.append(PageBreak()) + with _log_phase("cover_page", scan_id=scan_id, framework=framework): + elements.extend(self.create_cover_page(data)) + elements.append(PageBreak()) # Executive summary (framework-specific) - elements.extend(self.create_executive_summary(data)) + with _log_phase("executive_summary", scan_id=scan_id, framework=framework): + elements.extend(self.create_executive_summary(data)) # Body sections (charts + requirements index) # Override _build_body_sections() in subclasses to change section order - elements.extend(self._build_body_sections(data)) + with _log_phase("body_sections", scan_id=scan_id, framework=framework): + elements.extend(self._build_body_sections(data)) # Detailed findings - heaviest section, loads findings on-demand - logger.info("Building detailed findings section...") - elements.extend(self.create_detailed_findings(data, **kwargs)) - gc.collect() # Free findings data after processing + with _log_phase("detailed_findings", scan_id=scan_id, framework=framework): + elements.extend(self.create_detailed_findings(data, **kwargs)) + gc.collect() # Free findings data after processing # 4. Build the PDF - logger.info("Building PDF document with %d elements...", len(elements)) - self._build_pdf(doc, elements, data) + logger.info( + "doc_build_about_to_run framework=%s scan_id=%s elements=%d", + framework, + scan_id, + len(elements), + ) + with _log_phase("doc_build", scan_id=scan_id, framework=framework): + self._build_pdf(doc, elements, data) # Final cleanup del elements gc.collect() - logger.info("Successfully generated report at %s", output_path) + logger.info( + "report_generation_end framework=%s scan_id=%s output_path=%s", + framework, + scan_id, + output_path, + ) - except Exception as e: - import traceback - - tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown" - logger.error("Error generating report, line %s -- %s", tb_lineno, e) - logger.error("Full traceback:\n%s", traceback.format_exc()) + except Exception: + # logger.exception captures the full traceback; the contextual + # keys keep production search-by-scan-id viable. + logger.exception( + "report_generation_failed framework=%s scan_id=%s compliance_id=%s", + framework, + scan_id, + compliance_id, + ) raise def _build_body_sections(self, data: ComplianceData) -> list: @@ -638,15 +755,25 @@ class BaseComplianceReportGenerator(ABC): for req in requirements: check_ids_to_load.extend(req.checks) - # Load findings on-demand only for the checks that will be displayed - # Uses the shared findings cache to avoid duplicate queries across reports + # Load findings on-demand only for the checks that will be displayed. + # When ``only_failed`` is active at requirement level, also push the + # FAIL filter down to the finding level: a requirement marked FAIL + # because 1/1000 findings failed must not render a table dominated by + # 999 PASS rows. That hides the actual failure under noise and + # makes the per-check cap truncate the wrong rows. + # ``total_counts`` is populated with the pre-cap total per check_id + # (FAIL-only when only_failed is active) so the "Showing first N of + # M" banner uses the same denominator the reader cares about. logger.info("Loading findings on-demand for %d requirements", len(requirements)) + total_counts: dict[str, int] = {} findings_by_check_id = _load_findings_for_requirement_checks( data.tenant_id, data.scan_id, check_ids_to_load, data.prowler_provider, data.findings_by_check_id, # Pass the cache to update it + total_counts_out=total_counts, + only_failed_findings=only_failed, ) for req in requirements: @@ -678,9 +805,31 @@ class BaseComplianceReportGenerator(ABC): ) ) else: - # Create findings table - findings_table = self._create_findings_table(findings) - elements.append(findings_table) + # Surface truncation BEFORE the tables so readers see it + # at the same scroll position as the data itself, not + # after thousands of rendered rows. + loaded = len(findings) + total = total_counts.get(check_id, loaded) + if total > loaded: + kind = "failed findings" if only_failed else "findings" + elements.append( + Paragraph( + f"⚠ Showing first {loaded:,} of " + f"{total:,} {kind} for this check. " + f"Use the CSV or JSON-OCSF export for the full " + f"list. The PDF caps detail rows to keep " + f"the report readable and bounded in size.", + self.styles["normal"], + ) + ) + elements.append(Spacer(1, 0.05 * inch)) + + # Create chunked findings tables to prevent OOM when a + # single check has thousands of findings (ReportLab + # resolves layout per Flowable, so many small tables + # render contiguously with a bounded memory peak). + findings_tables = self._create_findings_tables(findings) + elements.extend(findings_tables) elements.append(Spacer(1, 0.1 * inch)) @@ -735,6 +884,7 @@ class BaseComplianceReportGenerator(ABC): provider_obj: Provider | None, requirement_statistics: dict | None, findings_cache: dict | None, + prowler_provider: Any | None = None, ) -> ComplianceData: """Load and aggregate compliance data from the database. @@ -746,6 +896,9 @@ class BaseComplianceReportGenerator(ABC): provider_obj: Optional pre-fetched Provider requirement_statistics: Optional pre-aggregated statistics findings_cache: Optional pre-loaded findings + prowler_provider: Optional pre-initialized Prowler provider. When + the master function initializes it once and passes it in, + we skip the per-report ``initialize_prowler_provider`` call. Returns: Aggregated ComplianceData object @@ -755,12 +908,22 @@ class BaseComplianceReportGenerator(ABC): if provider_obj is None: provider_obj = Provider.objects.get(id=provider_id) - prowler_provider = initialize_prowler_provider(provider_obj) + if prowler_provider is None: + prowler_provider = initialize_prowler_provider(provider_obj) provider_type = provider_obj.provider - # Load compliance framework - frameworks_bulk = Compliance.get_bulk(provider_type) - compliance_obj = frameworks_bulk.get(compliance_id) + # Load compliance framework — fall back to the universal loader + # for top-level JSONs (e.g. csa_ccm_4.0) that Compliance.get_bulk + # does not scan. + compliance_obj = Compliance.get_bulk(provider_type).get(compliance_id) + if not compliance_obj: + universal_framework = get_bulk_compliance_frameworks_universal( + provider_type + ).get(compliance_id) + if universal_framework: + compliance_obj = _adapt_universal_to_legacy( + universal_framework, provider_type + ) if not compliance_obj: raise ValueError(f"Compliance framework not found: {compliance_id}") @@ -823,13 +986,32 @@ class BaseComplianceReportGenerator(ABC): ) -> SimpleDocTemplate: """Create the PDF document template. + Validates that ``output_path`` is a filesystem path string with an + existing parent directory. SimpleDocTemplate technically accepts a + BytesIO too, but we want every report to land on disk so the + Celery worker doesn't hold the full PDF in memory while uploading + to S3. + Args: output_path: Path for the output PDF data: Compliance data for metadata Returns: Configured SimpleDocTemplate + + Raises: + TypeError: ``output_path`` is not a string. + FileNotFoundError: The parent directory does not exist. """ + if not isinstance(output_path, str): + raise TypeError( + "output_path must be a filesystem path string; " + f"got {type(output_path).__name__}" + ) + parent_dir = os.path.dirname(output_path) + if parent_dir and not os.path.isdir(parent_dir): + raise FileNotFoundError(f"Output directory does not exist: {parent_dir}") + return SimpleDocTemplate( output_path, pagesize=letter, @@ -876,47 +1058,10 @@ class BaseComplianceReportGenerator(ABC): onLaterPages=add_footer, ) - def _create_findings_table(self, findings: list[FindingOutput]) -> Any: - """Create a findings table. - - Args: - findings: List of finding objects - - Returns: - ReportLab Table element - """ - - def get_finding_title(f): - metadata = getattr(f, "metadata", None) - if metadata: - return getattr(metadata, "CheckTitle", getattr(f, "check_id", "")) - return getattr(f, "check_id", "") - - def get_resource_name(f): - name = getattr(f, "resource_name", "") - if not name: - name = getattr(f, "resource_uid", "") - return name - - def get_severity(f): - metadata = getattr(f, "metadata", None) - if metadata: - return getattr(metadata, "Severity", "").capitalize() - return "" - - # Convert findings to dicts for the table - data = [] - for f in findings: - item = { - "title": get_finding_title(f), - "resource_name": get_resource_name(f), - "severity": get_severity(f), - "status": getattr(f, "status", "").upper(), - "region": getattr(f, "region", "global"), - } - data.append(item) - - columns = [ + # Column layout shared by all findings sub-tables. Defined as a method so + # subclasses can override it without re-implementing the chunking logic. + def _findings_table_columns(self) -> list[ColumnConfig]: + return [ ColumnConfig("Finding", 2.5 * inch, "title"), ColumnConfig("Resource", 3 * inch, "resource_name"), ColumnConfig("Severity", 0.9 * inch, "severity"), @@ -924,9 +1069,122 @@ class BaseComplianceReportGenerator(ABC): ColumnConfig("Region", 0.9 * inch, "region"), ] + @staticmethod + def _finding_to_row(f: FindingOutput) -> dict[str, str]: + """Project a FindingOutput onto the row dict the table expects. + + Kept defensive: missing metadata or attributes return empty strings + rather than raising, so a single malformed finding never breaks the + whole report. + """ + metadata = getattr(f, "metadata", None) + title = ( + getattr(metadata, "CheckTitle", getattr(f, "check_id", "")) + if metadata + else getattr(f, "check_id", "") + ) + resource_name = getattr(f, "resource_name", "") or getattr( + f, "resource_uid", "" + ) + severity = getattr(metadata, "Severity", "").capitalize() if metadata else "" + return { + "title": title, + "resource_name": resource_name, + "severity": severity, + "status": getattr(f, "status", "").upper(), + "region": getattr(f, "region", "global"), + } + + def _create_findings_tables( + self, + findings: list[FindingOutput], + chunk_size: int | None = None, + ) -> list[Any]: + """Build a list of small findings tables to keep ``doc.build()`` memory bounded. + + ReportLab resolves layout (column widths, row heights, page-breaks) + per Flowable. A single ``LongTable`` of 15k rows forces all of that + to be computed at once and reliably OOMs the worker on large scans. + Splitting into chunks of ``chunk_size`` rows produces an equivalent- + looking PDF (LongTable repeats headers; chunks render contiguously) + with a bounded memory peak per chunk. + + Args: + findings: List of finding objects for a single check. + chunk_size: Rows per sub-table. ``None`` uses + ``FINDINGS_TABLE_CHUNK_SIZE`` from config. + + Returns: + List of ReportLab flowables (interleaved ``Table``/``LongTable`` + and small ``Spacer`` between chunks). Empty list when there are + no findings. + """ + if not findings: + return [] + + chunk_size = chunk_size or FINDINGS_TABLE_CHUNK_SIZE + + # Build all rows first so we can chunk without re-walking the + # FindingOutput list. Malformed findings are skipped with a logged + # exception, never enough to abort the entire report. + rows: list[dict[str, str]] = [] + for f in findings: + try: + rows.append(self._finding_to_row(f)) + except Exception: + logger.exception( + "Skipping malformed finding while building table for check %s", + getattr(f, "check_id", "unknown"), + ) + + if not rows: + return [] + + columns = self._findings_table_columns() + + flowables: list = [] + total = len(rows) + for start in range(0, total, chunk_size): + chunk = rows[start : start + chunk_size] + flowables.append( + create_data_table( + data=chunk, + columns=columns, + header_color=self.config.primary_color, + normal_style=self.styles["normal_center"], + ) + ) + # A tiny spacer between chunks keeps them visually contiguous + # without forcing a page-break (KeepTogether would negate the + # memory benefit of chunking). + if start + chunk_size < total: + flowables.append(Spacer(1, 0.05 * inch)) + + if total > chunk_size: + logger.debug( + "Built %d findings sub-tables (chunk_size=%d, total_findings=%d)", + (total + chunk_size - 1) // chunk_size, + chunk_size, + total, + ) + + return flowables + + def _create_findings_table(self, findings: list[FindingOutput]) -> Any: + """Deprecated alias kept for backwards compatibility. + + Returns the first chunk produced by ``_create_findings_tables``. + New callers MUST use ``_create_findings_tables``, which returns a + list of flowables and is what ``create_detailed_findings`` invokes. + """ + flowables = self._create_findings_tables(findings) + if flowables: + return flowables[0] + # Empty input → return an empty (header-only) table so callers that + # used to receive a Table never get None. return create_data_table( - data=data, - columns=columns, + data=[], + columns=self._findings_table_columns(), header_color=self.config.primary_color, normal_style=self.styles["normal_center"], ) diff --git a/api/src/backend/tasks/jobs/reports/charts.py b/api/src/backend/tasks/jobs/reports/charts.py index 0f0338acab..7e9ad7ef20 100644 --- a/api/src/backend/tasks/jobs/reports/charts.py +++ b/api/src/backend/tasks/jobs/reports/charts.py @@ -1,9 +1,11 @@ import gc import io import math -from typing import Callable +import time +from collections.abc import Callable import matplotlib +from celery.utils.log import get_task_logger # Use non-interactive Agg backend for memory efficiency in server environments # This MUST be set before importing pyplot @@ -20,6 +22,26 @@ from .config import ( # noqa: E402 CHART_DPI_DEFAULT, ) +logger = get_task_logger(__name__) + + +def _log_chart_built(name: str, dpi: int, buffer: io.BytesIO, started: float) -> None: + """Emit a structured DEBUG line summarising a chart render. + + Centralised so the formatting stays consistent across all chart helpers + and so we never accidentally pay for buffer.getbuffer().nbytes when + debug logging is disabled. + """ + if logger.isEnabledFor(10): # logging.DEBUG + logger.debug( + "chart_built name=%s dpi=%d bytes=%d elapsed_s=%.2f", + name, + dpi, + buffer.getbuffer().nbytes, + time.perf_counter() - started, + ) + + # Use centralized DPI setting from config DEFAULT_CHART_DPI = CHART_DPI_DEFAULT @@ -77,6 +99,7 @@ def create_vertical_bar_chart( Returns: BytesIO buffer containing the PNG image """ + _started = time.perf_counter() if color_func is None: color_func = get_chart_color_for_percentage @@ -122,6 +145,7 @@ def create_vertical_bar_chart( plt.close(fig) gc.collect() # Force garbage collection after heavy matplotlib operation + _log_chart_built("vertical_bar", dpi, buffer, _started) return buffer @@ -156,6 +180,7 @@ def create_horizontal_bar_chart( Returns: BytesIO buffer containing the PNG image """ + _started = time.perf_counter() if color_func is None: color_func = get_chart_color_for_percentage @@ -207,6 +232,7 @@ def create_horizontal_bar_chart( plt.close(fig) gc.collect() # Force garbage collection after heavy matplotlib operation + _log_chart_built("horizontal_bar", dpi, buffer, _started) return buffer @@ -239,6 +265,7 @@ def create_radar_chart( Returns: BytesIO buffer containing the PNG image """ + _started = time.perf_counter() num_vars = len(labels) angles = [n / float(num_vars) * 2 * math.pi for n in range(num_vars)] @@ -275,6 +302,7 @@ def create_radar_chart( plt.close(fig) gc.collect() # Force garbage collection after heavy matplotlib operation + _log_chart_built("radar", dpi, buffer, _started) return buffer @@ -303,6 +331,7 @@ def create_pie_chart( Returns: BytesIO buffer containing the PNG image """ + _started = time.perf_counter() fig, ax = plt.subplots(figsize=figsize) _, _, autotexts = ax.pie( @@ -330,6 +359,7 @@ def create_pie_chart( plt.close(fig) gc.collect() # Force garbage collection after heavy matplotlib operation + _log_chart_built("pie", dpi, buffer, _started) return buffer @@ -362,6 +392,7 @@ def create_stacked_bar_chart( Returns: BytesIO buffer containing the PNG image """ + _started = time.perf_counter() fig, ax = plt.subplots(figsize=figsize) # Default colors if not provided @@ -401,4 +432,5 @@ def create_stacked_bar_chart( plt.close(fig) gc.collect() # Force garbage collection after heavy matplotlib operation + _log_chart_built("stacked_bar", dpi, buffer, _started) return buffer diff --git a/api/src/backend/tasks/jobs/reports/cis.py b/api/src/backend/tasks/jobs/reports/cis.py new file mode 100644 index 0000000000..8a1cfb6eba --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/cis.py @@ -0,0 +1,754 @@ +import os +import re +from collections import defaultdict +from typing import Any + +from api.models import StatusChoices +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + RequirementData, + get_requirement_metadata, +) +from .charts import ( + create_horizontal_bar_chart, + create_pie_chart, + create_stacked_bar_chart, + get_chart_color_for_percentage, +) +from .components import ColumnConfig, create_data_table, escape_html, truncate_text +from .config import ( + CHART_COLOR_GREEN_1, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COLOR_BG_BLUE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_DARK_GRAY, + COLOR_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_LIGHT_BLUE, + COLOR_SAFE, + COLOR_WHITE, +) + +# Ordered buckets used both in the executive summary tables and the charts +# section. Exposed as module constants so the two call sites never drift. +_PROFILE_BUCKET_ORDER: tuple[str, ...] = ("L1", "L2", "Other") +_ASSESSMENT_BUCKET_ORDER: tuple[str, ...] = ("Automated", "Manual") + +# Anchored matchers for profile normalization — substring checks on "L1"/"L2" +# would happily match unrelated tokens like "CL2 Worker" or "HL2" coming from +# future CIS profile enum values. +_LEVEL_2_RE = re.compile(r"(?:\bLevel\s*2\b|\bL2\b|Level_2)") +_LEVEL_1_RE = re.compile(r"(?:\bLevel\s*1\b|\bL1\b|Level_1)") + + +def _normalize_profile(profile: Any) -> str: + """Bucket a CIS Profile enum/string into one of: ``L1``, ``L2``, ``Other``. + + The ``CIS_Requirement_Attribute_Profile`` enum has values like + ``"Level 1"``, ``"Level 2"``, ``"E3 Level 1"``, ``"E5 Level 2"``. We + collapse them into three buckets to keep charts and badges readable + across CIS variants, using anchored regex matches so that future enum + values cannot accidentally promote e.g. ``"CL2 Worker"`` into ``L2``. + + Args: + profile: The profile value (enum member, string, or ``None``). + + Returns: + One of ``"L1"``, ``"L2"``, ``"Other"``. + """ + if profile is None: + return "Other" + value = getattr(profile, "value", None) or str(profile) + if _LEVEL_2_RE.search(value): + return "L2" + if _LEVEL_1_RE.search(value): + return "L1" + return "Other" + + +def _profile_badge_text(bucket: str) -> str: + """Map a normalized profile bucket (L1/L2/Other) to a short badge label.""" + return {"L1": "Level 1", "L2": "Level 2"}.get(bucket, "Other") + + +# ============================================================================= +# CIS Report Generator +# ============================================================================= + + +class CISReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for CIS (Center for Internet Security) Benchmarks. + + CIS differs from single-version frameworks (ENS, NIS2, CSA) in that: + - Each provider has multiple CIS versions (e.g. AWS: 1.4, 1.5, ..., 6.0). + - Section names differ across versions and providers and MUST be derived + at runtime from the loaded compliance data. + - Requirements carry Profile (Level 1/Level 2) and AssessmentStatus + (Automated/Manual) attributes that drive the executive summary and + charts. + + This generator produces: + - Cover page with Prowler logo and dynamic CIS version/provider metadata + - Executive summary with overall compliance score, counts, and breakdowns + by Profile and AssessmentStatus + - Charts: overall status pie, pass rate by section (horizontal bar), + Level 1 vs Level 2 pass/fail distribution (stacked bar) + - Requirements index grouped by dynamic section + - Detailed findings for FAIL requirements with CIS-specific audit / + remediation / rationale details + """ + + # Per-run memoization cache for ``_compute_statistics``. ``generate()`` + # is the public entry point and is called once per PDF, so scoping the + # cache to the last seen ComplianceData instance is enough to avoid the + # double computation between executive summary and charts section. + _stats_cache_key: int | None = None + _stats_cache_value: dict | None = None + + # Body section ordering — ensure every top-level section starts on its + # own clean page. The base class only puts a PageBreak AFTER Charts and + # Requirements Index, so Executive Summary and Charts end up sharing a + # page. This override prepends a PageBreak so Compliance Analysis always + # begins on a fresh page. + def _build_body_sections(self, data: ComplianceData) -> list: + return [PageBreak(), *super()._build_body_sections(data)] + + # ------------------------------------------------------------------------- + # Cover page override — shows dynamic CIS version + provider in the title + # ------------------------------------------------------------------------- + + def create_cover_page(self, data: ComplianceData) -> list: + """Create the CIS report cover page with Prowler + CIS logos side by side.""" + elements = [] + + # Create logos side by side (same pattern as NIS2 / ENS) + prowler_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + cis_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/cis_logo.png" + ) + + if os.path.exists(cis_logo_path): + prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch) + cis_logo = Image(cis_logo_path, width=2.3 * inch, height=1.1 * inch) + logos_table = Table( + [[prowler_logo, cis_logo]], colWidths=[4 * inch, 2.5 * inch] + ) + logos_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("VALIGN", (1, 0), (1, 0), "MIDDLE"), + ] + ) + ) + elements.append(logos_table) + elif os.path.exists(prowler_logo_path): + # Fallback: only the Prowler logo if the CIS asset is missing + elements.append(Image(prowler_logo_path, width=5 * inch, height=1 * inch)) + + elements.append(Spacer(1, 0.5 * inch)) + + # Dynamic title: "CIS Benchmark v5.0 — AWS Compliance Report" + provider_label = "" + if data.provider_obj: + provider_label = f" — {data.provider_obj.provider.upper()}" + title_text = ( + f"CIS Benchmark v{data.version}{provider_label}
Compliance Report" + ) + elements.append(Paragraph(title_text, self.styles["title"])) + elements.append(Spacer(1, 0.5 * inch)) + + # Metadata table via base class helper + info_rows = self._build_info_rows(data, language=self.config.language) + metadata_data = [] + for label, value in info_rows: + if label in ("Name:", "Description:") and value: + metadata_data.append( + [label, Paragraph(str(value), self.styles["normal_center"])] + ) + else: + metadata_data.append([label, value]) + + metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch]) + metadata_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(metadata_table) + + return elements + + # ------------------------------------------------------------------------- + # Executive Summary + # ------------------------------------------------------------------------- + + def create_executive_summary(self, data: ComplianceData) -> list: + """Create the CIS executive summary section.""" + elements = [] + + elements.append(Paragraph("Executive Summary", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + stats = self._compute_statistics(data) + + # --- Summary metrics table --- + summary_data = [ + ["Metric", "Value"], + ["Total Requirements", str(stats["total"])], + ["Passed", str(stats["passed"])], + ["Failed", str(stats["failed"])], + ["Manual", str(stats["manual"])], + ["Overall Compliance", f"{stats['overall_compliance']:.1f}%"], + ] + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE), + ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE), + ("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE), + ("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY), + ("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"), + ("FONTSIZE", (0, 0), (-1, 0), 12), + ("FONTSIZE", (0, 1), (-1, -1), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), + ("BOTTOMPADDING", (0, 0), (-1, 0), 10), + ( + "ROWBACKGROUNDS", + (1, 1), + (1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(summary_table) + elements.append(Spacer(1, 0.25 * inch)) + + # --- Profile breakdown table --- + elements.append(Paragraph("Breakdown by Profile", self.styles["h2"])) + elements.append(Spacer(1, 0.1 * inch)) + profile_counts = stats["profile_counts"] + profile_table_data = [["Profile", "Passed", "Failed", "Manual", "Total"]] + for bucket in _PROFILE_BUCKET_ORDER: + counts = profile_counts.get(bucket, {"passed": 0, "failed": 0, "manual": 0}) + total = counts["passed"] + counts["failed"] + counts["manual"] + if total == 0: + continue + profile_table_data.append( + [ + _profile_badge_text(bucket), + str(counts["passed"]), + str(counts["failed"]), + str(counts["manual"]), + str(total), + ] + ) + profile_table = Table( + profile_table_data, + colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch], + ) + profile_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(profile_table) + elements.append(Spacer(1, 0.25 * inch)) + + # --- Assessment status breakdown --- + elements.append(Paragraph("Breakdown by Assessment Status", self.styles["h2"])) + elements.append(Spacer(1, 0.1 * inch)) + assessment_counts = stats["assessment_counts"] + assessment_table_data = [["Assessment", "Passed", "Failed", "Manual", "Total"]] + for bucket in _ASSESSMENT_BUCKET_ORDER: + counts = assessment_counts.get( + bucket, {"passed": 0, "failed": 0, "manual": 0} + ) + total = counts["passed"] + counts["failed"] + counts["manual"] + if total == 0: + continue + assessment_table_data.append( + [ + bucket, + str(counts["passed"]), + str(counts["failed"]), + str(counts["manual"]), + str(total), + ] + ) + assessment_table = Table( + assessment_table_data, + colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch], + ) + assessment_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_LIGHT_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(assessment_table) + elements.append(Spacer(1, 0.25 * inch)) + + # --- Top 5 failing sections --- + top_failing = stats["top_failing_sections"] + if top_failing: + elements.append( + Paragraph("Top Sections with Lowest Compliance", self.styles["h2"]) + ) + elements.append(Spacer(1, 0.1 * inch)) + top_table_data = [["Section", "Passed", "Failed", "Compliance"]] + for section_label, section_stats in top_failing: + passed = section_stats["passed"] + failed = section_stats["failed"] + total = passed + failed + pct = (passed / total * 100) if total > 0 else 100 + top_table_data.append( + [ + truncate_text(section_label, 55), + str(passed), + str(failed), + f"{pct:.1f}%", + ] + ) + top_table = Table( + top_table_data, + colWidths=[3.5 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch], + ) + top_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(top_table) + + return elements + + # ------------------------------------------------------------------------- + # Charts section + # ------------------------------------------------------------------------- + + def create_charts_section(self, data: ComplianceData) -> list: + """Create the CIS charts section.""" + elements = [] + + elements.append(Paragraph("Compliance Analysis", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + # --- Pie chart: overall Pass / Fail / Manual --- + stats = self._compute_statistics(data) + pie_labels = [] + pie_values = [] + pie_colors = [] + if stats["passed"] > 0: + pie_labels.append(f"Pass ({stats['passed']})") + pie_values.append(stats["passed"]) + pie_colors.append(CHART_COLOR_GREEN_1) + if stats["failed"] > 0: + pie_labels.append(f"Fail ({stats['failed']})") + pie_values.append(stats["failed"]) + pie_colors.append(CHART_COLOR_RED) + if stats["manual"] > 0: + pie_labels.append(f"Manual ({stats['manual']})") + pie_values.append(stats["manual"]) + pie_colors.append(CHART_COLOR_YELLOW) + + if pie_values: + elements.append(Paragraph("Overall Status Distribution", self.styles["h2"])) + elements.append(Spacer(1, 0.1 * inch)) + pie_buffer = create_pie_chart( + labels=pie_labels, + values=pie_values, + colors=pie_colors, + ) + pie_buffer.seek(0) + elements.append(Image(pie_buffer, width=4.5 * inch, height=4.5 * inch)) + elements.append(Spacer(1, 0.2 * inch)) + + # --- Horizontal bar: pass rate by section --- + section_stats = stats["section_stats"] + if section_stats: + elements.append(PageBreak()) + elements.append(Paragraph("Compliance by Section", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + "The following chart shows compliance percentage for each CIS " + "section based on automated checks:", + self.styles["normal_center"], + ) + ) + elements.append(Spacer(1, 0.1 * inch)) + + # Sort sections by pass rate descending for readability + sorted_sections = sorted( + section_stats.items(), + key=lambda item: ( + (item[1]["passed"] / (item[1]["passed"] + item[1]["failed"]) * 100) + if (item[1]["passed"] + item[1]["failed"]) > 0 + else 100 + ), + reverse=True, + ) + bar_labels = [] + bar_values = [] + for section_label, section_data in sorted_sections: + total = section_data["passed"] + section_data["failed"] + if total == 0: + continue + pct = (section_data["passed"] / total) * 100 + bar_labels.append(truncate_text(section_label, 60)) + bar_values.append(pct) + + if bar_values: + bar_buffer = create_horizontal_bar_chart( + labels=bar_labels, + values=bar_values, + xlabel="Compliance (%)", + color_func=get_chart_color_for_percentage, + label_fontsize=9, + ) + bar_buffer.seek(0) + elements.append(Image(bar_buffer, width=6.5 * inch, height=5 * inch)) + + # --- Stacked bar: Level 1 vs Level 2 pass/fail --- + profile_counts = stats["profile_counts"] + has_profile_data = any( + (counts["passed"] + counts["failed"]) > 0 + for counts in profile_counts.values() + ) + if has_profile_data: + elements.append(PageBreak()) + elements.append(Paragraph("Profile Breakdown", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + "Distribution of Pass / Fail / Manual across CIS profile levels.", + self.styles["normal_center"], + ) + ) + elements.append(Spacer(1, 0.1 * inch)) + + profile_labels = [] + pass_series = [] + fail_series = [] + manual_series = [] + for bucket in _PROFILE_BUCKET_ORDER: + counts = profile_counts.get(bucket) + if not counts: + continue + total = counts["passed"] + counts["failed"] + counts["manual"] + if total == 0: + continue + profile_labels.append(_profile_badge_text(bucket)) + pass_series.append(counts["passed"]) + fail_series.append(counts["failed"]) + manual_series.append(counts["manual"]) + + if profile_labels: + stacked_buffer = create_stacked_bar_chart( + labels=profile_labels, + data_series={ + "Pass": pass_series, + "Fail": fail_series, + "Manual": manual_series, + }, + xlabel="Profile", + ylabel="Requirements", + ) + stacked_buffer.seek(0) + elements.append(Image(stacked_buffer, width=6 * inch, height=4 * inch)) + + return elements + + # ------------------------------------------------------------------------- + # Requirements Index + # ------------------------------------------------------------------------- + + def create_requirements_index(self, data: ComplianceData) -> list: + """Create the CIS requirements index grouped by dynamic section.""" + elements = [] + + elements.append(Paragraph("Requirements Index", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + sections = self._derive_sections(data) + by_section: dict[str, list[dict]] = defaultdict(list) + for req in data.requirements: + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + section = "Other" + profile_bucket = "Other" + assessment = "" + if meta: + section = getattr(meta, "Section", "Other") or "Other" + profile_bucket = _normalize_profile(getattr(meta, "Profile", None)) + assessment_enum = getattr(meta, "AssessmentStatus", None) + assessment = getattr(assessment_enum, "value", None) or str( + assessment_enum or "" + ) + by_section[section].append( + { + "id": req.id, + "description": truncate_text(req.description, 80), + "profile": _profile_badge_text(profile_bucket), + "assessment": assessment or "-", + "status": (req.status or "").upper(), + } + ) + + columns = [ + ColumnConfig("ID", 0.9 * inch, "id", align="LEFT"), + ColumnConfig("Description", 3.0 * inch, "description", align="LEFT"), + ColumnConfig("Profile", 0.9 * inch, "profile"), + ColumnConfig("Assessment", 1 * inch, "assessment"), + ColumnConfig("Status", 0.9 * inch, "status"), + ] + + for section in sections: + rows = by_section.get(section, []) + if not rows: + continue + elements.append(Paragraph(truncate_text(section, 90), self.styles["h2"])) + elements.append(Spacer(1, 0.05 * inch)) + table = create_data_table( + data=rows, + columns=columns, + header_color=self.config.primary_color, + normal_style=self.styles["normal_center"], + ) + elements.append(table) + elements.append(Spacer(1, 0.15 * inch)) + + return elements + + # ------------------------------------------------------------------------- + # Detailed findings hook — inject CIS-specific rationale / audit content + # ------------------------------------------------------------------------- + + def _render_requirement_detail_extras( + self, req: RequirementData, data: ComplianceData + ) -> list: + """Render CIS rationale, impact, audit, remediation and references.""" + extras = [] + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if meta is None: + return extras + + field_map = [ + ("Rationale", "RationaleStatement"), + ("Impact", "ImpactStatement"), + ("Audit Procedure", "AuditProcedure"), + ("Remediation", "RemediationProcedure"), + ("References", "References"), + ] + + for label, attr_name in field_map: + value = getattr(meta, attr_name, None) + if not value: + continue + text = str(value).strip() + if not text: + continue + extras.append(Paragraph(f"{label}:", self.styles["h3"])) + extras.append(Paragraph(escape_html(text), self.styles["normal"])) + extras.append(Spacer(1, 0.08 * inch)) + + return extras + + # ------------------------------------------------------------------------- + # Private helpers + # ------------------------------------------------------------------------- + + def _derive_sections(self, data: ComplianceData) -> list[str]: + """Extract ordered unique Section names from loaded compliance data.""" + seen: dict[str, bool] = {} + for req in data.requirements: + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if meta is None: + continue + section = getattr(meta, "Section", None) or "Other" + if section not in seen: + seen[section] = True + return list(seen.keys()) + + def _compute_statistics(self, data: ComplianceData) -> dict: + """Aggregate all statistics needed for summary and charts. + + Memoized per-``ComplianceData`` instance via ``_stats_cache_*``: the + executive summary and the charts section both need the same numbers, + so they would otherwise re-iterate the requirements twice. We key on + ``id(data)`` because ``ComplianceData`` is a dataclass and its + instances are not hashable. + + Returns a dict with: + - total, passed, failed, manual: int + - overall_compliance: float (percentage) + - profile_counts: {"L1": {"passed", "failed", "manual"}, ...} + - assessment_counts: {"Automated": {...}, "Manual": {...}} + - section_stats: {section_name: {"passed", "failed", "manual"}, ...} + - top_failing_sections: list[(section_name, stats)] (up to 5) + """ + cache_key = id(data) + if self._stats_cache_key == cache_key and self._stats_cache_value is not None: + return self._stats_cache_value + stats = self._compute_statistics_uncached(data) + self._stats_cache_key = cache_key + self._stats_cache_value = stats + return stats + + def _compute_statistics_uncached(self, data: ComplianceData) -> dict: + """Actual aggregation kernel; call ``_compute_statistics`` instead.""" + total = len(data.requirements) + passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS) + failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL) + manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL) + + evaluated = passed + failed + overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100.0 + + profile_counts: dict[str, dict[str, int]] = { + "L1": {"passed": 0, "failed": 0, "manual": 0}, + "L2": {"passed": 0, "failed": 0, "manual": 0}, + "Other": {"passed": 0, "failed": 0, "manual": 0}, + } + assessment_counts: dict[str, dict[str, int]] = { + "Automated": {"passed": 0, "failed": 0, "manual": 0}, + "Manual": {"passed": 0, "failed": 0, "manual": 0}, + } + section_stats: dict[str, dict[str, int]] = defaultdict( + lambda: {"passed": 0, "failed": 0, "manual": 0} + ) + + for req in data.requirements: + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if meta is None: + continue + + profile_bucket = _normalize_profile(getattr(meta, "Profile", None)) + assessment_enum = getattr(meta, "AssessmentStatus", None) + assessment_value = getattr(assessment_enum, "value", None) or str( + assessment_enum or "" + ) + assessment_bucket = ( + "Automated" if assessment_value == "Automated" else "Manual" + ) + section = getattr(meta, "Section", None) or "Other" + + status_key = { + StatusChoices.PASS: "passed", + StatusChoices.FAIL: "failed", + StatusChoices.MANUAL: "manual", + }.get(req.status) + if status_key is None: + continue + + profile_counts[profile_bucket][status_key] += 1 + assessment_counts[assessment_bucket][status_key] += 1 + section_stats[section][status_key] += 1 + + # Top 5 sections with lowest pass rate (only sections with evaluated reqs) + def _section_rate(item): + _, stats_ = item + evaluated_ = stats_["passed"] + stats_["failed"] + if evaluated_ == 0: + return 101 # sort evaluated=0 to the bottom + return stats_["passed"] / evaluated_ * 100 + + top_failing_sections = sorted( + ( + item + for item in section_stats.items() + if (item[1]["passed"] + item[1]["failed"]) > 0 + ), + key=_section_rate, + )[:5] + + return { + "total": total, + "passed": passed, + "failed": failed, + "manual": manual, + "overall_compliance": overall_compliance, + "profile_counts": profile_counts, + "assessment_counts": assessment_counts, + "section_stats": dict(section_stats), + "top_failing_sections": top_failing_sections, + } diff --git a/api/src/backend/tasks/jobs/reports/components.py b/api/src/backend/tasks/jobs/reports/components.py index 323c4547e6..0c15acb4cb 100644 --- a/api/src/backend/tasks/jobs/reports/components.py +++ b/api/src/backend/tasks/jobs/reports/components.py @@ -1,5 +1,6 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from reportlab.lib import colors from reportlab.lib.styles import ParagraphStyle @@ -26,6 +27,52 @@ from .config import ( ) +def truncate_text(text: str, max_len: int) -> str: + """Truncate ``text`` to ``max_len`` characters, appending an ellipsis if cut. + + Used by report generators that need to squeeze long descriptions, section + titles or finding titles into a fixed-width table cell. + + Args: + text: Source string. ``None`` and non-string values are treated as empty. + max_len: Maximum output length including the ellipsis. Values < 4 are + clamped so the result never grows beyond ``max_len``. + + Returns: + The original string if short enough, otherwise ``text[: max_len - 3] + "..."``. + When ``max_len < 4`` a plain substring of length ``max_len`` is returned + so callers never get a string longer than they asked for. + """ + if not text: + return "" + text = str(text) + if len(text) <= max_len: + return text + if max_len < 4: + return text[:max_len] + return text[: max_len - 3] + "..." + + +def escape_html(text: str) -> str: + """Escape the minimal HTML entities required for safe ReportLab Paragraph rendering. + + ReportLab's ``Paragraph`` parses a small HTML subset, so raw ``<``, ``>`` + and ``&`` in user-provided content (rationale, remediation, etc.) would + break layout or be interpreted as tags. This helper mirrors + ``html.escape`` but avoids pulling in the stdlib dependency and keeps the + output deterministic. + + Args: + text: Untrusted source string. + + Returns: + A string safe to embed inside a ReportLab Paragraph. + """ + return ( + str(text or "").replace("&", "&").replace("<", "<").replace(">", ">") + ) + + def get_color_for_risk_level(risk_level: int) -> colors.Color: """ Get color based on risk level. @@ -429,8 +476,15 @@ def create_data_table( else: value = item.get(col.field, "") + # Wrap every string cell in Paragraph so the data rows keep the + # caller-supplied font/colour/alignment. Skipping Paragraph for + # short cells (a tempting micro-optimisation) breaks visual + # consistency: ReportLab Table falls back to Helvetica/black for + # raw strings, mixing fonts within the same table. + # ``escape_html`` keeps ``<``/``>``/``&`` in resource names from + # breaking Paragraph's mini-HTML parser. if normal_style and isinstance(value, str): - value = Paragraph(value, normal_style) + value = Paragraph(escape_html(value), normal_style) row.append(value) table_data.append(row) @@ -462,17 +516,26 @@ def create_data_table( for idx, col in enumerate(columns): styles.append(("ALIGN", (idx, 0), (idx, -1), col.align)) - # Alternate row backgrounds - skip for very large tables as it adds memory overhead + # Alternate row backgrounds: single O(1) ROWBACKGROUNDS style entry. + # The previous implementation appended N per-row BACKGROUND commands, + # which scaled the TableStyle list linearly with row count. ReportLab + # cycles through the colour list row-by-row so the visual is identical. + # The ALTERNATE_ROWS_MAX_SIZE cap is preserved to mirror legacy + # behaviour (very large tables stay plain), but the memory cost of the + # styles list is now constant regardless of row count. if ( alternate_rows and len(table_data) > 1 and len(table_data) <= ALTERNATE_ROWS_MAX_SIZE ): - for i in range(1, len(table_data)): - if i % 2 == 0: - styles.append( - ("BACKGROUND", (0, i), (-1, i), colors.Color(0.98, 0.98, 0.98)) - ) + styles.append( + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [colors.white, colors.Color(0.98, 0.98, 0.98)], + ) + ) table.setStyle(TableStyle(styles)) return table diff --git a/api/src/backend/tasks/jobs/reports/config.py b/api/src/backend/tasks/jobs/reports/config.py index fe0326980d..3b660e014a 100644 --- a/api/src/backend/tasks/jobs/reports/config.py +++ b/api/src/backend/tasks/jobs/reports/config.py @@ -1,3 +1,4 @@ +import os from dataclasses import dataclass, field from reportlab.lib import colors @@ -23,6 +24,47 @@ ALTERNATE_ROWS_MAX_SIZE = 200 # Larger = fewer queries but more memory per batch FINDINGS_BATCH_SIZE = 2000 +# Maximum rows per findings sub-table. ReportLab resolves layout per Flowable; +# splitting a huge findings list into multiple smaller tables keeps the peak +# memory of doc.build() bounded. A single 15k-row LongTable would force +# ReportLab to compute all column widths/row heights/page-breaks at once and +# OOM the worker; 300-row chunks are rendered contiguously with negligible +# visual impact. +FINDINGS_TABLE_CHUNK_SIZE = 300 + +# Maximum findings rendered per check in the detailed-findings section. +# +# Product behaviour: compliance PDFs render at most ``MAX_FINDINGS_PER_CHECK`` +# **failed** findings per check (PASS rows are excluded at SQL level by the +# ``only_failed`` flag that all four list-rendering frameworks default to: +# ThreatScore, NIS2, CSA, CIS; ENS does not render finding tables). Above +# this cap each affected check renders an in-PDF banner +# ("Showing first 100 of N failed findings for this check. Use the CSV +# or JSON export for the full list") so the reader knows the table is +# truncated and where to find the full data. +# +# Why a cap exists at all: +# * ``FindingOutput.transform_api_finding`` is O(N) per finding (Pydantic +# v1 validation + nested model construction). +# * ReportLab resolves layout per Flowable; thousands of sub-tables make +# ``doc.build()`` very slow and grow the PDF unboundedly. +# * A human-readable executive/auditor PDF does not need 12,000 rows for +# one check; that is forensic data and lives in the CSV/JSON exports. +# +# Why 100 specifically: +# * Covers ~99% of real scans without truncation (most checks emit far +# fewer than 100 findings even in enterprise estates). +# * Worst-case rendered rows = 100 × ~500 checks = 50k rows across all +# frameworks, which keeps RSS bounded and a 5-framework run completes +# in minutes instead of hours. +# +# Override at runtime via ``DJANGO_PDF_MAX_FINDINGS_PER_CHECK``: +# * Set to ``0`` to disable the cap entirely (load every finding; only +# advisable for small scans). +# * Set to a larger value (e.g. ``500``) for forensic detail in big runs; +# watch RSS in the Celery worker. +MAX_FINDINGS_PER_CHECK = int(os.environ.get("DJANGO_PDF_MAX_FINDINGS_PER_CHECK", "100")) + # ============================================================================= # Base colors @@ -313,6 +355,32 @@ FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = { has_niveles=False, has_weight=False, ), + "cis": FrameworkConfig( + name="cis", + display_name="CIS Benchmark", + logo_filename=None, + primary_color=COLOR_BLUE, + secondary_color=COLOR_LIGHT_BLUE, + bg_color=COLOR_BG_BLUE, + attribute_fields=[ + "Section", + "SubSection", + "Profile", + "AssessmentStatus", + "Description", + "RationaleStatement", + "ImpactStatement", + "RemediationProcedure", + "AuditProcedure", + "References", + ], + sections=None, # Derived dynamically per CIS variant (section names differ across versions/providers) + language="en", + has_risk_levels=False, + has_dimensions=False, + has_niveles=False, + has_weight=False, + ), } @@ -336,5 +404,7 @@ def get_framework_config(compliance_id: str) -> FrameworkConfig | None: return FRAMEWORK_REGISTRY["nis2"] if "csa" in compliance_lower or "ccm" in compliance_lower: return FRAMEWORK_REGISTRY["csa_ccm"] + if compliance_lower.startswith("cis_") or "cis" in compliance_lower: + return FRAMEWORK_REGISTRY["cis"] return None diff --git a/api/src/backend/tasks/jobs/reports/csa.py b/api/src/backend/tasks/jobs/reports/csa.py index c55ed198de..0a53c17cdf 100644 --- a/api/src/backend/tasks/jobs/reports/csa.py +++ b/api/src/backend/tasks/jobs/reports/csa.py @@ -1,11 +1,10 @@ from collections import defaultdict +from api.models import StatusChoices from celery.utils.log import get_task_logger from reportlab.lib.units import inch from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle -from api.models import StatusChoices - from .base import ( BaseComplianceReportGenerator, ComplianceData, diff --git a/api/src/backend/tasks/jobs/reports/ens.py b/api/src/backend/tasks/jobs/reports/ens.py index 617d4ea59f..44c874bfc1 100644 --- a/api/src/backend/tasks/jobs/reports/ens.py +++ b/api/src/backend/tasks/jobs/reports/ens.py @@ -1,13 +1,12 @@ import os from collections import defaultdict +from api.models import StatusChoices from reportlab.lib import colors from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle -from api.models import StatusChoices - from .base import ( BaseComplianceReportGenerator, ComplianceData, diff --git a/api/src/backend/tasks/jobs/reports/nis2.py b/api/src/backend/tasks/jobs/reports/nis2.py index 4ac5fa3d15..ed936f9571 100644 --- a/api/src/backend/tasks/jobs/reports/nis2.py +++ b/api/src/backend/tasks/jobs/reports/nis2.py @@ -1,11 +1,10 @@ import os from collections import defaultdict +from api.models import StatusChoices from reportlab.lib.units import inch from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle -from api.models import StatusChoices - from .base import ( BaseComplianceReportGenerator, ComplianceData, diff --git a/api/src/backend/tasks/jobs/reports/threatscore.py b/api/src/backend/tasks/jobs/reports/threatscore.py index e23085b1c3..a71ebde536 100644 --- a/api/src/backend/tasks/jobs/reports/threatscore.py +++ b/api/src/backend/tasks/jobs/reports/threatscore.py @@ -1,12 +1,11 @@ import gc +from api.models import StatusChoices from reportlab.lib import colors from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle -from api.models import StatusChoices - from .base import ( BaseComplianceReportGenerator, ComplianceData, diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 3a96e892e1..d69e0c8941 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -5,22 +5,11 @@ import re import time import uuid from collections import defaultdict -from datetime import datetime, timezone +from collections.abc import Iterable +from datetime import UTC, datetime from typing import Any import sentry_sdk -from celery.utils.log import get_task_logger -from config.env import env -from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS -from django.db import IntegrityError, OperationalError -from django.db.models import Case, Count, IntegerField, Max, Min, Prefetch, Q, Sum, When -from django.utils import timezone as django_timezone -from tasks.jobs.queries import ( - COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, - COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL, -) -from tasks.utils import CustomEncoder - from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE from api.constants import SEVERITY_ORDER from api.db_router import READ_REPLICA_ALIAS, MainRouter @@ -29,9 +18,8 @@ from api.db_utils import ( SET_CONFIG_QUERY, psycopg_connection, rls_transaction, - update_objects_in_batches, ) -from api.exceptions import ProviderConnectionError +from api.exceptions import ProviderConnectionError, ProviderDeletedException from api.models import ( AttackSurfaceOverview, ComplianceOverviewSummary, @@ -46,6 +34,7 @@ from api.models import ( ResourceFindingMapping, ResourceScanSummary, ResourceTag, + ResourceTagMapping, Scan, ScanCategorySummary, ScanGroupSummary, @@ -55,9 +44,32 @@ from api.models import ( from api.models import StatusChoices as FindingStatus from api.utils import initialize_prowler_provider, return_prowler_provider from api.v1.serializers import ScanTaskSerializer +from celery.utils.log import get_task_logger +from config.django.base import DJANGO_FINDINGS_BATCH_SIZE +from config.env import env +from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS +from django.db import DatabaseError, IntegrityError, OperationalError, transaction +from django.db.models import ( + Case, + Count, + Exists, + IntegerField, + Max, + Min, + OuterRef, + Q, + Sum, + When, +) +from django.utils import timezone as django_timezone from prowler.lib.check.models import CheckMetadata from prowler.lib.outputs.finding import Finding as ProwlerFinding from prowler.lib.scan.scan import Scan as ProwlerScan +from tasks.jobs.queries import ( + COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, + COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL, +) +from tasks.utils import CustomEncoder, batched logger = get_task_logger(__name__) @@ -84,8 +96,16 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = ( ) # Controls how many findings we process per micro-batch before flushing to DB writes FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000) -# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres -SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=500) +# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres. +SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=1000) +# Throttle scan progress persistence: minimum progress delta (fraction 0-1) +# between two persisted progress updates. +PROGRESS_THROTTLE_DELTA = env.float("DJANGO_SCAN_PROGRESS_THROTTLE_DELTA", default=0.01) +# Throttle scan progress persistence: maximum seconds without persisting progress +# regardless of delta (so slow checks still show progress in the UI). +PROGRESS_THROTTLE_SECONDS = env.float( + "DJANGO_SCAN_PROGRESS_THROTTLE_SECONDS", default=10.0 +) ATTACK_SURFACE_PROVIDER_COMPATIBILITY = { "internet-exposed": None, # Compatible with all providers @@ -97,6 +117,20 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = { _ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {} +def _save_scan_instance( + scan_instance: Scan, provider_id: str, update_fields: list[str] +) -> None: + try: + with transaction.atomic(): # Savepoint for not killing the `rls_transaction` + scan_instance.save(update_fields=update_fields) + except DatabaseError: + if Scan.objects.filter(pk=scan_instance.id).exists(): + raise + raise ProviderDeletedException( + f"Provider '{provider_id}' for scan '{scan_instance.id}' was deleted during the scan" + ) from None + + def aggregate_category_counts( categories: list[str], severity: str, @@ -189,8 +223,9 @@ def _get_attack_surface_mapping_from_provider(provider_type: str) -> dict: "iam_inline_policy_allows_privilege_escalation", }, "ec2-imdsv1": { - "ec2_instance_imdsv2_enabled" - }, # AWS only - IMDSv1 enabled findings + "ec2_instance_imdsv2_enabled", + "ec2_instance_account_imdsv2_enabled", + }, # AWS only - instance-level IMDSv1 exposure and account IMDS defaults } for category_name, check_ids in attack_surface_check_mappings.items(): if check_ids is None: @@ -247,6 +282,7 @@ def _store_resources( provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -254,6 +290,7 @@ def _store_resources( ) if not created: + resource_instance.name = finding.resource_name resource_instance.region = finding.region resource_instance.service = finding.service_name resource_instance.type = finding.resource_type @@ -287,7 +324,7 @@ def _copy_compliance_requirement_rows( csv_buffer = io.StringIO() writer = csv.writer(csv_buffer) - datetime_now = datetime.now(tz=timezone.utc) + datetime_now = datetime.now(tz=UTC) for row in rows: writer.writerow( [ @@ -333,68 +370,71 @@ def _copy_compliance_requirement_rows( def _persist_compliance_requirement_rows( - tenant_id: str, rows: list[dict[str, Any]], batch_size: int = 10000 -) -> None: + tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000 +) -> int: """Persist compliance requirement rows using batched COPY with ORM fallback. - Splits large row sets into batches to reduce lock duration and improve concurrency. + ``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size`` + rows instead of the full set. A batch that fails COPY falls back to an ORM + ``bulk_create`` of just that batch. Args: tenant_id: Target tenant UUID. - rows: Precomputed row dictionaries that reflect the compliance - overview state for a scan. + rows: Iterable of row dictionaries reflecting the compliance overview + state for a scan. batch_size: Number of rows per COPY batch (default: 10000). + + Returns: + int: total number of rows persisted. """ - if not rows: - return - - total_rows = len(rows) - total_batches = (total_rows + batch_size - 1) // batch_size - - try: - # Process rows in batches to reduce lock duration - for batch_num in range(total_batches): - start_idx = batch_num * batch_size - end_idx = min(start_idx + batch_size, total_rows) - batch = rows[start_idx:end_idx] + total_rows = 0 + batch_num = 0 + for batch, _is_last in batched(rows, batch_size): + if not batch: + continue + batch_num += 1 + try: _copy_compliance_requirement_rows(tenant_id, batch) + except Exception as error: + logger.exception( + f"COPY bulk insert for compliance requirements batch {batch_num} " + "failed; falling back to ORM bulk_create for this batch", + exc_info=error, + ) + fallback_objects = [ + ComplianceRequirementOverview( + id=row["id"], + tenant_id=row["tenant_id"], + inserted_at=row["inserted_at"], + compliance_id=row["compliance_id"], + framework=row["framework"], + version=row["version"], + description=row["description"], + region=row["region"], + requirement_id=row["requirement_id"], + requirement_status=row["requirement_status"], + passed_checks=row["passed_checks"], + failed_checks=row["failed_checks"], + total_checks=row["total_checks"], + passed_findings=row.get("passed_findings", 0), + total_findings=row.get("total_findings", 0), + scan_id=row["scan_id"], + ) + for row in batch + ] + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.bulk_create( + fallback_objects, batch_size=500 + ) - logger.info( - f"Compliance COPY batch {batch_num + 1}/{total_batches}: " - f"inserted {len(batch)} rows ({start_idx + len(batch)}/{total_rows} total)" - ) - except Exception as error: - logger.exception( - "COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create", - exc_info=error, + total_rows += len(batch) + logger.info( + f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows " + f"({total_rows} total)" ) - # Fallback: use ORM bulk_create for all remaining rows - fallback_objects = [ - ComplianceRequirementOverview( - id=row["id"], - tenant_id=row["tenant_id"], - inserted_at=row["inserted_at"], - compliance_id=row["compliance_id"], - framework=row["framework"], - version=row["version"], - description=row["description"], - region=row["region"], - requirement_id=row["requirement_id"], - requirement_status=row["requirement_status"], - passed_checks=row["passed_checks"], - failed_checks=row["failed_checks"], - total_checks=row["total_checks"], - passed_findings=row.get("passed_findings", 0), - total_findings=row.get("total_findings", 0), - scan_id=row["scan_id"], - ) - for row in rows - ] - with rls_transaction(tenant_id): - ComplianceRequirementOverview.objects.bulk_create( - fallback_objects, batch_size=500 - ) + + return total_rows def _create_compliance_summaries( @@ -454,9 +494,12 @@ def _create_compliance_summaries( ) ) - # Bulk insert summaries - if summary_objects: - with rls_transaction(tenant_id): + # Idempotent re-run: clear this scan's prior summaries before re-inserting, so a + # recovered scan-compliance-overviews run reflects its own re-derived rows instead + # of keeping a stale one (bulk_create ignore_conflicts alone would keep the old). + with rls_transaction(tenant_id): + ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete() + if summary_objects: ComplianceOverviewSummary.objects.bulk_create( summary_objects, batch_size=500, ignore_conflicts=True ) @@ -514,16 +557,26 @@ def _process_finding_micro_batch( """ # Accumulate objects for bulk operations findings_to_create = [] - mappings_to_create = [] dirty_resources = {} + resources_with_new_tag_mappings: set[str] = set() resource_denormalized_data = [] # (finding_instance, resource_instance) pairs + tag_mappings_to_create: list[ResourceTagMapping] = [] skipped_findings_count = 0 # Track findings skipped due to UID length - # Prefetch last statuses for all findings in this batch - # TEMPORARY WORKAROUND: Filter out UIDs > 300 chars to avoid query errors - finding_uids = [ - f.uid for f in findings_batch if f is not None and len(f.uid) <= 300 - ] + # Separate findings into those persistable (uid <= 300) and over-limit. + # Resources/tags ARE still resolved for over-limit findings to preserve the + # original behavior (resources are persisted even when their finding is dropped). + non_null_findings = [f for f in findings_batch if f is not None] + persistable_findings = [f for f in non_null_findings if len(f.uid) <= 300] + skipped_findings_count = len(non_null_findings) - len(persistable_findings) + none_count = len(findings_batch) - len(non_null_findings) + if none_count: + logger.error( + f"{none_count} None finding(s) detected on scan {scan_instance.id}." + ) + + # Prefetch last statuses for all persistable findings in this batch (read replica) + finding_uids = [f.uid for f in persistable_findings] with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): last_statuses = { item["uid"]: (item["status"], item["first_seen_at"]) @@ -534,273 +587,418 @@ def _process_finding_micro_batch( .order_by("uid", "-inserted_at") .distinct("uid") } - # Update cache for uid, data in last_statuses.items(): if uid not in last_status_cache: last_status_cache[uid] = data - # Process each finding in the batch - for finding in findings_batch: - if finding is None: - logger.error(f"None finding detected on scan {scan_instance.id}.") - continue + # All DB writes for this micro-batch run inside ONE rls_transaction, + # with deadlock-retry at micro-batch granularity instead of per-finding. + for attempt in range(CELERY_DEADLOCK_ATTEMPTS): + try: + with rls_transaction(tenant_id): + # 1) Pre-resolve Resources in bulk + # Collect all uids referenced by this batch that are not in cache yet. + # NOTE: we intentionally include empty-string uids here. The SDK + # explicitly emits findings with `resource_uid=""` for some flows + # (IaC scans, some Azure/GCP/K8s checks). The original + # `get_or_create` behavior was to create/share a Resource with + # uid="" for these findings rather than dropping them. Preserve + # that behavior; do NOT filter by truthiness. + batch_resource_uids: set[str] = set() + for f in non_null_findings: + if f.resource_uid not in resource_cache: + batch_resource_uids.add(f.resource_uid) - # Process resource with deadlock retry - for attempt in range(CELERY_DEADLOCK_ATTEMPTS): - try: - with rls_transaction(tenant_id): - resource_uid = finding.resource_uid - if resource_uid not in resource_cache: - check_metadata = finding.get_metadata() - group = check_metadata.get("resourcegroup") or None - resource_instance, _ = Resource.objects.get_or_create( + if batch_resource_uids: + existing_resources = { + r.uid: r + for r in Resource.objects.filter( tenant_id=tenant_id, - provider=provider_instance, - uid=resource_uid, - defaults={ - "region": finding.region, - "service": finding.service_name, - "type": finding.resource_type, - "name": finding.resource_name, - "groups": [group] if group else None, - }, + provider_id=provider_instance.id, + uid__in=batch_resource_uids, ) - resource_cache[resource_uid] = resource_instance - resource_failed_findings_cache[resource_uid] = 0 - else: - resource_instance = resource_cache[resource_uid] - break - except (OperationalError, IntegrityError) as db_err: - if attempt < CELERY_DEADLOCK_ATTEMPTS - 1: - logger.warning( - f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} " - f"detected when processing resource {resource_uid} on scan {scan_instance.id}. Retrying..." + } + missing_uids = batch_resource_uids - existing_resources.keys() + if missing_uids: + # Build defaults from the first finding referencing each uid. + first_finding_per_uid: dict[str, ProwlerFinding] = {} + for f in non_null_findings: + if f.resource_uid in missing_uids: + first_finding_per_uid.setdefault(f.resource_uid, f) + resources_to_create = [] + for uid in missing_uids: + f = first_finding_per_uid[uid] + check_metadata = f.get_metadata() + group = check_metadata.get("resourcegroup") or None + resources_to_create.append( + Resource( + tenant_id=tenant_id, + provider=provider_instance, + uid=uid, + region=f.region, + service=f.service_name, + type=f.resource_type, + name=f.resource_name, + groups=[group] if group else None, + ) + ) + Resource.objects.bulk_create( + resources_to_create, + batch_size=SCAN_DB_BATCH_SIZE, + ignore_conflicts=True, + unique_fields=["tenant_id", "provider_id", "uid"], + ) + # Re-fetch to obtain instances we just created AND any + # created concurrently by another scan against the same provider. + existing_resources.update( + { + r.uid: r + for r in Resource.objects.filter( + tenant_id=tenant_id, + provider_id=provider_instance.id, + uid__in=missing_uids, + ) + } + ) + for uid, r in existing_resources.items(): + resource_cache[uid] = r + resource_failed_findings_cache.setdefault(uid, 0) + + # 2) Pre-resolve ResourceTags in bulk + batch_tag_kv: set[tuple[str, str]] = set() + for f in non_null_findings: + for k, v in f.resource_tags.items(): + if (k, v) not in tag_cache: + batch_tag_kv.add((k, v)) + + if batch_tag_kv: + keys_to_query = {k for k, _ in batch_tag_kv} + existing_tags = { + (t.key, t.value): t + for t in ResourceTag.objects.filter( + tenant_id=tenant_id, key__in=keys_to_query + ) + if (t.key, t.value) in batch_tag_kv + } + missing_kv = batch_tag_kv - existing_tags.keys() + if missing_kv: + ResourceTag.objects.bulk_create( + [ + ResourceTag(tenant_id=tenant_id, key=k, value=v) + for k, v in missing_kv + ], + batch_size=SCAN_DB_BATCH_SIZE, + ignore_conflicts=True, + unique_fields=["tenant_id", "key", "value"], + ) + existing_tags.update( + { + (t.key, t.value): t + for t in ResourceTag.objects.filter( + tenant_id=tenant_id, + key__in={k for k, _ in missing_kv}, + ) + if (t.key, t.value) in missing_kv + } + ) + tag_cache.update(existing_tags) + + # 3) Per-finding in-memory processing + for finding in non_null_findings: + resource_uid = finding.resource_uid + resource_instance = resource_cache.get(resource_uid) + if resource_instance is None: + # Should be unreachable after the pre-resolve step. Defensive log. + logger.error( + f"Resource {resource_uid} missing from cache after pre-resolve " + f"on scan {scan_instance.id}; skipping finding." + ) + continue + + # Detect resource field changes (defer save until end-of-batch bulk_update). + check_metadata = finding.get_metadata() + group = check_metadata.get("resourcegroup") or None + updated = False + if finding.region and resource_instance.region != finding.region: + resource_instance.region = finding.region + updated = True + if ( + finding.resource_name + and resource_instance.name != finding.resource_name + ): + resource_instance.name = finding.resource_name + updated = True + if resource_instance.service != finding.service_name: + resource_instance.service = finding.service_name + updated = True + if resource_instance.type != finding.resource_type: + resource_instance.type = finding.resource_type + updated = True + if resource_instance.metadata != finding.resource_metadata: + resource_instance.metadata = json.dumps( + finding.resource_metadata, cls=CustomEncoder + ) + updated = True + if resource_instance.details != finding.resource_details: + resource_instance.details = finding.resource_details + updated = True + if resource_instance.partition != finding.partition: + resource_instance.partition = finding.partition + updated = True + if group and ( + not resource_instance.groups + or group not in resource_instance.groups + ): + resource_instance.groups = (resource_instance.groups or []) + [ + group + ] + updated = True + + if updated: + dirty_resources[resource_uid] = resource_instance + + # Accumulate ResourceTagMapping rows; bulk_create at end of block. + for k, v in finding.resource_tags.items(): + tag_instance = tag_cache.get((k, v)) + if tag_instance is None: + # Should not happen after pre-resolve; skip defensively. + continue + tag_mappings_to_create.append( + ResourceTagMapping( + tenant_id=tenant_id, + resource=resource_instance, + tag=tag_instance, + ) + ) + + unique_resources.add( + (resource_instance.uid, resource_instance.region) ) - time.sleep(0.1 * (2**attempt)) - continue - else: - raise db_err - # Track resource field changes (defer save) - updated = False - check_metadata = finding.get_metadata() - group = check_metadata.get("resourcegroup") or None - if finding.region and resource_instance.region != finding.region: - resource_instance.region = finding.region - updated = True - if resource_instance.service != finding.service_name: - resource_instance.service = finding.service_name - updated = True - if resource_instance.type != finding.resource_type: - resource_instance.type = finding.resource_type - updated = True - if resource_instance.metadata != finding.resource_metadata: - resource_instance.metadata = json.dumps( - finding.resource_metadata, cls=CustomEncoder - ) - updated = True - if resource_instance.details != finding.resource_details: - resource_instance.details = finding.resource_details - updated = True - if resource_instance.partition != finding.partition: - resource_instance.partition = finding.partition - updated = True - if group and ( - not resource_instance.groups or group not in resource_instance.groups - ): - resource_instance.groups = (resource_instance.groups or []) + [group] - updated = True + # TEMPORARY WORKAROUND: Skip findings with UID > 300 chars + # TODO: Remove this after implementing text field migration for finding.uid + if len(finding.uid) > 300: + logger.warning( + f"Skipping finding with UID exceeding 300 characters. " + f"Length: {len(finding.uid)}, " + f"Check: {finding.check_id}, " + f"Resource: {finding.resource_name}, " + f"UID: {finding.uid}" + ) + continue - if updated: - dirty_resources[resource_uid] = resource_instance - - # Process tags - tags = [] - with rls_transaction(tenant_id): - for key, value in finding.resource_tags.items(): - tag_key = (key, value) - if tag_key not in tag_cache: - tag_instance, _ = ResourceTag.objects.get_or_create( - tenant_id=tenant_id, key=key, value=value + finding_uid = finding.uid + last_status, last_first_seen_at = last_status_cache.get( + finding_uid, (None, None) ) - tag_cache[tag_key] = tag_instance - else: - tag_instance = tag_cache[tag_key] - tags.append(tag_instance) - resource_instance.upsert_or_delete_tags(tags=tags) - unique_resources.add((resource_instance.uid, resource_instance.region)) + status = FindingStatus[finding.status] + delta = _create_finding_delta(last_status, status) - # Prepare finding data - finding_uid = finding.uid + if not last_first_seen_at: + last_first_seen_at = datetime.now(tz=UTC) - # TEMPORARY WORKAROUND: Skip findings with UID > 300 chars - # TODO: Remove this after implementing text field migration for finding.uid - if len(finding_uid) > 300: - skipped_findings_count += 1 - logger.warning( - f"Skipping finding with UID exceeding 300 characters. " - f"Length: {len(finding_uid)}, " - f"Check: {finding.check_id}, " - f"Resource: {finding.resource_name}, " - f"UID: {finding_uid}" - ) - continue + # Determine if finding should be muted and why + # Priority: mutelist processor (highest) > manual mute rules + is_muted = False + muted_reason = None + if finding.muted: + is_muted = True + muted_reason = "Muted by mutelist" + elif finding_uid in mute_rules_cache: + is_muted = True + muted_reason = mute_rules_cache[finding_uid] - last_status, last_first_seen_at = last_status_cache.get( - finding_uid, (None, None) - ) + if status == FindingStatus.FAIL and not is_muted: + resource_failed_findings_cache[resource_uid] += 1 - status = FindingStatus[finding.status] - delta = _create_finding_delta(last_status, status) + check_metadata["compliance"] = finding.compliance + finding_instance = Finding( + tenant_id=tenant_id, + uid=finding_uid, + delta=delta, + check_metadata=check_metadata, + status=status, + status_extended=finding.status_extended, + severity=finding.severity, + impact=finding.severity, + raw_result=finding.raw, + check_id=finding.check_id, + scan=scan_instance, + first_seen_at=last_first_seen_at, + muted=is_muted, + muted_at=datetime.now(tz=UTC) if is_muted else None, + muted_reason=muted_reason, + compliance=finding.compliance, + categories=check_metadata.get("categories", []) or [], + resource_groups=check_metadata.get("resourcegroup") or None, + # Denormalized resource arrays populated directly on insert + # (was previously a separate bulk_update; saves a CASE WHEN + # over thousands of rows per micro-batch). + resource_regions=[resource_instance.region] + if resource_instance.region + else [], + resource_services=[resource_instance.service] + if resource_instance.service + else [], + resource_types=[resource_instance.type] + if resource_instance.type + else [], + ) + findings_to_create.append(finding_instance) + resource_denormalized_data.append( + (finding_instance, resource_instance) + ) - if not last_first_seen_at: - last_first_seen_at = datetime.now(tz=timezone.utc) + scan_resource_cache.add( + ( + str(resource_instance.id), + resource_instance.service, + resource_instance.region, + resource_instance.type, + ) + ) - # Determine if finding should be muted and why - # Priority: mutelist processor (highest) > manual mute rules - is_muted = False - muted_reason = None + aggregate_category_counts( + categories=check_metadata.get("categories", []) or [], + severity=finding.severity.value, + status=status.value, + delta=delta.value if delta else None, + muted=is_muted, + cache=scan_categories_cache, + ) - # Check mutelist processor first (highest priority) - if finding.muted: - is_muted = True - muted_reason = "Muted by mutelist" - # If not muted by mutelist, check manual mute rules - elif finding_uid in mute_rules_cache: - is_muted = True - muted_reason = mute_rules_cache[finding_uid] + aggregate_resource_group_counts( + resource_group=check_metadata.get("resourcegroup") or None, + severity=finding.severity.value, + status=status.value, + delta=delta.value if delta else None, + muted=is_muted, + resource_uid=resource_instance.uid if resource_instance else "", + cache=scan_resource_groups_cache, + group_resources_cache=group_resources_cache, + ) - # Increment failed_findings_count cache if needed - if status == FindingStatus.FAIL and not is_muted: - resource_failed_findings_cache[resource_uid] += 1 + # 4) Bulk create ResourceTagMappings + # Replaces the original per-resource `upsert_or_delete_tags` + # (which did one `update_or_create` + SELECT FOR UPDATE per mapping). + if tag_mappings_to_create: + # Pre-SELECT existing pairs: `bulk_create(ignore_conflicts=True)` + # does not populate `pk`, so we cannot tell new vs existing from + # the result; we need that to bump `updated_at` only on resources + # that actually gain a mapping. + candidate_resource_ids = { + m.resource_id for m in tag_mappings_to_create + } + candidate_tag_ids = {m.tag_id for m in tag_mappings_to_create} + existing_pairs = set( + ResourceTagMapping.objects.filter( + tenant_id=tenant_id, + resource_id__in=candidate_resource_ids, + tag_id__in=candidate_tag_ids, + ).values_list("resource_id", "tag_id") + ) + resource_uid_by_id = { + str(r.id): uid for uid, r in resource_cache.items() + } + for m in tag_mappings_to_create: + if (m.resource_id, m.tag_id) not in existing_pairs: + uid = resource_uid_by_id.get(str(m.resource_id)) + if uid is not None: + resources_with_new_tag_mappings.add(uid) - # Create finding object (don't save yet) - check_metadata = finding.get_metadata() - check_metadata["compliance"] = finding.compliance - finding_instance = Finding( - tenant_id=tenant_id, - uid=finding_uid, - delta=delta, - check_metadata=check_metadata, - status=status, - status_extended=finding.status_extended, - severity=finding.severity, - impact=finding.severity, - raw_result=finding.raw, - check_id=finding.check_id, - scan=scan_instance, - first_seen_at=last_first_seen_at, - muted=is_muted, - muted_at=datetime.now(tz=timezone.utc) if is_muted else None, - muted_reason=muted_reason, - compliance=finding.compliance, - categories=check_metadata.get("categories", []) or [], - resource_groups=check_metadata.get("resourcegroup") or None, - ) - findings_to_create.append(finding_instance) - resource_denormalized_data.append((finding_instance, resource_instance)) + ResourceTagMapping.objects.bulk_create( + tag_mappings_to_create, + batch_size=SCAN_DB_BATCH_SIZE, + ignore_conflicts=True, + unique_fields=["tenant_id", "resource_id", "tag_id"], + ) - # Track for scan summary - scan_resource_cache.add( - ( - str(resource_instance.id), - resource_instance.service, - resource_instance.region, - resource_instance.type, - ) - ) + # 5) Bulk create Findings + if findings_to_create: + Finding.objects.bulk_create( + findings_to_create, batch_size=SCAN_DB_BATCH_SIZE + ) - # Track categories with counts for ScanCategorySummary by (category, severity) - aggregate_category_counts( - categories=check_metadata.get("categories", []) or [], - severity=finding.severity.value, - status=status.value, - delta=delta.value if delta else None, - muted=is_muted, - cache=scan_categories_cache, - ) + # 6) Bulk create ResourceFindingMapping rows + mappings_to_create = [ + ResourceFindingMapping( + tenant_id=tenant_id, + resource=resource_instance, + finding=finding_instance, + ) + for finding_instance, resource_instance in resource_denormalized_data + ] + if mappings_to_create: + created_mappings = ResourceFindingMapping.objects.bulk_create( + mappings_to_create, + batch_size=SCAN_DB_BATCH_SIZE, + ignore_conflicts=True, + unique_fields=["tenant_id", "resource_id", "finding_id"], + ) + inserted = sum(1 for m in created_mappings if m.pk) + if inserted != len(mappings_to_create): + logger.error( + f"scan {scan_instance.id}: expected " + f"{len(mappings_to_create)} ResourceFindingMapping rows, " + f"inserted {inserted}. Rolling back micro-batch." + ) - # Track resource groups with counts for ScanGroupSummary - aggregate_resource_group_counts( - resource_group=check_metadata.get("resourcegroup") or None, - severity=finding.severity.value, - status=status.value, - delta=delta.value if delta else None, - muted=is_muted, - resource_uid=resource_instance.uid if resource_instance else "", - cache=scan_resource_groups_cache, - group_resources_cache=group_resources_cache, - ) - - # Bulk operations within single transaction - with rls_transaction(tenant_id): - # Bulk create findings - if findings_to_create: - Finding.objects.bulk_create( - findings_to_create, batch_size=SCAN_DB_BATCH_SIZE - ) - - # Bulk create resource-finding mappings - for finding_instance, resource_instance in resource_denormalized_data: - mappings_to_create.append( - ResourceFindingMapping( - tenant_id=tenant_id, - resource=resource_instance, - finding=finding_instance, + # 7) Bulk update Resources + # Union of: + # - resources whose fields changed (dirty_resources) + # - resources that got new tag mappings (need updated_at bump, + # preserving the original `self.save(update_fields=["updated_at"])` + # behavior of `upsert_or_delete_tags`) + all_resource_uids_to_touch = ( + set(dirty_resources.keys()) | resources_with_new_tag_mappings ) - ) - - if mappings_to_create: - ResourceFindingMapping.objects.bulk_create( - mappings_to_create, - batch_size=SCAN_DB_BATCH_SIZE, - ignore_conflicts=True, - ) - - # Update finding denormalized arrays - findings_to_update = [] - for finding_instance, resource_instance in resource_denormalized_data: - if not finding_instance.resource_regions: - finding_instance.resource_regions = [] - if not finding_instance.resource_services: - finding_instance.resource_services = [] - if not finding_instance.resource_types: - finding_instance.resource_types = [] - - if resource_instance.region not in finding_instance.resource_regions: - finding_instance.resource_regions.append(resource_instance.region) - if resource_instance.service not in finding_instance.resource_services: - finding_instance.resource_services.append(resource_instance.service) - if resource_instance.type not in finding_instance.resource_types: - finding_instance.resource_types.append(resource_instance.type) - - findings_to_update.append(finding_instance) - - if findings_to_update: - Finding.objects.bulk_update( - findings_to_update, - ["resource_regions", "resource_services", "resource_types"], - batch_size=SCAN_DB_BATCH_SIZE, - ) - - # Bulk update dirty resources - if dirty_resources: - update_objects_in_batches( - tenant_id=tenant_id, - model=Resource, - objects=list(dirty_resources.values()), - fields=[ - "metadata", - "details", - "partition", - "region", - "service", - "type", - "groups", - ], - batch_size=1000, - ) + if all_resource_uids_to_touch: + now_utc = datetime.now(tz=UTC) + resources_to_bulk_update = [] + for uid in all_resource_uids_to_touch: + # Use the instance from dirty_resources if present (has mutated + # fields), otherwise the cached one (for updated_at bump only). + r = dirty_resources.get(uid) or resource_cache.get(uid) + if r is None: + continue + # Manually bump updated_at since bulk_update bypasses auto_now. + r.updated_at = now_utc + resources_to_bulk_update.append(r) + if resources_to_bulk_update: + Resource.objects.bulk_update( + resources_to_bulk_update, + [ + "name", + "metadata", + "details", + "partition", + "region", + "service", + "type", + "groups", + "updated_at", + ], + batch_size=1000, + ) + # Successful execution: leave deadlock retry loop. + break + except (OperationalError, IntegrityError) as db_err: + if attempt < CELERY_DEADLOCK_ATTEMPTS - 1: + logger.warning( + f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} " + f"on micro-batch for scan {scan_instance.id}. Retrying (attempt {attempt + 1})..." + ) + time.sleep(0.1 * (2**attempt)) + # Clear accumulators that we appended to inside the failed transaction + # so the retry produces consistent results. + findings_to_create.clear() + resource_denormalized_data.clear() + tag_mappings_to_create.clear() + dirty_resources.clear() + resources_with_new_tag_mappings.clear() + continue + raise # Log skipped findings summary if skipped_findings_count > 0: @@ -845,13 +1043,18 @@ def perform_prowler_scan( group_resources_cache: dict[str, set] = {} start_time = time.time() exc = None + skip_final_scan_update = False with rls_transaction(tenant_id): provider_instance = Provider.objects.get(pk=provider_id) scan_instance = Scan.objects.get(pk=scan_id) scan_instance.state = StateChoices.EXECUTING - scan_instance.started_at = datetime.now(tz=timezone.utc) - scan_instance.save() + scan_instance.started_at = datetime.now(tz=UTC) + _save_scan_instance( + scan_instance, + provider_id, + ["state", "started_at", "updated_at"], + ) # Find the mutelist processor if it exists with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): @@ -893,10 +1096,14 @@ def perform_prowler_scan( f"Provider {provider_instance.provider} is not connected: {e}" ) finally: - provider_instance.connection_last_checked_at = datetime.now( - tz=timezone.utc + provider_instance.connection_last_checked_at = datetime.now(tz=UTC) + provider_instance.save( + update_fields=[ + "connected", + "connection_last_checked_at", + "updated_at", + ] ) - provider_instance.save() # If the provider is not connected, raise an exception outside the transaction. # If raised within the transaction, the transaction will be rolled back and the provider will not be marked @@ -911,6 +1118,13 @@ def perform_prowler_scan( last_status_cache = {} resource_failed_findings_cache = defaultdict(int) + # Throttle scan_instance progress writes to avoid hammering the writer: + # only persist when progress moves by at least `PROGRESS_THROTTLE_DELTA` + # OR `PROGRESS_THROTTLE_SECONDS` have elapsed. The final progress (100) + # always persists in the `finally` block below. + last_persisted_progress = -1.0 + last_persisted_progress_at = 0.0 + for progress, findings in prowler_scan.scan(): # Process findings in micro-batches findings_list = list(findings) @@ -937,10 +1151,24 @@ def perform_prowler_scan( group_resources_cache=group_resources_cache, ) - # Update scan progress - with rls_transaction(tenant_id): - scan_instance.progress = progress - scan_instance.save() + # Throttled progress save (the final save in the `finally` block + # below always runs regardless of throttle). + now = time.time() + progress_delta = progress - last_persisted_progress + elapsed = now - last_persisted_progress_at + if ( + progress_delta >= PROGRESS_THROTTLE_DELTA + or elapsed >= PROGRESS_THROTTLE_SECONDS + ): + with rls_transaction(tenant_id): + scan_instance.progress = progress + _save_scan_instance( + scan_instance, + provider_id, + ["progress", "updated_at"], + ) + last_persisted_progress = progress + last_persisted_progress_at = now scan_instance.state = StateChoices.COMPLETED @@ -954,25 +1182,50 @@ def perform_prowler_scan( resources_to_update.append(resource_instance) if resources_to_update: - update_objects_in_batches( - tenant_id=tenant_id, - model=Resource, - objects=resources_to_update, - fields=["failed_findings_count"], - batch_size=1000, - ) + # Single rls_transaction wrapping the bulk_update (previously + # `update_objects_in_batches` opened one rls_transaction per + # chunk; for tenants with many resources this collapsed N + # BEGINs/COMMITs into 1). + with rls_transaction(tenant_id): + Resource.objects.bulk_update( + resources_to_update, + ["failed_findings_count"], + batch_size=SCAN_DB_BATCH_SIZE, + ) + except ProviderDeletedException as e: + logger.warning(str(e)) + exception = e + skip_final_scan_update = True except Exception as e: logger.error(f"Error performing scan {scan_id}: {e}") exception = e scan_instance.state = StateChoices.FAILED finally: - with rls_transaction(tenant_id): - scan_instance.duration = time.time() - start_time - scan_instance.completed_at = datetime.now(tz=timezone.utc) - scan_instance.unique_resource_count = len(unique_resources) - scan_instance.save() + if not skip_final_scan_update: + try: + with rls_transaction(tenant_id): + scan_instance.duration = time.time() - start_time + scan_instance.completed_at = datetime.now(tz=UTC) + scan_instance.unique_resource_count = len(unique_resources) + if exception is None: + scan_instance.progress = 100 + _save_scan_instance( + scan_instance, + provider_id, + [ + "state", + "duration", + "completed_at", + "unique_resource_count", + "progress", + "updated_at", + ], + ) + except ProviderDeletedException as e: + logger.warning(str(e)) + exception = e if exception is not None: raise exception @@ -1189,17 +1442,52 @@ 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 } - 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( tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str ) -> tuple[dict, dict]: """ - Aggregate findings by region using optimized ORM queries. + Aggregate findings by region using streaming, column-scoped ORM reads. - Replaces nested Python loops with efficient queries and aggregation. + Reads only the consumed columns as tuples via ``values_list`` and streams + them with ``.iterator()``, using the denormalized ``resource_regions`` array + instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the + regions of a finding's related resources, so it yields the same per-region + tally without joining the resource table. Args: tenant_id: Tenant UUID @@ -1211,12 +1499,12 @@ def _aggregate_findings_by_region( - check_status_by_region: {region: {check_id: status}} - findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}} """ - check_status_by_region = {} - findings_count_by_compliance = {} + check_status_by_region: dict = {} + findings_count_by_compliance: dict = {} + + normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()) with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Fetch only PASS/FAIL findings (optimized query reduces data transfer) - # Other statuses are not needed for check_status or ThreatScore calculation findings = ( Finding.all_objects.filter( tenant_id=tenant_id, @@ -1224,42 +1512,28 @@ def _aggregate_findings_by_region( muted=False, status__in=["PASS", "FAIL"], ) - .only("id", "check_id", "status", "compliance") - .prefetch_related( - Prefetch( - "resources", - queryset=Resource.objects.only("id", "region"), - to_attr="small_resources", - ) + .values_list("check_id", "status", "resource_regions", "compliance") + .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + ) + + for check_id, status, resource_regions, compliance in findings: + threatscore_requirements = (compliance or {}).get( + modeled_threatscore_compliance_id ) - ) - # Process findings in a single pass (more efficient than original nested loops) - normalized_id = re.sub( - r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() - ) - - for finding in findings: - status = finding.status - - for resource in finding.small_resources: - region = resource.region - - # Aggregate check status by region - current_status = check_status_by_region.setdefault(region, {}) + for region in resource_regions or (): # Priority: FAIL > any other status - if current_status.get(finding.check_id) != "FAIL": - current_status[finding.check_id] = status + current_status = check_status_by_region.setdefault(region, {}) + if current_status.get(check_id) != "FAIL": + current_status[check_id] = status # Aggregate ThreatScore compliance counts - if modeled_threatscore_compliance_id in (finding.compliance or {}): + if threatscore_requirements: compliance_key = findings_count_by_compliance.setdefault( region, {} ).setdefault(normalized_id, {}) - for requirement_id in finding.compliance[ - modeled_threatscore_compliance_id - ]: + for requirement_id in threatscore_requirements: requirement_stats = compliance_key.setdefault( requirement_id, {"total": 0, "pass": 0} ) @@ -1306,8 +1580,8 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): (compliance_id, requirement_id) ) - compliance_requirement_rows: list[dict[str, Any]] = [] regions = [] + requirements_created = 0 requirement_statuses = defaultdict( lambda: {"fail_count": 0, "pass_count": 0, "total_count": 0} ) @@ -1347,44 +1621,93 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): else: requirement_stats["failed_checks"] += 1 - # Prepare compliance requirement rows and compute summaries in single pass - utc_datetime_now = datetime.now(tz=timezone.utc) - - # Pre-compute shared strings (optimization: reduces string conversions) + utc_datetime_now = datetime.now(tz=UTC) tenant_id_str = str(tenant_id) scan_id_str = str(scan_instance.id) - for region in regions: - region_stats = region_requirement_stats.get(region, {}) - for compliance_id, compliance in compliance_template.items(): - modeled_compliance_id = _normalized_compliance_key( - compliance["framework"], compliance["version"] + # Per-framework constants that don't depend on the region. + compliance_plan = [] + for compliance_id, compliance in compliance_template.items(): + modeled_compliance_id = _normalized_compliance_key( + compliance["framework"], compliance["version"] + ) + framework = compliance["framework"] + version = compliance["version"] or "" + requirements = [ + ( + requirement_id, + requirement.get("description") or "", + len(requirement["checks"]), ) - compliance_stats = region_stats.get(compliance_id, {}) - # Create an overview record for each requirement within each compliance framework for requirement_id, requirement in compliance[ "requirements" - ].items(): - stats = compliance_stats.get(requirement_id) - passed_checks = stats["passed_checks"] if stats else 0 - failed_checks = stats["failed_checks"] if stats else 0 - total_checks = len(requirement["checks"]) - if total_checks == 0: - requirement_status = "MANUAL" - elif failed_checks > 0: - requirement_status = "FAIL" - else: - requirement_status = "PASS" + ].items() + ] + compliance_plan.append( + ( + compliance_id, + framework, + version, + modeled_compliance_id, + requirements, + ) + ) - compliance_requirement_rows.append( - { + # Yield rows lazily (consumed batch-by-batch by COPY) so peak memory + # stays bounded; tally requirement_statuses in the same pass. + def _iter_compliance_requirement_rows(): + for region in regions: + region_stats = region_requirement_stats.get(region, {}) + region_findings = findings_count_by_compliance.get(region, {}) + for ( + compliance_id, + framework, + version, + modeled_compliance_id, + requirements, + ) in compliance_plan: + compliance_stats = region_stats.get(compliance_id, {}) + compliance_findings = region_findings.get( + modeled_compliance_id, {} + ) + for requirement_id, description, total_checks in requirements: + stats = compliance_stats.get(requirement_id) + if stats: + passed_checks = stats["passed_checks"] + failed_checks = stats["failed_checks"] + else: + passed_checks = 0 + failed_checks = 0 + if total_checks == 0: + requirement_status = "MANUAL" + elif failed_checks > 0: + requirement_status = "FAIL" + else: + requirement_status = "PASS" + + finding_counts = compliance_findings.get(requirement_id) + if finding_counts: + passed_findings = finding_counts.get("pass", 0) + total_findings = finding_counts.get("total", 0) + else: + passed_findings = 0 + total_findings = 0 + + key = (compliance_id, requirement_id) + requirement_statuses[key]["total_count"] += 1 + if requirement_status == "FAIL": + requirement_statuses[key]["fail_count"] += 1 + elif requirement_status == "PASS": + requirement_statuses[key]["pass_count"] += 1 + + yield { "id": uuid.uuid4(), "tenant_id": tenant_id_str, "inserted_at": utc_datetime_now, "compliance_id": compliance_id, - "framework": compliance["framework"], - "version": compliance["version"] or "", - "description": requirement.get("description") or "", + "framework": framework, + "version": version, + "description": description, "region": region, "requirement_id": requirement_id, "requirement_status": requirement_status, @@ -1392,37 +1715,23 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): "failed_checks": failed_checks, "total_checks": total_checks, "scan_id": scan_id_str, - "passed_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("pass", 0), - "total_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("total", 0), + "passed_findings": passed_findings, + "total_findings": total_findings, } - ) - # Update summary tracking (single-pass optimization) - key = (compliance_id, requirement_id) - requirement_statuses[key]["total_count"] += 1 - if requirement_status == "FAIL": - requirement_statuses[key]["fail_count"] += 1 - elif requirement_status == "PASS": - requirement_statuses[key]["pass_count"] += 1 + # Idempotent re-run: clear this scan's rows before re-inserting. + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() - # Bulk create requirement records using PostgreSQL COPY - _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows) + requirements_created = _persist_compliance_requirement_rows( + tenant_id, _iter_compliance_requirement_rows() + ) # Create pre-aggregated summaries for fast compliance overview lookups _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) return { - "requirements_created": len(compliance_requirement_rows), + "requirements_created": requirements_created, "regions_processed": list(regions), "compliance_frameworks": ( list(compliance_template.keys()) if regions else [] @@ -1535,13 +1844,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}") @@ -1786,9 +2106,7 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): summary_timestamp = scan.completed_at if django_timezone.is_naive(summary_timestamp): - summary_timestamp = django_timezone.make_aware( - summary_timestamp, timezone.utc - ) + summary_timestamp = django_timezone.make_aware(summary_timestamp, UTC) summary_timestamp = summary_timestamp.replace( hour=0, minute=0, second=0, microsecond=0 ) @@ -1803,7 +2121,10 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): output_field=IntegerField(), ) - # Aggregate findings by check_id for this scan + # Aggregate findings by check_id for this scan. + # `pass_count`, `fail_count` and `manual_count` only count non-muted + # findings. Muted findings are tracked separately via the + # `*_muted_count` fields. aggregated = ( Finding.objects.filter( tenant_id=tenant_id, @@ -1814,9 +2135,50 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): severity_order=Max(severity_case), pass_count=Count("id", filter=Q(status="PASS", muted=False)), fail_count=Count("id", filter=Q(status="FAIL", muted=False)), + manual_count=Count("id", filter=Q(status="MANUAL", muted=False)), + pass_muted_count=Count("id", filter=Q(status="PASS", muted=True)), + fail_muted_count=Count("id", filter=Q(status="FAIL", muted=True)), + manual_muted_count=Count("id", filter=Q(status="MANUAL", muted=True)), muted_count=Count("id", filter=Q(muted=True)), + nonmuted_count=Count("id", filter=Q(muted=False)), new_count=Count("id", filter=Q(delta="new", muted=False)), changed_count=Count("id", filter=Q(delta="changed", muted=False)), + new_fail_count=Count( + "id", filter=Q(delta="new", status="FAIL", muted=False) + ), + new_fail_muted_count=Count( + "id", filter=Q(delta="new", status="FAIL", muted=True) + ), + new_pass_count=Count( + "id", filter=Q(delta="new", status="PASS", muted=False) + ), + new_pass_muted_count=Count( + "id", filter=Q(delta="new", status="PASS", muted=True) + ), + new_manual_count=Count( + "id", filter=Q(delta="new", status="MANUAL", muted=False) + ), + new_manual_muted_count=Count( + "id", filter=Q(delta="new", status="MANUAL", muted=True) + ), + changed_fail_count=Count( + "id", filter=Q(delta="changed", status="FAIL", muted=False) + ), + changed_fail_muted_count=Count( + "id", filter=Q(delta="changed", status="FAIL", muted=True) + ), + changed_pass_count=Count( + "id", filter=Q(delta="changed", status="PASS", muted=False) + ), + changed_pass_muted_count=Count( + "id", filter=Q(delta="changed", status="PASS", muted=True) + ), + changed_manual_count=Count( + "id", filter=Q(delta="changed", status="MANUAL", muted=False) + ), + changed_manual_muted_count=Count( + "id", filter=Q(delta="changed", status="MANUAL", muted=True) + ), resources_total=Count("resources__id", distinct=True), resources_fail=Count( "resources__id", @@ -1824,7 +2186,9 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): filter=Q(status="FAIL", muted=False), ), # Use prefixed names to avoid conflict with model field names - agg_first_seen_at=Min("first_seen_at"), + agg_first_seen_at=Min( + "first_seen_at", filter=Q(delta="new", muted=False) + ), agg_last_seen_at=Max("inserted_at"), agg_failing_since=Min( "first_seen_at", filter=Q(status="FAIL", muted=False) @@ -1893,9 +2257,26 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): severity_order=row["severity_order"] or 1, pass_count=row["pass_count"], fail_count=row["fail_count"], + manual_count=row["manual_count"], + pass_muted_count=row["pass_muted_count"], + fail_muted_count=row["fail_muted_count"], + manual_muted_count=row["manual_muted_count"], muted_count=row["muted_count"], + muted=row["nonmuted_count"] == 0, new_count=row["new_count"], changed_count=row["changed_count"], + new_fail_count=row["new_fail_count"], + new_fail_muted_count=row["new_fail_muted_count"], + new_pass_count=row["new_pass_count"], + new_pass_muted_count=row["new_pass_muted_count"], + new_manual_count=row["new_manual_count"], + new_manual_muted_count=row["new_manual_muted_count"], + changed_fail_count=row["changed_fail_count"], + changed_fail_muted_count=row["changed_fail_muted_count"], + changed_pass_count=row["changed_pass_count"], + changed_pass_muted_count=row["changed_pass_muted_count"], + changed_manual_count=row["changed_manual_count"], + changed_manual_muted_count=row["changed_manual_muted_count"], resources_total=row["resources_total"], resources_fail=row["resources_fail"], first_seen_at=row["agg_first_seen_at"], @@ -1915,9 +2296,26 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): "severity_order", "pass_count", "fail_count", + "manual_count", + "pass_muted_count", + "fail_muted_count", + "manual_muted_count", "muted_count", + "muted", "new_count", "changed_count", + "new_fail_count", + "new_fail_muted_count", + "new_pass_count", + "new_pass_muted_count", + "new_manual_count", + "new_manual_muted_count", + "changed_fail_count", + "changed_fail_muted_count", + "changed_pass_count", + "changed_pass_muted_count", + "changed_manual_count", + "changed_manual_muted_count", "resources_total", "resources_fail", "first_seen_at", @@ -1939,3 +2337,169 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): "created": created_count, "updated": updated_count, } + + +def reset_ephemeral_resource_findings_count(tenant_id: str, scan_id: str) -> dict: + """Zero failed_findings_count for resources missing from a completed full-scope scan. + + Resources that exist in the database for the scan's provider but were not + touched by this scan are treated as ephemeral. We keep their historical + findings, but reset the denormalized counter that drives the Resources page + sort so they stop ranking at the top. + + Skipped (no-op) when: + - The scan is not in COMPLETED state. + - The scan ran with any scoping filter in scanner_args (partial scope). + + Query design (must scale to 500k+ resources per provider): + Phase 1 — collect ephemeral IDs with one anti-join read. + Outer filter ``(tenant_id, provider_id, failed_findings_count > 0)`` + uses ``resources_tenant_provider_idx``. The correlated + ``NOT EXISTS`` subquery hits the implicit unique index + ``(tenant_id, scan_id, resource_id)`` on ``ResourceScanSummary``. + ``NOT EXISTS`` (vs ``NOT IN``) is null-safe and lets the planner + choose between hash anti-join and indexed nested-loop anti-join. + ``.iterator(chunk_size=...)`` skips the queryset cache so memory + stays bounded while streaming UUIDs. + Phase 2 — UPDATE in fixed-size batches. + One large UPDATE would hold row-exclusive locks for seconds and + create a WAL spike. Batched UPDATEs by ``id__in`` (~1k rows each) + hit the primary key, keep each lock window ~50ms, bound WAL chunks, + and let other writers proceed between batches. + ``failed_findings_count__gt=0`` in the UPDATE is idempotent under + concurrent scans and skips no-op rewrites. + Reads use the primary DB, not the replica: ``ResourceScanSummary`` rows + were written by the same scan task that triggered this one, so replica + lag could falsely classify scanned resources as ephemeral. + + Scope detection (``Scan.is_full_scope()``) derives the set of scoping + scanner_args from ``prowler.lib.scan.scan.Scan.__init__`` via + introspection, so the API can never drift from the SDK's filter + contract. Imported scans are also rejected by trigger — they may only + cover a partial slice of resources. + """ + with rls_transaction(tenant_id): + scan = Scan.objects.filter(tenant_id=tenant_id, id=scan_id).first() + + if scan is None: + logger.warning(f"Scan {scan_id} not found") + return {"status": "skipped", "reason": "scan not found"} + + if scan.state != StateChoices.COMPLETED: + logger.info(f"Scan {scan_id} not completed; skipping ephemeral reset") + return {"status": "skipped", "reason": "scan not completed"} + + if not scan.is_full_scope(): + logger.info( + f"Scan {scan_id} ran with scoping filters; skipping ephemeral reset" + ) + return {"status": "skipped", "reason": "partial scan scope"} + + # Race protection: if a newer completed full-scope scan exists for this + # provider, our ResourceScanSummary set is stale relative to the resources' + # current failed_findings_count values (which the newer scan already + # refreshed). Wiping based on the older scan would zero counts the newer + # scan just set. Skip and let the newer scan's reset task do the work; if + # this task was delayed in the queue, that's the correct outcome. + # `completed_at__isnull=False` is required: Postgres orders NULL first in + # DESC, so a sibling COMPLETED scan with a missing completed_at would sort + # as "newest" and incorrectly cause us to skip. + with rls_transaction(tenant_id): + latest_full_scope_scan_id = ( + Scan.objects.filter( + tenant_id=tenant_id, + provider_id=scan.provider_id, + state=StateChoices.COMPLETED, + completed_at__isnull=False, + ) + .order_by("-completed_at", "-inserted_at") + .values_list("id", flat=True) + .first() + ) + if latest_full_scope_scan_id != scan.id: + logger.info( + f"Scan {scan_id} is not the latest completed scan for provider " + f"{scan.provider_id}; skipping ephemeral reset" + ) + return {"status": "skipped", "reason": "newer scan exists"} + + # Defensive gate: ResourceScanSummary rows are written by perform_prowler_scan + # via best-effort bulk_create. If those writes failed silently (or the scan + # genuinely produced resources but no summaries were persisted), the + # ~Exists(in_scan) anti-join below would classify EVERY resource for this + # provider as ephemeral and zero their counts. Bail loudly instead. + with rls_transaction(tenant_id): + summaries_present = ResourceScanSummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).exists() + if scan.unique_resource_count > 0 and not summaries_present: + logger.error( + f"Scan {scan_id} reports {scan.unique_resource_count} unique " + f"resources but no ResourceScanSummary rows are persisted; " + f"skipping ephemeral reset to avoid wiping valid counts" + ) + return {"status": "skipped", "reason": "summaries missing"} + + # Stays on the primary DB intentionally. ResourceScanSummary rows are + # written by perform_prowler_scan in the same chain that triggered this + # task, so replica lag could return an empty/partial summary set; a stale + # read here would classify every Resource as ephemeral and wipe valid + # failed_findings_count values on the primary. Same rationale as + # update_provider_compliance_scores below in this module. + # Materializing the ID list (rather than streaming the iterator into + # batched UPDATEs) is intentional: it lets the UPDATEs run in their own + # short rls_transactions instead of one long transaction holding row locks + # on every batch. At 500k UUIDs the peak memory is ~40 MB — acceptable for + # a Celery worker — and is the better trade-off versus a multi-second + # write-lock window blocking concurrent scans. + with rls_transaction(tenant_id): + in_scan = ResourceScanSummary.objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + resource_id=OuterRef("pk"), + ) + ephemeral_ids = list( + Resource.objects.filter( + tenant_id=tenant_id, + provider_id=scan.provider_id, + failed_findings_count__gt=0, + ) + .filter(~Exists(in_scan)) + .values_list("id", flat=True) + .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + ) + + if not ephemeral_ids: + logger.info(f"No ephemeral resources for scan {scan_id}") + return { + "status": "completed", + "scan_id": str(scan_id), + "provider_id": str(scan.provider_id), + "reset": 0, + } + + total_updated = 0 + for batch, _ in batched(ephemeral_ids, DJANGO_FINDINGS_BATCH_SIZE): + # batched() always yields a final tuple, which is empty when the input + # length is an exact multiple of the batch size. Skip it so we don't + # issue a no-op UPDATE ... WHERE id IN (). + if not batch: + continue + with rls_transaction(tenant_id): + total_updated += Resource.objects.filter( + tenant_id=tenant_id, + id__in=batch, + failed_findings_count__gt=0, + ).update(failed_findings_count=0) + + logger.info( + f"Ephemeral resource reset for scan {scan_id}: " + f"{total_updated} resources zeroed for provider {scan.provider_id}" + ) + + return { + "status": "completed", + "scan_id": str(scan_id), + "provider_id": str(scan.provider_id), + "reset": total_updated, + } diff --git a/api/src/backend/tasks/jobs/threatscore.py b/api/src/backend/tasks/jobs/threatscore.py index a9a7516e55..663c179ea2 100644 --- a/api/src/backend/tasks/jobs/threatscore.py +++ b/api/src/backend/tasks/jobs/threatscore.py @@ -1,14 +1,13 @@ +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Provider, StatusChoices from celery.utils.log import get_task_logger +from prowler.lib.check.compliance_models import Compliance from tasks.jobs.threatscore_utils import ( _aggregate_requirement_statistics_from_database, _calculate_requirements_data_from_statistics, ) -from api.db_router import READ_REPLICA_ALIAS -from api.db_utils import rls_transaction -from api.models import Provider, StatusChoices -from prowler.lib.check.compliance_models import Compliance - logger = get_task_logger(__name__) diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 2ef29484ee..2e2fb87ba5 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -1,11 +1,12 @@ -from celery.utils.log import get_task_logger -from config.django.base import DJANGO_FINDINGS_BATCH_SIZE -from django.db.models import Count, Q - from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction from api.models import Finding, Scan, StatusChoices +from celery.utils.log import get_task_logger +from config.django.base import DJANGO_FINDINGS_BATCH_SIZE +from django.db.models import Count, F, Q, Window +from django.db.models.functions import RowNumber from prowler.lib.outputs.finding import Finding as FindingOutput +from tasks.jobs.reports.config import MAX_FINDINGS_PER_CHECK logger = get_task_logger(__name__) @@ -154,6 +155,8 @@ def _load_findings_for_requirement_checks( check_ids: list[str], prowler_provider, findings_cache: dict[str, list[FindingOutput]] | None = None, + total_counts_out: dict[str, int] | None = None, + only_failed_findings: bool = False, ) -> dict[str, list[FindingOutput]]: """ Load findings for specific check IDs on-demand with optional caching. @@ -178,6 +181,23 @@ def _load_findings_for_requirement_checks( prowler_provider: The initialized Prowler provider instance. findings_cache (dict, optional): Cache of already loaded findings. If provided, checks are first looked up in cache before querying database. + total_counts_out (dict, optional): If provided, populated with + ``{check_id: total_findings_in_db}`` BEFORE any per-check cap is + applied. Lets callers render a "Showing first N of M" banner for + truncated checks. Only populated for ``check_ids`` actually + queried (cache hits keep whatever value the caller already had). + When ``only_failed_findings=True`` the total is FAIL-only. + only_failed_findings (bool): When True, push the ``status=FAIL`` + filter down into the SQL query so PASS rows are never loaded + from the DB nor pydantic-transformed. This matches the + ``only_failed`` requirement-level filter applied at PDF render + time: a requirement marked FAIL because 1/1000 findings failed + shouldn't render a table of 999 PASS rows. That hides the + actual failure under noise and wastes the per-check cap on + irrelevant data. NOTE: the findings cache stores whatever the + first caller asked for, so all callers in a single + ``generate_compliance_reports`` run MUST pass the same flag + (which they do: it threads from ``only_failed`` defaults). Returns: dict[str, list[FindingOutput]]: Dictionary mapping check_id to list of FindingOutput objects. @@ -222,17 +242,88 @@ def _load_findings_for_requirement_checks( ) with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Use iterator with chunk_size for memory-efficient streaming - # chunk_size controls how many rows Django fetches from DB at once - findings_queryset = ( - Finding.all_objects.filter( - tenant_id=tenant_id, - scan_id=scan_id, - check_id__in=check_ids_to_load, - ) - .order_by("check_id", "uid") - .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + base_qs = Finding.all_objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + check_id__in=check_ids_to_load, ) + if only_failed_findings: + # Push the FAIL filter down into SQL: DB returns ~N×FAIL + # rows instead of N×ALL, and we never spend pydantic CPU on + # PASS findings the PDF would never render. + base_qs = base_qs.filter(status=StatusChoices.FAIL) + + # Aggregate totals once so we (a) know which checks need capping + # and (b) can surface "Showing first N of M" in the PDF banner. + # Cheap: a single COUNT grouped by check_id. + totals: dict[str, int] = { + row["check_id"]: row["total"] + for row in base_qs.values("check_id").annotate(total=Count("id")) + } + if total_counts_out is not None: + total_counts_out.update(totals) + + cap = MAX_FINDINGS_PER_CHECK + checks_over_cap = ( + {cid for cid, n in totals.items() if n > cap} if cap > 0 else set() + ) + + # Use iterator with chunk_size for memory-efficient streaming. + # FindingOutput.transform_api_finding (prowler/lib/outputs/finding.py) + # reads finding.resources.first() and resource.tags.all() per + # finding, which without prefetch generates 2N queries per chunk. + # prefetch_related runs once per iterator chunk (Django >=4.1) and + # collapses that into a constant 2 extra queries per chunk. + if checks_over_cap: + # Two-step query so we can both cap rows per check AND attach + # prefetch_related on the streamed results: + # + # 1) ``ranked`` annotates every matching finding with a + # per-check row number via a window function. The + # partition keeps numbering independent per check, and + # ordering by ``uid`` makes the "first N" selection + # deterministic across runs (same scan → same rows). + # + # 2) The outer ``Finding.all_objects.filter(id__in=...)`` + # keeps only IDs whose row number is within the cap and + # re-opens a plain queryset on it. Django cannot combine + # ``Window`` annotations with ``prefetch_related`` on the + # same queryset (the window is evaluated post-aggregation + # and the prefetch loader fights with it), so the inner + # SELECT becomes a subquery and the outer queryset is + # free to prefetch resources/tags as usual. + # + # PostgreSQL only materialises + # ``cap * |checks_over_cap| + sum(uncapped)`` rows for the + # window step, vs the full table scan the previous path did. + ranked = base_qs.annotate( + rn=Window( + expression=RowNumber(), + partition_by=[F("check_id")], + order_by=F("uid").asc(), + ) + ) + findings_queryset = ( + Finding.all_objects.filter( + id__in=ranked.filter(rn__lte=cap).values("id") + ) + .prefetch_related("resources", "resources__tags") + .order_by("check_id", "uid") + .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + ) + logger.info( + "Per-check cap=%d active for %d checks (max %d each); " + "skipping transform for surplus rows", + cap, + len(checks_over_cap), + cap, + ) + else: + findings_queryset = ( + base_qs.prefetch_related("resources", "resources__tags") + .order_by("check_id", "uid") + .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + ) # Pre-initialize empty lists for all check_ids to load # This avoids repeated dict lookups and 'if not in' checks @@ -248,7 +339,11 @@ def _load_findings_for_requirement_checks( findings_count += 1 logger.info( - f"Loaded {findings_count} findings for {len(check_ids_to_load)} checks" + "Loaded %d findings for %d checks (truncated %d checks total=%d)", + findings_count, + len(check_ids_to_load), + len(checks_over_cap), + sum(totals.values()), ) # Build result dict using cache references (no data duplication) @@ -258,3 +353,45 @@ def _load_findings_for_requirement_checks( } return result + + +def _get_compliance_check_ids(compliance_obj) -> set[str]: + """Return the union of all check_ids referenced by a compliance framework. + + Used by the master report orchestrator to evict entries from + ``findings_cache`` once no pending framework needs them (PROWLER-1733). + + Accepts the legacy ``Compliance`` shape (``Requirements`` / ``Checks`` + lists) and the universal ``ComplianceFramework`` shape (``requirements`` + / ``checks`` dict keyed by provider). ``None`` returns an empty set so + callers can pass ``frameworks_bulk.get(...)`` directly. + """ + if compliance_obj is None: + return set() + + requirements = getattr(compliance_obj, "Requirements", None) or getattr( + compliance_obj, "requirements", None + ) + if not requirements: + return set() + + check_ids: set[str] = set() + try: + # Mock objects in unit tests return another Mock for any attribute + # access — truthy but not iterable. Treat that as "no checks". + for requirement in requirements: + requirement_checks = getattr(requirement, "Checks", None) + if requirement_checks is None: + checks_by_provider = getattr(requirement, "checks", None) or {} + requirement_checks = [ + check_id + for check_ids_list in checks_by_provider.values() + for check_id in check_ids_list + ] + try: + check_ids.update(requirement_checks) + except TypeError: + continue + except TypeError: + return set() + return check_ids diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index fbd0440af8..e7bb0982cd 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -1,13 +1,29 @@ import os -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from shutil import rmtree +from api.compliance import ( + get_compliance_frameworks, + get_prowler_provider_compliance, +) +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import delete_related_daily_task, rls_transaction +from api.decorators import handle_provider_deletion, set_tenant +from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices +from api.utils import initialize_prowler_provider +from api.v1.serializers import ScanTaskSerializer from celery import chain, group, shared_task from celery.utils.log import get_task_logger from config.celery import RLSTask from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY from django_celery_beat.models import PeriodicTask +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance import ( + process_universal_compliance_frameworks, +) +from prowler.lib.outputs.compliance.generic.generic import GenericCompliance +from prowler.lib.outputs.finding import Finding as FindingOutput from tasks.jobs.attack_paths import ( attack_paths_scan, can_provider_run_attack_paths_scan, @@ -15,13 +31,13 @@ from tasks.jobs.attack_paths import ( from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans from tasks.jobs.backfill import ( + aggregate_scan_category_summaries, + aggregate_scan_resource_group_summaries, backfill_compliance_summaries, backfill_daily_severity_summaries, backfill_finding_group_summaries, backfill_provider_compliance_scores, backfill_resource_scan_summaries, - backfill_scan_category_summaries, - backfill_scan_resource_group_summaries, ) from tasks.jobs.connection import ( check_integration_connection, @@ -46,7 +62,12 @@ 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.orphan_recovery import reconcile_orphans +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, @@ -54,6 +75,7 @@ from tasks.jobs.scan import ( aggregate_findings, create_compliance_requirements, perform_prowler_scan, + reset_ephemeral_resource_findings_count, update_provider_compliance_scores, ) from tasks.utils import ( @@ -62,17 +84,6 @@ from tasks.utils import ( get_next_execution_datetime, ) -from api.compliance import get_compliance_frameworks -from api.db_router import READ_REPLICA_ALIAS -from api.db_utils import rls_transaction -from api.decorators import handle_provider_deletion, set_tenant -from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices -from api.utils import initialize_prowler_provider -from api.v1.serializers import ScanTaskSerializer -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.generic.generic import GenericCompliance -from prowler.lib.outputs.finding import Finding as FindingOutput - logger = get_task_logger(__name__) @@ -154,6 +165,13 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str) generate_outputs_task.si( scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id ), + # post-scan task — runs in the parallel group so a + # failure cannot cascade into reports or integrations. Its only + # prerequisite is that perform_prowler_scan has committed + # ResourceScanSummary, which is true by the time this chain fires. + reset_ephemeral_resource_findings_count_task.si( + tenant_id=tenant_id, scan_id=scan_id + ), ), group( # Use optimized task that generates both reports with shared queries @@ -169,10 +187,25 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str) ).apply_async() if can_provider_run_attack_paths_scan(tenant_id, provider_id): - perform_attack_paths_scan_task.apply_async( + # Row is normally created upstream, so this is a safeguard so we can attach the task id below + attack_paths_scan = attack_paths_db_utils.retrieve_attack_paths_scan( + tenant_id, scan_id + ) + if attack_paths_scan is None: + attack_paths_scan = attack_paths_db_utils.create_attack_paths_scan( + tenant_id, scan_id, provider_id + ) + + # Persist the Celery task id so the periodic cleanup can revoke scans stuck in SCHEDULED + result = perform_attack_paths_scan_task.apply_async( kwargs={"tenant_id": tenant_id, "scan_id": scan_id} ) + if attack_paths_scan and result: + attack_paths_db_utils.set_attack_paths_scan_task_id( + tenant_id, attack_paths_scan.id, result.task_id + ) + @shared_task(base=RLSTask, name="provider-connection-check") @set_tenant @@ -225,7 +258,9 @@ def delete_provider_task(provider_id: str, tenant_id: str): return delete_provider(tenant_id=tenant_id, pk=provider_id) -@shared_task(base=RLSTask, name="scan-perform", queue="scans") +# acks_late=False: a re-run would duplicate findings and the task is not auto-recovered, +# so a crashed scan is dropped rather than redelivered by the broker (as before #11416). +@shared_task(base=RLSTask, name="scan-perform", queue="scans", acks_late=False) @handle_provider_deletion def perform_scan_task( tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None @@ -246,6 +281,17 @@ def perform_scan_task( Returns: dict: The result of the scan execution, typically including the status and results of the performed checks. """ + with rls_transaction(tenant_id): + if not Provider.objects.filter(pk=provider_id).exists(): + logger.warning( + "scan-perform skipped: provider %s no longer exists " + "(tenant=%s, scan=%s)", + provider_id, + tenant_id, + scan_id, + ) + return None + result = perform_prowler_scan( tenant_id=tenant_id, scan_id=scan_id, @@ -258,7 +304,14 @@ def perform_scan_task( return result -@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans") +# acks_late=False: like scan-perform; a dropped run is re-fired by Beat on the next tick. +@shared_task( + base=RLSTask, + bind=True, + name="scan-perform-scheduled", + queue="scans", + acks_late=False, +) @handle_provider_deletion def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): """ @@ -282,6 +335,16 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): task_id = self.request.id with rls_transaction(tenant_id): + if not Provider.objects.filter(pk=provider_id).exists(): + logger.warning( + "scheduled scan-perform skipped: provider %s no longer exists " + "(tenant=%s)", + provider_id, + tenant_id, + ) + delete_related_daily_task(provider_id) + return None + periodic_task_instance = PeriodicTask.objects.get( name=f"scan-perform-scheduled-{provider_id}" ) @@ -342,7 +405,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): ) finally: with rls_transaction(tenant_id): - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if next_scan_datetime <= now: interval_delta = timedelta(**{interval.period: interval.every}) while next_scan_datetime <= now: @@ -374,7 +437,8 @@ class AttackPathsScanRLSTask(RLSTask): SDK initialization, or Neo4j configuration errors during setup). """ - def on_failure(self, exc, task_id, args, kwargs, _einfo): + def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002 + del args # Required by Celery's Task.on_failure signature; not used. tenant_id = kwargs.get("tenant_id") scan_id = kwargs.get("scan_id") @@ -412,13 +476,42 @@ def cleanup_stale_attack_paths_scans_task(): return cleanup_stale_attack_paths_scans() +@shared_task(name="reconcile-orphan-tasks", queue="celery") +def reconcile_orphan_tasks_task(): + """Periodic watchdog: recover tasks whose worker is gone (deploys, crashes).""" + return reconcile_orphans() + + @shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,)) def delete_tenant_task(tenant_id: str): return delete_tenant(pk=tenant_id) +def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path: + """Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id}).""" + return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id) + + +class ScanReportRLSTask(RLSTask): + """ + RLS task that removes the scan's tmp output directory when the task fails. + + Covers failures both inside and outside the task body (e.g. ENOSPC mid-write, + or setup errors) so partial artifacts do not accumulate on the worker disk. + """ + + def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002 + del args # Required by Celery's Task.on_failure signature; not used. + tenant_id = kwargs.get("tenant_id") + scan_id = kwargs.get("scan_id") + + if tenant_id and scan_id: + logger.error(f"Scan report task {task_id} failed: {exc}") + rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True) + + @shared_task( - base=RLSTask, + base=ScanReportRLSTask, name="scan-report", queue="scan-reports", ) @@ -440,6 +533,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}") @@ -450,11 +556,23 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): provider_uid = provider_obj.uid provider_type = provider_obj.provider + # Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk. frameworks_bulk = Compliance.get_bulk(provider_type) + # Universal-only frameworks (top-level JSONs like `dora_2022_2554.json`) are emitted + # via `process_universal_compliance_frameworks` below. + universal_bulk = get_prowler_provider_compliance(provider_type) + universal_only_names = { + name + for name in universal_bulk + if name not in frameworks_bulk and universal_bulk[name].outputs + } frameworks_avail = get_compliance_frameworks(provider_type) out_dir, comp_dir = _generate_output_directory( DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id ) + # Removed on success here and on failure by ScanReportRLSTask.on_failure, + # so partial artifacts do not accumulate and fill the disk (ENOSPC). + scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id) def get_writer(writer_map, name, factory, is_last): """ @@ -472,6 +590,10 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): output_writers = {} compliance_writers = {} + # Shared across batches so universal writers are created once and reused. + universal_compliance_state: dict[str, list] = {"compliance": []} + universal_base_dir = os.path.dirname(out_dir) + universal_output_filename = os.path.basename(out_dir) scan_summary = FindingOutput._transform_findings_stats( ScanSummary.objects.filter(scan_id=scan_id) @@ -526,8 +648,30 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): writer.batch_write_data_to_file(**extra) writer._data.clear() - # Compliance CSVs + # Universal-only frameworks (e.g. `dora_2022_2554.json`). + if universal_only_names: + process_universal_compliance_frameworks( + input_compliance_frameworks=universal_only_names, + universal_frameworks=universal_bulk, + finding_outputs=fos, + output_directory=universal_base_dir, + output_filename=universal_output_filename, + provider=provider_type, + generated_outputs=universal_compliance_state, + from_cli=False, + is_last=is_last, + ) + + # Compliance CSVs (per-framework exporters). for name in frameworks_avail: + if name in universal_only_names: + continue + if name not in frameworks_bulk: + logger.warning( + "Compliance framework '%s' missing from bulk; skipping CSV export", + name, + ) + continue compliance_obj = frameworks_bulk[name] klass = GenericCompliance @@ -603,7 +747,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): # TODO: We need to create a new periodic task to delete the output files # This task shouldn't be responsible for deleting the output files try: - rmtree(Path(compressed).parent, ignore_errors=True) + rmtree(scan_tmp_dir, ignore_errors=True) except Exception as e: logger.error(f"Error deleting output files: {e}") final_location, did_upload = upload_uri, True @@ -659,9 +803,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 +815,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 +830,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") @@ -758,6 +902,32 @@ def aggregate_daily_severity_task(tenant_id: str, scan_id: str): return aggregate_daily_severity(tenant_id=tenant_id, scan_id=scan_id) +@shared_task(name="scan-reset-ephemeral-resources", queue="overview") +@handle_provider_deletion +def reset_ephemeral_resource_findings_count_task(tenant_id: str, scan_id: str): + """Reset failed_findings_count for resources missing from a completed full-scope scan. + + Failures are swallowed and returned as a status: this task lives inside the + post-scan group, and Celery propagates group-member exceptions into the next + chain step — meaning a crash here would block compliance reports and + integrations. The reset is purely cosmetic (UI sort optimization), so a + bad run is logged and absorbed rather than allowed to cascade. + """ + try: + return reset_ephemeral_resource_findings_count( + tenant_id=tenant_id, scan_id=scan_id + ) + except Exception as exc: # noqa: BLE001 — intentionally broad + logger.exception( + f"reset_ephemeral_resource_findings_count failed for scan {scan_id}: {exc}" + ) + return { + "status": "failed", + "scan_id": str(scan_id), + "reason": str(exc), + } + + @shared_task(base=RLSTask, name="scan-finding-group-summaries", queue="overview") @set_tenant(keep_tenant=True) @handle_provider_deletion @@ -771,26 +941,80 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str): ) @set_tenant(keep_tenant=True) def reaggregate_all_finding_group_summaries_task(tenant_id: str): - """Reaggregate finding group summaries for all providers' latest completed scans.""" - latest_scan_ids = list( - Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) - .order_by("provider_id", "-completed_at", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) - if latest_scan_ids: - logger.info( - "Reaggregating finding group summaries for %d scans: %s", - len(latest_scan_ids), - latest_scan_ids, + """Reaggregate every pre-aggregated summary table for this tenant. + + Mirrors the unbounded scope of `mute_historical_findings_task`: that task + rewrites every Finding row whose UID matches a mute rule, with no time + 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 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. + """ + completed_scans = list( + Scan.objects.filter( + tenant_id=tenant_id, + state=StateChoices.COMPLETED, + completed_at__isnull=False, ) + .order_by("-completed_at") + .values("id", "completed_at", "provider_id") + ) + + # Keep the latest scan per (provider, day) pair so the daily summary row + # the aggregator writes is the most recent snapshot of that day for that + # provider. Iterating from most recent to oldest means the first scan we + # see for a given key wins. + latest_scans: dict[tuple, str] = {} + for scan in completed_scans: + key = (scan["provider_id"], scan["completed_at"].date()) + if key not in latest_scans: + latest_scans[key] = str(scan["id"]) + + scan_ids = list(latest_scans.values()) + if scan_ids: + logger.info( + "Reaggregating overview/finding summaries for %d scans (provider x day)", + len(scan_ids), + ) + # DailySeveritySummary reads from ScanSummary, so ScanSummary must be + # recomputed first; the other aggregators read Finding directly and + # can run in parallel with the severity step. group( - aggregate_finding_group_summaries_task.si( - tenant_id=tenant_id, scan_id=str(scan_id) + chain( + perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id), + group( + aggregate_daily_severity_task.si( + tenant_id=tenant_id, scan_id=scan_id + ), + 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 latest_scan_ids + for scan_id in scan_ids ).apply_async() - return {"scans_reaggregated": len(latest_scan_ids)} + return {"scans_reaggregated": len(scan_ids)} @shared_task(base=RLSTask, name="lighthouse-connection-check") @@ -934,10 +1158,13 @@ def security_hub_integration_task( return upload_security_hub_integration(tenant_id, provider_id, scan_id) +# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered, +# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues). @shared_task( base=RLSTask, name="integration-jira", queue="integrations", + acks_late=False, ) def jira_integration_task( tenant_id: str, @@ -959,13 +1186,17 @@ def jira_integration_task( @handle_provider_deletion def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str): """ - Optimized task to generate ThreatScore, ENS, NIS2, and CSA CCM reports with shared queries. + Optimized task to generate ThreatScore, ENS, NIS2, CSA CCM and CIS reports with shared queries. This task is more efficient than running separate report tasks because it reuses database queries: - Provider object fetched once (instead of multiple times) - Requirement statistics aggregated once (instead of multiple times) - Can reduce database load by up to 50-70% + CIS emits a single PDF per run: the one matching the highest CIS version + available for the scan's provider, picked dynamically from + ``Compliance.get_bulk`` (no hard-coded provider → version mapping). + Args: tenant_id (str): The tenant identifier. scan_id (str): The scan identifier. @@ -982,6 +1213,7 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: generate_ens=True, generate_nis2=True, generate_csa=True, + generate_cis=True, ) diff --git a/api/src/backend/tasks/tests/test_attack_paths_provider_config.py b/api/src/backend/tasks/tests/test_attack_paths_provider_config.py new file mode 100644 index 0000000000..41ec2847d4 --- /dev/null +++ b/api/src/backend/tasks/tests/test_attack_paths_provider_config.py @@ -0,0 +1,30 @@ +from tasks.jobs.attack_paths.provider_config import AWS_NORMALIZED_LISTS +from tasks.jobs.attack_paths.sync import _build_catalog_index, _node_to_sync_dict + + +def test_aws_vpc_endpoint_id_lists_are_normalized(): + catalog = _build_catalog_index(AWS_NORMALIZED_LISTS) + record = { + "element_id": "node-1", + "labels": ["AWSVpcEndpoint"], + "props": { + "id": "vpce-123", + "route_table_ids": ["rtb-1"], + "network_interface_ids": ["eni-1"], + "subnet_ids": ["subnet-1"], + }, + } + + _, parent, children, rels = _node_to_sync_dict(record, "provider-id", catalog) + + assert parent["props"] == {"id": "vpce-123"} + assert {child["_child_label"] for child in children} == { + "AWSVpcEndpointRouteTableIdsItem", + "AWSVpcEndpointNetworkInterfaceIdsItem", + "AWSVpcEndpointSubnetIdsItem", + } + assert {rel["rel_type"] for rel in rels} == { + "HAS_ROUTE_TABLE_IDS", + "HAS_NETWORK_INTERFACE_IDS", + "HAS_SUBNET_IDS", + } diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index 0eae2224f7..ab77a5af67 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -1,16 +1,9 @@ from contextlib import nullcontext -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock, call, patch import pytest -from django_celery_results.models import TaskResult -from tasks.jobs.attack_paths import findings as findings_module -from tasks.jobs.attack_paths import indexes as indexes_module -from tasks.jobs.attack_paths import internet as internet_module -from tasks.jobs.attack_paths import sync as sync_module -from tasks.jobs.attack_paths.scan import run as attack_paths_run - from api.models import ( AttackPathsScan, Finding, @@ -22,7 +15,21 @@ from api.models import ( StatusChoices, Task, ) +from django_celery_results.models import TaskResult from prowler.lib.check.models import Severity +from tasks.jobs.attack_paths import findings as findings_module +from tasks.jobs.attack_paths import indexes as indexes_module +from tasks.jobs.attack_paths import internet as internet_module +from tasks.jobs.attack_paths import sync as sync_module +from tasks.jobs.attack_paths.scan import run as attack_paths_run + +SYNC_RESULT_EMPTY = { + "nodes": 0, + "child_nodes": 0, + "relationships": 0, + "structural_relationships": 0, + "item_relationships": 0, +} @pytest.mark.django_db @@ -33,24 +40,28 @@ class TestAttackPathsRun: "tasks.jobs.attack_paths.scan.utils.call_within_event_loop", side_effect=lambda fn, *a, **kw: fn(*a, **kw), ) + @patch("tasks.jobs.attack_paths.scan.db_utils.set_scan_migrated") @patch("tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") - @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") - @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") + @patch( + "tasks.jobs.attack_paths.scan.sync.sync_graph", + return_value=SYNC_RESULT_EMPTY, + ) + @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", return_value=0) @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -64,7 +75,7 @@ class TestAttackPathsRun: def test_run_success_flow( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -81,6 +92,7 @@ class TestAttackPathsRun: mock_finish, mock_set_provider_graph_data_ready, mock_set_graph_data_ready, + mock_set_scan_migrated, mock_event_loop, mock_drop_db, tenants_fixture, @@ -132,7 +144,7 @@ class TestAttackPathsRun: assert result == ingestion_result mock_retrieve_scan.assert_called_once_with(str(tenant.id), str(scan.id)) mock_starting.assert_called_once() - config = mock_starting.call_args[0][2] + config = mock_starting.call_args[0][1] assert config.neo4j_database == "tenant-db" mock_get_db_name.assert_has_calls( [call(attack_paths_scan.id, temporary=True), call(provider.tenant_id)] @@ -157,6 +169,7 @@ class TestAttackPathsRun: target_database="tenant-db", tenant_id=str(provider.tenant_id), provider_id=str(provider.id), + provider_type="aws", ) mock_get_ingestion.assert_called_once_with(provider.provider) mock_event_loop.assert_called_once() @@ -170,9 +183,12 @@ class TestAttackPathsRun: attack_paths_scan, StateChoices.COMPLETED, ingestion_result ) mock_set_provider_graph_data_ready.assert_called_once_with( - attack_paths_scan, False + attack_paths_scan, False, "neo4j" ) mock_set_graph_data_ready.assert_called_once_with(attack_paths_scan, True) + # is_migrated is flipped to True only after the sync succeeds, so reads + # don't switch to the new catalog/sink before the graph is live. + mock_set_scan_migrated.assert_called_once_with(attack_paths_scan, True, "neo4j") @patch( "tasks.jobs.attack_paths.scan.utils.stringify_exception", @@ -188,17 +204,17 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( "tasks.jobs.attack_paths.scan.graph_database.get_database_name", return_value="db-scan-id", ) - @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri") + @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri") @patch( "tasks.jobs.attack_paths.scan.initialize_prowler_provider", return_value=MagicMock(_enabled_regions=["us-east-1"]), @@ -210,7 +226,7 @@ class TestAttackPathsRun: def test_run_failure_marks_scan_failed( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_get_db_name, mock_create_db, mock_cartography_indexes, @@ -287,17 +303,17 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( "tasks.jobs.attack_paths.scan.graph_database.get_database_name", return_value="db-scan-id", ) - @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri") + @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri") @patch( "tasks.jobs.attack_paths.scan.initialize_prowler_provider", return_value=MagicMock(_enabled_regions=["us-east-1"]), @@ -309,7 +325,7 @@ class TestAttackPathsRun: def test_failure_before_gate_does_not_flip_graph_data_ready_true( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_get_db_name, mock_create_db, mock_cartography_indexes, @@ -390,17 +406,17 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( "tasks.jobs.attack_paths.scan.graph_database.get_database_name", return_value="db-scan-id", ) - @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri") + @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri") @patch( "tasks.jobs.attack_paths.scan.initialize_prowler_provider", return_value=MagicMock(_enabled_regions=["us-east-1"]), @@ -412,7 +428,7 @@ class TestAttackPathsRun: def test_run_failure_marks_scan_failed_even_when_drop_database_fails( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_get_db_name, mock_create_db, mock_cartography_indexes, @@ -489,22 +505,25 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") - @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") + @patch( + "tasks.jobs.attack_paths.scan.sync.sync_graph", + return_value=SYNC_RESULT_EMPTY, + ) @patch( "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", side_effect=RuntimeError("drop failed"), ) @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -518,7 +537,7 @@ class TestAttackPathsRun: def test_failure_after_gate_before_drop_restores_graph_data_ready( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -584,8 +603,8 @@ class TestAttackPathsRun: attack_paths_run(str(tenant.id), str(scan.id), "task-456") assert mock_set_provider_graph_data_ready.call_args_list == [ - call(attack_paths_scan, False), - call(attack_paths_scan, True), + call(attack_paths_scan, False, "neo4j"), + call(attack_paths_scan, True, "neo4j"), ] @patch( @@ -609,15 +628,15 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -631,7 +650,7 @@ class TestAttackPathsRun: def test_failure_after_drop_before_sync_leaves_graph_data_ready_false( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -698,7 +717,7 @@ class TestAttackPathsRun: # Only called with False (gate), never with True (no recovery for partial data) mock_set_provider_graph_data_ready.assert_called_once_with( - attack_paths_scan, False + attack_paths_scan, False, "neo4j" ) @patch( @@ -711,6 +730,7 @@ class TestAttackPathsRun: ) @patch("tasks.jobs.attack_paths.scan.graph_database.drop_database") @patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.db_utils.set_scan_migrated") @patch( "tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready", side_effect=[RuntimeError("flag failed"), None], @@ -718,19 +738,22 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") - @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") + @patch( + "tasks.jobs.attack_paths.scan.sync.sync_graph", + return_value=SYNC_RESULT_EMPTY, + ) @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -744,7 +767,7 @@ class TestAttackPathsRun: def test_failure_after_sync_restores_graph_data_ready( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -760,6 +783,7 @@ class TestAttackPathsRun: mock_update_progress, mock_set_provider_graph_data_ready, mock_set_graph_data_ready, + mock_set_scan_migrated, mock_finish, mock_drop_db, mock_event_loop, @@ -816,8 +840,11 @@ class TestAttackPathsRun: ] # set_provider_graph_data_ready only called once with False (the gate) mock_set_provider_graph_data_ready.assert_called_once_with( - attack_paths_scan, False + attack_paths_scan, False, "neo4j" ) + # is_migrated is flipped once after the sync and is not touched again by + # the failure-recovery branch + mock_set_scan_migrated.assert_called_once_with(attack_paths_scan, True, "neo4j") @patch( "tasks.jobs.attack_paths.scan.utils.stringify_exception", @@ -833,22 +860,25 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") - @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") + @patch( + "tasks.jobs.attack_paths.scan.sync.sync_graph", + return_value=SYNC_RESULT_EMPTY, + ) @patch( "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", side_effect=RuntimeError("drop failed"), ) @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis", return_value=(0, 0)) @patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes") @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") - @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run") @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") @patch( - "tasks.jobs.attack_paths.scan.graph_database.get_uri", + "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri", return_value="bolt://neo4j", ) @patch( @@ -862,7 +892,7 @@ class TestAttackPathsRun: def test_recovery_failure_does_not_suppress_original_exception( self, mock_init_provider, - mock_get_uri, + mock_get_ingest_uri, mock_create_db, mock_clear_cache, mock_cartography_indexes, @@ -1105,7 +1135,7 @@ class TestFailAttackPathsScan: fail_attack_paths_scan(str(tenant.id), "nonexistent", "setup exploded") def test_fail_recovers_graph_data_ready_when_data_exists( - self, tenants_fixture, providers_fixture, scans_fixture + self, tenants_fixture, providers_fixture, scans_fixture, sink_backend_stub ): from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan @@ -1124,16 +1154,18 @@ class TestFailAttackPathsScan: state=StateChoices.EXECUTING, ) + # `recover_graph_data_ready` routes `has_provider_data` through + # `sink_module.get_backend_for_scan(scan)`. With `is_migrated=False` + # and the default `ATTACK_PATHS_SINK_DATABASE=neo4j`, the factory + # returns the active backend, which `sink_backend_stub` replaces. + sink_backend_stub.has_provider_data.return_value = True + with ( patch( "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", return_value=attack_paths_scan, ), patch("tasks.jobs.attack_paths.db_utils.graph_database.drop_database"), - patch( - "tasks.jobs.attack_paths.db_utils.graph_database.has_provider_data", - return_value=True, - ), patch( "tasks.jobs.attack_paths.db_utils.set_provider_graph_data_ready" ) as mock_set_ready, @@ -1143,7 +1175,7 @@ class TestFailAttackPathsScan: mock_set_ready.assert_called_once_with(attack_paths_scan, True) def test_fail_leaves_graph_data_ready_false_when_no_data( - self, tenants_fixture, providers_fixture, scans_fixture + self, tenants_fixture, providers_fixture, scans_fixture, sink_backend_stub ): from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan @@ -1162,16 +1194,14 @@ class TestFailAttackPathsScan: state=StateChoices.EXECUTING, ) + sink_backend_stub.has_provider_data.return_value = False + with ( patch( "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", return_value=attack_paths_scan, ), patch("tasks.jobs.attack_paths.db_utils.graph_database.drop_database"), - patch( - "tasks.jobs.attack_paths.db_utils.graph_database.has_provider_data", - return_value=False, - ), patch( "tasks.jobs.attack_paths.db_utils.set_provider_graph_data_ready" ) as mock_set_ready, @@ -1260,6 +1290,20 @@ class TestAttackPathsFindingsHelpers: [call(mock_session, stmt) for stmt in FINDINGS_INDEX_STATEMENTS] ) + def test_create_findings_indexes_runs_even_when_sink_is_neptune(self, settings): + # The index helpers run against the temp ingest DB, which is always + # Neo4j regardless of the configured sink. A Neptune sink must not + # suppress index creation on that DB (regression for the dropped + # in-helper sink gate). + settings.ATTACK_PATHS_SINK_DATABASE = "neptune" + mock_session = MagicMock() + with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write: + indexes_module.create_findings_indexes(mock_session) + + from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS + + assert mock_run_write.call_count == len(FINDINGS_INDEX_STATEMENTS) + def test_load_findings_batches_requests(self, providers_fixture): provider = providers_fixture[0] provider.provider = Provider.ProviderChoices.AWS @@ -1273,11 +1317,13 @@ class TestAttackPathsFindingsHelpers: config = SimpleNamespace(update_tag=12345) mock_session = MagicMock() + first_result = MagicMock() + first_result.single.return_value = {"merged_count": 1, "dropped_count": 0} + second_result = MagicMock() + second_result.single.return_value = {"merged_count": 0, "dropped_count": 1} + mock_session.run.side_effect = [first_result, second_result] + with ( - patch( - "tasks.jobs.attack_paths.findings.get_root_node_label", - return_value="AWSAccount", - ), patch( "tasks.jobs.attack_paths.findings.get_node_uid_field", return_value="arn", @@ -1286,6 +1332,7 @@ class TestAttackPathsFindingsHelpers: "tasks.jobs.attack_paths.findings.get_provider_resource_label", return_value="_AWSResource", ), + patch("tasks.jobs.attack_paths.findings.logger") as mock_logger, ): findings_module.load_findings( mock_session, findings_generator(), provider, config @@ -1294,26 +1341,16 @@ class TestAttackPathsFindingsHelpers: assert mock_session.run.call_count == 2 for call_args in mock_session.run.call_args_list: params = call_args.args[1] - assert params["provider_uid"] == str(provider.uid) assert params["last_updated"] == config.update_tag assert "findings_data" in params - def test_cleanup_findings_runs_batches(self, providers_fixture): - provider = providers_fixture[0] - config = SimpleNamespace(update_tag=1024) - mock_session = MagicMock() - - first_batch = MagicMock() - first_batch.single.return_value = {"deleted_findings_count": 3} - second_batch = MagicMock() - second_batch.single.return_value = {"deleted_findings_count": 0} - mock_session.run.side_effect = [first_batch, second_batch] - - findings_module.cleanup_findings(mock_session, provider, config) - - assert mock_session.run.call_count == 2 - params = mock_session.run.call_args.args[1] - assert params["last_updated"] == config.update_tag + summary_log = next( + call_args.args[0] + for call_args in mock_logger.info.call_args_list + if call_args.args and "Finished loading" in call_args.args[0] + ) + assert "edges_merged=1" in summary_log + assert "edges_dropped=1" in summary_log def test_stream_findings_with_resources_returns_latest_scan_data( self, @@ -1494,11 +1531,12 @@ class TestAttackPathsFindingsHelpers: "default", ): result = findings_module._enrich_batch_with_resources( - [finding_dict], str(tenant.id) + [finding_dict], str(tenant.id), lambda uid: f"short:{uid}" ) assert len(result) == 1 assert result[0]["resource_uid"] == resource.uid + assert result[0]["resource_short_uid"] == f"short:{resource.uid}" assert result[0]["id"] == str(finding.id) assert result[0]["status"] == "FAIL" @@ -1582,7 +1620,7 @@ class TestAttackPathsFindingsHelpers: "default", ): result = findings_module._enrich_batch_with_resources( - [finding_dict], str(tenant.id) + [finding_dict], str(tenant.id), lambda uid: uid ) assert len(result) == 3 @@ -1656,7 +1694,7 @@ class TestAttackPathsFindingsHelpers: patch("tasks.jobs.attack_paths.findings.logger") as mock_logger, ): result = findings_module._enrich_batch_with_resources( - [finding_dict], str(tenant.id) + [finding_dict], str(tenant.id), lambda uid: uid ) assert len(result) == 0 @@ -1690,10 +1728,6 @@ class TestAttackPathsFindingsHelpers: yield # Make it a generator with ( - patch( - "tasks.jobs.attack_paths.findings.get_root_node_label", - return_value="AWSAccount", - ), patch( "tasks.jobs.attack_paths.findings.get_node_uid_field", return_value="arn", @@ -1707,6 +1741,63 @@ class TestAttackPathsFindingsHelpers: mock_session.run.assert_not_called() + @pytest.mark.parametrize( + "uid, expected", + [ + ( + "arn:aws:ec2:us-east-1:552455647653:instance/i-05075b63eb51baacb", + "i-05075b63eb51baacb", + ), + ( + "arn:aws:ec2:us-east-1:123456789012:volume/vol-0abcd1234ef567890", + "vol-0abcd1234ef567890", + ), + ( + "arn:aws:ec2:us-east-1:123456789012:security-group/sg-0123abcd", + "sg-0123abcd", + ), + ("arn:aws:s3:::my-bucket-name", "my-bucket-name"), + ("arn:aws:iam::123456789012:role/MyRole", "MyRole"), + ( + "arn:aws:lambda:us-east-1:123456789012:function:my-function", + "my-function", + ), + ("i-05075b63eb51baacb", "i-05075b63eb51baacb"), + ], + ) + def test_extract_short_uid_aws_variants(self, uid, expected): + from tasks.jobs.attack_paths.aws import extract_short_uid + + assert extract_short_uid(uid) == expected + + def test_insert_finding_template_has_short_id_fallback(self): + from tasks.jobs.attack_paths.queries import ( + INSERT_FINDING_TEMPLATE, + render_cypher_template, + ) + + rendered = render_cypher_template( + INSERT_FINDING_TEMPLATE, + { + "__NODE_UID_FIELD__": "arn", + "__RESOURCE_LABEL__": "_AWSResource", + }, + ) + + assert ( + "resource_by_uid:_AWSResource {arn: finding_data.resource_uid}" in rendered + ) + assert "resource_by_id:_AWSResource {id: finding_data.resource_uid}" in rendered + assert ( + "resource_by_short:_AWSResource {id: finding_data.resource_short_uid}" + in rendered + ) + assert "head(collect(resource_by_short)) AS resource_by_short" in rendered + assert ( + "COALESCE(resource_by_uid, resource_by_id, resource_by_short)" in rendered + ) + assert "RETURN merged_count, dropped_count" in rendered + class TestAddResourceLabel: def test_add_resource_label_applies_private_label(self): @@ -1744,7 +1835,13 @@ def _make_session_ctx(session, call_order=None, name=None): class TestSyncNodes: - def test_sync_nodes_adds_private_label(self): + def test_iter_sink_batches_rejects_zero_batch_size(self): + with pytest.raises( + ValueError, match="Sink batch size must be greater than zero" + ): + list(sync_module._iter_sink_batches([], batch_size=0)) + + def test_sync_nodes_passes_isolation_labels_to_sink(self): row = { "internal_id": 1, "element_id": "elem-1", @@ -1754,29 +1851,32 @@ class TestSyncNodes: mock_source_1 = MagicMock() mock_source_1.run.return_value = [row] - mock_target = MagicMock() mock_source_2 = MagicMock() mock_source_2.run.return_value = [] + sink = MagicMock() with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(mock_source_1), - _make_session_ctx(mock_target), _make_session_ctx(mock_source_2), ], ): - total = sync_module.sync_nodes( - "source-db", "target-db", "tenant-1", "prov-1" + result = sync_module.sync_nodes( + "source-db", "target-db", "tenant-1", "prov-1", sink, [] ) - assert total == 1 - query = mock_target.run.call_args.args[0] - assert "_ProviderResource" in query - assert "_Tenant_tenant1" in query - assert "_Provider_prov1" in query + assert result["parents"] == 1 + sink.write_nodes.assert_called_once() + target_db, labels, batch = sink.write_nodes.call_args.args + assert target_db == "target-db" + assert "_ProviderResource" in labels + assert "_Tenant_tenant1" in labels + assert "_Provider_prov1" in labels + assert batch[0]["provider_element_id"] == "prov-1:elem-1" + assert batch[0]["props"] == {"key": "value"} - def test_sync_nodes_source_closes_before_target_opens(self): + def test_sync_nodes_writes_after_source_session_closes(self): row = { "internal_id": 1, "element_id": "elem-1", @@ -1788,21 +1888,23 @@ class TestSyncNodes: src_1 = MagicMock() src_1.run.return_value = [row] - tgt = MagicMock() src_2 = MagicMock() src_2.run.return_value = [] + sink = MagicMock() + sink.write_nodes.side_effect = lambda *_a, **_kw: call_order.append( + "sink:write" + ) with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1, call_order, "source1"), - _make_session_ctx(tgt, call_order, "target"), _make_session_ctx(src_2, call_order, "source2"), ], ): - sync_module.sync_nodes("src-db", "tgt-db", "t-1", "p-1") + sync_module.sync_nodes("src-db", "tgt-db", "t-1", "p-1", sink, []) - assert call_order.index("source1:exit") < call_order.index("target:enter") + assert call_order.index("source1:exit") < call_order.index("sink:write") def test_sync_nodes_pagination_with_batch_size_1(self): row_a = { @@ -1824,44 +1926,89 @@ class TestSyncNodes: src_2.run.return_value = [row_b] src_3 = MagicMock() src_3.run.return_value = [] - tgt_1 = MagicMock() - tgt_2 = MagicMock() + sink = MagicMock() with ( patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1), - _make_session_ctx(tgt_1), _make_session_ctx(src_2), - _make_session_ctx(tgt_2), _make_session_ctx(src_3), ], ), patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1), ): - total = sync_module.sync_nodes("src", "tgt", "t-1", "p-1") + result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, []) - assert total == 2 + assert result["parents"] == 2 + assert sink.write_nodes.call_count == 2 assert src_1.run.call_args.args[1]["last_id"] == -1 assert src_2.run.call_args.args[1]["last_id"] == 1 + def test_sync_nodes_chunks_expanded_list_rows_before_sink_write(self): + row = { + "internal_id": 1, + "element_id": "elem-1", + "labels": ["SomeLabel"], + "props": {"values": ["a", "b", "c", "d", "e"]}, + } + normalized_lists = [ + sync_module.NormalizedList( + "SomeLabel", + "values", + "SomeLabelValuesItem", + "HAS_VALUES", + ) + ] + + src_1 = MagicMock() + src_1.run.return_value = [row] + src_2 = MagicMock() + src_2.run.return_value = [] + sink = MagicMock() + + with ( + patch( + "tasks.jobs.attack_paths.sync.graph_database.get_session", + side_effect=[ + _make_session_ctx(src_1), + _make_session_ctx(src_2), + ], + ), + patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2), + ): + result = sync_module.sync_nodes( + "src", "tgt", "t-1", "p-1", sink, normalized_lists + ) + + assert result == {"parents": 1, "children": 5, "parent_child_rels": 5} + assert [ + len(call_args.args[2]) for call_args in sink.write_nodes.call_args_list[1:] + ] == [2, 2, 1] + assert [ + len(call_args.args[3]) + for call_args in sink.write_relationships.call_args_list + ] == [2, 2, 1] + def test_sync_nodes_empty_source_returns_zero(self): src = MagicMock() src.run.return_value = [] + sink = MagicMock() with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[_make_session_ctx(src)], ) as mock_get_session: - total = sync_module.sync_nodes("src", "tgt", "t-1", "p-1") + result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, []) - assert total == 0 + assert result["parents"] == 0 assert mock_get_session.call_count == 1 + sink.write_nodes.assert_not_called() class TestSyncRelationships: - def test_sync_relationships_source_closes_before_target_opens(self): + def test_sync_relationships_writes_after_source_session_closes(self): row = { "internal_id": 1, "rel_type": "HAS", @@ -1874,21 +2021,23 @@ class TestSyncRelationships: src_1 = MagicMock() src_1.run.return_value = [row] - tgt = MagicMock() src_2 = MagicMock() src_2.run.return_value = [] + sink = MagicMock() + sink.write_relationships.side_effect = lambda *_a, **_kw: call_order.append( + "sink:write" + ) with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1, call_order, "source1"), - _make_session_ctx(tgt, call_order, "target"), _make_session_ctx(src_2, call_order, "source2"), ], ): - sync_module.sync_relationships("src", "tgt", "p-1") + sync_module.sync_relationships("src", "tgt", "p-1", sink) - assert call_order.index("source1:exit") < call_order.index("target:enter") + assert call_order.index("source1:exit") < call_order.index("sink:write") def test_sync_relationships_pagination_with_batch_size_1(self): row_a = { @@ -1912,40 +2061,76 @@ class TestSyncRelationships: src_2.run.return_value = [row_b] src_3 = MagicMock() src_3.run.return_value = [] - tgt_1 = MagicMock() - tgt_2 = MagicMock() + sink = MagicMock() with ( patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[ _make_session_ctx(src_1), - _make_session_ctx(tgt_1), _make_session_ctx(src_2), - _make_session_ctx(tgt_2), _make_session_ctx(src_3), ], ), patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1), ): - total = sync_module.sync_relationships("src", "tgt", "p-1") + total = sync_module.sync_relationships("src", "tgt", "p-1", sink) assert total == 2 + assert sink.write_relationships.call_count == 2 assert src_1.run.call_args.args[1]["last_id"] == -1 assert src_2.run.call_args.args[1]["last_id"] == 1 + def test_sync_relationships_chunks_grouped_rows_before_sink_write(self): + rows = [ + { + "internal_id": idx, + "rel_type": "HAS", + "start_element_id": f"s-{idx}", + "end_element_id": f"e-{idx}", + "props": {}, + } + for idx in range(1, 6) + ] + + src_1 = MagicMock() + src_1.run.return_value = rows + src_2 = MagicMock() + src_2.run.return_value = [] + sink = MagicMock() + + with ( + patch( + "tasks.jobs.attack_paths.sync.graph_database.get_session", + side_effect=[ + _make_session_ctx(src_1), + _make_session_ctx(src_2), + ], + ), + patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2), + ): + total = sync_module.sync_relationships("src", "tgt", "p-1", sink) + + assert total == 5 + assert [ + len(call_args.args[3]) + for call_args in sink.write_relationships.call_args_list + ] == [2, 2, 1] + def test_sync_relationships_empty_source_returns_zero(self): src = MagicMock() src.run.return_value = [] + sink = MagicMock() with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", side_effect=[_make_session_ctx(src)], ) as mock_get_session: - total = sync_module.sync_relationships("src", "tgt", "p-1") + total = sync_module.sync_relationships("src", "tgt", "p-1", sink) assert total == 0 assert mock_get_session.call_count == 1 + sink.write_relationships.assert_not_called() class TestInternetAnalysis: @@ -2017,6 +2202,8 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan is not None assert attack_paths_scan.graph_data_ready is False + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" def test_create_attack_paths_scan_inherits_true_from_previous( self, tenants_fixture, providers_fixture, scans_fixture @@ -2037,6 +2224,8 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan, state=StateChoices.COMPLETED, graph_data_ready=True, + is_migrated=True, + sink_backend="neptune", ) new_scan = Scan.objects.create( @@ -2057,6 +2246,109 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan is not None assert attack_paths_scan.graph_data_ready is True + # is_migrated tracks the data being served: inherited from the ready scan + assert attack_paths_scan.is_migrated is True + assert attack_paths_scan.sink_backend == "neptune" + + def test_create_attack_paths_scan_prefers_active_sink_ready_scan( + self, tenants_fixture, providers_fixture, scans_fixture, settings + ): + from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan + + settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + is_migrated=False, + sink_backend="neo4j", + ) + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + is_migrated=True, + sink_backend="neptune", + ) + + new_scan = Scan.objects.create( + name="New Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + tenant_id=tenant.id, + ) + + with patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ): + attack_paths_scan = create_attack_paths_scan( + str(tenant.id), str(new_scan.id), provider.id + ) + + assert attack_paths_scan is not None + assert attack_paths_scan.graph_data_ready is True + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" + + def test_create_attack_paths_scan_inherits_is_migrated_false_from_legacy_ready( + self, tenants_fixture, providers_fixture, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + # Previous scan is ready but pre-cutover (legacy Neo4j graph shape) + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + is_migrated=False, + sink_backend="neo4j", + ) + + new_scan = Scan.objects.create( + name="New Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + tenant_id=tenant.id, + ) + + with patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ): + attack_paths_scan = create_attack_paths_scan( + str(tenant.id), str(new_scan.id), provider.id + ) + + assert attack_paths_scan is not None + assert attack_paths_scan.graph_data_ready is True + # Reads stay on the legacy catalog/backend until this scan's own sync + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" def test_create_attack_paths_scan_inherits_false_when_no_previous_ready( self, tenants_fixture, providers_fixture, scans_fixture @@ -2077,6 +2369,7 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan, state=StateChoices.FAILED, graph_data_ready=False, + sink_backend="neptune", ) new_scan = Scan.objects.create( @@ -2097,6 +2390,8 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan is not None assert attack_paths_scan.graph_data_ready is False + assert attack_paths_scan.is_migrated is False + assert attack_paths_scan.sink_backend == "neo4j" def test_set_graph_data_ready_updates_field( self, tenants_fixture, providers_fixture, scans_fixture @@ -2203,7 +2498,7 @@ class TestAttackPathsDbUtilsGraphDataReady: assert attack_paths_scan.state == StateChoices.FAILED assert attack_paths_scan.graph_data_ready is True - def test_set_provider_graph_data_ready_updates_all_scans_for_provider( + def test_set_provider_graph_data_ready_updates_all_scans_for_provider_sink( self, tenants_fixture, providers_fixture, scans_fixture ): from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready @@ -2231,6 +2526,7 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan_a, state=StateChoices.COMPLETED, graph_data_ready=True, + sink_backend="neptune", ) new_ap_scan = AttackPathsScan.objects.create( tenant_id=tenant.id, @@ -2238,6 +2534,7 @@ class TestAttackPathsDbUtilsGraphDataReady: scan=scan_b, state=StateChoices.EXECUTING, graph_data_ready=True, + sink_backend="neptune", ) with patch( @@ -2251,6 +2548,48 @@ class TestAttackPathsDbUtilsGraphDataReady: assert old_ap_scan.graph_data_ready is False assert new_ap_scan.graph_data_ready is False + def test_set_provider_graph_data_ready_preserves_other_sink_scans( + self, tenants_fixture, providers_fixture, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + legacy_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.COMPLETED, + graph_data_ready=True, + sink_backend="neo4j", + ) + neptune_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.EXECUTING, + graph_data_ready=True, + sink_backend="neptune", + ) + + with patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ): + set_provider_graph_data_ready(neptune_scan, False) + + legacy_scan.refresh_from_db() + neptune_scan.refresh_from_db() + assert legacy_scan.graph_data_ready is True + assert neptune_scan.graph_data_ready is False + def test_set_provider_graph_data_ready_does_not_affect_other_providers( self, tenants_fixture, providers_fixture, scans_fixture ): @@ -2315,7 +2654,7 @@ class TestCleanupStaleAttackPathsScans: provider=provider, scan=scan, state=StateChoices.EXECUTING, - started_at=started_at or datetime.now(tz=timezone.utc), + started_at=started_at or datetime.now(tz=UTC), ) task_result = None @@ -2408,7 +2747,7 @@ class TestCleanupStaleAttackPathsScans: provider.provider = Provider.ProviderChoices.AWS provider.save() - old_start = datetime.now(tz=timezone.utc) - timedelta(hours=49) + old_start = datetime.now(tz=UTC) - timedelta(hours=49) ap_scan, task_result = self._create_executing_scan( tenant, provider, started_at=old_start, worker="live-worker@host" ) @@ -2626,7 +2965,7 @@ class TestCleanupStaleAttackPathsScans: provider.save() # Old scan with no Task/TaskResult - old_start = datetime.now(tz=timezone.utc) - timedelta(hours=49) + old_start = datetime.now(tz=UTC) - timedelta(hours=49) ap_scan = AttackPathsScan.objects.create( tenant_id=tenant.id, provider=provider, @@ -2673,3 +3012,197 @@ class TestCleanupStaleAttackPathsScans: assert result["cleaned_up_count"] == 2 # Worker should be pinged exactly once — cache prevents second ping mock_alive.assert_called_once_with("shared-worker@host") + + # `SCHEDULED` state cleanup + def _create_scheduled_scan( + self, + tenant, + provider, + *, + age_minutes, + parent_state, + with_task=True, + ): + """Create a SCHEDULED AttackPathsScan with a parent Scan in `parent_state`. + + `age_minutes` controls how far in the past `started_at` is set, so + callers can place rows safely past the cleanup cutoff. + """ + parent_scan = Scan.objects.create( + name="Parent Prowler scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=parent_state, + tenant_id=tenant.id, + ) + + ap_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=parent_scan, + state=StateChoices.SCHEDULED, + started_at=datetime.now(tz=UTC) - timedelta(minutes=age_minutes), + ) + + task_result = None + if with_task: + task_result = TaskResult.objects.create( + task_id=str(ap_scan.id), + task_name="attack-paths-scan-perform", + status="PENDING", + ) + task = Task.objects.create( + id=task_result.task_id, + task_runner_task=task_result, + tenant_id=tenant.id, + ) + ap_scan.task = task + ap_scan.save(update_fields=["task_id"]) + + return ap_scan, task_result + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + def test_cleans_up_scheduled_scan_when_parent_is_terminal( + self, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + ap_scan, task_result = self._create_scheduled_scan( + tenant, + provider, + age_minutes=24 * 60 * 3, # 3 days, safely past any threshold + parent_state=StateChoices.FAILED, + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 1 + assert str(ap_scan.id) in result["scan_ids"] + + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.FAILED + assert ap_scan.progress == 100 + assert ap_scan.completed_at is not None + assert ap_scan.ingestion_exceptions == { + "global_error": "Scan never started — cleaned up by periodic task" + } + + # SCHEDULED revoke must NOT terminate a running worker + mock_revoke.assert_called_once() + assert mock_revoke.call_args.kwargs == {"terminate": False} + + # Temp DB never created for SCHEDULED, so no drop attempted + mock_drop_db.assert_not_called() + # Tenant Neo4j data is untouched in this path + mock_recover.assert_not_called() + + task_result.refresh_from_db() + assert task_result.status == "FAILURE" + assert task_result.date_done is not None + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + def test_skips_scheduled_scan_when_parent_still_in_flight( + self, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + ap_scan, _ = self._create_scheduled_scan( + tenant, + provider, + age_minutes=24 * 60 * 3, + parent_state=StateChoices.EXECUTING, + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.SCHEDULED + mock_revoke.assert_not_called() + + +class TestNormalizeSinkProperties: + """Coerce Cartography-emitted property values into sink-portable primitives. + + Lists become comma-strings, dicts become JSON strings, temporals become + ISO strings, spatials become their stringified form. The same coercion + runs regardless of the active sink so queries are portable. + """ + + @pytest.mark.parametrize( + "raw, expected", + [ + ( + {"a": "x", "b": 1, "c": 1.5, "d": True, "e": None}, + {"a": "x", "b": 1, "c": 1.5, "d": True, "e": None}, + ), + ( + {"actions": ["s3:GetObject", "s3:PutObject"], "tags": []}, + {"actions": "s3:GetObject,s3:PutObject", "tags": ""}, + ), + ( + {"condition": {"StringEquals": {"aws:SourceAccount": "123456789012"}}}, + { + "condition": '{"StringEquals": {"aws:SourceAccount": "123456789012"}}' + }, + ), + ], + ) + def test_primitive_list_and_dict_branches(self, raw, expected): + sync_module._normalize_sink_properties(raw, labels=None) + assert raw == expected + + def test_temporal_and_spatial_become_strings(self): + class FakeDateTime: + def iso_format(self) -> str: + return "2026-05-13T10:00:00+00:00" + + class FakeSpatialPoint: + def __str__(self) -> str: + return "POINT(1.0 2.0)" + + # The spatial branch is detected by module prefix, not by base class. + FakeSpatialPoint.__module__ = "neo4j.spatial.fake" + + props = { + "created_at": FakeDateTime(), + "location": FakeSpatialPoint(), + } + sync_module._normalize_sink_properties(props, labels=None) + assert props == { + "created_at": "2026-05-13T10:00:00+00:00", + "location": "POINT(1.0 2.0)", + } diff --git a/api/src/backend/tasks/tests/test_backfill.py b/api/src/backend/tasks/tests/test_backfill.py index 469b0a393b..8ae39905fc 100644 --- a/api/src/backend/tasks/tests/test_backfill.py +++ b/api/src/backend/tasks/tests/test_backfill.py @@ -1,16 +1,8 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest -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, -) - from api.models import ( ComplianceOverviewSummary, Finding, @@ -24,6 +16,13 @@ from api.models import ( ) from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status +from tasks.jobs.backfill import ( + aggregate_scan_category_summaries, + aggregate_scan_resource_group_summaries, + backfill_compliance_summaries, + backfill_provider_compliance_scores, + backfill_resource_scan_summaries, +) @pytest.fixture(scope="function") @@ -183,6 +182,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 +230,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 +317,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 +378,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 +468,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} @@ -405,7 +535,7 @@ class TestBackfillProviderComplianceScores: scan2 = scans_fixture[1] # Set completed_at to make the scan eligible for backfill - scan.completed_at = datetime.now(timezone.utc) + scan.completed_at = datetime.now(UTC) scan.save() scan2.state = StateChoices.AVAILABLE scan2.completed_at = None diff --git a/api/src/backend/tasks/tests/test_beat.py b/api/src/backend/tasks/tests/test_beat.py index 5c25e97340..8679872164 100644 --- a/api/src/backend/tasks/tests/test_beat.py +++ b/api/src/backend/tasks/tests/test_beat.py @@ -2,11 +2,10 @@ import json from unittest.mock import patch import pytest -from django_celery_beat.models import IntervalSchedule, PeriodicTask -from tasks.beat import schedule_provider_scan - from api.exceptions import ConflictException from api.models import Scan +from django_celery_beat.models import IntervalSchedule, PeriodicTask +from tasks.beat import schedule_provider_scan @pytest.mark.django_db diff --git a/api/src/backend/tasks/tests/test_connection.py b/api/src/backend/tasks/tests/test_connection.py index e5e39d8778..e8b27e0b00 100644 --- a/api/src/backend/tasks/tests/test_connection.py +++ b/api/src/backend/tasks/tests/test_connection.py @@ -1,16 +1,15 @@ import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest +from api.models import Integration, LighthouseConfiguration, Provider from tasks.jobs.connection import ( check_integration_connection, check_lighthouse_connection, check_provider_connection, ) -from api.models import Integration, LighthouseConfiguration, Provider - @pytest.mark.parametrize( "provider_data", @@ -38,7 +37,7 @@ def test_check_provider_connection( mock_provider_connection_test.assert_called_once() assert provider.connected is True assert provider.connection_last_checked_at is not None - assert provider.connection_last_checked_at <= datetime.now(tz=timezone.utc) + assert provider.connection_last_checked_at <= datetime.now(tz=UTC) @patch("tasks.jobs.connection.Provider.objects.get") diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index 0ed8c5ddb2..c6e2cd408c 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -1,11 +1,10 @@ -from unittest.mock import call, patch +from unittest.mock import MagicMock, call, patch import pytest -from django.core.exceptions import ObjectDoesNotExist -from tasks.jobs.deletion import delete_provider, delete_tenant - from api.attack_paths import database as graph_database from api.models import Provider, Tenant, TenantComplianceSummary +from django.core.exceptions import ObjectDoesNotExist +from tasks.jobs.deletion import delete_provider, delete_tenant @pytest.mark.django_db @@ -61,10 +60,12 @@ class TestDeleteProvider: aps1 = create_attack_paths_scan(instance) aps2 = create_attack_paths_scan(instance) + backend = MagicMock() with ( patch( - "tasks.jobs.deletion.graph_database.drop_subgraph", + "tasks.jobs.deletion.sink_module.get_backend_for_name", + return_value=backend, ), patch( "tasks.jobs.deletion.graph_database.drop_database", @@ -73,12 +74,55 @@ class TestDeleteProvider: result = delete_provider(tenant_id, instance.id) assert result + backend.drop_subgraph.assert_called_once_with( + graph_database.get_database_name(tenant_id), str(instance.id) + ) expected_tmp_calls = [ call(f"db-tmp-scan-{str(aps1.id).lower()}"), call(f"db-tmp-scan-{str(aps2.id).lower()}"), ] mock_drop_database.assert_has_calls(expected_tmp_calls, any_order=True) + def test_delete_provider_drops_graph_data_from_all_recorded_sinks( + self, providers_fixture, create_attack_paths_scan + ): + instance = providers_fixture[0] + tenant_id = str(instance.tenant_id) + create_attack_paths_scan(instance, sink_backend="neo4j") + create_attack_paths_scan(instance, sink_backend="neptune") + neo4j_backend = MagicMock() + neptune_backend = MagicMock() + + def get_backend_for_name(name): + return { + "neo4j": neo4j_backend, + "neptune": neptune_backend, + }[name] + + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ), + patch( + "tasks.jobs.deletion.sink_module.get_backend_for_name", + side_effect=get_backend_for_name, + ) as mock_get_backend_for_name, + patch("tasks.jobs.deletion.graph_database.drop_database"), + ): + result = delete_provider(tenant_id, instance.id) + + assert result + mock_get_backend_for_name.assert_has_calls( + [call("neo4j"), call("neptune")], any_order=True + ) + neo4j_backend.drop_subgraph.assert_called_once_with( + "tenant-db", str(instance.id) + ) + neptune_backend.drop_subgraph.assert_called_once_with( + "tenant-db", str(instance.id) + ) + def test_delete_provider_continues_when_temp_db_drop_fails( self, providers_fixture, create_attack_paths_scan ): @@ -86,10 +130,12 @@ class TestDeleteProvider: tenant_id = str(instance.tenant_id) create_attack_paths_scan(instance) + backend = MagicMock() with ( patch( - "tasks.jobs.deletion.graph_database.drop_subgraph", + "tasks.jobs.deletion.sink_module.get_backend_for_name", + return_value=backend, ), patch( "tasks.jobs.deletion.graph_database.drop_database", diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index e246405cdd..9cb727e8d0 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -1,7 +1,12 @@ from unittest.mock import MagicMock, patch import pytest +from api.db_router import READ_REPLICA_ALIAS, MainRouter +from api.models import Integration +from api.utils import prowler_integration_connection_test from django.db import OperationalError +from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection +from prowler.providers.common.models import Connection from tasks.jobs.integrations import ( get_s3_client_from_integration, get_security_hub_client_from_integration, @@ -10,12 +15,6 @@ from tasks.jobs.integrations import ( upload_security_hub_integration, ) -from api.db_router import READ_REPLICA_ALIAS, MainRouter -from api.models import Integration -from api.utils import prowler_integration_connection_test -from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection -from prowler.providers.common.models import Connection - @pytest.mark.django_db class TestS3IntegrationUploads: @@ -264,10 +263,9 @@ class TestS3IntegrationUploads: def test_s3_integration_rejects_invalid_output_directory_characters(self): """Test that S3 integration validation rejects invalid characters.""" - from rest_framework.exceptions import ValidationError - from api.models import Integration from api.v1.serializers import BaseWriteIntegrationSerializer + from rest_framework.exceptions import ValidationError integration_type = Integration.IntegrationChoices.AMAZON_S3 providers = [] @@ -290,10 +288,9 @@ class TestS3IntegrationUploads: def test_s3_integration_rejects_empty_output_directory(self): """Test that S3 integration validation rejects empty directories.""" - from rest_framework.exceptions import ValidationError - from api.models import Integration from api.v1.serializers import BaseWriteIntegrationSerializer + from rest_framework.exceptions import ValidationError integration_type = Integration.IntegrationChoices.AMAZON_S3 providers = [] diff --git a/api/src/backend/tasks/tests/test_muting.py b/api/src/backend/tasks/tests/test_muting.py index d8ae310f2e..2e542980bf 100644 --- a/api/src/backend/tasks/tests/test_muting.py +++ b/api/src/backend/tasks/tests/test_muting.py @@ -1,13 +1,12 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import uuid4 import pytest -from django.core.exceptions import ObjectDoesNotExist -from tasks.jobs.muting import mute_historical_findings - from api.models import Finding, MuteRule +from django.core.exceptions import ObjectDoesNotExist from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status +from tasks.jobs.muting import mute_historical_findings @pytest.mark.django_db @@ -162,7 +161,7 @@ class TestMuteHistoricalFindings: "Description": f"Muted description {i}", }, muted=True, - muted_at=datetime.now(timezone.utc), + muted_at=datetime.now(UTC), muted_reason="Already muted", ) muted_uids.append(finding.uid) diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py new file mode 100644 index 0000000000..b78aca4e63 --- /dev/null +++ b/api/src/backend/tasks/tests/test_orphan_recovery.py @@ -0,0 +1,406 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from celery import states +from django.test import override_settings +from django_celery_results.models import TaskResult +from tasks.jobs.orphan_recovery import ( + _decode_celery_field, + _reconcile_task_results, + _recovery_attempt_count, + advisory_lock, + is_worker_alive, + reconcile_orphans, + reenqueueable_tasks, +) + + +def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.STARTED): + """Create a TaskResult mimicking an in-flight task, backdated past the grace.""" + tr = TaskResult.objects.create( + task_id=str(uuid4()), + status=status, + task_name=name, + worker=worker, + task_kwargs=repr(kwargs), + task_args=repr([]), + ) + TaskResult.objects.filter(pk=tr.pk).update( + date_created=datetime.now(tz=UTC) - timedelta(minutes=created_minutes_ago) + ) + tr.refresh_from_db() + return tr + + +@pytest.mark.django_db +class TestDecodeCeleryField: + def test_decodes_single_encoded_repr(self): + assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"} + + def test_decodes_double_encoded(self): + import json + + stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"})) + assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"} + + def test_empty_returns_default(self): + assert _decode_celery_field(None, {}) == {} + assert _decode_celery_field("", []) == [] + + def test_unparseable_raises(self): + with pytest.raises(ValueError): + _decode_celery_field("<>", {}) + + +@pytest.mark.django_db +class TestReconcileTaskResults: + def _patches(self, alive): + """Patch worker liveness, revoke, and the task registry for re-enqueue.""" + mock_app = MagicMock() + mock_task = MagicMock() + mock_app.tasks.get.return_value = mock_task + return ( + patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=alive), + patch("tasks.jobs.orphan_recovery.revoke_task"), + patch("tasks.jobs.orphan_recovery.current_app", mock_app), + mock_task, + ) + + def test_recovers_non_scan_task(self, tenants_fixture): + """A NON-scan task (tenant-deletion) left orphaned is re-enqueued too.""" + tenant = tenants_fixture[0] + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenant.id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["recovered"] + tr.refresh_from_db() + assert tr.status == states.REVOKED # stale result cleared (no pending alert) + mock_task.apply_async.assert_called_once() + call = mock_task.apply_async.call_args.kwargs + assert call["kwargs"] == {"tenant_id": str(tenant.id)} + assert call["task_id"] != tr.task_id # fresh task id + + def test_external_integration_task_is_not_reenqueued_by_default( + self, tenants_fixture + ): + """External side-effect tasks without proven idempotency stay terminal. + + integration-s3 rebuilds its upload from worker-local files that do not + survive the crash, so re-enqueuing it would upload nothing. + """ + tr = _orphan_result( + name="integration-s3", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "provider_id": str(uuid4()), + "output_directory": "/tmp/gone", + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_disabled_group_task_is_not_reenqueued(self, tenants_fixture): + """A task whose group feature flag is off stays terminal, not re-enqueued.""" + tr = _orphan_result( + name="scan-summary", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "scan_id": str(uuid4()), + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_disabled_group_task_does_not_consume_recovery_attempt( + self, tenants_fixture + ): + """A disabled-group task is failed without incrementing its Valkey attempt + counter, so re-enabling the group does not start it at the cap.""" + tr = _orphan_result( + name="scan-summary", + kwargs={"tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4())}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count") as mock_count, + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_count.assert_not_called() + + def test_scan_task_is_skipped_entirely(self, tenants_fixture): + """Scan tasks are excluded from recovery: the watchdog never touches them.""" + tr = _orphan_result( + name="scan-perform", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "scan_id": str(uuid4()), + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id not in result["recovered"] + assert tr.task_id not in result["failed"] + assert tr.task_id not in result["skipped"] + mock_task.apply_async.assert_not_called() + + def test_jira_integration_task_is_not_reenqueued(self, tenants_fixture): + """integration-jira stays terminal: re-running it would create duplicate Jira + issues, so an orphaned send is failed instead of re-enqueued.""" + tenant = tenants_fixture[0] + kwargs = { + "tenant_id": str(tenant.id), + "integration_id": str(uuid4()), + "project_key": "PROWLER", + "issue_type": "Task", + "finding_ids": [str(uuid4()), str(uuid4())], + } + tr = _orphan_result( + name="integration-jira", + kwargs=kwargs, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + tr.refresh_from_db() + assert tr.status == states.REVOKED # stale result cleared (no pending alert) + mock_task.apply_async.assert_not_called() + + def test_skips_live_worker(self, tenants_fixture): + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="alive@host", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=True) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["skipped"] + mock_task.apply_async.assert_not_called() + + def test_skips_recently_created(self, tenants_fixture): + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=0, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + # too recent: excluded by the grace window (not even a candidate) + assert tr.task_id not in result["recovered"] + mock_task.apply_async.assert_not_called() + + def test_denylisted_task_failed_not_reenqueued(self, tenants_fixture): + """A non-allowlisted task is failed, never blind re-run.""" + tr = _orphan_result( + name="some-non-idempotent-task", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + tr.refresh_from_db() + assert tr.status == states.REVOKED + mock_task.apply_async.assert_not_called() + + def test_recovery_cap_marks_failed(self, tenants_fixture): + """When the recovery counter exceeds the cap, the task is failed not re-run.""" + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=4), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + +@pytest.mark.django_db +class TestOrphanRecoveryHelpers: + def test_advisory_lock_acquires_and_releases(self): + with advisory_lock() as acquired: + assert acquired is True + + def test_is_worker_alive_true_when_responds(self): + inspect = MagicMock() + inspect.ping.return_value = {"w@h": {"ok": "pong"}} + with patch( + "tasks.jobs.orphan_recovery.current_app.control.inspect", + return_value=inspect, + ): + assert is_worker_alive("w@h") is True + + def test_is_worker_alive_false_when_silent(self): + inspect = MagicMock() + inspect.ping.return_value = None + with patch( + "tasks.jobs.orphan_recovery.current_app.control.inspect", + return_value=inspect, + ): + assert is_worker_alive("w@h") is False + + def test_recovery_attempt_count_increments(self): + # Unique signature so the Valkey counter starts fresh for this test. + kwargs_repr = repr({"probe": str(uuid4())}) + redis_client = MagicMock() + redis_client.incr.side_effect = [1, 2] + with patch("redis.from_url", return_value=redis_client): + assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1 + assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2 + + +class TestRecoveryFeatureFlags: + def test_all_groups_enabled_by_default(self): + tasks = reenqueueable_tasks() + assert "scan-summary" in tasks + assert {"provider-deletion", "tenant-deletion"} <= tasks + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_summaries_group_flag_excludes_summary_tasks(self): + tasks = reenqueueable_tasks() + assert "scan-summary" not in tasks + assert "scan-compliance-overviews" not in tasks + assert "provider-deletion" in tasks + + @override_settings(TASK_RECOVERY_DELETIONS_ENABLED=False) + def test_deletions_group_flag_excludes_deletion_tasks(self): + tasks = reenqueueable_tasks() + assert "provider-deletion" not in tasks + assert "tenant-deletion" not in tasks + assert "scan-summary" in tasks + + +@pytest.mark.django_db +class TestRecoveryMasterFlag: + @override_settings(TASK_RECOVERY_ENABLED=False) + def test_master_flag_disables_task_recovery(self): + with ( + patch( + "tasks.jobs.orphan_recovery._reconcile_task_results" + ) as mock_reconcile, + patch( + "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", + return_value={}, + ), + ): + result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) + + mock_reconcile.assert_not_called() + assert result["acquired"] is True + assert result["enabled"] is False + + @override_settings(TASK_RECOVERY_ENABLED=True) + def test_master_flag_enabled_runs_task_recovery(self): + with ( + patch( + "tasks.jobs.orphan_recovery._reconcile_task_results", + return_value={"recovered": [], "failed": [], "skipped": []}, + ) as mock_reconcile, + patch( + "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", + return_value={}, + ), + ): + reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) + + mock_reconcile.assert_called_once() diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py index 858f4c06ca..c290e6fe1d 100644 --- a/api/src/backend/tasks/tests/test_reports.py +++ b/api/src/backend/tasks/tests/test_reports.py @@ -1,10 +1,31 @@ +import os +import time import uuid from unittest.mock import Mock, patch import matplotlib import pytest +from api.models import ( + Finding, + Resource, + ResourceFindingMapping, + ResourceTag, + ResourceTagMapping, + StateChoices, + StatusChoices, +) +from prowler.lib.check.models import Severity from reportlab.lib import colors -from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report +from tasks.jobs.report import ( + STALE_TMP_OUTPUT_LOCK_FILE_NAME, + STALE_TMP_OUTPUT_MAX_AGE_HOURS, + _cleanup_stale_tmp_output_directories, + _is_scan_directory_protected, + _pick_latest_cis_variant, + _should_run_stale_cleanup, + generate_compliance_reports, + generate_threatscore_report, +) from tasks.jobs.reports import ( CHART_COLOR_GREEN_1, CHART_COLOR_GREEN_2, @@ -29,9 +50,6 @@ from tasks.jobs.threatscore_utils import ( _load_findings_for_requirement_checks, ) -from api.models import Finding, Resource, ResourceFindingMapping, StatusChoices -from prowler.lib.check.models import Severity - matplotlib.use("Agg") # Use non-interactive backend for tests @@ -350,6 +368,677 @@ class TestLoadFindingsForChecks: assert result == {} + def test_prefetch_avoids_n_plus_one(self, tenants_fixture, scans_fixture): + """Loading N findings must NOT execute O(N) extra queries for resources/tags. + + Regression test for PROWLER-1733. ``FindingOutput.transform_api_finding`` + reads ``finding.resources.first()`` and ``resource.tags.all()`` per + finding. Without ``prefetch_related`` that's 2N additional queries; + with prefetch it collapses to a small constant per iterator chunk. + """ + from django.db import connections + from django.test.utils import CaptureQueriesContext + + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + # Build N findings, each linked to one resource that owns 2 tags. + N = 20 + for i in range(N): + finding = Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"f-prefetch-{i}", + check_id="aws_check_prefetch", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={ + "provider": "aws", + "checkid": "aws_check_prefetch", + "checktitle": "t", + "checktype": [], + "servicename": "s", + "subservicename": "", + "severity": "high", + "resourcetype": "r", + "description": "", + "risk": "", + "relatedurl": "", + "remediation": { + "recommendation": {"text": "", "url": ""}, + "code": { + "nativeiac": "", + "terraform": "", + "cli": "", + "other": "", + }, + }, + "resourceidtemplate": "", + "categories": [], + "dependson": [], + "relatedto": [], + "notes": "", + }, + raw_result={}, + ) + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=scan.provider, + uid=f"r-prefetch-{i}", + name=f"r-prefetch-{i}", + metadata="{}", + details="", + region="us-east-1", + service="s", + type="t::r", + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, finding=finding, resource=resource + ) + for k in ("env", "owner"): + tag, _ = ResourceTag.objects.get_or_create( + tenant_id=tenant.id, key=k, value=f"v-{i}-{k}" + ) + ResourceTagMapping.objects.create( + tenant_id=tenant.id, resource=resource, tag=tag + ) + + mock_provider = Mock() + mock_provider.type = "aws" + mock_provider.identity.account = "test" + + # Patch transform_api_finding to a no-op so the test isolates queries + # to the queryset/prefetch path (transform itself is exercised by + # the integration tests above and not by this regression check). + with patch( + "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding", + side_effect=lambda model, provider: Mock(check_id=model.check_id), + ): + with CaptureQueriesContext( + connections["default_read_replica"] + if "default_read_replica" in connections.databases + else connections["default"] + ) as ctx: + _load_findings_for_requirement_checks( + str(tenant.id), + str(scan.id), + ["aws_check_prefetch"], + mock_provider, + ) + + # Expected: a small constant number of queries irrespective of N. + # Pre-fix this would be ~1 + 2*N. We give some slack for RLS SET + # LOCAL statements that the rls_transaction emits. + assert len(ctx.captured_queries) < N, ( + f"Expected O(1) queries with prefetch_related; got " + f"{len(ctx.captured_queries)} for N={N} (N+1 regression?)" + ) + + def test_max_findings_per_check_cap(self, tenants_fixture, scans_fixture): + """When a check exceeds ``MAX_FINDINGS_PER_CHECK``, only ``cap`` rows + are loaded AND ``total_counts_out`` reports the pre-cap total. + + Guards the PROWLER-1733 truncation knob: prevents both runaway memory + and silent data loss in the PDF (the banner relies on knowing the + real total). + """ + from unittest.mock import patch as _patch + + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + # Create 12 findings for a single check; cap to 5. + check_id = "aws_check_cap_test" + for i in range(12): + finding = Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"f-cap-{i:02d}", + check_id=check_id, + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=scan.provider, + uid=f"r-cap-{i:02d}", + name=f"r-cap-{i:02d}", + metadata="{}", + details="", + region="us-east-1", + service="s", + type="t::r", + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, finding=finding, resource=resource + ) + + mock_provider = Mock(type="aws") + mock_provider.identity.account = "test" + + totals: dict = {} + # Patch the cap to a small value AND skip the heavy transform so we + # only assert on row counts and totals. + with ( + _patch("tasks.jobs.threatscore_utils.MAX_FINDINGS_PER_CHECK", 5), + _patch( + "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding", + side_effect=lambda model, provider: Mock(check_id=model.check_id), + ), + ): + result = _load_findings_for_requirement_checks( + str(tenant.id), + str(scan.id), + [check_id], + mock_provider, + total_counts_out=totals, + ) + + assert len(result[check_id]) == 5, ( + f"cap=5 should yield exactly 5 loaded findings, got {len(result[check_id])}" + ) + assert totals[check_id] == 12, ( + f"total_counts_out should report the pre-cap total (12), got {totals[check_id]}" + ) + + def test_only_failed_findings_pushes_down_to_sql( + self, tenants_fixture, scans_fixture + ): + """When ``only_failed_findings=True``, PASS rows are excluded by the + DB filter, not just visually hidden afterwards. + + Regression for the consistency fix: previously the requirement-level + ``only_failed`` flag filtered which requirements appeared, but inside + each rendered requirement the table still showed PASS rows mixed + with FAIL, which combined with ``MAX_FINDINGS_PER_CHECK`` could + truncate to 1000 PASS findings and hide the actual failure. + """ + from unittest.mock import patch as _patch + + tenant = tenants_fixture[0] + scan = scans_fixture[0] + check_id = "aws_check_only_failed_test" + + # Mix PASS and FAIL so the filter has something to drop. + for i in range(6): + status = StatusChoices.FAIL if i % 2 == 0 else StatusChoices.PASS + finding = Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"f-of-{i:02d}", + check_id=check_id, + status=status, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=scan.provider, + uid=f"r-of-{i:02d}", + name=f"r-of-{i:02d}", + metadata="{}", + details="", + region="us-east-1", + service="s", + type="t::r", + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, finding=finding, resource=resource + ) + + mock_provider = Mock(type="aws") + mock_provider.identity.account = "test" + + totals: dict = {} + with _patch( + "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding", + side_effect=lambda model, provider: Mock( + check_id=model.check_id, status=model.status + ), + ): + result = _load_findings_for_requirement_checks( + str(tenant.id), + str(scan.id), + [check_id], + mock_provider, + total_counts_out=totals, + only_failed_findings=True, + ) + + # 3 FAIL + 3 PASS in DB; FAIL-only filter should load just 3. + loaded = result[check_id] + assert len(loaded) == 3, f"expected 3 FAIL findings, got {len(loaded)}" + statuses = {getattr(f, "status", None) for f in loaded} + assert statuses == {StatusChoices.FAIL}, ( + f"expected all loaded findings to be FAIL; got statuses {statuses}" + ) + # total_counts must reflect the FAIL-only total, not the global total. + assert totals[check_id] == 3, ( + f"total_counts should be FAIL-only (3), got {totals[check_id]}" + ) + + def test_max_findings_per_check_disabled(self, tenants_fixture, scans_fixture): + """``MAX_FINDINGS_PER_CHECK=0`` disables the cap; load all rows.""" + from unittest.mock import patch as _patch + + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + check_id = "aws_check_uncapped" + for i in range(8): + f = Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"f-unc-{i:02d}", + check_id=check_id, + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + r = Resource.objects.create( + tenant_id=tenant.id, + provider=scan.provider, + uid=f"r-unc-{i:02d}", + name=f"r-unc-{i:02d}", + metadata="{}", + details="", + region="us-east-1", + service="s", + type="t::r", + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, finding=f, resource=r + ) + + mock_provider = Mock(type="aws") + mock_provider.identity.account = "test" + totals: dict = {} + with ( + _patch("tasks.jobs.threatscore_utils.MAX_FINDINGS_PER_CHECK", 0), + _patch( + "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding", + side_effect=lambda model, provider: Mock(check_id=model.check_id), + ), + ): + result = _load_findings_for_requirement_checks( + str(tenant.id), + str(scan.id), + [check_id], + mock_provider, + total_counts_out=totals, + ) + + assert len(result[check_id]) == 8 + assert totals[check_id] == 8 + + +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: @@ -422,6 +1111,601 @@ 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( + self, + mock_cis, + mock_upload, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Scan with no findings and ``generate_cis=True`` must yield a flat + ``{"upload": False, "path": ""}`` entry, consistent with the other + frameworks (no nested dict, no sentinel keys).""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + 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": False, "path": ""} + mock_cis.assert_not_called() + + @patch("api.utils.initialize_prowler_provider") + @patch("tasks.jobs.report.rmtree") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.generate_csa_report") + @patch("tasks.jobs.report.generate_nis2_report") + @patch("tasks.jobs.report.generate_ens_report") + @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_findings_cache_eviction_after_framework( + self, + mock_scan_summary_filter, + mock_provider_get, + mock_get_bulk, + mock_aggregate_stats, + mock_generate_output_dir, + mock_threatscore, + mock_ens, + mock_nis2, + mock_csa, + mock_cis, + mock_upload_to_s3, + mock_rmtree, + mock_init_provider, + ): + """After each framework finishes, exclusive entries are evicted. + + Threat scenario for PROWLER-1733: the shared ``findings_cache`` used + to grow monotonically through all 5 frameworks. With the new + eviction logic, check_ids only used by ThreatScore are dropped when + ThreatScore finishes, before ENS runs. + """ + from types import SimpleNamespace + + from tasks.jobs import report as report_mod + + mock_scan_summary_filter.return_value.exists.return_value = True + mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws") + # ThreatScore consumes {tsc_only, shared}; ENS consumes {ens_only, + # shared}. After ThreatScore evicts, tsc_only must be gone but + # shared and ens_only must remain. + mock_get_bulk.return_value = { + "prowler_threatscore_aws": SimpleNamespace( + Requirements=[SimpleNamespace(Checks=["tsc_only", "shared"])] + ), + "ens_rd2022_aws": SimpleNamespace( + Requirements=[SimpleNamespace(Checks=["ens_only", "shared"])] + ), + } + mock_aggregate_stats.return_value = {} + mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out" + mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf" + mock_init_provider.return_value = Mock(name="prowler_provider") + + # Seed the cache as if both frameworks had already loaded their + # findings. We mutate it indirectly: each generator wrapper is a + # Mock: make ThreatScore populate the cache, and have ENS observe + # the state at call time so we can introspect post-eviction. + observed_state: dict = {} + + def _threatscore_side_effect(**kwargs): + cache = kwargs["findings_cache"] + cache["tsc_only"] = ["tsc-finding"] + cache["shared"] = ["shared-finding"] + + def _ens_side_effect(**kwargs): + # ENS runs AFTER threatscore's _evict_after_framework("threatscore"). + observed_state["cache_keys_when_ens_runs"] = set( + kwargs["findings_cache"].keys() + ) + kwargs["findings_cache"]["ens_only"] = ["ens-finding"] + + mock_threatscore.side_effect = _threatscore_side_effect + mock_ens.side_effect = _ens_side_effect + + report_mod.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=False, + generate_csa=False, + generate_cis=False, + ) + + # ``tsc_only`` was exclusive to ThreatScore → evicted before ENS ran. + # ``shared`` is still pending for ENS → must remain. + assert "tsc_only" not in observed_state["cache_keys_when_ens_runs"], ( + "tsc_only should have been evicted before ENS ran" + ) + assert "shared" in observed_state["cache_keys_when_ens_runs"], ( + "shared must remain in cache because ENS still needs it" + ) + + @patch("tasks.jobs.report.initialize_prowler_provider") + @patch("tasks.jobs.report.rmtree") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.generate_csa_report") + @patch("tasks.jobs.report.generate_nis2_report") + @patch("tasks.jobs.report.generate_ens_report") + @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_prowler_provider_initialized_once( + self, + mock_scan_summary_filter, + mock_provider_get, + mock_get_bulk, + mock_aggregate_stats, + mock_generate_output_dir, + mock_threatscore, + mock_ens, + mock_nis2, + mock_csa, + mock_cis, + mock_upload_to_s3, + mock_rmtree, + mock_init_provider, + ): + """``initialize_prowler_provider`` must be called exactly once for + the whole batch (PROWLER-1733). Previously each generator re-init'd + the SDK provider in ``_load_compliance_data`` → 5 inits per scan. + """ + mock_scan_summary_filter.return_value.exists.return_value = True + mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws") + # CIS variant discovery needs at least one cis_* key. + mock_get_bulk.return_value = {"cis_6.0_aws": Mock()} + mock_aggregate_stats.return_value = {} + mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out" + mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf" + mock_init_provider.return_value = Mock(name="prowler_provider") + + 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, + ) + + # All 5 wrappers were invoked once each… + mock_threatscore.assert_called_once() + mock_ens.assert_called_once() + mock_nis2.assert_called_once() + mock_csa.assert_called_once() + mock_cis.assert_called_once() + # …but the SDK provider was initialized only once. + assert mock_init_provider.call_count == 1, ( + f"expected 1 init, got {mock_init_provider.call_count} " + f"(prowler_provider must be shared across reports)" + ) + + # The shared instance must reach every wrapper as kwargs. + shared = mock_init_provider.return_value + for mock_wrapper in ( + mock_threatscore, + mock_ens, + mock_nis2, + mock_csa, + mock_cis, + ): + _, call_kwargs = mock_wrapper.call_args + assert call_kwargs.get("prowler_provider") is shared + + @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: + """Test suite covering the CIS branch of generate_compliance_reports.""" + + def _force_scan_has_findings(self, monkeypatch): + """Bypass the ScanSummary.exists() early-return guard.""" + + class _FakeManager: + def filter(self, **kwargs): + class _Q: + def exists(self): + return True + + return _Q() + + monkeypatch.setattr("tasks.jobs.report.ScanSummary.objects", _FakeManager()) + + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.Compliance.get_bulk") + def test_cis_picks_latest_version( + self, + mock_get_bulk, + mock_cis, + mock_upload, + mock_stats, + monkeypatch, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """CIS branch should generate a single PDF for the highest version. + + The returned ``results["cis"]`` must have the same flat shape as the + other single-version frameworks (``{"upload", "path"}``) — the picked + variant is an internal detail and is not exposed in the result. + """ + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + self._force_scan_has_findings(monkeypatch) + + mock_stats.return_value = {} + # Multiple CIS variants + a non-CIS framework that must be ignored. + # Includes 1.10 to verify the selection is not lexicographic. + mock_get_bulk.return_value = { + "cis_1.4_aws": Mock(), + "cis_1.10_aws": Mock(), + "cis_2.0_aws": Mock(), + "cis_5.0_aws": Mock(), + "ens_rd2022_aws": Mock(), + } + mock_upload.return_value = "s3://bucket/path" + + 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, + ) + + # Exactly one call for the latest version, never for older variants + # or non-CIS frameworks. + assert mock_cis.call_count == 1 + assert mock_cis.call_args.kwargs["compliance_id"] == "cis_5.0_aws" + + assert result["cis"]["upload"] is True + assert result["cis"]["path"] == "s3://bucket/path" + assert "compliance_id" not in result["cis"] + + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.Compliance.get_bulk") + def test_cis_latest_variant_failure_captured_in_results( + self, + mock_get_bulk, + mock_cis, + mock_upload, + mock_stats, + monkeypatch, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """A failure in the latest CIS variant must be surfaced in the flat results entry.""" + 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_1.4_aws": Mock(), + "cis_5.0_aws": Mock(), + } + mock_cis.side_effect = RuntimeError("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, + ) + + # Only the latest variant is attempted; its failure lands in a flat + # entry keyed under "cis" with the same shape as sibling frameworks. + assert mock_cis.call_count == 1 + assert result["cis"]["upload"] is False + assert result["cis"]["error"] == "boom" + assert "compliance_id" not in result["cis"] + + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.Compliance.get_bulk") + def test_cis_provider_without_cis_skipped_cleanly( + self, + mock_get_bulk, + mock_cis, + mock_upload, + mock_stats, + monkeypatch, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """When ``Compliance.get_bulk`` returns no CIS entry the CIS branch + must skip cleanly and record a flat ``{"upload": False, "path": ""}`` + entry — no hard-coded provider whitelist is consulted.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + self._force_scan_has_findings(monkeypatch) + mock_stats.return_value = {} + # No ``cis_*`` keys in the bulk → no variant picked. + mock_get_bulk.return_value = {"ens_rd2022_aws": Mock()} + + 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": 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.""" + + def test_empty_returns_none(self): + assert _pick_latest_cis_variant([]) is None + + def test_single_variant(self): + assert _pick_latest_cis_variant(["cis_5.0_aws"]) == "cis_5.0_aws" + + def test_numeric_not_lexicographic(self): + """1.10 must beat 1.2 (lex sort would pick 1.2).""" + variants = ["cis_1.2_kubernetes", "cis_1.10_kubernetes"] + assert _pick_latest_cis_variant(variants) == "cis_1.10_kubernetes" + + def test_major_version_wins(self): + variants = ["cis_1.4_aws", "cis_2.0_aws", "cis_5.0_aws", "cis_6.0_aws"] + assert _pick_latest_cis_variant(variants) == "cis_6.0_aws" + + def test_minor_version_breaks_tie(self): + variants = ["cis_3.0_aws", "cis_3.1_aws", "cis_2.9_aws"] + assert _pick_latest_cis_variant(variants) == "cis_3.1_aws" + + def test_three_part_version(self): + """Versions like 3.0.1 must win over 3.0.""" + variants = ["cis_3.0_aws", "cis_3.0.1_aws"] + assert _pick_latest_cis_variant(variants) == "cis_3.0.1_aws" + + def test_malformed_names_ignored(self): + variants = ["notcis_1.0_aws", "cis_abc_aws", "cis_5.0_aws"] + assert _pick_latest_cis_variant(variants) == "cis_5.0_aws" + + def test_only_malformed_returns_none(self): + variants = ["notcis_1.0_aws", "cis_abc_aws"] + assert _pick_latest_cis_variant(variants) is None + + def test_multidigit_provider_name(self): + """Provider name with underscores (e.g. googleworkspace) must parse.""" + variants = ["cis_1.3_googleworkspace"] + assert _pick_latest_cis_variant(variants) == "cis_1.3_googleworkspace" + + def test_accepts_iterator(self): + """The helper must accept any iterable, not just lists.""" + + def _gen(): + yield "cis_1.4_aws" + yield "cis_5.0_aws" + + assert _pick_latest_cis_variant(_gen()) == "cis_5.0_aws" + + def test_rejects_single_integer_version(self): + """The regex requires at least one dotted component. ``cis_5_aws`` + without a minor version is malformed per the backend contract.""" + assert _pick_latest_cis_variant(["cis_5_aws"]) is None + + def test_rejects_trailing_dot(self): + """Inputs like ``cis_5._aws`` must be rejected at the regex stage + instead of silently normalising to ``(5, 0)``.""" + assert _pick_latest_cis_variant(["cis_5._aws", "cis_1.0_aws"]) == "cis_1.0_aws" + + def test_rejects_lone_dot_version(self): + """``cis_._aws`` has no numeric component and must be skipped.""" + assert _pick_latest_cis_variant(["cis_._aws", "cis_1.0_aws"]) == "cis_1.0_aws" + class TestOptimizationImprovements: """Test suite for optimization-related functionality.""" diff --git a/api/src/backend/tasks/tests/test_reports_base.py b/api/src/backend/tasks/tests/test_reports_base.py index d2fda4f830..6246654436 100644 --- a/api/src/backend/tasks/tests/test_reports_base.py +++ b/api/src/backend/tasks/tests/test_reports_base.py @@ -1269,6 +1269,48 @@ class TestComponentEdgeCases: # Should be a LongTable for large datasets assert isinstance(table, LongTable) + def test_zebra_uses_rowbackgrounds_not_per_row_background(self, monkeypatch): + """The styles list must contain exactly one ROWBACKGROUNDS entry + regardless of row count, never N per-row BACKGROUND entries. + """ + captured: dict = {} + + # Capture the list passed to TableStyle. create_data_table builds a + # list of style tuples and wraps it in a TableStyle exactly once; + # by patching TableStyle we intercept that list. + import tasks.jobs.reports.components as comp_mod + + original_table_style = comp_mod.TableStyle + + def _capture_table_style(style_list): + captured["styles"] = list(style_list) + return original_table_style(style_list) + + monkeypatch.setattr(comp_mod, "TableStyle", _capture_table_style) + + data = [{"name": f"Item {i}"} for i in range(60)] + columns = [ColumnConfig("Name", 2 * inch, "name")] + comp_mod.create_data_table(data, columns, alternate_rows=True) + + styles = captured["styles"] + # Count by command name. + names = [s[0] for s in styles if isinstance(s, tuple) and s] + # Exactly one ROWBACKGROUNDS entry. + assert names.count("ROWBACKGROUNDS") == 1 + # Zero per-row BACKGROUND entries on data rows. (The header row + # BACKGROUND command is intentional and lives at coords (0,0)/(-1,0).) + data_row_bg = [ + s + for s in styles + if isinstance(s, tuple) + and s[0] == "BACKGROUND" + and not (s[1] == (0, 0) and s[2] == (-1, 0)) + ] + assert data_row_bg == [], ( + f"expected no per-row BACKGROUND entries on data rows; " + f"got {len(data_row_bg)}" + ) + def test_create_risk_component_zero_values(self): """Test risk component with zero values.""" component = create_risk_component(risk_level=0, weight=0, score=0) @@ -1344,3 +1386,194 @@ class TestFrameworkConfigEdgeCases: assert get_framework_config("my_custom_threatscore_compliance") is not None assert get_framework_config("ens_something_else") is not None assert get_framework_config("nis2_gcp") is not None + + +# ============================================================================= +# Findings Table Chunking Tests (PROWLER-1733) +# ============================================================================= +# +# These tests guard the OOM-prevention behaviour added in PROWLER-1733: +# ``_create_findings_tables`` must split a list of findings into multiple +# small sub-tables instead of producing one giant Table, which would force +# ReportLab to resolve layout for all rows at once and OOM the worker on +# scans with thousands of findings per check. + + +class _DummyMetadata: + """Lightweight stand-in for FindingOutput.metadata used in chunking tests.""" + + def __init__(self, check_title: str = "Title", severity: str = "high"): + self.CheckTitle = check_title + self.Severity = severity + + +class _DummyFinding: + """Lightweight stand-in for FindingOutput used in chunking tests. + + The chunking code only reads a small set of attributes via ``getattr``, + so a duck-typed object is enough and lets the tests run without touching + the DB or pydantic deserialisation. + """ + + def __init__( + self, + check_id: str = "aws_check", + resource_name: str = "res-1", + resource_uid: str = "", + status: str = "FAIL", + region: str = "us-east-1", + with_metadata: bool = True, + ): + self.check_id = check_id + self.resource_name = resource_name + self.resource_uid = resource_uid + self.status = status + self.region = region + if with_metadata: + self.metadata = _DummyMetadata() + else: + self.metadata = None + + +def _make_concrete_generator(): + """Return a minimal concrete subclass of BaseComplianceReportGenerator.""" + + class _Concrete(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + return _Concrete(FrameworkConfig(name="test", display_name="Test")) + + +class TestFindingsTableChunking: + """Tests for ``_create_findings_tables`` (PROWLER-1733).""" + + def test_chunking_produces_expected_number_of_subtables(self): + """5000 findings @ chunk_size=300 → 17 sub-tables + 16 spacers.""" + generator = _make_concrete_generator() + findings = [_DummyFinding(check_id="c1") for _ in range(5000)] + + flowables = generator._create_findings_tables(findings, chunk_size=300) + + tables = [f for f in flowables if isinstance(f, (Table, LongTable))] + spacers = [f for f in flowables if isinstance(f, Spacer)] + # ceil(5000 / 300) == 17 + assert len(tables) == 17 + # Spacer between every pair of contiguous tables, not after the last + assert len(spacers) == 16 + + def test_chunk_size_param_overrides_default(self): + """250 findings @ chunk_size=100 → 3 sub-tables.""" + generator = _make_concrete_generator() + findings = [_DummyFinding(check_id="c2") for _ in range(250)] + + flowables = generator._create_findings_tables(findings, chunk_size=100) + tables = [f for f in flowables if isinstance(f, (Table, LongTable))] + assert len(tables) == 3 + + def test_empty_findings_returns_empty_list(self): + """No findings → no flowables. Callers can extend(...) safely.""" + generator = _make_concrete_generator() + assert generator._create_findings_tables([]) == [] + + def test_single_chunk_has_no_spacer(self): + """A single sub-table must not emit a trailing spacer.""" + generator = _make_concrete_generator() + findings = [_DummyFinding(check_id="c3") for _ in range(10)] + + flowables = generator._create_findings_tables(findings, chunk_size=300) + assert len(flowables) == 1 + assert isinstance(flowables[0], (Table, LongTable)) + + def test_malformed_finding_is_skipped(self): + """A broken finding must not abort the report; it is logged and skipped.""" + generator = _make_concrete_generator() + + class _Broken: + # No attributes at all; getattr() defaults will mostly cope, but + # we force an explicit error by making the metadata attribute + # itself raise on access. + @property + def metadata(self): + raise RuntimeError("boom") + + check_id = "broken" + + findings = [ + _DummyFinding(check_id="c4"), + _Broken(), + _DummyFinding(check_id="c4"), + ] + flowables = generator._create_findings_tables(findings, chunk_size=300) + # Two good rows → one sub-table containing them; the broken one is + # logged and dropped, not propagated. + tables = [f for f in flowables if isinstance(f, (Table, LongTable))] + assert len(tables) == 1 + + def test_create_findings_table_alias_returns_first_chunk(self): + """The deprecated alias must keep returning a single Table flowable.""" + generator = _make_concrete_generator() + findings = [_DummyFinding(check_id="c5") for _ in range(700)] + + first = generator._create_findings_table(findings) + assert isinstance(first, (Table, LongTable)) + + def test_create_findings_table_alias_empty(self): + """Alias on empty input returns an empty (header-only) Table, not None.""" + generator = _make_concrete_generator() + result = generator._create_findings_table([]) + # The legacy alias never returned None; an empty header-only table + # is a strict superset of that contract. + assert isinstance(result, (Table, LongTable)) + + +# ============================================================================= +# Logging Context Manager Tests (PROWLER-1733) +# ============================================================================= + + +class TestLogPhaseContextManager: + """Tests for ``_log_phase`` (PROWLER-1733). + + The context manager emits structured ``phase_start`` / ``phase_end`` + logs with ``scan_id``, ``framework`` and ``elapsed_s``, so Datadog/ + CloudWatch queries can pivot by scan and find the slow section. + """ + + def test_emits_start_and_end_with_elapsed_and_rss(self, caplog): + from tasks.jobs.reports.base import _log_phase + + caplog.set_level("INFO", logger="tasks.jobs.reports.base") + with _log_phase("unit_test_phase", scan_id="s-1", framework="Test FW"): + pass + + messages = [r.getMessage() for r in caplog.records] + starts = [m for m in messages if "phase_start" in m] + ends = [m for m in messages if "phase_end" in m] + + assert len(starts) == 1 and len(ends) == 1 + assert "phase=unit_test_phase" in starts[0] + assert "scan_id=s-1" in starts[0] + assert "framework=Test FW" in starts[0] + assert "elapsed_s=" in ends[0] + assert "rss_kb=" in ends[0] + assert "delta_rss_kb=" in ends[0] + + def test_failure_logs_phase_failed_and_reraises(self, caplog): + from tasks.jobs.reports.base import _log_phase + + caplog.set_level("INFO", logger="tasks.jobs.reports.base") + with pytest.raises(RuntimeError, match="boom"): + with _log_phase("failing_phase", scan_id="s-2", framework="FW"): + raise RuntimeError("boom") + + messages = [r.getMessage() for r in caplog.records] + assert any("phase_failed" in m and "failing_phase" in m for m in messages) + # No phase_end on the failure path. + assert not any("phase_end" in m for m in messages) diff --git a/api/src/backend/tasks/tests/test_reports_cis.py b/api/src/backend/tasks/tests/test_reports_cis.py new file mode 100644 index 0000000000..31e5a5495f --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_cis.py @@ -0,0 +1,531 @@ +from unittest.mock import Mock, patch + +import pytest +from api.models import StatusChoices +from reportlab.platypus import Image, LongTable, Paragraph, Table +from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData +from tasks.jobs.reports.cis import ( + CISReportGenerator, + _normalize_profile, + _profile_badge_text, +) + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def cis_generator(): + """Create a CISReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["cis"] + return CISReportGenerator(config) + + +def _make_attr( + section: str, + profile_value: str = "Level 1", + assessment_value: str = "Automated", + sub_section: str = "", + **extras, +) -> Mock: + """Build a mock CIS_Requirement_Attribute with duck-typed fields.""" + attr = Mock() + attr.Section = section + attr.SubSection = sub_section + # CIS enums have `.value`. Use a simple Mock that exposes `.value`. + attr.Profile = Mock(value=profile_value) + attr.AssessmentStatus = Mock(value=assessment_value) + attr.Description = extras.get("description", "desc") + attr.RationaleStatement = extras.get("rationale", "the rationale") + attr.ImpactStatement = extras.get("impact", "the impact") + attr.RemediationProcedure = extras.get("remediation", "the remediation") + attr.AuditProcedure = extras.get("audit", "the audit") + attr.AdditionalInformation = "" + attr.DefaultValue = "" + attr.References = extras.get("references", "https://example.com") + return attr + + +@pytest.fixture +def basic_cis_compliance_data(): + """Create basic ComplianceData for CIS testing (no requirements).""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="cis_5.0_aws", + framework="CIS", + name="CIS Amazon Web Services Foundations Benchmark v5.0.0", + version="5.0", + description="Center for Internet Security AWS Foundations Benchmark", + ) + + +@pytest.fixture +def populated_cis_compliance_data(basic_cis_compliance_data): + """CIS data with mixed requirements across 2 sections, Profile L1/L2, Pass/Fail/Manual.""" + data = basic_cis_compliance_data + data.requirements = [ + RequirementData( + id="1.1", + description="Maintain current contact details", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + checks=["aws_check_1"], + ), + RequirementData( + id="1.2", + description="Ensure root account has no access keys", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=3, + total_findings=3, + checks=["aws_check_2"], + ), + RequirementData( + id="1.3", + description="Ensure MFA is enabled for all IAM users", + status=StatusChoices.MANUAL, + checks=[], + ), + RequirementData( + id="2.1", + description="Ensure S3 Buckets are logging", + status=StatusChoices.PASS, + passed_findings=2, + failed_findings=0, + total_findings=2, + checks=["aws_check_3"], + ), + RequirementData( + id="2.2", + description="Ensure encryption at rest is enabled", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=4, + total_findings=4, + checks=["aws_check_4"], + ), + ] + data.attributes_by_requirement_id = { + "1.1": { + "attributes": { + "req_attributes": [ + _make_attr( + "1 Identity and Access Management", + profile_value="Level 1", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_1"], + } + }, + "1.2": { + "attributes": { + "req_attributes": [ + _make_attr( + "1 Identity and Access Management", + profile_value="Level 1", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_2"], + } + }, + "1.3": { + "attributes": { + "req_attributes": [ + _make_attr( + "1 Identity and Access Management", + profile_value="Level 2", + assessment_value="Manual", + ) + ], + "checks": [], + } + }, + "2.1": { + "attributes": { + "req_attributes": [ + _make_attr( + "2 Storage", + profile_value="Level 2", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_3"], + } + }, + "2.2": { + "attributes": { + "req_attributes": [ + _make_attr( + "2 Storage", + profile_value="Level 1", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_4"], + } + }, + } + return data + + +# ============================================================================= +# Helper function tests +# ============================================================================= + + +class TestNormalizeProfile: + """Test suite for _normalize_profile helper.""" + + def test_level_1_string(self): + assert _normalize_profile(Mock(value="Level 1")) == "L1" + + def test_level_2_string(self): + assert _normalize_profile(Mock(value="Level 2")) == "L2" + + def test_e3_level_1(self): + assert _normalize_profile(Mock(value="E3 Level 1")) == "L1" + + def test_e5_level_2(self): + assert _normalize_profile(Mock(value="E5 Level 2")) == "L2" + + def test_none_returns_other(self): + assert _normalize_profile(None) == "Other" + + def test_substring_trap_rejected(self): + """Unrelated tokens containing the literal ``L2`` must NOT map to L2.""" + # A future enum value like "CL2 Kubernetes Worker" would be silently + # misclassified by a naive substring check. + assert _normalize_profile(Mock(value="CL2 Worker")) == "Other" + assert _normalize_profile(Mock(value="HL2 Legacy")) == "Other" + + def test_raw_string_level_1(self): + # Mock without .value falls back to str(profile); use a real string + class NoValue: + def __str__(self): + return "Level 1" + + assert _normalize_profile(NoValue()) == "L1" + + def test_unknown_profile_returns_other(self): + assert _normalize_profile(Mock(value="Custom Profile")) == "Other" + + +class TestProfileBadgeText: + def test_l1_label(self): + assert _profile_badge_text("L1") == "Level 1" + + def test_l2_label(self): + assert _profile_badge_text("L2") == "Level 2" + + def test_other_label(self): + assert _profile_badge_text("Other") == "Other" + + +# ============================================================================= +# Generator initialization +# ============================================================================= + + +class TestCISGeneratorInitialization: + def test_generator_created(self, cis_generator): + assert cis_generator is not None + assert cis_generator.config.name == "cis" + + def test_generator_language(self, cis_generator): + assert cis_generator.config.language == "en" + + def test_generator_sections_dynamic(self, cis_generator): + # CIS sections differ per variant so config.sections MUST be None + assert cis_generator.config.sections is None + + def test_attribute_fields_contain_cis_specific(self, cis_generator): + for field in ("Profile", "AssessmentStatus", "RationaleStatement"): + assert field in cis_generator.config.attribute_fields + + +# ============================================================================= +# _derive_sections +# ============================================================================= + + +class TestDeriveSections: + def test_preserves_first_seen_order( + self, cis_generator, populated_cis_compliance_data + ): + sections = cis_generator._derive_sections(populated_cis_compliance_data) + assert sections == [ + "1 Identity and Access Management", + "2 Storage", + ] + + def test_deduplicates_sections(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [ + RequirementData(id="1.1", description="a", status=StatusChoices.PASS), + RequirementData(id="1.2", description="b", status=StatusChoices.PASS), + ] + attr = _make_attr("1 IAM") + basic_cis_compliance_data.attributes_by_requirement_id = { + "1.1": {"attributes": {"req_attributes": [attr], "checks": []}}, + "1.2": {"attributes": {"req_attributes": [attr], "checks": []}}, + } + assert cis_generator._derive_sections(basic_cis_compliance_data) == ["1 IAM"] + + def test_empty_data_returns_empty(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [] + basic_cis_compliance_data.attributes_by_requirement_id = {} + assert cis_generator._derive_sections(basic_cis_compliance_data) == [] + + +# ============================================================================= +# _compute_statistics +# ============================================================================= + + +class TestComputeStatistics: + def test_totals(self, cis_generator, populated_cis_compliance_data): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + assert stats["total"] == 5 + assert stats["passed"] == 2 + assert stats["failed"] == 2 + assert stats["manual"] == 1 + + def test_overall_compliance_excludes_manual( + self, cis_generator, populated_cis_compliance_data + ): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + # 2 passed / 4 evaluated (pass + fail) = 50% + assert stats["overall_compliance"] == pytest.approx(50.0) + + def test_overall_compliance_all_manual( + self, cis_generator, basic_cis_compliance_data + ): + basic_cis_compliance_data.requirements = [ + RequirementData(id="x", description="d", status=StatusChoices.MANUAL), + ] + attr = _make_attr("1 IAM", profile_value="Level 1", assessment_value="Manual") + basic_cis_compliance_data.attributes_by_requirement_id = { + "x": {"attributes": {"req_attributes": [attr], "checks": []}}, + } + stats = cis_generator._compute_statistics(basic_cis_compliance_data) + # No evaluated → defaults to 100% + assert stats["overall_compliance"] == 100.0 + + def test_profile_counts(self, cis_generator, populated_cis_compliance_data): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + profile = stats["profile_counts"] + # From fixture: + # L1: 1.1 (PASS, Auto), 1.2 (FAIL, Auto), 2.2 (FAIL, Auto) → pass=1, fail=2, manual=0 + # L2: 1.3 (MANUAL, Manual), 2.1 (PASS, Auto) → pass=1, fail=0, manual=1 + assert profile["L1"] == {"passed": 1, "failed": 2, "manual": 0} + assert profile["L2"] == {"passed": 1, "failed": 0, "manual": 1} + + def test_assessment_counts(self, cis_generator, populated_cis_compliance_data): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + assessment = stats["assessment_counts"] + # Automated: 1.1 PASS, 1.2 FAIL, 2.1 PASS, 2.2 FAIL → pass=2, fail=2, manual=0 + # Manual: 1.3 MANUAL → pass=0, fail=0, manual=1 + assert assessment["Automated"] == {"passed": 2, "failed": 2, "manual": 0} + assert assessment["Manual"] == {"passed": 0, "failed": 0, "manual": 1} + + def test_top_failing_sections_includes_all_evaluated( + self, cis_generator, populated_cis_compliance_data + ): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + top = stats["top_failing_sections"] + # Both sections have 1 PASS + 1 FAIL evaluated → tied at 50%. The + # sort is stable, so both must appear and both must be capped at + # 5 entries. + assert len(top) == 2 + section_names = {name for name, _ in top} + assert section_names == { + "1 Identity and Access Management", + "2 Storage", + } + + def test_compute_statistics_is_memoized( + self, cis_generator, populated_cis_compliance_data + ): + """Calling ``_compute_statistics`` twice with the same data must + reuse the cached value and not re-run the uncached kernel.""" + with patch.object( + CISReportGenerator, + "_compute_statistics_uncached", + wraps=cis_generator._compute_statistics_uncached, + ) as spy: + cis_generator._compute_statistics(populated_cis_compliance_data) + cis_generator._compute_statistics(populated_cis_compliance_data) + assert spy.call_count == 1 + + +# ============================================================================= +# Executive summary +# ============================================================================= + + +class TestCISExecutiveSummary: + def test_title_present(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_executive_summary(populated_cis_compliance_data) + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + text = " ".join(str(p.text) for p in paragraphs) + assert "Executive Summary" in text + + def test_tables_rendered(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_executive_summary(populated_cis_compliance_data) + tables = [e for e in elements if isinstance(e, Table)] + # Exact count: Summary, Profile, Assessment, Top Failing Sections = 4. + assert len(tables) == 4 + + def test_no_requirements(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [] + basic_cis_compliance_data.attributes_by_requirement_id = {} + elements = cis_generator.create_executive_summary(basic_cis_compliance_data) + # With no requirements: Summary table always renders, and both Profile + # and Assessment breakdown tables render with a 0-filled default row, + # but Top Failing Sections is suppressed → exactly 3 tables. + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) == 3 + + +# ============================================================================= +# Charts section +# ============================================================================= + + +class TestCISChartsSection: + def test_charts_rendered(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_charts_section(populated_cis_compliance_data) + # At least 1 image for the pie + 1 for section bar + 1 for stacked + images = [e for e in elements if isinstance(e, Image)] + assert len(images) >= 1 + + def test_charts_no_data_no_crash(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [] + basic_cis_compliance_data.attributes_by_requirement_id = {} + elements = cis_generator.create_charts_section(basic_cis_compliance_data) + # Must not raise; may or may not have any Image + assert isinstance(elements, list) + + +# ============================================================================= +# Requirements index +# ============================================================================= + + +class TestCISRequirementsIndex: + def test_title_present(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_requirements_index( + populated_cis_compliance_data + ) + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + text = " ".join(str(p.text) for p in paragraphs) + assert "Requirements Index" in text + + def test_groups_by_section(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_requirements_index( + populated_cis_compliance_data + ) + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + text = " ".join(str(p.text) for p in paragraphs) + assert "1 Identity and Access Management" in text + assert "2 Storage" in text + + def test_renders_tables_per_section( + self, cis_generator, populated_cis_compliance_data + ): + elements = cis_generator.create_requirements_index( + populated_cis_compliance_data + ) + # One table per section with requirements. ``create_data_table`` + # returns a LongTable when the row count exceeds its threshold and a + # plain Table otherwise — both are valid. + tables = [e for e in elements if isinstance(e, (Table, LongTable))] + assert len(tables) == 2 + + +# ============================================================================= +# Detailed findings extras hook +# ============================================================================= + + +class TestRenderRequirementDetailExtras: + def test_inserts_all_fields(self, cis_generator, populated_cis_compliance_data): + req = populated_cis_compliance_data.requirements[1] # 1.2 FAIL + extras = cis_generator._render_requirement_detail_extras( + req, populated_cis_compliance_data + ) + text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph)) + assert "Rationale" in text + assert "Impact" in text + assert "Audit Procedure" in text + assert "Remediation" in text + assert "References" in text + + def test_missing_metadata_returns_empty( + self, cis_generator, basic_cis_compliance_data + ): + basic_cis_compliance_data.attributes_by_requirement_id = {} + req = RequirementData(id="99", description="unknown", status=StatusChoices.FAIL) + extras = cis_generator._render_requirement_detail_extras( + req, basic_cis_compliance_data + ) + assert extras == [] + + def test_escapes_html_chars(self, cis_generator, basic_cis_compliance_data): + attr = _make_attr( + "1 IAM", + rationale="", + ) + basic_cis_compliance_data.attributes_by_requirement_id = { + "1.1": {"attributes": {"req_attributes": [attr], "checks": []}} + } + req = RequirementData(id="1.1", description="d", status=StatusChoices.FAIL) + extras = cis_generator._render_requirement_detail_extras( + req, basic_cis_compliance_data + ) + text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph)) + assert " + + + + +
+ + + + +
+
+ +
+
+

Node types

+ {legend_nodes_html} +

Edge types

+ {legend_edges_html} +
+
+ + + + +""" + + +def _build_legend_html(colours: dict, shape: str) -> str: + rows = [] + for key, colour in sorted(colours.items()): + if shape == "dot": + rows.append( + f'
' + f'
' + f"{key}
" + ) + else: + rows.append( + f'
' + f'
' + f"{key}
" + ) + return "\n".join(rows) + + +def write_html(graph: ConnectivityGraph, file_path: str) -> None: + """Render the graph as a self-contained interactive HTML page.""" + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + nodes_json = json.dumps( + [ + { + "id": n.id, + "type": n.type, + "name": n.name, + "service": n.service, + "region": n.region, + "account_id": n.account_id, + "properties": n.properties, + } + for n in graph.nodes + ], + indent=None, + default=str, + ) + edges_json = json.dumps( + [ + { + "source_id": e.source_id, + "target_id": e.target_id, + "edge_type": e.edge_type, + "label": e.label or "", + } + for e in graph.edges + ], + indent=None, + default=str, + ) + + html = _HTML_TEMPLATE.format( + generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"), + nodes_json=nodes_json, + edges_json=edges_json, + node_colours_json=json.dumps(_NODE_COLOURS), + edge_colours_json=json.dumps(_EDGE_COLOURS), + legend_nodes_html=_build_legend_html(_NODE_COLOURS, "dot"), + legend_edges_html=_build_legend_html(_EDGE_COLOURS, "line"), + ) + + with open(file_path, "w", encoding="utf-8") as fh: + fh.write(html) + + logger.info(f"Inventory graph HTML written to {file_path}") + except Exception as e: + logger.error( + f"inventory_output.write_html: {e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}" + ) + + +# --------------------------------------------------------------------------- +# Convenience entry-point called from __main__.py +# --------------------------------------------------------------------------- + + +def generate_inventory_outputs(output_path: str) -> None: + """ + Build the connectivity graph from currently-loaded service clients and write + both JSON and HTML outputs. + + Args: + output_path: base file path WITHOUT extension, e.g. + "output/prowler-output-20240101120000". + The function appends .inventory.json and .inventory.html. + """ + from lib.graph_builder import build_graph + + graph = build_graph() + + if not graph.nodes: + logger.warning( + "Inventory graph: no nodes discovered. " + "Make sure at least one AWS service was scanned before generating the inventory." + ) + + write_json(graph, f"{output_path}.inventory.json") + write_html(graph, f"{output_path}.inventory.html") diff --git a/contrib/inventory-graph/lib/models.py b/contrib/inventory-graph/lib/models.py new file mode 100644 index 0000000000..bb2c9a3ce7 --- /dev/null +++ b/contrib/inventory-graph/lib/models.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class ResourceNode: + """ + Represents a single AWS resource as a node in the connectivity graph. + + id : globally unique identifier — always the resource ARN + type : coarse resource type used for grouping/colour, e.g. "lambda_function" + name : human-readable label shown on the graph + service : AWS service name, e.g. "lambda", "ec2", "rds" + region : AWS region the resource lives in + account_id: AWS account ID + properties: additional resource-specific metadata (runtime, vpc_id, etc.) + """ + + id: str + type: str + name: str + service: str + region: str + account_id: str + properties: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ResourceEdge: + """ + Represents a directional relationship between two resource nodes. + + source_id : ARN of the source node + target_id : ARN of the target node + edge_type : semantic type of the relationship, e.g.: + "network" – resources share a network path (VPC/subnet/SG) + "iam" – IAM trust or permission relationship + "triggers" – one resource can invoke another (event source → Lambda) + "data_flow" – data is written/read (Lambda → SQS dead-letter queue) + "depends_on" – soft dependency (Lambda layer, subnet belongs to VPC) + "routes_to" – traffic routing (LB → target) + "encrypts" – KMS key encrypts the resource + label : optional short label rendered on the edge in the HTML graph + """ + + source_id: str + target_id: str + edge_type: str + label: Optional[str] = None + + +@dataclass +class ConnectivityGraph: + """ + Container for the full inventory connectivity graph. + + nodes: all discovered resource nodes + edges: all discovered edges between nodes + """ + + nodes: List[ResourceNode] = field(default_factory=list) + edges: List[ResourceEdge] = field(default_factory=list) + + def add_node(self, node: ResourceNode) -> None: + self.nodes.append(node) + + def add_edge(self, edge: ResourceEdge) -> None: + self.edges.append(edge) + + def node_ids(self) -> set: + return {n.id for n in self.nodes} diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index 61146e1e1f..a6074c7852 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -28,7 +28,7 @@ containers: image: repository: prowlercloud/prowler-api pullPolicy: IfNotPresent - command: ["../docker-entrypoint.sh", "beat"] + command: ["/home/prowler/docker-entrypoint.sh", "beat"] secrets: POSTGRES_HOST: @@ -73,7 +73,7 @@ secrets: DJANGO_SECRETS_ENCRYPTION_KEY: DJANGO_BROKER_VISIBILITY_TIMEOUT: 86400 -releaseConfigRoot: /home/prowler/.cache/pypoetry/virtualenvs/prowler-api-NnJNioq7-py3.12/lib/python3.12/site-packages/ +releaseConfigRoot: /home/prowler/.venv/lib/python3.12/site-packages/ releaseConfigPath: prowler/config/config.yaml mainConfig: @@ -438,6 +438,34 @@ mainConfig: # Minimum number of Availability Zones that an ELBv2 must be in elbv2_min_azs: 2 + # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + # aws.cloudfront_distributions_pqc_tls_enabled + cloudfront_pqc_min_protocol_versions: + - "TLSv1.3_2025" + # aws.apigateway_domain_name_pqc_tls_enabled + apigateway_pqc_tls_allowed_policies: + - "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PQ_2025_09" + + # AWS Post-Quantum SSH Key Exchange Configuration + # aws.transfer_server_pqc_ssh_kex_enabled + transfer_pqc_ssh_allowed_policies: + - "TransferSecurityPolicy-2025-03" + - "TransferSecurityPolicy-FIPS-2025-03" + - "TransferSecurityPolicy-AS2Restricted-2025-07" + + + # aws.rolesanywhere_trust_anchor_pqc_pki + rolesanywhere_pqc_pca_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" # AWS Secrets Configuration # Patterns to ignore in the secrets checks @@ -590,13 +618,16 @@ resources: {} # memory: 128Mi # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +# /health/live succeeds while the process answers; /health/ready also +# checks PostgreSQL, Valkey and Neo4j connectivity and returns 503 when +# any of them is unreachable. livenessProbe: httpGet: - path: / + path: /health/live port: http readinessProbe: httpGet: - path: / + path: /health/ready port: http #This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml index 38d6e65ee3..856770722a 100644 --- a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml +++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml @@ -11,8 +11,7 @@ data: {{- else }} AUTH_URL: {{ .Values.ui.authUrl | quote }} {{- end }} - API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" + UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" AUTH_TRUST_HOST: "true" UI_PORT: {{ .Values.ui.service.port | quote }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml index 6c11a28f9b..88f034fc96 100644 --- a/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml +++ b/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml @@ -34,6 +34,10 @@ spec: securityContext: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.worker.initContainers }} + initContainers: + {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: worker {{- with .Values.worker.securityContext }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml index 749ea946fd..c1ef9ebf0c 100644 --- a/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml +++ b/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml @@ -32,6 +32,10 @@ spec: securityContext: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.worker_beat.initContainers }} + initContainers: + {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: worker-beat {{- with .Values.worker_beat.securityContext }} diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml index 9a162bd67e..ed390af29e 100644 --- a/contrib/k8s/helm/prowler-app/values.yaml +++ b/contrib/k8s/helm/prowler-app/values.yaml @@ -270,20 +270,23 @@ api: # 3m30s to setup DB # startupProbe: # httpGet: - # path: /api/v1/docs + # path: /health/live # port: http # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + # /health/live succeeds while the process answers; /health/ready also + # checks PostgreSQL, Valkey and Neo4j connectivity and returns 503 when + # any of them is unreachable. livenessProbe: failureThreshold: 10 httpGet: - path: /api/v1/docs + path: /health/live port: http periodSeconds: 20 readinessProbe: failureThreshold: 10 httpGet: - path: /api/v1/docs + path: /health/ready port: http periodSeconds: 20 @@ -437,7 +440,7 @@ worker_beat: tag: "" command: - - ../docker-entrypoint.sh + - /home/prowler/docker-entrypoint.sh args: - beat diff --git a/contrib/k8s/helm/prowler-ui/values.yaml b/contrib/k8s/helm/prowler-ui/values.yaml index d4f2dbe137..aca0178b3a 100644 --- a/contrib/k8s/helm/prowler-ui/values.yaml +++ b/contrib/k8s/helm/prowler-ui/values.yaml @@ -21,8 +21,8 @@ fullnameOverride: "" secrets: SITE_URL: http://localhost:3000 - API_BASE_URL: http://prowler-api:8080/api/v1 - NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs + UI_API_BASE_URL: http://prowler-api:8080/api/v1 + UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST: True UI_PORT: 3000 # openssl rand -base64 32 diff --git a/contrib/reverse-proxy/README.md b/contrib/reverse-proxy/README.md new file mode 100644 index 0000000000..a6387e689a --- /dev/null +++ b/contrib/reverse-proxy/README.md @@ -0,0 +1,64 @@ +# Prowler Reverse Proxy Configuration + +Ready-to-use nginx configuration for running Prowler behind a reverse proxy. + +## Problem + +Prowler's default Docker setup exposes two separate services: +- **UI** on port 3000 +- **API** on port 8080 + +This causes CORS issues and authentication failures (especially SAML SSO) when accessed through an external reverse proxy, since the proxy typically exposes a single domain. + +## Solution + +This adds an nginx container that unifies both services behind a single port, correctly forwarding headers so that Django generates proper URLs for SAML ACS callbacks and API responses. + +## Quick Start + +From the prowler root directory: + + docker compose -f docker-compose.yml \ + -f contrib/reverse-proxy/docker-compose.reverse-proxy.yml \ + up -d + +Access Prowler at http://localhost (port 80). + +## With an External Reverse Proxy + +Point your external reverse proxy to the prowler-nginx container on port 80. + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| PROWLER_PROXY_PORT | 80 | Port exposed by the nginx proxy | + +### Example: Traefik + + services: + nginx: + labels: + - "traefik.enable=true" + - "traefik.http.routers.prowler.rule=Host(`prowler.example.com`)" + - "traefik.http.routers.prowler.tls.certresolver=letsencrypt" + - "traefik.http.services.prowler.loadbalancer.server.port=80" + +### Example: Caddy + + prowler.example.com { + reverse_proxy prowler-nginx:80 + } + +## SAML SSO + +If using SAML SSO behind a reverse proxy, also set the SAML_ACS_BASE_URL environment variable: + + SAML_ACS_BASE_URL=https://prowler.example.com + +## Architecture + + Internet -> External Reverse Proxy -> prowler-nginx:80 + |-- /api/* -> prowler-api:8080 + |-- /accounts/saml/ -> prowler-api:8080 + +-- /* -> prowler-ui:3000 diff --git a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml new file mode 100644 index 0000000000..b8f8edec30 --- /dev/null +++ b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml @@ -0,0 +1,42 @@ +# Prowler Reverse Proxy - Docker Compose Override +# +# Use this alongside the main docker-compose.yml to add an nginx +# reverse proxy that unifies UI and API behind a single port. +# +# Usage: +# docker compose -f docker-compose.yml -f contrib/reverse-proxy/docker-compose.reverse-proxy.yml up -d +# +# Then access Prowler at http://localhost (port 80) or configure +# your external reverse proxy (Traefik, Caddy, Cloudflare Tunnel, +# Pangolin, etc.) to point to this container on port 80. +# +# For HTTPS with your own certs, see the README in this directory. +# +# Fixes: https://github.com/prowler-cloud/prowler/issues/8516 + +services: + nginx: + image: nginx:alpine@sha256:8b1e78743a03dbb2c95171cc58639fef29abc8816598e27fb910ed2e621e589a + container_name: prowler-nginx + restart: unless-stopped + ports: + - "${PROWLER_PROXY_PORT:-80}:80" + volumes: + - ./contrib/reverse-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - prowler-ui + - prowler-api + networks: + - prowler-network + + # Override UI to not expose port externally (nginx handles it) + prowler-ui: + ports: !reset [] + + # Override API to not expose port externally (nginx handles it) + prowler-api: + ports: !reset [] + +networks: + prowler-network: + driver: bridge diff --git a/contrib/reverse-proxy/nginx.conf b/contrib/reverse-proxy/nginx.conf new file mode 100644 index 0000000000..58520295bc --- /dev/null +++ b/contrib/reverse-proxy/nginx.conf @@ -0,0 +1,70 @@ +# Prowler Reverse Proxy Configuration +# Routes both UI and API through a single endpoint +# +# Usage: See docker-compose.reverse-proxy.yml +# Fixes: https://github.com/prowler-cloud/prowler/issues/8516 + +upstream prowler-ui { + server prowler-ui:3000; +} + +upstream prowler-api { + server prowler-api:8080; +} + +server { + listen 80; + server_name _; + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # API requests — proxy to prowler-api + location /api/ { + proxy_pass http://prowler-api/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 300s; + proxy_connect_timeout 10s; + + # Handle large scan payloads + client_max_body_size 50m; + } + + # SAML endpoints — proxy to prowler-api + location /accounts/saml/ { + proxy_pass http://prowler-api/accounts/saml/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + + # Everything else — proxy to prowler-ui + location / { + proxy_pass http://prowler-ui/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # WebSocket support for Next.js HMR (dev) and live updates + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } +} diff --git a/contrib/wazuh/prowler-wrapper.py b/contrib/wazuh/prowler-wrapper.py index e7e7faa9af..b9b25a7ebf 100644 --- a/contrib/wazuh/prowler-wrapper.py +++ b/contrib/wazuh/prowler-wrapper.py @@ -220,7 +220,7 @@ def _send_prowler_results(prowler_results, _prowler_version, options): try: _debug("RESULT MSG --- {0}".format(_check_result), 2) _check_result = json.loads(TEMPLATE_CHECK.format(_check_result)) - except: + except Exception: _debug( "INVALID JSON --- {0}".format(TEMPLATE_CHECK.format(_check_result)), 1 ) diff --git a/dashboard/common_methods.py b/dashboard/common_methods.py index a2f9ffe89b..b9f59513a5 100644 --- a/dashboard/common_methods.py +++ b/dashboard/common_methods.py @@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2): return html.Div(section_containers, className="compliance-data-layout") +def _status_bar(success, failed, classname): + """Build the stacked PASS/FAIL bar shown next to an accordion title.""" + fig = go.Figure( + data=[ + go.Bar( + name="Failed", + x=[failed], + y=[""], + orientation="h", + marker=dict(color="#e77676"), + width=[0.8], + ), + go.Bar( + name="Success", + x=[success], + y=[""], + orientation="h", + marker=dict(color="#45cc6e"), + width=[0.8], + ), + ] + ) + fig.update_layout( + barmode="stack", + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + showlegend=False, + width=350, + height=30, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + yaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + annotations=[ + dict( + x=success + failed, + y=0, + xref="x", + yref="y", + text=str(success), + showarrow=False, + font=dict(color="#45cc6e", size=14), + xanchor="left", + yanchor="middle", + ), + dict( + x=0, + y=0, + xref="x", + yref="y", + text=str(failed), + showarrow=False, + font=dict(color="#e77676", size=14), + xanchor="right", + yanchor="middle", + ), + ], + ) + fig.add_annotation( + x=failed, + y=0.3, + text="|", + showarrow=False, + xanchor="center", + yanchor="middle", + font=dict(size=20), + ) + return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname) + + +def get_section_containers_generic(data, section_col, id_col): + """Two-level view: section -> requirement id (+ description) -> checks. + + Sorts lexicographically so arbitrary requirement IDs never crash the + version-aware sort used by the CIS renderer. + """ + data["STATUS"] = data["STATUS"].apply(map_status_to_icon) + data[section_col] = data[section_col].astype(str) + data[id_col] = data[id_col].astype(str) + data.sort_values(by=[section_col, id_col], inplace=True) + + counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0) + counts_id = ( + data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0) + ) + + def count(counts, key, emoji): + return counts.loc[key, emoji] if emoji in counts.columns else 0 + + has_description = "REQUIREMENTS_DESCRIPTION" in data.columns + table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"] + + section_containers = [] + for section in data[section_col].unique(): + graph_div = html.Div( + _status_bar( + count(counts_section, section, pass_emoji), + count(counts_section, section, fail_emoji), + "info-bar", + ), + className="graph-section", + ) + + internal_items = [] + for req_id in data[data[section_col] == section][id_col].unique(): + specific_data = data[ + (data[section_col] == section) & (data[id_col] == req_id) + ] + data_table = dash_table.DataTable( + data=specific_data.to_dict("records"), + columns=[ + {"name": i, "id": i} + for i in table_cols + if i in specific_data.columns + ], + style_table={"overflowX": "auto"}, + style_as_list_view=True, + style_cell={"textAlign": "left", "padding": "5px"}, + ) + graph_div_req = html.Div( + _status_bar( + count(counts_id, (section, req_id), pass_emoji), + count(counts_id, (section, req_id), fail_emoji), + "info-bar-child", + ), + className="graph-section-req", + ) + + title = req_id + if has_description: + title = ( + f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}" + ) + if len(title) > 130: + title = title[:130] + " ..." + + internal_items.append( + html.Div( + [ + graph_div_req, + dbc.Accordion( + [ + dbc.AccordionItem( + title=title, + children=[ + html.Div( + [data_table], + className="inner-accordion-content", + ) + ], + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner--child", + ) + ) + + section_containers.append( + html.Div( + [ + graph_div, + dbc.Accordion( + [ + dbc.AccordionItem( + title=f"{section}", children=internal_items + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner", + ) + ) + + return html.Div(section_containers, className="compliance-data-layout") + + def get_section_containers_format4(data, section_1): data["STATUS"] = data["STATUS"].apply(map_status_to_icon) diff --git a/dashboard/compliance/aws_ai_security_framework_aws.py b/dashboard/compliance/aws_ai_security_framework_aws.py new file mode 100644 index 0000000000..ece9bdf9cb --- /dev/null +++ b/dashboard/compliance/aws_ai_security_framework_aws.py @@ -0,0 +1,27 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + ) diff --git a/dashboard/compliance/cis_2_0_1_kubernetes.py b/dashboard/compliance/cis_2_0_1_kubernetes.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_2_0_1_kubernetes.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_5_0_gcp.py b/dashboard/compliance/cis_5_0_gcp.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_5_0_gcp.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_6_0_azure.py b/dashboard/compliance/cis_6_0_azure.py new file mode 100644 index 0000000000..9d33cc67a8 --- /dev/null +++ b/dashboard/compliance/cis_6_0_azure.py @@ -0,0 +1,25 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_7_0_aws.py b/dashboard/compliance/cis_7_0_aws.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_7_0_aws.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_7_0_m365.py b/dashboard/compliance/cis_7_0_m365.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_7_0_m365.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/generic.py b/dashboard/compliance/generic.py new file mode 100644 index 0000000000..f7d68bb52a --- /dev/null +++ b/dashboard/compliance/generic.py @@ -0,0 +1,44 @@ +import warnings + +from dashboard.common_methods import ( + get_section_containers_format4, + get_section_containers_generic, +) + +warnings.filterwarnings("ignore") + + +def get_table(data): + # Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime. + attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")] + + # Section column (in priority order): + # 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention + # 2. First discovered attribute column — covers novel schemas + # 3. None — no section, group flat by requirement id + if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols: + section_col = "REQUIREMENTS_ATTRIBUTES_SECTION" + elif attr_cols: + section_col = attr_cols[0] + else: + section_col = None + + base_cols = [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "STATUS", + "CHECKID", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + + # Two levels (section -> requirement id) when a section distinct from the + # id exists; otherwise group flat by requirement id. + if section_col and section_col != "REQUIREMENTS_ID": + needed = [section_col] + base_cols + aux = data[[c for c in needed if c in data.columns]].copy() + return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID") + + aux = data[[c for c in base_cols if c in data.columns]].copy() + return get_section_containers_format4(aux, "REQUIREMENTS_ID") diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index 930432b6c4..3fb230f314 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -156,7 +156,7 @@ def create_layout_compliance( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index c1da9f611e..773dd095da 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -215,6 +215,58 @@ else: ) +def _ensure_scope_columns(data): + """Guarantee ACCOUNTID and REGION exist. + + Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive + them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and + fall back to "-" to avoid a KeyError. + """ + cols = list(data.columns) + scope = [] + if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols: + start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE") + scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")] + + if "ACCOUNTID" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True) + else: + data["ACCOUNTID"] = "-" + if "REGION" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "REGION"}, inplace=True) + else: + data["REGION"] = "-" + return data + + +def _dispatch_compliance_renderer(data, analytics_input): + """Resolve the compliance renderer module and return (table, deduped_data). + + Tries to import the framework-specific builtin module. On + ModuleNotFoundError (dynamic/external provider with no dedicated module), + falls back to the generic renderer. Any other ImportError is re-raised. + get_table() is called OUTSIDE the try block so errors inside the renderer + surface as real exceptions rather than being swallowed. + """ + current = analytics_input.replace(".", "_") + target = f"dashboard.compliance.{current}" + try: + module = importlib.import_module(target) + except ModuleNotFoundError as exc: + if exc.name != target: + raise + from dashboard.compliance import generic as module + dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] + if "MUTED" in data.columns: + dedup_columns.insert(2, "MUTED") + data = data.drop_duplicates(subset=dedup_columns) + if "threatscore" in analytics_input: + data = get_threatscore_mean_by_pillar(data) + return module.get_table(data), data + + @callback( [ Output("output", "children"), @@ -292,7 +344,7 @@ def display_data( data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True) # Filter the chosen level of the CIS - if is_level_1: + if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns: data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")] # Rename the column PROJECTID to ACCOUNTID for GCP @@ -314,6 +366,9 @@ def display_data( data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True) data["REGION"] = "-" + # Normalize scope columns for any remaining (e.g. dynamic) provider. + data = _ensure_scope_columns(data) + # Filter ACCOUNT if account_filter == ["All"]: updated_cloud_account_values = data["ACCOUNTID"].unique() @@ -409,36 +464,7 @@ def display_data( # Check cases where the compliance start with AWS_ if "aws_" in analytics_input: analytics_input = analytics_input + "_aws" - try: - current = analytics_input.replace(".", "_") - compliance_module = importlib.import_module( - f"dashboard.compliance.{current}" - ) - # Build subset list based on available columns - dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] - if "MUTED" in data.columns: - dedup_columns.insert(2, "MUTED") - data = data.drop_duplicates(subset=dedup_columns) - - if "threatscore" in analytics_input: - data = get_threatscore_mean_by_pillar(data) - - table = compliance_module.get_table(data) - except ModuleNotFoundError: - table = html.Div( - [ - html.H5( - "No data found for this compliance", - className="card-title", - style={"text-align": "left", "color": "black"}, - ) - ], - style={ - "width": "99%", - "margin-right": "0.8%", - "margin-bottom": "10px", - }, - ) + table, data = _dispatch_compliance_renderer(data, analytics_input) df = data.copy() # Remove Muted rows diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 665aa8e195..e705f15e9f 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -1538,7 +1538,7 @@ def filter_data( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 554177bc5c..d737298183 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,4 +1,11 @@ services: + api-dev-init: + image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 + volumes: + - ./_data/api:/data + command: ["sh", "-c", "chown -R 1000:1000 /data"] + restart: "no" + api-dev: hostname: "prowler-api" image: prowler-api-dev @@ -21,12 +28,20 @@ services: - ./_data/api:/home/prowler/.config/prowler-api - outputs:/tmp/prowler_api_output depends_on: + api-dev-init: + condition: service_completed_successfully postgres: condition: service_healthy valkey: condition: service_healthy neo4j: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/health/live || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s entrypoint: - "/home/prowler/docker-entrypoint.sh" - "dev" @@ -49,7 +64,7 @@ services: condition: service_healthy postgres: - image: postgres:16.3-alpine3.20 + image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41 hostname: "postgres-db" volumes: - ./_data/postgres:/var/lib/postgresql/data @@ -73,7 +88,7 @@ services: retries: 5 valkey: - image: valkey/valkey:7-alpine3.19 + image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc hostname: "valkey" volumes: - ./_data/valkey:/data @@ -89,7 +104,7 @@ services: retries: 3 neo4j: - image: graphstack/dozerdb:5.26.3.0 + image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247 hostname: "neo4j" volumes: - ./_data/neo4j:/data @@ -124,6 +139,8 @@ services: worker-dev: image: prowler-api-dev + # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop. + stop_grace_period: 120s build: context: ./api dockerfile: Dockerfile @@ -139,11 +156,7 @@ services: - ./api/docker-entrypoint.sh:/home/prowler/docker-entrypoint.sh - outputs:/tmp/prowler_api_output depends_on: - valkey: - condition: service_healthy - postgres: - condition: service_healthy - neo4j: + api-dev: condition: service_healthy ulimits: nofile: @@ -165,18 +178,14 @@ services: - path: ./.env required: false depends_on: - valkey: - condition: service_healthy - postgres: - condition: service_healthy - neo4j: + api-dev: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 entrypoint: - - "../docker-entrypoint.sh" + - "/home/prowler/docker-entrypoint.sh" - "beat" mcp-server: @@ -204,6 +213,7 @@ services: interval: 10s timeout: 5s retries: 3 + start_period: 60s volumes: outputs: diff --git a/docker-compose.yml b/docker-compose.yml index 4112624dc2..6cb5ac237d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,13 @@ # docker compose -f docker-compose-dev.yml up # services: + api-init: + image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 + volumes: + - ./_data/api:/data + command: ["sh", "-c", "chown -R 1000:1000 /data"] + restart: "no" + api: hostname: "prowler-api" image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} @@ -17,12 +24,20 @@ services: - ./_data/api:/home/prowler/.config/prowler-api - output:/tmp/prowler_api_output depends_on: + api-init: + condition: service_completed_successfully postgres: condition: service_healthy valkey: condition: service_healthy neo4j: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/health/live || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s entrypoint: - "/home/prowler/docker-entrypoint.sh" - "prod" @@ -33,13 +48,19 @@ services: - path: .env required: false ports: - - ${UI_PORT:-3000}:${UI_PORT:-3000} + - ${UI_PORT:-3000}:3000 depends_on: mcp-server: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 60s postgres: - image: postgres:16.3-alpine3.20 + image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41 hostname: "postgres-db" volumes: - ./_data/postgres:/var/lib/postgresql/data @@ -59,7 +80,7 @@ services: retries: 5 valkey: - image: valkey/valkey:7-alpine3.19 + image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc hostname: "valkey" volumes: - ./_data/valkey:/data @@ -75,7 +96,7 @@ services: retries: 3 neo4j: - image: graphstack/dozerdb:5.26.3.0 + image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247 hostname: "neo4j" volumes: - ./_data/neo4j:/data @@ -108,15 +129,15 @@ services: worker: image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} + # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop. + stop_grace_period: 120s env_file: - path: .env required: false volumes: - "output:/tmp/prowler_api_output" depends_on: - valkey: - condition: service_healthy - postgres: + api: condition: service_healthy ulimits: nofile: @@ -132,16 +153,14 @@ services: - path: ./.env required: false depends_on: - valkey: - condition: service_healthy - postgres: + api: condition: service_healthy ulimits: nofile: soft: 65536 hard: 65536 entrypoint: - - "../docker-entrypoint.sh" + - "/home/prowler/docker-entrypoint.sh" - "beat" mcp-server: @@ -159,6 +178,7 @@ services: interval: 10s timeout: 5s retries: 3 + start_period: 60s volumes: output: diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 2f9efd405d..8278a7f88a 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -134,7 +134,7 @@ Example 1 is vague and even potentially ambiguous. Verbs state your purpose and Explicit use of second-person pronouns (you) and possessives (your) should be minimized whenever possible. Those constructions are best reserved for cases when instructions are directly given in an imperative form: -**Example of Improvement Through Avoiding Second Person Pronouns** +### Example of Improvement Through Avoiding Second Person Pronouns **Original:** Prowler App can be installed in different ways, depending on your environment: @@ -236,7 +236,7 @@ The use of bullet points is highly recommended when: * Information can be logically divided into multiple categories, each sharing characteristics, features, or other relevant classifications. * Items are significant enough as standalone concepts to deserve their own bullet point. -**Example of Improvement Through Bullet Points** +#### Example of Improvement Through Bullet Points **Original:** It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMS, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme), and your custom security frameworks. @@ -467,7 +467,7 @@ Effective headers and section titles enhance document readability and structure, * **Example:** * How to Clone and Install Prowler from GitHub (header: Title case) - * How to install poetry dependencies (subheading: Sentence case) + * How to install uv dependencies (subheading: Sentence case) 5. **Using Keywords in Headers** Headers should include relevant keywords to improve document searchability: * **Good:** Scanning AWS Accounts in Parallel diff --git a/docs/README.md b/docs/README.md index 79917aeed1..595f79bc5a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,10 +10,10 @@ This repository contains the Prowler Open Source documentation powered by [Mintl ## Local Development -Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview documentation changes locally: +Install a reviewed version of the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview documentation changes locally: ```bash -npm i -g mint +npm install --global mint@4.2.560 ``` Run the following command at the root of your documentation (where `mint.json` is located): diff --git a/docs/developer-guide/ai-skills.mdx b/docs/developer-guide/ai-skills.mdx index 6a0787dac8..ceb154d027 100644 --- a/docs/developer-guide/ai-skills.mdx +++ b/docs/developer-guide/ai-skills.mdx @@ -8,7 +8,77 @@ This guide explains the AI Skills system that provides on-demand context and pat **What are AI Skills?** Skills are structured instructions that help AI agents (Claude Code, Cursor, Copilot, etc.) understand Prowler's conventions, patterns, and best practices. -## Architecture Overview +Skills live in the [`skills/`](https://github.com/prowler-cloud/prowler/tree/master/skills) directory of the Prowler OSS repository. Each skill is a folder containing a `SKILL.md` file with its patterns and metadata. + +## Installation + +To enable skills for the supported AI coding assistants, run the setup script from the repository root: + +```bash +./skills/setup.sh +``` + +The script creates symlinks so each tool finds the skills in its expected location: + +| Tool | Created by setup | +|------|------------------| +| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` | +| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` | +| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) | +| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` | + +After running the setup, restart the AI coding assistant to load the skills. + +## Using Skills + +AI agents discover skills automatically and load them when a request matches a skill trigger. To load a skill manually during a session, point the agent to the skill's `SKILL.md` file: + +```text +Read skills/{skill-name}/SKILL.md +``` + +For the full list of available skills, their triggers, and the Auto-invoke mappings, see the [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) and [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) in the repository. + +## Available Skills + +| Type | Skills | +|------|--------| +| **Generic** | typescript, react-19, nextjs-16, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5, vitest, tdd | +| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci, prowler-attack-paths-query | +| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui | +| **Meta** | skill-creator, skill-sync | + + +This table is a snapshot. The repository is the source of truth: see [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) for the current, complete list. + + +## Skill Structure + +Each skill follows the [Agent Skills spec](https://agentskills.io): + +```text +skills/{skill-name}/ +├── SKILL.md # Patterns, rules, decision trees +├── assets/ # Code templates, schemas +└── references/ # Links to local docs (single source of truth) +``` + +## Key Design Decisions + +1. **Self-contained skills** - Critical patterns inline for fast loading +2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx` +3. **Single source of truth** - Skills reference docs, no duplication +4. **On-demand loading** - AI loads only what's needed for the task + +## Creating New Skills + +Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) for the full list of available skills and their triggers. + +## How Skills Work + +The diagrams below explain the internals of the skill system. They are useful for understanding the design, but are not required to install or use skills. + +### Architecture Overview ```mermaid graph LR @@ -28,7 +98,7 @@ graph LR style F fill:#1a4d2e,stroke:#66bb6a,color:#fff ``` -## How It Works +### Request Lifecycle ```mermaid sequenceDiagram @@ -68,7 +138,7 @@ sequenceDiagram A->>U: Creates check with correct patterns ``` -## Before vs After +### With and Without Skills ```mermaid graph TD @@ -96,7 +166,7 @@ graph TD style AFTER fill:#1a4d1a,stroke:#66bb6a,color:#fff ``` -## Complete Architecture +### Full Component Map ```mermaid flowchart TB @@ -110,7 +180,7 @@ flowchart TB subgraph GENERIC["Generic Skills"] G1["typescript"] G2["react-19"] - G3["nextjs-15"] + G3["nextjs-16"] G4["tailwind-4"] G5["pytest"] G6["playwright"] @@ -186,34 +256,3 @@ flowchart TB style STRUCTURE fill:#5c3d1a,stroke:#ffb74d,color:#fff style DOCS fill:#1a3d4d,stroke:#4dd0e1,color:#fff ``` - -## Skills Included - -| Type | Skills | -|------|--------| -| **Generic** | typescript, react-19, nextjs-15, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5 | -| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci | -| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui | -| **Meta** | skill-creator, skill-sync | - -## Skill Structure - -Each skill follows the [Agent Skills spec](https://agentskills.io): - -``` -skills/{skill-name}/ -├── SKILL.md # Patterns, rules, decision trees -├── assets/ # Code templates, schemas -└── references/ # Links to local docs (single source of truth) -``` - -## Key Design Decisions - -1. **Self-contained skills** - Critical patterns inline for fast loading -2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx` -3. **Single source of truth** - Skills reference docs, no duplication -4. **On-demand loading** - AI loads only what's needed for the task - -## Creating New Skills - -Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See `AGENTS.md` for the full list of available skills and their triggers. diff --git a/docs/developer-guide/alibabacloud-details.mdx b/docs/developer-guide/alibabacloud-details.mdx index 4c21e17b29..3877b8349a 100644 --- a/docs/developer-guide/alibabacloud-details.mdx +++ b/docs/developer-guide/alibabacloud-details.mdx @@ -203,10 +203,10 @@ For detailed authentication configuration, see the [Authentication documentation ## Regions -Alibaba Cloud has multiple regions across the globe. By default, Prowler audits all available regions. You can specify specific regions using the `--regions` CLI argument: +Alibaba Cloud has multiple regions across the globe. By default, Prowler audits all available regions. You can specify specific regions using the `--region` CLI argument: ```bash -prowler alibabacloud --regions cn-hangzhou cn-shanghai +prowler alibabacloud --region cn-hangzhou cn-shanghai ``` The list of supported regions is maintained in [`prowler/providers/alibabacloud/config.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/alibabacloud/config.py). diff --git a/docs/developer-guide/aws-details.mdx b/docs/developer-guide/aws-details.mdx index 70f94f4006..e6f561ea3d 100644 --- a/docs/developer-guide/aws-details.mdx +++ b/docs/developer-guide/aws-details.mdx @@ -73,6 +73,58 @@ The best reference to understand how to implement a new service is following the - AWS API calls are wrapped in try/except blocks, with specific handling for `ClientError` and generic exceptions, always logging errors. - If ARN is not present for some resource, it can be constructed using string interpolation, always including partition, service, region, account, and resource ID. - Tags and additional attributes that cannot be retrieved from the default call, should be collected and stored for each resource using dedicated methods and threading using the resource object list as iterator. +- When accessing dictionary values from AWS API responses, always use `.get()` with a default value instead of direct dictionary access (e.g., `response.get("Policies", {})` instead of `response["Policies"]`). AWS API responses may not always include all keys, and direct access can cause `KeyError` exceptions that break the entire scan for that service. + +### Extending an Existing Service with New Attributes + +When adding a new check that requires data not yet collected by an existing service, you need to extend the service by adding new attributes to its resource models and updating the data collection methods. This is a common contributor task that follows a consistent pattern: + +1. **Identify the missing data**: Determine which AWS API call provides the data you need and whether it's already being called by the service. + +2. **Add new attributes to the resource model**: Extend the Pydantic `BaseModel` class for the resource with the new fields. Use `Optional` types with `None` as the default value to maintain backward compatibility with existing checks. + +3. **Update the data collection method**: Modify the existing method that fetches resource details to also extract and store the new attributes. If no existing method fetches the data, add a new method and call it in the constructor using `self.__threading_call__` if possible. + +4. **Use safe dictionary access**: When extracting values from API responses, always use `.get()` with appropriate defaults to prevent `KeyError` exceptions when the API doesn't return certain fields. + +#### Example: Adding DKIM Status to SES Identities + +```python +# Step 1 & 2: Add new fields to the resource model +class Identity(BaseModel): + name: str + arn: str + region: str + type: Optional[str] + policy: Optional[dict] = None + tags: Optional[list] = [] + # New attributes for DKIM check + dkim_status: Optional[str] = None + dkim_signing_attributes_origin: Optional[str] = None + +# Step 3: Update the data collection method +def _get_email_identities(self, identity): + try: + regional_client = self.regional_clients[identity.region] + identity_attributes = regional_client.get_email_identity( + EmailIdentity=identity.name + ) + # Step 4: Use .get() for safe dictionary access + for content_key, content_value in identity_attributes.get("Policies", {}).items(): + identity.policy = loads(content) + identity.tags = identity_attributes.get("Tags", []) + # Extract new DKIM attributes + identity.dkim_status = identity_attributes.get("DkimStatus") + identity.dkim_signing_attributes_origin = ( + identity_attributes.get("DkimSigningAttributesOrigin") + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) +``` + +5. **Update the service tests**: Add the new attributes to the test mock data and assertions to verify correct data extraction. ## Specific Patterns in AWS Checks diff --git a/docs/developer-guide/check-metadata-guidelines.mdx b/docs/developer-guide/check-metadata-guidelines.mdx index bac16f6fc0..85b84620d5 100644 --- a/docs/developer-guide/check-metadata-guidelines.mdx +++ b/docs/developer-guide/check-metadata-guidelines.mdx @@ -215,3 +215,6 @@ Also is important to keep all code examples as short as possible, including the | e5 | M365 and Azure Entra checks enabled by or dependent on an E5 license (e.g., advanced threat protection, audit, DLP, and eDiscovery) | | privilege-escalation | Detects IAM policies or permissions that allow identities to elevate their privileges beyond their intended scope, potentially gaining administrator or higher-level access through specific action combinations | | ec2-imdsv1 | Identifies EC2 instances using Instance Metadata Service version 1 (IMDSv1), which is vulnerable to SSRF attacks and should be replaced with IMDSv2 for enhanced security | +| vercel-hobby-plan | Vercel checks whose audited feature is available on the Hobby plan (and therefore also on Pro and Enterprise plans) | +| vercel-pro-plan | Vercel checks whose audited feature requires a Pro plan or higher, including features also available on Enterprise or via supported paid add-ons for Pro plans | +| vercel-enterprise-plan | Vercel checks whose audited feature requires the Enterprise plan | diff --git a/docs/developer-guide/checks.mdx b/docs/developer-guide/checks.mdx index da469678fa..5334799d36 100644 --- a/docs/developer-guide/checks.mdx +++ b/docs/developer-guide/checks.mdx @@ -20,21 +20,35 @@ The most common high level steps to create a new check are: 3. Create a check-specific folder. The path should follow this pattern: `prowler/providers//services//`. Adhere to the [Naming Format for Checks](#naming-format-for-checks). 4. Populate the folder with files as specified in [File Creation](#file-creation). 5. Run the check locally to ensure it works as expected. For checking you can use the CLI in the next way: - - To ensure the check has been detected by Prowler: `poetry run python prowler-cli.py --list-checks | grep `. - - To run the check, to find possible issues: `poetry run python prowler-cli.py --log-level ERROR --verbose --check `. + - To ensure the check has been detected by Prowler: `uv run python prowler-cli.py --list-checks | grep `. + - To run the check, to find possible issues: `uv run python prowler-cli.py --log-level ERROR --verbose --check `. 6. Create comprehensive tests for the check that cover multiple scenarios including both PASS (compliant) and FAIL (non-compliant) cases. For detailed information about test structure and implementation guidelines, refer to the [Testing](/developer-guide/unit-testing) documentation. 7. If the check and its corresponding tests are working as expected, you can submit a PR to Prowler. ### Naming Format for Checks -Checks must be named following the format: `service_subservice_resource_action`. +If you already know the check name when creating a request or implementing a check, use a descriptive identifier with lowercase letters and underscores only. + +Recommended patterns: + +- `__` The name components are: -- `service` – The main service being audited (e.g., ec2, entra, iam, etc.) -- `subservice` – An individual component or subset of functionality within the service that is being audited. This may correspond to a shortened version of the class attribute accessed within the check. If there is no subservice, just omit. -- `resource` – The specific resource type being evaluated (e.g., instance, policy, role, etc.) -- `action` – The security aspect or configuration being checked (e.g., public, encrypted, enabled, etc.) +- `service` – The main service or product area being audited (e.g., ec2, entra, iam, bedrock). +- `resource` – The resource, feature, or configuration being evaluated. It can be a single word or a compound phrase joined with underscores (e.g., instance, policy, guardrail, sensitive_information_filter). +- `best_practice` – The expected secure state or best practice being checked (e.g., enabled, encrypted, restricted, configured, not_publicly_accessible). + +Additional guidance: + +- Use underscores only. Do not use hyphens. +- Keep the name specific enough to describe the behavior of the check. +- The first segment should match the service or product area whenever possible. + +Examples: + +- `s3_bucket_versioning_enabled` +- `bedrock_guardrail_sensitive_information_filter_enabled` ### File Creation @@ -387,7 +401,7 @@ Provides both code examples and best practice recommendations for addressing the #### Categories -One or more functional groupings used for execution filtering (e.g., `internet-exposed`). You can define new categories just by adding to this field. +One or more functional groupings used for execution filtering (e.g., `internet-exposed`). Categories must match the predefined values enforced by `CheckMetadata`; adding a new category requires updating the validator and the metadata documentation. For the complete list of available categories, see [Categories Guidelines](/developer-guide/check-metadata-guidelines#categories-guidelines). @@ -431,3 +445,5 @@ The metadata structure is enforced in code using a Pydantic model. For reference ## Specific Check Patterns Details for specific providers can be found in documentation pages named using the pattern `-details`. + +Checks that scan resources for plaintext secrets follow a dedicated batched structure. Refer to [Secret-Scanning Checks](/developer-guide/secret-scanning-checks) before creating or updating one. diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx index 76b339601d..7349b6ff86 100644 --- a/docs/developer-guide/configurable-checks.mdx +++ b/docs/developer-guide/configurable-checks.mdx @@ -40,9 +40,184 @@ When adding a new configurable check to Prowler, update the following files: # aws.awslambda_function_vpc_multi_az lambda_min_azs: 2 ``` +- **Provider Schema:** Add the typed field to the provider's Pydantic schema in `prowler/config/schema/.py`. This is required: the loader validates user configs against these schemas and the shipped `config.yaml` must round-trip with zero warnings. See [Adding a Parameter to the Provider Schema](#adding-a-parameter-to-the-provider-schema) below. - **Test Fixtures:** If tests depend on this configuration, add the variable to `tests/config/fixtures/config.yaml`. - **Documentation:** Document the new variable in the list of configurable checks in `docs/tutorials/configuration_file.md`. For a complete list of checks that already support configuration, see the [Configuration File Tutorial](/user-guide/cli/tutorials/configuration_file). + +Because a configurable check's verdict depends on the `audit_config` value it reads, a compliance requirement can lose meaning if the scan ran with a looser threshold than the control demands. Compliance frameworks can guard against this with **configuration guardrails**: a requirement declares the strictest configuration it tolerates and is forced to FAIL when the scan's config falls short. See [Configuration Guardrails for Requirements](/developer-guide/security-compliance-framework#configuration-guardrails-for-requirements). + + +## Adding a Parameter to the Provider Schema + +Most providers have a typed Pydantic schema in `prowler/config/schema/`, registered in `prowler/config/schema/registry.py`. When a config is loaded and the provider has a registered schema, `validate_provider_config` checks each user-supplied key against it, logs a warning, and drops any field that fails validation. The consumer's `.get(key, default)` then falls back to the built-in default. Providers without a registered schema are passed through unchanged. + +This catches typos in a value (for example, `0.2` typed as `20`, or `"medium"` for an enum that expects `"MEDIUM"`). It does NOT catch typos in a key name: `disalowed_regions` (one `l` missing) is treated as an unknown key and passes through untouched, because third-party check plugins legitimately rely on unknown keys being preserved. Reviewers should still check that any new key the YAML adds is named exactly the same as the field on the schema. + +### Where to Add the Field + +1. Open `prowler/config/schema/.py` (for example, `aws.py`). +2. Add a field on the provider's schema class. Always make it `Optional[...] = None` so the absence of the key is valid. +3. Apply the tightest type the value allows. Examples below. + +If you are introducing an entirely new provider rather than a new parameter, also add an entry mapping the provider name to its schema class in `prowler/config/schema/registry.py`. The loader uses that registry to find the schema for the provider it is loading. + +### Choosing the Right Type + +| Value kind | Field declaration | +|---|---| +| Boolean toggle | `Optional[bool] = None` | +| Strictly positive integer (days, counts) | `Optional[int] = Field(default=None, gt=0)` | +| Fraction in 0..1 (threshold) | `Optional[float] = Field(default=None, ge=0.0, le=1.0)` | +| Closed set of strings | `Optional[Literal["A", "B", "C"]] = None` | +| Free-form string | `Optional[str] = None` | +| List of strings or ints | `Optional[list[str]] = None` | + +Prefer `Literal[...]` over `str` whenever the value is one of a known set. Prefer `Field(gt=0)` over `int` whenever zero or negative would be nonsensical. The point of the schema is to catch real-world mistakes that previously passed silently. + +### Custom Validators (Only When Needed) + +If the value has structural rules beyond type and range, add a `field_validator`. Examples already in `aws.py`: + +- `_validate_port_range` rejects ports outside `0..65535`. +- `_validate_account_ids` rejects anything that isn't a 12-digit AWS account ID. +- `_validate_trusted_ips` rejects entries that aren't a valid IP or CIDR. + +Raise `ValueError` from the validator. The framework converts the error into a warning and drops the offending key. + +### Example: Adding a New Parameter + +Say a new check needs `max_iam_role_session_hours`, a strictly positive integer that defaults to 12 in code. + +1. **Schema** (`prowler/config/schema/aws.py`): + ```python + # IAM + max_iam_role_session_hours: Optional[int] = Field(default=None, gt=0) + ``` +2. **Shipped config** (`prowler/config/config.yaml`): + ```yaml + # aws.iam_role_session_duration_within_limit + max_iam_role_session_hours: 12 + ``` +3. **Consumer** (the check): + ```python + max_hours = iam_client.audit_config.get("max_iam_role_session_hours", 12) + ``` +4. **Tests** in `tests/config/schema/aws_schema_test.py`: + - one test for a valid value that round-trips, + - one test for an invalid value (zero, negative, wrong type) that is dropped. + +### What the Loader Guarantees + +- **Unknown keys pass through.** Third-party check plugins can introduce arbitrary keys without schema edits; they will not be filtered. +- **Invalid values never crash the run.** They produce a single warning per field and the key is dropped. +- **Coerced values are normalized.** A YAML-quoted `"180"` for an `int` field arrives downstream as the integer `180`. +- **The shipped `config.yaml` must round-trip cleanly.** The integration test `test_shipped_default_config_loads_without_warnings` will fail if a key is added to the YAML without a matching schema field, so the two stay in sync. + +## Configuration Value Limits + +Configurable thresholds enforce hard limits. A value outside the documented range is **dropped with a warning** and the check falls back to its built-in default (the same as if the key were absent). These bounds are intentionally conservative: they are not the absolute service maxima but the range that still produces a meaningful security check. + +Use this section as the reference when upgrading an existing config: if a value you set is being rejected, it is outside the range below. + +Only fields with a numeric range, a fixed value set, or a length cap are listed. Fields typed as free-form strings or lists (for example `disallowed_regions`, `secrets_ignore_patterns`, `trusted_account_ids`) have no range limit — they are validated for shape only (a 12-digit account ID, a valid IP/CIDR, a dotted version string), not for magnitude. + +### AWS + +| Key | Allowed range | Notes | +|---|---|---| +| `max_unused_access_keys_days` | `30..180` days | CIS AWS 1.13 recommends 45; NIST IA-5 ≤90 | +| `max_console_access_days` | `30..180` days | CIS AWS 1.12 recommends 45 | +| `max_unused_sagemaker_access_days` | `7..180` days | | +| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group | +| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years | +| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved | +| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min | +| `max_disconnect_timeout_in_seconds` | `60..3600` s | | +| `max_session_duration_seconds` | `600..86400` s | 10 min .. 24 h (AppStream per-session hard limit) | +| `lambda_min_azs` | `1..6` | | +| `recommended_cdk_bootstrap_version` | `1..100` | | +| `log_group_retention_days` | one of `1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653` | only the CloudWatch Logs API-accepted retention values | +| `threat_detection_privilege_escalation_threshold` | `0.0..1.0` | fraction of suspicious actions | +| `threat_detection_privilege_escalation_minutes` | `5..43200` min | under 5 min the signal is mostly false positives | +| `threat_detection_enumeration_threshold` | `0.0..1.0` | | +| `threat_detection_enumeration_minutes` | `5..43200` min | | +| `threat_detection_llm_jacking_threshold` | `0.0..1.0` | | +| `threat_detection_llm_jacking_minutes` | `5..43200` min | | +| `days_to_expire_threshold` (ACM) | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry | +| `elb_min_azs` | `1..6` | | +| `elbv2_min_azs` | `1..6` | | +| `minimum_snapshot_retention_period` | `1..35` days | ElastiCache service hard limit | +| `max_days_secret_unused` | `7..365` days | | +| `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 | +| `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year | +| `shodan_api_key` | ≤512 chars | | + +### Azure + +| Key | Allowed range | Notes | +|---|---|---| +| `vm_backup_min_daily_retention_days` | `7..9999` days | Azure Backup hard limit; under 7 days defeats DR/ransomware recovery | +| `apim_threat_detection_llm_jacking_threshold` | `0.0..1.0` | fraction of suspicious actions | +| `apim_threat_detection_llm_jacking_minutes` | `5..43200` min | under 5 min the signal is mostly false positives | +| `shodan_api_key` | ≤512 chars | | + +### GCP + +| Key | Allowed range | Notes | +|---|---|---| +| `mig_min_zones` | `1..5` | | +| `max_snapshot_age_days` | `1..1095` days | 3 years | +| `max_unused_account_days` | `30..365` days | | +| `storage_min_retention_days` | `1..3650` days | | +| `shodan_api_key` | ≤512 chars | | + +### Kubernetes + +| Key | Allowed range | Notes | +|---|---|---| +| `audit_log_maxbackup` | `2..1000` | CIS Kubernetes 1.2.18 recommends ≥10 | +| `audit_log_maxsize` | `10..10000` MB | CIS Kubernetes 1.2.19 recommends ≥100 MB | +| `audit_log_maxage` | `7..3650` days | CIS Kubernetes 1.2.17 recommends ≥30 days | + +### M365 + +| Key | Allowed range | Notes | +|---|---|---| +| `sign_in_frequency` | `1..168` h | 1 h .. 7 days; Conditional Access baseline for admins ≤24 h | +| `recommended_mailtips_large_audience_threshold` | `5..10000` | Microsoft default 25 | +| `audit_log_age` | `30..3650` days | M365 E3 default 90 days; SEC/FINRA require ≥7 years | + +### GitHub + +| Key | Allowed range | Notes | +|---|---|---| +| `inactive_not_archived_days_threshold` | `30..3650` days | CIS GitHub recommends 180 | + +### Cloudflare + +| Key | Allowed range | Notes | +|---|---|---| +| `max_retries` | `0..10` | 0 disables retries | + +### MongoDB Atlas + +| Key | Allowed range | Notes | +|---|---|---| +| `max_service_account_secret_validity_hours` | `1..720` h | 1 h .. 30 days | + +### Vercel + +| Key | Allowed range | Notes | +|---|---|---| +| `days_to_expire_threshold` | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry | +| `stale_token_threshold_days` | `30..3650` days | NIST AC-2(3) typical window 30..90 days | +| `stale_invitation_threshold_days` | `7..365` days | | +| `max_owner_percentage` | `1..50` % | guidance recommends ≤25% | +| `max_owners` | `1..1000` | absolute cap, overrides percentage for large teams | + +These bounds live in the provider schemas under `prowler/config/schema/`; each field's `Field(ge=..., le=...)` (or `field_validator`) is the source of truth and the descriptions there carry the full rationale. + This approach ensures that checks are easily configurable, making Prowler highly adaptable to different environments and requirements. diff --git a/docs/developer-guide/documentation.mdx b/docs/developer-guide/documentation.mdx index f1fae30d35..d0fb808af2 100644 --- a/docs/developer-guide/documentation.mdx +++ b/docs/developer-guide/documentation.mdx @@ -28,7 +28,7 @@ This includes the [AGENTS.md](https://github.com/prowler-cloud/prowler/blob/mast ```bash - npm i -g mint + npm install --global mint@4.2.560 ``` For detailed instructions, check the [Mintlify documentation](https://www.mintlify.com/docs/installation). diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx index 0a62251531..9013a32245 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -221,9 +221,9 @@ Before running E2E tests: ``` - **Ensure Prowler API is available** - - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). + - By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). - Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally). - - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests. + - If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests. - **Ensure Prowler App UI is available** - Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default). diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx new file mode 100644 index 0000000000..1f4d17ee2e --- /dev/null +++ b/docs/developer-guide/environment-variables.mdx @@ -0,0 +1,53 @@ +--- +title: 'Environment Variable Naming Convention' +--- + +Prowler is a monorepo composed of several runtime components — Prowler App (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix. + +## Component Prefixes + +Each component owns a dedicated prefix for the environment variables it reads: + +| Component | Prefix | Status | +|-----------|--------|--------| +| Prowler App (web UI) | `UI_` | Adopted | +| Prowler API (backend) | `API_` | Planned | +| Prowler SDK | `SDK_` | Planned | +| Prowler MCP Server | `MCP_` | Planned | + +## Why Component Prefixes Matter + +Component prefixes solve three concrete problems in a shared configuration file: + +- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity. +- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable. +- **Reduced accidental exposure:** For Prowler App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle. + +## Prowler App + +Prowler App has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting). + +The former build-time variables map to the new runtime variables as follows: + +| Former variable | New variable | +|-----------------|--------------| +| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` | +| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` | +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` | +| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` | +| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` | + +The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of the App's runtime configuration. + +## Upcoming Breaking Change + + +Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading. + + +Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released. + +## Deprecated Names + +- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container. +- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change). diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 2a4aa3abe1..1d434912ef 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -80,7 +80,7 @@ Before proceeding, ensure the following: - Git is installed. - Python 3.10 or higher is installed. -- `poetry` is installed to manage dependencies. +- `uv` is installed to manage dependencies. ### Forking the Prowler Repository @@ -97,49 +97,95 @@ cd prowler ### Dependency Management and Environment Isolation -To prevent conflicts between environments, we recommend using `poetry`, a Python dependency management solution. Install it by following the [instructions](https://python-poetry.org/docs/#installation). +To prevent conflicts between environments, we recommend using [`uv`](https://docs.astral.sh/uv/), a fast Python package and project manager. Install it by following the [official instructions](https://docs.astral.sh/uv/getting-started/installation/). ### Installing Dependencies To install all required dependencies, including those needed for development, run: ``` -poetry install --with dev -eval $(poetry env activate) +uv sync +source .venv/bin/activate ``` - -Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`. -If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment. -In case you have any doubts, consult the [Poetry environment activation guide](https://python-poetry.org/docs/managing-environments/#activating-the-environment). +### Running the Local API Development Stack - +For API development, Prowler provides a Makefile-based local stack in addition to the manual and Docker Compose workflows documented in the API README. PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +To start the local API stack, run: + +```shell +make dev +``` + +This command starts the required services, creates a `tmux` session with panes for the API, worker, and PostgreSQL logs, waits until the API responds, and prints the API URL and log file paths. The API is available at: + +```text +http://localhost:8080/api/v1 +``` + +Use these commands to manage the stack: + +```shell +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +The UI is not started by this workflow. Start it separately by following the UI development instructions in the `ui/` directory. ### Pre-Commit Hooks -This repository uses Git pre-commit hooks managed by the [pre-commit](https://pre-commit.com/) tool, it is installed with `poetry install --with dev`. Next, run the following command in the root of this repository: +This repository uses Git pre-commit hooks managed by the [prek](https://prek.j178.dev/) tool, it is installed with `uv sync`. Next, run the following command in the root of this repository: ```shell -pre-commit install +prek install ``` Successful installation should produce the following output: ```shell -pre-commit installed at .git/hooks/pre-commit +prek installed at `.git/hooks/pre-commit` ``` + +If pre-commit hooks were previously installed, run `prek install --overwrite` to replace the existing hook. Otherwise, both tools will run on each commit. + + +#### Enable TruffleHog as a Pre-Push Hook + +By default, only `pre-commit` hooks are installed. To enable [`TruffleHog`](https://github.com/trufflesecurity/trufflehog) secret scanning on every push, install the `pre-push` hook type explicitly: + +```shell +prek install --hook-type pre-push +``` + +Successful installation should produce the following output: + +```shell +prek installed at `.git/hooks/pre-push` +``` + +Once installed, TruffleHog runs before each push and blocks the operation when verified secrets are detected. + ### Code Quality and Security Checks Before merging pull requests, several automated checks and utilities ensure code security and updated dependencies: -These should have been already installed if `poetry install --with dev` was already run. +These should have been already installed if `uv sync` was already run. - [`bandit`](https://pypi.org/project/bandit/) for code security review. -- [`safety`](https://pypi.org/project/safety/) and [`dependabot`](https://github.com/features/security) for dependencies. +- [`osv-scanner`](https://github.com/google/osv-scanner) and [`dependabot`](https://github.com/features/security) for dependencies. - [`hadolint`](https://github.com/hadolint/hadolint) and [`dockle`](https://github.com/goodwithtech/dockle) for container security. - [`Snyk`](https://docs.snyk.io/integrations/snyk-container-integrations/container-security-with-docker-hub-integration) for container security in Docker Hub. - [`clair`](https://github.com/quay/clair) for container security in Amazon ECR. @@ -163,6 +209,8 @@ These resources help ensure that AI-assisted contributions maintain consistency All dependencies are listed in the `pyproject.toml` file. +The SDK keeps direct dependencies pinned to exact versions, while `uv.lock` records the full resolved dependency tree and the artifact hashes for every package. Use `uv sync` from the lock file instead of ad-hoc `pip` installs when you need a reproducible environment. + For proper code documentation, refer to the following and follow the code documentation practices presented there: [Google Python Style Guide - Comments and Docstrings](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings). @@ -187,8 +235,8 @@ prowler/ ├── contrib/ # Community-contributed scripts or modules ├── kubernetes/ # Kubernetes deployment files ├── .github/ # GitHub-related files (workflows, issue templates, etc.) -├── pyproject.toml # Python project configuration (Poetry) -├── poetry.lock # Poetry lock file +├── pyproject.toml # Python project configuration (uv) +├── uv.lock # uv lock file ├── README.md # Project overview and getting started ├── Makefile # Common development commands ├── Dockerfile # SDK Docker container diff --git a/docs/developer-guide/lighthouse-architecture.mdx b/docs/developer-guide/lighthouse-architecture.mdx index 38ee50c5b8..e8acc6278c 100644 --- a/docs/developer-guide/lighthouse-architecture.mdx +++ b/docs/developer-guide/lighthouse-architecture.mdx @@ -15,8 +15,7 @@ This document describes the internal architecture of Prowler Lighthouse AI, enab Lighthouse AI operates as a Langchain-based agent that connects Large Language Models (LLMs) with Prowler security data through the Model Context Protocol (MCP). -Prowler Lighthouse Architecture -Prowler Lighthouse Architecture +![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png) ### Three-Tier Architecture diff --git a/docs/developer-guide/outputs.mdx b/docs/developer-guide/outputs.mdx index 15b785b799..f74c06a393 100644 --- a/docs/developer-guide/outputs.mdx +++ b/docs/developer-guide/outputs.mdx @@ -8,7 +8,7 @@ Prowler supports multiple output formats, allowing users to tailor findings pres - Output Organization in Prowler - Prowler outputs are managed within the `/lib/outputs` directory. Each format—such as JSON, CSV, HTML—is implemented as a Python class. + Prowler outputs are managed within the `/lib/outputs` directory. Each format—such as JSON, CSV, HTML, SARIF—is implemented as a Python class. - Outputs are generated based on scan findings, which are stored as structured dictionaries containing details such as: diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index 0fb3bc549d..d3e7d3631f 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -750,6 +750,35 @@ def init_parser(self): # More arguments for the provider. ``` +##### Sensitive CLI Arguments + +CLI flags that accept secrets (tokens, passwords, API keys) require special handling to protect credentials from leaking in HTML output and process listings: + +1. **Use `nargs="?"` with `default=None`** so the flag works both with and without an inline value. This allows the provider to fall back to an environment variable when no value is passed. +2. **Add a `SENSITIVE_ARGUMENTS` frozenset** at the top of the `arguments.py` file listing every flag that accepts secret values: + + ```python + SENSITIVE_ARGUMENTS = frozenset({"--your-provider-password", "--your-provider-token"}) + ``` + + Prowler automatically discovers these frozensets and uses them to redact values in HTML output and warn users who pass secrets directly on the command line. + +3. **Document the environment variable** in the `help` text so users know the recommended alternative: + + ```python + _parser.add_argument( + "--your-provider-password", + nargs="?", + default=None, + metavar="PASSWORD", + help="Password for authentication. We recommend using the YOUR_PROVIDER_PASSWORD environment variable instead.", + ) + ``` + + +Do not add new arguments that require passing secrets as CLI values without an environment variable fallback. Prowler CLI warns users when sensitive flags receive explicit values on the command line. + + #### Step 5: Implement Mutelist **Explanation:** @@ -974,7 +1003,7 @@ class ProwlerArgumentParser: formatter_class=RawTextHelpFormatter, usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard,iac,your_provider} ...", epilog=""" -Available Cloud Providers: +Available Providers: {aws,azure,gcp,kubernetes,m365,github,iac,nhn,your_provider} aws AWS Provider azure Azure Provider @@ -1248,10 +1277,12 @@ Dependencies ensure that your provider's required libraries are available when P **File:** `pyproject.toml` ```toml -[tool.poetry.dependencies] -python = ">=3.10,<3.13" -# ... other dependencies -your-sdk-library = "^1.0.0" # Add your SDK dependency +[project] +requires-python = ">=3.10,<3.13" +dependencies = [ + # ... other dependencies + "your-sdk-library>=1.0.0,<2.0.0", # Add your SDK dependency +] ``` #### Step 18: Create Tests diff --git a/docs/developer-guide/prowler-studio.mdx b/docs/developer-guide/prowler-studio.mdx new file mode 100644 index 0000000000..a4a2a609b5 --- /dev/null +++ b/docs/developer-guide/prowler-studio.mdx @@ -0,0 +1,131 @@ +--- +title: 'Prowler Studio' +--- + +**Prowler Studio is an AI workflow that ensures Claude Code follows Prowler's skills, guardrails, and best practices when creating new security checks.** What lands in the resulting pull request is consistent, tested, and ready for human review — not half-correct boilerplate that needs to be rewritten. + + +**Contributor Tool**: Prowler Studio is a workflow for advanced contributors adding new Prowler security checks. It is not part of Prowler Cloud, Prowler App, or Prowler CLI. + + + +**Preview Feature**: Prowler Studio is under active development and breaking changes are expected. Please report issues or share feedback on [GitHub](https://github.com/prowler-cloud/prowler-studio/issues) or in the [Slack community](https://goto.prowler.com/slack). + + + + Clone the source code, install Prowler Studio, and explore the agent workflow in detail. + + +## The Problem + +Adding a new check to [Prowler](https://github.com/prowler-cloud/prowler) is more than writing detection logic. A correct check has to: + +- Match Prowler's exact service and check folder structure and naming conventions +- Wire up metadata, severity, remediation, tests, and compliance mappings +- Mirror the patterns used by the hundreds of existing checks in the same provider +- Actually load when Prowler scans for available checks — silent structural mistakes are easy to make + +Asking a general-purpose AI assistant to do this usually means guessing. It misses conventions, skips tests, or invents structure that looks right but does not load. The result is a half-correct PR that needs to be reviewed line by line or rewritten. + +## The Solution + +Prowler Studio enforces the workflow end-to-end. Describe the check once — a markdown ticket, a Jira issue, or a GitHub issue — and the workflow: + +1. **Loads Prowler-specific skills into every agent.** Every step starts with the same context an experienced Prowler engineer would have in mind. See [AI Skills System](/developer-guide/ai-skills) for how skills are structured. +2. **Runs specialized agents in sequence.** Implementation → testing → compliance mapping → review → PR creation. Each agent has one job and a tight scope. +3. **Verifies as it goes.** The check must load in Prowler. Tests must pass. If something fails, the agent fixes it and re-runs (up to a bounded number of attempts) before moving on. +4. **Produces a complete pull request.** Branch, passing check, tests, compliance mappings, and a pull request waiting for human review. + +The result is a consistent starting point, every time, on every supported provider. + +## Quick Start + +### Install + +Prowler Studio requires [`uv`](https://docs.astral.sh/uv/getting-started/installation/) — see the official [installation guide](https://docs.astral.sh/uv/getting-started/installation/). + +```bash +git clone https://github.com/prowler-cloud/prowler-studio +cd prowler-studio +uv sync +source .venv/bin/activate +``` + +### Describe the Check + +A ticket is a structured markdown description of the check to create. It is the only input the workflow needs; every agent (implementation, testing, compliance mapping, review, PR creation) uses it as the source of truth, so the more concrete it is, the closer the first PR will land to the desired outcome. + +The ticket can be supplied in three ways: + +- **Local markdown file** → `--ticket path/to/ticket.md` +- **Jira issue** → `--jira-url https://...` (uses the issue body) +- **GitHub issue** → `--github-url https://...` (uses the issue body) + +The content should follow the **New Check Request** template: + +- The local copy at [`check_ticket_template.md`](https://github.com/prowler-cloud/prowler-studio/blob/main/check_ticket_template.md) covers `--ticket` and Jira tickets. +- A prefilled GitHub form is also available: [Create a New Check Request issue](https://github.com/prowler-cloud/prowler/issues/new?template=new-check-request.yml). + +Sections marked *Optional* can be skipped; everything else helps the agents make the right decisions. + +### Run the Workflow + +From a local markdown ticket: + +```bash +prowler-studio --ticket check_ticket.md +``` + +From a Jira ticket: + +```bash +prowler-studio --jira-url https://mycompany.atlassian.net/browse/PROJ-123 +``` + +From a GitHub issue: + +```bash +prowler-studio --github-url https://github.com/owner/repo/issues/123 +``` + + +Provide exactly one of `--ticket`, `--jira-url`, or `--github-url`. + + +Keep changes local (no push, no pull request): + +```bash +prowler-studio -b feat/my-check --ticket check_ticket.md --local +``` + +### What You Get + +After a successful run the working environment contains: + +- A new branch on a clean Prowler worktree containing the check, metadata, tests, and compliance mappings +- A pull request opened against Prowler (skipped with `--local`) +- A timestamped log file under `logs/` capturing every step the agents took + +## CLI Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--branch` | `-b` | Branch name (default: `feat/-` or `feat/`) | +| `--ticket` | `-t` | Path to a markdown check ticket file | +| `--jira-url` | `-j` | Jira ticket URL (e.g., `https://mycompany.atlassian.net/browse/PROJ-123`) | +| `--github-url` | `-g` | GitHub issue URL (e.g., `https://github.com/owner/repo/issues/123`) | +| `--working-dir` | `-w` | Working directory for the Prowler clone (default: `./working`) | +| `--no-worktree` | | Legacy mode — work directly on the main clone instead of using worktrees | +| `--cleanup-worktree` | | Remove the worktree after a successful pull request is created | +| `--local` | | Keep changes local — skip push and pull request creation | + +## Configuration + +Set these environment variables depending on the input source: + +| Variable | When Needed | Purpose | +|----------|-------------|---------| +| `GITHUB_TOKEN` | `--github-url` (recommended) | Higher GitHub API rate limits and access to private issues | +| `JIRA_SITE_URL` | `--jira-url` | Jira site, e.g. `https://mycompany.atlassian.net` | +| `JIRA_EMAIL` | `--jira-url` | Email of the Jira account used to fetch the ticket | +| `JIRA_API_TOKEN` | `--jira-url` | API token for the Jira account | diff --git a/docs/developer-guide/secret-scanning-checks.mdx b/docs/developer-guide/secret-scanning-checks.mdx new file mode 100644 index 0000000000..5044366b7e --- /dev/null +++ b/docs/developer-guide/secret-scanning-checks.mdx @@ -0,0 +1,119 @@ +--- +title: 'Secret-Scanning Checks' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler scans audited resources for plaintext secrets using [Kingfisher](https://github.com/mongodb/kingfisher), an open-source secret-scanning engine that Prowler invokes as a subprocess. This guide explains the structure every secret-scanning check must follow to keep scanning correct and efficient on large accounts. + + +Since Prowler 5.32.0 the secret-scanning checks scan with Kingfisher. Earlier versions used the `detect-secrets` library. + + +## Overview + +Secret detection runs through a single helper in `prowler/lib/utils/utils.py`: + +- **`detect_secrets_scan_batch(payloads, excluded_secrets=..., validate=...)`** scans many payloads in chunked subprocess invocations and returns a `{key: [findings]}` dictionary. To scan a single payload, pass a one-entry mapping (for example, `{0: data}`). + +Every Kingfisher invocation carries a fixed process-startup cost (around 100 ms). Scanning once per resource would spawn thousands of subprocesses on large accounts (for example, thousands of CloudWatch log groups). `detect_secrets_scan_batch` amortizes that cost: it writes each payload to a temporary file as it consumes them, runs one subprocess per chunk (500 payloads by default), and maps the findings back to each payload by key. + +## The Batched Structure + +Every secret-scanning check follows three phases. + +### Phase 1: Collect + +Define a generator that yields `(key, payload)` for each scannable unit. The generator builds payload strings only — it does not call Kingfisher. Lazy yielding keeps memory and temporary-disk usage bounded to a single chunk, which matters when an account holds thousands of resources. + +### Phase 2: Batch + +Call `detect_secrets_scan_batch` once with the generator. The helper consumes it in chunks, runs Kingfisher per chunk, and returns the keys that produced findings mapped to their finding lists. + +### Phase 3: Report + +Iterate the resources, look up the findings by key, and build one report per resource. Emit a finding for **every** iterated resource — never drop one silently. When a resource's payload cannot be prepared for scanning (for example, user data that fails to base64-decode or decompress), report it as `MANUAL` with a status explaining the scan could not inspect it, rather than omitting it or claiming `PASS`. + +```python +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.example.example_client import example_client + + +class example_resource_no_secrets(Check): + def execute(self): + findings = [] + excluded = example_client.audit_config.get("secrets_ignore_patterns", []) + validate = example_client.audit_config.get("secrets_validate", False) + resources = list(example_client.resources) + + # Phase 1: collect — builds strings only, no scan. + def payloads(): + for index, resource in enumerate(resources): + if resource.scannable_data: + yield index, serialize(resource) + + # Phase 2: batch — one call, chunked subprocesses. + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=excluded, validate=validate + ) + + # Phase 3: report — look up findings by key. + for index, resource in enumerate(resources): + report = Check_Report_AWS(metadata=self.metadata(), resource=resource) + report.status = "PASS" + report.status_extended = f"No secrets found in {resource.name}." + detect_secrets_output = batch_results.get(index) + if detect_secrets_output: + report.status = "FAIL" + report.status_extended = ( + f"Potential secret found in {resource.name} -> ..." + ) + annotate_verified_secrets(report, detect_secrets_output) + findings.append(report) + + return findings +``` + +## Choosing the Key + +The key maps each finding back to its source. Two shapes cover every check: + +- **One payload per resource:** use the resource index. This fits checks that serialize a single payload per resource, such as launch configurations, CloudFormation outputs, SSM documents, Step Functions definitions, and OpenStack metadata. +- **Several payloads per resource:** use a `(resource_index, fragment)` tuple, where the fragment identifies the variable, log stream, container, file, or version. Phase 3 groups the per-fragment findings to build the resource report. This fits CloudWatch log streams, ECS containers, CodeBuild variables, Glue arguments, and Lambda code files. + +Derive the indices from the same `list(...)` of resources in both Phase 1 and Phase 3 so the order stays stable and the keys align. + +## Preserving Per-Payload Results + +`detect_secrets_scan_batch` runs Kingfisher with `--no-dedup`, so a secret that appears in more than one payload is reported for each one. This reproduces the result of scanning each payload individually. Build payload strings exactly as a single scan would: serialize the same data and keep line ordering, because messages often map a finding's `line_number` back to a variable name or metadata key. + +## Validation and Severity + +`detect_secrets_scan_batch` accepts `validate`, read from `secrets_validate` in the provider configuration or the `--scan-secrets-validate` flag. When enabled, Kingfisher confirms whether each secret is live, and confirmed secrets carry `is_verified: True`. + +After marking a report as `FAIL`, pass the findings to `annotate_verified_secrets(report, findings)`. When any secret is verified, the helper escalates the finding to critical severity and appends a note that the secret was confirmed live. Validation stays off by default because it sends the discovered secret to the provider API. + +## Excluded Secrets + +`detect_secrets_scan_batch` applies `secrets_ignore_patterns` — regular expressions from the provider configuration — against each finding's source line and drops the matches, mirroring single-scan behavior. + +## Testing + +To assert on the verified-secret path, mock `detect_secrets_scan_batch` in the check module and return the keyed dictionary. For a single resource scanned at index `0`: + +```python +mock.patch( + "prowler.providers.aws.services.example.example_resource_no_secrets.example_resource_no_secrets.detect_secrets_scan_batch", + return_value={ + 0: [{"type": "...", "line_number": 1, "is_verified": True}] + }, +) +``` + +Most tests need no mock at all: they seed resources that contain example secrets and assert on the `FAIL` status and message, which exercises the real batched path. Refer to the [Testing](/developer-guide/unit-testing) documentation for the general structure. diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index bb01af8271..75392ed3a7 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -2,47 +2,808 @@ title: 'Creating a New Security Compliance Framework in Prowler' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + +This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the two supported JSON schemas (universal and legacy), the Pydantic models that validate each framework, check mapping conventions, output formatting, local validation, testing, and the pull request process. + ## Introduction -To create or contribute a custom security framework for Prowler—or to integrate a public framework—you must ensure the necessary checks are available. If they are missing, they must be implemented before proceeding. +A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC, DORA) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud. -Each framework is defined in a compliance file per provider. The file should follow the structure used in `prowler/compliance//` and be named `__.json`. Follow the format below to create your own. +Prowler ships 85+ compliance frameworks across all providers. The catalog lives under `prowler/compliance//` (legacy, per-provider) or `prowler/compliance/` (universal, multi-provider). -## Compliance Framework + +A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when no Prowler check can automate it. In that case, leave the requirement's check list empty, but do not omit the requirement. -### Compliance Framework Structure +Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework. + -Each compliance framework file consists of structured metadata that identifies the framework and maps security checks to requirements or controls. Please note that a single requirement can be linked to multiple Prowler checks: +### Two supported schemas -- `Framework`: string – The distinguished name of the framework (e.g., CIS). -- `Provider`: string – The cloud provider where the framework applies (AWS, Azure, OCI). -- `Version`: string – The framework version (e.g., 1.4 for CIS). -- `Requirements`: array of objects. – Defines security requirements and their mapping to Prowler checks. All requirements or controls are to be included with the mapping to Prowler. -- `Requirements_Id`: string – A unique identifier for each requirement within the framework -- `Requirements_Description`: string – The requirement description as specified in the framework. -- `Requirements_Attributes`: array of objects. – Contains relevant metadata such as security levels, sections, and any additional data needed for reporting with the result of the findings. Attributes should be derived directly from the framework’s own terminology, ensuring consistency with its established definitions. -- `Requirements_Checks`: array. The Prowler checks that are needed to prove this requirement. It can be one or multiple checks. In case automation is not feasible, this can be empty. +| Schema | When to use | File location | Discovered as | +| --- | --- | --- | --- | +| **Universal (recommended for new frameworks)** | Multi-provider frameworks, or single-provider frameworks that benefit from declarative table/PDF rendering | `prowler/compliance/.json` (top-level) | Available for **every** provider whose key appears in any `requirement.checks` dict | +| **Legacy provider-specific** | Single-provider frameworks with framework-specific attribute classes already declared in the codebase (CIS, ENS, ISO 27001, etc.) | `prowler/compliance//__.json` | Available only under that provider | + +Auto-discovery happens in `get_bulk_compliance_frameworks_universal(provider)` (`prowler/lib/check/compliance_models.py`), which scans **both** the top-level `prowler/compliance/` directory and every per-provider sub-directory. Legacy frameworks are transparently converted to the universal `ComplianceFramework` model via `adapt_legacy_to_universal()` before being returned, so the rest of Prowler — CLI table rendering, CSV/OCSF outputs, PDF generation — works the same regardless of the source schema. + +> The legacy entry-point `Compliance.get_bulk(provider)` (used by older code paths) only scans per-provider sub-directories. Universal top-level files are picked up exclusively via the universal loader; this matters if you are wiring a new code path against the legacy API. + +For **new** frameworks, prefer the universal schema: it requires no Python code changes, supports multiple providers in a single file, and table/PDF rendering is driven entirely from declarative configuration inside the JSON. + +> All Pydantic models in `compliance_models.py` are imported from `pydantic.v1`. Subclasses you add for the legacy schema must use `from pydantic.v1 import BaseModel`. + +### Prerequisites + +Before adding a new framework, complete the following checks: + +- **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance//` for an existing JSON file matching the name and version. +- **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide. +- **Review a reference framework.** Use an existing framework with a similar structure as your template: + - Universal: `prowler/compliance/dora_2022_2554.json`, `prowler/compliance/csa_ccm_4.0.json`. + - Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`. + +## Universal Compliance Framework + +### Where the file lives + +Place the file at the top level of the compliance directory: ``` +prowler/compliance/.json +``` + +Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora_2022_2554.json`. + +The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`). + +### Top-level structure + +```json { - "Framework": "-", - "Version": "", + "framework": "", + "name": "", + "version": "", + "description": "", + "icon": "", + "attributes_metadata": [ /* see below */ ], + "outputs": { /* see below — optional */ }, + "requirements": [ /* see below */ ] +} +``` + +A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora_2022_2554.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. + +Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository. + +### `attributes_metadata` + +Declares the shape of the per-requirement `attributes` dict. When this field is present, the root validator `validate_attributes_against_metadata` (`compliance_models.py`) enforces the schema at load time and rejects: + +- Missing keys marked `required: true`. +- Keys present in `attributes` but not declared in `attributes_metadata` (typo / drift guard). +- Values that violate a declared `enum`. +- Values whose Python type does not match a declared `int`, `float` or `bool`. + +The runtime type check **only** covers `int`, `float` and `bool`. For `str`, `list_str` and `list_dict` the type is documentation-only — non-conforming values won't fail validation. If `attributes_metadata` is omitted, **no per-requirement validation runs at all**. + +```json +"attributes_metadata": [ + { + "key": "Pillar", + "label": "Pillar", + "type": "str", + "required": true, + "enum": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "output_formats": { "csv": true, "ocsf": true } + }, + { + "key": "Article", + "label": "Article", + "type": "str", + "required": true, + "output_formats": { "csv": true, "ocsf": true } + } +] +``` + +Per attribute: + +- `key` (required): attribute name as it will appear in `requirement.attributes`. +- `label`: human-readable label used in CSV headers and PDF. +- `type`: one of `str`, `int`, `float`, `bool`, `list_str`, `list_dict`. Defaults to `str`. +- `enum`: optional list of allowed values; non-conforming values are rejected at load time. +- `required`: if `true`, every requirement must include this key with a non-null value. +- `enum_display` / `enum_order`: optional per-enum-value visual metadata (label, abbreviation, color, icon) and explicit ordering for PDF rendering. +- `output_formats`: `{ "csv": , "ocsf": }` — toggles inclusion in each output format. Both default to `true`. + +### `outputs` + +Optional. Controls how the framework is rendered in the console table and in the generated PDF report. Skipping it falls back to sensible defaults. + +```json +"outputs": { + "table_config": { + "group_by": "Pillar" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003399", + "secondary_color": "#0055A5", + "bg_color": "#F0F4FA", + "group_by_field": "Pillar", + "sections": [ "ICT Risk Management", "ICT-Related Incident Reporting", "..." ], + "section_short_names": { "ICT Risk Management": "ICT Risk Mgmt" }, + "charts": [ + { + "id": "pillar_compliance", + "type": "horizontal_bar", + "group_by": "Pillar", + "title": "Compliance Score by Pillar", + "y_label": "Pillar", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { "only_failed": true, "include_manual": false } + } +} +``` + +`table_config.group_by` must reference an attribute key declared in `attributes_metadata`. The same applies to `pdf_config.group_by_field` and to every `charts[].group_by`. + +For frameworks with weighted scoring (e.g. ThreatScore) declare `pdf_config.scoring` with `risk_field` / `weight_field` / `risk_boost_factor`. For column splitting (e.g. CIS Level 1 vs Level 2) use `table_config.split_by`. + +### `requirements` + +```json +"requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. ...", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 5", + "ArticleTitle": "Governance and organisation" + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_mfa_enabled" + ], + "azure": [], + "gcp": [] + } + } +] +``` + +Per requirement: + +- `id` (required): unique identifier within the framework. +- `description` (required): the requirement text as authored by the framework. +- `name`: short title shown alongside the id. +- `attributes`: flat dict; keys must conform to `attributes_metadata`. +- `checks`: dict keyed by provider name (the same lowercase keys listed in the previous section). Each value is a list of Prowler check names that evidence this requirement for that provider. The list **may be empty** and the dict itself defaults to `{}` if omitted; either way the requirement is still loaded and listed by `--list-compliance-requirements`, it just has zero checks to execute. Note: there is **no automatic check-existence validation** at load time — referencing a non-existent check name will silently produce a requirement with no findings. Validate this yourself (see "Validating Your Framework" below). +- `config_requirements`: optional list of configuration guardrails. Each entry asserts that a configurable check referenced by this requirement ran with a configuration strict enough to actually satisfy the requirement; otherwise the requirement is forced to FAIL. See [Configuration Guardrails for Requirements](#configuration-guardrails-for-requirements) for the full schema and semantics. In the universal schema the field name is lowercase (`config_requirements`); legacy files use `ConfigRequirements`. + +For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model). + +### Multi-provider frameworks + +A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict. + +When extending an existing universal framework with a new provider, the only change required is editing `requirement.checks`: + +```diff + "checks": { + "aws": ["iam_avoid_root_usage", "iam_no_root_access_key"], ++ "azure": ["entra_policy_ensure_mfa_for_admin_roles"] + } +``` + +No code changes, no new file, no registration step. + +## Legacy Provider-Specific Compliance Framework + +The legacy schema is still fully supported and remains the format used by most frameworks shipped today (CIS, NIST, ISO 27001, FedRAMP, PCI DSS, GDPR, HIPAA, ENS, etc.). It binds a framework to a single provider and validates each requirement against a framework-specific Pydantic attribute class. + +The legacy schema spans **four layers** — a complete contribution must touch every layer that applies: + +- **Layer 1 — Schema validation:** the Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape. +- **Layer 2 — JSON catalog:** the framework JSON file in `prowler/compliance//` lists every requirement and maps it to checks. +- **Layer 3 — Output formatter:** the Python module in `prowler/lib/outputs/compliance//` builds the CSV row model, the per-provider transformer, and the CLI summary table. +- **Layer 4 — Output dispatchers:** the dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier. + +The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions. + +### Directory structure and file naming + +Compliance frameworks live at: + +``` +prowler/compliance//__.json +``` + +The filename conventions are: + +- All lowercase, words separated with underscores. +- `` is a supported provider identifier (same lowercase list as the universal section above). +- `` is optional but recommended. Omit only when the framework has no versioning (e.g. `ccc_aws.json`). +- The file basename (without `.json`) is the framework key that Prowler CLI accepts via `--compliance`. + +Examples: + +- `prowler/compliance/aws/cis_2.0_aws.json` +- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` +- `prowler/compliance/azure/ens_rd2022_azure.json` +- `prowler/compliance/kubernetes/cis_1.10_kubernetes.json` +- `prowler/compliance/aws/ccc_aws.json` + +The output formatter directory mirrors the framework name: + +``` +prowler/lib/outputs/compliance// +├── .py # CLI summary-table dispatcher +├── _.py # Per-provider transformer class +├── models.py # Pydantic CSV row model +└── __init__.py +``` + +### JSON schema reference + +Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py`). + +| Field | Type | Required | Description | +|---|---|---|---| +| `Framework` | string | Yes | Canonical framework identifier, for example `CIS`, `NIST-800-53-Revision-5`, `ENS`, `CCC`. | +| `Name` | string | Yes | Human-readable framework name displayed by Prowler App. | +| `Version` | string | Yes (recommended) | Framework version, e.g. `2.0`. See [Version Handling](#version-handling). | +| `Provider` | string | Yes | Upper-cased provider identifier: `AWS`, `AZURE`, `GCP`, `KUBERNETES`, `M365`, `GITHUB`, `GOOGLEWORKSPACE`, and so on. | +| `Description` | string | Yes | Short description of the framework's scope and purpose. | +| `Requirements` | array | Yes | List of [requirement objects](#requirement-object). | + +#### Requirement Object + +Each entry in `Requirements` describes one control or requirement. + +| Field | Type | Required | Description | +|---|---|---|---| +| `Id` | string | Yes | Unique identifier within the framework, for example `1.10` or `CCC.Core.CN01.AR01`. | +| `Name` | string | No | Optional human-readable name (frameworks like NIST distinguish control name from description). | +| `Description` | string | Yes | Verbatim description from the source framework. | +| `Attributes` | array | Yes | List of [attribute objects](#attribute-objects). The shape depends on the framework. | +| `Checks` | array of strings | Yes | Prowler check identifiers that automate the requirement. Leave the list empty when the control cannot be automated. | +| `ConfigRequirements` | array of objects | No | Optional [configuration guardrails](#configuration-guardrails-for-requirements). Each entry asserts that a configurable check ran with a configuration strict enough to satisfy the requirement; when it did not, the requirement is forced to FAIL. | + +#### Attribute Objects + +`Attributes` is parsed against the union declared in `Compliance_Requirement.Attributes` (`compliance_models.py`). Pydantic v1 tries each member of the union in declaration order and falls back to `Generic_Compliance_Requirement_Attribute` (the last entry) when nothing else matches — so a brand-new shape that doesn't match any existing class will silently be accepted as Generic, losing its specific fields. + +As of today, the registered attribute classes are: `CIS_Requirement_Attribute`, `ENS_Requirement_Attribute`, `ASDEssentialEight_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `AWS_Well_Architected_Requirement_Attribute`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `CCC_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`, and `Generic_Compliance_Requirement_Attribute` (fallback). MITRE-style frameworks use the separate `Mitre_Requirement` model with `Tactics` / `SubTechniques` / `Platforms` / `TechniqueURL` at the requirement top level. The most common shapes are summarized below. + +##### CIS_Requirement_Attribute + +Used by every CIS benchmark. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `Section` | string | Yes | Top-level section, e.g. `1 Identity and Access Management`. | +| `SubSection` | string | No | Optional second-level grouping. | +| `Profile` | enum | Yes | One of `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2`. | +| `AssessmentStatus` | enum | Yes | `Manual` or `Automated`. | +| `Description` | string | Yes | Control description. | +| `RationaleStatement` | string | Yes | Reason the control exists. | +| `ImpactStatement` | string | Yes | Impact of non-compliance. | +| `RemediationProcedure` | string | Yes | Remediation steps. | +| `AuditProcedure` | string | Yes | Audit steps. | +| `AdditionalInformation` | string | Yes | Free-form notes. | +| `DefaultValue` | string | No | Default configuration value, when relevant. | +| `References` | string | Yes | Colon-separated list of reference URLs. | + +##### ENS_Requirement_Attribute + +Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `IdGrupoControl` | string | Yes | Control group identifier. | +| `Marco` | string | Yes | Framework block (`operacional`, `organizativo`, `proteccion`). | +| `Categoria` | string | Yes | Control category. | +| `DescripcionControl` | string | Yes | Control description in Spanish. | +| `Tipo` | enum | Yes | `refuerzo`, `requisito`, `recomendacion`, `medida`. | +| `Nivel` | enum | Yes | `opcional`, `bajo`, `medio`, `alto`. | +| `Dimensiones` | array of enum | Yes | Subset of `confidencialidad`, `integridad`, `trazabilidad`, `autenticidad`, `disponibilidad`. | +| `ModoEjecucion` | string | Yes | Execution mode (`manual`, `automático`, `híbrido`). | +| `Dependencias` | array of strings | Yes | Ids of prerequisite controls. Empty list when none. | + +##### CCC_Requirement_Attribute + +Used by the Common Cloud Controls Catalog. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `FamilyName` | string | Yes | Control family, e.g. `Data`. | +| `FamilyDescription` | string | Yes | Description of the family. | +| `Section` | string | Yes | Section title. | +| `SubSection` | string | Yes | Subsection title, or empty string. | +| `SubSectionObjective` | string | Yes | Stated objective for the subsection. | +| `Applicability` | array of strings | Yes | Applicability tags such as `tlp-green`, `tlp-amber`, `tlp-red`. | +| `Recommendation` | string | Yes | Implementation recommendation. | +| `SectionThreatMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. | +| `SectionGuidelineMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. | + +##### Generic_Compliance_Requirement_Attribute + +The fallback attribute model used when no framework-specific schema applies (e.g. NIST 800-53, PCI DSS, GDPR, HIPAA). It is **always the last** element of the `Compliance_Requirement.Attributes` Union; that ordering is load-bearing. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `ItemId` | string | No | Item identifier. | +| `Section` | string | No | Section name. | +| `SubSection` | string | No | Subsection name. | +| `SubGroup` | string | No | Subgroup name. | +| `Service` | string | No | Affected service, e.g. `iam`. | +| `Type` | string | No | Control type. | +| `Comment` | string | No | Free-form comment. | + +For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`) consult `prowler/lib/check/compliance_models.py` for the full field sets. + + +The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union — otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`. + + +#### Minimal working example + +The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity. + +```json title="prowler/compliance/aws/my_framework_1.0_aws.json" +{ + "Framework": "My-Framework", + "Name": "My Framework 1.0 for AWS", + "Version": "1.0", + "Provider": "AWS", + "Description": "Internal baseline for AWS accounts.", "Requirements": [ { - "Id": "", - "Description": "Full description of the requirement", - "Checks": [ - "Here is the prowler check or checks that will be executed" - ], + "Id": "MF-1.1", + "Description": "Root account must have multi-factor authentication enabled.", "Attributes": [ { - + "ItemId": "MF-1.1", + "Section": "Identity and Access Management", + "SubSection": "Root Account", + "Service": "iam" } + ], + "Checks": [ + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled" ] }, - ... + { + "Id": "MF-2.1", + "Description": "S3 buckets must block public access at the account level.", + "Attributes": [ + { + "ItemId": "MF-2.1", + "Section": "Data Protection", + "Service": "s3" + } + ], + "Checks": [ + "s3_account_level_public_access_blocks" + ] + } ] } ``` -Finally, to have a proper output file for your reports, your framework data model has to be created in `prowler/lib/outputs/models.py` and also the CLI table output in `prowler/lib/outputs/compliance.py`. Also, you need to add a new conditional in `prowler/lib/outputs/file_descriptors.py` if creating a new CSV model. +### Mapping checks to requirements + +Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control. + +- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count. +- List every check by its canonical identifier — the value of `CheckID` inside the check's `.metadata.json` file. +- One requirement can reference multiple checks. The requirement is evaluated as FAIL when any referenced check produces a FAIL finding for a resource in scope. +- Leave `Checks` (legacy) or `checks.` (universal) as an empty array when the requirement cannot be automated. The requirement still appears in the report and contributes to the total. +- Reuse checks across requirements when the same control applies in multiple places. Do not duplicate check logic to match framework structure. +- Avoid referencing checks from a different provider. A legacy compliance file is bound to one provider, and cross-provider checks will never match findings in the scan. + +To discover available checks: + +```bash +uv run python prowler-cli.py --list-checks +``` + +### Supporting multiple providers (legacy) + +The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider: + +``` +prowler/compliance/aws/cis_2.0_aws.json +prowler/compliance/azure/cis_2.0_azure.json +prowler/compliance/gcp/cis_2.0_gcp.json +``` + +Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them; change only the `Provider`, `Checks`, and provider-specific metadata. The CIS output formatter already supports every provider listed above. + +For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter). + +### Output formatter + +Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema. + +For a new legacy framework named `my_framework`, create: + +``` +prowler/lib/outputs/compliance/my_framework/ +├── __init__.py +├── my_framework.py # CLI summary table dispatcher +├── my_framework_aws.py # Per-provider transformer +└── models.py # CSV row Pydantic model +``` + +#### Step 1 — Define the CSV row model + +In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`. + +#### Step 2 — Implement the transformer + +In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`. + +#### Step 3 — Add the summary-table dispatcher + +In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`. + +#### Step 4 — Register the framework in the dispatchers + +- Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`. +- Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan. + + +For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema. + + +### Legacy-to-universal adapter + +At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically. + +Loader-error behaviour differs between the two entry points: + +- `load_compliance_framework()` (legacy) is **fail-fast**: it calls `sys.exit(1)` on any `ValidationError` (`compliance_models.py`). +- `load_compliance_framework_universal()` is more lenient — it logs the error and returns `None`, so `get_bulk_compliance_frameworks_universal()` simply skips the broken file and keeps loading the rest. + +## Configuration Guardrails for Requirements + + + +Some requirements are only truly satisfied when the configurable checks behind them ran with a configuration strict enough to meet the control. A [configurable check](/developer-guide/configurable-checks) reads thresholds from the scan's `audit_config`, so loosening a value can make the check PASS while the requirement it backs is, in fact, not satisfied. + +A worked example: CIS AWS 6.0 requirement 2.11 ("credentials unused for 45 days or more are disabled") maps to `iam_user_accesskey_unused`, which is driven by the `max_unused_access_keys_days` config key. If a user raises that value to `120`, the check passes for a key unused for 90 days — yet the requirement explicitly demands a 45-day threshold, so the PASS is misleading. + +Configuration guardrails close that gap. A requirement declares the configuration it expects, and when the scan ran with a configuration too loose to honor it, the requirement is forced to **FAIL** in every compliance output, with the reason surfaced in the finding's extended status. + + +Guardrails are an **optional** safety net for configurable checks. A requirement that maps only to non-configurable checks does not need them. When the field is absent, behavior is unchanged. + + +### Where guardrails are declared + +The field is attached to each requirement and exists in both schemas: + +- **Legacy** (`prowler/compliance//...`): `ConfigRequirements`, a list of objects, validated against the `Compliance_Requirement_ConfigConstraint` Pydantic model (`prowler/lib/check/compliance_models.py`). +- **Universal** (`prowler/compliance/...`): `config_requirements`, the same list of objects as plain dicts on `UniversalComplianceRequirement`. + +When a legacy file is adapted to the universal model, `adapt_legacy_to_universal()` copies `ConfigRequirements` into `config_requirements` (`compliance_models.py`), so downstream code only ever reads one shape. + +### Constraint schema + +Each entry in the list is a single constraint with the following fields: + +| Field | Type | Required | Description | +|---|---|---|---| +| `Check` | string | Yes | The configurable check this constraint guards. Should be one of the requirement's `Checks`. Used only to build a human-readable reason. | +| `ConfigKey` | string | Yes | The `audit_config` key the check reads (for example `max_unused_access_keys_days`). | +| `Operator` | enum | Yes | How to compare the applied value against `Value`. One of `lte`, `gte`, `eq`, `in`, `subset`, `superset`. | +| `Value` | bool, int, float, string, or list | Yes | The strictest configuration the requirement tolerates. The accepted Python type depends on the operator (see below). | +| `Provider` | string | No | The provider this constraint applies to (e.g. `aws`). **Required for universal (multi-provider) frameworks**, where the same requirement maps checks across providers — the constraint is only evaluated when the scanned provider matches. Single-provider (legacy) frameworks omit it. | + +### Operators + +| Operator | Applied value satisfies the guardrail when… | Typical use | +|---|---|---| +| `lte` | `applied <= Value` | Maximum-age / maximum-count thresholds (e.g. `max_unused_access_keys_days <= 45`). | +| `gte` | `applied >= Value` | Minimum-retention / minimum-count thresholds. | +| `eq` | `applied == Value` | Boolean toggles or an exact required value (e.g. `mute_non_default_regions == false`). | +| `in` | `applied` is one of `Value` (a list) | The applied scalar must belong to an allowed set. | +| `subset` | `set(applied) <= set(Value)` | **Allowlist** configs — every applied value must already be permitted. Widening the allowlist with a weaker value (e.g. adding TLS `1.0` to `recommended_minimal_tls_versions`) breaks the guardrail. | +| `superset` | `set(applied) >= set(Value)` | **Denylist** configs — every forbidden value must remain forbidden. Removing an entry from a denylist (e.g. dropping a weak algorithm from `insecure_key_algorithms`) breaks the guardrail. | + + +`subset` / `superset` require both the applied value and `Value` to be lists; any other type is treated as not satisfied. For `eq` against a boolean, declare `Value` as a JSON boolean (`false`, not `0`) — the model keeps booleans distinct from integers. + + +### How guardrails are evaluated + +All evaluation lives in one shared module, `prowler/lib/check/compliance_config_eval.py`, consumed by every compliance output (CSV, OCSF, and the CLI tables) and reused by the Prowler App backend so the rule is defined exactly once. + +1. The applied configuration is the scan-global `audit_config` (the same mapping for every resource and region), resolved via `get_scan_audit_config()`. +2. For each requirement that declares constraints, `evaluate_config_constraints()` walks the list and returns `(is_compliant, reason)`. The requirement is compliant when **every** explicitly-set key satisfies its constraint. +3. A constraint tagged with a `Provider` that does **not** match the provider being scanned (resolved via `get_scan_provider_type()`) is **skipped**. This scopes a universal framework's constraints to the right provider, so a guardrail authored for an AWS check never affects a GCP or Azure scan of the same requirement. Untagged constraints (legacy single-provider frameworks) always apply. +4. A constraint whose `ConfigKey` is **not present** in `audit_config` is **skipped** — the check's built-in default is assumed to already match what the requirement expects. This is why nothing changes for the default configuration. +5. When a constraint is violated, the finding's status is overridden to `FAIL` and a plain-language explanation is prepended to `status_extended` (via `apply_config_status()`). The message opens with `Configuration not valid for this requirement.` and names the check, the value the scan applied, what the requirement needs and how to fix it. For the table generators, `get_effective_status()` applies the same FAIL roll-up so per-section counts stay consistent. + + +Guardrails only ever make a result **stricter** (they can turn PASS into FAIL); they never relax a real FAIL into PASS. A requirement with no constraints, or whose keys all use defaults, is reported exactly as before. + + +### Example: legacy framework + +From `prowler/compliance/aws/cis_6.0_aws.json`, requirement 2.11 declares two guardrails — one per configurable check it maps to: + +```json title="prowler/compliance/aws/cis_6.0_aws.json" +{ + "Id": "2.11", + "Description": "Ensure credentials unused for 45 days or more are disabled.", + "Checks": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], + "Attributes": [ /* ... */ ] +} +``` + +A boolean guardrail from the same file: requirement 2.5 (IAM Access Analyzer) only holds when regions are not muted, so a scan with `mute_non_default_regions: true` cannot be trusted for it: + +```json +"ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } +] +``` + +### Example: universal framework + +The universal schema uses the lowercase `config_requirements` key with the identical object shape: + +```json +{ + "id": "MF-2.1", + "name": "Restrict TLS to modern versions", + "description": "Endpoints must negotiate only TLS 1.2 or higher.", + "checks": { + "aws": ["elbv2_listener_ssl_listeners"] + }, + "config_requirements": [ + { + "Check": "elbv2_listener_ssl_listeners", + "Provider": "aws", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": ["TLS 1.2", "TLS 1.3"] + } + ] +} +``` + +Each constraint declares the `Provider` it targets so the guardrail is only evaluated on scans of that provider — essential for universal frameworks like CSA CCM and DORA, where one requirement maps checks across `aws`, `azure`, `gcp` and more. Because the operator is `subset`, adding `"TLS 1.0"` to `recommended_minimal_tls_versions` widens the allowlist beyond `["TLS 1.2", "TLS 1.3"]` and the requirement is forced to FAIL. + +### What the user sees + +With a loosened config, the affected requirement's findings report: + +```text +Status: FAIL +StatusExtended: Configuration not valid for this requirement. The check + iam_user_accesskey_unused has max_unused_access_keys_days set + to 120, but the requirement needs a value of 45 or lower. + Update it to 45 or lower. +``` + +The same `Configuration not valid for this requirement.` message appears identically across the CSV, OCSF, and console-table outputs. + +### Authoring guidelines + +- Declare a guardrail only for keys whose value actually changes whether the requirement is met. Most configurable checks do not need one. +- Set `Value` to the **strictest** configuration the control tolerates — the same number the control text cites (CIS 45 days, NIST ≤90, and so on). +- Keep `ConfigKey` spelled exactly as the check reads it from `audit_config`; an unknown key is never present in the config and the constraint is silently skipped. +- In a **universal (multi-provider) framework**, always set `Provider` to the provider that owns `Check` — otherwise the guardrail would leak onto scans of the other providers the requirement maps. Legacy single-provider files omit it. +- Pick the operator from the value's role: a max threshold is `lte`, a min threshold is `gte`, a toggle is `eq`, an allowlist is `subset`, a denylist is `superset`. +- An unrecognized operator does **not** block the requirement — a malformed constraint is treated as satisfied rather than failing the whole framework. Validate your JSON with the tests below. + +### Testing guardrails + +The shared evaluator and the per-output integration are covered by: + +- `tests/lib/check/compliance_config_eval_test.py` — operator semantics, skipped-key behavior, and the FAIL override. +- `tests/lib/check/compliance_config_constraint_model_test.py` — model validation (types, operator enum, bool-vs-int). +- `tests/lib/check/compliance_config_requirements_data_test.py` — sanity-checks the guardrails shipped in the JSON catalog. +- Per-output tests under `tests/lib/outputs/compliance/` (CIS AWS/Azure, ENS AWS, OCSF, universal table) confirm the override reaches each format. + +Run them with: + +```bash +uv run pytest -n auto \ + tests/lib/check/compliance_config_eval_test.py \ + tests/lib/check/compliance_config_constraint_model_test.py \ + tests/lib/check/compliance_config_requirements_data_test.py \ + tests/lib/outputs/compliance/ +``` + +## Version handling + +Prowler matches frameworks by concatenating `Framework` and `Version`. A missing or empty `Version` collapses several frameworks to the same key and breaks CLI filtering with `--compliance`. + +- Always set `Version` (or `version` for universal frameworks) to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`, `2022/2554`). +- When the source catalog has no version, use the first year of adoption or the release date. +- For **legacy** files, make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version. + +## Validating Your Framework + +Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists. + +### 1. Schema validation + +For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. + +```python +from prowler.lib.check.compliance_models import ( + load_compliance_framework_universal, + get_bulk_compliance_frameworks_universal, +) + +fw = load_compliance_framework_universal("prowler/compliance/.json") +assert fw is not None, "load returned None — check the logs for the validation error" +print(fw.framework, len(fw.requirements), fw.get_providers()) + +bulk = get_bulk_compliance_frameworks_universal("aws") +assert "" in bulk +``` + +### 2. Check existence cross-check + +There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory: + +```python +import os +real = set() +for svc in os.listdir("prowler/providers/aws/services"): + svc_path = f"prowler/providers/aws/services/{svc}" + if not os.path.isdir(svc_path): + continue + for entry in os.listdir(svc_path): + if os.path.isfile(f"{svc_path}/{entry}/{entry}.metadata.json"): + real.add(entry) + +referenced = {c for r in fw.requirements for c in r.checks.get("aws", [])} +missing = referenced - real +assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}" +``` + +### 3. CLI smoke test + +```bash +uv run python prowler-cli.py --list-compliance +``` + +The framework must appear in the output. A validation error indicates a schema mismatch. + +```bash +uv run python prowler-cli.py \ + --compliance \ + --log-level ERROR +``` + +Verify that: + +- Prowler produces a CSV file under `output/compliance/` with the expected name. +- The CLI summary table lists every section / pillar of the framework. +- Findings roll up under the expected requirements. + +### 4. Inspect the CSV output + +Open the generated CSV and confirm: + +- All columns defined in `models.py` (legacy) or in `attributes_metadata` (universal) appear. +- Every requirement has at least one row per scanned resource (when there are findings). +- Attribute values such as `Requirements_Attributes_Section` reflect the JSON content. + +### 5. Verify the framework in Prowler App + +Launch Prowler App locally (`docker compose up` from the repository root) and run a scan with the new compliance framework. Confirm the compliance page renders the requirements, sections, and status widgets correctly. + +## Testing + +Compliance contributions require two layers of tests. + +- **Schema tests** exercise the Pydantic models. Extend `tests/lib/check/universal_compliance_models_test.py` with a case that loads the new JSON file and asserts the attribute type matches the expected model. +- **Output tests** (legacy frameworks only) exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance//` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows. + +Run the suite with: + +```bash +uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \ + tests/lib/outputs/compliance/ +``` + +For guidance on writing Prowler SDK tests, refer to [Unit Testing](/developer-guide/unit-testing). + +## Running and listing your framework + +Once the file is in place, the CLI auto-discovers it: + +```sh +prowler --list-compliance # framework appears in the list +prowler --compliance --list-checks +prowler --compliance # full scan + compliance report +prowler --compliance --list-compliance-requirements +``` + +For end-user-facing tutorials (recommended for high-profile frameworks), add a dedicated page under `docs/user-guide/compliance/tutorials/` and register it in the `"Compliance"` group of `docs/docs.json`. See `docs/user-guide/compliance/tutorials/threatscore.mdx` as a reference. + +## Submitting the pull request + +Before opening the pull request: + +1. Run the complete QA pipeline: + ```bash + uv run pre-commit run --all-files + uv run pytest -n auto + ``` +2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers. +3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, e.g. `feat(compliance): add My Framework 1.0 for AWS`. +4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`. + +## Troubleshooting + +The following issues are the most common when contributing a compliance framework. + +- **`ValidationError: field required` during scan (legacy).** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`. +- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values (legacy).** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Keep the generic model in the last Union position and ensure every required field is present in the JSON. +- **`attributes_metadata validation failed` (universal).** The root validator in `compliance_models.py` rejected the file. The error message lists each offending requirement; common causes are unknown attribute keys (typo or missing entry in `attributes_metadata`), enum violations, or missing required keys. +- **`--compliance` filter does not find the framework.** For legacy: the filename does not match `__.json`, the version is empty, or the file lives outside `prowler/compliance//`. For universal: the file is not at the top level of `prowler/compliance/` or it loaded as `None` (check logs for the validation error). +- **CLI summary table is empty but the CSV is populated (legacy).** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key. +- **CSV file is missing after the scan (legacy).** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`. +- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep ` to confirm, or run the check-existence cross-check from "Validating Your Framework". + +## Reference examples + +Use the following files as templates when modeling a new contribution. + +- `prowler/compliance/dora_2022_2554.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. +- `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud). +- `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape. +- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape. +- `prowler/compliance/aws/ccc_aws.json` — legacy CCC attribute shape. +- `prowler/compliance/azure/ens_rd2022_azure.json` — legacy ENS attribute shape. +- `prowler/lib/check/compliance_models.py` — canonical Pydantic schemas for both formats. +- `prowler/lib/outputs/compliance/cis/` — reference implementation of a multi-provider legacy output formatter. +- `prowler/lib/outputs/compliance/generic/` — reference implementation of a legacy generic output formatter. diff --git a/docs/developer-guide/server-sent-events.mdx b/docs/developer-guide/server-sent-events.mdx new file mode 100644 index 0000000000..c91f27227f --- /dev/null +++ b/docs/developer-guide/server-sent-events.mdx @@ -0,0 +1,241 @@ +--- +title: 'Server-Sent Events (SSE)' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Prowler API. SSE lets the backend push a one-way stream of events to a client over a single long-lived HTTP connection — ideal for live progress, token-by-token LLM output, or any "the server has news for you" use case where the client should not poll. + + +The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base. + + +## When to use SSE + +| Need | Use | +|------|-----| +| Server pushes incremental updates, client only reads | **SSE** | +| Bidirectional, low-latency messaging (chat both ways, games) | WebSocket | +| Client asks, server answers once | Plain REST | + +SSE is the right tool when the **client only consumes**: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API, and needs no extra protocol. + +## How it works + +SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`: + +| Piece | File | Responsibility | +|-------|------|----------------| +| `BaseSSEViewSet` | `api/sse/base_views.py` | Base DRF viewset a feature subclasses. The feature implements `get_channels`; the base handles auth, the tenant transaction, and delegates streaming to `django-eventstream`. | +| `SSEChannelManager` | `api/sse/channelmanager.py` | Registered in `settings.EVENTSTREAM_CHANNELMANAGER_CLASS`. Reads the channel set off the request and enforces the platform-wide tenant gate. | +| `SSEAuthentication` | `api/authentication.py` | Same JWT/API-key stack as the rest of the API, plus an `?access_token=` fallback for browser `EventSource` clients. Lives with the other authentication classes, not in the `sse` package. | +| `make_channel_name` / `tenant_id_from_channel` | `api/sse/utils.py` | Single source of truth for the channel-name format, so publishers and the channel manager agree byte-for-byte. | +| Settings | `config/settings/eventstream.py` | Valkey Pub/Sub backend (dedicated DB), channel manager, allowed headers. | + +### Transport: the server runs on ASGI + +SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn's native **`asgi` worker** (`config.asgi:application`). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in `config/guniconf.py` and used by both the dev and production entrypoints — no separate server process is needed. + +### The data flow + +``` +publisher (Celery task / view) subscriber (browser, CLI) + │ │ + │ send_event(channel, "scan.progress", …) │ GET …/event-stream + ▼ ▼ + Valkey Pub/Sub ◄────────────────────► BaseSSEViewSet.list + (EVENTSTREAM_VALKEY_DB) → get_channels() (RLS-scoped) + → SSEChannelManager (tenant gate) + → StreamingHttpResponse (text/event-stream) +``` + +A publisher anywhere in the system (most often a Celery task) calls `send_event(channel, event_type, payload)`. `django-eventstream` fans it out over Valkey Pub/Sub to every connection subscribed to that channel. + +## Adding an SSE endpoint to your feature + +The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature. + + + + + +Channels follow the format `::`, built only through `make_channel_name`. The prefix is owned by your feature and may contain hyphens but **never colons** (the parser splits on `:`). + +```python +CHANNEL_PREFIX = "scan-progress" +``` + +The tenant id is baked into every channel name. That is what lets the platform enforce cross-tenant isolation without knowing anything about your feature. + + + + + +Create the viewset for the SSE sub-resource. The only required method is `get_channels`; it runs inside the tenant transaction set up by the base class, so any database lookup inside it is automatically RLS-scoped. + +```python +# scans/event_streams.py +from api.sse import BaseSSEViewSet, make_channel_name +from django.shortcuts import get_object_or_404 +from scans.models import Scan + +CHANNEL_PREFIX = "scan-progress" + + +class ScanEventStreamViewSet(BaseSSEViewSet): + def get_queryset(self): + # RLS already scopes to the tenant; narrow further as needed + # (e.g. only scans the requesting user may see). + return Scan.objects.filter(tenant_id=self.request.tenant_id) + + def get_channels(self) -> set[str]: + scan = get_object_or_404(self.get_queryset(), pk=self.kwargs["scan_pk"]) + return {make_channel_name(CHANNEL_PREFIX, scan.tenant_id, scan.id)} +``` + + +`get_channels` **must raise** the relevant DRF exception (`NotFound`, `PermissionDenied`, `NotAuthenticated`) when authorization fails — `get_object_or_404` does this for you. Returning an empty set surfaces as django-eventstream's confusing "No channels specified" error instead of the real cause. + + + + + + +Mount the endpoint as an `event-stream` sub-resource. Keep it **outside the DRF router**, which would force the URL into a list/detail convention. Route the `get` method to the viewset's `list` action. + +```python +# scans/urls.py +path( + "scans//event-stream", + ScanEventStreamViewSet.as_view({"get": "list"}), + name="scan-event-stream", +), +``` + + + + + +A feature owns its event types in `//events.py`: one `publish_` function per event type, each body a **single** `send_event` call so the wire-level string lives in exactly one place. + +```python +# scans/events.py +from django_eventstream import send_event + + +def publish_progress(channel: str, checked: int, total: int) -> None: + send_event(channel, "scan.progress", {"checked": checked, "total": total}) + + +def publish_end(channel: str, scan_id: str) -> None: + # Terminal event carries the canonical id so reconnecting clients + # can refetch the persisted resource over REST. + send_event(channel, "scan.end", {"scan_id": scan_id}) + + +def publish_error(channel: str, code: str, detail: str) -> None: + send_event(channel, "scan.error", {"code": code, "detail": detail}) +``` + +There is no platform-side enum, registry, or dispatch table — **the naming convention is the contract** (see below). + + + + + +Wherever the work happens — usually a Celery task — build the channel the same way and publish: + +```python +from api.sse import make_channel_name +from scans.events import publish_progress, publish_end + +channel = make_channel_name("scan-progress", scan.tenant_id, scan.id) +publish_progress(channel, checked=42, total=100) +... +publish_end(channel, scan_id=str(scan.id)) +``` + + + + + +## Event naming convention + +Every event uses an event type of the form **`.`** (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable. + +| Verb | When to use | +|------|-------------| +| `delta` | An incremental piece of a stream the client concatenates (LLM text tokens, audio chunks). Standard term across OpenAI / Anthropic / LiteLLM / Vercel AI SDK. | +| `start` | Begin marker for a compound operation (e.g. a tool call whose execution will be reported by a matching `end`). | +| `end` | Terminal marker. Carries the canonical resource id so reconnecting clients can refetch persisted state via REST. | +| `progress` | Periodic checkpoint with quantifiable completion, e.g. `{"checked": 42, "total": 100}`. | +| `created` / `updated` / `deleted` | Resource-lifecycle events for cross-client sync streams. | +| `error` | Terminal failure. Carries a stable `code` for client switching and a human-readable `detail`. | + + +Payloads are **flat JSON**. The wire-level `event:` field already names the event type, so do **not** wrap the payload in `{"type": ..., "data": ...}`. Include the canonical resource UUID on terminal events so reconnecting clients can reconcile via REST. + + +## Authentication + +SSE endpoints use the same authentication stack as the rest of the API. Non-browser clients (CLI, programmatic) send the standard `Authorization` header — JWT or API key. + +Browser `EventSource` is the only widely available SSE client API and it **cannot set custom headers**. For that case only, the endpoint accepts a JWT via the `?access_token=` query parameter. The header always wins when present — a header is intentional, while a query parameter can leak into referers and logs, so it is consulted only as a fallback. + +```javascript +// Browser +const es = new EventSource( + `/api/v1/scans/${scanId}/event-stream?access_token=${jwt}` +); +``` + +```bash +# CLI / programmatic — header, exactly like every other endpoint +curl -N -H "Authorization: Bearer $JWT" \ + https:///api/v1/scans/$SCAN_ID/event-stream +``` + +## Tenant isolation & security model + +Authorization is enforced at two layers: + +1. **At connect**, `get_channels` runs under the regular DRF stack inside the tenant transaction (`rls_transaction`). Resource lookups are RLS-scoped, so a user cannot even resolve a channel for a resource they cannot see. Narrow the queryset further (e.g. `created_by=request.user`) when a resource is per-user within a tenant. +2. **After connect**, `SSEChannelManager.can_read_channel` re-verifies tenant membership by parsing the tenant id embedded in the channel name. Cross-tenant subscription is rejected even if a URL-level check ever has a bug. A malformed channel name is treated as "not authorized". + +Because the tenant id lives inside the channel name, this gate works for any feature without the platform knowing anything about it. + +## Reconnect & state recovery + +The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead: + +- Terminal events (`*.end`) carry the canonical resource **UUID**. +- On reconnect, the client refetches the authoritative state from the normal REST endpoint using that id. + +Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource. + +## Local development + +- The dev and production entrypoints both launch Gunicorn with the `asgi` worker (`config.asgi:application`). In dev, `DJANGO_DEBUG=True` enables hot reload; `preload_app` is automatically disabled under debug so edited code is picked up. +- SSE uses a **dedicated Valkey database** (`EVENTSTREAM_VALKEY_DB`, default `2`) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the same `VALKEY_*` connection settings as the rest of the platform. + +| Env var | Default | Purpose | +|---------|---------|---------| +| `EVENTSTREAM_VALKEY_DB` | `2` | Valkey DB index for the SSE Pub/Sub bus | +| `DJANGO_WORKER_CLASS` | `asgi` | Gunicorn worker class | + +Test the stream end to end with `curl -N` (disable buffering) and an auth header: + +```bash +curl -N -H "Authorization: Bearer $JWT" \ + http://localhost:8080/api/v1/scans/$SCAN_ID/event-stream +``` + +## Testing + +The platform basis is covered by `api/tests/test_sse.py` (channel parsing, the tenant gate, and auth precedence). For a feature endpoint, test: + +- `get_channels` returns the expected channel for an authorized resource and raises `NotFound`/`PermissionDenied` otherwise. +- Each `publish_` helper emits the correct event type and flat payload (mock `send_event`). +- The producer builds the channel with `make_channel_name` using the resource's own `tenant_id`. diff --git a/docs/developer-guide/services.mdx b/docs/developer-guide/services.mdx index ac7088dda0..62700efb73 100644 --- a/docs/developer-guide/services.mdx +++ b/docs/developer-guide/services.mdx @@ -23,7 +23,7 @@ Within this folder the following files are also to be created: - `_service.py` – Contains all the logic and API calls of the service. - `_client_.py` – Contains the initialization of the freshly created service's class so that the checks can use it. -Once the files are create, you can check that the service has been created by running the following command: `poetry run python prowler-cli.py --list-services | grep `. +Once the files are create, you can check that the service has been created by running the following command: `uv run python prowler-cli.py --list-services | grep `. ## Service Structure and Initialisation diff --git a/docs/developer-guide/stackit-details.mdx b/docs/developer-guide/stackit-details.mdx new file mode 100644 index 0000000000..9d4ea5d458 --- /dev/null +++ b/docs/developer-guide/stackit-details.mdx @@ -0,0 +1,730 @@ +--- +title: 'StackIT Provider' +--- + +This page details the [StackIT Cloud](https://www.stackit.de/) provider implementation in Prowler. + +By default, Prowler audits a single StackIT project per scan. To configure it, provide the project ID and either a service account key file path or inline service account key JSON. + +## StackIT Provider Classes Architecture + +The StackIT provider implementation follows the general [Provider structure](/developer-guide/provider). This section focuses on the StackIT-specific implementation, highlighting how the generic provider concepts are realized for StackIT in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](/developer-guide/provider). + +### `StackitProvider` (Main Class) + +- **Location:** [`prowler/providers/stackit/stackit_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/stackit_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for StackIT-specific logic, API authentication, credential validation, and configuration. +- **Key StackIT Responsibilities:** + - Initializes StackIT SDK authentication via a service account key file or inline service account key JSON. The SDK mints and refreshes access tokens internally. + - Validates the service account credentials and project ID (UUID format validation). + - Loads and manages configuration, mutelist, and fixer settings. + - Provides properties and methods for downstream StackIT service classes to access credentials, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/stackit/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/models.py) +- **Purpose:** Define structured data for StackIT identity and output configuration. +- **Key StackIT Models:** + - `StackITIdentityInfo`: Holds StackIT identity metadata, including project ID and project name (fetched automatically from Resource Manager API). + - `StackITOutputOptions`: Customizes default output filenames so StackIT reports include the audited project ID. + - IaaS resource models such as `SecurityGroup` and `SecurityGroupRule` are defined in the IaaS service module. + +### StackIT Services + +- **Location:** [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services) +- **Purpose:** Implement StackIT service clients and resource collection logic following the generic [service pattern](/developer-guide/services#service-base-class). +- **Current Implementation:** The `IaaSService` collects security groups, rules, and network interface usage across supported StackIT regions. + +### Exception Handling + +- **Location:** [`prowler/providers/stackit/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for StackIT-specific error handling, such as credential validation, API connection, and configuration errors. +- **Key Exception Classes:** + - `StackITBaseException`: Base exception for all StackIT provider errors. + - `StackITCredentialsError`: Raised when credentials are invalid or missing. + - `StackITInvalidProjectIdError`: Raised when project ID is invalid or not in UUID format. + - `StackITAPIError`: Raised when StackIT API calls fail. + +## Authentication + +### Service Account Creation and Key Generation + +StackIT uses service account keys for API authentication. Service account keys are RSA key-pair based and provide secure, short-lived access tokens. + +### Creating a Service Account Key + +#### Method 1: Via StackIT Portal + +1. **Navigate to Service Accounts** + - Go to the [StackIT Portal](https://portal.stackit.cloud/) + - Select your project + - Click on **Service Accounts** in the left sidebar + +2. **Create or Select Service Account** + - If you don't have a service account, click **Create Service Account** + - Provide a name and description + - Assign necessary permissions: + - For IaaS security checks: `iaas.viewer` or `project.owner` + - For comprehensive audits: `project.owner` + +3. **Generate Service Account Key** + - Select your service account + - Navigate to **Service Account Keys** + - Click **Create key** + - Choose one of the following options: + - **STACKIT-generated key pair** (Recommended): Let STACKIT automatically generate an RSA key-pair + - **User-provided key pair**: Upload your own RSA 2048 public key + +4. **Download and Save the Key** + - Download the generated service account key file (JSON format) + - **Important**: Save the key securely - it contains your private key and will only be available once + - Store the key file in a secure location (e.g., `~/.stackit/sa_key.json`) + +#### Method 2: Via StackIT CLI + +```bash +# Install STACKIT CLI (if not already installed) +# Follow instructions at: https://github.com/stackitcloud/stackit-cli + +# Create service account key (STACKIT-generated) +stackit service-account key create --email my-service-account@example.com + +# Or create with your own RSA 2048 public key +# First, generate your RSA key pair: +openssl genrsa -out private-key.pem 2048 +openssl rsa -in private-key.pem -pubout -out public-key.pem + +# Then create the key with your public key: +stackit service-account key create \ + --email my-service-account@example.com \ + --public-key "$(cat public-key.pem)" +``` + +### Finding Your Project ID + +Your StackIT project ID is a UUID that can be found: + +1. In the StackIT Portal URL when viewing your project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...` +2. In the project settings page +3. Using the StackIT CLI: `stackit project list` + +### Passing the Service Account Key to Prowler + +Prowler accepts the service account credentials in two equivalent forms; both go through the same StackIT SDK flow and refresh access tokens internally. + +#### Option 1: Key File Path (key persisted on disk) + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + +Or as CLI flags: + +```bash +prowler stackit \ + --stackit-service-account-key-path ~/.stackit/sa-key.json \ + --stackit-project-id 12345678-1234-1234-1234-123456789abc +``` + +#### Option 2: Inline Key Content (CI/CD, secret managers) + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + +Prefer the environment variable over the matching `--stackit-service-account-key` CLI flag; passing the secret on the command line leaks it through process listings and shell history. + +### Credential Lookup Order + +Prowler resolves credentials in this order: + +1. **Command-line arguments**: + - `--stackit-service-account-key` + - `--stackit-service-account-key-path` + - `--stackit-project-id` +2. **Environment variables**: + - `STACKIT_SERVICE_ACCOUNT_KEY` + - `STACKIT_SERVICE_ACCOUNT_KEY_PATH` + - `STACKIT_PROJECT_ID` + +When both the inline key and the key file path are set, the inline content takes precedence. + +## Configuration + +### Command-Line Arguments + +StackIT-specific command-line arguments: + +| Argument | Description | Required | Default | +|----------|-------------|----------|---------| +| `--stackit-service-account-key-path` | Path to a StackIT service account key JSON file | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY_PATH` | +| `--stackit-service-account-key` | Inline JSON content of a StackIT service account key (preferred env var: `STACKIT_SERVICE_ACCOUNT_KEY`) | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY` | +| `--stackit-project-id` | StackIT project ID (UUID format) | Yes* | `$STACKIT_PROJECT_ID` | +| `--stackit-region` | StackIT region(s) to scan | No | All available regions | + +\* Required unless provided via environment variables. + +### Input Validation + +The StackIT provider performs comprehensive input validation: + +- **Service Account Credentials**: + - At least one of `service_account_key_path` (file path) or `service_account_key` (inline JSON) must be supplied; both empty raises `StackITNonExistentTokenError` + - When both are provided the inline content takes precedence + - The key file path is logged as-is; the inline content is redacted in the credentials box + +- **Project ID**: + - Must not be empty + - Must be a valid UUID format (e.g., `12345678-1234-1234-1234-123456789abc`) + - Validated using Python's UUID constructor + +Invalid credentials will result in clear error messages before any API calls are made. + +## Available Services + +### IaaS (Infrastructure as a Service) + +- **Service Class:** `IaaSService` +- **Location:** [`prowler/providers/stackit/services/iaas/iaas_service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/services/iaas/iaas_service.py) +- **SDK:** Uses the [stackit-iaas](https://pypi.org/project/stackit-iaas/) Python SDK +- **Purpose:** Manages IaaS resources including security groups, servers, and network interfaces. + +**Supported Resources:** +- Security Groups and Rules +- Servers (Virtual Machines) +- Network Interfaces (NICs) + +**Key Features:** +- Automatic discovery of all security groups in the project +- Security rule parsing with support for unrestricted access detection +- Network interface analysis to determine whether security groups are in use +- By default, reports only security groups attached to at least one NIC; `--scan-unused-services` includes unused security groups too + +## Available Checks + +The StackIT provider currently implements 4 security checks focused on network security: + +### 1. iaas_security_group_ssh_unrestricted + +- **Severity:** High +- **Description:** Detects security groups that allow unrestricted SSH access (port 22) from the internet. +- **Risk:** Unrestricted SSH access increases the attack surface and risk of brute-force attacks. +- **Detection Logic:** + - Checks for ingress rules allowing TCP port 22 + - Flags rules with `ip_range=None` or `ip_range="0.0.0.0/0"` or `ip_range="::/0"` + - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled + +### 2. iaas_security_group_rdp_unrestricted + +- **Severity:** High +- **Description:** Detects security groups that allow unrestricted RDP access (port 3389) from the internet. +- **Risk:** Unrestricted RDP access enables potential unauthorized remote desktop access. +- **Detection Logic:** + - Checks for ingress rules allowing TCP port 3389 + - Flags unrestricted IP ranges (None, 0.0.0.0/0, ::/0) + - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled + +### 3. iaas_security_group_database_unrestricted + +- **Severity:** High +- **Description:** Detects security groups that allow unrestricted access to common database ports. +- **Monitored Ports:** + - MySQL: 3306 + - PostgreSQL: 5432 + - MongoDB: 27017 + - Redis: 6379 + - SQL Server: 1433 + - CouchDB: 5984 +- **Risk:** Unrestricted database access can lead to data breaches and unauthorized data access. + +### 4. iaas_security_group_all_traffic_unrestricted + +- **Severity:** Critical +- **Description:** Detects security groups that allow all traffic from the internet. +- **Detection Logic:** + - Checks for rules with `port_range=None` (all ports) + - Checks for rules with port range covering 0-65535 or 1-65535 + - Flags unrestricted IP ranges + - Critical security misconfiguration requiring immediate remediation + +### Important Implementation Notes + +**Self-Referencing Security Group Rules:** + +Security group rules with `remoteSecurityGroupId` set are automatically filtered out from unrestricted access checks. These rules only allow traffic from instances within the same security group (self-referencing), not from the internet, and are therefore not flagged as security risks. + +**Rule Display Names:** + +All findings include user-friendly rule descriptions when available. If a security group rule has a description field set (the name shown in the StackIT UI), it will be displayed in the finding message along with the rule ID: +- With description: `'Allow SSH from office' (sgr-abc123)` +- Without description: `'sgr-abc123'` + +**Network Interface (NIC) Usage Filtering:** + +The IaaS service lists project NICs and records the security group IDs attached to them. Checks use that signal to decide whether a security group is in use: + +1. **Default behavior:** Report security groups attached to at least one NIC. +2. **`--scan-unused-services`:** Report every security group, including unused ones. +3. **FAIL logic:** Internet exposure is driven by security group rules that allow unrestricted source ranges, not by the presence of a public IP on the NIC. + +**Unrestricted IP Ranges:** + +The StackIT API represents "unrestricted" in two ways: +- **`ip_range=null`**: No IP restriction specified (implicit unrestricted) +- **`ip_range="0.0.0.0/0"` or `"::/0"`**: Explicitly configured to allow all IPs + +Both are flagged as unrestricted. A `null` value is **more permissive** than an explicit range and applies to all protocols/ports if other fields are also `null`. + +## Requirements + +### Python Version + +- **Minimum:** Python 3.10+ +- **Reason:** The StackIT SDK requires Python 3.10 or higher + +### Dependencies + +The StackIT provider requires the following Python packages (automatically installed with Prowler): + +- **stackit-core** (v0.2.0): Core SDK for StackIT API authentication and configuration +- **stackit-iaas** (v1.4.0): IaaS service SDK for managing compute resources +- **stackit-resourcemanager** (v0.8.0): Resource Manager SDK for fetching project metadata (e.g., project names) + +These dependencies are defined in `pyproject.toml` and installed automatically with: + +```bash +poetry install +``` + +**Note:** The `stackit-resourcemanager` package enables automatic retrieval of project names for display in reports. If this package is not available, Prowler will still function normally but project names will be empty in the output. + +## Region Support + +### Supported Regions + +- **Available Regions:** `eu01` (Germany South) and `eu02` (Austria West) +- **Default:** All scans use both `eu01` and `eu02` regions by default. + +### Multi-Region Scanning + +Prowler supports scanning multiple StackIT regions in a single execution. By default, it will scan all regions defined in the `stackit_regions_by_service.json` configuration file. + +### CLI Argument + +You can specify which regions to scan using the `--stackit-region` argument: + +```bash +# Scan only eu01 +prowler stackit --stackit-region eu01 + +# Scan both eu01 and eu02 +prowler stackit --stackit-region eu01 eu02 +``` + +### Implementation Details + +- **Regional Clients:** Prowler generates a separate API client for each audited region. +- **Service Iteration:** Each service (e.g., IaaS) iterates through the regional clients to fetch and audit resources. +- **Identity Tracking:** The `audited_regions` are stored in the identity model for reporting. + +### Future Enhancements + +As StackIT adds more regions, they can be easily added to Prowler by updating the `prowler/providers/stackit/stackit_regions_by_service.json` file without requiring code changes. + +## Command Examples + +### Scan Specific Regions + +Scan only the `eu01` region: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --stackit-region eu01 +``` + +Scan multiple regions: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --stackit-region eu01 eu02 +``` + +### Scan Specific Checks + +Run only SSH unrestricted check: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --checks iaas_security_group_ssh_unrestricted +``` + +### Scan All Security Group Checks + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --services iaas +``` + +### Output Formats + +Generate JSON output: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --output-formats json +``` + +Generate HTML report: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --output-formats html +``` + +## Known Limitations + +### Current Limitations + +1. **Single Project Scope**: Only one project can be scanned at a time +2. **Service Coverage**: Only the IaaS service is currently implemented +3. **Check Coverage**: Limited to security group network security checks (4 checks total) +4. **No Compliance Frameworks**: Compliance framework mappings are not yet implemented + +### Planned Enhancements + +- Multi-project scanning capability +- Additional IaaS checks (volume encryption, server public IP exposure, backup status) +- Compliance framework mappings (CIS, custom StackIT best practices) +- StackIT CLI remediation examples in metadata + +## Troubleshooting + +### Authentication Errors + +**Error:** `StackIT service account key was rejected` + +**Solutions:** +1. Re-issue the service account key in the StackIT Portal +2. Verify the service account key file or inline JSON content is complete +3. Check that the service account has the necessary permissions (`iaas.viewer` or `project.owner`) +4. Ensure the service account key is provided through `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_SERVICE_ACCOUNT_KEY`, or the matching CLI arguments + +**Error:** `StackIT credentials not found or are invalid` + +**Solutions:** +1. Ensure the project ID and one service account credential source are provided +2. Check that credentials are set via environment variables or command-line arguments +3. Verify there are no extra spaces or newlines in the credentials + +**Error:** `Invalid StackIT project ID format` + +**Solutions:** +1. Verify the project ID is a valid UUID format: `12345678-1234-1234-1234-123456789abc` +2. Copy the project ID directly from the StackIT Portal +3. Ensure there are no extra spaces or quotes around the UUID + +### API Connection Errors + +**Error:** `Failed to connect to StackIT API` + +**Solutions:** +1. Check your internet connection +2. Verify the StackIT API endpoint is accessible from your network +3. Check if there are any firewall rules blocking HTTPS connections +4. Review the full error message for specific API error codes + +**Error:** `HTTP 403 Forbidden` + +**Solutions:** +1. Verify the service account has the correct permissions +2. Ensure the project ID is correct and you have access to it +3. Check that the service account is enabled (not disabled or expired) +4. Verify the service account key has not been revoked + +**Error:** `HTTP 404 Not Found` + +**Solutions:** +1. Verify the project ID exists and is correct +2. Check that the IaaS service is enabled in your project +3. Ensure you're using the correct region (eu01) + +### Empty Results + +**Issue:** No security groups or findings reported + +**Solutions:** +1. Verify that security groups exist in your project +2. Check that the IaaS service is properly configured +3. Ensure the service account has `iaas.viewer` permission +4. Check Prowler logs for any API errors (use `--log-level DEBUG`) + +### Debug Mode + +Enable debug logging for detailed troubleshooting: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --log-level DEBUG +``` + +This will show: +- API authentication details (with inline service account keys redacted) +- Resource discovery progress +- Security rule parsing details +- Any API errors or warnings + +## Specific Patterns in StackIT Services + +The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other StackIT services as reference. + +### StackIT Service Common Patterns + +- Services communicate with StackIT using the StackIT Python SDK, you can find the documentation [here](https://github.com/stackitcloud/stackit-sdk-python). +- Service constructors receive a `StackitProvider` instance and use it to access credentials, identity, and configuration. +- The provider builds StackIT SDK `Configuration` objects from the service account key path or inline key content. +- Resource containers **must** be initialized in the constructor, typically as lists or dictionaries. +- Do not manipulate `os.environ` for credentials inside services. Use the provider session and SDK configuration helpers. +- All StackIT resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- StackIT SDK calls are wrapped in try/except blocks, with specific handling for API errors, always logging errors. +- **Centralized Error Handling**: Use `provider.handle_api_error(exception)` for consistent authentication error detection across all services. +- **SDK Warning Suppression**: StackIT SDK prints deprecation warnings to stderr - use the `suppress_stderr()` context manager during SDK initialization and API calls. +- **Unrestricted Access Detection**: In StackIT API, `None` values mean "allow all" (more permissive than explicit 0.0.0.0/0). + - `protocol=None` → All protocols allowed + - `ip_range=None` → All source IPs allowed (unrestricted!) + - `port_range=None` → All ports allowed + - `remote_security_group_id` set → Only allows traffic from the same security group (not unrestricted!) + +### IaaS Service Specific Patterns + +**Security Group Discovery:** +```python +# List all security groups +security_groups = client.list_security_groups( + project_id=self.project_id, + region=region, +) + +# List network interfaces to determine security group usage +nics = client.list_project_nics( + project_id=self.project_id, + region=region, +) + +# Checks report in-use security groups by default. Use --scan-unused-services +# to include security groups that are not attached to any NIC. +``` + +**Centralized Authentication Error Handling:** +```python +def _handle_api_call(self, api_function, *args, **kwargs): + """Wrapper for API calls with centralized error handling.""" + try: + with suppress_stderr(): # Suppress SDK warnings + return api_function(*args, **kwargs) + except Exception as e: + # Use centralized error handler from provider + self.provider.handle_api_error(e) # Detects 401 and raises StackITInvalidTokenError +``` + +**Unrestricted Access Detection:** +```python +def is_unrestricted(rule): + """Check if a rule allows unrestricted access.""" + # Filter out self-referencing rules + if rule.remote_security_group_id is not None: + return False + # Check for unrestricted IP ranges + return rule.ip_range is None or rule.ip_range in ["0.0.0.0/0", "::/0"] + +def is_tcp(rule): + """Check if a rule applies to TCP protocol.""" + # None means all protocols (including TCP) + return rule.protocol is None or rule.protocol.lower() in ["tcp", "all"] + +def includes_port(rule, port): + """Check if a rule includes a specific port.""" + # None means all ports + if rule.port_range is None: + return True + return rule.port_range.min <= port <= rule.port_range.max +``` + +## Specific Patterns in StackIT Checks + +The StackIT checks pattern is described in [checks page](/developer-guide/checks). You can find all the currently implemented checks: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar StackIT checks as reference. + +### Check Report Class + +The `CheckReportStackIT` class models a single finding for a StackIT resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`CheckReportStackIT` extends the base report structure with StackIT-specific fields, enabling detailed tracking of the resource, project, and location associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `CheckReportStackIT`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its StackIT-specific attributes from the resource, using the following logic: + +- **`resource_id`**: + - Uses `resource.id` if present. + - Otherwise, uses `resource.resource_id` if present. + - Defaults to an empty string if none are available. + +- **`resource_name`**: + - Uses `resource.name` if present. + - Defaults to an empty string if not available. + +- **`project_id`**: + - Uses `resource.project_id` if present. + - Defaults to an empty string if not available (should be set in check logic). + +- **`location`**: + - Uses `resource.region` if present. + - Otherwise, uses `resource.location` if present. + - Defaults to an empty string if not available. + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +from prowler.lib.check.models import CheckReportStackIT + +report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group +) +report.status = "FAIL" +report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet." +report.resource_id = security_group.id +report.resource_name = security_group.name +report.project_id = security_group.project_id +report.location = security_group.region +``` + +### Common Check Pattern + +```python +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + +class iaas_security_group_ssh_unrestricted(Check): + """Check if IaaS security groups allow unrestricted SSH access.""" + + def execute(self): + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group + ) + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted SSH access." + + # Check each rule + for rule in security_group.rules: + if (rule.is_ingress() and + rule.is_tcp() and + rule.includes_port(22) and + rule.is_unrestricted()): + report.status = "FAIL" + report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet." + break + + findings.append(report) + + return findings +``` + +## Resources + +### Official StackIT Documentation + +- **StackIT Portal**: [https://portal.stackit.cloud/](https://portal.stackit.cloud/) +- **StackIT Documentation**: [https://docs.stackit.cloud/](https://docs.stackit.cloud/) +- **StackIT API Documentation**: [https://docs.api.eu01.stackit.cloud/](https://docs.api.eu01.stackit.cloud/) + +### Python SDK + +- **StackIT Python SDK (GitHub)**: [https://github.com/stackitcloud/stackit-sdk-python](https://github.com/stackitcloud/stackit-sdk-python) +- **stackit-core (PyPI)**: [https://pypi.org/project/stackit-core/](https://pypi.org/project/stackit-core/) +- **stackit-iaas (PyPI)**: [https://pypi.org/project/stackit-iaas/](https://pypi.org/project/stackit-iaas/) +- **IaaS Models**: [https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models](https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models) + +### Prowler Resources + +- **Provider Implementation**: [`prowler/providers/stackit/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/) +- **IaaS Service**: [`prowler/providers/stackit/services/iaas/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/) +- **Prowler Hub**: [https://hub.prowler.com/](https://hub.prowler.com/) +- **GitHub Issues**: [https://github.com/prowler-cloud/prowler/issues](https://github.com/prowler-cloud/prowler/issues) + +## Contributing + +If you'd like to contribute to the StackIT provider: + +1. **Add New Checks**: Follow the [check creation guide](/developer-guide/checks#creating-a-check) and use existing StackIT checks as templates +2. **Enhance Services**: Implement additional IaaS resource discovery or add new services +3. **Improve Documentation**: Add metadata enhancements, CLI remediation examples, or Terraform code samples +4. **Report Issues**: Submit bug reports or feature requests on [GitHub](https://github.com/prowler-cloud/prowler/issues) + +### Quick Start for Contributors + +1. **Install dependencies**: `poetry install` (includes stackit-core and stackit-iaas) +2. **Set credentials**: Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` and `STACKIT_PROJECT_ID` +3. **Run checks**: `prowler stackit` +4. **View code**: Start in `prowler/providers/stackit/` +5. **Add checks**: Create new check directories under `services/iaas/` +6. **Run tests**: `poetry run pytest tests/providers/stackit/ -v` + +### Code Quality Standards + +The StackIT provider should follow the same quality expectations as the rest of the Prowler SDK: + +- Keep service and check logic covered by unit tests. +- Redact inline service account keys from generated output. +- Keep documentation aligned with the implemented services and checks. +- Follow existing provider, service, and check patterns before adding StackIT-specific abstractions. diff --git a/docs/docs.json b/docs/docs.json index 3b89e17a2a..cf98bbf0db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -12,6 +12,24 @@ "dark": "/images/prowler-logo-white.png", "light": "/images/prowler-logo-black.png" }, + "contextual": { + "options": [ + "copy", + "view", + { + "title": "Request a feature", + "description": "Open a feature request on GitHub", + "icon": "plus", + "href": "https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml" + }, + { + "title": "Report an issue", + "description": "Open a bug report on GitHub", + "icon": "bug", + "href": "https://github.com/prowler-cloud/prowler/issues/new?template=bug_report.yml" + } + ] + }, "navigation": { "tabs": [ { @@ -55,6 +73,12 @@ "getting-started/products/prowler-lighthouse-ai" ] }, + { + "group": "Prowler for Claude Code", + "pages": [ + "getting-started/products/prowler-claude-code-plugin" + ] + }, { "group": "Prowler MCP Server", "pages": [ @@ -98,8 +122,12 @@ ] }, "user-guide/tutorials/prowler-app-rbac", + "user-guide/tutorials/prowler-app-multi-tenant", "user-guide/tutorials/prowler-app-api-keys", - "user-guide/tutorials/prowler-app-import-findings", + "user-guide/tutorials/prowler-import-findings", + "user-guide/tutorials/prowler-scan-scheduling", + "user-guide/tutorials/prowler-alerts", + "user-guide/tutorials/prowler-app-scan-configuration", { "group": "Mutelist", "expanded": true, @@ -132,23 +160,31 @@ ] }, "user-guide/tutorials/prowler-app-attack-paths", + "user-guide/tutorials/prowler-app-finding-groups", "user-guide/tutorials/prowler-cloud-public-ips", { "group": "Tutorials", "pages": [ "user-guide/tutorials/prowler-app-sso-entra", + "user-guide/tutorials/prowler-app-sso-google-workspace", "user-guide/tutorials/bulk-provider-provisioning", "user-guide/tutorials/aws-organizations-bulk-provisioning" ] } ] }, + { + "group": "CI/CD", + "pages": [ + "user-guide/tutorials/prowler-app-github-action", + "user-guide/cookbooks/cicd-pipeline" + ] + }, { "group": "CLI", "pages": [ "user-guide/cli/tutorials/misc", "user-guide/cli/tutorials/reporting", - "user-guide/cli/tutorials/compliance", "user-guide/cli/tutorials/dashboard", "user-guide/cli/tutorials/configuration_file", "user-guide/cli/tutorials/logging", @@ -298,18 +334,47 @@ "user-guide/providers/openstack/authentication" ] }, + { + "group": "Scaleway", + "pages": [ + "user-guide/providers/scaleway/getting-started-scaleway", + "user-guide/providers/scaleway/authentication" + ] + }, + { + "group": "StackIT", + "pages": [ + "user-guide/providers/stackit/getting-started-stackit", + "user-guide/providers/stackit/authentication" + ] + }, { "group": "Vercel", "pages": [ "user-guide/providers/vercel/getting-started-vercel", "user-guide/providers/vercel/authentication" ] + }, + { + "group": "Okta", + "pages": [ + "user-guide/providers/okta/getting-started-okta", + "user-guide/providers/okta/authentication" + ] + }, + { + "group": "Linode", + "pages": [ + "user-guide/providers/linode/getting-started-linode", + "user-guide/providers/linode/authentication" + ] } ] }, { "group": "Compliance", "pages": [ + "user-guide/compliance/tutorials/compliance", "user-guide/compliance/tutorials/threatscore" ] }, @@ -317,7 +382,8 @@ "group": "Cookbooks", "pages": [ "user-guide/cookbooks/kubernetes-in-cluster", - "user-guide/cookbooks/cicd-pipeline" + "user-guide/cookbooks/cicd-pipeline", + "user-guide/cookbooks/powerbi-cis-benchmarks" ] } ] @@ -332,12 +398,15 @@ "developer-guide/provider", "developer-guide/services", "developer-guide/checks", + "developer-guide/secret-scanning-checks", "developer-guide/outputs", "developer-guide/integrations", "developer-guide/security-compliance-framework", "developer-guide/lighthouse-architecture", "developer-guide/mcp-server", - "developer-guide/ai-skills" + "developer-guide/ai-skills", + "developer-guide/prowler-studio", + "developer-guide/server-sent-events" ] }, { @@ -350,13 +419,15 @@ "developer-guide/kubernetes-details", "developer-guide/m365-details", "developer-guide/github-details", - "developer-guide/llm-details" + "developer-guide/llm-details", + "developer-guide/stackit-details" ] }, { "group": "Miscellaneous", "pages": [ "developer-guide/documentation", + "developer-guide/environment-variables", { "group": "Testing", "pages": [ @@ -474,6 +545,10 @@ } }, "redirects": [ + { + "source": "/user-guide/cli/tutorials/compliance", + "destination": "/user-guide/compliance/tutorials/compliance" + }, { "source": "/projects/prowler-open-source/en/latest/tutorials/prowler-app-lighthouse", "destination": "/user-guide/tutorials/prowler-app-lighthouse" @@ -521,6 +596,14 @@ { "source": "/contact", "destination": "/support" + }, + { + "source": "/user-guide/tutorials/prowler-app-import-findings", + "destination": "/user-guide/tutorials/prowler-import-findings" + }, + { + "source": "/user-guide/tutorials/prowler-app-alerts", + "destination": "/user-guide/tutorials/prowler-alerts" } ] } diff --git a/docs/getting-started/basic-usage/prowler-app.mdx b/docs/getting-started/basic-usage/prowler-app.mdx index 26d7d8ee2e..bc39353dcc 100644 --- a/docs/getting-started/basic-usage/prowler-app.mdx +++ b/docs/getting-started/basic-usage/prowler-app.mdx @@ -32,11 +32,11 @@ Access Prowler App by logging in with **email and password**. Log In -## Add Cloud Provider +## Add Provider -Configure a cloud provider for scanning: +Configure a provider for scanning: -1. Navigate to `Settings > Cloud Providers` and click `Add Account`. +1. Navigate to `Settings > Providers` and click `Add Provider`. 2. Select the cloud provider. 3. Enter the provider's identifier (Optional: Add an alias): - **AWS**: Account ID diff --git a/docs/getting-started/basic-usage/prowler-cli.mdx b/docs/getting-started/basic-usage/prowler-cli.mdx index 95e7a77b51..ebb9fc22a6 100644 --- a/docs/getting-started/basic-usage/prowler-cli.mdx +++ b/docs/getting-started/basic-usage/prowler-cli.mdx @@ -25,7 +25,12 @@ If you prefer the former verbose output, use: `--verbose`. This allows seeing mo ## Report Generation -By default, Prowler generates CSV, JSON-OCSF, and HTML reports. To generate a JSON-ASFF report (used by AWS Security Hub), specify `-M` or `--output-modes`: +By default, Prowler generates CSV, JSON-OCSF, and HTML reports. Additional provider-specific formats are available: + +* **JSON-ASFF** (AWS only): Used by AWS Security Hub +* **SARIF** (IaC only): Used by GitHub Code Scanning + +To specify output formats, use the `-M` or `--output-modes` flag: ```console prowler -M csv json-asff json-ocsf html diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index d80d8e511d..ec11680dd5 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool |----------|------------|------------------------| | Prowler Hub | 10 tools | No | | Prowler Documentation | 2 tools | No | -| Prowler Cloud/App | 29 tools | Yes | +| Prowler Cloud/App | 32 tools | Yes | ## Tool Naming Convention @@ -36,6 +36,14 @@ Tools for searching, viewing, and analyzing security findings across all cloud p - **`prowler_app_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships - **`prowler_app_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report +### Finding Groups Management + +Tools for listing finding groups aggregated by check ID, viewing complete group counters, and drilling down into affected resources. + +- **`prowler_app_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting +- **`prowler_app_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers +- **`prowler_app_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources + ### Provider Management Tools for managing cloud provider connections in Prowler. diff --git a/docs/getting-started/basic-usage/prowler-mcp.mdx b/docs/getting-started/basic-usage/prowler-mcp.mdx index a9357dcdeb..2c32dbdbfc 100644 --- a/docs/getting-started/basic-usage/prowler-mcp.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp.mdx @@ -44,13 +44,21 @@ Choose the configuration based on your deployment: **Configuration:** + + Avoid configuring MCP clients to run `npx mcp-remote` directly. `npx` can download and execute a new package version on each run. Install a reviewed version of `mcp-remote` in a dedicated local workspace, then point the MCP client to the installed binary. + + ```bash + mkdir -p ~/.local/share/prowler-mcp-bridge + cd ~/.local/share/prowler-mcp-bridge + npm init -y + npm install --save-exact mcp-remote@0.1.38 + ``` ```json { "mcpServers": { "prowler": { - "command": "npx", + "command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote", "args": [ - "mcp-remote", "https://mcp.prowler.com/mcp", // or your self-hosted Prowler MCP Server URL "--header", "Authorization: Bearer ${PROWLER_APP_API_KEY}" @@ -72,14 +80,20 @@ Choose the configuration based on your deployment: 2. Go to "Developer" tab 3. Click in "Edit Config" button 4. Edit the `claude_desktop_config.json` file with your favorite editor - 5. Add the following configuration: + 5. Install a reviewed version of `mcp-remote` in a dedicated local workspace: + ```bash + mkdir -p ~/.local/share/prowler-mcp-bridge + cd ~/.local/share/prowler-mcp-bridge + npm init -y + npm install --save-exact mcp-remote@0.1.38 + ``` + 6. Add the following configuration: ```json { "mcpServers": { "prowler": { - "command": "npx", + "command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote", "args": [ - "mcp-remote", "https://mcp.prowler.com/mcp", "--header", "Authorization: Bearer ${PROWLER_APP_API_KEY}" diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 681034885c..598b2ac44a 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -20,7 +20,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai _Commands_: - ```bash + + ```bash macOS/Linux VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name) curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. @@ -28,6 +29,15 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai docker compose up -d ``` + ```powershell Windows PowerShell + $VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" + # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" + docker compose up -d + ``` + + For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API. @@ -37,8 +47,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai _Requirements_: - `git` installed. - - `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation). - - `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). + - `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/). + - `pnpm` installed through [Corepack](https://pnpm.io/installation#using-corepack) or the standalone [pnpm installation](https://pnpm.io/installation). - `Docker Compose` installed: https://docs.docker.com/compose/install/. @@ -49,8 +59,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/api \ - poetry install \ - eval $(poetry env activate) \ + uv sync \ + source .venv/bin/activate \ set -a \ source .env \ docker compose up postgres valkey -d \ @@ -59,11 +69,6 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai gunicorn -c config/guniconf.py config.wsgi:application ``` - - Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`. - - If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment. In case you have any doubts, consult the Poetry environment activation guide: https://python-poetry.org/docs/managing-environments/#activating-the-environment - > Now, you can access the API documentation at http://localhost:8080/api/v1/docs. _Commands to run the API Worker_: @@ -71,8 +76,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/api \ - poetry install \ - eval $(poetry env activate) \ + uv sync \ + source .venv/bin/activate \ set -a \ source .env \ cd src/backend \ @@ -84,8 +89,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/api \ - poetry install \ - eval $(poetry env activate) \ + uv sync \ + source .venv/bin/activate \ set -a \ source .env \ cd src/backend \ @@ -97,9 +102,11 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/ui \ - npm install \ - npm run build \ - npm start + corepack enable \ + corepack install \ + pnpm install --frozen-lockfile \ + pnpm run build \ + pnpm start ``` > Enjoy Prowler App at http://localhost:3000 by signing up with your email and password. @@ -121,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.22.0" -PROWLER_API_VERSION="5.22.0" +PROWLER_UI_VERSION="5.31.0" +PROWLER_API_VERSION="5.31.0" ``` diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx index 2f82d53166..eae3d54ab1 100644 --- a/docs/getting-started/installation/prowler-cli.mdx +++ b/docs/getting-started/installation/prowler-cli.mdx @@ -4,7 +4,7 @@ title: 'Installation' ## Installation -To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): +To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): @@ -12,7 +12,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * `pipx` installed: [pipx installation](https://pipx.pypa.io/stable/installation/). * AWS, GCP, Azure and/or Kubernetes credentials @@ -30,7 +30,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * `Python pip >= 21.0.0` * AWS, GCP, Azure, M365 and/or Kubernetes credentials @@ -40,12 +40,6 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i pip install prowler prowler -v ``` - - To upgrade Prowler to the latest version: - - ``` bash - pip install --upgrade prowler - ``` _Requirements_: @@ -68,7 +62,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements for Developers_: * `git` - * `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation). + * `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/). * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: @@ -76,8 +70,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i ```bash git clone https://github.com/prowler-cloud/prowler cd prowler - poetry install - poetry run python prowler-cli.py -v + uv sync + uv run python prowler-cli.py -v ``` @@ -87,7 +81,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: @@ -102,8 +96,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.12` is installed. - * `Python >= 3.10, <= 3.12` + * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.13` is installed. + * `Python >= 3.10, <= 3.13` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: @@ -170,6 +164,68 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i +## Updating Prowler CLI + +Upgrade Prowler CLI to the latest release using the same method chosen for installation: + + + + ```bash + pipx upgrade prowler + prowler -v + ``` + + + ```bash + pip install --upgrade prowler + prowler -v + ``` + + + Pull the desired image tag to fetch the latest version: + + ```bash + docker pull toniblyx/prowler:latest + ``` + + + Replace `latest` with a specific release tag (for example, `stable` or ``) to pin a version. Refer to the [Container Versions](#container-versions) section for the full list of available tags. + + + + Pull the latest changes and sync the environment: + + ```bash + cd prowler + git pull + uv sync + uv run python prowler-cli.py -v + ``` + + + To upgrade to a specific release, check out the corresponding tag before syncing: `git checkout `. + + + + ```bash + brew upgrade prowler + prowler -v + ``` + + + Both AWS CloudShell and Azure CloudShell install Prowler with `pipx`, so the upgrade command is the same: + + ```bash + pipx upgrade prowler + prowler -v + ``` + + + + + To install a specific version instead of the latest release, pin it explicitly. For example, with `pipx`: `pipx install prowler==`, or with `pip`: `pip install prowler==`. The available releases are listed in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases). + + ## Container Versions The available versions of Prowler CLI are the following: diff --git a/docs/getting-started/installation/prowler-mcp.mdx b/docs/getting-started/installation/prowler-mcp.mdx index 7bf766e143..06f204da6a 100644 --- a/docs/getting-started/installation/prowler-mcp.mdx +++ b/docs/getting-started/installation/prowler-mcp.mdx @@ -141,6 +141,45 @@ Choose one of the following installation methods: --- +## Updating Prowler MCP Server + +When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action. + + + + Pull the latest image and restart the container: + + ```bash + docker pull prowlercloud/prowler-mcp + ``` + + + Recreate any running container after pulling the new image so the updated version takes effect. + + + + Pull the latest changes and sync the dependencies: + + ```bash + cd prowler/mcp_server + git pull + uv sync + uv run prowler-mcp --help + ``` + + + Pull the latest source and rebuild the image: + + ```bash + cd prowler/mcp_server + git pull + docker build -t prowler-mcp . + ``` + + + +--- + ## Command Line Options The Prowler MCP Server supports the following command-line arguments: diff --git a/docs/getting-started/products/img/prowler-app-architecture.png b/docs/getting-started/products/img/prowler-app-architecture.png deleted file mode 100644 index 889bc0da88..0000000000 Binary files a/docs/getting-started/products/img/prowler-app-architecture.png and /dev/null differ diff --git a/docs/getting-started/products/prowler-app.mdx b/docs/getting-started/products/prowler-app.mdx index f21e0222cd..2df6015d06 100644 --- a/docs/getting-started/products/prowler-app.mdx +++ b/docs/getting-started/products/prowler-app.mdx @@ -11,16 +11,19 @@ Prowler App is a web application that simplifies running Prowler. It provides: ## Components -Prowler App consists of three main components: +Prowler App consists of four main components: - **Prowler UI**: User-friendly web interface for running Prowler and viewing results, powered by Next.js - **Prowler API**: Backend API that executes Prowler scans and stores results, built with Django REST Framework - **Prowler SDK**: Python SDK that integrates with Prowler CLI for advanced functionality +- **Prowler MCP Server**: Model Context Protocol server that exposes AI tools for Lighthouse, the AI-powered security assistant. Required dependency for Lighthouse. Supporting infrastructure includes: - **PostgreSQL**: Persistent storage of scan results - **Celery Workers**: Asynchronous execution of Prowler scans +- **Celery Beat (API Scheduler)**: Schedules recurring scans and enqueues jobs on the broker - **Valkey**: In-memory database serving as message broker for Celery workers +- **Neo4j**: Graph database used by the Attack Paths feature to combine cloud inventory with Prowler findings (currently populated by AWS scans) ![Prowler App Architecture](/images/products/prowler-app-architecture.png) diff --git a/docs/getting-started/products/prowler-claude-code-plugin.mdx b/docs/getting-started/products/prowler-claude-code-plugin.mdx new file mode 100644 index 0000000000..e3c11ec810 --- /dev/null +++ b/docs/getting-started/products/prowler-claude-code-plugin.mdx @@ -0,0 +1,101 @@ +--- +title: 'Prowler for Claude Code' +--- + +End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant. + + +**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback. + + +## Requirements + + + + Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code). + + + The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). + + + Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile). + + + +## Installation + + + + Inside a Claude Code session: + + ```text + /plugin marketplace add prowler-cloud/prowler + /plugin install prowler@prowler-plugins + ``` + + + If you already have the repository checked out: + + ```text + /plugin marketplace add /absolute/path/to/prowler + /plugin install prowler@prowler-plugins + ``` + + + +## Configuration + +On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud. + + +To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again. + + +## Verify the installation + +In a Claude Code session: + +```text +/mcp → "prowler" appears as a connected server +/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage +``` + +If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts. + +## Usage + +Open a conversation that mentions the framework you want to comply with. Examples: + +- *"Make my AWS production account compliant with CIS 4.0."* +- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."* +- *"Help me get to 100% on PCI-DSS for this GCP project."* + +You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**: + + + + Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying. + + + Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable. + + + +Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end. + +## Uninstalling + +```text +/plugin uninstall prowler@prowler-plugins +/plugin marketplace remove prowler-plugins +``` + +The stored API key is removed automatically. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. | +| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. | +| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). | diff --git a/docs/getting-started/products/prowler-lighthouse-ai.mdx b/docs/getting-started/products/prowler-lighthouse-ai.mdx index 6f197ae546..4e2eed8538 100644 --- a/docs/getting-started/products/prowler-lighthouse-ai.mdx +++ b/docs/getting-started/products/prowler-lighthouse-ai.mdx @@ -59,6 +59,10 @@ Prowler Lighthouse AI is powerful, but there are limitations: - **NextJS session dependence**: If your Prowler application session expires or logs out, Lighthouse AI will error out. Refresh and log back in to continue. - **Response quality**: The response quality depends on the selected LLM provider and model. Choose models with strong tool-calling capabilities for best results. We recommend `gpt-5` model from OpenAI. +## Architecture + +![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png) + ## Extending Lighthouse AI Lighthouse AI retrieves data through Prowler MCP. To add new capabilities, extend the Prowler MCP Server with additional tools and Lighthouse AI discovers them automatically. diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index ea6fe8fcc0..762b088326 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -46,8 +46,7 @@ Search and retrieve official Prowler documentation: The following diagram illustrates the Prowler MCP Server architecture and its integration points: -Prowler MCP Server Schema -Prowler MCP Server Schema +![Prowler MCP Server Schema](/images/prowler_mcp_schema.png) The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components: - Prowler Cloud/App for security operations diff --git a/docs/images/compliance/prowler-app-compliance-card-download.png b/docs/images/compliance/prowler-app-compliance-card-download.png new file mode 100644 index 0000000000..ec652f8774 Binary files /dev/null and b/docs/images/compliance/prowler-app-compliance-card-download.png differ diff --git a/docs/images/compliance/prowler-app-compliance-detail-download.png b/docs/images/compliance/prowler-app-compliance-detail-download.png new file mode 100644 index 0000000000..96c4cfc980 Binary files /dev/null and b/docs/images/compliance/prowler-app-compliance-detail-download.png differ diff --git a/docs/images/compliance/prowler-app-compliance-detail-header.png b/docs/images/compliance/prowler-app-compliance-detail-header.png new file mode 100644 index 0000000000..03065cc7a5 Binary files /dev/null and b/docs/images/compliance/prowler-app-compliance-detail-header.png differ diff --git a/docs/images/compliance/prowler-app-compliance-overview.png b/docs/images/compliance/prowler-app-compliance-overview.png new file mode 100644 index 0000000000..03302d1902 Binary files /dev/null and b/docs/images/compliance/prowler-app-compliance-overview.png differ diff --git a/docs/images/compliance/prowler-app-compliance-requirements-accordion.png b/docs/images/compliance/prowler-app-compliance-requirements-accordion.png new file mode 100644 index 0000000000..47ceba8f23 Binary files /dev/null and b/docs/images/compliance/prowler-app-compliance-requirements-accordion.png differ diff --git a/docs/images/compliance/prowler-app-compliance-threatscore-card.png b/docs/images/compliance/prowler-app-compliance-threatscore-card.png new file mode 100644 index 0000000000..3bcf349c79 Binary files /dev/null and b/docs/images/compliance/prowler-app-compliance-threatscore-card.png differ diff --git a/docs/images/compliance/prowler-app-compliance-threatscore-detail.png b/docs/images/compliance/prowler-app-compliance-threatscore-detail.png new file mode 100644 index 0000000000..57e909a94c Binary files /dev/null and b/docs/images/compliance/prowler-app-compliance-threatscore-detail.png differ diff --git a/docs/images/finding-groups-drawer.png b/docs/images/finding-groups-drawer.png new file mode 100644 index 0000000000..2c07f63249 Binary files /dev/null and b/docs/images/finding-groups-drawer.png differ diff --git a/docs/images/finding-groups-expanded.png b/docs/images/finding-groups-expanded.png new file mode 100644 index 0000000000..677df0020f Binary files /dev/null and b/docs/images/finding-groups-expanded.png differ diff --git a/docs/images/finding-groups-list.png b/docs/images/finding-groups-list.png new file mode 100644 index 0000000000..e70d2bb969 Binary files /dev/null and b/docs/images/finding-groups-list.png differ diff --git a/docs/images/finding-groups-other-findings.png b/docs/images/finding-groups-other-findings.png new file mode 100644 index 0000000000..35605a7710 Binary files /dev/null and b/docs/images/finding-groups-other-findings.png differ diff --git a/docs/images/github-action/scan-summary.png b/docs/images/github-action/scan-summary.png new file mode 100644 index 0000000000..6a231ec06f Binary files /dev/null and b/docs/images/github-action/scan-summary.png differ diff --git a/docs/images/lighthouse-architecture-dark.png b/docs/images/lighthouse-architecture-dark.png deleted file mode 100644 index 0ed77712c5..0000000000 Binary files a/docs/images/lighthouse-architecture-dark.png and /dev/null differ diff --git a/docs/images/lighthouse-architecture-light.png b/docs/images/lighthouse-architecture-light.png deleted file mode 100644 index 076240ccb2..0000000000 Binary files a/docs/images/lighthouse-architecture-light.png and /dev/null differ diff --git a/docs/images/lighthouse-architecture.mmd b/docs/images/lighthouse-architecture.mmd new file mode 100644 index 0000000000..47407544e9 --- /dev/null +++ b/docs/images/lighthouse-architecture.mmd @@ -0,0 +1,37 @@ +flowchart TB + browser([Browser]) + + subgraph NEXTJS["Next.js Server"] + route["API Route
(auth + context assembly)"] + agent["LangChain Agent"] + + subgraph TOOLS["Agent Tools"] + metatools["Meta-tools
describe_tool / execute_tool / load_skill"] + end + + mcpclient["MCP Client
(HTTP transport)"] + end + + llm["LLM Provider
(OpenAI / Bedrock / OpenAI-compatible)"] + + subgraph MCP["Prowler MCP Server"] + app_tools["prowler_app_* tools
(auth required)"] + hub_tools["prowler_hub_* tools
(no auth)"] + docs_tools["prowler_docs_* tools
(no auth)"] + end + + api["Prowler API"] + hub["hub.prowler.com"] + docs["docs.prowler.com
(Mintlify)"] + + browser <-->|SSE stream| route + route --> agent + agent <-->|LLM API| llm + agent --> metatools + metatools --> mcpclient + mcpclient -->|MCP HTTP · Bearer token
for prowler_app_* only| app_tools + mcpclient -->|MCP HTTP| hub_tools + mcpclient -->|MCP HTTP| docs_tools + app_tools -->|REST| api + hub_tools -->|REST| hub + docs_tools -->|REST| docs diff --git a/docs/images/lighthouse-architecture.png b/docs/images/lighthouse-architecture.png new file mode 100644 index 0000000000..b2e8266154 Binary files /dev/null and b/docs/images/lighthouse-architecture.png differ diff --git a/docs/images/powerbi/benchmark-page.png b/docs/images/powerbi/benchmark-page.png new file mode 100644 index 0000000000..0e03a2b91c Binary files /dev/null and b/docs/images/powerbi/benchmark-page.png differ diff --git a/docs/images/powerbi/download-compliance-scan.png b/docs/images/powerbi/download-compliance-scan.png new file mode 100644 index 0000000000..4626d90edd Binary files /dev/null and b/docs/images/powerbi/download-compliance-scan.png differ diff --git a/docs/images/powerbi/overview-page.png b/docs/images/powerbi/overview-page.png new file mode 100644 index 0000000000..c3afde727a Binary files /dev/null and b/docs/images/powerbi/overview-page.png differ diff --git a/docs/images/powerbi/report-cover.png b/docs/images/powerbi/report-cover.png new file mode 100644 index 0000000000..051b913933 Binary files /dev/null and b/docs/images/powerbi/report-cover.png differ diff --git a/docs/images/powerbi/requirement-page.png b/docs/images/powerbi/requirement-page.png new file mode 100644 index 0000000000..a8b30be722 Binary files /dev/null and b/docs/images/powerbi/requirement-page.png differ diff --git a/docs/images/powerbi/validation.png b/docs/images/powerbi/validation.png new file mode 100644 index 0000000000..f227c4daa8 Binary files /dev/null and b/docs/images/powerbi/validation.png differ diff --git a/docs/images/powerbi/walkthrough-video-thumb.png b/docs/images/powerbi/walkthrough-video-thumb.png new file mode 100644 index 0000000000..2f76b8f1cf Binary files /dev/null and b/docs/images/powerbi/walkthrough-video-thumb.png differ diff --git a/docs/images/products/prowler-app-architecture.mmd b/docs/images/products/prowler-app-architecture.mmd new file mode 100644 index 0000000000..0c13d580c3 --- /dev/null +++ b/docs/images/products/prowler-app-architecture.mmd @@ -0,0 +1,37 @@ +flowchart TB + user([User / Security Team]) + cli([Prowler CLI]) + + subgraph APP["Prowler App"] + ui["Prowler UI
(Next.js)"] + api["Prowler API
(Django REST Framework)"] + worker["API Worker
(Celery)"] + beat["API Scheduler
(Celery Beat)"] + mcp["Prowler MCP Server
(Lighthouse AI tools)"] + end + + sdk["Prowler SDK
(Python)"] + + subgraph DATA["Data Layer"] + pg[("PostgreSQL")] + valkey[("Valkey / Redis")] + neo4j[("Neo4j")] + end + + providers["Providers"] + + user --> ui + user --> cli + ui -->|REST| api + ui -->|MCP HTTP| mcp + mcp -->|REST| api + api --> pg + api --> valkey + beat -->|enqueue jobs| valkey + valkey -->|dispatch| worker + worker --> pg + worker -->|Attack Paths| neo4j + worker -->|invokes| sdk + cli --> sdk + + sdk --> providers diff --git a/docs/images/products/prowler-app-architecture.png b/docs/images/products/prowler-app-architecture.png index 889bc0da88..6b0dacb776 100644 Binary files a/docs/images/products/prowler-app-architecture.png and b/docs/images/products/prowler-app-architecture.png differ diff --git a/docs/images/providers/select-vercel-prowler-cloud.png b/docs/images/providers/select-vercel-prowler-cloud.png new file mode 100644 index 0000000000..b332103e1f Binary files /dev/null and b/docs/images/providers/select-vercel-prowler-cloud.png differ diff --git a/docs/images/providers/vercel-launch-scan.png b/docs/images/providers/vercel-launch-scan.png new file mode 100644 index 0000000000..4e7dd36a25 Binary files /dev/null and b/docs/images/providers/vercel-launch-scan.png differ diff --git a/docs/images/providers/vercel-team-id-form.png b/docs/images/providers/vercel-team-id-form.png new file mode 100644 index 0000000000..fad53fe017 Binary files /dev/null and b/docs/images/providers/vercel-team-id-form.png differ diff --git a/docs/images/providers/vercel-token-form.png b/docs/images/providers/vercel-token-form.png new file mode 100644 index 0000000000..991b9ddedc Binary files /dev/null and b/docs/images/providers/vercel-token-form.png differ diff --git a/docs/images/prowler-app/alerts/alert-email-example.png b/docs/images/prowler-app/alerts/alert-email-example.png new file mode 100644 index 0000000000..3c13f6b556 Binary files /dev/null and b/docs/images/prowler-app/alerts/alert-email-example.png differ diff --git a/docs/images/prowler-app/alerts/alerts-list.png b/docs/images/prowler-app/alerts/alerts-list.png new file mode 100644 index 0000000000..7bb03415fa Binary files /dev/null and b/docs/images/prowler-app/alerts/alerts-list.png differ diff --git a/docs/images/prowler-app/alerts/create-alert-from-findings.png b/docs/images/prowler-app/alerts/create-alert-from-findings.png new file mode 100644 index 0000000000..5524389877 Binary files /dev/null and b/docs/images/prowler-app/alerts/create-alert-from-findings.png differ diff --git a/docs/images/prowler-app/alerts/create-alert-modal.png b/docs/images/prowler-app/alerts/create-alert-modal.png new file mode 100644 index 0000000000..54924638af Binary files /dev/null and b/docs/images/prowler-app/alerts/create-alert-modal.png differ diff --git a/docs/images/prowler-app/alerts/edit-alert-test.png b/docs/images/prowler-app/alerts/edit-alert-test.png new file mode 100644 index 0000000000..252a0cf67c Binary files /dev/null and b/docs/images/prowler-app/alerts/edit-alert-test.png differ diff --git a/docs/images/prowler-app/multi-tenant/create-organization-button.png b/docs/images/prowler-app/multi-tenant/create-organization-button.png new file mode 100644 index 0000000000..70675c334f Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/create-organization-button.png differ diff --git a/docs/images/prowler-app/multi-tenant/create-organization-modal.png b/docs/images/prowler-app/multi-tenant/create-organization-modal.png new file mode 100644 index 0000000000..f0f932035c Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/create-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/delete-active-organization-modal.png b/docs/images/prowler-app/multi-tenant/delete-active-organization-modal.png new file mode 100644 index 0000000000..41f5231753 Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/delete-active-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/delete-organization-modal.png b/docs/images/prowler-app/multi-tenant/delete-organization-modal.png new file mode 100644 index 0000000000..dd06f937e9 Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/delete-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/edit-organization-modal.png b/docs/images/prowler-app/multi-tenant/edit-organization-modal.png new file mode 100644 index 0000000000..e0d28c727d Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/edit-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png b/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png new file mode 100644 index 0000000000..ed3b190c61 Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/expel-user-organization-modal.png differ diff --git a/docs/images/prowler-app/multi-tenant/expel-user-organization.png b/docs/images/prowler-app/multi-tenant/expel-user-organization.png new file mode 100644 index 0000000000..09f72e04ea Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/expel-user-organization.png differ diff --git a/docs/images/prowler-app/multi-tenant/organizations-card.png b/docs/images/prowler-app/multi-tenant/organizations-card.png new file mode 100644 index 0000000000..10ddb4b15b Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/organizations-card.png differ diff --git a/docs/images/prowler-app/multi-tenant/sign-in-invitation.png b/docs/images/prowler-app/multi-tenant/sign-in-invitation.png new file mode 100644 index 0000000000..418216c09b Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/sign-in-invitation.png differ diff --git a/docs/images/prowler-app/multi-tenant/switch-organization-modal.png b/docs/images/prowler-app/multi-tenant/switch-organization-modal.png new file mode 100644 index 0000000000..b9e2607a75 Binary files /dev/null and b/docs/images/prowler-app/multi-tenant/switch-organization-modal.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-1.png b/docs/images/prowler-app/saml/saml-sso-gw-1.png new file mode 100644 index 0000000000..cebdbd5240 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-1.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-10.png b/docs/images/prowler-app/saml/saml-sso-gw-10.png new file mode 100644 index 0000000000..e60274dd44 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-10.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-13.png b/docs/images/prowler-app/saml/saml-sso-gw-13.png new file mode 100644 index 0000000000..03d8e85f44 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-13.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-15.png b/docs/images/prowler-app/saml/saml-sso-gw-15.png new file mode 100644 index 0000000000..26d948dbfd Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-15.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-17.png b/docs/images/prowler-app/saml/saml-sso-gw-17.png new file mode 100644 index 0000000000..7a0069d61a Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-17.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-19.png b/docs/images/prowler-app/saml/saml-sso-gw-19.png new file mode 100644 index 0000000000..9457336148 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-19.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-2.png b/docs/images/prowler-app/saml/saml-sso-gw-2.png new file mode 100644 index 0000000000..e3b539fc2e Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-2.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-3.png b/docs/images/prowler-app/saml/saml-sso-gw-3.png new file mode 100644 index 0000000000..8c163f6ff4 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-3.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-4.png b/docs/images/prowler-app/saml/saml-sso-gw-4.png new file mode 100644 index 0000000000..df96b3912b Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-4.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-5.png b/docs/images/prowler-app/saml/saml-sso-gw-5.png new file mode 100644 index 0000000000..88a2984cee Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-5.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-7.png b/docs/images/prowler-app/saml/saml-sso-gw-7.png new file mode 100644 index 0000000000..a2050b6ff2 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-7.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-8.png b/docs/images/prowler-app/saml/saml-sso-gw-8.png new file mode 100644 index 0000000000..4f5d68a020 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-8.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-1.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-1.png new file mode 100644 index 0000000000..ca077ca5aa Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-1.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-2.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-2.png new file mode 100644 index 0000000000..f0e3b597bb Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-2.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-3.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-3.png new file mode 100644 index 0000000000..bf186a1e21 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-3.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-4.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-4.png new file mode 100644 index 0000000000..dd558d70cb Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-4.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-5.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-5.png new file mode 100644 index 0000000000..47ee49f6a0 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-5.png differ diff --git a/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png new file mode 100644 index 0000000000..e76809b810 Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png differ diff --git a/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png new file mode 100644 index 0000000000..980b16bf7f Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png differ diff --git a/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png new file mode 100644 index 0000000000..a7dab3310c Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png differ diff --git a/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png b/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png new file mode 100644 index 0000000000..26b4b160ec Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png differ diff --git a/docs/images/prowler_mcp_schema.mmd b/docs/images/prowler_mcp_schema.mmd new file mode 100644 index 0000000000..96973546f6 --- /dev/null +++ b/docs/images/prowler_mcp_schema.mmd @@ -0,0 +1,29 @@ +flowchart LR + subgraph HOSTS["MCP Hosts"] + chat["Chat Interfaces
(Claude Desktop, LobeChat)"] + ide["IDEs and Code Editors
(Claude Code, Cursor)"] + apps["Other AI Applications
(5ire, custom agents)"] + end + + subgraph MCP["Prowler MCP Server"] + app_tools["prowler_app_* tools
(JWT or API key auth)
Findings · Providers · Scans
Resources · Muting · Compliance
Attack Paths"] + hub_tools["prowler_hub_* tools
(no auth)
Checks Catalog · Check Code
Fixers · Compliance Frameworks"] + docs_tools["prowler_docs_* tools
(no auth)
Search · Document Retrieval"] + end + + api["Prowler API
(REST)"] + hub["hub.prowler.com
(REST)"] + docs["docs.prowler.com
(Mintlify)"] + + chat -->|STDIO or HTTP| app_tools + chat -->|STDIO or HTTP| hub_tools + chat -->|STDIO or HTTP| docs_tools + ide -->|STDIO or HTTP| app_tools + ide -->|STDIO or HTTP| hub_tools + ide -->|STDIO or HTTP| docs_tools + apps -->|STDIO or HTTP| app_tools + apps -->|STDIO or HTTP| hub_tools + apps -->|STDIO or HTTP| docs_tools + app_tools -->|REST| api + hub_tools -->|REST| hub + docs_tools -->|REST| docs diff --git a/docs/images/prowler_mcp_schema.png b/docs/images/prowler_mcp_schema.png new file mode 100644 index 0000000000..8a8884fa5e Binary files /dev/null and b/docs/images/prowler_mcp_schema.png differ diff --git a/docs/images/prowler_mcp_schema_dark.png b/docs/images/prowler_mcp_schema_dark.png deleted file mode 100644 index 7771557601..0000000000 Binary files a/docs/images/prowler_mcp_schema_dark.png and /dev/null differ diff --git a/docs/images/prowler_mcp_schema_light.png b/docs/images/prowler_mcp_schema_light.png deleted file mode 100644 index c542d84ed2..0000000000 Binary files a/docs/images/prowler_mcp_schema_light.png and /dev/null differ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 766f60e3c0..51a27eb555 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -21,29 +21,61 @@ ## Supported Providers -The supported providers right now are: +Prowler supports a wide range of providers organized by category: -| Provider | Support | Audit Scope/Entities | Interface | -| -------------------------------------------------------------------------------- | ---------- | ---------------------------- | ------------ | -| [AWS](/user-guide/providers/aws/getting-started-aws) | Official | Accounts | UI, API, CLI | -| [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI | -| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI | -| [Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) | Official | Clusters | UI, API, CLI | -| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI | -| [Github](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI | -| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | -| [Alibaba Cloud](/user-guide/providers/alibabacloud/getting-started-alibabacloud) | Official | Accounts | UI, API, CLI | -| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI | -| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | -| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | -| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI | -| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | CLI | -| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | -| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images | CLI, API | -| [Google Workspace](/user-guide/providers/googleworkspace/getting-started-googleworkspace) | Official | Domains | CLI | -| **NHN** | Unofficial | Tenants | CLI | +### Cloud Service Providers (Infrastructure) -For more information about the checks and compliance of each provider visit [Prowler Hub](https://hub.prowler.com). +| Provider | Support | Audit Scope/Entities | Interface | +| -------------------------------------------------------------------------------- | ---------- | ------------------------ | ------------ | +| [Alibaba Cloud](/user-guide/providers/alibabacloud/getting-started-alibabacloud) | Official | Accounts | UI, API, CLI | +| [AWS](/user-guide/providers/aws/getting-started-aws) | Official | Accounts | UI, API, CLI | +| [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI | +| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI | +| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI | +| [Linode](/user-guide/providers/linode/getting-started-linode) | [Contact us](https://prowler.com/contact) | Accounts | CLI | +| **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI | +| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI | +| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | +| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) | [Contact us](https://prowler.com/contact) | Organizations | CLI | +| [StackIT](/user-guide/providers/stackit/getting-started-stackit) | [Contact us](https://prowler.com/contact) | Projects | CLI | + +### Infrastructure as Code Providers + +| Provider | Support | Audit Scope/Entities | Interface | +| --------------------------------------------------------------------- | -------- | -------------------- | ------------ | +| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | + +### Software as a Service (SaaS) Providers + +| Provider | Support | Audit Scope/Entities | Interface | +| ----------------------------------------------------------------------------------------- | -------- | ---------------------------- | ------------ | +| [GitHub](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI | +| [Google Workspace](/user-guide/providers/googleworkspace/getting-started-googleworkspace) | Official | Domains | UI, API, CLI | +| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | +| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI | +| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | +| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI | +| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI | + +### Kubernetes + +| Provider | Support | Audit Scope/Entities | Interface | +| -------------------------------------------------------------------------- | -------- | -------------------- | ------------ | +| [Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) | Official | Clusters | UI, API, CLI | + +### Containers + +| Provider | Support | Audit Scope/Entities | Interface | +| ------------------------------------------------------------------- | -------- | -------------------- | --------- | +| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images / Registries | CLI, API | + +### Custom Providers (Prowler Cloud Enterprise Only) + +| Provider | Support | Audit Scope/Entities | Interface | +| -------------------- | -------- | -------------------- | --------- | +| VMware/Broadcom VCF | Official | Infrastructure | CLI | + +For more information about the checks and compliance of each provider, visit [Prowler Hub](https://hub.prowler.com). ## Where to go next? diff --git a/docs/security/data-regions.mdx b/docs/security/data-regions.mdx index 0602caf7b9..14c13ab98b 100644 --- a/docs/security/data-regions.mdx +++ b/docs/security/data-regions.mdx @@ -9,6 +9,8 @@ Prowler Cloud runs on AWS with high availability built in. | Region | URL | Location | |--------|-----|----------| | **EU** | [cloud.prowler.com](https://cloud.prowler.com) | Ireland (`eu-west-1`) | +| **US** | On-Demand | On-Demand | + ## Business Continuity diff --git a/docs/security/index.mdx b/docs/security/index.mdx index a9034c4cea..30364d5b82 100644 --- a/docs/security/index.mdx +++ b/docs/security/index.mdx @@ -14,7 +14,7 @@ All Prowler code goes through the same security pipeline, whether running on Pro Security tools and practices applied to all Prowler code. -## Prowler Cloud vs Self-Managed +## Prowler Cloud vs Prowler OSS (Self-Managed) | | Prowler Cloud | Self-Managed | |--|---------------|--------------| diff --git a/docs/security/software-security.mdx b/docs/security/software-security.mdx index 4c690e988b..3fee8d1251 100644 --- a/docs/security/software-security.mdx +++ b/docs/security/software-security.mdx @@ -2,96 +2,205 @@ title: 'Software Security' --- -Prowler follows a **security-by-design approach** throughout the software development lifecycle. All changes go through automated checks at every stage, from local development to production deployment. +Prowler applies security-by-design across the development lifecycle. Every change passes automated checks at each stage: pre-commit hooks locally, multiple CI gates on pull requests, branch protection before merge, container scanning before publish, and registry monitoring after release. -[Pre-commit](https://github.com/prowler-cloud/prowler/blob/master/.pre-commit-config.yaml) validations catch issues early, and [CI/CD pipelines](https://github.com/prowler-cloud/prowler/tree/master/.github) include multiple security gates ensuring code quality, secure configurations, and compliance with internal standards. +All security tooling and configuration lives in the [Prowler GitHub repository](https://github.com/prowler-cloud/prowler): [pre-commit hooks](https://github.com/prowler-cloud/prowler/blob/master/.pre-commit-config.yaml), [CI/CD workflows](https://github.com/prowler-cloud/prowler/tree/master/.github/workflows), and [Dependabot configuration](https://github.com/prowler-cloud/prowler/blob/master/.github/dependabot.yml). -Container registries are continuously scanned for vulnerabilities, with findings automatically reported to the security team for assessment and remediation. This process evolves alongside the stack as new languages, frameworks, and technologies are adopted, ensuring security practices remain comprehensive, proactive, and adaptable. +## Coverage + +Security controls cover six domains, each detailed below: + +| Domain | What It Protects | +|--------|------------------| +| [**CI/CD**](#cicd-security) | GitHub Actions workflows, runners, third-party actions | +| [**SAST**](#static-application-security-testing-sast) | Application source code | +| [**SCA**](#software-composition-analysis-sca) | Third-party dependencies and their known vulnerabilities | +| [**Supply-Chain Pinning**](#supply-chain-pinning) | Reproducible installs across Python, npm, GitHub Actions, container base images | +| [**Containers**](#container-security) | Runtime images for UI, API, SDK, Model Context Protocol (MCP) Server | +| [**Secrets**](#secrets-detection) | Credentials, tokens, API keys in code and git history | + +## CI/CD Security + +Every GitHub Actions workflow uses runner hardening, pinned action versions, and audited permissions. + +### Runner Hardening With StepSecurity + +- [**`step-security/harden-runner`**](https://github.com/step-security/harden-runner) runs as the first step in every workflow, pinned by commit SHA. +- Workflows are being migrated to explicit egress controls: some already declare an egress allow-list with `egress-policy: block`, while others still run in `egress-policy: audit` until their allowed endpoints are fully defined. +- **Global Block Policy** (StepSecurity) blocks known-malicious domains and IP addresses across every workflow run. This protection applies even in audit mode, so workflows that have not yet moved to `block` still resist known-bad egress. + +### Third-Party Action Pinning + +- Every third-party action reference uses a commit SHA with the version as a comment: `uses: org/action@ # v1.2.3`. +- Dependabot tracks the comment and proposes SHA-pinned upgrades on a monthly cadence. + +### Workflow Permissions + +- Workflows declare `permissions: {}` at the top level and grant the minimum required scopes per job. +- Code review covers permission changes; zizmor enforces the rules (see below). + +### Workflow Security Audit With Zizmor + +- **[zizmor](https://github.com/zizmorcore/zizmor)** audits every workflow file for known security anti-patterns. Runs via [`ci-zizmor.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ci-zizmor.yml). +- Triggers on every push, every pull request that touches `.github/`, and on a daily schedule. +- Results upload to the GitHub Security tab via Static Analysis Results Interchange Format (SARIF). +- Key [audit rules](https://docs.zizmor.sh/audits/) the build gates on: + - **[PWN Request](https://docs.zizmor.sh/audits/#dangerous-triggers)** (`dangerous-triggers`): unsafe use of `pull_request_target` with checked-out PR code. + - **[Script Injection](https://docs.zizmor.sh/audits/#template-injection)** (`template-injection`): unsanitized `${{ github.event.* }}` expressions in `run:` blocks. + - **[artipacked](https://docs.zizmor.sh/audits/#artipacked)**: credential leakage through artifacts. + - **[Excessive permissions](https://docs.zizmor.sh/audits/#excessive-permissions)** (`excessive-permissions`): workflows with unneeded `write` scopes. + +### Branch Protection + +Pull requests to `master` and the active `v5.*` release branches must pass several required workflows before merge. These gates prevent specific classes of supply-chain and pipeline attacks from reaching the main branch: + +- **Compromised packages:** the **npm Package Compromised Updates** and **PyPI Package Compromised Updates** checks (StepSecurity) fail any PR that introduces a package version present in the compromised-package feed. Layered on top of osv-scanner. +- **Premature releases:** the **npm Package Cooldown** and **PyPI Package Cooldown** checks (StepSecurity) fail any PR that introduces a package version published within the cooldown window. Layered on top of pnpm's `minimumReleaseAge`. +- **Workflow exploitation:** the **PWN Request** and **Script Injection** checks (StepSecurity) reject the corresponding zizmor-detected anti-patterns at PR time. Layered on top of zizmor's audit. +- **Vulnerable code or dependencies:** CodeQL (UI, API, SDK), osv-scanner (SDK, API, UI), Bandit (SDK, API), and Trivy (container images) must all pass. ## Static Application Security Testing (SAST) -Multiple SAST tools are employed across the codebase to identify security vulnerabilities, code quality issues, and potential bugs during development. +Multiple SAST tools run on every push and pull request to catch vulnerabilities and code-quality issues before merge. -### CodeQL Analysis +### Cross-Language -- **Scope:** UI (JavaScript/TypeScript), API (Python), and SDK (Python) -- **Frequency:** On every push and pull request, plus daily scheduled scans -- **Integration:** Results uploaded to GitHub Security tab via SARIF format -- **Purpose:** Identifies security vulnerabilities, coding errors, and potential exploits in source code +- **CodeQL:** semantic code analysis for the UI (JavaScript/TypeScript), API (Python), and SDK (Python). Runs on every push and pull request, plus a daily scheduled scan, via [`sdk-codeql.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-codeql.yml), [`api-codeql.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-codeql.yml), and [`ui-codeql.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-codeql.yml). Results upload to the GitHub Security tab via SARIF. -### Python Security Scanners +### Python (SDK + API) -- **Bandit:** Detects common security issues in Python code (SQL injection, hardcoded passwords, etc.) - - Configured to ignore test files and report only high-severity issues - - Runs on both SDK and API codebases -- **Pylint:** Static code analysis with security-focused checks - - Integrated into pre-commit hooks and CI/CD pipelines +- **Bandit:** detects common Python security issues (SQL injection, hardcoded credentials, insecure deserialization). Runs in pre-commit and on every PR/push in [`sdk-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-security.yml) and [`api-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-security.yml). +- **Pylint:** analyzes your code without actually running it. It checks for errors, enforces a coding standard, looks for code smells, and can suggest refactors. Runs in pre-commit and on every PR/push in [`sdk-code-quality.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-code-quality.yml) and [`api-code-quality.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-code-quality.yml). +- **Vulture:** dead-code detection at `--min-confidence 100`. Unused code can hide incomplete implementations or stale security paths. Runs in pre-commit and on every PR/push in `sdk-security.yml` and `api-security.yml`. +- **Flake8:** style and correctness checks for the SDK. Runs in pre-commit and on every PR/push in [`sdk-code-quality.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-code-quality.yml). -### Code Quality & Dead Code Detection +### JavaScript/TypeScript (UI) -- **Vulture:** Identifies unused code that could indicate incomplete implementations or security gaps -- **Flake8:** Style guide enforcement with security-relevant checks -- **Shellcheck:** Security and correctness checks for shell scripts +- **TypeScript (`tsc`):** strict type checking for the UI. Catches whole classes of null/undefined and type-confusion bugs at build time. Runs on every PR/push via `pnpm run healthcheck` in [`ui-tests.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-tests.yml). +- **ESLint:** UI linting with a capped warning budget (`--max-warnings 40`). Runs on every PR/push via `pnpm run healthcheck` in `ui-tests.yml`. +- **Knip:** dead-code and unused-export detection for the UI. The UI analogue to Vulture. + + +Knip runs locally on demand via `pnpm run lint:knip` and is not yet wired into CI. + + +### Shell + +- **Shellcheck:** correctness and security checks for shell scripts in `.github/scripts/` and `scripts/`. Runs in pre-commit on staged files. ## Software Composition Analysis (SCA) -Dependencies are continuously monitored for known vulnerabilities with timely updates ensured. +Dependencies are scanned against public vulnerability databases on every pull request and push, with results posted directly on the PR. -### Dependency Vulnerability Scanning +### Cross-Language -- **Safety:** Scans Python dependencies against known vulnerability databases - - Runs on every commit via pre-commit hooks - - Integrated into CI/CD for SDK and API - - Configured with selective ignores for tracked exceptions -- **Trivy:** Multi-purpose scanner for containers and dependencies - - Scans all container images (UI, API, SDK, MCP Server) - - Checks for vulnerabilities in OS packages and application dependencies - - Reports findings to GitHub Security tab +- **osv-scanner:** scans lockfiles against the [OSV.dev](https://osv.dev) vulnerability database for SDK (`uv.lock`), API (`api/uv.lock`), and UI (`ui/pnpm-lock.yaml`). Runs via [`sdk-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-security.yml), [`api-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-security.yml), and [`ui-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-security.yml). + - The action installs the `osv-scanner` binary and verifies its SHA-256 checksum against the upstream-signed `SHA256SUMS` manifest before running. Any mismatch aborts the scan. + - Gates the build on `HIGH`, `CRITICAL`, and `UNKNOWN` severity findings. + - Posts and updates a per-lockfile report as a pull request comment. + - Per-vulnerability ignores live in [`osv-scanner.toml`](https://github.com/prowler-cloud/prowler/blob/master/osv-scanner.toml) at the repo root, each with a reason and an expiry date. +- **Trivy:** scans container images for OS-package and application-dependency vulnerabilities. Runs in [`sdk-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-container-checks.yml), [`api-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-container-checks.yml), [`ui-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-container-checks.yml), and [`mcp-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/mcp-container-checks.yml). Trivy uploads SARIF to the GitHub Security tab and posts a scan summary on the PR. +- **Dependabot:** [configured](https://github.com/prowler-cloud/prowler/blob/master/.github/dependabot.yml) for monthly updates of the SDK Python dependencies, GitHub Actions, Docker base images, and pre-commit hooks. Dependabot opens pull requests for known security advisories, so critical patches reach the team without delay. A 7-day default cooldown reduces exposure to compromised package releases. +- **Renovate:** [configured](https://github.com/prowler-cloud/prowler/blob/master/.github/renovate.json) dependency update automation is transitioning from Dependabot to **Renovate** to gain finer control over update cadence, grouping, and per-component scope. Both tools currently run in parallel during the migration. -### Automated Dependency Updates +#### Renovate (Primary) -- **Dependabot:** Automated pull requests for dependency updates - - **Python (pip):** Monthly updates for SDK - - **GitHub Actions:** Monthly updates for workflow dependencies - - **Docker:** Monthly updates for base images - - Temporarily paused for API and UI to maintain stability during active development - - **Security-first approach:** Even when paused, Dependabot automatically creates pull requests for security vulnerabilities, ensuring critical security patches are never delayed +Configuration: [`.github/renovate.json`](https://github.com/prowler-cloud/prowler/blob/master/.github/renovate.json) + +- **Coverage:** Python (SDK, API, MCP Server), npm (UI), GitHub Actions, Docker images, and Pre-commit hooks +- **Range Strategy:** Versions are pinned to ensure reproducible builds +- **Vulnerability Alerts:** GitHub Security Advisories generate immediate pull requests that bypass rate limits and scheduled windows, labeled `security` for prioritized triage + +#### Dependabot (Legacy) + +Configuration: [`.github/dependabot.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/dependabot.yml) + +Dependabot remains active for the SDK and shared automation ecosystems until the Renovate migration completes: + +- **Python (pip):** Monthly updates for SDK +- **GitHub Actions:** Monthly updates for workflow dependencies +- **Docker:** Monthly updates for base images +- **Pre-commit:** Monthly updates for hook revisions + +Dependabot is paused for the API and UI; Renovate now handles those components. Even when paused, Dependabot continues to open pull requests for security vulnerabilities, ensuring critical patches are never delayed. + +### JavaScript/TypeScript (UI) + +- **pnpm audit:** runs `pnpm audit --audit-level critical` on every UI pull request and push as part of `pnpm run audit` in [`ui-tests.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-tests.yml). Cross-checks the npm registry's advisory database in addition to the OSV scan and surfaces npm-specific advisories that may not yet have an OSV identifier. + +## Supply-Chain Pinning + +Pinning runs across Python, npm, GitHub Actions, and container base images. Every install resolves to the exact set of versions already vetted in CI, and any drift fails loudly instead of slipping in silently. + +### Python (uv) + +The SDK, API, and MCP Server all use [uv](https://docs.astral.sh/uv/) for dependency management. Each component has its own project manifest and lock file: + +| Component | Manifest | Lock File | +|-----------|----------|-----------| +| SDK | `pyproject.toml` | `uv.lock` | +| API | `api/pyproject.toml` | `api/uv.lock` | +| MCP Server | `mcp_server/pyproject.toml` | `mcp_server/uv.lock` | + +The controls applied across all three: + +- **Direct dependencies pinned to exact versions** (`==`). No version ranges in dependency lists. +- **Transitive dependencies pinned** via `[tool.uv].constraint-dependencies` in the SDK and API manifests. The constraint set mirrors the versions locked in the corresponding `uv.lock`. A future `uv lock` preserves these versions instead of silently picking up newer releases, and the resolver fails when a constraint becomes infeasible, signaling that a deliberate bump is needed. +- **Lock files committed.** CI installs strictly from the lock. +- **uv itself pinned** in the [`setup-python-uv`](https://github.com/prowler-cloud/prowler/tree/master/.github/actions/setup-python-uv) composite action. + + +The MCP Server has a small direct-dependency surface and does not yet declare a separate constraint set. Its lock file is the source of truth. + + +### JavaScript/TypeScript (pnpm) + +The UI uses [pnpm](https://pnpm.io) with supply-chain controls configured in [`ui/pnpm-workspace.yaml`](https://github.com/prowler-cloud/prowler/blob/master/ui/pnpm-workspace.yaml). + +- **Minimum release age** (`minimumReleaseAge: 1440`): packages must publish at least 24 hours before install. This reduces exposure during the window when a compromised release has not yet been detected and yanked. +- **Lifecycle script allow-list** (`strictDepBuilds: true` + `allowBuilds`): only explicitly approved packages may run `install` or `postinstall` scripts (currently `sharp`, `esbuild`, `@sentry/cli`, `@heroui/shared-utils`, `unrs-resolver`, `msw`). Any unlisted package with lifecycle scripts fails the install. +- **Trust policy** (`trustPolicy: no-downgrade`): the install fails when a package's trust evidence drops, for example after a new publisher takes over. +- **Block exotic subdeps** (`blockExoticSubdeps: true`): transitive dependencies cannot ship as git URLs or tarballs. Every package in the tree resolves from the configured registry. +- **Transitive overrides** in [`ui/package.json`](https://github.com/prowler-cloud/prowler/blob/master/ui/package.json) force specific versions for transitive packages (`lodash`, `serialize-javascript`, `qs`, `rollup`, `minimatch`, `ajv`, and others). +- **`pnpm-lock.yaml` committed** and CI installs strictly from the lock. +- **pnpm itself pinned** via the `packageManager` field in `package.json` with an integrity hash. + +### GitHub Actions + +- Every third-party action reference uses a commit SHA, with the version in a trailing comment. +- Dependabot opens monthly PRs to bump pinned SHAs. + +### Container Base Images + +- Every Dockerfile references base images by digest (`image@sha256:...`). +- Dependabot opens monthly PRs to bump digests. ## Container Security -All container images are scanned before deployment. +Container images get scanned twice: once in CI before they push to a registry, and continuously after publish by the registries themselves. -### Trivy Vulnerability Scanning +### Pre-Publish (CI) -- Scans images for vulnerabilities and misconfigurations -- Generates SARIF reports uploaded to GitHub Security tab -- Creates PR comments with scan summaries -- Configurable to fail builds on critical findings -- Reports include CVE counts and remediation guidance +- **Trivy** scans for OS-package and application-dependency vulnerabilities. Runs in [`sdk-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-container-checks.yml), [`api-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-container-checks.yml), [`ui-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-container-checks.yml), and [`mcp-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/mcp-container-checks.yml). Trivy uploads SARIF to the GitHub Security tab and posts a summary on the PR. Builds can fail on critical findings when configured to. +- **Hadolint** validates Dockerfile syntax and structure against secure-build best practices. Runs in pre-commit and in the same `*-container-checks.yml` workflows linked above. -### Hadolint +### Post-Publish (Registries) -- Validates Dockerfile syntax and structure -- Ensures secure image building practices +- **Amazon ECR:** ECR continuously scans published images for vulnerabilities. New advisories disclosed after publish surface on the image without requiring a rebuild. +- **Docker Hub:** Docker Hub continuously scans the same images mirrored from ECR. +- The security team reviews findings from both registries for triage and remediation. ## Secrets Detection -Prowler protects against accidental exposure of sensitive credentials. - -### TruffleHog - -- Scans entire codebase and Git history for secrets -- Runs on every push and pull request -- Pre-commit hook prevents committing secrets -- Detects high-entropy strings, API keys, tokens, and credentials -- Configured to report verified and unknown findings +- **[TruffleHog](https://github.com/trufflesecurity/trufflehog)** scans the codebase and git history on every push and pull request via [`find-secrets.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/find-secrets.yml). Detects high-entropy strings, API keys, tokens, and credentials, and reports verified and unknown findings. +- A pre-commit hook runs the same check locally and blocks secrets before they leave the developer machine. ## Security Monitoring -- **GitHub Security Tab:** Centralized view of all security findings from CodeQL, Trivy, and other SARIF-compatible tools -- **Artifact Retention:** Security scan reports retained for post-deployment analysis -- **PR Comments:** Automated security feedback on pull requests for rapid remediation +- **GitHub Security tab:** centralized view of findings from CodeQL, Trivy, zizmor, and any other SARIF-compatible tool. +- **PR comments:** osv-scanner and Trivy post per-PR summaries so issues surface during review, not after merge. +- **Artifact retention:** the build retains security scan reports for post-deployment analysis. ## Contact -For questions regarding software security, visit the [Support page](/support). +For questions about software security, see the [Support page](/support). To report a vulnerability, follow the [responsible disclosure process](https://prowler.com/.well-known/security.txt). diff --git a/docs/snippets/version-badge.mdx b/docs/snippets/version-badge.mdx index 7541ff823a..62d55c4b9d 100644 --- a/docs/snippets/version-badge.mdx +++ b/docs/snippets/version-badge.mdx @@ -1,12 +1,17 @@ export const VersionBadge = ({ version }) => { return ( - -

- Added in:  - {version} -

-
- - + + + + Added in:  + {version} + + + ); }; diff --git a/docs/style.css b/docs/style.css index 3e9bedbc80..5c626fb06b 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,4 +1,21 @@ /* Version Badge Styling */ +.version-badge-link, +.version-badge-link:hover, +.version-badge-link:focus, +.version-badge-link:active, +.version-badge-link:visited { + display: inline-block; + text-decoration: none !important; + background-image: none !important; + border-bottom: none !important; + color: inherit; + transition: opacity 0.15s ease-in-out; +} + +.version-badge-link:hover { + opacity: 0.85; +} + .version-badge-container { display: inline-block; margin: 0 0 1rem 0; diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index a6a28c0ad6..3125d74ef5 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -2,6 +2,8 @@ title: 'Troubleshooting' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + ## Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]` That is an error related to file descriptors or opened files allowed by your operating system. @@ -81,6 +83,39 @@ docker compose down docker compose up -d ``` +### Worker Uses Too Much Memory on Hosts with Many CPUs + + + +When Prowler App runs self-hosted on a machine or Kubernetes node with many CPUs, +the Celery worker may create one prefork process per detected CPU if concurrency +is not configured explicitly. Each process loads the SDK runtime and cloud +provider clients, so idle memory can be high and worker containers can be +terminated by their memory limit. + +Set `DJANGO_CELERY_WORKER_CONCURRENCY` in the worker runtime environment to cap +the number of prefork processes: + +```yaml +services: + worker: + environment: + DJANGO_CELERY_WORKER_CONCURRENCY: "4" +``` + +For Kubernetes deployments, set the same variable on the worker Deployment: + +```yaml +env: + - name: DJANGO_CELERY_WORKER_CONCURRENCY + value: "4" +``` + +Lower values reduce idle memory and the number of tasks a worker can run in +parallel. Increase the value only when the worker has enough memory for the +expected scan workload. Leaving the variable unset preserves Celery's default +CPU-based concurrency. + ### API Container Fails to Start with JWT Key Permission Error See [GitHub Issue #8897](https://github.com/prowler-cloud/prowler/issues/8897) for more details. @@ -159,6 +194,40 @@ When these environment variables are set, the API will use them directly instead A fix addressing this permission issue is being evaluated in [PR #9953](https://github.com/prowler-cloud/prowler/pull/9953).
+### Scan Stuck in Executing State After Worker Crash + +When running Prowler App via Docker Compose, a scan may remain indefinitely in the `executing` state if the worker process crashes (for example, due to an Out of Memory condition) before it can update the scan status. Since it is not currently possible to cancel a scan in `executing` state through the UI, the workaround is to manually update the scan record in the database. + +**Root Cause:** + +The Celery worker process terminates unexpectedly (OOM, node failure, etc.) before transitioning the scan state to `completed` or `failed`. The scan record remains in `executing` with no active process to advance it. + +**Solution:** + +Connect to the database using the `prowler_admin` user. Due to Row-Level Security (RLS), the default database user cannot see scan records — you must use `prowler_admin`: + +```bash +psql -U prowler_admin -d prowler_db +``` + +Identify the stuck scan by filtering for scans in `executing` state: + +```sql +SELECT id, name, state, started_at FROM scans WHERE state = 'executing'; +``` + +Update the scan state to `failed` using the scan ID: + +```sql +UPDATE scans SET state = 'failed' WHERE id = ''; +``` + +After this change, the scan will appear as failed in the UI and you can launch a new scan. + + +A feature to cancel executing scans directly from the UI is being tracked in [GitHub Issue #6893](https://github.com/prowler-cloud/prowler/issues/6893). + + ### SAML/OAuth ACS URL Incorrect When Running Behind a Proxy or Load Balancer See [GitHub Issue #9724](https://github.com/prowler-cloud/prowler/issues/9724) for more details. @@ -167,35 +236,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan **Root Cause:** -Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle. +The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required. **Solution:** -You must **rebuild** the UI Docker image with your external URL: - -```bash -# Clone the repository (if you haven't already) -git clone https://github.com/prowler-cloud/prowler.git -cd prowler/ui - -# Build with your external URL as a build argument -docker build \ - --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \ - --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \ - -t prowler-ui-custom:latest \ - --target prod \ - . -``` - -Then update your `docker-compose.yml` to use your custom image instead of the pre-built one: +Set the runtime environment variables to your external URL and restart the UI container: ```yaml services: ui: - image: prowler-ui-custom:latest # Use your custom-built image + image: prowlercloud/prowler-ui:stable + environment: + UI_API_BASE_URL: https://prowler.example.com/api/v1 + UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs # ... rest of configuration ``` +The same values can be supplied through your `.env` file: + +```bash +UI_API_BASE_URL=https://prowler.example.com/api/v1 +UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs +``` + -The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables. +Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient. diff --git a/docs/user-guide/cli/tutorials/compliance.mdx b/docs/user-guide/cli/tutorials/compliance.mdx deleted file mode 100644 index 8754be6237..0000000000 --- a/docs/user-guide/cli/tutorials/compliance.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: 'Compliance' ---- - -Prowler allows you to execute checks based on requirements defined in compliance frameworks. By default, it will execute and give you an overview of the status of each compliance framework: - - - -You can find CSVs containing detailed compliance results in the compliance folder within Prowler's output folder. - -## Execute Prowler based on Compliance Frameworks - -Prowler can analyze your environment based on a specific compliance framework and get more details, to do it, you can use option `--compliance`: - -```sh -prowler --compliance -``` - -Standard results will be shown and additionally the framework information as the sample below for CIS AWS 2.0. For details a CSV file has been generated as well. - - - - -**If Prowler can't find a resource related with a check from a compliance requirement, this requirement won't appear on the output** - - -## List Available Compliance Frameworks - -To see which compliance frameworks are covered by Prowler, use the `--list-compliance` option: - -```sh -prowler --list-compliance -``` - -Or you can visit [Prowler Hub](https://hub.prowler.com/compliance). - -## List Requirements of Compliance Frameworks -To list requirements for a compliance framework, use the `--list-compliance-requirements` option: - -```sh -prowler --list-compliance-requirements -``` - -Example for the first requirements of CIS 1.5 for AWS: - -``` -Listing CIS 1.5 AWS Compliance Requirements: - -Requirement Id: 1.1 - - Description: Maintain current contact details - - Checks: - account_maintain_current_contact_details - -Requirement Id: 1.2 - - Description: Ensure security contact information is registered - - Checks: - account_security_contact_information_is_registered - -Requirement Id: 1.3 - - Description: Ensure security questions are registered in the AWS account - - Checks: - account_security_questions_are_registered_in_the_aws_account - -Requirement Id: 1.4 - - Description: Ensure no 'root' user account access key exists - - Checks: - iam_no_root_access_key - -Requirement Id: 1.5 - - Description: Ensure MFA is enabled for the 'root' user account - - Checks: - iam_root_mfa_enabled - -[redacted] - -``` - -## Create and contribute adding other Security Frameworks - -This information is part of the Developer Guide and can be found [here](/developer-guide/security-compliance-framework). diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 7f96891cbb..8a7550de06 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -2,6 +2,8 @@ title: "Configuration File" --- +import { VersionBadge } from "/snippets/version-badge.mdx" + Several Prowler's checks have user configurable variables that can be modified in a common **configuration file**. This file can be found in the following [path](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml): ``` @@ -10,14 +12,20 @@ prowler/config/config.yaml Additionally, you can input a custom configuration file using the `--config-file` argument. + +Numeric thresholds enforce hard limits. A value outside the accepted range is dropped with a warning and the check falls back to its built-in default. See [Configuration Value Limits](/developer-guide/configurable-checks#configuration-value-limits) for the exact range of every bounded option (max-days caps, percentages, counts, etc.). + + ## AWS ### Configurable Checks + The following list includes all the AWS checks with configurable variables that can be changed in the configuration yaml file: | Check Name | Value | Type | |---------------------------------------------------------------|--------------------------------------------------|-----------------| | `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer | +| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings | | `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer | | `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer | | `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer | @@ -55,7 +63,11 @@ The following list includes all the AWS checks with configurable variables that | `elasticache_redis_cluster_backup_enabled` | `minimum_snapshot_retention_period` | Integer | | `elb_is_in_multiple_az` | `elb_min_azs` | Integer | | `elbv2_is_in_multiple_az` | `elbv2_min_azs` | Integer | +| `rolesanywhere_trust_anchor_pqc_pki` | `rolesanywhere_pqc_pca_key_algorithms` | List of Strings | +| `cloudfront_distributions_pqc_tls_enabled` | `cloudfront_pqc_min_protocol_versions` | List of Strings | +| `apigateway_domain_name_pqc_tls_enabled` | `apigateway_pqc_tls_allowed_policies` | List of Strings | | `guardduty_is_enabled` | `mute_non_default_regions` | Boolean | +| `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer | | `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer | | `iam_user_console_access_unused` | `max_console_access_days` | Integer | | `organizations_delegated_administrators` | `organizations_trusted_delegated_administrators` | List of Strings | @@ -66,6 +78,7 @@ The following list includes all the AWS checks with configurable variables that | `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer | | `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings | | `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean | +| `transfer_server_pqc_ssh_kex_enabled` | `transfer_pqc_ssh_allowed_policies` | List of Strings | | `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings | | `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings | | `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings | @@ -76,6 +89,32 @@ The following list includes all the AWS checks with configurable variables that | `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings | +### Validating Discovered Secrets + + + +By default, the secret-scanning checks run fully offline: secrets are detected but never sent anywhere. Setting `secrets_validate` to `True` additionally confirms whether each discovered secret is live by authenticating with it against the corresponding provider API. The discovered secret itself serves as the credential, so Prowler requires no additional permissions to validate it. + +`secrets_validate` applies to every AWS secret-scanning check listed above (those that accept `secrets_ignore_patterns`). The `--scan-secrets-validate` CLI flag is provider-wide: it also enables validation for the secret-scanning checks of other providers, such as the OpenStack metadata checks. + +To enable validation through the configuration file, set the value under the `aws` section: + +```yaml +aws: + secrets_validate: True +``` + +To enable validation for a single scan (any provider), use Prowler CLI: + +``` +prowler aws --scan-secrets-validate +``` + + +Secret validation makes outbound network calls that authenticate with each discovered secret. The credential is exercised against the provider, so the call appears in the audited account's logs and can trigger its monitoring (for example, AWS CloudTrail records the validation request). Validation stays disabled by default so that scans remain fully offline. + + + ## Azure ### Configurable Checks @@ -90,6 +129,7 @@ The following list includes all the Azure checks with configurable variables tha | `sqlserver_recommended_minimal_tls_version` | `recommended_minimal_tls_versions` | List of Strings | | `vm_sufficient_daily_backup_retention_period` | `vm_backup_min_daily_retention_days` | Integer | | `vm_desired_sku_size` | `desired_vm_sku_sizes` | List of Strings | +| `storage_smb_channel_encryption_with_secure_algorithm` | `recommended_smb_channel_encryption_algorithms` | List of Strings | | `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String | | `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float | | `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer | @@ -157,6 +197,16 @@ The following list includes all the Vercel checks with configurable variables th | `team_member_role_least_privilege` | `max_owners` | Integer | | `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer | +## Okta + +### Configurable Checks +The following list includes all the Okta checks with configurable variables that can be changed in the configuration YAML file: + +| Check Name | Value | Type | +|---------------------------------------------------------------|------------------------------------|---------| +| `application_admin_console_session_idle_timeout_15min` | `okta_admin_console_idle_timeout_max_minutes` | Integer | +| `signon_global_session_idle_timeout_15min` | `okta_max_session_idle_minutes` | Integer | + ## Config YAML File Structure @@ -186,6 +236,8 @@ aws: max_unused_access_keys_days: 45 # aws.iam_user_console_access_unused --> CIS recommends 45 days max_console_access_days: 45 + # aws.iam_user_access_not_stale_to_sagemaker --> default 90 days + max_unused_sagemaker_access_days: 90 # AWS EC2 Configuration # aws.ec2_elastic_ip_shodan @@ -522,6 +574,18 @@ azure: "1.3" ] + # Azure Storage + # azure.storage_smb_channel_encryption_with_secure_algorithm + # List of SMB channel encryption algorithms allowed on file shares. A storage + # account passes only if every enabled algorithm is in this list. Defaults to + # the value required by CIS (AES-256-GCM only, excluding weaker AES-128 ciphers). + recommended_smb_channel_encryption_algorithms: + [ + "AES-256-GCM", + # "AES-128-CCM", + # "AES-128-GCM", + ] + # Azure Virtual Machines # azure.vm_desired_sku_size # List of desired VM SKU sizes that are allowed in the organization diff --git a/docs/user-guide/cli/tutorials/pentesting.mdx b/docs/user-guide/cli/tutorials/pentesting.mdx index f59c9ccc2e..35d5b72be7 100644 --- a/docs/user-guide/cli/tutorials/pentesting.mdx +++ b/docs/user-guide/cli/tutorials/pentesting.mdx @@ -6,20 +6,33 @@ Prowler has some checks that analyse pentesting risks (Secrets, Internet Exposed ## Detect Secrets -Prowler uses `detect-secrets` library to search for any secrets that are stores in plaintext within your environment. +Prowler scans for secrets stored in plaintext within the audited environment using [Kingfisher](https://github.com/mongodb/kingfisher), an open-source secret-scanning engine. By default these scans run fully offline, so no data leaves the audited environment. Discovered secrets can optionally be validated against the provider APIs to confirm whether they are live — see [Validating Discovered Secrets](/user-guide/cli/tutorials/configuration_file#validating-discovered-secrets). -The actual checks that have this functionality are the following: +The checks with this functionality are the following. + +AWS: - autoscaling\_find\_secrets\_ec2\_launch\_configuration - awslambda\_function\_no\_secrets\_in\_code - awslambda\_function\_no\_secrets\_in\_variables - cloudformation\_stack\_outputs\_find\_secrets +- cloudwatch\_log\_group\_no\_secrets\_in\_logs +- codebuild\_project\_no\_secrets\_in\_variables - ec2\_instance\_secrets\_user\_data - ec2\_launch\_template\_no\_secrets - ecs\_task\_definitions\_no\_environment\_secrets +- glue\_etl\_jobs\_no\_secrets\_in\_arguments - ssm\_document\_secrets +- stepfunctions\_statemachine\_no\_secrets\_in\_definition -To execute detect-secrets related checks, you can run the following command: +OpenStack: + +- compute\_instance\_metadata\_sensitive\_data +- blockstorage\_volume\_metadata\_sensitive\_data +- blockstorage\_snapshot\_metadata\_sensitive\_data +- objectstorage\_container\_metadata\_sensitive\_data + +To execute the secret-scanning checks, run the following command: ```console prowler --categories secrets @@ -66,22 +79,38 @@ prowler --categories internet-exposed ### Shodan -Prowler allows you check if any public IPs in your Cloud environments are exposed in Shodan with the `-N`/`--shodan ` option: +Prowler can check whether any public IPs in cloud environments are exposed in Shodan using the `-N`/`--shodan` option. -For example, you can check if any of your AWS Elastic Compute Cloud (EC2) instances has an elastic IP exposed in Shodan: +#### Using the Environment Variable (Recommended) + +Set the `SHODAN_API_KEY` environment variable to avoid exposing the API key in process listings and shell history: ```console -prowler aws -N/--shodan -c ec2_elastic_ip_shodan +export SHODAN_API_KEY= ``` -Also, you can check if any of your Azure Subscription has an public IP exposed in Shodan: +Then run Prowler with the `--shodan` flag (no value needed): ```console -prowler azure -N/--shodan -c network_public_ip_shodan +prowler aws --shodan -c ec2_elastic_ip_shodan ``` -And finally, you can check if any of your GCP projects has an public IP address exposed in Shodan: - ```console -prowler gcp -N/--shodan -c compute_public_address_shodan +prowler azure --shodan -c network_public_ip_shodan ``` + +```console +prowler gcp --shodan -c compute_public_address_shodan +``` + +#### Using the CLI Flag + +Alternatively, pass the API key directly on the command line: + +```console +prowler aws --shodan -c ec2_elastic_ip_shodan +``` + + +Passing secret values directly on the command line exposes them in process listings and shell history. Prowler CLI displays a warning when this pattern is detected. Use the `SHODAN_API_KEY` environment variable instead. + diff --git a/docs/user-guide/cli/tutorials/prowler-check-kreator.mdx b/docs/user-guide/cli/tutorials/prowler-check-kreator.mdx deleted file mode 100644 index e6c708a212..0000000000 --- a/docs/user-guide/cli/tutorials/prowler-check-kreator.mdx +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: 'Prowler Check Kreator' ---- - - -Currently, this tool is only available for creating checks for the AWS provider. - - - -If you are looking for a way to create new checks for all the supported providers, you can use [Prowler Studio](https://github.com/prowler-cloud/prowler-studio), it is an AI-powered toolkit for generating and managing security checks for Prowler (better version of the Check Kreator). - - -## Introduction - -**Prowler Check Kreator** is a utility designed to streamline the creation of new checks for Prowler. This tool generates all necessary files required to add a new check to the Prowler repository. Specifically, it creates: - -- A dedicated folder for the check. -- The main check script. -- A metadata file with essential details. -- A folder and file structure for testing the check. - -## Usage - -To use the tool, execute the main script with the following command: - -```bash -python util/prowler_check_kreator/prowler_check_kreator.py -``` - -Parameters: - -- ``: Currently only AWS is supported. -- ``: The name you wish to assign to the new check. - -## AI integration - -This tool optionally integrates AI to assist in generating the check code and metadata file content. When AI assistance is chosen, the tool uses [Gemini](https://gemini.google.com/) to produce preliminary code and metadata. - - -For this feature to work, you must have the library `google-generativeai` installed in your Python environment. - - - -AI-generated code and metadata might contain errors or require adjustments to align with specific Prowler requirements. Carefully review all AI-generated content before committing. - - -To enable AI assistance, simply confirm when prompted by the tool. Additionally, ensure that the `GEMINI_API_KEY` environment variable is set with a valid Gemini API key. For instructions on obtaining your API key, refer to the [Gemini documentation](https://ai.google.dev/gemini-api/docs/api-key). diff --git a/docs/user-guide/cli/tutorials/reporting.mdx b/docs/user-guide/cli/tutorials/reporting.mdx index 65eec93263..19a14c9ae2 100644 --- a/docs/user-guide/cli/tutorials/reporting.mdx +++ b/docs/user-guide/cli/tutorials/reporting.mdx @@ -61,6 +61,7 @@ Prowler natively supports the following reporting output formats: - JSON-OCSF - JSON-ASFF (AWS only) - HTML +- SARIF (IaC only) Hereunder is the structure for each of the supported report formats by Prowler: @@ -368,6 +369,29 @@ Each finding is a `json` object within a list. The following image is an example of the HTML output: + +### SARIF (IaC Only) + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +The SARIF (Static Analysis Results Interchange Format) output generates a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) document compatible with GitHub Code Scanning and other SARIF-compatible tools. This format is exclusively available for the IaC provider, as it is designed for static analysis results that reference specific files and line numbers. + +```console +prowler iac --scan-repository-url https://github.com/user/repo -M sarif +``` + + +The SARIF output format is only available when using the `iac` provider. Attempting to use it with other providers results in an error. + + +The SARIF output includes: + +* **Rules:** Each unique check ID produces a rule entry with severity, description, remediation, and a markdown help panel. +* **Results:** Only failed (non-muted) findings are included, with file paths and line numbers for precise annotation. +* **Severity mapping:** Prowler severities map to SARIF levels (`critical`/`high` → `error`, `medium` → `warning`, `low`/`informational` → `note`). + ## V4 Deprecations Some deprecations have been made to unify formats and improve outputs. diff --git a/docs/user-guide/cli/tutorials/scan-unused-services.mdx b/docs/user-guide/cli/tutorials/scan-unused-services.mdx index 6d675e159f..ae0311cb97 100644 --- a/docs/user-guide/cli/tutorials/scan-unused-services.mdx +++ b/docs/user-guide/cli/tutorials/scan-unused-services.mdx @@ -18,9 +18,11 @@ prowler --scan-unused-services #### ACM (AWS Certificate Manager) -Certificates stored in ACM without active usage in AWS resources are excluded. By default, Prowler only scans actively used certificates. Unused certificates will not be checked if they are expired, if their expiring date is near or if they are good. +Certificates stored in ACM without active usage in AWS resources are excluded. By default, Prowler only scans actively used certificates. Unused certificates are not evaluated for expiration, transparency logging, or weak key algorithms. - `acm_certificates_expiration_check` +- `acm_certificates_transparency_logs_enabled` +- `acm_certificates_with_secure_key_algorithms` #### Athena @@ -28,6 +30,13 @@ Upon AWS account creation, Athena provisions a default primary workgroup for the - `athena_workgroup_encryption` - `athena_workgroup_enforce_configuration` +- `athena_workgroup_logging_enabled` + +#### Amazon Bedrock + +Generative AI workloads benefit from private VPC endpoint connectivity to keep prompt and model traffic off the public internet. Prowler only evaluates this configuration for VPCs in use (with active ENIs). + +- `bedrock_vpc_endpoints_configured` #### AWS CloudTrail @@ -38,15 +47,23 @@ AWS CloudTrail should have at least one trail with a data event to record all S3 #### AWS Elastic Compute Cloud (EC2) -If Amazon Elastic Block Store (EBS) default encyption is not enabled, sensitive data at rest will remain unprotected in EC2. However, Prowler will only generate a finding if EBS volumes exist where default encryption could be enforced. +If Amazon Elastic Block Store (EBS) default encryption is not enabled, sensitive data at rest remains unprotected in EC2. Prowler only generates a finding if EBS volumes exist where default encryption could be enforced. - `ec2_ebs_default_encryption` +**EBS Snapshot Public Access**: Public EBS snapshots can leak data. Prowler only evaluates the account-level block setting if EBS snapshots exist in the account. + +- `ec2_ebs_snapshot_account_block_public_access` + +**EC2 Instance Metadata Service (IMDS)**: Enforcing IMDSv2 at the account level mitigates SSRF-based credential theft. Prowler only evaluates the account-level setting if EC2 instances exist in the account. + +- `ec2_instance_account_imdsv2_enabled` + **Security Groups**: Misconfigured security groups increase the attack surface. Prowler scans only attached security groups to report vulnerabilities in actively used configurations. Applies to: -- 15 security group-related checks, including open ports and ingress/egress traffic rules. +- 20 security group-related checks, including open ports and ingress/egress traffic rules. - `ec2_securitygroup_allow_ingress_from_internet_to_port_X` - `ec2_securitygroup_default_restrict_traffic` @@ -56,6 +73,18 @@ Prowler scans only attached security groups to report vulnerabilities in activel - `ec2_networkacl_allow_ingress_X_port` +#### AWS Identity and Access Management (IAM) + +Customer-managed IAM policies that are not attached to any user, group, or role grant no effective permissions until a principal is bound to them. Prowler treats such policies as dormant by default and skips the content-evaluation checks below when `--scan-unused-services` is not set. Enable the flag to surface findings on unattached policies as well. + +- `iam_policy_allows_privilege_escalation` +- `iam_policy_no_full_access_to_cloudtrail` +- `iam_policy_no_full_access_to_kms` +- `iam_policy_no_wildcard_marketplace_subscribe` +- `iam_no_custom_policy_permissive_role_assumption` + +The dedicated `iam_customer_unattached_policy_no_administrative_privileges` check still inspects unattached policies regardless of the flag, since its purpose is to highlight dormant administrator privileges. + #### AWS Glue AWS Glue best practices recommend encrypting metadata and connection passwords in Data Catalogs. @@ -71,6 +100,12 @@ Amazon Inspector is a vulnerability discovery service that automates continuous - `inspector2_is_enabled` +#### AWS Key Management Service (KMS) + +Customer managed Customer Master Keys (CMKs) in the `Disabled` state cannot be used for cryptographic operations, so Prowler skips the unintentional-deletion check on them by default. Enable the flag to evaluate disabled CMKs as well. + +- `kms_cmk_not_deleted_unintentionally` + #### Amazon Macie Amazon Macie leverages machine learning to automatically discover, classify, and protect sensitive data in S3 buckets. Prowler only generates findings if Macie is disabled and there are S3 buckets in the AWS account. @@ -83,6 +118,15 @@ A network firewall is essential for monitoring and controlling traffic within a - `networkfirewall_in_all_vpc` +#### Amazon Relational Database Service (RDS) + +RDS event subscriptions notify operators of critical database events. Prowler only evaluates these subscription checks when RDS clusters or instances exist in the account. + +- `rds_cluster_critical_event_subscription` +- `rds_instance_critical_event_subscription` +- `rds_instance_event_subscription_parameter_groups` +- `rds_instance_event_subscription_security_groups` + #### Amazon S3 To prevent unintended data exposure: @@ -99,6 +143,10 @@ VPC settings directly impact network security and availability. - `vpc_flow_logs_enabled` +- VPC Endpoint for EC2: Routes EC2 API calls through a private VPC endpoint to keep traffic off the public internet. Prowler only evaluates this configuration for VPCs in use, i.e., those with active ENIs. + + - `vpc_endpoint_for_ec2_enabled` + - VPC Subnet Public IP Restrictions: Prevent unintended exposure of resources to the internet. Prowler only checks this configuration for VPCs in use, i.e., those with active ENIs. - `vpc_subnet_no_public_ip_by_default` diff --git a/docs/user-guide/compliance/tutorials/compliance.mdx b/docs/user-guide/compliance/tutorials/compliance.mdx new file mode 100644 index 0000000000..63b2502b25 --- /dev/null +++ b/docs/user-guide/compliance/tutorials/compliance.mdx @@ -0,0 +1,267 @@ +--- +title: 'Compliance' +description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud, Prowler App, and Prowler CLI.' +--- + +Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler App, or Prowler CLI. + +Out of the box, Prowler covers frameworks such as CIS Benchmarks, NIST 800-53, NIST CSF, NIS2, ENS RD2022, ISO 27001, PCI-DSS, SOC 2, GDPR, HIPAA, AWS Well-Architected, BSI C5, CSA CCM, MITRE ATT&CK, KISA ISMS-P, FedRAMP, and Prowler ThreatScore. The full catalog is available at [Prowler Hub](https://hub.prowler.com/compliance). + + +For the unified compliance score methodology used across frameworks, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore). + + + + + Review compliance posture using Prowler Cloud + + + Run compliance scans using Prowler CLI + + + +## Prowler Cloud + +The Compliance section in Prowler Cloud and Prowler App centralizes compliance posture across every connected provider. It aggregates scan results, surfaces Prowler ThreatScore, and exposes detailed requirement-level evidence for each supported framework. + +### Accessing the Compliance Section + +To open the compliance overview, follow these steps: + +1. Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) or to a self-hosted Prowler App instance. +2. Select **Compliance** from the left navigation. + +The page lists every framework evaluated by the most recent completed scan of the selected provider. + +Compliance overview page in Prowler Cloud and App showing filters, the Prowler ThreatScore card, and the framework grid + + +Compliance results require at least one completed scan. If no scan has finished yet, Prowler Cloud and App display a notice prompting to launch or wait for a scan to complete. + + +### Filtering Compliance Results + +The filters bar at the top of the overview controls which scan and which regions feed every card on the page. + +#### Scan Selector + +The scan selector lists completed scans across all connected providers. Each entry includes the provider type, alias, and completion timestamp. Selecting a scan updates the entire page, including ThreatScore and every framework card. + +#### Region Filter + +The region multi-select narrows results to one or more regions detected in the selected scan. Use it to evaluate compliance posture for a specific geography or account boundary. The filter applies to: + +* The framework grid scores and pass/fail counts. +* The detailed requirement view inside each framework. + + +Region filters apply only to providers that report a region attribute (for example, AWS, Azure, and Google Cloud). Providers without regions ignore the filter. + + +#### Clearing Filters + +Select **Clear filters** to reset both the region filter and any other applied filter to its default state. The scan selector is preserved. + +### Reviewing the Prowler ThreatScore Card + +When the selected scan includes Prowler ThreatScore data, a dedicated card appears at the top of the overview, showing: + +* The overall ThreatScore (0–100) with a color-coded indicator. +* A progress bar reflecting current posture. +* Per-pillar bars for IAM, Attack Surface, and Logging and Monitoring. + +Prowler ThreatScore badge on the Compliance overview showing the overall score and per-pillar bars + +Selecting the card opens the ThreatScore framework detail page, covered in [Working With the Framework Detail Page](#working-with-the-framework-detail-page). + +For a complete explanation of the methodology, formula, and weighting, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore). + +### Exploring the Framework Grid + +Below ThreatScore, the framework grid shows one card per supported compliance framework. Each card includes: + +* **Framework logo and name:** Identifies the standard (CIS, NIST, ENS, ISO 27001, PCI-DSS, SOC 2, NIS2, CSA CCM, MITRE ATT&CK, and more). +* **Version:** Indicates the framework version applied to the scan. +* **Score:** The percentage of passing requirements over the total evaluated. +* **Passing Requirements:** A `passed / total` counter for additional context. +* **Download dropdown:** Quick access to the CSV report and, when supported, the PDF report. + +Download dropdown on a framework card showing CSV and PDF report options + +Select any card to open the framework detail page. + + +Score color coding follows three thresholds: red for severely low compliance, amber for partial compliance, and green for healthy posture. Hover over the score for the exact percentage. + + +### Working With the Framework Detail Page + +The detail page provides everything needed to evaluate a single framework: aggregate metrics, top failure sections, and a requirement-by-requirement view. + +#### Header, Summary Cards, and Download Actions + +The header shows the framework name, version, the provider scan being reviewed, and CSV / PDF download buttons. Below the header, summary cards condense the framework state at a glance: + +* **Requirements Status:** Donut chart with `Pass`, `Fail`, and `Manual` counts plus the total number of requirements. +* **Top Failed Sections:** Ranks the sections or pillars with the highest number of failing requirements. +* **ThreatScore Breakdown:** Appears only on the ThreatScore framework. It shows the overall score and per-pillar scores aligned with the ThreatScore pillars (IAM, Attack Surface, Logging and Monitoring, Encryption). + +The same layout applies to every compliance framework. ThreatScore is the only framework that includes the extra Breakdown card on the left; for any other framework, the Requirements Status and Top Failed Sections cards span the full row. + +Prowler ThreatScore detail page including the extra Breakdown card alongside Requirements Status and Top Failed Sections + +CIS framework detail page showing only the Requirements Status donut and the Top Failed Sections card, without the ThreatScore Breakdown + +#### Requirements Accordion + +Below the summary cards, an accordion organizes every requirement of the framework. Expand a section to see: + +* **Requirement ID and title:** Reflect the official identifier from the framework. +* **Pass / Fail / Manual badges:** Indicate the status of each requirement based on the underlying checks. +* **Custom details panel:** Opens additional context tailored to the framework. For frameworks with custom layouts, the panel surfaces fields such as control objectives, severity, attack tactics, regulatory references, or required evidence. + +Select a requirement to open the detail panel and review the failing checks, the resources affected, and remediation guidance. + +Expanded CIS requirement showing description, rationale, remediation procedure, audit procedure, profile and assessment tags, references, and the underlying check + +##### Frameworks With Custom Detail Layouts + +Several frameworks include enriched detail panels that highlight fields specific to the standard: + +* ASD Essential Eight +* AWS Well-Architected Framework +* BSI C5 +* Cloud Controls Matrix (CSA CCM) +* CIS Benchmarks +* CCC (Common Cloud Controls) +* ENS RD2022 +* ISO 27001 +* KISA ISMS-P +* MITRE ATT&CK +* Prowler ThreatScore + +Frameworks without a custom layout fall back to the generic details panel, which still exposes the official requirement metadata captured by Prowler. + +### Downloading Compliance Reports + +Prowler Cloud and App expose two formats: + +* **CSV report:** Every requirement, every check, and every finding for the selected scan and filters. Available for all supported frameworks. +* **PDF report:** Curated executive-style report. Currently supported for Prowler ThreatScore, ENS RD2022, NIS2, and CSA CCM. Additional PDF reports are added in subsequent Prowler releases. + + +**PDF detail section is capped at the first 100 failed findings per check.** The PDF is intended as an executive/auditor document, not a raw data dump: when a check produces more than 100 failed findings the report renders the first 100 and shows a banner pointing the reader to the CSV or JSON-OCSF export for the complete list. The compliance CSV and the scan outputs are never truncated. + +The cap is configurable per deployment via the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` environment variable on the Prowler API workers; set it to `0` to disable truncation entirely. The default value of `100` keeps the PDF readable and bounded in size on enterprise-scale scans (hundreds of thousands of findings) without affecting smaller scans, where the cap is rarely reached. + +Only **failed** findings are rendered in the detail section. PASS findings for the same check are excluded at query time. The PDF surfaces what needs attention, and the CSV/JSON exports surface everything for forensic review. + + +#### Downloading From the Detail Page + +Inside any framework detail page, the **CSV** and **PDF** buttons in the header trigger the same downloads as the overview dropdown. The PDF button only appears for frameworks that support it. + +Top of a framework detail page showing the CSV and PDF download buttons in the header + + +Region filters disable the per-card download dropdown to avoid generating partial reports. Open the framework detail page when downloads scoped to a region are required, or remove the region filter to download the full report. + + +#### Downloading the Full Scan Output + +To export every framework, finding, and resource at once, use the **Scan Jobs** section instead. The ZIP archive contains the CSV, JSON-OCSF, and HTML reports plus a `compliance/` subfolder with one CSV per framework. See [Prowler App — Getting Started](/user-guide/tutorials/prowler-app) for details. + +### API Access + +Every report available in the UI is also reachable through the Prowler API. The following endpoints are the most relevant: + +* [Retrieve a scan compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve) +* [Download a complete scan output (ZIP)](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_report_retrieve) + +Use the API to integrate compliance evidence into ticketing systems, executive dashboards, or downstream pipelines. + +## Prowler CLI + +Prowler CLI evaluates the same compliance frameworks as Prowler Cloud and App, and produces detailed CSV outputs alongside the standard scan results. By default, it runs every supported framework and prints a status summary at the end of the scan: + + + +Detailed compliance results are stored as CSV files under the `compliance/` subfolder of Prowler's output directory. + +### Scan a Specific Compliance Framework + +To scope a scan to a single framework and get the framework-specific summary, use the `--compliance` option: + +```sh +prowler --compliance +``` + +Standard results plus the framework breakdown are printed to the terminal. A dedicated CSV is also generated under the `compliance/` output folder. Sample output for CIS AWS 2.0: + + + + +If Prowler cannot find a resource related with a check from a compliance requirement, that requirement is omitted from the output. + + +### List Available Compliance Frameworks + +To see which compliance frameworks are covered by a given provider, use the `--list-compliance` option: + +```sh +prowler --list-compliance +``` + +The full catalog is also browsable at [Prowler Hub](https://hub.prowler.com/compliance). + +### List Requirements of a Compliance Framework + +To inspect the requirements that compose a specific framework, use the `--list-compliance-requirements` option: + +```sh +prowler --list-compliance-requirements +``` + +Sample output for the first requirements of CIS 1.5 for AWS: + +``` +Listing CIS 1.5 AWS Compliance Requirements: + +Requirement Id: 1.1 + - Description: Maintain current contact details + - Checks: + account_maintain_current_contact_details + +Requirement Id: 1.2 + - Description: Ensure security contact information is registered + - Checks: + account_security_contact_information_is_registered + +Requirement Id: 1.3 + - Description: Ensure security questions are registered in the AWS account + - Checks: + account_security_questions_are_registered_in_the_aws_account + +Requirement Id: 1.4 + - Description: Ensure no 'root' user account access key exists + - Checks: + iam_no_root_access_key + +Requirement Id: 1.5 + - Description: Ensure MFA is enabled for the 'root' user account + - Checks: + iam_root_mfa_enabled + +[redacted] + +``` + +## Contributing New Compliance Frameworks + +To request a new framework or contribute one, see [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework). The developer guide covers the Pydantic schema, JSON catalog, output formatter, and PR submission steps required to ship a new framework end to end. + +## Related Documentation + +* [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore) +* [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework) +* [Prowler App — Getting Started](/user-guide/tutorials/prowler-app) diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx index 9dffd5049c..11295b1f29 100644 --- a/docs/user-guide/cookbooks/cicd-pipeline.mdx +++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx @@ -2,7 +2,11 @@ title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud' --- -This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). Examples cover GitHub Actions and GitLab CI. + +For new projects, use the official [Prowler GitHub Action](/user-guide/tutorials/prowler-app-github-action) — a Docker-based reusable action that runs scans, optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning. The GitHub Actions examples below document the legacy pip-based flow. + + +This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-import-findings). Examples cover GitHub Actions and GitLab CI. ## Prerequisites @@ -15,7 +19,7 @@ This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that Prowler CLI provides the `--push-to-cloud` flag, which uploads scan results directly to Prowler Cloud after a scan completes. Combined with the `PROWLER_CLOUD_API_KEY` environment variable, this enables fully automated ingestion without manual file uploads. -For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation. +For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-import-findings) documentation. The examples in this guide use AWS as the target provider, but the same approach applies to any provider supported by Prowler (Azure, GCP, Kubernetes, and others). Replace `prowler aws` with the desired provider command (e.g., `prowler gcp`, `prowler azure`) and configure the corresponding credentials in the CI/CD environment. @@ -123,7 +127,7 @@ Add the following to `.gitlab-ci.yml`: ```yaml prowler-scan: - image: python:3.12-slim + image: python:3.13-slim stage: test script: - pip install prowler @@ -150,7 +154,7 @@ stages: - security .prowler-base: - image: python:3.12-slim + image: python:3.13-slim stage: security before_script: - pip install prowler @@ -191,7 +195,7 @@ By default, Prowler exits with a non-zero code when it finds failing checks. Thi * **GitLab CI**: Add `allow_failure: true` to the job -Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for details. +Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-import-findings#troubleshooting) for details. ### Caching Prowler Installation diff --git a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx index 765bcf9313..661eeb17da 100644 --- a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx +++ b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx @@ -2,7 +2,7 @@ title: 'Run Kubernetes In-Cluster and Send Findings to Prowler Cloud' --- -This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads. +This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads. ## Prerequisites @@ -181,7 +181,7 @@ Once the job completes and findings are pushed: 2. Open the "Scans" section to verify the ingestion job status 3. Browse findings under the Kubernetes provider -For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation. +For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-import-findings) documentation. ## Tips and Troubleshooting @@ -204,4 +204,4 @@ For details on the ingestion workflow and status tracking, refer to the [Import --namespace prowler-ns ``` -* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for common error messages. +* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-import-findings#troubleshooting) for common error messages. diff --git a/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx b/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx new file mode 100644 index 0000000000..9b9e41ed93 --- /dev/null +++ b/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx @@ -0,0 +1,168 @@ +--- +title: "Visualize Multi-Cloud CIS Benchmarks With Power BI" +description: "Ingest Prowler compliance CSV exports into a ready-made Microsoft Power BI template that surfaces CIS Benchmark posture across AWS, Azure, Google Cloud, and Kubernetes." +--- + +The Multi-Cloud CIS Benchmarks Power BI template turns Prowler compliance CSV exports into an interactive dashboard. The template ingests scan results from Prowler CLI or Prowler Cloud and renders cross-provider CIS Benchmark coverage, profile-level breakdowns, regional drill-downs, and time-series trends. Center for Internet Security (CIS) Benchmarks are industry-standard configuration baselines maintained by CIS. + +The template and its source files live in the Prowler repository under [`contrib/PowerBI/Multicloud CIS Benchmarks`](https://github.com/prowler-cloud/prowler/tree/master/contrib/PowerBI/Multicloud%20CIS%20Benchmarks). + +Multi-Cloud CIS Benchmarks Power BI report cover showing aggregated compliance posture across providers + +## Prerequisites + +The setup requires the following components: + +* **Microsoft Power BI Desktop:** free download from Microsoft. +* **Prowler compliance CSV exports:** produced by Prowler CLI or downloaded from Prowler Cloud or Prowler App. +* **Local directory:** holds the CSV exports that the template ingests at load time. + +## Supported CIS Benchmarks + +The template ships with predefined mappings for the following CIS Benchmark versions. Exports must match these versions for the dashboard to populate correctly: + +| Compliance Framework | Version | +| ---------------------------------------------- | -------- | +| CIS Amazon Web Services Foundations Benchmark | v6.0 | +| CIS Microsoft Azure Foundations Benchmark | v5.0 | +| CIS Google Cloud Platform Foundation Benchmark | v4.0 | +| CIS Kubernetes Benchmark | v1.12.0 | + + +Other CIS Benchmark versions are not recognized by the template. Confirm the framework version before running the scan or downloading the export. + + +## Setup + +### Step 1: Install Microsoft Power BI Desktop + +Download and install Microsoft Power BI Desktop from the official Microsoft site. The template is opened with this application. + +### Step 2: Generate Compliance CSV Exports + +Compliance CSV exports can be generated through Prowler CLI or downloaded from Prowler Cloud and Prowler App. + +#### Option A: Prowler CLI + +Run a scan with the `--compliance` flag pointing to the appropriate CIS framework, for example: + +```sh +prowler aws --compliance cis_6.0_aws +prowler azure --compliance cis_5.0_azure +prowler gcp --compliance cis_4.0_gcp +prowler kubernetes --compliance cis_1.12_kubernetes +``` + +The compliance CSV exports are written to `output/compliance/` by default. + +#### Option B: Prowler Cloud or Prowler App + +Open the Compliance section, select the desired CIS Benchmark, and download the CSV export. + +Compliance section in Prowler Cloud showing the CSV download option for a CIS Benchmark scan + +### Step 3: Create a Local Directory for the Exports + +Place every CSV export in a single local directory. The template parses filenames to detect the provider, so filenames must keep the provider keyword (`aws`, `azure`, `gcp`, or `kubernetes`). + + +Time-series visualizations such as "Compliance Percent Over Time" require multiple scans from different dates in the same directory. + + +### Step 4: Open the Power BI Template + +Download the template file [`Prowler Multicloud CIS Benchmarks.pbit`](https://github.com/prowler-cloud/prowler/raw/master/contrib/PowerBI/Multicloud%20CIS%20Benchmarks/Prowler%20Multicloud%20CIS%20Benchmarks.pbit) and open it. Power BI Desktop prompts for the full filepath to the directory created in step 3. + +### Step 5: Provide the Directory Filepath + +Enter the absolute filepath without quotation marks. The Windows "copy as path" feature wraps the path in quotation marks automatically; remove them before submitting. + +### Step 6: Save the Report as a `.pbix` File + +Once the filepath is submitted, the template ingests the CSV exports and renders the report. Save the populated report as a `.pbix` file for future use. Re-running the `.pbit` template generates a fresh report against an updated directory. + +## Validation + +To confirm the CSV exports were ingested correctly, open the "Configuration" tab inside the report. + +Configuration tab in the Power BI report displaying loaded CIS Benchmarks, the Prowler CSV folder path, and the list of ingested exports + +The "Configuration" tab exposes three tables: + +* **Loaded CIS Benchmarks:** lists the benchmarks and versions supported by the template. This table is defined by the template itself and is not editable. All benchmarks remain listed regardless of which provider exports were supplied. +* **Prowler CSV Folder:** displays the absolute path provided during template load. +* **Loaded Prowler Exports:** lists every CSV file detected in the directory. A green checkmark identifies the file used as the latest assessment for each provider and benchmark combination. + +## Report Sections + +The report is organized into three navigable pages: + +| Report Page | Purpose | +| ----------- | ------------------------------------------------------------------------------------ | +| Overview | Aggregates CIS Benchmark posture across AWS, Azure, Google Cloud, and Kubernetes. | +| Benchmark | Focuses on a single CIS Benchmark with profile-level and regional filters. | +| Requirement | Drill-through page that surfaces details for a single benchmark requirement. | + +### Overview Page + +The Overview page summarizes CIS Benchmark posture across every supported provider. + +Overview page in the Power BI report aggregating CIS Benchmark posture across AWS, Azure, Google Cloud, and Kubernetes + +The Overview page contains the following components: + +| Component | Description | +| ---------------------------------------- | ---------------------------------------------------------------------------- | +| CIS Benchmark Overview | Table listing benchmark name, version, and overall compliance percentage. | +| Provider by Requirement Status | Bar chart breaking down requirements by status and provider. | +| Compliance Percent Heatmap | Heatmap of compliance percentage by benchmark and profile level. | +| Profile Level by Requirement Status | Bar chart breaking down requirements by status and profile level. | +| Compliance Percent Over Time by Provider | Line chart tracking overall compliance percentage over time by provider. | + +### Benchmark Page + +The Benchmark page focuses on a single CIS Benchmark. The benchmark, profile level, and region can be selected through dropdown filters. + +Benchmark page in the Power BI report showing region heatmap, section breakdown, time-series trend, and the requirements table + +The Benchmark page contains the following components: + +| Component | Description | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Compliance Percent Heatmap | Heatmap of compliance percentage by region and profile level. | +| Benchmark Section by Requirement Status | Bar chart of requirements grouped by benchmark section and status. | +| Compliance Percent Over Time by Region | Line chart tracking compliance percentage over time by region. | +| Benchmark Requirements | Table listing requirement section, requirement number, requirement title, number of resources tested, status, and failing checks. | + +### Requirement Page + +The Requirement page is a drill-through view that exposes the full context of a single requirement. To populate the page, right-click a row in the "Benchmark Requirements" table on the Benchmark page and select "Drill through" > "Requirement". + +Requirement drill-through page in the Power BI report showing rationale, remediation, regional breakdown, and the resource-level check results + +The Requirement page contains the following components: + +| Component | Description | +| ------------------------------------------ | -------------------------------------------------------------------------------------------- | +| Title | Requirement title. | +| Rationale | Rationale for the requirement. | +| Remediation | Remediation guidance for the requirement. | +| Region by Check Status | Bar chart of Prowler check results grouped by region and status. | +| Resource Checks for Benchmark Requirements | Table listing resource ID, resource name, status, description, and the underlying Prowler check. | + +## Walkthrough Video + +A full walkthrough is available on YouTube: + +[![Multi-Cloud CIS Benchmarks Power BI walkthrough video thumbnail](/images/powerbi/walkthrough-video-thumb.png)](https://www.youtube.com/watch?v=lfKFkTqBxjU) + +## Related Resources + + + + Review the Compliance workflow across Prowler Cloud, Prowler App, and Prowler CLI. + + + Explore the built-in local dashboard for Prowler CSV exports. + + diff --git a/docs/user-guide/providers/alibabacloud/authentication.mdx b/docs/user-guide/providers/alibabacloud/authentication.mdx index 1062e947b5..16cc46b95f 100644 --- a/docs/user-guide/providers/alibabacloud/authentication.mdx +++ b/docs/user-guide/providers/alibabacloud/authentication.mdx @@ -2,7 +2,7 @@ title: 'Alibaba Cloud Authentication in Prowler' --- -Prowler requires Alibaba Cloud credentials to perform security checks. Authentication is supported via multiple methods, prioritized as follows: +Prowler supports multiple Alibaba Cloud authentication flows. If more than one is configured at the same time, the provider resolves them in this order: 1. **Credentials URI** 2. **OIDC Role Authentication** @@ -12,119 +12,325 @@ Prowler requires Alibaba Cloud credentials to perform security checks. Authentic 6. **Permanent Access Keys** 7. **Default Credential Chain** -## Authentication Methods + +Do not use the AccessKey pair of the main Alibaba Cloud account for Prowler. Use a RAM user, a RAM role, or another temporary credential flow instead. + -### Credentials URI (Recommended for Centralized Services) +## Choose The Right Method -Prowler can retrieve credentials from an external URI endpoint. Provide the URI via the `--credentials-uri` flag or the `ALIBABA_CLOUD_CREDENTIALS_URI` environment variable. The URI must return credentials in the standard JSON format. +| Where Prowler runs | What you need to create | Recommended method | +| --- | --- | --- | +| Local workstation | RAM user + AccessKey pair | [RAM User And AccessKey](#ram-user-and-accesskey) | +| CI runner outside Alibaba Cloud | RAM user + AccessKey pair, optionally a target RAM role | [RAM Role Assumption](#ram-role-assumption-recommended) | +| ECS instance | ECS RAM role attached to the instance | [ECS RAM Role](#ecs-ram-role) | +| ACK / Kubernetes | OIDC IdP + RAM role + OIDC token file | [OIDC Role Authentication](#oidc-role-authentication) | +| Internal credential broker | An HTTP endpoint that returns STS credentials | [Credentials URI](#credentials-uri) | + +## RAM User And AccessKey + +This is the simplest setup for a workstation or a basic CI runner. + +### Create The RAM User + +1. Open the [RAM console](https://ram.console.alibabacloud.com/). +2. Go to `Identities` > `Users`. +3. Click `Create User`. +4. Enter a logon name and display name. +5. In `Access Configuration`, select `Permanent AccessKey`. + +![Create a RAM user and enable Permanent AccessKey](./img/create_user.png) + +6. Save the generated `AccessKey ID` and `AccessKey Secret` immediately. Alibaba Cloud only shows the secret once. +7. Grant the user the read permissions required for the Alibaba Cloud services you want Prowler to scan. + +![Grant permissions to the RAM user](./img/grant_permissions.png) + +Alibaba Cloud walkthroughs with current console screenshots: + +- [Create a RAM user](https://www.alibabacloud.com/help/en/ram/user-guide/create-a-ram-user) +- [Create an AccessKey pair](https://www.alibabacloud.com/help/en/ram/user-guide/create-an-accesskey-pair) +- [Grant permissions to a RAM user](https://www.alibabacloud.com/help/en/ram/user-guide/grant-permissions-to-the-ram-user) + +### Use The AccessKey With Prowler ```bash -# Using CLI flag -prowler alibabacloud --credentials-uri http://localhost:8080/credentials - -# Or using environment variable -export ALIBABA_CLOUD_CREDENTIALS_URI="http://localhost:8080/credentials" -prowler alibabacloud -``` - -### OIDC Role Authentication (Recommended for ACK/Kubernetes) - -OIDC authentication assumes the specified role using an OIDC token. This is the most secure method for containerized applications running in ACK (Alibaba Container Service for Kubernetes) with RRSA enabled. - -The role ARN can be provided via the `--oidc-role-arn` flag or the `ALIBABA_CLOUD_ROLE_ARN` environment variable. The OIDC provider ARN and token file must be set via environment variables: - -- `ALIBABA_CLOUD_OIDC_PROVIDER_ARN` -- `ALIBABA_CLOUD_OIDC_TOKEN_FILE` - -```bash -# Using CLI flag for role ARN -export ALIBABA_CLOUD_OIDC_PROVIDER_ARN="acs:ram::123456789012:oidc-provider/ack-rrsa-provider" -export ALIBABA_CLOUD_OIDC_TOKEN_FILE="/var/run/secrets/tokens/oidc-token" -prowler alibabacloud --oidc-role-arn acs:ram::123456789012:role/YourRole - -# Or using all environment variables -export ALIBABA_CLOUD_ROLE_ARN="acs:ram::123456789012:role/YourRole" -export ALIBABA_CLOUD_OIDC_PROVIDER_ARN="acs:ram::123456789012:oidc-provider/ack-rrsa-provider" -export ALIBABA_CLOUD_OIDC_TOKEN_FILE="/var/run/secrets/tokens/oidc-token" -prowler alibabacloud -``` - -### ECS RAM Role (Recommended for ECS Instances) - -When running on an ECS instance with an attached RAM role, Prowler can obtain credentials from the ECS instance metadata service. - -```bash -# Using CLI argument -prowler alibabacloud --ecs-ram-role RoleName - -# Or using environment variable -export ALIBABA_CLOUD_ECS_METADATA="RoleName" -prowler alibabacloud -``` - -### RAM Role Assumption (Recommended for Cross-Account) - -For cross-account access, use RAM role assumption. Provide the initial credentials (access keys) via environment variables and the target role ARN via the `--role-arn` flag or the `ALIBABA_CLOUD_ROLE_ARN` environment variable. - -The `--role-session-name` flag customizes the session identifier (defaults to `ProwlerAssessmentSession`). - -```bash -# Using CLI flags export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id" export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" -prowler alibabacloud --role-arn acs:ram::123456789012:role/ProwlerAuditRole --role-session-name MyAuditSession -# Or using all environment variables -export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id" -export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" -export ALIBABA_CLOUD_ROLE_ARN="acs:ram::123456789012:role/ProwlerAuditRole" prowler alibabacloud ``` -### STS Temporary Credentials +Prowler also accepts `ALIYUN_ACCESS_KEY_ID` and `ALIYUN_ACCESS_KEY_SECRET` for compatibility, but `ALIBABA_CLOUD_*` is the preferred naming. -If you already have temporary STS credentials, you can provide them via environment variables. +### Use The Default Credential Chain + +If you prefer not to export credentials in every shell, you can store them with the Alibaba Cloud CLI and let Prowler reuse the default credential chain from `~/.aliyun/config.json`. + +```bash +aliyun configure --mode AK + +prowler alibabacloud +``` + +For profile management details, see Alibaba Cloud's [CLI credential management guide](https://www.alibabacloud.com/help/en/cli/other-configure-command-operations). + +## RAM Role Assumption (Recommended) + +Use this when: + +- you want short-lived credentials instead of long-lived AccessKeys in Prowler, +- you are scanning another Alibaba Cloud account, or +- you are configuring Alibaba Cloud in Prowler Cloud and want to provide a `Role ARN`. + +This flow has two parts: + +1. A source identity that can call `sts:AssumeRole`. +2. A target RAM role that has the scan permissions. + +### Create The Source Identity + +Create a RAM user with an AccessKey pair by following the steps in [RAM User And AccessKey](#ram-user-and-accesskey), or reuse an existing automation identity. + +### Create The Target Role + +1. Open the [RAM console](https://ram.console.alibabacloud.com/). +2. Go to `Identities` > `Roles`. +3. Click `Create Role`. +4. Set `Principal Type` to `Cloud Account`. +5. Choose: + - `Current Account` if the RAM user and the role are in the same account. + - `Other Account` if the RAM user belongs to a different Alibaba Cloud account. +6. Give the role a name such as `ProwlerAuditRole`. +7. Attach the scan permissions to the role. +8. Copy the role ARN in the format `acs:ram:::role/`. + +If you want to restrict the role so that only one RAM user or one RAM role can assume it, edit the trust policy accordingly. + +Helpful references: + +- [Create a RAM role for a trusted Alibaba Cloud account](https://www.alibabacloud.com/help/en/ram/user-guide/create-a-ram-role-for-a-trusted-alibaba-cloud-account) +- [Assume a RAM role](https://www.alibabacloud.com/help/doc-detail/116820.html) + +### Allow The Source Identity To Assume The Role + +The source RAM user must be able to call `sts:AssumeRole`. + +The easiest starting point is to attach Alibaba Cloud's `AliyunSTSAssumeRoleAccess` policy to that RAM user. If you want tighter scope, attach a custom policy limited to the target role ARN. + +### Run Prowler + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID="source-user-access-key-id" +export ALIBABA_CLOUD_ACCESS_KEY_SECRET="source-user-access-key-secret" + +prowler alibabacloud \ + --role-arn acs:ram::123456789012:role/ProwlerAuditRole \ + --role-session-name ProwlerAssessmentSession +``` + +You can also set the role ARN with `ALIBABA_CLOUD_ROLE_ARN`, but the source AccessKey pair is still required for this flow. + +## STS Temporary Credentials + +Use this if another tool already gives you a temporary `AccessKey ID`, `AccessKey Secret`, and `SecurityToken`. + +This is common when: + +- a CI platform brokers Alibaba credentials for the job, +- your internal tooling already calls `AssumeRole`, or +- you want to test with a short-lived session before switching to a RAM role flow. ```bash export ALIBABA_CLOUD_ACCESS_KEY_ID="your-sts-access-key-id" export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-sts-access-key-secret" export ALIBABA_CLOUD_SECURITY_TOKEN="your-sts-security-token" + prowler alibabacloud ``` -### Permanent Access Keys - -You can use standard permanent access keys via environment variables. +You can also store the session in the Alibaba CLI configuration: ```bash -export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id" -export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" +aliyun configure --mode StsToken + prowler alibabacloud ``` -## Required Permissions + +Prowler does not mint standalone STS sessions for you. If you use this method, you must provide all three STS values from your external workflow. + -The credentials used by Prowler should have the minimum required permissions to audit the resources. At a minimum, the following permissions are recommended: +## ECS RAM Role -- `ram:GetUser` -- `ram:ListUsers` -- `ram:GetPasswordPolicy` -- `ram:GetAccountSummary` -- `ram:ListVirtualMFADevices` -- `ram:ListGroups` -- `ram:ListPolicies` -- `ram:ListAccessKeys` -- `ram:GetLoginProfile` -- `ram:ListPoliciesForUser` -- `ram:ListGroupsForUser` -- `actiontrail:DescribeTrails` -- `oss:GetBucketLogging` -- `oss:GetBucketAcl` -- `rds:DescribeDBInstances` -- `rds:DescribeDBInstanceAttribute` -- `ecs:DescribeInstances` -- `vpc:DescribeVpcs` -- `sls:ListProject` -- `sls:ListAlerts` -- `sls:ListLogStores` -- `sls:GetLogStore` +Use this when Prowler runs on an ECS instance and you do not want to store any AccessKeys on disk. + +### Create And Attach The Role + +1. Open the [RAM console](https://ram.console.alibabacloud.com/). +2. Go to `Identities` > `Roles`. +3. Click `Create Role`. +4. Set the trusted entity to `Alibaba Cloud Service`. +5. Select `ECS` as the trusted service. +6. Attach the read permissions required for the scan. +7. Attach that RAM role to the ECS instance that runs Prowler. + +Alibaba Cloud guide: + +- [Instance RAM roles](https://www.alibabacloud.com/help/en/doc-detail/54579.html) + +### Run Prowler + +```bash +prowler alibabacloud --ecs-ram-role ProwlerEcsRole +``` + +Or: + +```bash +export ALIBABA_CLOUD_ECS_METADATA="ProwlerEcsRole" + +prowler alibabacloud +``` + +## OIDC Role Authentication + +Use this when Prowler runs in ACK or another Kubernetes environment that provides an OIDC token file. + +### Create The OIDC Identity Provider + +1. Open the [RAM console](https://ram.console.alibabacloud.com/). +2. Go to `Integrations` > `SSO`. +3. Select `Role-based SSO`, then the `OIDC` tab. +4. Click `Create IdP`. +5. Fill in: + - `IdP Name` + - `Issuer URL` + - `Fingerprint` + - `Client ID` +6. Create the IdP and note its ARN. + +Alibaba Cloud guides: + +- [Manage an OIDC IdP](https://www.alibabacloud.com/help/en/ram/manage-an-oidc-idp) +- [Overview of role-based OIDC SSO](https://www.alibabacloud.com/help/en/ram/overview-of-oidc-based-sso) + +### Create The RAM Role Trusted By That IdP + +Create a RAM role whose trusted entity is the OIDC IdP, then attach the scan permissions to that role. + +If you are running in ACK with RRSA, this is typically the role bound to the service account that runs Prowler. + +### Provide The OIDC Variables To Prowler + +Prowler currently expects: + +- `--oidc-role-arn` for the RAM role ARN, +- `ALIBABA_CLOUD_OIDC_PROVIDER_ARN` for the OIDC provider ARN, +- `ALIBABA_CLOUD_OIDC_TOKEN_FILE` for the token file path. + +Example: + +```bash +export ALIBABA_CLOUD_OIDC_PROVIDER_ARN="acs:ram::123456789012:oidc-provider/ack-rrsa-provider" +export ALIBABA_CLOUD_OIDC_TOKEN_FILE="/var/run/secrets/ack.alibabacloud.com/rrsa-tokens/token" + +prowler alibabacloud --oidc-role-arn acs:ram::123456789012:role/ProwlerAckRole +``` + +If you use ACK RRSA, Alibaba's `ack-pod-identity-webhook` can inject the three required environment variables and mount the token file into the pod automatically: + +- [ack-pod-identity-webhook](https://www.alibabacloud.com/help/en/cs/user-guide/ack-pod-identity-webhook) +- [Use RRSA to authorize different pods to access different cloud services](https://www.alibabacloud.com/help/doc-detail/356611.html) + + +Even if your pod already exposes `ALIBABA_CLOUD_ROLE_ARN`, use `--oidc-role-arn` with Prowler. The provider currently reads the role ARN for OIDC from the CLI argument. + + +## Credentials URI + +Use this only if you already operate an internal credential broker that returns temporary Alibaba Cloud credentials over HTTP. + +The endpoint must return a JSON body with this structure: + +```json +{ + "Code": "Success", + "AccessKeyId": "STS.xxxxx", + "AccessKeySecret": "xxxxx", + "SecurityToken": "xxxxx", + "Expiration": "2026-04-23T10:00:00Z" +} +``` + +Run Prowler with: + +```bash +prowler alibabacloud --credentials-uri http://localhost:8080/credentials +``` + +Or: + +```bash +export ALIBABA_CLOUD_CREDENTIALS_URI="http://localhost:8080/credentials" + +prowler alibabacloud +``` + +For the expected response format, see Alibaba Cloud's SDK guide for [URI credentials](https://www.alibabacloud.com/help/en/sdk/developer-reference/v2-manage-access-credentials). + +## Permissions Guidance + +The exact minimum policy depends on the checks and services you enable. + +If you are using the RAM console's `Grant Permission` screen, search for the **system policy names** below. Alibaba Cloud often uses product policy names that differ from the service name shown in Prowler. + +### System Policies In The RAM Console + +| Prowler use case | Policy name in RAM console | Notes | +| --- | --- | --- | +| Source user for `--role-arn` | `AliyunSTSAssumeRoleAccess` | Grants `sts:AssumeRole` so the source identity can assume the scan role. | +| RAM checks | `AliyunRAMReadOnlyAccess` | Covers RAM read APIs such as users, groups, policies, MFA devices, and account alias. | +| ECS checks | `AliyunECSReadOnlyAccess` | Read-only ECS access. | +| VPC checks | `AliyunVPCReadOnlyAccess` | Read-only VPC access. | +| OSS checks | `AliyunOSSReadOnlyAccess` | Read-only OSS access. | +| ActionTrail checks | `AliyunActionTrailReadOnlyAccess` | Read-only ActionTrail access. | +| SLS checks | `AliyunLogReadOnlyAccess` | In the RAM console, Simple Log Service appears as `Log`. | +| RDS checks | `AliyunRDSReadOnlyAccess` | Read-only RDS access. | +| ACK / Container Service checks | `AliyunCSReadOnlyAccess` | In the RAM console, ACK permissions appear under `CS`. | +| Security Center checks | `AliyunYundunSASReadOnlyAccess` | In the RAM console, Security Center appears under `Yundun SAS`. | + +### Recommended Starting Point + +For a broad Alibaba Cloud scan, the identity used by Prowler usually needs read access to the services Prowler currently audits, including: + +- `RAM` +- `ECS` +- `VPC` +- `OSS` +- `ActionTrail` +- `Simple Log Service (SLS)` +- `RDS` +- `Container Service / ACK` +- `Security Center` + +Use the following setup as a practical starting point: + +- If you use **static AccessKeys**, attach the read-only policies above directly to the RAM user used by Prowler. +- If you use **RAM role assumption**, attach `AliyunSTSAssumeRoleAccess` to the source RAM user and attach the read-only policies above to the target scan role. +- If you use **ECS RAM role** or **OIDC/RRSA**, attach the read-only policies above to the role assumed by Prowler. + +If you prefer a tighter custom policy instead of system policies, the current provider relies on read APIs such as: + +- `ram:Get*`, `ram:List*` +- `ecs:Describe*` +- `vpc:Describe*` +- `oss:Get*`, `oss:List*` +- `actiontrail:Describe*` +- `log:Get*`, `log:List*`, `log:Query*` +- `rds:Describe*` +- `cs:Get*`, `cs:List*`, `cs:Describe*` +- `yundun-sas:Get*`, `yundun-sas:Describe*`, `yundun-sas:List*` + + +If a service is denied, Prowler can still start, but checks for that service may fail or return incomplete results. + diff --git a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx index 0a1d54b079..36520a8f8c 100644 --- a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx +++ b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx @@ -12,9 +12,9 @@ Before you begin, make sure you have: 1. An **Alibaba Cloud Account ID** (visible in the Alibaba Cloud Console under your profile). 2. **Credentials** with appropriate permissions: - - **RAM User with Access Keys**: For static credential authentication. - - **RAM Role**: For cross-account access using role assumption (recommended). -3. The required permissions for Prowler to audit your resources. See the [Alibaba Cloud Authentication](/user-guide/providers/alibabacloud/authentication) guide for the full list of required permissions. + - **RAM User with Access Keys**: For local CLI usage or simple CI setups. See [RAM User and AccessKey](/user-guide/providers/alibabacloud/authentication#ram-user-and-accesskey). + - **RAM Role**: For role assumption and Prowler Cloud onboarding. See [RAM Role Assumption](/user-guide/providers/alibabacloud/authentication#ram-role-assumption-recommended). +3. The required permissions for Prowler to audit your resources. See the [Alibaba Cloud Authentication](/user-guide/providers/alibabacloud/authentication) guide for setup steps and permission guidance. @@ -37,16 +37,16 @@ Before you begin, make sure you have: ![Get Account ID](/images/providers/alibaba-account-id.png) -### Step 2: Access Prowler Cloud or Prowler App +### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Alibaba Cloud" @@ -64,7 +64,7 @@ After the Account ID is in place, select the authentication method that matches #### RAM Role Assumption (Recommended) -Use this method for secure cross-account access. For detailed instructions on how to create the RAM role, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#ram-role-assumption-recommended-for-cross-account). +Use this method for secure cross-account access. For detailed instructions on how to create the RAM role, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#ram-role-assumption-recommended). 1. Enter the **Role ARN** (format: `acs:ram:::role/`) 2. Enter the **Access Key ID** and **Access Key Secret** of the RAM user that will assume the role @@ -77,7 +77,7 @@ The RAM user whose credentials you provide must have permission to assume the ta #### Credentials (Static Access Keys) -Use static credentials for quick scans (not recommended for production). For detailed setup, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#permanent-access-keys). +Use static credentials for quick scans (not recommended for production). For detailed setup, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#ram-user-and-accesskey). 1. Enter the **Access Key ID** and **Access Key Secret** @@ -104,7 +104,7 @@ You can also run Alibaba Cloud assessments directly from the CLI. Both command-l ### Step 1: Select an Authentication Method -Choose one of the following authentication methods. For the complete list and detailed configuration, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication). +Choose one of the following authentication methods. For step-by-step credential creation and the full list of supported authentication modes, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication). #### Environment Variables @@ -114,6 +114,13 @@ export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" prowler alibabacloud ``` +#### Default Credential Chain + +```bash +aliyun configure --mode AK +prowler alibabacloud +``` + #### RAM Role Assumption ```bash @@ -146,7 +153,7 @@ prowler alibabacloud #### Scan specific regions ```bash -prowler alibabacloud --regions cn-hangzhou cn-shanghai +prowler alibabacloud --region cn-hangzhou cn-shanghai ``` #### Run specific checks diff --git a/docs/user-guide/providers/alibabacloud/img/create_user.png b/docs/user-guide/providers/alibabacloud/img/create_user.png new file mode 100644 index 0000000000..fee21eab6f Binary files /dev/null and b/docs/user-guide/providers/alibabacloud/img/create_user.png differ diff --git a/docs/user-guide/providers/alibabacloud/img/grant_permissions.png b/docs/user-guide/providers/alibabacloud/img/grant_permissions.png new file mode 100644 index 0000000000..1001e26acd Binary files /dev/null and b/docs/user-guide/providers/alibabacloud/img/grant_permissions.png differ diff --git a/docs/user-guide/providers/aws/authentication.mdx b/docs/user-guide/providers/aws/authentication.mdx index b919cde8ec..22deb6ab99 100644 --- a/docs/user-guide/providers/aws/authentication.mdx +++ b/docs/user-guide/providers/aws/authentication.mdx @@ -7,6 +7,11 @@ Prowler requires AWS credentials to function properly. Authentication is availab - Static Credentials - Assumed Role +When using **Assumed Role**, the Prowler UI exposes two credential sources for calling `sts:AssumeRole`. The labels differ between Prowler Cloud and self-hosted Prowler App, but both map to the same underlying credential types: + +- **AWS SDK Default** (shown as *"Prowler Cloud will assume your IAM role"* in Prowler Cloud and *"AWS SDK Default"* in self-hosted Prowler App): Prowler uses the credentials already available to the API and worker containers through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). This is the default in Prowler Cloud and requires extra configuration in self-hosted Prowler App (see [Configuring AWS SDK Default for Self-Hosted Prowler App](#configuring-aws-sdk-default-for-self-hosted-prowler-app)). +- **Access & Secret Key**: You paste an IAM user's `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optionally `AWS_SESSION_TOKEN` into the form. Prowler uses those keys to call `sts:AssumeRole`. + ## Required Permissions To ensure full functionality, attach the following AWS managed policies to the designated user or role: @@ -76,6 +81,68 @@ This method grants permanent access and is the recommended setup for production --- +## Configuring AWS SDK Default for Self-Hosted Prowler App + +When self-hosting Prowler App with Docker Compose, the API and worker containers do not have AWS credentials by default. Selecting **AWS SDK Default** without configuring those credentials produces: + +``` +AWSAssumeRoleError[1012]: AWS assume role error - An error occurred (InvalidClientTokenId) when calling the AssumeRole operation: The security token included in the request is invalid. +``` + +To fix this, expose an IAM identity with `sts:AssumeRole` permission on the target role to both the `api` and `worker` services. + +### Option 1: Environment Variables in `.env` + +Add the following keys to the `.env` file used by `docker-compose.yml`: + +```bash +AWS_ACCESS_KEY_ID="" +AWS_SECRET_ACCESS_KEY="" +AWS_SESSION_TOKEN="" +AWS_DEFAULT_REGION="us-east-1" +``` + +The existing `docker-compose.yml` already loads `.env` into the `api`, `worker`, and `worker-beat` services, so `boto3` will pick them up through the default credential chain. + + +Treat the `.env` file as a secret. Do not commit it to version control, scope the IAM identity to the minimum permissions required (`sts:AssumeRole` on the target `ProwlerScan` role only), prefer short-lived credentials over long-lived access keys, and rotate the keys immediately if you suspect exposure. + + +Recreate the containers to apply the change. A plain `docker compose restart` will **not** reload values from a modified `.env` file — you must force-recreate: + +```bash +docker compose up -d --force-recreate api worker worker-beat +``` + +### Option 2: IAM Role (Host with Instance Metadata) + +If you run Prowler App on an EC2 instance, ECS task, or EKS pod with an attached IAM role that can assume the scan role, no extra configuration is needed — `boto3` resolves credentials through instance or task metadata automatically. + +### Trust Policy: Align `IAMPrincipal` With Your Identity + +The [Prowler scan role CloudFormation template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml) restricts the trust policy with: + +``` +aws:PrincipalArn StringLike arn:aws:iam::: +``` + +`IAMPrincipal` defaults to `role/prowler*`, which only allows IAM roles whose name starts with `prowler`. If the identity hosting the API and worker containers is anything else, the `sts:AssumeRole` call fails with `AccessDenied` even when the credentials themselves are valid. + +Redeploy (or update) the CloudFormation stack with an `IAMPrincipal` that matches your identity: + +| Your identity on the API/worker containers | `IAMPrincipal` value | +| --- | --- | +| IAM user (for example `prowler-app`) | `user/prowler-app` | +| IAM role whose name doesn't start with `prowler` | `role/` | + +`AccountId` must also point to the account where that identity lives — the default is Prowler Cloud's account and only applies when assuming from Prowler Cloud. + + +The same `External ID` entered in the Prowler UI must match the `ExternalId` parameter used when deploying the CloudFormation stack. A mismatch produces `AccessDenied` on `sts:AssumeRole`, not `InvalidClientTokenId`. + + +--- + ## Credentials diff --git a/docs/user-guide/providers/aws/cloudshell.mdx b/docs/user-guide/providers/aws/cloudshell.mdx index 7374fb1fbf..3c45cd1b2b 100644 --- a/docs/user-guide/providers/aws/cloudshell.mdx +++ b/docs/user-guide/providers/aws/cloudshell.mdx @@ -27,7 +27,7 @@ To download results from AWS CloudShell: ## Cloning Prowler from GitHub -Due to the limited storage in AWS CloudShell's home directory, installing Poetry dependencies for running Prowler from GitHub can be problematic. +Due to the limited storage in AWS CloudShell's home directory, installing uv dependencies for running Prowler from GitHub can be problematic. The following workaround ensures successful installation: @@ -37,17 +37,9 @@ adduser prowler su prowler git clone https://github.com/prowler-cloud/prowler.git cd prowler -pip install poetry -mkdir /tmp/poetry -poetry config cache-dir /tmp/poetry -eval $(poetry env activate) -poetry install +pip install uv +mkdir /tmp/uv-cache +UV_CACHE_DIR=/tmp/uv-cache uv sync +source .venv/bin/activate python prowler-cli.py -v ``` - - -Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`. - -If your Poetry version is below v2.0.0, continue using `poetry shell` to activate your environment. For further guidance, refer to the Poetry Environment Activation Guide https://python-poetry.org/docs/managing-environments/#activating-the-environment. - - diff --git a/docs/user-guide/providers/aws/getting-started-aws.mdx b/docs/user-guide/providers/aws/getting-started-aws.mdx index 7d003d9821..f0c5ab882a 100644 --- a/docs/user-guide/providers/aws/getting-started-aws.mdx +++ b/docs/user-guide/providers/aws/getting-started-aws.mdx @@ -2,7 +2,7 @@ title: 'Getting Started With AWS on Prowler' --- -## Prowler App +## Prowler Cloud @@ -16,16 +16,16 @@ title: 'Getting Started With AWS on Prowler' ![Account ID detail](/images/providers/aws-account-id.png) -### Step 2: Access Prowler Cloud or Prowler App +### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Amazon Web Services" @@ -46,15 +46,15 @@ Before proceeding, choose the preferred authentication mode: **Credentials** -* Quick scan as current user -* No extra setup -* Credentials time out +* Quick scan using an IAM user's access keys +* No extra setup in AWS +* Static keys can be rotated or revoked at any time **Assumed Role** -* Preferred Setup -* Permanent Credentials -* Requires access to create role +* Recommended for production +* With AWS SDK Default as the credential source, no long-lived keys are stored in Prowler (Access & Secret Key still requires pasted keys) +* Requires permission to create an IAM role in the target account --- @@ -67,18 +67,23 @@ This method grants permanent access and is the recommended setup for production For detailed instructions on how to create the role, see [Authentication > Assume Role](/user-guide/providers/aws/authentication#assume-role-recommended). -8. Once the role is created, go to the **IAM Console**, click on the "ProwlerScan" role to open its details: +7. Once the role is created, go to the **IAM Console**, click on the "ProwlerScan" role to open its details: ![ProwlerScan role info](/images/providers/prowler-scan-pre-info.png) -9. Copy the **Role ARN** +8. Copy the **Role ARN** ![New Role Info](/images/providers/get-role-arn.png) -10. Paste the ARN into the corresponding field in Prowler Cloud or Prowler App +9. Paste the ARN into the corresponding field in Prowler Cloud or Prowler App ![Input the Role ARN](/images/providers/paste-role-arn-prowler.png) +10. Select the credential source Prowler should use to call `sts:AssumeRole`. The option label differs between deployments but both map to the same `aws-sdk-default` credential type: + + - **"Prowler Cloud will assume your IAM role"** (default in Prowler Cloud) / **"AWS SDK Default"** (in self-hosted Prowler App): Prowler uses the credentials available in the API and worker environment through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). In self-hosted Prowler App, these containers have no AWS credentials by default — see [Configuring AWS SDK Default for Self-Hosted Prowler App](/user-guide/providers/aws/authentication#configuring-aws-sdk-default-for-self-hosted-prowler-app) before choosing this option, or the connection test will fail with `InvalidClientTokenId`. + - **Access & Secret Key**: Paste an IAM user's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (and optional `AWS_SESSION_TOKEN`) into the form. The IAM principal must be allowed to assume the target role and must match the `IAMPrincipal` parameter of the scan role template (default: `role/prowler*`). + 11. Click "Next", then "Launch Scan" ![Next button in Prowler Cloud](/images/providers/next-button-prowler-cloud.png) diff --git a/docs/user-guide/providers/aws/regions-and-partitions.mdx b/docs/user-guide/providers/aws/regions-and-partitions.mdx index 02ae8fe6ab..377612013e 100644 --- a/docs/user-guide/providers/aws/regions-and-partitions.mdx +++ b/docs/user-guide/providers/aws/regions-and-partitions.mdx @@ -33,6 +33,41 @@ To scan a particular AWS region with Prowler, use: prowler aws -f/--region eu-west-1 us-east-1 ``` +### Excluding Specific Regions + +To scan all supported AWS regions except a specific subset, use the `--excluded-region` flag: + +```console +prowler aws --excluded-region eu-west-1 me-south-1 +``` + +You can also configure the exclusion list with the `PROWLER_AWS_DISALLOWED_REGIONS` environment variable as a comma-separated list: + +```console +export PROWLER_AWS_DISALLOWED_REGIONS="eu-west-1,me-south-1" +prowler aws +``` + +Or with the AWS provider configuration in `config.yaml`: + +```yaml +aws: + disallowed_regions: + - eu-west-1 + - me-south-1 +``` + +When more than one source is set, precedence is: + +1. `--excluded-region` +2. `PROWLER_AWS_DISALLOWED_REGIONS` +3. `aws.disallowed_regions` in `config.yaml` + + +For self-hosted App or API-triggered scans, set `PROWLER_AWS_DISALLOWED_REGIONS` in the runtime environment of the backend scan containers such as `api` and `worker`. The `ui` container does not enforce AWS region selection. + + + ### AWS Credentials Configuration For details on configuring AWS credentials, refer to the following [Botocore](https://github.com/boto/botocore) [file](https://github.com/boto/botocore/blob/22a19ea7c4c2c4dd7df4ab8c32733cba0c7597a4/botocore/data/partitions.json). diff --git a/docs/user-guide/providers/aws/v2_to_v3_checks_mapping.mdx b/docs/user-guide/providers/aws/v2_to_v3_checks_mapping.mdx index 8937465cf7..82fbcea789 100644 --- a/docs/user-guide/providers/aws/v2_to_v3_checks_mapping.mdx +++ b/docs/user-guide/providers/aws/v2_to_v3_checks_mapping.mdx @@ -4,7 +4,7 @@ title: 'Check Mapping Prowler v4/v3 to v2' Prowler v3 and v4 introduce distinct identifiers while preserving the checks originally implemented in v2. This change was made because, in previous versions, check names were primarily derived from the CIS Benchmark for AWS. Starting with v3 and v4, all checks are independent of any security framework and have unique names and IDs. -For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/cli/tutorials/compliance) section. +For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/compliance/tutorials/compliance) section. ``` checks_v4_v3_to_v2_mapping = { diff --git a/docs/user-guide/providers/azure/getting-started-azure.mdx b/docs/user-guide/providers/azure/getting-started-azure.mdx index a5299c614b..66b3b14e3a 100644 --- a/docs/user-guide/providers/azure/getting-started-azure.mdx +++ b/docs/user-guide/providers/azure/getting-started-azure.mdx @@ -2,7 +2,7 @@ title: 'Getting Started With Azure on Prowler' --- -## Prowler App +## Prowler Cloud > Walkthrough video onboarding an Azure Subscription using Service Principal. @@ -32,16 +32,16 @@ For detailed instructions on how to create the Service Principal and configure p --- -### Step 2: Access Prowler App +### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Navigate to `Configuration` > `Cloud Providers` +2. Navigate to `Configuration` > `Providers` - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click on `Add Cloud Provider` +3. Click on `Add Provider` - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select `Microsoft Azure` @@ -51,7 +51,7 @@ For detailed instructions on how to create the Service Principal and configure p ![Add Subscription ID](/images/providers/add-subscription-id.png) -### Step 3: Add Credentials to Prowler App +### Step 3: Add Credentials to Prowler Cloud For Azure, Prowler App uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application. diff --git a/docs/user-guide/providers/cloudflare/authentication.mdx b/docs/user-guide/providers/cloudflare/authentication.mdx index eedcd57a33..fe69656a04 100644 --- a/docs/user-guide/providers/cloudflare/authentication.mdx +++ b/docs/user-guide/providers/cloudflare/authentication.mdx @@ -44,6 +44,15 @@ User API Tokens are the recommended authentication method because they: Create a **User API Token**, not an Account API Token. User API Tokens are created from the profile settings and offer finer permission control. +**Quick Setup:** Use these pre-configured links to open the Cloudflare Dashboard with the required permissions already selected: + +- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a **User API Token** (recommended). Opens the **Create Custom Token** form prefilled with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner`. Adjust **Account Resources** and **Zone Resources** to match the accounts and zones you want to scan, then click **Create Token**. +- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/) instead. Use this for automation or CI/CD where the token should not depend on a specific user account remaining active. Requires the **Super Administrator** or **Administrator** role on the account. + + +Template URLs only pre-fill the token creation form. Review the permissions, configure resources, and click **Create Token** to complete the process. + + ### Step 1: Create a User API Token 1. Log into the [Cloudflare Dashboard](https://dash.cloudflare.com). diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx index 2bc18bcca8..c4303b4d50 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -14,6 +14,15 @@ Set up authentication for Cloudflare with the [Cloudflare Authentication](/user- - Grant the required read-only permissions (`Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`) - Identify the Cloudflare Account ID to use as the provider identifier + +**Quick Setup:** Use these pre-configured links to create a token with the required permissions already selected: + +- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a User API Token (recommended). +- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/), better suited for automation and CI/CD. + +Both links open the Cloudflare Dashboard with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner` prefilled. See [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication#api-token-recommended) for the equivalent manual steps. + + Onboard Cloudflare using Prowler Cloud @@ -42,13 +51,13 @@ The Account ID is a 32-character hexadecimal string (e.g., `372e67954025e0ba6aaa ### Step 2: Open Prowler Cloud 1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Navigate to "Configuration" > "Cloud Providers". +2. Navigate to "Configuration" > "Providers". - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider". +3. Click "Add Provider". - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Cloudflare". diff --git a/docs/user-guide/providers/gcp/authentication.mdx b/docs/user-guide/providers/gcp/authentication.mdx index 9447d73a20..ec53445c84 100644 --- a/docs/user-guide/providers/gcp/authentication.mdx +++ b/docs/user-guide/providers/gcp/authentication.mdx @@ -138,6 +138,10 @@ To keep permissions focused: 4. Continue through the wizard and finish. No principals need to be granted access in step 3 unless you want other identities to impersonate this account. + +To use this service account with `--organization-id`, additionally grant `roles/cloudasset.viewer` at the organization node and enable the Cloud Asset API in the service account's host project. See [Scanning a Specific GCP Organization](./organization). Without these, organization-wide scans silently fall back to listing only the projects accessible to the service account. + + ### Step 3: Generate a JSON Key 1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**. diff --git a/docs/user-guide/providers/gcp/getting-started-gcp.mdx b/docs/user-guide/providers/gcp/getting-started-gcp.mdx index 1cdc587d45..e16114e19a 100644 --- a/docs/user-guide/providers/gcp/getting-started-gcp.mdx +++ b/docs/user-guide/providers/gcp/getting-started-gcp.mdx @@ -2,7 +2,7 @@ title: 'Getting Started With GCP on Prowler' --- -## Prowler App +## Prowler Cloud ### Step 1: Get the GCP Project ID @@ -11,16 +11,16 @@ title: 'Getting Started With GCP on Prowler' ![Get the Project ID](/images/providers/project-id-console.png) -### Step 2: Access Prowler Cloud or Prowler App +### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Google Cloud Platform" diff --git a/docs/user-guide/providers/gcp/organization.mdx b/docs/user-guide/providers/gcp/organization.mdx index 6f4f7658e5..6790be5827 100644 --- a/docs/user-guide/providers/gcp/organization.mdx +++ b/docs/user-guide/providers/gcp/organization.mdx @@ -11,8 +11,19 @@ prowler gcp --organization-id organization-id ``` -Ensure the credentials used have one of the following roles at the organization level: -Cloud Asset Viewer (`roles/cloudasset.viewer`), or Cloud Asset Owner (`roles/cloudasset.owner`). +Ensure the credentials used have one of the following roles bound **at the organization node** (not at a project): Cloud Asset Viewer (`roles/cloudasset.viewer`) or Cloud Asset Owner (`roles/cloudasset.owner`). The role must be bound directly on the organization so the Cloud Asset API can enumerate projects across the whole hierarchy. + +```bash +gcloud organizations add-iam-policy-binding \ + --member="serviceAccount:" \ + --role="roles/cloudasset.viewer" +``` + +The Cloud Asset API (`cloudasset.googleapis.com`) must also be enabled in the project that owns the credentials (the service account's host project, or the quota project for user credentials): + +```bash +gcloud services enable cloudasset.googleapis.com --project +``` diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index adbfd235a8..0b5ab6b720 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -275,7 +275,7 @@ For step-by-step setup instructions for Prowler Cloud, see the [Getting Started ### Using Personal Access Token -1. In Prowler Cloud, navigate to **Configuration** > **Cloud Providers** > **Add Cloud Provider** > **GitHub**. +1. In Prowler Cloud, navigate to **Configuration** > **Providers** > **Add Provider** > **GitHub**. 2. Enter your GitHub Account ID (username or organization name). diff --git a/docs/user-guide/providers/github/getting-started-github.mdx b/docs/user-guide/providers/github/getting-started-github.mdx index 3211d7058d..079f9a1d7b 100644 --- a/docs/user-guide/providers/github/getting-started-github.mdx +++ b/docs/user-guide/providers/github/getting-started-github.mdx @@ -49,13 +49,13 @@ Before adding GitHub to Prowler Cloud/App, ensure you have: ### Step 1: Access Prowler Cloud/App 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to **Configuration** → **Cloud Providers** +2. Go to **Configuration** → **Providers** - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click **Add Cloud Provider** +3. Click **Add Provider** - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select **GitHub** @@ -153,8 +153,8 @@ Before running Prowler CLI for GitHub, ensure you have: # Install via pip pip install prowler - # Or via poetry - poetry install + # Or via uv (from the cloned repo) + uv sync ``` 2. **Authentication Credentials** diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx index d8812fa7b3..049b4fb4ae 100644 --- a/docs/user-guide/providers/googleworkspace/authentication.mdx +++ b/docs/user-guide/providers/googleworkspace/authentication.mdx @@ -6,17 +6,20 @@ import { VersionBadge } from "/snippets/version-badge.mdx" -Prowler for Google Workspace uses a **Service Account with Domain-Wide Delegation** to authenticate to the Google Workspace Admin SDK. This allows Prowler to read directory data on behalf of a super administrator without requiring an interactive login. +Prowler for Google Workspace uses a **Service Account with Domain-Wide Delegation** to authenticate to the Google Workspace Admin SDK and the Cloud Identity Policy API. This allows Prowler to read directory data and domain-level application policies on behalf of a super administrator without requiring an interactive login. ## Required Open Authorization (OAuth) Scopes -Prowler requests the following read-only OAuth 2.0 scopes from the Google Workspace Admin SDK: +Prowler requests the following read-only OAuth 2.0 scopes: | Scope | Description | |-------|-------------| | `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, Chat, Drive, Gmail, Groups, Marketplace, Security, and Sites service checks) | +| `https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly` | Read access to admin roles and role assignments | The delegated user must be a **super administrator** in your Google Workspace organization. Using a non-admin account will result in permission errors when accessing the Admin SDK. @@ -30,13 +33,24 @@ If no GCP project exists, create one at [https://console.cloud.google.com](https The project is only used to host the Service Account — it does not need to have any Google Workspace data in it. -### Step 2: Enable the Admin SDK API +### Step 2: Enable Required APIs -1. Navigate to the [Google Cloud Console](https://console.cloud.google.com) -2. Select the target project -3. Navigate to **APIs & Services → Library** -4. Search for **Admin SDK API** -5. Click **Enable** +In the [Google Cloud Console](https://console.cloud.google.com), select the target project and navigate to **APIs & Services → Library**. Search for and enable each of the following APIs: + +| API | Required For | +|-----|--------------| +| **Admin SDK API** | Directory service checks (users, roles, domains) | +| **Cloud Identity API** | All service checks except Directory (domain-level application policies) | + +For each API: + +1. Search for the API name in the library +2. Click the API result +3. Click **Enable** + + +Both APIs must be enabled in the same GCP project that hosts the Service Account. All service checks except Directory will return no findings if the Cloud Identity API is not enabled. + ### Step 3: Create a Service Account @@ -73,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/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** @@ -114,7 +128,7 @@ The delegated user must be provided via the `GOOGLEWORKSPACE_DELEGATED_USER` env - **Use environment variables** — Never hardcode credentials in scripts or commands - **Use a dedicated Service Account** — Create one specifically for Prowler, separate from other integrations -- **Use read-only scopes** — Prowler only requires the three read-only scopes listed above +- **Use read-only scopes** — Prowler only requires the read-only scopes listed above - **Restrict key access** — Set file permissions to `600` on the JSON key file - **Rotate keys regularly** — Delete and regenerate the JSON key periodically - **Use a least-privilege super admin** — Consider using a dedicated super admin account for Prowler's delegated user rather than a personal admin account @@ -151,7 +165,7 @@ python3 -c "import json; json.load(open('/path/to/key.json'))" && echo "Valid JS The Service Account cannot impersonate the delegated user. This usually means Domain-Wide Delegation has not been configured, or the OAuth scopes are incorrect. Verify: - The Service Account Client ID is correctly entered in the Admin Console -- All three required OAuth scopes are included +- All required OAuth scopes are included - The delegated user is a super administrator ### Permission Denied on Admin SDK Calls @@ -159,5 +173,14 @@ The Service Account cannot impersonate the delegated user. This usually means Do If Prowler connects but returns empty results or permission errors for specific API calls: - Confirm Domain-Wide Delegation is fully propagated (wait a few minutes after setup) -- Verify all three scopes are authorized in the Admin Console +- Verify all scopes are authorized in the Admin Console - Ensure the delegated user is an active super administrator + +### Policy API Checks Return No Findings + +If the Directory checks run successfully but other service checks (Calendar, Chat, Drive, Gmail, Groups, Marketplace, Security, Sites) return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify: + +- The **Cloud Identity API** is enabled in the GCP project hosting the Service Account (Step 2) +- The scope `https://www.googleapis.com/auth/cloud-identity.policies.readonly` is included in the Domain-Wide Delegation OAuth scopes list in the Admin Console (Step 5) +- The delegated user is a super administrator (the Policy API only returns data to super admins) +- Domain-Wide Delegation has had time to propagate after adding the new scope (a few minutes) diff --git a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx index 361de533e1..8931c43ebd 100644 --- a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx +++ b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx @@ -43,13 +43,13 @@ The Customer ID starts with the letter "C" followed by alphanumeric characters ( ### Step 2: Open Prowler Cloud 1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Navigate to "Configuration" > "Cloud Providers". +2. Navigate to "Configuration" > "Providers". - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider". +3. Click "Add Provider". - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Google Workspace". @@ -78,7 +78,7 @@ The Service Account JSON is the full content of the key file downloaded when cre ![Check Connection](/images/providers/googleworkspace-check-connection.png) -If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all three OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors. +If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all required OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors. ### Step 5: Launch the Scan diff --git a/docs/user-guide/providers/iac/getting-started-iac.mdx b/docs/user-guide/providers/iac/getting-started-iac.mdx index 849571b2c8..d1e978dc75 100644 --- a/docs/user-guide/providers/iac/getting-started-iac.mdx +++ b/docs/user-guide/providers/iac/getting-started-iac.mdx @@ -29,9 +29,9 @@ Prowler IaC provider scans the following Infrastructure as Code configurations f - For remote repository scans, authentication can be provided via [git URL](https://git-scm.com/docs/git-clone#_git_urls), CLI flags or environment variables. - Check the [IaC Authentication](/user-guide/providers/iac/authentication) page for more details. - Mutelist logic ([filtering](https://trivy.dev/latest/docs/configuration/filtering/)) is handled by Trivy, not Prowler. -- Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.). +- Results are output in the same formats as other Prowler providers (CSV, JSON-OCSF, HTML), plus [SARIF](/user-guide/cli/tutorials/reporting#sarif-iac-only) for GitHub Code Scanning integration. -## Prowler App +## Prowler Cloud @@ -42,13 +42,13 @@ Scanner selection is not configurable in Prowler App. Default scanners, misconfi ### Step 1: Access Prowler Cloud/App 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Infrastructure as Code" @@ -140,8 +140,20 @@ prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/tes ### Output -Use the standard Prowler output options, for example: +Use the standard Prowler output options. The IaC provider also supports [SARIF](/user-guide/cli/tutorials/reporting#sarif-iac-only) output for GitHub Code Scanning integration: ```sh -prowler iac --scan-path ./iac --output-formats csv json html +prowler iac --scan-path ./iac --output-formats csv json-ocsf html ``` + +#### SARIF Output + + + +To generate SARIF output for integration with SARIF-compatible tools: + +```sh +prowler iac --scan-repository-url https://github.com/user/repo -M sarif +``` + +See the [SARIF reporting documentation](/user-guide/cli/tutorials/reporting#sarif-iac-only) for details on the format and severity mapping. diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx index 9a3d67258d..b9c305d0ef 100644 --- a/docs/user-guide/providers/image/getting-started-image.mdx +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -34,13 +34,13 @@ Prowler Cloud does not support scanner selection. The vulnerability, secret, and ### Step 1: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Navigate to "Configuration" > "Cloud Providers" +2. Navigate to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Container Registry" diff --git a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx index c4f4822792..9ca791110f 100644 --- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx +++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx @@ -2,18 +2,18 @@ title: 'Getting Started with Kubernetes' --- -## Prowler App +## Prowler Cloud ### Step 1: Access Prowler Cloud/App 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Kubernetes" diff --git a/docs/user-guide/providers/linode/authentication.mdx b/docs/user-guide/providers/linode/authentication.mdx new file mode 100644 index 0000000000..feac121f40 --- /dev/null +++ b/docs/user-guide/providers/linode/authentication.mdx @@ -0,0 +1,97 @@ +--- +title: "Linode Authentication in Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode uses a **Personal Access Token** (PAT) for authentication. Prowler reads the token **exclusively** from the `LINODE_TOKEN` environment variable, so the secret is never exposed in shell history or process listings. There are no credential CLI flags. + +## Required Permissions + +Prowler requires read-only access to your Linode account. The following OAuth scopes are needed on the Personal Access Token: + +| Scope | Access | Description | +|-------|--------|-------------| +| `account` | `Read Only` | Required to list users and verify account identity | +| `linodes` | `Read Only` | Required to list instances and their configurations | +| `firewall` | `Read Only` | Required to list firewalls and their rules | + + +Ensure the token has all required scopes. Missing permissions will cause some checks to fail or return incomplete results. + + +--- + +## Personal Access Token + +### Step 1: Create a Personal Access Token + +1. Log into the [Linode Cloud Manager](https://cloud.linode.com). +2. Click on your username in the top-right corner, then select **API Tokens** under the "My Profile" section. +3. Click **Create a Personal Access Token**. +4. Configure the token: + - **Label:** A descriptive name (e.g., "Prowler Security Scanner") + - **Expiry:** Set an appropriate expiration (e.g., 6 months) + - **Permissions:** Set the following scopes to **Read Only**: + - Account + - Linodes + - Firewall + - All other scopes can be set to **No Access** +5. Click **Create Token**. +6. Copy the token immediately — it will not be shown again. + +### Step 2: Configure Authentication + +Set the `LINODE_TOKEN` environment variable: + +```bash +export LINODE_TOKEN="your-personal-access-token" +``` + +Then run Prowler: + +```bash +prowler linode +``` + +--- + +## Verifying Authentication + +To verify that Prowler can connect to your Linode account, run: + +```bash +prowler linode --list-checks +``` + +If authentication succeeds, you will see a list of available checks. If it fails, Prowler will display an error message indicating the credentials issue. + +--- + +## CI/CD Integration + +For automated pipelines, set the token as a secret environment variable: + +**GitHub Actions:** + +```yaml +env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + +steps: + - name: Run Prowler + run: prowler linode +``` + +**GitLab CI:** + +```yaml +variables: + LINODE_TOKEN: $LINODE_TOKEN + +prowler_scan: + script: + - prowler linode +``` diff --git a/docs/user-guide/providers/linode/getting-started-linode.mdx b/docs/user-guide/providers/linode/getting-started-linode.mdx new file mode 100644 index 0000000000..128fdf1c90 --- /dev/null +++ b/docs/user-guide/providers/linode/getting-started-linode.mdx @@ -0,0 +1,61 @@ +--- +title: 'Getting Started With Linode on Prowler' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode scans your Linode infrastructure for security misconfigurations, including compute settings, networking rules, user account security, and more. + + +Linode support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact). + + +## Prerequisites + +Set up authentication for Linode with the [Linode Authentication](/user-guide/providers/linode/authentication) guide before starting: + +- Create a Linode Personal Access Token with read-only permissions +- The token requires at minimum: `account:read_only`, `linodes:read_only`, and `firewall:read_only` scopes + +## Prowler CLI + +### Run Prowler for Linode + +Once authenticated with a Personal Access Token, set the `LINODE_TOKEN` environment variable and run Prowler for Linode. Prowler reads the token exclusively from the environment variable, so the secret is never exposed in shell history or process listings: + +```bash +export LINODE_TOKEN="your-personal-access-token" +prowler linode +``` + +### Run Specific Checks + +```bash +prowler linode --checks compute_instance_backups_enabled compute_instance_watchdog_enabled +``` + +### Run a Specific Service + +```bash +prowler linode --services networking +``` + +### Scan Specific Regions + +Use `--region` (alias `--filter-region` / `-f`) to limit the scan to one or more Linode regions. Region-less resources (account administration and Cloud Firewalls) are always scanned; only regional resources such as instances are filtered. When the flag is omitted, all regions are scanned. + +```bash +prowler linode --region eu-central us-east +``` + +## Available Services + +Prowler for Linode currently supports the following services: + +| Service | Description | +|---------|-------------| +| `administration` | Account administration includes users and access controls such as two-factor authentication | +| `compute` | Compute includes Linode instances and their workload configuration | +| `networking` | Networking includes Cloud Firewalls and their stateful network rules | diff --git a/docs/user-guide/providers/llm/getting-started-llm.mdx b/docs/user-guide/providers/llm/getting-started-llm.mdx index 94256b3ee2..8cca7f2a08 100644 --- a/docs/user-guide/providers/llm/getting-started-llm.mdx +++ b/docs/user-guide/providers/llm/getting-started-llm.mdx @@ -22,7 +22,7 @@ Install promptfoo using one of the following methods: **Using npm:** ```bash -npm install -g promptfoo +npm install --global promptfoo@0.121.11 ``` **Using Homebrew (macOS):** diff --git a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx index 1e6830c722..a21b796b5c 100644 --- a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx +++ b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx @@ -42,13 +42,13 @@ Set up authentication for Microsoft 365 with the [Microsoft 365 Authentication]( ### Step 2: Open Prowler Cloud 1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Navigate to "Configuration" > "Cloud Providers". +2. Navigate to "Configuration" > "Providers". - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider". +3. Click "Add Provider". - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Microsoft 365". diff --git a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx index c10c6aac30..c68bfac9c2 100644 --- a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx +++ b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx @@ -38,7 +38,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in you ### Step 1: Add the provider -1. Navigate to **Cloud Providers** and click **Add Cloud Provider**. +1. Navigate to **Providers** and click **Add Provider**. ![Add provider list](./img/add-provider-list.png) 2. Select **MongoDB Atlas** from the provider list. 3. Enter your **Organization ID** (24 hex characters). This value is visible in the Atlas UI under **Organization Settings**. diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 8affa2f992..fc9fb620bf 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -14,10 +14,10 @@ The following steps apply to Prowler Cloud and the self-hosted Prowler App. 3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint. 4. Note the **Region** identifier to scan (for example, `us-ashburn-1`). -### Step 2: Access Prowler Cloud or Prowler App +### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Go to **Configuration** → **Cloud Providers** and click **Add Cloud Provider**. -![Add OCI Cloud Provider](./images/oci-add-cloud-provider.png) +2. Go to **Configuration** → **Providers** and click **Add Provider**. +![Add OCI Provider](./images/oci-add-cloud-provider.png) 3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then choose **Next**. ![Add OCI Cloud Tenancy](./images/oci-add-tenancy.png) @@ -46,7 +46,7 @@ Before you begin, ensure you have: ```bash pip install prowler # or for development: - poetry install + uv sync ``` 2. **OCI Python SDK** (automatically installed with Prowler): @@ -398,7 +398,7 @@ prowler oci --severity critical high ### Next Steps -- Learn about [Compliance Frameworks](/user-guide/cli/tutorials/compliance) in Prowler +- Learn about [Compliance Frameworks](/user-guide/compliance/tutorials/compliance) in Prowler - Review [Prowler Output Formats](/user-guide/cli/tutorials/reporting) - Explore [Integrations](/user-guide/cli/tutorials/integrations) with SIEM and ticketing systems diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx new file mode 100644 index 0000000000..2e08cac8af --- /dev/null +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -0,0 +1,234 @@ +--- +title: 'Okta Authentication in Prowler' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler authenticates to Okta as a **service application** using **OAuth 2.0 with a private-key JWT** (Client Credentials grant). The integration is read-only by scope and follows DISA STIG guidance for least-privilege access. + +## Common Setup + +### Prerequisites + +- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes equivalent sign-on policy concepts under older naming. +- A **Super Administrator** account on that organization for the one-time service-app setup. +- An **API Services** app integration created in the Okta Admin Console. + +### Authentication Method Overview + +| Method | Status | Use Case | +|---|---|---| +| **OAuth 2.0 (private-key JWT)** | Supported | Production scans, CI/CD, Prowler App. | + +The private-key JWT flow is the only supported authentication method in the initial release. The service application proves possession of a private key on every token request; Okta returns a short-lived access token, refreshed automatically by the SDK. + + +If a different authentication method is needed (SSWS API token, OAuth with user delegation, etc.), please open a [feature request](https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml) describing the use case. + + +### Required OAuth Scopes + +The bundled checks require the following read-only scopes: + +- `okta.policies.read` +- `okta.brands.read` +- `okta.apps.read` +- `okta.authenticators.read` +- `okta.networkZones.read` +- `okta.apiTokens.read` +- `okta.roles.read` +- `okta.groups.read` +- `okta.logStreams.read` +- `okta.idps.read` + +Additional scopes will be needed as more services and checks are added. These are the current ones needed: + +| Scope | Used by | +|---|---| +| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies | +| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | +| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications | +| `okta.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP | +| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation | +| `okta.apiTokens.read` | API token metadata and token network conditions | +| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) | +| `okta.groups.read` | Group memberships of API token owners, used to resolve admin roles inherited via group assignment (e.g. Super Admin granted through the default admin group) | +| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) | +| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) | + +### Required Admin Role + +The service application must be assigned **one** of the following Okta admin roles: + +- **Read-Only Administrator** — covers every `signon` check and runs `application_authentication_policy_network_zone_enforced` against the apps it can see. **Visibility caveat:** under Read-Only Administrator the `/api/v1/apps` endpoint returns only the apps the service application is itself assigned to — typically just the service app's own row (for example, `Prowler Scanner`). The check still produces a finding for that app, but the rest of the org's app inventory is invisible at this role level. +- **Super Administrator** — required additionally to evaluate five application-service checks that target Okta's first-party apps (Okta Admin Console, Okta Dashboard). With Super Administrator, `application_authentication_policy_network_zone_enforced` also evaluates the full org-wide app inventory instead of the service-app-only slice. + +Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. Read-Only Administrator is the minimum role that lets the granted `okta.*.read` scopes return configuration data to Prowler's checks; without it, the credential probe at provider startup fails and the scan never gets to evaluate any check. + +#### When Super Administrator is required + +Four checks need to resolve the Authentication Policy bound to Okta's first-party apps (Okta Admin Console, Okta Dashboard) and depend on `/api/v1/apps` returning those system apps — which Okta restricts to Super Administrator: + +| Check | STIG | +|---|---| +| `application_admin_console_mfa_required` | V-273193 | +| `application_admin_console_phishing_resistant_authentication` | V-273191 | +| `application_dashboard_mfa_required` | V-273194 | +| `application_dashboard_phishing_resistant_authentication` | V-273190 | + +Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/apps` for every role below Super Administrator, so `okta.apps.read` alone is not enough. The `okta.apps.manageFirstPartyApps` permission exists only in the paid Okta Identity Governance role `ACCESS_REQUESTS_ADMIN` and cannot be added to custom roles ([Okta Permissions Catalog](https://developer.okta.com/docs/api/openapi/okta-management/guides/permissions)). + +A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator. + +`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement. + +When the service app runs with Read-Only Administrator, the checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running. + + +Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed. + + +## Step-by-Step Setup + +### 1. Go to the admin console + +![Okta — admin console page](/user-guide/providers/okta/images/select-admin-console.png) + +### 2. [Optional] - Disable the privilege-escalation bypass (org-wide, one-time) + +In the Okta Admin Console, go to **Settings → Account → Public client app admins** and ensure it is **off**. When enabled, every API Services app can be auto-assigned the Super Administrator role after scopes are granted, which would invalidate the read-only premise of this integration. + +![Okta — disable Public client app admins](/user-guide/providers/okta/images/public-client-app-admins.png) + +### 3. Create the API Services app + +1. Go to **Applications → Applications**. + +![Okta — create API Services app](/user-guide/providers/okta/images/go-to-applications.png) + +2. **Create App Integration** + +![Okta — create App integration](/user-guide/providers/okta/images/create-new-application.png) + +3. Sign-in method: **API Services**. Click **Next**. +4. Name the app (for example, `Prowler Scanner`) and click **Save**. +5. Copy the displayed **Client ID** — you'll use it as `OKTA_CLIENT_ID`. + +![Okta — copy client id](/user-guide/providers/okta/images/copy-client-id.png) + +### 4. Switch to private-key authentication and generate a keypair + +On the new app's **General** tab, scroll to **Client Credentials**: + +1. Click **Edit**. +2. Set **Client authentication** to **Public key / Private key**. +3. Under **Public Keys**, click **Add key**. +4. In the modal, click **Generate new key**. Okta creates a JWK pair. +5. Click the **PEM** tab to switch the displayed format (or keep JWK — Prowler accepts both). +6. Copy the entire `-----BEGIN PRIVATE KEY-----` block (or the JWK JSON). +7. Click **Done**, then **Save**. + + +Okta displays the private key **only once**. If you close the modal without copying, you must generate a new key. + + +![Okta — create Public Key](/user-guide/providers/okta/images/create-public-key.png) + +### 5. Grant the required OAuth scopes + +On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`. + +![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) + +### 6. Assign an admin role + +On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**: + +- **Role:** Read-Only Administrator (default) — covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (typically only the service app's own row). +- **Resources:** All resources + +Save the changes. + +To additionally evaluate the first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, and phishing-resistant authentication) and to widen the per-app network-zone check to the full org-wide app inventory, assign **Super Administrator** instead. Without Super Administrator, the five first-party checks return MANUAL and the network-zone check is limited to the service app's own visibility — the rest of the scan still runs. See [Required Admin Role](#required-admin-role) for the full breakdown. + +![Okta — grant Read-Only role](/user-guide/providers/okta/images/grant-roles.png) + +### 7. [Optional] Verify DPoP setting + +Prowler sends DPoP (Demonstrating Proof of Possession) proofs on every token request. The integration works whether the **Require Demonstrating Proof of Possession (DPoP) header in token requests** setting on the service app is on or off — but enabling it is the more secure default. + +## Prowler CLI Authentication + +### Using Environment Variables (Required for Secrets) + +Private key material **must** be supplied via environment variables — Prowler does not accept secrets through CLI flags. + +```bash +export OKTA_ORG_DOMAIN="YOUR-ORG.okta.com" +export OKTA_CLIENT_ID="0oa1234567890abcdef" + +# Either of the two — content takes precedence over file when both are set. +export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" +# or +export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" + +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" + +uv run python prowler-cli.py okta +``` + +### Non-Secret CLI Flags + +Non-secret values are also available as CLI flags for ergonomic overrides: + +| Flag | Equivalent env var | +|---|---| +| `--okta-org-domain` | `OKTA_ORG_DOMAIN` | +| `--okta-client-id` | `OKTA_CLIENT_ID` | +| `--okta-scopes` | `OKTA_SCOPES` | + +Run a single check directly: + +```bash +uv run python prowler-cli.py okta --check signon_global_session_idle_timeout_15min +``` + +## Troubleshooting + +### `OktaInvalidOrgDomainError` + +The org domain must be `.okta.com` (or `.oktapreview.com` / `.okta-emea.com` / `.okta-gov.com` / `.okta.mil` / `.okta-miltest.com` / `.trex-govcloud.com`). Pass the bare hostname only — no `https://` scheme, no path, no trailing slash. Custom (vanity) domains are not currently accepted. + +### `OktaPrivateKeyFileError` + +The file at `OKTA_PRIVATE_KEY_FILE` is missing, unreadable, or empty. Confirm the path and that the file contains a non-empty PEM block or JWK JSON document. + +### `OktaInvalidCredentialsError` at provider init + +Prowler validates credentials at startup by listing one sign-on policy. This error indicates the credential material itself was rejected: + +- **`invalid_client`** — the public key registered in Okta does not match the private key on disk. Generate a fresh keypair and try again. + +### `OktaInsufficientPermissionsError` at provider init + +Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role: + +- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. +- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**. + +### Application-service checks return MANUAL on first-party apps + +When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list. + +### `invalid_dpop_proof` + +The org or the service app requires DPoP. The provider always sends DPoP proofs, so this error indicates the SDK could not build a valid proof — typically because the private key on disk does not match the public key uploaded to Okta. Regenerate the keypair. + +## Additional Resources + +- [Implement OAuth 2.0 for an Okta service app](https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/) +- [Okta Policy API reference](https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/) +- [DISA STIG for Okta (V-273186)](https://stigviewer.com/stigs/okta/) diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx new file mode 100644 index 0000000000..e04e0d4a13 --- /dev/null +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -0,0 +1,204 @@ +--- +title: 'Getting Started With Okta on Prowler' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + +Prowler for Okta scans an Okta organization for identity and session-management misconfigurations. The provider authenticates as a service application using **OAuth 2.0 with a private-key JWT** (Client Credentials grant) — no end-user login, read-only by scope. + +## Prerequisites + +Set up authentication for Okta with the [Okta Authentication](/user-guide/providers/okta/authentication) guide before starting: + +- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names. +- A **Super Administrator** account on that organization for the one-time service-app setup. +- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. +- Python 3.10+ and Prowler 5.27.0 or later installed locally. + + + + Onboard Okta using Prowler Cloud + + + Onboard Okta using Prowler CLI + + + +## Prowler Cloud + + + +### Step 1: Add the Provider + +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +2. Navigate to "Configuration" > "Providers". + + ![Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Provider". + + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Okta". + + ![Select Okta](/user-guide/providers/okta/images/select-okta-provider.png) + +5. Enter the **Org Domain** of the target Okta organization and an optional alias, then click "Next". + + ![Add Okta Org Domain](/user-guide/providers/okta/images/okta-org-domain-form.png) + + +The Org Domain must be the bare hostname of an Okta-managed organization — for example, `acme.okta.com`, `acme.oktapreview.com`, `acme.okta-emea.com`, `acme.okta-gov.com`, `acme.okta.mil`, `acme.okta-miltest.com`, or `acme.trex-govcloud.com`. Omit the `https://` scheme, any path, and any trailing slash. + + +### Step 2: Provide Credentials + +Prowler Cloud authenticates to Okta with the **OAuth 2.0 Private Key JWT** flow exposed by an Okta **API Services** app. The service application, keypair, scope grants, and Read-Only Administrator role are set up once in the Okta Admin Console — full instructions are in the [Okta Authentication](/user-guide/providers/okta/authentication) guide. + +1. Enter the **Client ID** of the Okta API Services app (for example, `0oa123456789abcdef`). +2. Paste the **Private Key** whose matching public key (JWK) is registered on the service app. Both PEM-encoded RSA keys and JWK JSON documents are accepted. +3. Click "Next". + + ![Okta Credentials Form](/user-guide/providers/okta/images/okta-credentials-form.png) + + +The private key is transmitted over TLS and stored as an encrypted secret in the backend. Rotate or revoke the matching public key from the Okta Admin Console at any time to invalidate the credential without changes on the Prowler side. + + +### Step 3: Launch the Scan + +1. Review the connection summary. Prowler Cloud runs a credential probe against the Okta Management API before saving — a failed probe surfaces the underlying Okta error (`invalid_scope`, `Forbidden`, invalid credentials, etc.) so the configuration can be corrected before the first scan. +2. Choose the scan schedule: run a single scan or set up daily scans (every 24 hours). +3. Click **Launch Scan** to start auditing the Okta organization. + +--- + +## Prowler CLI + + + +### Step 1: Set Up Authentication + +Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guide to create the service application, generate a keypair, grant scopes, and assign the Read-Only Administrator role. Then export the credentials: + +```bash +export OKTA_ORG_DOMAIN="acme.okta.com" +export OKTA_CLIENT_ID="0oa1234567890abcdef" +export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +``` + +The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. + +#### Supplying the Private Key as Content + +For automated environments where writing the key to disk is not desirable (CI runners, container secrets, etc.), the private key may be passed directly as a string: + +```bash +export OKTA_ORG_DOMAIN="acme.okta.com" +export OKTA_CLIENT_ID="0oa1234567890abcdef" +export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" +``` + +`OKTA_PRIVATE_KEY` takes precedence over `OKTA_PRIVATE_KEY_FILE` when both are set. The private key is intentionally not exposed as a CLI flag — secrets must be supplied via environment variables only. + +### Step 2: Run the First Scan + +Run a baseline scan after credentials are configured: + +```bash +prowler okta +``` + +Or run a specific check directly: + +```bash +prowler okta --check signon_global_session_idle_timeout_15min +``` + +Prowler prints a summary table; full findings are written to the configured output formats. + +### Step 3: Use a Custom Configuration (Optional) + +Prowler uses a configuration file to customize check thresholds. The Okta configuration currently includes: + +```yaml +okta: + # okta.signon_global_session_idle_timeout_15min + # Defaults to 15 minutes per DISA STIG V-273186. + okta_max_session_idle_minutes: 15 + # okta.application_admin_console_session_idle_timeout_15min + # Defaults to 15 minutes per DISA STIG V-273187. + okta_admin_console_idle_timeout_max_minutes: 15 +``` + +To use a custom configuration: + +```bash +prowler okta --config-file /path/to/config.yaml +``` + +## Supported Services + +Prowler for Okta includes security checks across the following services: + +| Service | Description | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | +| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | +| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status | +| **Network** | Network Zone blocklists for anonymized proxy sources | +| **API Token** | API token owner-role validation and Network Zone restrictions | +| **User** | User lifecycle automations (inactivity-based deprovisioning) | +| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM | +| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility | + +## Troubleshooting + +### STIG Rule Ordering + +The initial check is mapped to DISA STIG `V-273186` / `OKTA-APP-000020`. Prowler implements the STIG procedure as written: the **Default Policy** must have a **Priority 1** rule that is **not** `Default Rule`, and that rule must set **Maximum Okta global session idle time** to 15 minutes or less. + +This is stricter than simply finding the same timeout value somewhere else in the policy set. A compliant custom rule in another policy, or a compliant timeout on the built-in `Default Rule`, does not satisfy this STIG procedure. + +### Default Scopes + +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services: + +- `okta.policies.read` +- `okta.brands.read` +- `okta.apps.read` +- `okta.authenticators.read` +- `okta.networkZones.read` +- `okta.apiTokens.read` +- `okta.roles.read` +- `okta.groups.read` +- `okta.logStreams.read` +- `okta.idps.read` + +The service app must have these scopes granted in the **Okta API Scopes** tab. `okta.groups.read` is required so the API token Super Admin check can resolve admin roles inherited via group membership; without it the check falls back to direct-only role assignments and emits a best-effort caveat. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. + +When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI): + +```bash +# Environment variable — comma-separated +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read" + +# CLI flag — space-separated +prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.authenticators.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read +``` + +For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/). + + +As new services and checks land in the Okta provider, the default scope list grows alongside them. Re-check the granted scopes on the service app after each Prowler upgrade and grant any newly required `okta.*.read` scopes in the Admin Console. + + +### Common Errors + +- **`OktaInvalidOrgDomainError`** — the org domain must be `.okta.com` (or `.oktapreview.com` / `.okta-emea.com` / `.okta-gov.com` / `.okta.mil` / `.okta-miltest.com` / `.trex-govcloud.com`). Pass the bare hostname only — no `https://` scheme, no path, no trailing slash. +- **`OktaPrivateKeyFileError`** — confirm the file is readable and contains a non-empty PEM or JWK body. +- **`OktaInsufficientPermissionsError`** — the credential probe reached Okta but the service app cannot perform the request. The error string carries `invalid_scope`, `Forbidden`, `not authorized`, or `permission`. Fix by granting the missing `okta.*.read` scope from **Okta API Scopes** and confirming the **Read-Only Administrator** role is assigned to the service app. +- **`OktaInvalidCredentialsError`** — the credential probe reached Okta but Okta rejected the JWT. Typically the private key on disk does not match the public JWK uploaded to the service app, or the JWT signing parameters are wrong. Regenerate the keypair and re-upload the public JWK. +- **Token requests failing for an unknown scope** — the app was granted a narrower scope set than `OKTA_SCOPES` requests. Either narrow `OKTA_SCOPES` or grant the missing scopes in the Admin Console. diff --git a/docs/user-guide/providers/okta/images/copy-client-id.png b/docs/user-guide/providers/okta/images/copy-client-id.png new file mode 100644 index 0000000000..f589acf4a5 Binary files /dev/null and b/docs/user-guide/providers/okta/images/copy-client-id.png differ diff --git a/docs/user-guide/providers/okta/images/create-new-application.png b/docs/user-guide/providers/okta/images/create-new-application.png new file mode 100644 index 0000000000..2df35a877b Binary files /dev/null and b/docs/user-guide/providers/okta/images/create-new-application.png differ diff --git a/docs/user-guide/providers/okta/images/create-public-key.png b/docs/user-guide/providers/okta/images/create-public-key.png new file mode 100644 index 0000000000..2597791785 Binary files /dev/null and b/docs/user-guide/providers/okta/images/create-public-key.png differ diff --git a/docs/user-guide/providers/okta/images/go-to-applications.png b/docs/user-guide/providers/okta/images/go-to-applications.png new file mode 100644 index 0000000000..fd8eecb2f5 Binary files /dev/null and b/docs/user-guide/providers/okta/images/go-to-applications.png differ diff --git a/docs/user-guide/providers/okta/images/grant-permissions.png b/docs/user-guide/providers/okta/images/grant-permissions.png new file mode 100644 index 0000000000..890bf3e862 Binary files /dev/null and b/docs/user-guide/providers/okta/images/grant-permissions.png differ diff --git a/docs/user-guide/providers/okta/images/grant-roles.png b/docs/user-guide/providers/okta/images/grant-roles.png new file mode 100644 index 0000000000..8a670ddf73 Binary files /dev/null and b/docs/user-guide/providers/okta/images/grant-roles.png differ diff --git a/docs/user-guide/providers/okta/images/okta-credentials-form.png b/docs/user-guide/providers/okta/images/okta-credentials-form.png new file mode 100644 index 0000000000..c37553c59d Binary files /dev/null and b/docs/user-guide/providers/okta/images/okta-credentials-form.png differ diff --git a/docs/user-guide/providers/okta/images/okta-org-domain-form.png b/docs/user-guide/providers/okta/images/okta-org-domain-form.png new file mode 100644 index 0000000000..fb145d2877 Binary files /dev/null and b/docs/user-guide/providers/okta/images/okta-org-domain-form.png differ diff --git a/docs/user-guide/providers/okta/images/public-client-app-admins.png b/docs/user-guide/providers/okta/images/public-client-app-admins.png new file mode 100644 index 0000000000..345b4adcca Binary files /dev/null and b/docs/user-guide/providers/okta/images/public-client-app-admins.png differ diff --git a/docs/user-guide/providers/okta/images/select-admin-console.png b/docs/user-guide/providers/okta/images/select-admin-console.png new file mode 100644 index 0000000000..849bd465d2 Binary files /dev/null and b/docs/user-guide/providers/okta/images/select-admin-console.png differ diff --git a/docs/user-guide/providers/okta/images/select-okta-provider.png b/docs/user-guide/providers/okta/images/select-okta-provider.png new file mode 100644 index 0000000000..1a854bab37 Binary files /dev/null and b/docs/user-guide/providers/okta/images/select-okta-provider.png differ diff --git a/docs/user-guide/providers/openstack/getting-started-openstack.mdx b/docs/user-guide/providers/openstack/getting-started-openstack.mdx index b80ebe0e9f..1ff80c3e2e 100644 --- a/docs/user-guide/providers/openstack/getting-started-openstack.mdx +++ b/docs/user-guide/providers/openstack/getting-started-openstack.mdx @@ -34,7 +34,7 @@ Before running Prowler with the OpenStack provider, ensure you have: ### Step 1: Add the Provider -1. Navigate to "Cloud Providers" and click "Add Cloud Provider". +1. Navigate to "Providers" and click "Add Provider". ![Providers List](./images/select-provider.png) 2. Select "OpenStack" from the provider list. 3. Enter the "Project ID" from the OpenStack provider. diff --git a/docs/user-guide/providers/scaleway/authentication.mdx b/docs/user-guide/providers/scaleway/authentication.mdx new file mode 100644 index 0000000000..cecabf3a97 --- /dev/null +++ b/docs/user-guide/providers/scaleway/authentication.mdx @@ -0,0 +1,37 @@ +--- +title: 'Scaleway Authentication in Prowler' +--- + +Prowler authenticates to Scaleway using a **Scaleway API key** (access key + secret key). The integration is read-only and only needs permission to list IAM users and API keys in the audited organization. + +## Prerequisites + +1. A Scaleway organization with IAM access. +2. A Scaleway API key with at least the `IAMReadOnly` policy bound to a dedicated IAM user (do not use the account root user). +3. Your organization ID (visible at the top right of the Scaleway console). + +## Authentication Method + +Prowler reads credentials **exclusively** from the standard Scaleway environment variables. There are no credential CLI flags, so secrets are never exposed in shell history or process listings. + +| Variable | Purpose | +|---|---| +| `SCW_ACCESS_KEY` | API key access key | +| `SCW_SECRET_KEY` | API key secret key | +| `SCW_DEFAULT_ORGANIZATION_ID` | Optional, required when the key bearer is an application | +| `SCW_DEFAULT_PROJECT_ID` | Optional, default project for project-scoped resources | +| `SCW_DEFAULT_REGION` | Optional, defaults to `fr-par` | + +The scope variables can also be passed as CLI flags (`--organization-id`, `--project-id`, `--region`), which override the corresponding environment variables. + +```bash +export SCW_ACCESS_KEY="SCW..." +export SCW_SECRET_KEY="..." +export SCW_DEFAULT_ORGANIZATION_ID="..." + +prowler scaleway +``` + +## Required Scaleway Permissions + +The API key bearer needs read access to the IAM API in order to list users and API keys. The `IAMReadOnly` policy is sufficient. Refer to the [Scaleway IAM policy reference](https://www.scaleway.com/en/docs/identity-and-access-management/iam/reference-content/permission-sets/) for the full list of permissions. diff --git a/docs/user-guide/providers/scaleway/getting-started-scaleway.mdx b/docs/user-guide/providers/scaleway/getting-started-scaleway.mdx new file mode 100644 index 0000000000..283a7fe2e8 --- /dev/null +++ b/docs/user-guide/providers/scaleway/getting-started-scaleway.mdx @@ -0,0 +1,37 @@ +--- +title: "Getting Started With Scaleway on Prowler" +--- + +Prowler for Scaleway scans IAM resources in your Scaleway organization for security misconfigurations. The current release ships one check that flags API keys still owned by the account root user. + +## Prerequisites + +1. A Scaleway organization with IAM access. +2. A Scaleway API key with at least the `IAMReadOnly` policy bound to a dedicated IAM user (do not use the account root user). +3. Your organization ID (visible at the top right of the Scaleway console). + +## Authentication + +Prowler authenticates to Scaleway with a Scaleway API key. See [Scaleway Authentication in Prowler](./authentication) for the full setup, environment variables, CLI flags, and required permissions. + +## Run a scan + +```bash +export SCW_ACCESS_KEY="SCW..." +export SCW_SECRET_KEY="..." +export SCW_DEFAULT_ORGANIZATION_ID="..." + +prowler scaleway +``` + +To run only the IAM root-key check: + +```bash +prowler scaleway --check iam_api_keys_no_root_owned +``` + +## Checks shipped + +| Check ID | Severity | Description | +|---|---|---| +| `iam_api_keys_no_root_owned` | Critical | Fails when any Scaleway IAM API key is still owned by the account root user. | diff --git a/docs/user-guide/providers/stackit/authentication.mdx b/docs/user-guide/providers/stackit/authentication.mdx new file mode 100644 index 0000000000..dabc407c50 --- /dev/null +++ b/docs/user-guide/providers/stackit/authentication.mdx @@ -0,0 +1,100 @@ +--- +title: 'StackIT Authentication' +--- + +Prowler authenticates with StackIT using a **service account key file**. The StackIT SDK signs the RSA challenge in the key file and mints/refreshes access tokens internally for the life of the scan, so no manual token rotation is needed. + +## Service Account Key + +StackIT uses RSA key-pair based service account keys. They are issued once, must be stored securely, and are read by the SDK on every scan to mint short-lived access tokens transparently. + +### Option 1: Create the Key via the StackIT Portal + +1. Open the [StackIT Portal](https://portal.stackit.cloud/) and select your project. +2. In the left sidebar, click **Service Accounts**. +3. Create a service account if you do not have one already. Assign: + - `iaas.viewer` for the IaaS security group checks currently shipped, or + - `project.owner` if you want to cover any future service Prowler adds. +4. Open the service account and go to **Service Account Keys**. +5. Click **Create key** and choose **STACKIT-generated key pair** (recommended). Download the resulting JSON file and store it securely (for example, `~/.stackit/sa-key.json`). The private material is only shown once. + +### Option 2: Create the Key via the StackIT CLI + +```bash +# Install the StackIT CLI from https://github.com/stackitcloud/stackit-cli first +stackit service-account key create --email my-service-account@example.com +``` + +## Project ID + +Your StackIT project ID is a UUID. You can find it in: + +1. The portal URL when viewing the project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...` +2. The project settings page +3. `stackit project list` + +## Passing Credentials to Prowler + +You can give Prowler either the **path** to the key file on disk or the **inline JSON content** of the key. Both go through the same StackIT SDK flow and refresh access tokens internally. + +### Option A: Key File Path (workstation, persistent agents) + +Recommended when the key is stored on disk. + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + +Or as CLI flags: + +```bash +prowler stackit \ + --stackit-service-account-key-path ~/.stackit/sa-key.json \ + --stackit-project-id 12345678-1234-1234-1234-123456789abc +``` + + +Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account. + + +### Option B: Inline Key Content (CI/CD, secret managers) + +Recommended when the key is fetched at run time from a secret manager (GitHub Actions secret, AWS Secrets Manager, HashiCorp Vault, etc.) and you do not want to write it to disk. + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + + +Prefer the `STACKIT_SERVICE_ACCOUNT_KEY` environment variable over the matching CLI flag (`--stackit-service-account-key`); passing the secret on the command line leaks it through process listings and shell history. + + +When both the inline content and a key path are set, the inline content wins. + +## Credential Lookup Order + +Prowler resolves credentials in this order: + +1. CLI arguments: `--stackit-service-account-key`, `--stackit-service-account-key-path`, `--stackit-project-id` +2. Environment variables: `STACKIT_SERVICE_ACCOUNT_KEY`, `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_PROJECT_ID` + +When both the inline key and the key file path are set, the inline content takes precedence. + +## Token Lifetime + +Access tokens are minted on demand by the SDK from the key file and refreshed before they expire. There is nothing to rotate while Prowler is running. + +## Troubleshooting + +| Symptom | Likely Cause | Fix | +|---------|--------------|-----| +| `401 Unauthorized` during scan | Key file is missing fields, the public key is no longer registered, or the key was revoked | Re-issue the service account key in the StackIT portal and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH` | +| `403 Forbidden` during scan | Service account lacks role on the project | Re-check role assignment in the StackIT portal; `iaas.viewer` is the minimum for the shipped IaaS checks | +| `StackIT project ID must be a valid UUID` | The project ID is not in UUID format | Copy the UUID from the portal URL or `stackit project list` | +| `StackIT service account credentials are required` | None of the four credential inputs is set | Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` or `STACKIT_SERVICE_ACCOUNT_KEY` (or use their CLI counterparts) before running Prowler | diff --git a/docs/user-guide/providers/stackit/getting-started-stackit.mdx b/docs/user-guide/providers/stackit/getting-started-stackit.mdx new file mode 100644 index 0000000000..6c56008a89 --- /dev/null +++ b/docs/user-guide/providers/stackit/getting-started-stackit.mdx @@ -0,0 +1,141 @@ +--- +title: 'Getting Started With StackIT' +--- + +Prowler supports [StackIT](https://www.stackit.de/) from the CLI. This guide walks you through the requirements and how to run scans. + + +StackIT support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact). + + +## Prerequisites + +Before running Prowler with the StackIT provider, ensure you have: + +1. A StackIT account with at least one project +2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup. +3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli)) + +## Prowler CLI + +### Step 1: Point Prowler at the Service Account Key + +Prowler authenticates with a StackIT service account key. The SDK signs the RSA challenge in the key and refreshes access tokens internally for the life of the scan, so there is no manual token rotation. + +**On a workstation or persistent agent** (key on disk): + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" +``` + +**In CI/CD** (key in a secret manager, never written to disk): + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" +``` + +CLI flags work too: + +```bash +prowler stackit \ + --stackit-service-account-key-path ~/.stackit/sa-key.json \ + --stackit-project-id 12345678-1234-1234-1234-123456789abc +``` + + +For the inline key, prefer the `STACKIT_SERVICE_ACCOUNT_KEY` env var over the matching CLI flag; passing the secret on the command line leaks it through process listings and shell history. + +Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account. + + +### Step 2: Run Your First Scan + +```bash +prowler stackit +``` + +Prowler will discover and audit the project's IaaS security groups across the available StackIT regions. + +**Scan specific regions:** + +```bash +prowler stackit --stackit-region eu01 eu02 +``` + +**Run specific security checks:** + +```bash +prowler stackit --checks iaas_security_group_ssh_unrestricted + +# List all available checks +prowler stackit --list-checks +``` + +**Filter by check severity:** + +```bash +prowler stackit --severity critical high +``` + +**Generate specific output formats:** + +```bash +# JSON only +prowler stackit --output-modes json + +# CSV and HTML +prowler stackit --output-modes csv html + +# Custom output directory +prowler stackit --output-directory /path/to/reports/ +``` + +**Use a mutelist to suppress findings:** + +```yaml +# mutelist.yaml +Mutelist: + Accounts: + "12345678-1234-1234-1234-123456789abc": + Checks: + iaas_security_group_ssh_unrestricted: + Regions: + - "*" + Resources: + - "test-sg-id" + Tags: [] +``` + +```bash +prowler stackit --mutelist-file mutelist.yaml +``` + +### Step 3: Review the Results + +Prowler outputs findings to the console and writes reports to the `output/` directory by default: + +- CSV: `output/prowler-output-stackit-{project_id}-{timestamp}.csv` +- JSON: `output/prowler-output-stackit-{project_id}-{timestamp}.json` +- HTML: `output/prowler-output-stackit-{project_id}-{timestamp}.html` + +## Supported StackIT Services + +| Service | StackIT API | Description | Example Checks | +|---------|-------------|-------------|----------------| +| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` | + +Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases). + +## Troubleshooting + +### Authentication Errors + +If the scan fails with a 401 error, the service account key is no longer valid (revoked, rotated or the key file is incomplete). Re-issue the key in the [StackIT portal](https://portal.stackit.cloud/) and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH`. + +### Permission Errors + +If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks). + +For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication). diff --git a/docs/user-guide/providers/vercel/getting-started-vercel.mdx b/docs/user-guide/providers/vercel/getting-started-vercel.mdx index 67d4853d18..c39c5f1e6a 100644 --- a/docs/user-guide/providers/vercel/getting-started-vercel.mdx +++ b/docs/user-guide/providers/vercel/getting-started-vercel.mdx @@ -13,9 +13,63 @@ Set up authentication for Vercel with the [Vercel Authentication](/user-guide/pr - Create a Vercel API Token with access to the target team - Identify the Team ID (optional, required to scope the scan to a single team) + + + Onboard Vercel using Prowler Cloud + + + Onboard Vercel using Prowler CLI + + + +## Prowler Cloud + + + +### Step 1: Add the Provider + +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +2. Navigate to "Configuration" > "Providers". + + ![Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Provider". + + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Vercel". + + ![Select Vercel](/images/providers/select-vercel-prowler-cloud.png) + +5. Enter the **Team ID** and an optional alias, then click "Next". + + ![Add Vercel Team ID](/images/providers/vercel-team-id-form.png) + + +The Team ID can be found in the Vercel Dashboard under "Settings" > "General". It follows the format `team_xxxxxxxxxxxxxxxxxxxx`. For detailed instructions, see the [Authentication guide](/user-guide/providers/vercel/authentication). + + +### Step 2: Provide Credentials + +1. Enter the **API Token** created in the Vercel Dashboard. + + ![API Token Form](/images/providers/vercel-token-form.png) + +For the complete token creation workflow, follow the [Authentication guide](/user-guide/providers/vercel/authentication#api-token). + +### Step 3: Launch the Scan + +1. Review the connection summary. +2. Choose the scan schedule: run a single scan or set up daily scans (every 24 hours). +3. Click **Launch Scan** to start auditing Vercel. + + ![Launch Scan](/images/providers/vercel-launch-scan.png) + +--- + ## Prowler CLI - + ### Step 1: Set Up Authentication @@ -106,3 +160,25 @@ Prowler for Vercel includes security checks across the following services: | **Project** | Deployment protection, environment variable security, fork protection, and skew protection | | **Security** | Web Application Firewall (WAF), rate limiting, IP blocking, and managed rulesets | | **Team** | SSO enforcement, directory sync, member access, and invitation hygiene | + +## Checks With Explicit Plan-Based Behavior + +Prowler currently includes 26 Vercel checks. The 11 checks below have explicit billing-plan handling in the provider metadata or check logic. When the scanned scope reports a billing plan, Prowler adds plan-aware context to findings for these checks. If the API does not expose the required configuration, Prowler may return `MANUAL` and require verification in the Vercel dashboard. + +| Check ID | Hobby | Pro | Enterprise | Notes | +|----------|-------|-----|------------|-------| +| `project_password_protection_enabled` | Not available | Available as a paid add-on | Available | Checks password protection for deployments | +| `project_production_deployment_protection_enabled` | Not available | Available with supported paid deployment protection options | Available | Checks protection for production deployments | +| `project_skew_protection_enabled` | Not available | Available | Available | Checks skew protection during rollouts | +| `security_custom_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API | +| `security_ip_blocking_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API | +| `team_saml_sso_enabled` | Not available | Available | Available | Checks team SAML SSO configuration | +| `team_saml_sso_enforced` | Not available | Available | Available | Checks SAML SSO enforcement for all team members | +| `team_directory_sync_enabled` | Not available | Not available | Available | Checks SCIM directory sync | +| `security_managed_rulesets_enabled` | Bot Protection and AI Bots managed rulesets | Bot Protection and AI Bots managed rulesets | All managed rulesets, including OWASP Core Ruleset | Returns `MANUAL` when the firewall configuration cannot be assessed from the API | +| `security_rate_limiting_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API | +| `security_waf_enabled` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API | + + +The five firewall-related checks (`security_waf_enabled`, `security_custom_rules_configured`, `security_ip_blocking_rules_configured`, `security_rate_limiting_configured`, and `security_managed_rulesets_enabled`) return `MANUAL` when the firewall configuration endpoint is not accessible from the API. The other 15 current Vercel checks do not currently include plan-specific handling in provider logic, but every Vercel check includes exactly one billing-plan metadata category (`vercel-hobby-plan`, `vercel-pro-plan`, or `vercel-enterprise-plan`) alongside its functional security category. + diff --git a/docs/user-guide/tutorials/PowerBI.mdx b/docs/user-guide/tutorials/PowerBI.mdx deleted file mode 100644 index 03faedd478..0000000000 --- a/docs/user-guide/tutorials/PowerBI.mdx +++ /dev/null @@ -1,115 +0,0 @@ -# Prowler Multicloud CIS Benchmarks PowerBI Template -![Prowler Report](https://github.com/user-attachments/assets/560f7f83-1616-4836-811a-16963223c72f) - -## Getting Started - -1. Install Microsoft PowerBI Desktop - - This report requires the Microsoft PowerBI Desktop software which can be downloaded for free from Microsoft. -2. Run compliance scans in Prowler - - The report uses compliance csv outputs from Prowler. Compliance scans be run using either [Prowler CLI](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli) or [Prowler Cloud/App](https://cloud.prowler.com/sign-in) - 1. Prowler CLI -> Run a Prowler scan using the --compliance option - 2. Prowler Cloud/App -> Navigate to the compliance section to download csv outputs -![Download Compliance Scan](https://github.com/user-attachments/assets/42c11a60-8ce8-4c60-a663-2371199c052b) - - - The template supports the following CIS Benchmarks only: - - | Compliance Framework | Version | - | ---------------------------------------------- | ------- | - | CIS Amazon Web Services Foundations Benchmark | v4.0.1 | - | CIS Google Cloud Platform Foundation Benchmark | v3.0.0 | - | CIS Microsoft Azure Foundations Benchmark | v3.0.0 | - | CIS Kubernetes Benchmark | v1.10.0 | - - Ensure you run or download the correct benchmark versions. -3. Create a local directory to store Prowler csvoutputs - - Once downloaded, place your csv outputs in a directory on your local machine. If you rename the files, they must maintain the provider in the filename. - - To use time-series capabilities such as "compliance percent over time" you'll need scans from multiple dates. -4. Download and run the PowerBI template file (.pbit) - - Running the .pbit file will open PowerBI Desktop and prompt you for the full filepath to the local directory -5. Enter the full filepath to the directory created in step 3 - - Provide the full filepath from the root directory. - - Ensure that the filepath is not wrapped in quotation marks (""). If you use Window's "copy as path" feature, it will automatically include quotation marks. -6. Save the report as a PowerBI file (.pbix) - - Once the filepath is entered, the template will automatically ingest and populate the report. You can then save this file as a new PowerBI report. If you'd like to generate another report, simply re-run the template file (.pbit) from step 4. - -## Validation - -After setting up your dashboard, you may want to validate the Prowler csv files were ingested correctly. To do this, navigate to the "Configuration" tab. - -The "loaded CIS Benchmarks" table shows the supported benchmarks and versions. This is defined by the template file and not editable by the user. All benchmarks will be loaded regardless of which providers you provided csv outputs for. - -The "Prowler CSV Folder" shows the path to the local directory you provided. - -The "Loaded Prowler Exports" table shows the ingested csv files from the local directory. It will mark files that are treated as the latest assessment with a green checkmark. - -![Prowler Validation](https://github.com/user-attachments/assets/a543ca9b-6cbe-4ad1-b32a-d4ac2163d447) - -## Report Sections - -The PowerBI Report is broken into three main report pages - -| Report Page | Description | -| ----------- | ----------------------------------------------------------------------------------- | -| Overview | Provides general CIS Benchmark overview across both AWS, Azure, GCP, and Kubernetes | -| Benchmark | Provides overview of a single CIS Benchmark | -| Requirement | Drill-through page to view details of a single requirement | - - -### Overview Page - -The overview page is a general CIS Benchmark overview across both AWS, Azure, GCP, and Kubernetes. - -![image](https://github.com/user-attachments/assets/94164fa9-36a4-4bb9-890d-e9a9a63a3e7d) - -The page has the following components: - -| Component | Description | -| ---------------------------------------- | ------------------------------------------------------------------------ | -| CIS Benchmark Overview | Table with benchmark name, Version, and overall compliance percentage | -| Provider by Requirement Status | Bar chart showing benchmark requirements by status by provider | -| Compliance Percent Heatmap | Heatmap showing compliance percent by benchmark and profile level | -| Profile level by Requirement Status | Bar chart showing requirements by status and profile level | -| Compliance Percent Over Time by Provider | Line chart showing overall compliance perecentage over time by provider. | - -### Benchmark Page - -The benchmark page provides an overview of a single CIS Benchmark. You can select the benchmark from the dropdown as well as scope down to specific profile levels or regions. - -![image](https://github.com/user-attachments/assets/34498ee8-317b-4b81-b241-c561451d8def) - -The page has the following components: - -| Component | Description | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Compliance Percent Heatmap | Heatmap showing compliance percent by region and profile level | -| Benchmark Section by Requirement Status | Bar chart showing benchmark requirements by bennchmark section and status | -| Compliance percent Over Time by Region | Line chart showing overall compliance percentage over time by region | -| Benchmark Requirements | Table showing requirement section, requirement number, reuqirement title, number of resources tested, status, and number of failing checks | - -### Requirement Page - -The requirement page is a drill-through page to view details of a single requirement. To populate the requirement page right click on a requiement from the "Benchmark Requirements" table on the benchmark page and select "Drill through" -> "Requirement". - -![image](https://github.com/user-attachments/assets/5c9172d9-56fe-4514-b341-7e708863fad6) - -The requirement page has the following components: - -| Component | Description | -| ------------------------------------------ | --------------------------------------------------------------------------------- | -| Title | Title of the requirement | -| Rationale | Rationale of the requirement | -| Remediation | Remedation guidance for the requirement | -| Region by Check Status | Bar chart showing Prowler checks by region and status | -| Resource Checks for Benchmark Requirements | Table showing Resource ID, Resource Name, Status, Description, and Prowler Checkl | - -## Walkthrough Video -[![image](https://github.com/user-attachments/assets/866642c6-43ac-4aac-83d3-bb625002da0b)](https://www.youtube.com/watch?v=lfKFkTqBxjU) diff --git a/docs/user-guide/tutorials/prowler-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx new file mode 100644 index 0000000000..e4475849ae --- /dev/null +++ b/docs/user-guide/tutorials/prowler-alerts.mdx @@ -0,0 +1,146 @@ +--- +title: 'Alerts' +description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria. + + +This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). + + +## Prerequisites + +Before creating Alerts, ensure that: + +* At least one scan has completed and produced findings. +* The user role includes the `manage_alerts` permission. + +The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details. + +## How Alerts Work + +Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist. + + +Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters. + + +Alerts run on one of three schedules: + +| Frequency | Description | +|-----------|-------------| +| After each scan | Evaluates the Alert after each completed scan. | +| Daily digest | Evaluates the Alert once per day and sends a digest when findings match. | +| After each scan and daily | Evaluates the Alert after every scan and in the daily digest. | + +## Creating an Alert From Findings + +To create an Alert: + +1. Navigate to **Findings** in Prowler Cloud. +2. Apply at least one [Alert-compatible filter](#alert-compatible-filters) to define the findings that should trigger the Alert. +3. Click **Create Alert**. + + ![Create Alert From Findings](/images/prowler-app/alerts/create-alert-from-findings.png) + +4. Configure the Alert settings: + * **Name:** Add a short, descriptive name. + * **Description:** Add optional context for the Alert. + * **Frequency:** Select when Prowler Cloud should evaluate the Alert. + * **Recipients:** Select the recipients who should receive the email digest. + + ![Create Alert Modal](/images/prowler-app/alerts/create-alert-modal.png) + +5. Click **Create**. + +After the Alert is created, Prowler Cloud evaluates it based on the selected frequency. + +## Alert-Compatible Filters + +An **Alert-compatible filter** is a Findings-page filter that the Alert condition language can evaluate when the Alert runs. The Findings page exposes many filters, but only a specific subset can be saved into an Alert. Filters outside this subset, such as **Status**, free-text search, sort, or pagination, are ignored when seeding an Alert from the current Findings view. + +When **Create Alert** is clicked on the Findings page, Prowler Cloud takes the active filters, keeps only the Alert-compatible ones, and uses them to build the Alert condition. + +The following filters are Alert-compatible: + +* Provider type +* Provider +* Severity +* Delta (new findings since the previous scan) +* Region +* Service +* Resource type +* Category +* Resource group + +If only the **Status** filter is applied on the Findings page, Prowler Cloud substitutes all severities as the condition base so the Alert can still be created. Status itself never becomes part of the Alert condition. + +## Managing Alerts + +Navigate to **Alerts** to review and manage existing Alerts. + +![Alerts List](/images/prowler-app/alerts/alerts-list.png) + +Each Alert provides these actions: + +| Action | Description | +|--------|-------------| +| Edit | Update name, description, recipients, frequency, or filters. | +| Enable/Disable | Start or stop Alert evaluation without deleting the Alert. | +| Delete | Permanently remove the Alert. | + +## Testing Alert Filters + +When editing an Alert, click **Test** to preview whether the current filters match existing findings. + +The test result indicates whether the filters match findings and includes a summary of the matching results. + +![Edit Alert Test Result](/images/prowler-app/alerts/edit-alert-test.png) + + +**The Test result is a snapshot, not a guarantee of future Alert triggers.** + +The Test evaluates the current filters against existing findings at the moment **Test** is clicked. It does not predict whether the Alert will trigger on its next evaluation. The Alert trigger depends on the state at evaluation time: + +* **After each scan:** The Alert is evaluated against the findings produced by that scan only. If the next scan produces no findings that match the filters, the Alert will not trigger, even if a Test run earlier in the day showed matches. +* **Daily digest:** The Alert is evaluated against the findings present on the digest day. If no matching findings exist for that day, the Alert will not trigger, even if previous days had matches. + +The reverse is also true: a Test showing no matches does not guarantee the Alert will stay silent. Future scans may produce matching findings. + +Use **Test** to validate that the filters are well-formed and target the intended findings, not to forecast future Alert behavior. + + +## Recipients + +Alert recipients are selected from the email addresses available in the tenant. Recipients receive an email digest each time an Alert evaluates and matches findings. + + +By default, the **organization owner** receives a **daily digest** for **critical findings**. Adjust the recipient, frequency, or filters in the Alert configuration to change this behavior. + + +If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed. + +## Email Notifications + +When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes: + +* The scan name and evaluation time. +* The total number of matching findings. +* The number of Alert rules that triggered. +* A preview of the affected findings, grouped by severity, with resource details and the originating rule. +* A direct link to view all matching findings in Prowler Cloud. + +![Alert Email Example](/images/prowler-app/alerts/alert-email-example.png) + +## Best Practices + +* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services. +* **Use clear names:** Choose names that explain the intent of the Alert. +* **Review recipients regularly:** Keep recipient lists aligned with current ownership. +* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings. +* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients. diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index 646ee557de..c463e3144b 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -3,13 +3,13 @@ title: "Attack Paths" description: "Identify privilege escalation chains and security misconfigurations across cloud environments using graph-based analysis." --- -import { VersionBadge } from "/snippets/version-badge.mdx" +import { VersionBadge } from "/snippets/version-badge.mdx"; Attack Paths analyzes relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited by threat actors. -By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own — such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources. +By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources. Attack Paths is currently available for **AWS** providers. Support for @@ -21,7 +21,7 @@ By mapping these relationships as a graph, Attack Paths reveals risks that indiv The following prerequisites are required for Attack Paths: - **An AWS provider is configured** with valid credentials in Prowler App. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). -- **At least one scan has completed** on the configured AWS provider. Attack Paths scans run automatically alongside regular security scans — no separate configuration is required. +- **At least one scan has completed** on the configured AWS provider. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required. ## How Attack Paths Scans Work @@ -105,6 +105,183 @@ If a query requires no parameters, the form displays a message confirming that t width="700" /> +## Writing Custom openCypher Queries + +In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue. + +To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query. + +### Constraints and Safety Limits + +Custom queries are sandboxed to keep the graph database safe and responsive: + +- **Read-only:** Only read operations are allowed. Statements that mutate the graph (`CREATE`, `MERGE`, `SET`, `DELETE`, `REMOVE`, `DROP`, `LOAD CSV`, `CALL { ... }` writes, etc.) are rejected before execution. +- **Length limit:** Each query is capped at **10,000 characters**. +- **Scoped to the selected scan:** Results are automatically scoped to the provider and scan selected on the left panel. There is no need to filter by tenant or scan identifier in the query body. + +### Example Queries + +The following examples are read-only and can be pasted directly into the editor. Each one demonstrates a different graph traversal pattern. + +**Internet-exposed EC2 instances with their security group rules:** + +```cypher +MATCH (i:EC2Instance)--(sg:EC2SecurityGroup)--(rule:IpPermissionInbound) +WHERE i.exposed_internet = true +RETURN i.instanceid AS instance, sg.name AS security_group, + rule.fromport AS from_port, rule.toport AS to_port +LIMIT 25 +``` + +**EC2 instances that can assume IAM roles:** + +```cypher +MATCH (i:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(r:AWSRole) +WHERE i.exposed_internet = true +RETURN i.instanceid AS instance, r.name AS role_name, r.arn AS role_arn +LIMIT 25 +``` + +**IAM principals with wildcard Allow statements:** + +```cypher +MATCH (principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) +WHERE a.value = '*' +RETURN DISTINCT principal.arn AS principal, policy.arn AS policy +LIMIT 25 +``` + +**Critical findings on internet-exposed resources:** + +```cypher +MATCH (i:EC2Instance)-[:HAS_FINDING]->(f:ProwlerFinding) +WHERE i.exposed_internet = true AND f.status = 'FAIL' + AND f.severity IN ['critical', 'high'] +RETURN i.instanceid AS instance, f.check_id AS check, + f.severity AS severity, f.status AS status +LIMIT 50 +``` + +**Roles trusting an AWS service (building block for PassRole escalation):** + +```cypher +MATCH (r:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(p:AWSPrincipal) +WHERE p.arn ENDS WITH '.amazonaws.com' +RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service +LIMIT 25 +``` + +### Working with List-Typed Properties + +Some Cartography node properties carry a list of values, such as `action`, `resource`, `notaction`, and `notresource` on `AWSPolicyStatement` nodes, the algorithms on `KMSKey`, the container-definition lists on `ECSContainerDefinition`, and many others. The Attack Paths graph models each such property as a set of child item nodes connected to the parent by a typed edge. To read the values, traverse the edge; the parent does not carry the list as a single field. + +The naming convention for any list-typed property on a parent label is: + +- **Child label:** `Item`. Example: `AWSPolicyStatement.resource` resolves to `AWSPolicyStatementResourceItem`. +- **Edge type:** `HAS_`. Example: `resource` resolves to `HAS_RESOURCE`. +- **Child property:** `value` for scalar lists (one string per list element). List-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) carry the original dict keys as named fields on the child node. + +To express "at least one item in the list satisfies a predicate", traverse the `HAS_*` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`. `RETURN DISTINCT` collapses duplicate parent rows produced when multiple child items satisfy the filter: + +```cypher +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) +WHERE toLower(a.value) STARTS WITH 's3:get' + OR toLower(a.value) STARTS WITH 's3:list' +RETURN DISTINCT stmt +LIMIT 25 +``` + +To check whether every item in the list satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached. This is the one case where the pattern-comprehension form is the right tool: + +```cypher +MATCH (stmt:AWSPolicyStatement) +WHERE size([ + (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) + WHERE NOT toLower(a.value) STARTS WITH 's3:' + | a + ]) = 0 + AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0 +RETURN stmt +LIMIT 25 +``` + +For the "is any item of this list a substring of a dynamic value" case, such as "does any resource pattern in this policy match a target role ARN", add the `HAS_*` traversal as its own `MATCH` and check the substring relationship between the item value and the dynamic node in `WHERE`: + +```cypher +MATCH (role:AWSRole) +WHERE role.name = 'Admin' +MATCH (principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_RESOURCE]->(r:AWSPolicyStatementResourceItem) +WHERE r.value = '*' + OR r.value CONTAINS role.name + OR role.arn CONTAINS r.value +RETURN DISTINCT principal.arn AS principal, stmt, role +LIMIT 25 +``` + +To return the list of values directly, collect them from the child items: + +```cypher +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) +RETURN stmt, collect(a.value) AS actions +LIMIT 25 +``` + +### Working with JSON-Encoded Properties + +Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. In the Attack Paths graph, object-typed properties are stored as JSON-encoded strings to keep the schema portable across graph backends. The value looks like: + +``` +'{"StringEquals":{"aws:SourceAccount":"123456789012"}}' +``` + +There is no JSON parser available at query time, so use `CONTAINS` for substring checks against keys or known values: + +```cypher +MATCH (stmt:AWSPolicyStatement) +WHERE stmt.effect = 'Allow' + AND stmt.condition CONTAINS '"aws:SourceAccount"' +RETURN stmt +LIMIT 25 +``` + +When a query needs to inspect the structured members of a condition (for example, evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values. + +### Tips for Writing Queries + +- Start small with `LIMIT` to inspect the shape of the data before broadening the pattern. +- Traverse `HAS_*` edges to reach list-typed property values (for example `action`, `resource`). The parent node does not carry the list as a single field; see [Working with List-Typed Properties](#working-with-list-typed-properties) for the patterns. +- On large scans, avoid broad disconnected patterns such as `MATCH (a:Label), (b:OtherLabel)`. Bind one side with a selective predicate first, and use `WITH DISTINCT` between expanding traversals when duplicates are possible. +- Use `RETURN` projections (`RETURN n.name, n.region`) instead of returning whole nodes to keep responses compact. +- Combine resource nodes with `ProwlerFinding` nodes via `HAS_FINDING` to correlate misconfigurations with the affected resources. +- When a query times out or returns no rows, simplify the pattern step by step until the first variant runs successfully, then add constraints back. + +### Cartography Schema Reference + +Attack Paths graphs are populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework. The node labels, relationship types, and properties available in custom queries follow the upstream Cartography schema for the corresponding provider. + +For the complete catalogue of node labels and relationships available in custom queries, refer to the official Cartography schema documentation: + +- **AWS:** [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html) + +In addition to the upstream schema, Prowler enriches the graph with: + +- **`ProwlerFinding`** nodes representing Prowler check results, linked to affected resources via `HAS_FINDING` relationships. +- **`Internet`** nodes used to model exposure paths from the public internet to internal resources. +- **List-typed properties** such as `action` or `resource` on `AWSPolicyStatement`, the algorithm lists on `KMSKey`, and similar lists on other node types are modeled as child item nodes linked by typed `HAS_*` edges. See [Working with List-Typed Properties](#working-with-list-typed-properties) for the read pattern. +- **Object-typed properties** such as `condition` on `AWSPolicyStatement` are stored as JSON-encoded strings. See [Working with JSON-Encoded Properties](#working-with-json-encoded-properties) for the read pattern. + + + AI assistants connected through Prowler MCP Server can fetch the exact + Cartography schema for the active scan via the + `prowler_app_get_attack_paths_cartography_schema` tool. This guarantees that + generated queries match the schema version pinned by the running Prowler + release. + + ## Executing a Query To run the selected query against the scan data, click **Execute Query**. The button displays a loading state while the query processes. @@ -234,105 +411,106 @@ Attack Paths currently supports the following built-in queries for AWS: #### Custom Attack Path Queries -| Query | Description | -|---|---| +| Query | Description | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------- | | **Internet-Exposed EC2 with Sensitive S3 Access** | Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets | #### Basic Resource Queries -| Query | Description | -|---|---| -| **RDS Instances Inventory** | List all provisioned RDS database instances in the account | -| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled | -| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access | -| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) | -| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy | -| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action | +| Query | Description | +| ------------------------------------------- | ------------------------------------------------------------------- | +| **RDS Instances Inventory** | List all provisioned RDS database instances in the account | +| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled | +| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access | +| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) | +| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy | +| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action | #### Network Exposure Queries -| Query | Description | -|---|---| -| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet | +| Query | Description | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------- | +| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet | | **Open Security Groups on Internet-Facing Resources** | Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0 | -| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners | -| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners | -| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address | +| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners | +| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners | +| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address | #### Privilege Escalation Queries These queries are based on research from [pathfinding.cloud](https://pathfinding.cloud) by Datadog. -| Query | Description | -|---|---| -| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions | -| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role | -| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached | -| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials | -| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources | -| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role | -| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts | -| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role | -| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role | -| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec | -| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | -| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | -| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec | -| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure | -| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS | -| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions | -| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS | -| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed | -| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS | -| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code | -| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code | -| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster | -| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster | -| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code | -| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials | -| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions | -| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials | -| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions | -| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code | -| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions | -| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution | -| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges | -| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account | -| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions | -| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions | -| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges | -| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions | -| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges | -| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges | -| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges | -| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members | -| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members | -| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions | -| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group | -| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges | -| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges | -| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access | -| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges | -| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges | -| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | -| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access | -| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | -| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions | -| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions | -| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions | -| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions | -| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role | -| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions | -| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment | -| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions | -| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions | -| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions | -| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup | -| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS | -| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS | -| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role | +| Query | Description | +| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions | +| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role | +| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached | +| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials | +| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources | +| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role | +| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts | +| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role | +| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role | +| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec | +| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | +| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role | +| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec | +| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure | +| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS | +| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions | +| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS | +| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed | +| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS | +| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code | +| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code | +| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster | +| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster | +| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code | +| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials | +| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions | +| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials | +| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions | +| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code | +| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions | +| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution | +| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges | +| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account | +| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions | +| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions | +| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges | +| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions | +| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges | +| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges | +| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges | +| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members | +| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members | +| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions | +| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group | +| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges | +| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges | +| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access | +| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges | +| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges | +| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | +| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access | +| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access | +| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions | +| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions | +| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions | +| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions | +| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role | +| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions | +| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment | +| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions | +| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions | +| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions | +| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup | +| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS | +| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS | +| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role | These tools enable workflows such as: + - Asking an AI assistant to identify privilege escalation paths in a specific AWS account - Automating attack path analysis across multiple scans - Combining attack path data with findings and compliance information for comprehensive security reports diff --git a/docs/user-guide/tutorials/prowler-app-finding-groups.mdx b/docs/user-guide/tutorials/prowler-app-finding-groups.mdx new file mode 100644 index 0000000000..e1bf83a50f --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-finding-groups.mdx @@ -0,0 +1,119 @@ +--- +title: 'Finding Groups' +description: 'Organize and triage security findings by check to reduce noise and prioritize remediation effectively.' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Finding Groups transforms security findings triage by grouping them by check instead of displaying a flat list. This dramatically reduces noise and enables faster, more effective prioritization. + +## Triage Challenges with Flat Finding Lists + +A real cloud environment produces thousands of findings per scan. A flat list makes it impossible to triage effectively: + +- **Signal buried in noise**: the same misconfiguration repeated across 200 resources shows up as 200 rows, burying the signal in repetitive data +- **Prioritization guesswork**: without grouping, understanding which issues affect the most resources requires manual counting and correlation +- **Tedious muting**: muting a false positive globally requires manually acting on each individual finding across the list +- **Lost context**: when investigating a single resource, related findings are scattered across the same flat list, making it hard to see the full picture + +## How Finding Groups Addresses These Challenges + +Finding Groups addresses these challenges by intelligently grouping findings by check. + +### Grouped View at a Glance + +Each row represents a single check title with key information immediately visible: + +- **Severity** indicator for quick risk assessment +- **Impacted providers** showing which cloud platforms are affected +- **X of Y impacted resources** counter displaying how many resources fail this check + +For example, `Vercel project has the Web Application Firewall enabled` across every affected project collapses to a single row — not one per project. Sort or filter by severity, provider, or status at the group level to triage top-down instead of drowning in per-resource rows. + +![Finding Groups list view](/images/finding-groups-list.png) + +### Expanding Groups for Details + +Expand any group inline to see the failing resources with detailed information: + +| Column | Description | +|--------|-------------| +| **UID** | Unique identifier for the resource | +| **Service** | The cloud service the resource belongs to | +| **Region** | Geographic region where the resource is deployed | +| **Severity** | Risk level of the finding | +| **Provider** | Cloud provider (AWS, Azure, GCP, Kubernetes, etc.) | +| **Last Seen** | When the finding was last detected | +| **Failing For** | Duration the resource has been in a failing state | + +![Finding Groups expanded view](/images/finding-groups-expanded.png) + +### Resource Detail Drawer + +Select any resource to open the detail drawer with full finding context: + +- **Risk**: the security risk associated with this finding +- **Description**: detailed explanation of what was detected +- **Status Extended**: additional status information and context +- **Remediation**: step-by-step guidance to resolve the issue +- **View in Prowler Hub**: direct link to explore the check in Prowler Hub +- **Analyze This Finding With Lighthouse AI**: one-click AI-powered analysis for deeper insights + +![Finding Groups resource detail drawer](/images/finding-groups-drawer.png) + +### Bulk Actions + +Bulk-mute an entire group instead of chasing duplicates across the list. This is especially useful for: + +- Known false positives that appear across many resources +- Findings in development or test environments +- Accepted risks that have been documented and approved + + +Muting findings does not resolve underlying security issues. Review each finding carefully before muting to ensure it represents an acceptable risk or has been properly addressed. + + +## Other Findings for This Resource + +Inside the resource detail drawer, the **Other Findings For This Resource** tab lists every finding that hits the same resource — passing, failing, and muted — alongside the one currently being reviewed. + +![Other Findings For This Resource tab](/images/finding-groups-other-findings.png) + +### Why This Matters + +When reviewing "WAF not enabled" on a Vercel project, the tab immediately shows: + +- Skew protection status +- Rate limiting configuration +- IP blocking settings +- Custom firewall rules +- Password protection findings + +All for that same project, without navigating back to the main list and filtering by resource UID. + +### Complete Context Within the Drawer + +Pair the Other Findings tab with: + +- **Scans tab**: scan history for this resource +- **Events tab**: changes and events over time + +This provides full context without leaving the drawer. + +## Best Practices + +1. **Start with high severity groups**: focus on critical and high severity groups first for maximum impact. +2. **Use filters strategically**: filter by provider or status at the group level to narrow the triage scope. +3. **Leverage bulk mute**: when a finding represents a confirmed false positive, mute the entire group at once. +4. **Check related findings**: review the Other Findings tab to understand the full security posture of a resource. +5. **Track failure duration**: use the "Failing For" column to prioritize long-standing issues that may indicate systemic problems. + +## Getting Started + +1. Navigate to the **Findings** section in Prowler Cloud/App. +2. Toggle to the **Grouped View** to see findings organized by check. +3. Select any group row to expand and see affected resources. +4. Select a resource to open the detail drawer with full context. +5. Use the **Other Findings For This Resource** tab to see all findings for that resource. diff --git a/docs/user-guide/tutorials/prowler-app-github-action.mdx b/docs/user-guide/tutorials/prowler-app-github-action.mdx new file mode 100644 index 0000000000..c874a37164 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-github-action.mdx @@ -0,0 +1,265 @@ +--- +title: 'GitHub Action' +description: 'Run Prowler scans in GitHub Actions using the official Docker-based action' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +The official **Prowler GitHub Action** runs Prowler scans inside your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. It supports every [Prowler provider](/user-guide/providers/) (AWS, Azure, GCP, Kubernetes, GitHub, Cloudflare, IaC, and more), optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning so findings appear in the **Security** tab and as inline PR annotations. + +Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · Marketplace listing: [Prowler Security Scan](https://github.com/marketplace/actions/prowler-security-scan). + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `provider` | yes | — | Cloud provider to scan (`aws`, `azure`, `gcp`, `github`, `kubernetes`, `iac`, `cloudflare`, etc.) | +| `image-tag` | no | `stable` | Docker image tag — `stable` (latest release), `latest` (master, not stable), or `` (pinned). See [available tags](https://hub.docker.com/r/prowlercloud/prowler/tags). | +| `output-formats` | no | `json-ocsf` | Output format(s) for scan results. Space-separated (e.g. `sarif json-ocsf`) | +| `push-to-cloud` | no | `false` | Push findings to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings). When `true`, `PROWLER_CLOUD_API_KEY` is auto-forwarded | +| `flags` | no | `""` | Additional CLI flags (e.g. `--severity critical high`). Values with spaces can be quoted: `--resource-tag 'Environment=My Server'` | +| `extra-env` | no | `""` | Space-, newline-, or comma-separated list of env var **names** to forward to the container (see [Authentication](#authentication)) | +| `upload-sarif` | no | `false` | Upload SARIF results to GitHub Code Scanning | +| `sarif-file` | no | `""` | Path to SARIF file (auto-detected from `output/` if not set) | +| `sarif-category` | no | `prowler` | Category for the SARIF upload (distinguishes multiple analyses) | +| `fail-on-findings` | no | `false` | Fail the workflow step when findings are detected (exit code 3) | + +## Usage + +### AWS scan + +```yaml +- uses: prowler-cloud/prowler@5.25 + with: + provider: aws + extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} +``` + +### Push findings to Prowler Cloud + +Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings) for centralized visibility, compliance tracking, and team collaboration. + +```yaml +- uses: prowler-cloud/prowler@5.25 + with: + provider: aws + push-to-cloud: true + extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} + PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }} +``` + + +When `push-to-cloud: true`, `PROWLER_CLOUD_API_KEY` is forwarded automatically — set it in `env:` but don't list it in `extra-env`. Requires a Prowler Cloud subscription and an API key with the **Manage Ingestions** permission. See [API Keys](/user-guide/tutorials/prowler-app-api-keys). + + +### Upload SARIF to GitHub Code Scanning + +Findings appear in the **Security** tab and as **inline PR annotations** when SARIF upload is enabled. + +```yaml +name: Prowler IaC Scan +on: + pull_request: + +permissions: + contents: read + security-events: write + actions: read + +jobs: + prowler: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: prowler-cloud/prowler@5.25 + with: + provider: iac + output-formats: sarif json-ocsf + upload-sarif: true + flags: --severity critical high +``` + + +**Requirements:** +- Include `sarif` in `output-formats` (the action warns if this is missing). +- The workflow needs `security-events: write` and `actions: read` permissions. +- GitHub Code Scanning is free for public repositories. Private repositories require a [GitHub Code Security](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security) license. + + +### Combine push-to-cloud with SARIF upload + +```yaml +- uses: prowler-cloud/prowler@5.25 + with: + provider: aws + output-formats: sarif json-ocsf + push-to-cloud: true + upload-sarif: true + extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} + PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }} +``` + +### Scan the current repository with the GitHub provider + +```yaml +name: Prowler GitHub Scan +on: + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + +jobs: + prowler: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: prowler-cloud/prowler@5.25 + with: + provider: github + flags: --repository ${{ github.repository }} + extra-env: GITHUB_PERSONAL_ACCESS_TOKEN + env: + GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.PROWLER_GITHUB_PAT }} +``` + + +`--repository` scans a single repo. Use `--organization ` instead to include org-level checks (MFA, security policies, etc.). See the [GitHub provider authentication](/user-guide/providers/github/authentication) for required token permissions. + + +### Fail the PR on findings + +By default the action tolerates findings (exit code 3) and succeeds. Set `fail-on-findings: true` to fail the workflow step when Prowler detects findings. Combine with `--severity` to control which severity levels trigger the failure: + +```yaml +- uses: prowler-cloud/prowler@5.25 + with: + provider: iac + output-formats: sarif + upload-sarif: true + fail-on-findings: true + flags: --severity critical high +``` + +The scan step fails if critical/high findings are detected, blocking the PR via required checks. SARIF is still uploaded (the upload step runs with `if: always()`) so findings appear in the Security tab regardless. + +## Authentication + +Each provider requires its own credentials passed as environment variables. Credentials are **not forwarded automatically** — list every env var name you need in the `extra-env` input, and set its value via `env:` at the step, job, or workflow level (typically from `secrets.*`). + +Refer to the [Prowler provider docs](/user-guide/providers/) for the full list of variables each provider supports. Common ones: + +| Provider | Typical `extra-env` | +|----------|---------------------| +| AWS | `AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_DEFAULT_REGION` (OIDC exports these automatically) | +| Azure | `AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_TENANT_ID` | +| GCP | `GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_ACCESS_TOKEN GOOGLE_CLOUD_PROJECT` | +| GitHub | `GITHUB_PERSONAL_ACCESS_TOKEN` *(or `GITHUB_OAUTH_APP_TOKEN`, or `GITHUB_APP_ID GITHUB_APP_KEY`)* | +| Kubernetes | `KUBECONFIG` | +| Cloudflare | `CLOUDFLARE_API_TOKEN` *(or `CLOUDFLARE_API_KEY CLOUDFLARE_API_EMAIL`)* | + + +`PROWLER_CLOUD_API_KEY` is auto-forwarded when `push-to-cloud: true` — no need to add it to `extra-env`. + + +### AWS + +Use [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) with OIDC (recommended) or pass static credentials. OIDC sets `AWS_*` env vars on the runner, so you only forward them: + +```yaml +permissions: + id-token: write + contents: read + +steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/ProwlerRole + aws-region: eu-west-1 + + - uses: prowler-cloud/prowler@5.25 + with: + provider: aws + extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_DEFAULT_REGION +``` + +### Azure + +Use [azure/login](https://github.com/Azure/login) with a service principal or pass credentials directly: + +```yaml +steps: + - uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - uses: prowler-cloud/prowler@5.25 + with: + provider: azure + extra-env: AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_TENANT_ID + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} +``` + +### GCP + +Use [google-github-actions/auth](https://github.com/google-github-actions/auth) with Workload Identity Federation (recommended): + +```yaml +permissions: + id-token: write + contents: read + +steps: + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider + service_account: prowler@my-project.iam.gserviceaccount.com + + - uses: prowler-cloud/prowler@5.25 + with: + provider: gcp + extra-env: GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_ACCESS_TOKEN GOOGLE_CLOUD_PROJECT +``` + +### Cloudflare + +Create a Cloudflare API Token with `Zone:Read`, `Zone Settings:Read`, and `DNS:Read` permissions ([provider auth docs](/user-guide/providers/cloudflare/authentication)). Then: + +```yaml +- uses: prowler-cloud/prowler@5.25 + with: + provider: cloudflare + extra-env: CLOUDFLARE_API_TOKEN + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} +``` + +## Outputs + +Scan results are written to `output/` in the workspace and uploaded as artifacts named `prowler-` with 30-day retention. + +When `upload-sarif` is enabled, SARIF results are also uploaded to GitHub Code Scanning and appear on the repository's **Security → Code scanning** tab, filtered by the branch that ran the scan. + +### Step summary + +The action writes a summary to the run page with a per-severity breakdown of failing checks, artifact and Code Scanning links, and (when `push-to-cloud: false`) a pointer to [Prowler Cloud](https://cloud.prowler.com) for continuous monitoring. + +GitHub Actions run page showing the Prowler IaC Scan Summary with failing and passing counts, severity breakdown, scan log link, artifact link, and GitHub Code Security link diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx index cf4886d2ee..b119893cb5 100644 --- a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx +++ b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx @@ -25,8 +25,7 @@ Behind the scenes, Lighthouse AI works as follows: Lighthouse AI supports multiple LLM providers including OpenAI, Amazon Bedrock, and OpenAI-compatible services. For configuration details, see [Using Multiple LLM Providers with Lighthouse](/user-guide/tutorials/prowler-app-lighthouse-multi-llm). -Prowler Lighthouse Architecture -Prowler Lighthouse Architecture +![Prowler Lighthouse Architecture](/images/lighthouse-architecture.png) diff --git a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx new file mode 100644 index 0000000000..b353bc85c5 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx @@ -0,0 +1,187 @@ +--- +title: 'Managing Organizations (Multi-Tenant)' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler App supports multi-tenancy through **Organizations**, allowing users to belong to multiple isolated environments within a single account. Each organization maintains its own providers, scans, findings, and user memberships, ensuring complete data separation between teams or business units. + +## Key Concepts + +* **Organization (Tenant):** An isolated workspace containing its own providers, scans, findings, roles, and users. Every Prowler account operates within at least one organization. +* **Membership:** The association between a user and an organization, including the membership role (`owner` or `member`). +* **Active Organization:** The organization currently in use for the session. All actions (scans, findings, provider management) apply to the active organization. + + +When a new account is created without an invitation, a default organization is automatically provisioned. Accounts created through an invitation join the inviter's organization instead. + + + +## Viewing Organizations + +To view all organizations associated with an account, navigate to the **Profile** page. The **Organizations** card displays every organization the user belongs to, including the role, name, join date, and whether it is the currently active organization. + +Organizations card in profile page + +## Creating an Organization + +To create a new organization: + +1. Navigate to the **Profile** page. + +2. In the **Organizations** card, click the **Create organization** button. + + Create organization button + +3. Enter a name for the new organization (maximum 100 characters). + + Create organization modal + +4. Click **Create**. The session automatically switches to the newly created organization. + + +Creating an organization requires being authenticated. Any user can create a new organization regardless of their current role. + + + +## Switching Between Organizations + +To switch the active organization: + +1. Navigate to the **Profile** page. + +2. In the **Organizations** card, locate the organization to switch to. + +3. Click the **Switch** button next to the desired organization. + +4. Confirm the switch in the dialog. The page reloads with the new organization's context, and all subsequent actions apply to it. + + Switch organization confirmation modal + + +The currently active organization is indicated by an **Active** badge. Switching updates the session tokens, so the page will reload automatically. + + + +## Editing an Organization Name + +Renaming an organization requires **both** of the following conditions to be met: + +* The user's **membership role** in that organization must be `owner` (visible as the `owner` badge in the Organizations card). +* The user must have a role that grants the **Manage Account** permission. + +Users who only meet one of the two conditions will not see the **Edit** button. For example, a user whose membership role is `member` will not see the **Edit** button even if their role grants `Manage Account`. + +To rename an organization: + +1. Navigate to the **Profile** page. + +2. In the **Organizations** card, click the **Edit** button next to the organization. + +3. Update the name and save the changes. + + Edit organization name modal + +## Deleting an Organization + +Organization owners with the **Manage Account** permission can delete an organization, provided they belong to at least two organizations (the last remaining organization cannot be deleted). + +### Deleting a Non-Active Organization + +1. Navigate to the **Profile** page. + +2. Click the **Delete** button next to the organization to remove. + +3. Type the organization name to confirm deletion. + + Delete organization confirmation modal + +4. Click **Delete**. The organization and all its associated data (providers, scans, findings) are permanently removed. + +### Deleting the Active Organization + +When deleting the currently active organization, an additional step is required: + +1. Navigate to the **Profile** page. + +2. Click the **Delete** button next to the active organization. + +3. Select which organization to switch to after deletion. + +4. Type the organization name to confirm. + + Delete active organization modal with target selection + +5. Click **Delete**. The session switches to the selected organization, and the deleted organization's data is permanently removed. + + +Deleting an organization is irreversible. All providers, scans, findings, and configuration data within the organization are permanently deleted. Users who belong only to the deleted organization will lose access to Prowler. + + +## Accepting an Invitation to an Organization + +When invited to join an organization, the invited user receives a link to accept the invitation. The flow adapts depending on whether the user already has a Prowler account: + +### Existing Users + +1. Open the invitation link. + +2. If already authenticated, the invitation is accepted automatically and the user is redirected to Prowler App. + +3. If not authenticated, choose **I have an account -- Sign in**, authenticate with existing credentials, and the invitation is accepted upon sign-in. + + Sign in screen after choosing I have an account from invitation + +### New Users + +1. Open the invitation link. + +2. Choose **I'm new -- Create an account**. + +3. Complete the sign-up process. Upon account creation, the invitation is accepted and the user joins the inviter's organization. + + +Invitations expire after 7 days. If an invitation has expired, contact the organization administrator to send a new one. For more details on invitation management, see [Managing Users and Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac#invitations). + + + +## Expelling a User From an Organization + +Organization owners can expel a member from the organization. Expelling removes the membership immediately, revoking access to all providers, scans, and findings scoped to that organization. Owners expelling themselves are blocked if they are the last remaining owner of the organization. + +To expel a user: + +1. Navigate to the **Users** page. + +2. Locate the user to remove and open the row actions menu. + +3. Select **Expel user**. + + Users table row action menu showing the 'Expel user' destructive option + + +4. Confirm the action in the dialog. The membership is removed immediately and the expelled user loses access to the organization. + + Confirmation dialog asking to expel the selected user from the current organization + + + +Expelling a user revokes any refresh tokens the account holds, but access tokens already issued remain valid until they expire. The default access token lifetime is 30 minutes, so an expelled user may retain access to the organization for up to that window before being fully locked out. + + + +If the expelled organization was the user's **only** organization, the account is permanently deleted along with the membership. All personal profile data associated with that account is removed and cannot be recovered. To preserve the account, confirm that the user belongs to another organization before expelling. + + +## Permissions Reference + +| Action | Required Conditions | +|--------|-------------------| +| View organizations | Any authenticated user | +| Create an organization | Any authenticated user | +| Switch organizations | Any authenticated user | +| Edit organization name | Membership role `owner` **and** a role with **Manage Account** permission | +| Delete an organization | Membership role `owner` **and** a role with **Manage Account** permission; must belong to more than one organization | +| Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves | diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index 8ac3a9f07b..cabea5bd35 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -47,7 +47,11 @@ Follow these steps to remove a user of your account: 1. Navigate to **Users** from the side menu. 2. Click the delete button of your current user. -> **Note: Each user will be able to delete himself and not others, regardless of his permissions.** +> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.** + +Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row. + +To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**. Remove User @@ -123,7 +127,7 @@ The Roles section in Prowler is designed to facilitate the assignment of custom ### Provider Groups -Provider Groups control visibility across specific providers. When creating a new role, you can assign specific groups to define their Cloud Provider visibility. This ensures that users with that role have access only to the Cloud Providers that are required. +Provider Groups control visibility across specific providers. When creating a new role, you can assign specific groups to define their Provider visibility. This ensures that users with that role have access only to the Providers that are required. By default, a new user role does not have visibility into any group. @@ -223,10 +227,11 @@ Assign administrative permissions by selecting from the following options: | Invite and Manage Users | All | Invite new users and manage existing ones. | | Manage Account | All | Adjust account settings, delete users and read/manage users permissions. | | Manage Scans | All | Run and review scans. | -| Manage Cloud Providers | All | Add or modify connected cloud providers. | +| Manage Providers | All | Add or modify connected providers. | | Manage Integrations | All | Add or modify the Prowler Integrations. | | Manage Ingestions | Prowler Cloud | Allow or deny the ability to submit findings ingestion batches via the API. | | Manage Billing | Prowler Cloud | Access and manage billing settings and subscription information. | +| Manage Alerts | Prowler Cloud | Create, edit, and delete alert rules and recipients. | The **Scope** column indicates where each permission applies. **All** means the permission is available in both Prowler Cloud and Self-Managed deployments. **Prowler Cloud** indicates permissions that are specific to [Prowler Cloud](https://cloud.prowler.com/sign-in). @@ -238,6 +243,8 @@ To grant all administrative permissions, select the **Grant all admin permission The following permissions are available exclusively in **Prowler Cloud**: -**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-app-import-findings) for details. +**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details. **Manage Billing:** Access and manage billing settings, subscription plans, and payment methods. + +**Manage Alerts:** Create, edit, and delete alert rules and recipients used to deliver scan-result digests via email. diff --git a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx index 728e4a4354..284b10eaaf 100644 --- a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx @@ -320,7 +320,7 @@ Once the required permissions are set up, proceed to configure the S3 integratio ![Add integration button](/images/prowler-app/s3/s3-integration-ui-3.png) 4. Complete the configuration form with the following details: - - **Cloud Providers:** Select the providers whose scan results should be exported to this S3 bucket + - **Providers:** Select the providers whose scan results should be exported to this S3 bucket - **Bucket Name:** Enter the name of the target S3 bucket (e.g., `my-security-findings-bucket`) - **Output Directory:** Specify the directory path within the bucket (e.g., `/prowler-findings/`, defaults to `output`) diff --git a/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx new file mode 100644 index 0000000000..451fdc6d99 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx @@ -0,0 +1,208 @@ +--- +title: 'Scan Configuration' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Scan Configuration lets you override Prowler's built-in scan defaults per tenant and per provider, directly from Prowler App — without editing files or redeploying. Each configuration is a small YAML document that changes how specific checks behave (thresholds, allowed values, retention windows, and so on), and you attach it to the cloud providers that should use it on their next scan. + + +Scan Configuration is a **Prowler Cloud-only** feature. The open-source API does not expose the `scan-configurations` endpoints, so the menu item and provider actions described here only appear in Prowler Cloud. + + +## What Is a Scan Configuration? + +Every Prowler scan reads a set of tunable values documented in [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml) — for example, how many days an access key can stay unused before it's flagged, or the minimum retention period for a storage bucket. A Scan Configuration is a **partial override** of those defaults: + +- You include **only** the keys you want to change. Everything else falls back to Prowler's built-in defaults. +- It is stored per tenant and applied to the **providers you attach** to it. +- A provider can be attached to **at most one** Scan Configuration at a time. +- Changes take effect on the provider's **next scan** — they do not re-run past scans. + +This is different from the [Mutelist](/user-guide/tutorials/prowler-app-mute-findings), which hides findings. A Scan Configuration changes how the checks themselves evaluate your resources. + +## Where to Find It + +In Prowler Cloud, open **Configuration → Scan** in the sidebar, or go directly to `/scans/config`. The page lists every Scan Configuration in your tenant, with search by name and a filter by provider. + +## Creating a Scan Configuration + + + + On the **Scan** page, click **New Scan Configuration**. + + + Give the configuration a descriptive **Name** (3–100 characters), e.g. `stricter-iam-aws`. Names must be unique within your tenant. + + + In the **Configuration (YAML)** field, add only the keys you want to override, grouped by provider. The editor is pre-filled with a representative default placeholder you can use as a starting point. + + + Under **Attach to providers**, pick the providers that should use this configuration. This is optional — you can save without any provider and attach them later. + + + Click **Save**. The server validates the configuration values and, if everything is valid, stores it and attaches the selected providers. + + + +### YAML Structure + +The YAML follows the structure of `config.yaml`: a mapping keyed by provider, with each provider section holding the keys you want to override. + +```yaml +aws: + max_unused_access_keys_days: 30 + max_console_access_days: 30 + max_security_group_rules: 25 + +azure: + defender_attack_path_minimal_risk_level: "Critical" + +gcp: + storage_min_retention_days: 30 +``` + +Scan Configuration works for **every provider Prowler scans** — you key your overrides by provider using the same section names as `config.yaml`. Each provider below ships a configuration schema, so its values are checked on save (ranges, enums, and types): + +| Provider | Section key | +| --- | --- | +| AWS | `aws` | +| Azure | `azure` | +| Google Cloud | `gcp` | +| Kubernetes | `kubernetes` | +| Microsoft 365 | `m365` | +| GitHub | `github` | +| MongoDB Atlas | `mongodbatlas` | +| Cloudflare | `cloudflare` | +| Vercel | `vercel` | +| Okta | `okta` | +| Alibaba Cloud | `alibabacloud` | +| OpenStack | `openstack` | + +Sections that aren't listed here — those contributed by third-party check plugins, or providers that don't yet ship tunable defaults — are **accepted as-is** and applied without server-side value validation. + + +You don't need to fill in every provider — include only the sections and keys you actually want to change. The placeholder shown in the editor is just an example; if you leave the field with only the placeholder (greyed-out) text, nothing is saved. + + +## How Validation Works + +Validation happens in two layers, mirroring the Advanced Mutelist editor: + +1. **Client-side (live): YAML syntax only.** As you type, the editor checks that the text parses to a valid YAML mapping. If it doesn't, you'll see an `Invalid YAML format` message and the **Save** button is disabled. When the syntax is valid, it shows **Valid YAML format**. +2. **Server-side (on save): configuration values.** When you click Save (or Update), the API validates the actual values — ranges, enums, and types — against Prowler's schema. Any problems are returned and shown **inline beneath the field**, for both create and edit. + +For example, `azure.defender_attack_path_minimal_risk_level` only accepts `Low`, `Medium`, `High`, or `Critical`. Saving any other value returns an inline error like: + +``` +azure.defender_attack_path_minimal_risk_level: Input should be 'Low', 'Medium', 'High' or 'Critical' +``` + + +"Valid YAML format" confirms only that the document is **syntactically** correct — it does **not** mean the values are valid. Value validation (ranges and enums) is performed by the server when you save. + +Be careful with indentation. A line like `azure: defender_attack_path_minimal_risk_level: Critical` (no newline/indent after `azure:`) is *valid YAML*, but it parses to a single top-level key named `azure:defender_attack_path_minimal_risk_level` instead of the nested `azure` section — so the value is never applied. Always nest provider keys: + +```yaml +azure: + defender_attack_path_minimal_risk_level: "Critical" +``` + + + +Unknown top-level sections and unknown keys inside a known provider section are **tolerated** (accepted without error) for backward compatibility with third-party check plugins. This means typos in section or key names won't be rejected on save — double-check your structure against `config.yaml`. + + +## Attaching Providers + +A Scan Configuration only has an effect once it's attached to one or more providers. There are two ways to manage attachments. + +### From the Scan Config Editor + +In the **Attach to providers** field, select the providers that should use this configuration. Providers already attached to **another** configuration are hidden from the selector, since each provider can belong to only one configuration at a time. + +### From the Provider's Row Menu + +You can also manage a provider's configuration from **Providers**: + + + + On the **Providers** page, open the **⋮** menu on a provider row. + + + Click **Edit Scan Configuration**. + + + In the dialog, choose an existing configuration from the dropdown to associate it, pick a different one to move the provider, or select **Default** to detach it. **Default** means the provider uses Prowler's built-in scan defaults from the SDK (no custom configuration), and it's always available — even if no custom configurations exist yet. Then click **Save**. + + + + +This dialog only **associates or disassociates** an existing configuration. To create or edit the configuration's YAML, use the **Scan Config** view (a link is provided in the dialog). + + + +Because a provider can belong to only one configuration, associating a provider that is already attached elsewhere **moves** it to the new configuration automatically — it is removed from the previous one. + + +## Editing and Deleting + +On the **Scan Config** page, open the **⋮** menu on a configuration row: + +- **Edit:** Choose **Edit** to open the editor, change its name, YAML, or attached providers, and click **Update**. Editing the YAML always happens here, never from the provider row. +- **Delete:** Choose **Delete** (in the danger zone) and confirm. Providers that were attached fall back to Prowler's built-in scan defaults on their next scan. + +## How It's Applied + +When a scan runs for a provider: + +1. If the provider is attached to a Scan Configuration, Prowler applies that configuration's overrides on top of the built-in defaults. +2. If it isn't attached to any, the built-in defaults from `config.yaml` are used. + +Overrides are merged key by key: any value you don't set keeps its default. + +## Common Examples + +**Stricter IAM hygiene for AWS:** + +```yaml +aws: + max_unused_access_keys_days: 30 + max_console_access_days: 30 + max_unused_sagemaker_access_days: 45 +``` + +**Raise Azure Defender attack-path sensitivity:** + +```yaml +azure: + defender_attack_path_minimal_risk_level: "Critical" +``` + +**Tighten GCP storage retention and key rotation:** + +```yaml +gcp: + storage_min_retention_days: 30 + secretmanager_max_rotation_days: 30 +``` + +## Troubleshooting + + +**Save is disabled.** The YAML has a syntax error (or the field is empty). Fix the `Invalid YAML format` message shown beneath the editor. + + + +**An inline error appears after saving.** The server rejected a value (out of range or not an allowed enum). The message names the exact path, e.g. `aws.max_unused_access_keys_days: ...`. Correct the value and save again. + + + +**A provider doesn't appear in the selector.** It's already attached to another Scan Configuration. Detach it there first, or use the provider row menu to move it. + + + +**My override doesn't seem to apply.** Check indentation (provider keys must be nested under their section) and key spelling — unknown keys are silently accepted. Compare against [`config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml). + diff --git a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx new file mode 100644 index 0000000000..2f10d443ae --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx @@ -0,0 +1,287 @@ +--- +title: 'SAML SSO: Google Workspace' +--- + +This page explains how to configure SAML-based Single Sign-On (SSO) in Prowler App using **Google Workspace** as the Identity Provider (IdP). The setup is divided into two parts: create a custom SAML app in Google Admin Console, then complete the configuration in Prowler App. + + +**Parallel Setup Required** + +Google Admin Console requires the ACS URL and Entity ID from Prowler App, while Prowler App displays these values only after opening the SAML configuration dialog. To work around this, open Prowler App in a separate browser tab, navigate to the profile page, open the "Configure SAML SSO" dialog, and copy the ACS URL and Entity ID before proceeding with the Google configuration. + + + +## Prerequisites + +- **Google Workspace**: Super Admin access (or delegated admin with app management permissions). +- **Prowler App**: Administrator access to the organization (role with "Manage Account" permission). +- Prowler App version **5.9.0** or later. + +--- + +## Part A - Google Admin Console + +### Step 1: Navigate to Web & Mobile Apps + +1. Go to [admin.google.com](https://admin.google.com). +2. In the left sidebar, navigate to **Apps > Web and mobile apps**. +3. Click "Add app", then select "Add custom SAML app". + +![Google Admin Console - Web & mobile apps](/images/prowler-app/saml/saml-sso-gw-1.png) + +### Step 2: Enter App Details + +1. In the **App name** field, enter a name (e.g., `Prowler`). +2. Optionally, add a description (e.g., `Prowler SAML APP`) and upload a logo. +3. Click "Continue". + +![Add custom SAML app - App details](/images/prowler-app/saml/saml-sso-gw-2.png) + +### Step 3: Download the IdP Metadata + +On the **Google Identity Provider details** screen: + +1. Google displays two options: + - **Option 1**: Click "Download Metadata" to save the XML file directly. This is the recommended approach. + - **Option 2**: Manually copy the **SSO URL**, **Entity ID**, and **Certificate**. +2. Download the metadata. This file is required to complete the Prowler App configuration in Part B. +3. Click "Continue". + +![Google Identity Provider details - Download metadata](/images/prowler-app/saml/saml-sso-gw-3.png) + + +**Save the Metadata File** + +Download and save the IdP metadata XML file before proceeding. This file cannot be easily retrieved later and is required to complete the SAML configuration in Prowler App. + + + +### Step 4: Configure the Service Provider Details + +Enter the following values obtained from the SAML SSO configuration dialog in Prowler App (see [Part B, Step 1](#step-1-open-the-saml-configuration-dialog) for details on where to find them): + +| Google Workspace Field | Value | +|------------------------|-------| +| **ACS URL** | The Assertion Consumer Service (ACS) URL displayed in Prowler App (e.g., `https://api.prowler.com/api/v1/accounts/saml/your-domain.com/acs/`). Self-hosted deployments use a different base URL. | +| **Entity ID** | The Audience URI displayed in Prowler App (e.g., `urn:prowler.com:sp`). | +| **Name ID format** | Select `EMAIL` from the dropdown. | +| **Name ID** | Select `Basic Information > Primary email` from the dropdown. | + +Click "Continue". + +![Service provider details - ACS URL, Entity ID, and Name ID configuration](/images/prowler-app/saml/saml-sso-gw-4.png) + +### Step 5: Configure Attribute Mapping + +To correctly provision users, configure the IdP to send the following attributes in the SAML assertion. The **App Attribute (SAML)** column lists the attribute names that Prowler expects. The **Google Directory Attribute** column shows a recommended source field, but any Google directory attribute can be used as long as it is mapped to the correct Prowler attribute name. + +Click "Add mapping" for each entry: + +| Google Directory Attribute | App Attribute (SAML) | Required | Notes | +|----------------------------|----------------------|----------|-------| +| `Basic Information > First name` | `firstName` | Yes | | +| `Basic Information > Last name` | `lastName` | Yes | | +| `Employee Details > Department` | `userType` | No | Determines the Prowler role. **Case-sensitive.** | +| `Employee Details > Organization` | `organization` | No | Company name displayed in Prowler App profile. | + + +**Remember the Mapped Fields** + +Take note of which Google directory attributes are mapped to each Prowler attribute. To update a user's role or organization in Prowler, modify the corresponding field in the user's Google Workspace profile (e.g., **Department** if mapped to `userType`). Changes propagate to Prowler on the next SAML login. + + + +Click "Finish" to create the SAML app. + +![Attribute mapping - Google Directory attributes to Prowler SAML attributes](/images/prowler-app/saml/saml-sso-gw-5.png) + + +**Dynamic Updates** + +Prowler App updates user attributes each time a user logs in. Any changes made in Google Workspace are reflected on the next login. + + + + +**Role Assignment via `userType`** + +The `userType` attribute controls which Prowler role is assigned to the user: + +- If `userType` matches an existing Prowler role name, the user receives that role automatically. +- If `userType` does not match any existing role, Prowler App creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. +- If `userType` is not set, the user's existing roles are left unchanged. + +The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles. + + + +### Step 6: Enable the App for Users + +By default, newly created SAML apps have user access set to **OFF**. To enable access: + +1. Return to **Apps > Web and mobile apps** and select the Prowler SAML app. +2. Click "User access" (or "View details" under the "User access" section). +3. Set the service status to **ON for everyone**, or enable it for specific organizational units or groups. +4. Click "Save". + + ![Service Status - Set to ON for everyone](/images/prowler-app/saml/saml-sso-gw-17.png) + +5. Verify in the apps list that the "User access" column displays **"ON for everyone"**. + + ![Web & mobile apps list - User access confirmed as "ON for everyone"](/images/prowler-app/saml/saml-sso-gw-19.png) + + +**Propagation Delay** + +Changes to the app status can take up to 24 hours to propagate across Google Workspace, although they typically take effect within a few minutes. + + + + +**"Can't Test SAML Login" Error** + +If attempting to use the "Test SAML login" option in Google Admin Console and receiving a "Can't test SAML login" message, click "Allow Access" to enable the app for the organizational unit that includes the admin account. This is the same as setting the service status to **ON** as described above. + +![Test SAML login - Allow access prompt](/images/prowler-app/saml/saml-sso-gw-15.png) + + + +--- + +## Part B - Prowler App Configuration + +### Step 1: Open the SAML Configuration Dialog + +1. Navigate to the profile settings page: + - **Prowler Cloud**: `https://cloud.prowler.com/profile` + - **Self-hosted**: `http://{your-domain}/profile` +2. Find the "SAML SSO Integration" card and click "Enable" (or "Update" if already configured). +3. The "Configure SAML SSO" dialog opens, displaying: + - **ACS URL**: The Assertion Consumer Service URL (copy this value for Part A, Step 4). This URL updates dynamically when the email domain is entered. + - **Audience**: The Entity ID (copy this value for Part A, Step 4). + - **Name ID Format**: The expected format (`urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`). + - **Supported Assertion Attributes**: The list of accepted attributes (`firstName`, `lastName`, `userType`, `organization`). + +![Prowler App - Configure SAML SSO dialog (initial state)](/images/prowler-app/saml/saml-sso-gw-prowler-1.png) + +### Step 2: Enter the Email Domain and Upload Metadata + +1. Enter the **email domain** for the organization (e.g., `prowler.cloud`). Prowler App uses this domain to identify users who should authenticate via SAML. The ACS URL updates automatically to reflect the configured domain. +2. Upload the **metadata XML file** downloaded in Part A, Step 3. +3. Click "Save". + +![Prowler App - Configure SAML SSO dialog (domain entered and ready to save)](/images/prowler-app/saml/saml-sso-gw-prowler-2.png) + +### Step 3: Verify the Enabled Status + +The "SAML SSO Integration" card should now display a **"Status: Enabled"** indicator with a checkmark, confirming that the configuration is complete. + +![Prowler App - SAML SSO Integration status showing "Enabled"](/images/prowler-app/saml/saml-sso-gw-prowler-3.png) + +--- + +## Testing the Integration + +### Optional: Create a Test User in Google Workspace + +To verify the integration without affecting existing users, create a dedicated test user in Google Admin Console: + +1. Navigate to **Directory > Users** in Google Admin Console. +2. Click "Add new user". + + ![Google Admin Console - Users directory](/images/prowler-app/saml/saml-sso-gw-7.png) + +3. Fill in the user details (first name, last name, and primary email address in the configured domain). + + ![Add new user form](/images/prowler-app/saml/saml-sso-gw-8.png) + +4. Complete the user creation. Google Workspace generates temporary credentials for the new account. + + ![User created successfully - Username and temporary password](/images/prowler-app/saml/saml-sso-gw-10.png) + +### Optional: Configure User Attributes for Role Mapping + +To test the `userType` → role mapping, set the **Department** attribute in the test user's profile. This value is sent as the `userType` SAML attribute based on the mapping configured in Part A, Step 5. + +1. In **Directory > Users**, click the test user's name to open the profile. +2. Click "User details", scroll to **Employee information**, and enter a value in the **Department** field (e.g., `Backend`). This value determines the Prowler role assigned to the user. +3. Click "Save". + + ![User information - Setting Department to "Backend" for userType mapping](/images/prowler-app/saml/saml-sso-gw-13.png) + +### SP-Initiated SSO (from Prowler) + +1. Navigate to the Prowler login page. +2. Click "Continue with SAML SSO". +3. Enter an email from the configured domain (e.g., `adrian@prowler.cloud`). +4. Click "Log in". The browser redirects to Google for authentication and returns to Prowler App upon success. + +![Prowler App - Sign in with SAML SSO](/images/prowler-app/saml/saml-sso-gw-prowler-4.png) + +### Verify User Profile and Role Mapping + +After a successful SSO login, the user profile in Prowler App reflects the attributes sent by Google Workspace: + +- **Name**: Populated from the `firstName` and `lastName` attributes. +- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with read-only access by default. +- **Permissions**: If the assigned permissions need to be adjusted, a Prowler administrator can either: + - Edit the permissions of the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. + - Set the `userType` attribute in the IdP to match an existing Prowler role that already has the desired permissions. The updated role is applied on the next SAML login. + +For more details on role assignment behavior and attribute mapping, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#configure-attribute-mapping-in-the-idp) page. + +![Prowler App - User profile showing role "Backend" created from userType mapping](/images/prowler-app/saml/saml-sso-gw-prowler-5.png) + +### IdP-Initiated SSO (from Google) + +1. Sign in to Google Workspace with an account that has access to the Prowler SAML app. +2. Open the Google Workspace app launcher (the grid icon in the top-right corner of any Google page). +3. Click the Prowler app tile. +4. The browser redirects directly to Prowler App, authenticated. + +For more information on the SSO login flows, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#idp-initiated-sso) page. + +--- + +## Troubleshooting + + +**User Lockout After Misconfiguration** + +If SAML is configured with incorrect metadata or an incorrect domain, users who authenticated via SAML cannot fall back to password login. A Prowler administrator must remove the SAML configuration via the API: + +```bash +curl -X DELETE 'https://api.prowler.com/api/v1/saml-config' \ + -H 'Authorization: Bearer ' \ + -H 'Accept: application/vnd.api+json' +``` + +After removal, affected users must reset their password to regain access using standard email and password login. This also applies when SAML is intentionally removed - all SAML-authenticated users need to reset their password. For more details, refer to the [SAML API Reference](/user-guide/tutorials/prowler-app-sso#saml-api-reference). For additional support, contact [Prowler Support](https://docs.prowler.com/user-guide/contact-support). + + + + +**Email Domain Uniqueness** + +Prowler does not allow two tenants to share the same email domain. If the domain is already associated with another tenant, the configuration will fail. This is by design to prevent authentication ambiguity. + + + + +**Just-in-Time Provisioning** + +Users who authenticate via SAML for the first time are automatically created in Prowler App. No prior invitation is needed. User attributes (`firstName`, `lastName`, `userType`) are updated on every login from the Google directory. + + + +--- + +## Quick Summary + +1. In **Google Admin Console**, create a custom SAML app using the ACS URL and Entity ID from Prowler App. +2. Configure **attribute mapping**: `firstName`, `lastName`, and optionally `userType` and `organization`. +3. **Download the metadata XML** from Google. +4. **Enable the app** in Google Workspace for the relevant users or groups. +5. In **Prowler App**, enter the email domain, upload the metadata XML, and save. +6. Verify the SAML SSO Integration shows **"Status: Enabled"**. +7. Test login via "Continue with SAML SSO" on the Prowler login page. diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index f823fae3df..1e61ec1060 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -75,7 +75,7 @@ Choose a Method: **IdP Configuration** - The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. For SSO integration with Azure AD / Entra ID, see our [Entra ID configuration instructions](/user-guide/tutorials/prowler-app-sso-entra). + The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, Google Workspace, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. For SSO integration with Azure AD / Entra ID, see our [Entra ID configuration instructions](/user-guide/tutorials/prowler-app-sso-entra). For Google Workspace, see our [Google Workspace configuration instructions](/user-guide/tutorials/prowler-app-sso-google-workspace). @@ -87,8 +87,8 @@ Choose a Method: |----------------|---------------------------------------------------------------------------------------------------------|----------| | `firstName` | The user's first name. | Yes | | `lastName` | The user's last name. | Yes | - | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | - | `companyName` | The user's company name. This is automatically populated if the IdP sends an `organization` attribute. | No | + | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | + | `organization` | The user's company name. | No | **IdP Attribute Mapping** @@ -140,7 +140,7 @@ Choose a Method: ![Okta User Profile — First Name and Last Name](/images/prowler-app/saml/okta-user-profile-name.png) * **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional. - * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive** and must match the exact name of an existing role in Prowler App. + * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive**: if it matches the exact name of an existing role in Prowler App the user receives that role; if no role with that name exists, a new one is created with read-only access. ![Okta User Profile — User Type and Organization](/images/prowler-app/saml/okta-user-profile-attributes.png) @@ -152,14 +152,10 @@ Choose a Method: The `userType` attribute controls which Prowler role is assigned to the user: * If a role with the specified name already exists in Prowler App, the user automatically receives that role. - * If the role does not exist, Prowler App creates a new role with that exact name but without any permissions, preventing the user from performing any actions. - * If `userType` is not defined in the user's Okta profile, the user is assigned the `no_permissions` role. + * If the role does not exist, Prowler App creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). + * If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler App are left unchanged. - In all cases where the resulting role has no permissions, a Prowler administrator (a user whose role includes the "Manage Account" permission) must configure the appropriate permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). - - This behavior is intentional: by defaulting to no permissions, Prowler App ensures that a misconfiguration in Okta cannot inadvertently grant elevated access. - - **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` without permissions, and a Prowler administrator must configure the desired permissions for it. + **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed. diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 0b368c5ad4..5e99a41ae7 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -72,8 +72,8 @@ To perform security scans, link a cloud provider account. Prowler supports the f Steps to add a provider: -1. Navigate to `Settings > Cloud Providers`. -2. Click `Add Account` to set up a new provider and provide your credentials. +1. Navigate to `Settings > Providers`. +2. Click `Add Provider` to set up a new provider and provide your credentials. Add Provider diff --git a/docs/user-guide/tutorials/prowler-check-kreator.mdx b/docs/user-guide/tutorials/prowler-check-kreator.mdx deleted file mode 100644 index 253f659814..0000000000 --- a/docs/user-guide/tutorials/prowler-check-kreator.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: 'Prowler Check Kreator' ---- - - -Currently, this tool is only available for creating checks for the AWS provider. - - - - -If you are looking for a way to create new checks for all the supported providers, you can use [Prowler Studio](https://github.com/prowler-cloud/prowler-studio), it is an AI-powered toolkit for generating and managing security checks for Prowler (better version of the Check Kreator). - - - -## Introduction - -**Prowler Check Kreator** is a utility designed to streamline the creation of new checks for Prowler. This tool generates all necessary files required to add a new check to the Prowler repository. Specifically, it creates: - -- A dedicated folder for the check. -- The main check script. -- A metadata file with essential details. -- A folder and file structure for testing the check. - -## Usage - -To use the tool, execute the main script with the following command: - -```bash -python util/prowler_check_kreator/prowler_check_kreator.py -``` - -Parameters: - -- ``: Currently only AWS is supported. -- ``: The name you wish to assign to the new check. - -## AI integration - -This tool optionally integrates AI to assist in generating the check code and metadata file content. When AI assistance is chosen, the tool uses [Gemini](https://gemini.google.com/) to produce preliminary code and metadata. - - -For this feature to work, you must have the library `google-generativeai` installed in your Python environment. - - - - -AI-generated code and metadata might contain errors or require adjustments to align with specific Prowler requirements. Carefully review all AI-generated content before committing. - - - -To enable AI assistance, simply confirm when prompted by the tool. Additionally, ensure that the `GEMINI_API_KEY` environment variable is set with a valid Gemini API key. For instructions on obtaining your API key, refer to the [Gemini documentation](https://ai.google.dev/gemini-api/docs/api-key). diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index e56627ee20..d0c71c7e40 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx" Prowler Cloud enables you to onboard all AWS accounts in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI. -This feature is **exclusively available in Prowler Cloud**. For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). +This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). ## Overview @@ -22,9 +22,9 @@ This feature is **exclusively available in Prowler Cloud**. For CLI-based multi- | **Individual accounts** | A few AWS accounts | Connect each account one by one with its own IAM role. | | **AWS Organizations** | 10+ accounts, or any org-managed environment | Connect once to your management account, discover all member accounts automatically, and scan them in bulk. | -### How it works +### How It Works -Before using the AWS Organizations wizard, you need to deploy **two IAM roles** in your AWS environment. The onboarding follows this sequence: +Before using the AWS Organizations wizard, you need to deploy **two Identity and Access Management (IAM) roles** in your AWS environment. The onboarding follows this sequence: Onboarding flow: 1. Create Management Account Role (Quick Create or Manual), 2. Deploy StackSet, 3. Run the Wizard, 4. Launch Scans @@ -32,7 +32,7 @@ Before using the AWS Organizations wizard, you need to deploy **two IAM roles** ## Key Concepts -### What is an External ID? +### What Is an External ID? An **External ID** is a security token that Prowler generates unique to your tenant. When Prowler assumes the IAM role in your AWS account, it presents this External ID to prove its identity. @@ -57,7 +57,7 @@ Prowler requires **two separate IAM roles** deployed in different places, each w **Same name, different permissions.** Both roles are named `ProwlerScan` — Prowler expects a consistent role name across all accounts. The management account role has the same scanning permissions as member accounts, plus additional Organizations discovery permissions (see [Step 1](#step-1-create-the-management-account-role) for the full list). -### What is a CloudFormation StackSet? +### What Is a CloudFormation StackSet? A [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html) lets you deploy the same CloudFormation template across multiple AWS accounts in a single operation. Prowler uses a StackSet to deploy the **ProwlerScan** IAM role into every member account of your organization, so you don't have to create the role manually in each account. @@ -246,10 +246,10 @@ Now that both roles are deployed — the management account role (Step 1) and th ### Open the Wizard -1. Navigate to **Cloud Providers** and click **Add Cloud Provider**. +1. Navigate to **Providers** and click **Add Provider**. - Cloud Providers page showing the Add Cloud Provider button + Providers page showing the Add Provider button 2. Select **Amazon Web Services** as the provider. @@ -437,14 +437,11 @@ If connection tests fail, here's how to fix common issues: ### Choose Scan Schedule -| Schedule Option | Description | -|-----------------|-------------| -| **Scan Daily (every 24 hours)** | Creates a recurring daily scan for all connected accounts (default). | -| **Run a single scan (no recurring schedule)** | Launches a one-time scan. | +The Organizations wizard uses the same schedule controls described in [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling#schedule-options). ### Launch -Click **Launch scan**. A toast notification confirms: *"Scan Launched — Daily scan scheduled for X accounts"* with a link to the Scans page. You will be redirected to the **Providers** page. +Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both. The toast includes a link to the **Scans** page. Prowler redirects to the **Providers** page. Scans are only launched for accounts that are accessible (passed connection testing) and were selected. diff --git a/docs/user-guide/tutorials/prowler-app-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx similarity index 96% rename from docs/user-guide/tutorials/prowler-app-import-findings.mdx rename to docs/user-guide/tutorials/prowler-import-findings.mdx index 48e6049943..6a3276e403 100644 --- a/docs/user-guide/tutorials/prowler-app-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-import-findings.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx" Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. -This feature is available exclusively in **Prowler Cloud** with a paid subscription. +This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). ## OCSF Detection Finding format @@ -365,6 +365,10 @@ Prowler must be installed in the CI/CD environment before running scans. Refer t ### GitHub Actions + +For new projects, use the official [Prowler GitHub Action](/user-guide/tutorials/prowler-app-github-action) — a Docker-based reusable action that runs scans, optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning. The example below documents the legacy pip-based flow. + + ```yaml - name: Install Prowler run: pip install prowler diff --git a/docs/user-guide/tutorials/prowler-scan-scheduling.mdx b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx new file mode 100644 index 0000000000..7b9173de05 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx @@ -0,0 +1,107 @@ +--- +title: 'Scan Scheduling' +description: 'Create, edit, and monitor recurring scans in Prowler Cloud and Enterprise.' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Scan Scheduling lets Prowler run recurring scans for connected providers. Use it to keep findings, compliance results, and resource inventory up to date without launching every scan manually. + + +This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). + + +## Prerequisites + +Before creating or editing scan schedules, ensure that: + +* At least one provider is connected. +* The user role includes the **Manage Scans** permission, configured through Role-Based Access Control (RBAC). See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details. + +## Schedule Options + +A Prowler Cloud or Enterprise subscription supports the following custom recurring schedule options. Prowler self-hosted runs a daily scan automatically and does not expose custom cadence controls. + +| Schedule Option | Description | Cloud & Enterprise | Self-Hosted | +|-----------------|-------------|--------------------|-------------| +| Daily | Runs one scan every day at the selected time. | Yes | Yes | +| Every 48 hours | Runs one scan every 48 hours, anchored to the selected time. | Yes | — | +| Weekly | Runs one scan every week on the selected day and time. | Yes | — | +| Monthly | Runs one scan every month on the selected day, from day 1 to day 28. | Yes | — | + +The scan time is always selected on the hour (for example, 14:00); minutes cannot be set. The schedule time uses the browser timezone when the schedule is saved. Prowler displays the next scheduled scan in that timezone. + +## Create a Schedule From Scans + +To create a schedule from the **Scans** page: + +1. Navigate to **Scans**. +2. Click **Launch Scan**. +3. Select a connected provider. +4. Select **On a schedule**. +5. Choose the **Scan Time** and **Repeats** values. +6. Optional: select **Launch an initial scan now for immediate findings** to run a scan immediately after saving the recurring schedule. +7. Click **Save Schedule**. + + + Launch A Scan modal showing On a schedule mode, weekly schedule controls, and Save Schedule button + + +After the schedule is saved, Prowler shows a confirmation toast with a link to the **Scheduled** tab. + +## Edit Schedules From Providers + +The **Providers** page shows each provider's current schedule in the **Scan Schedule** column. Providers without a recurring schedule show **None**. + + + Providers table showing the Scan Schedule column with Daily and None schedule states + + +To edit a provider schedule: + +1. Navigate to **Providers**. +2. Open the provider row actions menu. +3. Click **Edit Scan Schedule**. +4. Update the schedule fields. +5. Click **Save**. + + + Edit Scan Schedule modal showing a weekly provider schedule and Remove Scan Schedule action + + +To stop automatic scans for a provider, click **Remove Scan Schedule** in the edit modal. Removing a schedule stops future automatic scans; existing completed scan results remain available. + +## Bulk Edit Schedules + +Use bulk schedule editing when several providers need the same recurring cadence. + +To bulk edit provider schedules: + +1. Navigate to **Providers**. +2. Select the provider rows that should receive the same schedule. +3. Open the selected-row actions menu. +4. Click **Edit Scan Schedule (N)**, where **N** is the number of selected providers. +5. Save the schedule. + +For AWS Organizations and Organizational Unit rows, **Edit Scan Schedule** applies the schedule to the connected child providers in that group. + + +Bulk schedule edits apply one schedule to every selected provider. If the wrong providers are selected, Prowler applies the same cadence to unintended providers. To recover, reopen bulk edit with the correct selection or update affected provider schedules individually. + + +## Review Scheduled Scans + +To review upcoming scheduled scans: + +1. Navigate to **Scans**. +2. Click the **Scheduled** tab. + +The **Scheduled** tab shows configured schedules, next scan time, and last scan time. Pending rows represent configured schedules that have not started their next scan yet. + + + Scans Scheduled tab showing pending scheduled scans, schedule cadence, next scan, and last scan columns + + +To edit a schedule from this tab, open the row actions menu and click **Edit Scan Schedule**. diff --git a/docs/user-guide/tutorials/v2_to_v3_checks_mapping.mdx b/docs/user-guide/tutorials/v2_to_v3_checks_mapping.mdx index 4262a22be7..04c00bee17 100644 --- a/docs/user-guide/tutorials/v2_to_v3_checks_mapping.mdx +++ b/docs/user-guide/tutorials/v2_to_v3_checks_mapping.mdx @@ -2,7 +2,7 @@ Prowler v3 and v4 introduce distinct identifiers while preserving the checks originally implemented in v2. This change was made because, in previous versions, check names were primarily derived from the CIS Benchmark for AWS. Starting with v3 and v4, all checks are independent of any security framework and have unique names and IDs. -For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/cli/tutorials/compliance) section. +For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/compliance/tutorials/compliance) section. ``` checks_v4_v3_to_v2_mapping = { diff --git a/mcp_server/AGENTS.md b/mcp_server/AGENTS.md index c8f77bd4b1..a82cc42e33 100644 --- a/mcp_server/AGENTS.md +++ b/mcp_server/AGENTS.md @@ -2,7 +2,7 @@ > **Skills Reference**: See [`prowler-mcp`](../skills/prowler-mcp/SKILL.md) -### Auto-invoke Skills +## Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: @@ -68,7 +68,7 @@ Python 3.12+ | FastMCP 2.13.1 | httpx (async) | Pydantic | uv ## PROJECT STRUCTURE -``` +```text mcp_server/prowler_mcp_server/ ├── server.py # Main orchestration ├── prowler_hub/server.py # Hub tools (no auth) diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md index 09d8114196..8f1438a3bb 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -2,12 +2,48 @@ All notable changes to the **Prowler MCP Server** are documented in this file. -## [0.6.0] (Prowler UNRELEASED) +## [0.7.2] (Prowler v5.28.1) + +### 🐞 Fixed + +- Preserve authorization header in HTTP mode [(#11366)](https://github.com/prowler-cloud/prowler/pull/11366) + +--- + +## [0.7.1] (Prowler v5.28.0) + +### 🔐 Security + +- `fastmcp` from 2.14.0 to 3.2.4 for GHSA-5h2m-4q8j-pqpj, GHSA-rww4-4w9c-7733, and GHSA-vv7q-7jx5-f767, which also pulls fixed `jaraco.context`, `python-multipart`, `starlette`, and drops the vulnerable `lupa`/`urllib3` transitive deps [(#11284)](https://github.com/prowler-cloud/prowler/pull/11284) + +--- + +## [0.7.0] (Prowler v5.27.0) + +### 🚀 Added + +- Finding Groups tools [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140) + +### 🔐 Security + +- `cryptography` from 46.0.1 to 47.0.0 (transitive) for CVE-2026-39892 and CVE-2026-26007 / CVE-2026-34073 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978) + +--- + +## [0.6.0] (Prowler v5.23.0) ### 🚀 Added - Resource events tool to get timeline for a resource (who, what, when) [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412) +### 🔄 Changed + +- Pin `httpx` dependency to exact version for reproducible installs [(#10593)](https://github.com/prowler-cloud/prowler/pull/10593) + +### 🔐 Security + +- `authlib` bumped from 1.6.5 to 1.6.9 to fix CVE-2026-28802 (JWT `alg: none` validation bypass) [(#10579)](https://github.com/prowler-cloud/prowler/pull/10579) + --- ## [0.5.0] (Prowler v5.21.0) @@ -16,6 +52,8 @@ All notable changes to the **Prowler MCP Server** are documented in this file. - Attack Path tool to get Neo4j DB schema [(#10321)](https://github.com/prowler-cloud/prowler/pull/10321) +--- + ## [0.4.0] (Prowler v5.19.0) ### 🚀 Added diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index d8377a1762..c2759b20de 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -1,7 +1,7 @@ # ============================================================================= # Build stage - Install dependencies and build the application # ============================================================================= -FROM ghcr.io/astral-sh/uv:python3.13-alpine@sha256:8f53782bb232ab0b5558f3071e86e2bbfde884e18815f2b19cc57f2d336e9ee2 AS builder +FROM ghcr.io/astral-sh/uv:0.11.21-python3.13-alpine3.23@sha256:f09cc61ffc001f202701fdeae14dbdd50f6ca4cfcf248f41fd3234a302c8534f AS builder WORKDIR /app @@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================================= # Final stage - Minimal runtime environment # ============================================================================= -FROM python:3.13-alpine@sha256:bb1f2fdb1065c85468775c9d680dcd344f6442a2d1181ef7916b60a623f11d40 +FROM python:3.13.14-alpine3.23@sha256:b0513989fa9be54569cac73f48a60320b74bb0f9ffa886568eea7e48a2432c04 LABEL maintainer="https://github.com/prowler-cloud" diff --git a/mcp_server/README.md b/mcp_server/README.md index 41c5271d61..e990f0f363 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -10,6 +10,7 @@ Full access to Prowler Cloud platform and self-managed Prowler App for: - **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments +- **Finding Groups Analysis**: Triage findings grouped by check ID and drill down into affected resources - **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.) - **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments - **Resource Inventory**: Search and view detailed information about your audited resources @@ -56,13 +57,21 @@ Prowler MCP Server can be used in three ways: - Managed and maintained by Prowler team - Always up-to-date +Install a reviewed version of `mcp-remote` in a dedicated local workspace first. Avoid running `npx mcp-remote` directly because it can download and execute a new package version on each run. + +```bash +mkdir -p ~/.local/share/prowler-mcp-bridge +cd ~/.local/share/prowler-mcp-bridge +npm init -y +npm install --save-exact mcp-remote@0.1.38 +``` + ```json { "mcpServers": { "prowler": { - "command": "npx", + "command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote", "args": [ - "mcp-remote", "https://mcp.prowler.com/mcp", "--header", "Authorization: Bearer pk_YOUR_API_KEY_HERE" @@ -74,14 +83,14 @@ Prowler MCP Server can be used in three ways: ### 2. Local STDIO Mode -**Run the server locally on your machine** +Run the server locally on your machine: - Runs as a subprocess of your MCP client - Requires Python 3.12+ or Docker ### 3. Self-Hosted HTTP Mode -**Deploy your own remote MCP server** +Deploy your own remote MCP server: - Full control over deployment - Requires Python 3.12+ or Docker @@ -123,7 +132,7 @@ All tools follow a consistent naming pattern with prefixes: ## Architecture -``` +```text prowler_mcp_server/ ├── server.py # Main orchestrator (imports sub-servers with prefixes) ├── main.py # CLI entry point @@ -145,17 +154,20 @@ prowler_mcp_server/ The Prowler MCP Server enables powerful workflows through AI assistants: -**Security Operations** +### Security Operations + - "Show me all critical findings from my AWS production accounts" - "Register my new AWS account in Prowler and run a scheduled scan every day" - "List all muted findings and detect what findgings are muted by a not enough good reason in relation to their severity" -**Security Research** +### Security Research + - "Explain what the S3 bucket public access Prowler check does" - "Find all Prowler checks related to encryption at rest" - "What is the latest version of the CIS that Prowler is covering per provider?" -**Documentation & Learning** +### Documentation & Learning + - "How do I configure Prowler to scan my GCP organization?" - "What authentication methods does Prowler support for Azure?" - "How can I contribute with a new security check to Prowler?" diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py index cfd704eeca..bbe2eb7401 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py @@ -12,9 +12,10 @@ for optimal LLM token usage. from typing import Any, Literal -from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin from pydantic import BaseModel, ConfigDict, Field +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + class AttackPathScan(MinimalSerializerMixin, BaseModel): """Simplified attack paths scan representation for list operations. diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py b/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py index 4dfe5c4839..c68bd0df85 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py @@ -2,7 +2,6 @@ from typing import Any, Literal -from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin from pydantic import ( BaseModel, ConfigDict, @@ -11,6 +10,8 @@ from pydantic import ( model_serializer, ) +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + class ComplianceRequirementAttribute(MinimalSerializerMixin, BaseModel): """Requirement attributes including associated check IDs. diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py b/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py new file mode 100644 index 0000000000..ae8431ba63 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py @@ -0,0 +1,291 @@ +"""Pydantic models for Prowler Finding Groups responses.""" + +from typing import Literal + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + +FindingStatus = Literal["FAIL", "PASS", "MANUAL"] +FindingSeverity = Literal["critical", "high", "medium", "low", "informational"] +FindingDelta = Literal["new", "changed"] + + +def _attributes(data: dict) -> dict: + return data.get("attributes", {}) + + +def _counter(attributes: dict, key: str) -> int: + return attributes.get(key) or 0 + + +def _simplified_group_kwargs(data: dict) -> dict: + attributes = _attributes(data) + return { + "check_id": attributes.get("check_id", data.get("id", "")), + "check_title": attributes.get("check_title"), + "severity": attributes.get("severity", "informational"), + "status": attributes.get("status", "MANUAL"), + "muted": attributes.get("muted", False), + "impacted_providers": attributes.get("impacted_providers") or [], + "resources_fail": _counter(attributes, "resources_fail"), + "resources_total": _counter(attributes, "resources_total"), + "pass_count": _counter(attributes, "pass_count"), + "fail_count": _counter(attributes, "fail_count"), + "manual_count": _counter(attributes, "manual_count"), + "muted_count": _counter(attributes, "muted_count"), + "new_count": _counter(attributes, "new_count"), + "changed_count": _counter(attributes, "changed_count"), + "first_seen_at": attributes.get("first_seen_at"), + "last_seen_at": attributes.get("last_seen_at"), + "failing_since": attributes.get("failing_since"), + } + + +class SimplifiedFindingGroup(MinimalSerializerMixin): + """Finding group summary optimized for browsing many checks.""" + + check_id: str = Field(description="Public check ID that identifies this group") + check_title: str | None = Field( + default=None, description="Human-readable check title" + ) + severity: FindingSeverity = Field(description="Highest severity in the group") + status: FindingStatus = Field(description="Aggregated finding group status") + muted: bool = Field( + description="Whether all findings in this group are muted or accepted" + ) + impacted_providers: list[str] = Field( + default_factory=list, + description="Provider types impacted by this finding group", + ) + resources_fail: int = Field( + description="Number of non-muted failing resources in this group", ge=0 + ) + resources_total: int = Field( + description="Total number of resources in this group", ge=0 + ) + pass_count: int = Field( + description="Number of non-muted PASS findings in this group", ge=0 + ) + fail_count: int = Field( + description="Number of non-muted FAIL findings in this group", ge=0 + ) + manual_count: int = Field( + description="Number of non-muted MANUAL findings in this group", ge=0 + ) + muted_count: int = Field(description="Total muted findings in this group", ge=0) + new_count: int = Field(description="Number of new non-muted findings", ge=0) + changed_count: int = Field(description="Number of changed non-muted findings", ge=0) + first_seen_at: str | None = Field( + default=None, description="First time this group was detected" + ) + last_seen_at: str | None = Field( + default=None, description="Last time this group was detected" + ) + failing_since: str | None = Field( + default=None, description="First time this group started failing" + ) + + @classmethod + def from_api_response(cls, data: dict) -> "SimplifiedFindingGroup": + """Transform JSON:API finding group response to simplified format.""" + return cls(**_simplified_group_kwargs(data)) + + +class DetailedFindingGroup(SimplifiedFindingGroup): + """Finding group with complete counters and descriptive context.""" + + check_description: str | None = Field( + default=None, description="Description of the check behind this group" + ) + pass_muted_count: int = Field(description="Muted PASS findings", ge=0) + fail_muted_count: int = Field(description="Muted FAIL findings", ge=0) + manual_muted_count: int = Field(description="Muted MANUAL findings", ge=0) + new_fail_count: int = Field(description="New non-muted FAIL findings", ge=0) + new_fail_muted_count: int = Field(description="New muted FAIL findings", ge=0) + new_pass_count: int = Field(description="New non-muted PASS findings", ge=0) + new_pass_muted_count: int = Field(description="New muted PASS findings", ge=0) + new_manual_count: int = Field(description="New non-muted MANUAL findings", ge=0) + new_manual_muted_count: int = Field(description="New muted MANUAL findings", ge=0) + changed_fail_count: int = Field(description="Changed non-muted FAIL findings", ge=0) + changed_fail_muted_count: int = Field( + description="Changed muted FAIL findings", ge=0 + ) + changed_pass_count: int = Field(description="Changed non-muted PASS findings", ge=0) + changed_pass_muted_count: int = Field( + description="Changed muted PASS findings", ge=0 + ) + changed_manual_count: int = Field( + description="Changed non-muted MANUAL findings", ge=0 + ) + changed_manual_muted_count: int = Field( + description="Changed muted MANUAL findings", ge=0 + ) + + @classmethod + def from_api_response(cls, data: dict) -> "DetailedFindingGroup": + """Transform JSON:API finding group response to detailed format.""" + attributes = _attributes(data) + + return cls( + **_simplified_group_kwargs(data), + check_description=attributes.get("check_description"), + pass_muted_count=_counter(attributes, "pass_muted_count"), + fail_muted_count=_counter(attributes, "fail_muted_count"), + manual_muted_count=_counter(attributes, "manual_muted_count"), + new_fail_count=_counter(attributes, "new_fail_count"), + new_fail_muted_count=_counter(attributes, "new_fail_muted_count"), + new_pass_count=_counter(attributes, "new_pass_count"), + new_pass_muted_count=_counter(attributes, "new_pass_muted_count"), + new_manual_count=_counter(attributes, "new_manual_count"), + new_manual_muted_count=_counter(attributes, "new_manual_muted_count"), + changed_fail_count=_counter(attributes, "changed_fail_count"), + changed_fail_muted_count=_counter(attributes, "changed_fail_muted_count"), + changed_pass_count=_counter(attributes, "changed_pass_count"), + changed_pass_muted_count=_counter(attributes, "changed_pass_muted_count"), + changed_manual_count=_counter(attributes, "changed_manual_count"), + changed_manual_muted_count=_counter( + attributes, "changed_manual_muted_count" + ), + ) + + +class FindingGroupsListResponse(MinimalSerializerMixin): + """Paginated response for finding group list queries.""" + + groups: list[SimplifiedFindingGroup] = Field( + description="Finding groups matching the query" + ) + total_num_groups: int = Field( + description="Total groups matching the query across all pages", ge=0 + ) + total_num_pages: int = Field(description="Total pages available", ge=0) + current_page: int = Field(description="Current page number", ge=1) + + @classmethod + def from_api_response(cls, response: dict) -> "FindingGroupsListResponse": + """Transform JSON:API list response to simplified format.""" + pagination = response.get("meta", {}).get("pagination", {}) + groups = [ + SimplifiedFindingGroup.from_api_response(item) + for item in response.get("data", []) + ] + + return cls( + groups=groups, + total_num_groups=pagination.get("count", len(groups)), + total_num_pages=pagination.get("pages", 1), + current_page=pagination.get("page", 1), + ) + + +class FindingGroupResourceInfo(MinimalSerializerMixin): + """Nested resource information for a finding group row.""" + + uid: str = Field(description="Provider-native resource UID") + name: str = Field(description="Resource name") + service: str = Field(description="Cloud service") + region: str = Field(description="Cloud region") + type: str = Field(description="Resource type") + resource_group: str | None = Field( + default=None, description="Provider resource group or equivalent" + ) + + @classmethod + def from_api_response(cls, data: dict) -> "FindingGroupResourceInfo": + """Transform nested resource data to simplified format.""" + return cls( + uid=data.get("uid", ""), + name=data.get("name", ""), + service=data.get("service", ""), + region=data.get("region", ""), + type=data.get("type", ""), + resource_group=data.get("resource_group"), + ) + + +class FindingGroupProviderInfo(MinimalSerializerMixin): + """Nested provider information for a finding group resource row.""" + + type: str = Field(description="Provider type") + uid: str = Field(description="Provider-native account or subscription ID") + alias: str | None = Field(default=None, description="Provider alias") + + @classmethod + def from_api_response(cls, data: dict) -> "FindingGroupProviderInfo": + """Transform nested provider data to simplified format.""" + return cls( + type=data.get("type", ""), + uid=data.get("uid", ""), + alias=data.get("alias"), + ) + + +class FindingGroupResource(MinimalSerializerMixin): + """Resource row affected by a finding group.""" + + id: str = Field(description="Row identifier for this finding group resource") + resource: FindingGroupResourceInfo = Field(description="Affected resource") + provider: FindingGroupProviderInfo = Field(description="Affected provider") + finding_id: str = Field( + description="Finding UUID to use with prowler_app_get_finding_details" + ) + status: FindingStatus = Field(description="Finding status for this resource") + severity: FindingSeverity = Field(description="Finding severity") + muted: bool = Field(description="Whether the finding is muted") + delta: FindingDelta | None = Field(default=None, description="Change status") + first_seen_at: str | None = Field(default=None, description="First seen time") + last_seen_at: str | None = Field(default=None, description="Last seen time") + muted_reason: str | None = Field(default=None, description="Mute reason") + + @classmethod + def from_api_response(cls, data: dict) -> "FindingGroupResource": + """Transform JSON:API finding group resource response.""" + attributes = _attributes(data) + + return cls( + id=data.get("id", ""), + resource=FindingGroupResourceInfo.from_api_response( + attributes.get("resource") or {} + ), + provider=FindingGroupProviderInfo.from_api_response( + attributes.get("provider") or {} + ), + finding_id=str(attributes.get("finding_id", "")), + status=attributes.get("status", "MANUAL"), + severity=attributes.get("severity", "informational"), + muted=attributes.get("muted", False), + delta=attributes.get("delta"), + first_seen_at=attributes.get("first_seen_at"), + last_seen_at=attributes.get("last_seen_at"), + muted_reason=attributes.get("muted_reason"), + ) + + +class FindingGroupResourcesListResponse(MinimalSerializerMixin): + """Paginated response for finding group resource queries.""" + + resources: list[FindingGroupResource] = Field( + description="Resources matching the finding group query" + ) + total_num_resources: int = Field( + description="Total resources matching the query across all pages", ge=0 + ) + total_num_pages: int = Field(description="Total pages available", ge=0) + current_page: int = Field(description="Current page number", ge=1) + + @classmethod + def from_api_response(cls, response: dict) -> "FindingGroupResourcesListResponse": + """Transform JSON:API resource list response to simplified format.""" + pagination = response.get("meta", {}).get("pagination", {}) + resources = [ + FindingGroupResource.from_api_response(item) + for item in response.get("data", []) + ] + + return cls( + resources=resources, + total_num_resources=pagination.get("count", len(resources)), + total_num_pages=pagination.get("pages", 1), + current_page=pagination.get("page", 1), + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/findings.py b/mcp_server/prowler_mcp_server/prowler_app/models/findings.py index 5ef8702ce4..1373e36294 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/findings.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/findings.py @@ -2,9 +2,10 @@ from typing import Literal -from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin from pydantic import BaseModel, ConfigDict, Field +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + class CheckRemediation(MinimalSerializerMixin, BaseModel): """Remediation information for a security check.""" diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/muting.py b/mcp_server/prowler_mcp_server/prowler_app/models/muting.py index 779acdde5e..e50a150d20 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/muting.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/muting.py @@ -2,9 +2,10 @@ from typing import Any -from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin from pydantic import BaseModel, ConfigDict, Field +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + class MutelistResponse(MinimalSerializerMixin, BaseModel): """Simplified mutelist response with Prowler configuration. diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/providers.py b/mcp_server/prowler_mcp_server/prowler_app/models/providers.py index 07f3be1d37..af9509a963 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/providers.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/providers.py @@ -2,9 +2,10 @@ from typing import Any, Literal -from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin from pydantic import BaseModel +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + class SimplifiedProvider(MinimalSerializerMixin, BaseModel): """Simplified provider for list/search operations.""" diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/resources.py b/mcp_server/prowler_mcp_server/prowler_app/models/resources.py index 823134794f..13cb1ed4dd 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/resources.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/resources.py @@ -1,8 +1,9 @@ """Pydantic models for simplified resources responses.""" -from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin from pydantic import BaseModel +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + class SimplifiedResource(MinimalSerializerMixin, BaseModel): """Simplified resource with only LLM-relevant information for list operations.""" diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/scans.py b/mcp_server/prowler_mcp_server/prowler_app/models/scans.py index 696ac7837d..f8eef988ce 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/scans.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/scans.py @@ -11,9 +11,10 @@ for optimal LLM token usage. from typing import Any, Literal -from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin from pydantic import BaseModel, ConfigDict, Field +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + class SimplifiedScan(MinimalSerializerMixin, BaseModel): """Simplified scan representation for list operations. diff --git a/mcp_server/prowler_mcp_server/prowler_app/server.py b/mcp_server/prowler_mcp_server/prowler_app/server.py index 39b22d095c..e8e854144f 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/server.py +++ b/mcp_server/prowler_mcp_server/prowler_app/server.py @@ -1,4 +1,5 @@ from fastmcp import FastMCP + from prowler_mcp_server.prowler_app.utils.tool_loader import load_all_tools # Initialize MCP server diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py index ff9b8045a4..b08bbfe01f 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py @@ -7,6 +7,8 @@ through cloud infrastructure relationships. from typing import Any, Literal +from pydantic import Field + from prowler_mcp_server.prowler_app.models.attack_paths import ( AttackPathCartographySchema, AttackPathQuery, @@ -14,7 +16,6 @@ from prowler_mcp_server.prowler_app.models.attack_paths import ( AttackPathScansListResponse, ) from prowler_mcp_server.prowler_app.tools.base import BaseTool -from pydantic import Field class AttackPathsTools(BaseTool): diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py b/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py index 81f16a83e9..360dd5510d 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py @@ -6,13 +6,14 @@ across all cloud providers. from typing import Any +from pydantic import Field + from prowler_mcp_server.prowler_app.models.compliance import ( ComplianceFrameworksListResponse, ComplianceRequirementAttributesListResponse, ComplianceRequirementsListResponse, ) from prowler_mcp_server.prowler_app.tools.base import BaseTool -from pydantic import Field class ComplianceTools(BaseTool): diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py new file mode 100644 index 0000000000..905a352740 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py @@ -0,0 +1,470 @@ +"""Finding Groups tools for Prowler App MCP Server. + +This module provides read-only tools for finding group triage and drill-downs. +""" + +from typing import Any, Literal +from urllib.parse import quote + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.finding_groups import ( + DetailedFindingGroup, + FindingGroupResourcesListResponse, + FindingGroupsListResponse, +) +from prowler_mcp_server.prowler_app.tools.base import BaseTool + +StatusFilter = Literal["FAIL", "PASS", "MANUAL"] +SeverityFilter = Literal["critical", "high", "medium", "low", "informational"] +DeltaFilter = Literal["new", "changed"] + +GROUP_DETAIL_FIELDS = ( + "check_id,check_title,check_description,severity,status,muted," + "impacted_providers,resources_fail,resources_total,pass_count,fail_count," + "manual_count,pass_muted_count,fail_muted_count,manual_muted_count," + "muted_count,new_count,changed_count,new_fail_count,new_fail_muted_count," + "new_pass_count,new_pass_muted_count,new_manual_count,new_manual_muted_count," + "changed_fail_count,changed_fail_muted_count,changed_pass_count," + "changed_pass_muted_count,changed_manual_count,changed_manual_muted_count," + "first_seen_at,last_seen_at,failing_since" +) + +GROUP_LIST_FIELDS = ( + "check_id,check_title,severity,status,muted,impacted_providers," + "resources_fail,resources_total,pass_count,fail_count,manual_count," + "muted_count,new_count,changed_count,first_seen_at,last_seen_at,failing_since" +) + +RESOURCE_FIELDS = ( + "resource,provider,finding_id,status,severity,muted,delta," + "first_seen_at,last_seen_at,muted_reason" +) + + +class FindingGroupsTools(BaseTool): + """Tools for Finding Groups operations.""" + + @staticmethod + def _bool_value(value: bool | str) -> bool: + """Normalize bool-like MCP client values.""" + if isinstance(value, bool): + return value + return value.lower() == "true" + + @staticmethod + def _group_endpoint(date_range: tuple[str, str] | None) -> str: + return "/finding-groups/latest" if date_range is None else "/finding-groups" + + @staticmethod + def _resource_endpoint(check_id: str, date_range: tuple[str, str] | None) -> str: + escaped_check_id = quote(check_id, safe="") + if date_range is None: + return f"/finding-groups/latest/{escaped_check_id}/resources" + return f"/finding-groups/{escaped_check_id}/resources" + + def _base_date_params( + self, date_from: str | None, date_to: str | None + ) -> tuple[tuple[str, str] | None, dict[str, Any]]: + date_range = self.api_client.normalize_date_range( + date_from, date_to, max_days=2 + ) + if date_range is None: + return None, {} + + return date_range, { + "filter[inserted_at__gte]": date_range[0], + "filter[inserted_at__lte]": date_range[1], + } + + def _apply_common_filters( + self, + params: dict[str, Any], + provider: list[str], + provider_type: list[str], + provider_uid: list[str], + provider_alias: str | None, + region: list[str], + service: list[str], + resource_type: list[str], + resource_name: str | None, + resource_uid: str | None, + resource_group: list[str], + category: list[str], + check_id: list[str], + check_title: str | None, + severity: list[SeverityFilter], + status: list[StatusFilter], + muted: bool | str | None, + delta: list[DeltaFilter], + ) -> None: + if provider: + params["filter[provider__in]"] = provider + if provider_type: + params["filter[provider_type__in]"] = provider_type + if provider_uid: + params["filter[provider_uid__in]"] = provider_uid + if provider_alias: + params["filter[provider_alias__icontains]"] = provider_alias + if region: + params["filter[region__in]"] = region + if service: + params["filter[service__in]"] = service + if resource_type: + params["filter[resource_type__in]"] = resource_type + if resource_name: + params["filter[resource_name__icontains]"] = resource_name + if resource_uid: + params["filter[resource_uid__icontains]"] = resource_uid + if resource_group: + params["filter[resource_groups__in]"] = resource_group + if category: + params["filter[category__in]"] = category + if check_id: + params["filter[check_id__in]"] = check_id + if check_title: + params["filter[check_title__icontains]"] = check_title + if severity: + params["filter[severity__in]"] = severity + if status: + params["filter[status__in]"] = status + if muted is not None: + params["filter[muted]"] = self._bool_value(muted) + if delta: + params["filter[delta__in]"] = delta + + async def list_finding_groups( + self, + provider: list[str] = Field( + default=[], + description="Filter by provider UUIDs. Multiple values allowed. If empty, all visible providers are returned.", + ), + provider_type: list[str] = Field( + default=[], + description="Filter by provider type. Multiple values allowed, such as aws, azure, gcp, kubernetes, github, or m365.", + ), + provider_uid: list[str] = Field( + default=[], + description="Filter by provider-native account, subscription, or project IDs. Multiple values allowed.", + ), + provider_alias: str | None = Field( + default=None, + description="Filter by provider alias/name using partial matching.", + ), + region: list[str] = Field( + default=[], + description="Filter by cloud regions. Multiple values allowed.", + ), + service: list[str] = Field( + default=[], + description="Filter by cloud services. Multiple values allowed.", + ), + resource_type: list[str] = Field( + default=[], + description="Filter by resource types. Multiple values allowed.", + ), + resource_name: str | None = Field( + default=None, + description="Filter by resource name using partial matching.", + ), + resource_uid: str | None = Field( + default=None, + description="Filter by resource UID using partial matching.", + ), + resource_group: list[str] = Field( + default=[], + description="Filter by resource group values. Multiple values allowed.", + ), + category: list[str] = Field( + default=[], + description="Filter by finding categories. Multiple values allowed.", + ), + check_id: list[str] = Field( + default=[], + description="Filter by check IDs. Multiple values allowed.", + ), + check_title: str | None = Field( + default=None, + description="Filter by check title using partial matching.", + ), + severity: list[SeverityFilter] = Field( + default=[], + description="Filter by aggregated severity. Empty returns all severities.", + ), + status: list[StatusFilter] = Field( + default=["FAIL"], + description="Filter by aggregated status. Default returns failing groups. Pass [] to return all statuses.", + ), + muted: bool | str | None = Field( + default=None, + description="Filter by fully muted group state. Accepts true/false.", + ), + include_muted: bool | str = Field( + default=False, + description="When false, excludes fully muted groups. Set true to include fully muted groups.", + ), + delta: list[DeltaFilter] = Field( + default=[], + description="Filter by group delta values: new or changed.", + ), + date_from: str | None = Field( + default=None, + description="Start date for historical query in YYYY-MM-DD format. Maximum range is 2 days.", + ), + date_to: str | None = Field( + default=None, + description="End date for historical query in YYYY-MM-DD format. Maximum range is 2 days.", + ), + sort: str | None = Field( + default=None, + description="Optional sort expression supported by the finding-groups API, such as -fail_count,-severity,check_id.", + ), + page_size: int = Field( + default=50, description="Number of groups to return per page" + ), + page_number: int = Field( + default=1, description="Page number to retrieve (1-indexed)" + ), + ) -> dict[str, Any]: + """List finding groups aggregated by check ID. + + Default behavior returns the latest non-muted FAIL groups for fast triage. + Without dates this uses `/finding-groups/latest`. With `date_from` or + `date_to`, this uses `/finding-groups` with a maximum 2-day date window. + + Use this tool to find noisy or high-impact checks, then call + prowler_app_get_finding_group_details for complete counters or + prowler_app_list_finding_group_resources to drill into affected resources. + """ + try: + self.api_client.validate_page_size(page_size) + date_range, params = self._base_date_params(date_from, date_to) + endpoint = self._group_endpoint(date_range) + + self._apply_common_filters( + params, + provider, + provider_type, + provider_uid, + provider_alias, + region, + service, + resource_type, + resource_name, + resource_uid, + resource_group, + category, + check_id, + check_title, + severity, + status, + muted, + delta, + ) + + params["filter[include_muted]"] = self._bool_value(include_muted) + params["page[size]"] = page_size + params["page[number]"] = page_number + params["fields[finding-groups]"] = GROUP_LIST_FIELDS + if sort: + params["sort"] = sort + + clean_params = self.api_client.build_filter_params(params) + api_response = await self.api_client.get(endpoint, params=clean_params) + response = FindingGroupsListResponse.from_api_response(api_response) + return response.model_dump() + except Exception as e: + self.logger.error(f"Error listing finding groups: {e}") + return {"error": str(e), "status": "failed"} + + async def get_finding_group_details( + self, + check_id: str = Field( + description="Public check ID that identifies the finding group. This is not a UUID." + ), + date_from: str | None = Field( + default=None, + description="Start date for historical query in YYYY-MM-DD format. Maximum range is 2 days.", + ), + date_to: str | None = Field( + default=None, + description="End date for historical query in YYYY-MM-DD format. Maximum range is 2 days.", + ), + ) -> dict[str, Any]: + """Get complete details for one finding group by exact check ID. + + Uses `filter[check_id]` exact matching against latest data by default, + or historical data when dates are provided. Fully muted groups are + included by default so accepted risk does not look like a missing group. + """ + try: + date_range, params = self._base_date_params(date_from, date_to) + endpoint = self._group_endpoint(date_range) + + params.update( + { + "filter[check_id]": check_id, + "filter[include_muted]": True, + "page[size]": 1, + "page[number]": 1, + "fields[finding-groups]": GROUP_DETAIL_FIELDS, + } + ) + + clean_params = self.api_client.build_filter_params(params) + api_response = await self.api_client.get(endpoint, params=clean_params) + data = api_response.get("data", []) + + if not data: + return { + "error": f"Finding group '{check_id}' not found.", + "status": "not_found", + } + + group = DetailedFindingGroup.from_api_response(data[0]) + return group.model_dump() + except Exception as e: + self.logger.error(f"Error getting finding group details: {e}") + return {"error": str(e), "status": "failed"} + + async def list_finding_group_resources( + self, + check_id: str = Field( + description="Public check ID that identifies the finding group. This is not a UUID." + ), + provider: list[str] = Field( + default=[], + description="Filter by provider UUIDs. Multiple values allowed.", + ), + provider_type: list[str] = Field( + default=[], + description="Filter by provider type. Multiple values allowed.", + ), + provider_uid: list[str] = Field( + default=[], + description="Filter by provider-native account, subscription, or project IDs. Multiple values allowed.", + ), + provider_alias: str | None = Field( + default=None, + description="Filter by provider alias/name using partial matching.", + ), + region: list[str] = Field( + default=[], + description="Filter by cloud regions. Multiple values allowed.", + ), + service: list[str] = Field( + default=[], + description="Filter by cloud services. Multiple values allowed.", + ), + resource_type: list[str] = Field( + default=[], + description="Filter by resource types. Multiple values allowed.", + ), + resource_name: str | None = Field( + default=None, + description="Filter by resource name using partial matching.", + ), + resource_uid: str | None = Field( + default=None, + description="Filter by resource UID using partial matching.", + ), + resource_group: list[str] = Field( + default=[], + description="Filter by resource group values. Multiple values allowed.", + ), + category: list[str] = Field( + default=[], + description="Filter by finding categories. Multiple values allowed.", + ), + severity: list[SeverityFilter] = Field( + default=[], + description="Filter by severity. Empty returns all severities.", + ), + status: list[StatusFilter] = Field( + default=["FAIL"], + description="Filter by status. Default returns failing resources. Pass [] to return all statuses.", + ), + muted: bool | str | None = Field( + default=None, + description="Filter by muted state. Accepts true/false. Overrides include_muted when provided.", + ), + include_muted: bool | str = Field( + default=False, + description="When false, returns only actionable unmuted resources by applying muted=false. Set true to include muted and unmuted resources.", + ), + delta: list[DeltaFilter] = Field( + default=[], description="Filter by delta values: new or changed." + ), + date_from: str | None = Field( + default=None, + description="Start date for historical query in YYYY-MM-DD format. Maximum range is 2 days.", + ), + date_to: str | None = Field( + default=None, + description="End date for historical query in YYYY-MM-DD format. Maximum range is 2 days.", + ), + sort: str | None = Field( + default=None, + description="Optional sort expression supported by the finding group resources API.", + ), + page_size: int = Field( + default=50, description="Number of resources to return per page" + ), + page_number: int = Field( + default=1, description="Page number to retrieve (1-indexed)" + ), + ) -> dict[str, Any]: + """List resources affected by a finding group. + + Without dates this uses `/finding-groups/latest/{check_id}/resources`. + With `date_from` or `date_to`, this uses + `/finding-groups/{check_id}/resources` with a maximum 2-day date window. + + Default behavior returns FAIL, unmuted resources so the result is + actionable. Set `include_muted=True` to include accepted/suppressed + resources too. Each row includes nested resource and provider data plus + `finding_id`. Use `prowler_app_get_finding_details(finding_id)` to + retrieve complete remediation guidance for a specific resource finding. + """ + try: + self.api_client.validate_page_size(page_size) + date_range, params = self._base_date_params(date_from, date_to) + endpoint = self._resource_endpoint(check_id, date_range) + + if muted is None and not self._bool_value(include_muted): + muted = False + + self._apply_common_filters( + params, + provider, + provider_type, + provider_uid, + provider_alias, + region, + service, + resource_type, + resource_name, + resource_uid, + resource_group, + category, + [], + None, + severity, + status, + muted, + delta, + ) + + params["page[size]"] = page_size + params["page[number]"] = page_number + params["fields[finding-group-resources]"] = RESOURCE_FIELDS + if sort: + params["sort"] = sort + + clean_params = self.api_client.build_filter_params(params) + api_response = await self.api_client.get(endpoint, params=clean_params) + response = FindingGroupResourcesListResponse.from_api_response(api_response) + return response.model_dump() + except Exception as e: + self.logger.error(f"Error listing finding group resources: {e}") + return {"error": str(e), "status": "failed"} diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py index 042232e6a5..011e013b91 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py @@ -6,6 +6,8 @@ across all providers. from typing import Any +from pydantic import Field + from prowler_mcp_server.prowler_app.models.resources import ( DetailedResource, ResourceEventsResponse, @@ -13,7 +15,6 @@ from prowler_mcp_server.prowler_app.models.resources import ( ResourcesMetadataResponse, ) from prowler_mcp_server.prowler_app.tools.base import BaseTool -from pydantic import Field class ResourcesTools(BaseTool): diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py index 4b6d0e77f5..a6aacc3ce1 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py @@ -2,11 +2,12 @@ import asyncio from datetime import datetime, timedelta -from enum import Enum -from typing import Any, Dict +from enum import StrEnum +from typing import Any from urllib.parse import urlparse import httpx + from prowler_mcp_server import __version__ from prowler_mcp_server.lib.logger import logger from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth @@ -14,7 +15,7 @@ from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"}) -class HTTPMethod(str, Enum): +class HTTPMethod(StrEnum): """HTTP methods enum.""" GET = "GET" @@ -30,7 +31,7 @@ class SingletonMeta(type): All calls to the constructor return the same instance. """ - _instances: Dict[type, Any] = {} + _instances: dict[type, Any] = {} def __call__(cls, *args, **kwargs): """Control instance creation to ensure singleton behavior.""" diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py index 32ab72e574..72c06000df 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py @@ -2,9 +2,9 @@ import base64 import json import os from datetime import datetime -from typing import Dict, Optional from fastmcp.server.dependencies import get_http_headers + from prowler_mcp_server import __version__ from prowler_mcp_server.lib.logger import logger @@ -20,8 +20,8 @@ class ProwlerAppAuth: self.base_url = base_url.rstrip("/") logger.info(f"Using Prowler App API base URL: {self.base_url}") self.mode = mode - self.access_token: Optional[str] = None - self.api_key: Optional[str] = None + self.access_token: str | None = None + self.api_key: str | None = None if mode == "stdio": # STDIO mode self.api_key = os.getenv("PROWLER_APP_API_KEY") @@ -32,7 +32,7 @@ class ProwlerAppAuth: if not self.api_key.startswith("pk_"): raise ValueError("Prowler App API key format is incorrect") - def _parse_jwt(self, token: str) -> Optional[Dict]: + def _parse_jwt(self, token: str) -> dict | None: """Parse JWT token and return payload Args: @@ -68,7 +68,7 @@ class ProwlerAppAuth: async def authenticate(self) -> str: """Authenticate and return token (API key for STDIO, API key or JWT for HTTP).""" if self.mode == "http": - headers = get_http_headers() + headers = get_http_headers(include={"authorization"}) authorization_header = headers.get("authorization", None) if not authorization_header: @@ -109,7 +109,7 @@ class ProwlerAppAuth: else: return await self.authenticate() - def get_headers(self, token: str) -> Dict[str, str]: + def get_headers(self, token: str) -> dict[str, str]: """Get headers for API requests with authentication.""" if token.startswith("pk_"): authorization_header = f"Api-Key {token}" diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py b/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py index 834bcaef7f..b85c13af35 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py @@ -8,6 +8,7 @@ import importlib import pkgutil from fastmcp import FastMCP + from prowler_mcp_server.lib.logger import logger from prowler_mcp_server.prowler_app.tools.base import BaseTool diff --git a/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py b/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py index a366c39c30..ba6f2a12e7 100644 --- a/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py +++ b/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py @@ -1,7 +1,8 @@ import httpx -from prowler_mcp_server import __version__ from pydantic import BaseModel, Field +from prowler_mcp_server import __version__ + class SearchResult(BaseModel): """Search result model.""" diff --git a/mcp_server/prowler_mcp_server/server.py b/mcp_server/prowler_mcp_server/server.py index 12060366ad..7c85641dee 100644 --- a/mcp_server/prowler_mcp_server/server.py +++ b/mcp_server/prowler_mcp_server/server.py @@ -1,62 +1,64 @@ -import asyncio - from fastmcp import FastMCP +from starlette.responses import JSONResponse + from prowler_mcp_server import __version__ from prowler_mcp_server.lib.logger import logger -from starlette.responses import JSONResponse prowler_mcp_server = FastMCP("prowler-mcp-server") -async def setup_main_server(): +def setup_main_server(): """Set up the main Prowler MCP server with all available integrations.""" - # Import Prowler Hub tools with prowler_hub_ prefix + # Mount Prowler Hub tools with prowler_hub_ namespace try: - logger.info("Importing Prowler Hub server...") + logger.info("Mounting Prowler Hub server...") from prowler_mcp_server.prowler_hub.server import hub_mcp_server - await prowler_mcp_server.import_server(hub_mcp_server, prefix="prowler_hub") - logger.info("Successfully imported Prowler Hub server") + prowler_mcp_server.mount(hub_mcp_server, namespace="prowler_hub") + logger.info("Successfully mounted Prowler Hub server") except Exception as e: - logger.error(f"Failed to import Prowler Hub server: {e}") + logger.error(f"Failed to mount Prowler Hub server: {e}") - # Import Prowler App tools with prowler_app_ prefix + # Mount Prowler App tools with prowler_app_ namespace try: - logger.info("Importing Prowler App server...") + logger.info("Mounting Prowler App server...") from prowler_mcp_server.prowler_app.server import app_mcp_server - await prowler_mcp_server.import_server(app_mcp_server, prefix="prowler_app") - logger.info("Successfully imported Prowler App server") + prowler_mcp_server.mount(app_mcp_server, namespace="prowler_app") + logger.info("Successfully mounted Prowler App server") except Exception as e: - logger.error(f"Failed to import Prowler App server: {e}") + logger.error(f"Failed to mount Prowler App server: {e}") - # Import Prowler Documentation tools with prowler_docs_ prefix + # Mount Prowler Documentation tools with prowler_docs_ namespace try: - logger.info("Importing Prowler Documentation server...") + logger.info("Mounting Prowler Documentation server...") from prowler_mcp_server.prowler_documentation.server import docs_mcp_server - await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs") - logger.info("Successfully imported Prowler Documentation server") + prowler_mcp_server.mount(docs_mcp_server, namespace="prowler_docs") + logger.info("Successfully mounted Prowler Documentation server") except Exception as e: - logger.error(f"Failed to import Prowler Documentation server: {e}") + logger.error(f"Failed to mount Prowler Documentation server: {e}") -# Add health check endpoint +# Response follows the IETF Health Check Response Format +# (draft-inadarei-api-health-check-06). `version` is the contract version of +# this endpoint; `releaseId` is the package build version. @prowler_mcp_server.custom_route("/health", methods=["GET"]) -async def health_check(request) -> JSONResponse: +async def health_check(_request) -> JSONResponse: """Health check endpoint.""" return JSONResponse( - {"status": "healthy", "service": "prowler-mcp-server", "version": __version__} + { + "status": "pass", + "version": "1", + "releaseId": __version__, + "serviceId": "prowler-mcp-server", + "description": "Prowler MCP Server", + }, + media_type="application/health+json", + headers={"Cache-Control": "no-store"}, ) -# Get or create the event loop -try: - loop = asyncio.get_running_loop() - # If we have a running loop, schedule the setup as a task - loop.create_task(setup_main_server()) -except RuntimeError: - # No running loop, use asyncio.run (for standalone execution) - asyncio.run(setup_main_server()) +setup_main_server() app = prowler_mcp_server.http_app() diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml index 4ea4a9859e..63aefdf931 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -2,10 +2,18 @@ build-backend = "setuptools.build_meta" requires = ["setuptools>=61.0", "wheel"] +[dependency-groups] +dev = [ + "bandit==1.8.3", + "pytest==9.0.3", + "ruff==0.15.11", + "vulture==2.14" +] + [project] dependencies = [ - "fastmcp==2.14.0", - "httpx>=0.28.0" + "fastmcp==3.2.4", + "httpx==0.28.1" ] description = "MCP server for Prowler ecosystem" name = "prowler-mcp" @@ -16,5 +24,24 @@ version = "0.5.0" [project.scripts] prowler-mcp = "prowler_mcp_server.main:main" +[tool.pytest.ini_options] +testpaths = ["tests"] + +# Shared ruff baseline (kept in sync with api/pyproject.toml). +# target-version tracks this project's lowest supported Python. +[tool.ruff] +target-version = "py312" + +[tool.ruff.lint] +# Defaults (E4/E7/E9, F) plus import sorting, modern-syntax upgrades, and +# comprehension lints — all mechanically auto-fixable. flake8-bugbear (B) is a +# good next step but needs manual cleanup, so it is left out of the shared +# baseline for now. +extend-select = [ + "I", # isort — import ordering + "UP", # pyupgrade — modern syntax for the min supported Python + "C4" # flake8-comprehensions +] + [tool.uv] package = true diff --git a/mcp_server/tests/__init__.py b/mcp_server/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mcp_server/tests/test_health.py b/mcp_server/tests/test_health.py new file mode 100644 index 0000000000..47a676960d --- /dev/null +++ b/mcp_server/tests/test_health.py @@ -0,0 +1,46 @@ +"""Tests for the Prowler MCP Server health endpoint.""" + +from starlette.testclient import TestClient + +from prowler_mcp_server import __version__ +from prowler_mcp_server.server import app + + +def test_health_returns_ietf_pass_response(): + """GET /health returns 200 with the IETF health-check body and headers.""" + client = TestClient(app) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/health+json" + assert response.headers["cache-control"] == "no-store" + assert response.json() == { + "status": "pass", + "version": "1", + "releaseId": __version__, + "serviceId": "prowler-mcp-server", + "description": "Prowler MCP Server", + } + + +def test_health_release_id_matches_package_version(): + """The endpoint must surface the current package __version__ as releaseId. + + Drift between the response and the installed package would mislead any + monitoring tool that uses releaseId to identify the running build. + """ + client = TestClient(app) + + response = client.get("/health") + + assert response.json()["releaseId"] == __version__ + + +def test_health_rejects_non_get_methods(): + """The endpoint only exposes GET; other verbs return 405.""" + client = TestClient(app) + + response = client.post("/health") + + assert response.status_code == 405 diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index 31e4dd84d6..3258767442 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -13,64 +25,100 @@ wheels = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "attrs" -version = "25.3.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "authlib" -version = "1.6.5" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "bandit" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/a5/144a45f8e67df9d66c3bc3f7e69a39537db8bff1189ab7cff4e9459215da/bandit-1.8.3.tar.gz", hash = "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a", size = 4232005, upload-time = "2025-02-17T05:24:57.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/85/db74b9233e0aa27ec96891045c5e920a64dd5cbccd50f8e64e9460f48d35/bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8", size = 129078, upload-time = "2025-02-17T05:24:54.068Z" }, ] [[package]] name = "beartype" -version = "0.22.6" +version = "0.22.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/e2/105ceb1704cb80fe4ab3872529ab7b6f365cf7c74f725e6132d0efcf1560/beartype-0.22.6.tar.gz", hash = "sha256:97fbda69c20b48c5780ac2ca60ce3c1bb9af29b3a1a0216898ffabdd523e48f4", size = 1588975, upload-time = "2025-11-20T04:47:14.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/c9/ceecc71fe2c9495a1d8e08d44f5f31f5bca1350d5b2e27a4b6265424f59e/beartype-0.22.6-py3-none-any.whl", hash = "sha256:0584bc46a2ea2a871509679278cda992eadde676c01356ab0ac77421f3c9a093", size = 1324807, upload-time = "2025-11-20T04:47:11.837Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] [[package]] name = "cachetools" -version = "6.2.2" +version = "7.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e2/85f227594656000ff4d8adadae91a21f536d4a84c6c716a86bd6685874be/cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b", size = 40202, upload-time = "2026-05-03T20:00:29.391Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] [[package]] name = "certifi" -version = "2025.8.3" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -130,67 +178,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, -] - [[package]] name = "click" -version = "8.3.0" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -204,63 +201,60 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.1" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/62/e3664e6ffd7743e1694b244dde70b43a394f6f7fbcacf7014a8ff5197c73/cryptography-46.0.1.tar.gz", hash = "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7", size = 749198, upload-time = "2025-09-17T00:10:35.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/8c/44ee01267ec01e26e43ebfdae3f120ec2312aa72fa4c0507ebe41a26739f/cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475", size = 7285044, upload-time = "2025-09-17T00:08:36.807Z" }, - { url = "https://files.pythonhosted.org/packages/22/59/9ae689a25047e0601adfcb159ec4f83c0b4149fdb5c3030cc94cd218141d/cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080", size = 4308182, upload-time = "2025-09-17T00:08:39.388Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/ca6cc9df7118f2fcd142c76b1da0f14340d77518c05b1ebfbbabca6b9e7d/cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e", size = 4572393, upload-time = "2025-09-17T00:08:41.663Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a3/0f5296f63815d8e985922b05c31f77ce44787b3127a67c0b7f70f115c45f/cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6", size = 4308400, upload-time = "2025-09-17T00:08:43.559Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/74fcda3e4e01be1d32775d5b4dd841acaac3c1b8fa4d0774c7ac8d52463d/cryptography-46.0.1-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8", size = 4015786, upload-time = "2025-09-17T00:08:45.758Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b8/85d23287baeef273b0834481a3dd55bbed3a53587e3b8d9f0898235b8f91/cryptography-46.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28", size = 4982606, upload-time = "2025-09-17T00:08:47.602Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d3/de61ad5b52433b389afca0bc70f02a7a1f074651221f599ce368da0fe437/cryptography-46.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9", size = 4604234, upload-time = "2025-09-17T00:08:49.879Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1f/dbd4d6570d84748439237a7478d124ee0134bf166ad129267b7ed8ea6d22/cryptography-46.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736", size = 4307669, upload-time = "2025-09-17T00:08:52.321Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fd/ca0a14ce7f0bfe92fa727aacaf2217eb25eb7e4ed513b14d8e03b26e63ed/cryptography-46.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b", size = 4947579, upload-time = "2025-09-17T00:08:54.697Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/09c30543bb93401f6f88fce556b3bdbb21e55ae14912c04b7bf355f5f96c/cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab", size = 4603669, upload-time = "2025-09-17T00:08:57.16Z" }, - { url = "https://files.pythonhosted.org/packages/23/9a/38cb01cb09ce0adceda9fc627c9cf98eb890fc8d50cacbe79b011df20f8a/cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75", size = 4435828, upload-time = "2025-09-17T00:08:59.606Z" }, - { url = "https://files.pythonhosted.org/packages/0f/53/435b5c36a78d06ae0bef96d666209b0ecd8f8181bfe4dda46536705df59e/cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5", size = 4709553, upload-time = "2025-09-17T00:09:01.832Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c4/0da6e55595d9b9cd3b6eb5dc22f3a07ded7f116a3ea72629cab595abb804/cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0", size = 3058327, upload-time = "2025-09-17T00:09:03.726Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/cd29a35e0d6e78a0ee61793564c8cff0929c38391cb0de27627bdc7525aa/cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7", size = 3523893, upload-time = "2025-09-17T00:09:06.272Z" }, - { url = "https://files.pythonhosted.org/packages/f2/dd/eea390f3e78432bc3d2f53952375f8b37cb4d37783e626faa6a51e751719/cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0", size = 2932145, upload-time = "2025-09-17T00:09:08.568Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fb/c73588561afcd5e24b089952bd210b14676c0c5bf1213376350ae111945c/cryptography-46.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:4c49eda9a23019e11d32a0eb51a27b3e7ddedde91e099c0ac6373e3aacc0d2ee", size = 7193928, upload-time = "2025-09-17T00:09:10.595Z" }, - { url = "https://files.pythonhosted.org/packages/26/34/0ff0bb2d2c79f25a2a63109f3b76b9108a906dd2a2eb5c1d460b9938adbb/cryptography-46.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9babb7818fdd71394e576cf26c5452df77a355eac1a27ddfa24096665a27f8fd", size = 4293515, upload-time = "2025-09-17T00:09:12.861Z" }, - { url = "https://files.pythonhosted.org/packages/df/b7/d4f848aee24ecd1be01db6c42c4a270069a4f02a105d9c57e143daf6cf0f/cryptography-46.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a", size = 4545619, upload-time = "2025-09-17T00:09:15.397Z" }, - { url = "https://files.pythonhosted.org/packages/44/a5/42fedefc754fd1901e2d95a69815ea4ec8a9eed31f4c4361fcab80288661/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a", size = 4299160, upload-time = "2025-09-17T00:09:17.155Z" }, - { url = "https://files.pythonhosted.org/packages/86/a1/cd21174f56e769c831fbbd6399a1b7519b0ff6280acec1b826d7b072640c/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0a17377fa52563d730248ba1f68185461fff36e8bc75d8787a7dd2e20a802b7a", size = 3994491, upload-time = "2025-09-17T00:09:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/a8cbfa1c029987ddc746fd966711d4fa71efc891d37fbe9f030fe5ab4eec/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0d1922d9280e08cde90b518a10cd66831f632960a8d08cb3418922d83fce6f12", size = 4960157, upload-time = "2025-09-17T00:09:20.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/ae/63a84e6789e0d5a2502edf06b552bcb0fa9ff16147265d5c44a211942abe/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:af84e8e99f1a82cea149e253014ea9dc89f75b82c87bb6c7242203186f465129", size = 4577263, upload-time = "2025-09-17T00:09:23.356Z" }, - { url = "https://files.pythonhosted.org/packages/ef/8f/1b9fa8e92bd9cbcb3b7e1e593a5232f2c1e6f9bd72b919c1a6b37d315f92/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ef648d2c690703501714588b2ba640facd50fd16548133b11b2859e8655a69da", size = 4298703, upload-time = "2025-09-17T00:09:25.566Z" }, - { url = "https://files.pythonhosted.org/packages/c3/af/bb95db070e73fea3fae31d8a69ac1463d89d1c084220f549b00dd01094a8/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:e94eb5fa32a8a9f9bf991f424f002913e3dd7c699ef552db9b14ba6a76a6313b", size = 4926363, upload-time = "2025-09-17T00:09:27.451Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3b/d8fb17ffeb3a83157a1cc0aa5c60691d062aceecba09c2e5e77ebfc1870c/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657", size = 4576958, upload-time = "2025-09-17T00:09:29.924Z" }, - { url = "https://files.pythonhosted.org/packages/d9/46/86bc3a05c10c8aa88c8ae7e953a8b4e407c57823ed201dbcba55c4d655f4/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0", size = 4422507, upload-time = "2025-09-17T00:09:32.222Z" }, - { url = "https://files.pythonhosted.org/packages/a8/4e/387e5a21dfd2b4198e74968a541cfd6128f66f8ec94ed971776e15091ac3/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0", size = 4683964, upload-time = "2025-09-17T00:09:34.118Z" }, - { url = "https://files.pythonhosted.org/packages/25/a3/f9f5907b166adb8f26762071474b38bbfcf89858a5282f032899075a38a1/cryptography-46.0.1-cp314-cp314t-win32.whl", hash = "sha256:504e464944f2c003a0785b81668fe23c06f3b037e9cb9f68a7c672246319f277", size = 3029705, upload-time = "2025-09-17T00:09:36.381Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/4d3a4f1850db2e71c2b1628d14b70b5e4c1684a1bd462f7fffb93c041c38/cryptography-46.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c52fded6383f7e20eaf70a60aeddd796b3677c3ad2922c801be330db62778e05", size = 3502175, upload-time = "2025-09-17T00:09:38.261Z" }, - { url = "https://files.pythonhosted.org/packages/52/c7/9f10ad91435ef7d0d99a0b93c4360bea3df18050ff5b9038c489c31ac2f5/cryptography-46.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9495d78f52c804b5ec8878b5b8c7873aa8e63db9cd9ee387ff2db3fffe4df784", size = 2912354, upload-time = "2025-09-17T00:09:40.078Z" }, - { url = "https://files.pythonhosted.org/packages/98/e5/fbd632385542a3311915976f88e0dfcf09e62a3fc0aff86fb6762162a24d/cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b", size = 7255677, upload-time = "2025-09-17T00:09:42.407Z" }, - { url = "https://files.pythonhosted.org/packages/56/3e/13ce6eab9ad6eba1b15a7bd476f005a4c1b3f299f4c2f32b22408b0edccf/cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8", size = 4301110, upload-time = "2025-09-17T00:09:45.614Z" }, - { url = "https://files.pythonhosted.org/packages/a2/67/65dc233c1ddd688073cf7b136b06ff4b84bf517ba5529607c9d79720fc67/cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", size = 4562369, upload-time = "2025-09-17T00:09:47.601Z" }, - { url = "https://files.pythonhosted.org/packages/17/db/d64ae4c6f4e98c3dac5bf35dd4d103f4c7c345703e43560113e5e8e31b2b/cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2", size = 4302126, upload-time = "2025-09-17T00:09:49.335Z" }, - { url = "https://files.pythonhosted.org/packages/3d/19/5f1eea17d4805ebdc2e685b7b02800c4f63f3dd46cfa8d4c18373fea46c8/cryptography-46.0.1-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32", size = 4009431, upload-time = "2025-09-17T00:09:51.239Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/229ba6088fe7abccbfe4c5edb96c7a5ad547fac5fdd0d40aa6ea540b2985/cryptography-46.0.1-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef", size = 4980739, upload-time = "2025-09-17T00:09:54.181Z" }, - { url = "https://files.pythonhosted.org/packages/3a/9c/50aa38907b201e74bc43c572f9603fa82b58e831bd13c245613a23cff736/cryptography-46.0.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0", size = 4592289, upload-time = "2025-09-17T00:09:56.731Z" }, - { url = "https://files.pythonhosted.org/packages/5a/33/229858f8a5bb22f82468bb285e9f4c44a31978d5f5830bb4ea1cf8a4e454/cryptography-46.0.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128", size = 4301815, upload-time = "2025-09-17T00:09:58.548Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/b76b2c87fbd6ed4a231884bea3ce073406ba8e2dae9defad910d33cbf408/cryptography-46.0.1-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca", size = 4943251, upload-time = "2025-09-17T00:10:00.475Z" }, - { url = "https://files.pythonhosted.org/packages/94/0f/f66125ecf88e4cb5b8017ff43f3a87ede2d064cb54a1c5893f9da9d65093/cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc", size = 4591247, upload-time = "2025-09-17T00:10:02.874Z" }, - { url = "https://files.pythonhosted.org/packages/f6/22/9f3134ae436b63b463cfdf0ff506a0570da6873adb4bf8c19b8a5b4bac64/cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7", size = 4428534, upload-time = "2025-09-17T00:10:04.994Z" }, - { url = "https://files.pythonhosted.org/packages/89/39/e6042bcb2638650b0005c752c38ea830cbfbcbb1830e4d64d530000aa8dc/cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a", size = 4699541, upload-time = "2025-09-17T00:10:06.925Z" }, - { url = "https://files.pythonhosted.org/packages/68/46/753d457492d15458c7b5a653fc9a84a1c9c7a83af6ebdc94c3fc373ca6e8/cryptography-46.0.1-cp38-abi3-win32.whl", hash = "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1", size = 3043779, upload-time = "2025-09-17T00:10:08.951Z" }, - { url = "https://files.pythonhosted.org/packages/2f/50/b6f3b540c2f6ee712feeb5fa780bb11fad76634e71334718568e7695cb55/cryptography-46.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3", size = 3517226, upload-time = "2025-09-17T00:10:10.769Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, ] [[package]] name = "cyclopts" -version = "4.2.5" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -268,18 +262,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/f0/5a83ac365800969552a7eb261b03860dea38647fb6cc31574bdbcdc9f0f5/cyclopts-4.2.5.tar.gz", hash = "sha256:91aff5da5b8b841ccb32b5142c2d12fd93678fbc9eba7ef7319452323e24d6d4", size = 149497, upload-time = "2025-11-22T02:33:37.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/c3/d3f095120329616cc364af2bedcffd518d4db18c978f2f6c892d29e6af2f/cyclopts-4.12.0.tar.gz", hash = "sha256:86bfb5b35cb078decc1cca6c1be41f9a0e6202dc43b4f6056d5cfc6d1f4a69d1", size = 176123, upload-time = "2026-05-13T13:26:31.243Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/00/a9b81bdba88e2904602e970e46ffd18b6a833d902f18d91bdce6fc271c49/cyclopts-4.2.5-py3-none-any.whl", hash = "sha256:361be316ce7f6ce674cad8d34bf6c5e39c34daaeceae40632a55b599472975c7", size = 185196, upload-time = "2025-11-22T02:33:36.103Z" }, -] - -[[package]] -name = "diskcache" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d0/17b6b7d5f64dea337a7a409a1e4e0eeceda724046b9acd158fd1aa2f2328/cyclopts-4.12.0-py3-none-any.whl", hash = "sha256:ee03d2b9ef790d866cb3823a7e54b2be5252c82d34536579846fce068b30c38f", size = 213706, upload-time = "2026-05-13T13:26:29.744Z" }, ] [[package]] @@ -293,20 +278,20 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] name = "docutils" -version = "0.22.2" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] @@ -324,59 +309,56 @@ wheels = [ [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "fakeredis" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "fastmcp" -version = "2.14.0" +version = "3.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, + { name = "griffelib" }, { name = "httpx" }, + { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, - { name = "pydocket" }, { name = "pyperclip" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, + { name = "uncalled-for" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/50/9bb042a2d290ccadb35db3580ac507f192e1a39c489eb8faa167cd5e3b57/fastmcp-2.14.0.tar.gz", hash = "sha256:c1f487b36a3e4b043dbf3330e588830047df2e06f8ef0920d62dfb34d0905727", size = 8232562, upload-time = "2025-12-11T23:04:27.134Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/73/b5656172a6beb2eacec95f04403ddea1928e4b22066700fd14780f8f45d1/fastmcp-2.14.0-py3-none-any.whl", hash = "sha256:7b374c0bcaf1ef1ef46b9255ea84c607f354291eaf647ff56a47c69f5ec0c204", size = 398965, upload-time = "2025-12-11T23:04:25.587Z" }, + { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] @@ -418,20 +400,20 @@ wheels = [ [[package]] name = "httpx-sse" -version = "0.4.1" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -446,6 +428,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -460,23 +451,23 @@ wheels = [ [[package]] name = "jaraco-context" -version = "6.0.1" +version = "6.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, ] [[package]] name = "jaraco-functools" -version = "4.3.0" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, ] [[package]] @@ -488,9 +479,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -498,24 +510,23 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] name = "jsonschema-path" -version = "0.3.4" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pathable" }, { name = "pyyaml" }, { name = "referencing" }, - { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/86/cfee6dd25843bec0760f456599a4f7e7e40221a934b9229fda0662c859bc/jsonschema_path-0.4.6.tar.gz", hash = "sha256:c89eb635f4d497c9ac328eeff359c489755838806a7d033510a692e9576f5c4b", size = 15302, upload-time = "2026-04-27T18:57:08.412Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, + { url = "https://files.pythonhosted.org/packages/6c/43/3d3065c05a04bb550c143bfbb8e4fd7022cd327e1082bf257bac74923783/jsonschema_path-0.4.6-py3-none-any.whl", hash = "sha256:451354b5311fa955c3144e6e4e255388c751c0121c5570ec5bb9291dd42d08c9", size = 19565, upload-time = "2026-04-27T18:57:06.792Z" }, ] [[package]] @@ -547,73 +558,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] -[[package]] -name = "lupa" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, - { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, - { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, - { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, - { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, - { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, -] - [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -631,9 +590,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, ] [[package]] @@ -647,11 +606,11 @@ wheels = [ [[package]] name = "more-itertools" -version = "10.8.0" +version = "11.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] [[package]] @@ -668,51 +627,51 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.39.1" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pathable" -version = "0.4.4" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, -] - -[[package]] -name = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, ] [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] -name = "prometheus-client" -version = "0.24.1" +name = "pluggy" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -724,29 +683,45 @@ dependencies = [ { name = "httpx" }, ] +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "vulture" }, +] + [package.metadata] requires-dist = [ - { name = "fastmcp", specifier = "==2.14.0" }, - { name = "httpx", specifier = ">=0.28.0" }, + { name = "fastmcp", specifier = "==3.2.4" }, + { name = "httpx", specifier = "==0.28.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = "==1.8.3" }, + { name = "pytest", specifier = "==9.0.3" }, + { name = "ruff", specifier = "==0.15.11" }, + { name = "vulture", specifier = "==2.14" }, ] [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, ] keyring = [ { name = "keyring" }, @@ -754,35 +729,19 @@ keyring = [ memory = [ { name = "cachetools" }, ] -redis = [ - { name = "redis" }, -] - -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pydantic" -version = "2.11.9" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -790,9 +749,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -802,97 +761,109 @@ email = [ [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, -] - -[[package]] -name = "pydocket" -version = "0.17.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "fakeredis", extra = ["lua"] }, - { name = "opentelemetry-api" }, - { name = "prometheus-client" }, - { name = "py-key-value-aio", extra = ["memory", "redis"] }, - { name = "python-json-logger" }, - { name = "redis" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/17/1fb6309e40bbee999c5d881b8213a1078968412d855e064a9a94cfb9eeef/pydocket-0.17.2.tar.gz", hash = "sha256:8f02c68952701eb1b3a70d439b76392d15f1eb9568d0bde6a69997ea5c79c89f", size = 329829, upload-time = "2026-01-26T16:07:56.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/74/4c9b70753d5721165047e6428ac239ca083118474794deaca5a27d0ab212/pydocket-0.17.2-py3-none-any.whl", hash = "sha256:f43743b84b4e3d614d99b0cad2deebab028104c217745406ecf9e1efb8926e04", size = 91628, upload-time = "2026-01-26T16:07:55.018Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -902,38 +873,45 @@ crypto = [ [[package]] name = "pyperclip" -version = "1.10.0" +version = "1.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/99/25f4898cf420efb6f45f519de018f4faea5391114a8618b16736ef3029f1/pyperclip-1.10.0.tar.gz", hash = "sha256:180c8346b1186921c75dfd14d9048a6b5d46bfc499778811952c6dd6eb1ca6be", size = 12193, upload-time = "2025-09-18T00:54:00.384Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/bc/22540e73c5f5ae18f02924cd3954a6c9a4aa6b713c841a94c98335d333a1/pyperclip-1.10.0-py3-none-any.whl", hash = "sha256:596fbe55dc59263bff26e61d2afbe10223e2fccb5210c9c96a28d6887cfcc7ec", size = 11062, upload-time = "2025-09-18T00:53:59.252Z" }, + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, -] - -[[package]] -name = "python-json-logger" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.28" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, ] [[package]] @@ -963,173 +941,194 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, -] - -[[package]] -name = "redis" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "rich" -version = "14.1.0" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "rich-rst" -version = "1.3.1" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/69/5514c3a87b5f10f09a34bb011bc0927bc12c596c8dae5915604e71abc386/rich_rst-1.3.1.tar.gz", hash = "sha256:fad46e3ba42785ea8c1785e2ceaa56e0ffa32dbe5410dec432f37e4107c4f383", size = 13839, upload-time = "2024-04-30T04:40:38.125Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/bc/cc4e3dbc5e7992398dcb7a8eda0cbcf4fb792a0cdb93f857b478bf3cf884/rich_rst-1.3.1-py3-none-any.whl", hash = "sha256:498a74e3896507ab04492d326e794c3ef76e7cda078703aa592d1853d91098c1", size = 11621, upload-time = "2024-04-30T04:40:32.619Z" }, + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, ] [[package]] name = "rpds-py" -version = "0.27.1" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, - { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, - { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, - { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, - { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, - { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, - { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, - { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, - { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, - { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, - { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, - { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, - { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, - { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, - { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, - { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, - { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, - { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, - { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, - { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, - { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, - { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, - { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, - { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, - { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, - { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] @@ -1145,71 +1144,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] [[package]] name = "starlette" -version = "0.48.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] -name = "typer" -version = "0.21.1" +name = "stevedore" +version = "5.7.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, ] [[package]] @@ -1223,74 +1190,167 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] -name = "urllib3" -version = "2.5.0" +name = "uncalled-for" +version = "0.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, ] [[package]] name = "uvicorn" -version = "0.36.0" +version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/5e/f0cd46063a02fd8515f0e880c37d2657845b7306c16ce6c4ffc44afd9036/uvicorn-0.36.0.tar.gz", hash = "sha256:527dc68d77819919d90a6b267be55f0e76704dca829d34aea9480be831a9b9d9", size = 80032, upload-time = "2025-09-20T01:07:14.418Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/06/5cc0542b47c0338c1cb676b348e24a1c29acabc81000bced518231dded6f/uvicorn-0.36.0-py3-none-any.whl", hash = "sha256:6bb4ba67f16024883af8adf13aba3a9919e415358604ce46780d3f9bdc36d731", size = 67675, upload-time = "2025-09-20T01:07:12.984Z" }, + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[[package]] +name = "vulture" +version = "2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload-time = "2024-12-08T17:39:43.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload-time = "2024-12-08T17:39:40.573Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] [[package]] name = "websockets" -version = "15.0.1" +version = "16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "zipp" -version = "3.23.0" +version = "3.23.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, ] diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000000..59408b8709 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,31 @@ +# osv-scanner per-vulnerability ignore list. +# +# Each [[IgnoredVulns]] entry must include a `reason` explaining why the +# finding is accepted and an `ignoreUntil` date so the suppression auto-expires +# and gets re-evaluated. See https://github.com/google/osv-scanner for the +# config schema. + +[[IgnoredVulns]] +id = "PYSEC-2025-183" +ignoreUntil = 2026-08-20T00:00:00Z +reason = """ +CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes +weak encryption, but the underlying issue is that callers may pick a short +HMAC secret — key-length enforcement is the application's responsibility, not +a defect in the library. We are on pyjwt 2.13.0 (which now also emits an +InsecureKeyLengthWarning for short HMAC secrets) and enforce key strength in +our own auth code, so this advisory does not apply. +Re-evaluate when a non-disputed advisory or upstream fix lands. +""" + +[[IgnoredVulns]] +id = "PYSEC-2026-89" +ignoreUntil = 2026-08-20T00:00:00Z +reason = """ +False positive caused by a malformed PYSEC record. The equivalent GitHub +Security Advisory (GHSA-5wmx-573v-2qwq) for CVE-2025-69534 declares the issue +fixed in markdown 3.8.1. We are on markdown==3.10.2 (latest release, includes +the fix), but the PYSEC entry's range is [{introduced: "0"}, {}] with no +closing "fixed" event, so osv-scanner flags every version. There is no newer +release to upgrade to. Re-evaluate once the PYSEC record is corrected upstream. +""" diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 906f06fbfb..c0da045603 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -36,6 +36,8 @@ "lightsail:GetRelationalDatabases", "macie2:GetMacieSession", "macie2:GetAutomatedDiscoveryConfiguration", + "rolesanywhere:ListTagsForResource", + "rolesanywhere:ListTrustAnchors", "s3:GetAccountPublicAccessBlock", "shield:DescribeProtection", "shield:GetSubscriptionState", @@ -61,7 +63,9 @@ ], "Resource": [ "arn:*:apigateway:*::/restapis/*", - "arn:*:apigateway:*::/apis/*" + "arn:*:apigateway:*::/apis/*", + "arn:*:apigateway:*::/domainnames", + "arn:*:apigateway:*::/domainnames/*" ], "Sid": "AllowAPIGatewayReadOnly" } diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index 395fed6424..a7d91b0071 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -129,6 +129,8 @@ Resources: - "lightsail:GetRelationalDatabases" - "macie2:GetMacieSession" - "macie2:GetAutomatedDiscoveryConfiguration" + - "rolesanywhere:ListTagsForResource" + - "rolesanywhere:ListTrustAnchors" - "s3:GetAccountPublicAccessBlock" - "shield:DescribeProtection" - "shield:GetSubscriptionState" @@ -150,6 +152,8 @@ Resources: Resource: - "arn:*:apigateway:*::/restapis/*" - "arn:*:apigateway:*::/apis/*" + - "arn:*:apigateway:*::/domainnames" + - "arn:*:apigateway:*::/domainnames/*" - !If - OrganizationsEnabled - PolicyName: ProwlerOrganizations diff --git a/permissions/templates/terraform/README.md b/permissions/templates/terraform/README.md index ec41103e5e..1bb037daa2 100644 --- a/permissions/templates/terraform/README.md +++ b/permissions/templates/terraform/README.md @@ -28,12 +28,12 @@ This Terraform configuration creates the necessary IAM role and policies to allo ### Usage Examples -#### Basic deployment (without S3 integration): +#### Basic deployment (without S3 integration) ```bash terraform apply -var="external_id=your-external-id-here" ``` -#### With S3 integration enabled: +#### With S3 integration enabled ```bash terraform apply \ -var="external_id=your-external-id-here" \ @@ -42,14 +42,14 @@ terraform apply \ -var="s3_integration_bucket_account_id=123456789012" ``` -#### Using terraform.tfvars file (Recommended): +#### Using terraform.tfvars file (Recommended) ```bash cp terraform.tfvars.example terraform.tfvars # Edit the file with your values terraform apply ``` -#### Command line variables (Alternative): +#### Command line variables (Alternative) ```bash terraform apply -var="external_id=your-external-id-here" ``` diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index b8bb015697..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,6732 +0,0 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. - -[[package]] -name = "about-time" -version = "4.2.1" -description = "Easily measure timing and throughput of code blocks, with beautiful human friendly representations." -optional = false -python-versions = ">=3.7, <4" -groups = ["main"] -files = [ - {file = "about-time-4.2.1.tar.gz", hash = "sha256:6a538862d33ce67d997429d14998310e1dbfda6cb7d9bbfbf799c4709847fece"}, - {file = "about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341"}, -] - -[[package]] -name = "aiofiles" -version = "24.1.0" -description = "File support for asyncio." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, - {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -description = "Happy Eyeballs for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, - {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, - {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, - {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, - {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, - {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, - {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, - {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, - {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, - {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, - {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, - {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, - {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, - {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, - {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, - {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, - {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e"}, - {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7"}, - {file = "aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a"}, - {file = "aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704"}, - {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f"}, - {file = "aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1"}, - {file = "aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538"}, - {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.5.0" -aiosignal = ">=1.4.0" -async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -propcache = ">=0.2.0" -yarl = ">=1.17.0,<2.0" - -[package.extras] -speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] - -[[package]] -name = "aiosignal" -version = "1.4.0" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, - {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" -typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} - -[[package]] -name = "alibabacloud-actiontrail20200706" -version = "2.4.1" -description = "Alibaba Cloud ActionTrail (20200706) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_actiontrail20200706-2.4.1-py3-none-any.whl", hash = "sha256:5dee0009db9b7cba182fbac742820f6a949287a8faafb843b5107f7dc89136da"}, - {file = "alibabacloud_actiontrail20200706-2.4.1.tar.gz", hash = "sha256:b65c6b37a96443fbe625dd5a4dd1be52a7476006a411db75206908b11588ffa8"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-credentials" -version = "1.0.3" -description = "The alibabacloud credentials module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud-credentials-1.0.3.tar.gz", hash = "sha256:9d8707e96afc6f348e23f5677ed15a21c2dfce7cfe6669776548ee4c80e1dfaf"}, - {file = "alibabacloud_credentials-1.0.3-py3-none-any.whl", hash = "sha256:30c8302f204b663c655d97e1c283ee9f9f84a6257d7901b931477d6cf34445a8"}, -] - -[package.dependencies] -aiofiles = ">=22.1.0,<25.0.0" -alibabacloud-credentials-api = ">=1.0.0,<2.0.0" -alibabacloud-tea = ">=0.4.0" -APScheduler = ">=3.10.0,<4.0.0" - -[[package]] -name = "alibabacloud-credentials-api" -version = "1.0.0" -description = "Alibaba Cloud Gateway SPI SDK Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f"}, -] - -[[package]] -name = "alibabacloud-cs20151215" -version = "6.1.0" -description = "Alibaba Cloud CS (20151215) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_cs20151215-6.1.0-py3-none-any.whl", hash = "sha256:75e90b1bb9acca2236244bb0e44234ca4805d456ea4303ba4225ac15152a458e"}, - {file = "alibabacloud_cs20151215-6.1.0.tar.gz", hash = "sha256:5b3d99306701bf499ddd57cd9f2905b7721cb1bb4bb38ffe4d051f7b4e80e355"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-darabonba-array" -version = "0.1.0" -description = "Alibaba Cloud Darabonba Array SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_array-0.1.0.tar.gz", hash = "sha256:7f9a7c632518ff4f0cebb0d4e825a48c12e7cf0b9016ea25054dd73732e155aa"}, -] - -[[package]] -name = "alibabacloud-darabonba-encode-util" -version = "0.0.2" -description = "Darabonba Util Library for Alibaba Cloud Python SDK" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_encode_util-0.0.2.tar.gz", hash = "sha256:f1c484f276d60450fa49b4b2987194e741fcb2f7faae7f287c0ae65abc85fd4d"}, -] - -[[package]] -name = "alibabacloud-darabonba-map" -version = "0.0.1" -description = "Alibaba Cloud Darabonba Map SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_map-0.0.1.tar.gz", hash = "sha256:adb17384658a1a8f72418f1838d4b6a5fd2566bfd392a3ef06d9dbb0a595a23f"}, -] - -[[package]] -name = "alibabacloud-darabonba-signature-util" -version = "0.0.4" -description = "Darabonba Util Library for Alibaba Cloud Python SDK" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_signature_util-0.0.4.tar.gz", hash = "sha256:71d79b2ae65957bcfbf699ced894fda782b32f9635f1616635533e5a90d5feb0"}, -] - -[package.dependencies] -cryptography = ">=3.0.0" - -[[package]] -name = "alibabacloud-darabonba-string" -version = "0.0.4" -description = "Alibaba Cloud Darabonba String Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud-darabonba-string-0.0.4.tar.gz", hash = "sha256:ec6614c0448dadcbc5e466485838a1f8cfdd911135bea739e20b14511270c6f7"}, -] - -[[package]] -name = "alibabacloud-darabonba-time" -version = "0.0.1" -description = "Alibaba Cloud Darabonba Time SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_darabonba_time-0.0.1.tar.gz", hash = "sha256:0ad9c7b0696570d1a3f40106cc7777f755fd92baa0d1dcab5b7df78dde5b922d"}, -] - -[[package]] -name = "alibabacloud-ecs20140526" -version = "7.2.5" -description = "Alibaba Cloud Elastic Compute Service (20140526) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_ecs20140526-7.2.5-py3-none-any.whl", hash = "sha256:10bda5e185f6ba899e7d51477373595c629d66db7530a8a37433fb4e9034a96f"}, - {file = "alibabacloud_ecs20140526-7.2.5.tar.gz", hash = "sha256:2abbe630ce42d69061821f38950b938c5982cc31902ccd7132d05be328765a55"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-endpoint-util" -version = "0.0.4" -description = "The endpoint-util module of alibabaCloud Python SDK." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90"}, -] - -[[package]] -name = "alibabacloud-gateway-oss" -version = "0.0.17" -description = "Alibaba Cloud OSS SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_oss-0.0.17.tar.gz", hash = "sha256:8c4b66c8c7dd285fc210ee232ab3f062b5573258752804d19382000746531e29"}, -] - -[package.dependencies] -alibabacloud_credentials = ">=0.3.5" -alibabacloud_darabonba_array = ">=0.1.0,<1.0.0" -alibabacloud_darabonba_encode_util = ">=0.0.2,<1.0.0" -alibabacloud_darabonba_map = ">=0.0.1,<1.0.0" -alibabacloud_darabonba_signature_util = ">=0.0.4,<1.0.0" -alibabacloud_darabonba_string = ">=0.0.4,<1.0.0" -alibabacloud_darabonba_time = ">=0.0.1,<1.0.0" -alibabacloud_gateway_oss_util = ">=0.0.3,<1.0.0" -alibabacloud_gateway_spi = ">=0.0.1,<1.0.0" -alibabacloud_openapi_util = ">=0.2.1,<1.0.0" -alibabacloud_oss_util = ">=0.0.5,<1.0.0" -alibabacloud_tea_util = ">=0.3.11,<1.0.0" -alibabacloud_tea_xml = ">=0.0.2,<1.0.0" - -[[package]] -name = "alibabacloud-gateway-oss-util" -version = "0.0.3" -description = "Alibaba Cloud OSS Util Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_oss_util-0.0.3.tar.gz", hash = "sha256:5eb7fa450dc7350d5c71577974b9d7f489479e5c5ec7efc1c5376385e8c1c0a5"}, -] - -[[package]] -name = "alibabacloud-gateway-sls" -version = "0.4.0" -description = "Alibaba Cloud SLS Gateway Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_sls-0.4.0-py3-none-any.whl", hash = "sha256:a0299a83a5528025983b42b7533a28028461bced5e180a66f97999e0134760a6"}, - {file = "alibabacloud_gateway_sls-0.4.0.tar.gz", hash = "sha256:9d2aceb377c9b3ed0558149fda16fe39fa114cc0a22e22a88dc76efdda34633b"}, -] - -[package.dependencies] -alibabacloud-credentials = ">=1.0.2,<2.0.0" -alibabacloud-darabonba-array = ">=0.1.0,<1.0.0" -alibabacloud-darabonba-encode-util = ">=0.0.2,<1.0.0" -alibabacloud-darabonba-map = ">=0.0.1,<1.0.0" -alibabacloud-darabonba-signature-util = ">=0.0.4,<1.0.0" -alibabacloud-darabonba-string = ">=0.0.4,<1.0.0" -alibabacloud-gateway-sls-util = ">=0.4.0,<1.0.0" -alibabacloud-gateway-spi = ">=0.0.2,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-gateway-sls-util" -version = "0.4.0" -description = "Alibaba Cloud SLS Util Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_sls_util-0.4.0-py3-none-any.whl", hash = "sha256:c91ab7fe55af526a01d25b0d431088c4d241b160db055da3d8cb7330bd74595a"}, - {file = "alibabacloud_gateway_sls_util-0.4.0.tar.gz", hash = "sha256:f8b683a36a2ae3fe9a8225d3d97773ea769bdf9cdf4f4d033eab2eb6062ddd1f"}, -] - -[package.dependencies] -aliyun-log-fastpb = ">=0.2.0" -lz4 = ">=4.3.2" -zstd = ">=1.5.5.1" - -[[package]] -name = "alibabacloud-gateway-spi" -version = "0.0.3" -description = "Alibaba Cloud Gateway SPI SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b"}, -] - -[package.dependencies] -alibabacloud_credentials = ">=0.3.4" - -[[package]] -name = "alibabacloud-openapi-util" -version = "0.2.2" -description = "Aliyun Tea OpenApi Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_openapi_util-0.2.2.tar.gz", hash = "sha256:ebbc3906f554cb4bf8f513e43e8a33e8b6a3d4a0ef13617a0e14c3dda8ef52a8"}, -] - -[package.dependencies] -alibabacloud_tea_util = ">=0.0.2" -cryptography = ">=3.0.0" - -[[package]] -name = "alibabacloud-oss-util" -version = "0.0.6" -description = "The oss util module of alibabaCloud Python SDK." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6"}, -] - -[package.dependencies] -alibabacloud-tea = "*" - -[[package]] -name = "alibabacloud-oss20190517" -version = "1.0.6" -description = "Alibaba Cloud Object Storage Service (20190517) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_oss20190517-1.0.6-py3-none-any.whl", hash = "sha256:365fda353de6658a1a289f4d70dcd0394e2a8e2921b6b5834ba6d9772121d2f6"}, - {file = "alibabacloud_oss20190517-1.0.6.tar.gz", hash = "sha256:7cd0fb16af613ceb38d2e0e529aa1f58038c7cf59eb67c8c8775ae44ea717852"}, -] - -[package.dependencies] -alibabacloud-gateway-oss = ">=0.0.9,<1.0.0" -alibabacloud-gateway-spi = ">=0.0.1,<1.0.0" -alibabacloud-openapi-util = ">=0.2.1,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.6,<1.0.0" -alibabacloud-tea-util = ">=0.3.11,<1.0.0" - -[[package]] -name = "alibabacloud-ram20150501" -version = "1.2.0" -description = "Alibaba Cloud Resource Access Management (20150501) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_ram20150501-1.2.0-py3-none-any.whl", hash = "sha256:03a0f2a0259848787c1f74e802b486184a88e04183486bd9398766971e5eb00a"}, - {file = "alibabacloud_ram20150501-1.2.0.tar.gz", hash = "sha256:6253513c8880769f4fd5b36fedddb362a9ca628ad9ae9c05c0eeacf5fbc95b42"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-rds20140815" -version = "12.0.0" -description = "Alibaba Cloud rds (20140815) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_rds20140815-12.0.0-py3-none-any.whl", hash = "sha256:0bd7e2018a428d86b1b0681087336e74665b48fc3eb0a13c4f4377ed5eab2b08"}, - {file = "alibabacloud_rds20140815-12.0.0.tar.gz", hash = "sha256:e7421d94f18a914c0a06b0e7fad0daff557713f1c97d415d463a78c1270e9b98"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-sas20181203" -version = "6.1.0" -description = "Alibaba Cloud Threat Detection (20181203) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_sas20181203-6.1.0-py3-none-any.whl", hash = "sha256:1ad735332c50c7961be036b17420d56b5ec3b5557e3aea1daa19491e8b75da20"}, - {file = "alibabacloud_sas20181203-6.1.0.tar.gz", hash = "sha256:e49ffd53e630274a8bf5a8299ca753023ad118510c80f6d9c6fb018b7479bf37"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-sls20201230" -version = "5.9.0" -description = "Alibaba Cloud Log Service (20201230) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_sls20201230-5.9.0-py3-none-any.whl", hash = "sha256:c4ae14096817a9686af5a0ae2389f1f6a8781e60b9edb8643445250cf15c26f1"}, - {file = "alibabacloud_sls20201230-5.9.0.tar.gz", hash = "sha256:bea830b64fbc7ed1719ba386ceeefb120f08d705f03eb0e02409dc6f12a291da"}, -] - -[package.dependencies] -alibabacloud-gateway-sls = ">=0.3.0,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-sts20150401" -version = "1.1.6" -description = "Alibaba Cloud Sts (20150401) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_sts20150401-1.1.6-py3-none-any.whl", hash = "sha256:627f5ca1f86e19b0bf8ce0e99071a36fb65579fad9256fbee38fdc8d500598e9"}, - {file = "alibabacloud_sts20150401-1.1.6.tar.gz", hash = "sha256:c2529b41e0e4531e21cb393e4df346e19fd6d54cc6337d1138dbcd2191438d4c"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alibabacloud-tea" -version = "0.4.3" -description = "The tea module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a"}, -] - -[package.dependencies] -aiohttp = ">=3.7.0,<4.0.0" -requests = ">=2.21.0,<3.0.0" - -[[package]] -name = "alibabacloud-tea-openapi" -version = "0.4.1" -description = "Alibaba Cloud openapi SDK Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_openapi-0.4.1-py3-none-any.whl", hash = "sha256:e46bfa3ca34086d2c357d217a0b7284ecbd4b3bab5c88e075e73aec637b0e4a0"}, - {file = "alibabacloud_tea_openapi-0.4.1.tar.gz", hash = "sha256:2384b090870fdb089c3c40f3fb8cf0145b8c7d6c14abbac521f86a01abb5edaf"}, -] - -[package.dependencies] -alibabacloud-credentials = ">=1.0.2,<2.0.0" -alibabacloud-gateway-spi = ">=0.0.2,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" -cryptography = ">=3.0.0,<45.0.0" -darabonba-core = ">=1.0.3,<2.0.0" - -[[package]] -name = "alibabacloud-tea-util" -version = "0.3.14" -description = "The tea-util module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe"}, - {file = "alibabacloud_tea_util-0.3.14.tar.gz", hash = "sha256:708e7c9f64641a3c9e0e566365d2f23675f8d7c2a3e2971d9402ceede0408cdb"}, -] - -[package.dependencies] -alibabacloud-tea = ">=0.3.3" - -[[package]] -name = "alibabacloud-tea-xml" -version = "0.0.3" -description = "The tea-xml module of alibabaCloud Python SDK." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c"}, -] - -[package.dependencies] -alibabacloud-tea = ">=0.4.0" - -[[package]] -name = "alibabacloud-vpc20160428" -version = "6.13.0" -description = "Alibaba Cloud Virtual Private Cloud (20160428) SDK Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "alibabacloud_vpc20160428-6.13.0-py3-none-any.whl", hash = "sha256:933cf1e74322a20a2df27ca6323760d857744a4246eeadc9fb3eae01322fb1c6"}, - {file = "alibabacloud_vpc20160428-6.13.0.tar.gz", hash = "sha256:daf00679a83d422799f9fcf263739fe1f360641675843cbfbe623833fc8b1681"}, -] - -[package.dependencies] -alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" -alibabacloud-openapi-util = ">=0.2.2,<1.0.0" -alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" -alibabacloud-tea-util = ">=0.3.13,<1.0.0" - -[[package]] -name = "alive-progress" -version = "3.3.0" -description = "A new kind of Progress Bar, with real-time throughput, ETA, and very cool animations!" -optional = false -python-versions = "<4,>=3.9" -groups = ["main"] -files = [ - {file = "alive-progress-3.3.0.tar.gz", hash = "sha256:457dd2428b48dacd49854022a46448d236a48f1b7277874071c39395307e830c"}, - {file = "alive_progress-3.3.0-py3-none-any.whl", hash = "sha256:63dd33bb94cde15ad9e5b666dbba8fedf71b72a4935d6fb9a92931e69402c9ff"}, -] - -[package.dependencies] -about-time = "4.2.1" -graphemeu = "0.7.2" - -[[package]] -name = "aliyun-log-fastpb" -version = "0.2.0" -description = "Fast protobuf serialization for Aliyun Log using PyO3 and quick-protobuf" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:51633d92d2b349aed4843c0b503454fb4f7d73eeaaa54f82aa5a36c10c064ef5"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d2984aafc61ccbbf1db2589ce90b6d5a26e72dba137fb1fdf7f61ce3faa967c0"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:181fc61ac9934f58b0880fa5617a4a4dc709dba09f8be95b5a71e828f2e48053"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12b8bfddf0bc5450f16f1954c6387a73da124fae10d1205a17a0117e66bb56db"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8fbc83cbaa51d332e5e68871c1200014f1f3de54a8cba4fb55a634ee145cd4e4"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a86a6e11dd227d595fa23f69d30588446af19d045d1003bd1b66b5c9a55485"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd92c0b84ba300c1d1c227204c5f2fff243cea80bc3f9399293385e87c82ee3e"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c07a6d81a3eab6666949240da305236ed2350c305154d7e39fcc121fc52291"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cff4fbdd0edff94adcee1dcabf16daacb5d336a12fc897887aa6e4f0ad25152"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5a451809e2a062accbb8dae8750e507e58806e4a8da48d69215cdeef428e9d63"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:61f09df30232f1f5628d13310cf0e175171399ea1c75a8470e9f9d97b045bfb5"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:a5fbf0d41d8c0c964a3dc8dd0ee2e732f876b803e0ed3432550ef3b84dde84f1"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ae2f84ed0777e00045791044a56413f370afbd5b061505f5ded540c04b19c58e"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-win32.whl", hash = "sha256:967f9656c805602fd9be07d8c2756ad89204c852c99689c3c71aa035416ef42a"}, - {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:bbdcf7b85f0f3437c2a8e8a1db0ef5584d21468b7c7a358269a4c651c84f4a54"}, - {file = "aliyun_log_fastpb-0.2.0.tar.gz", hash = "sha256:91c714e76fb941c9a0db6b1aa1f4c56cb1626254ff5444c1179860f5e5b63d93"}, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "antlr4-python3-runtime" -version = "4.13.2" -description = "ANTLR 4.13.2 runtime for Python 3" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8"}, - {file = "antlr4_python3_runtime-4.13.2.tar.gz", hash = "sha256:909b647e1d2fc2b70180ac586df3933e38919c85f98ccc656a96cd3f25ef3916"}, -] - -[[package]] -name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "apscheduler" -version = "3.11.1" -description = "In-process task scheduler with Cron-like capabilities" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2"}, - {file = "apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221"}, -] - -[package.dependencies] -tzlocal = ">=3.0" - -[package.extras] -doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] -etcd = ["etcd3", "protobuf (<=3.21.0)"] -gevent = ["gevent"] -mongodb = ["pymongo (>=3.0)"] -redis = ["redis (>=3.0)"] -rethinkdb = ["rethinkdb (>=2.4.0)"] -sqlalchemy = ["sqlalchemy (>=1.4)"] -test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] -tornado = ["tornado (>=4.3)"] -twisted = ["twisted"] -zookeeper = ["kazoo"] - -[[package]] -name = "astroid" -version = "3.3.11" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec"}, - {file = "astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} - -[[package]] -name = "async-timeout" -version = "5.0.1" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - -[[package]] -name = "attrs" -version = "25.3.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - -[[package]] -name = "authlib" -version = "1.6.5" -description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a"}, - {file = "authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b"}, -] - -[package.dependencies] -cryptography = "*" - -[[package]] -name = "aws-sam-translator" -version = "1.99.0" -description = "AWS SAM Translator is a library that transform SAM templates into AWS CloudFormation templates" -optional = false -python-versions = "!=4.0,<=4.0,>=3.8" -groups = ["dev"] -files = [ - {file = "aws_sam_translator-1.99.0-py3-none-any.whl", hash = "sha256:b1997e09da876342655eb568e66098280ffd137213009f0136b57f4e7694c98c"}, - {file = "aws_sam_translator-1.99.0.tar.gz", hash = "sha256:be326054a7ee2f535fcd914db85e5d50bdf4054313c14888af69b6de3187cdf8"}, -] - -[package.dependencies] -boto3 = ">=1.34.0,<2.0.0" -jsonschema = ">=3.2,<5" -pydantic = ">=1.8,<1.10.15 || >1.10.15,<1.10.17 || >1.10.17,<3" -typing_extensions = ">=4.4" - -[package.extras] -dev = ["black (==24.3.0)", "boto3 (>=1.34.0,<2.0.0)", "boto3-stubs[appconfig,serverlessrepo] (>=1.34.0,<2.0.0)", "cloudformation-cli (>=0.2.39,<0.3.0)", "coverage (>=5.3,<8)", "dateparser (>=1.1,<2.0)", "mypy (>=1.3.0,<1.4.0)", "parameterized (>=0.7,<1.0)", "pytest (>=6.2,<8)", "pytest-cov (>=2.10,<5)", "pytest-env (>=0.6,<1)", "pytest-rerunfailures (>=9.1,<12)", "pytest-xdist (>=2.5,<4)", "pyyaml (>=6.0,<7.0)", "requests (>=2.28,<3.0)", "ruamel.yaml (==0.17.21)", "ruff (>=0.4.5,<0.5.0)", "tenacity (>=9.0,<10.0)", "types-PyYAML (>=6.0,<7.0)", "types-jsonschema (>=3.2,<4.0)"] - -[[package]] -name = "aws-xray-sdk" -version = "2.14.0" -description = "The AWS X-Ray SDK for Python (the SDK) enables Python developers to record and emit information from within their applications to the AWS X-Ray service." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "aws_xray_sdk-2.14.0-py2.py3-none-any.whl", hash = "sha256:cfbe6feea3d26613a2a869d14c9246a844285c97087ad8f296f901633554ad94"}, - {file = "aws_xray_sdk-2.14.0.tar.gz", hash = "sha256:aab843c331af9ab9ba5cefb3a303832a19db186140894a523edafc024cc0493c"}, -] - -[package.dependencies] -botocore = ">=1.11.3" -wrapt = "*" - -[[package]] -name = "awsipranges" -version = "0.3.3" -description = "Work with the AWS IP address ranges in native Python." -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf"}, - {file = "awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0"}, -] - -[[package]] -name = "azure-common" -version = "1.1.28" -description = "Microsoft Azure Client Library for Python (Common)" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-common-1.1.28.zip", hash = "sha256:4ac0cd3214e36b6a1b6a442686722a5d8cc449603aa833f3f0f40bda836704a3"}, - {file = "azure_common-1.1.28-py2.py3-none-any.whl", hash = "sha256:5c12d3dcf4ec20599ca6b0d3e09e86e146353d443e7fcc050c9a19c1f9df20ad"}, -] - -[[package]] -name = "azure-core" -version = "1.38.0" -description = "Microsoft Azure Core Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335"}, - {file = "azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993"}, -] - -[package.dependencies] -requests = ">=2.21.0" -typing-extensions = ">=4.6.0" - -[package.extras] -aio = ["aiohttp (>=3.0)"] -tracing = ["opentelemetry-api (>=1.26,<2.0)"] - -[[package]] -name = "azure-identity" -version = "1.21.0" -description = "Microsoft Azure Identity Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_identity-1.21.0-py3-none-any.whl", hash = "sha256:258ea6325537352440f71b35c3dffe9d240eae4a5126c1b7ce5efd5766bd9fd9"}, - {file = "azure_identity-1.21.0.tar.gz", hash = "sha256:ea22ce6e6b0f429bc1b8d9212d5b9f9877bd4c82f1724bfa910760612c07a9a6"}, -] - -[package.dependencies] -azure-core = ">=1.31.0" -cryptography = ">=2.5" -msal = ">=1.30.0" -msal-extensions = ">=1.2.0" -typing-extensions = ">=4.0.0" - -[[package]] -name = "azure-keyvault-keys" -version = "4.10.0" -description = "Microsoft Azure Key Vault Keys Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_keyvault_keys-4.10.0-py3-none-any.whl", hash = "sha256:210227e0061f641a79755f0e0bcbcf27bbfb4df630a933c43a99a29962283d0d"}, - {file = "azure_keyvault_keys-4.10.0.tar.gz", hash = "sha256:511206ae90aec1726a4d6ff5a92d754bd0c0f1e8751891368d30fb70b62955f1"}, -] - -[package.dependencies] -azure-core = ">=1.31.0" -cryptography = ">=2.1.4" -isodate = ">=0.6.1" -typing-extensions = ">=4.0.1" - -[[package]] -name = "azure-mgmt-apimanagement" -version = "5.0.0" -description = "Microsoft Azure API Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_apimanagement-5.0.0-py3-none-any.whl", hash = "sha256:b88c42a392333b60722fb86f15d092dfc19a8d67510dccd15c217381dff4e6ec"}, - {file = "azure_mgmt_apimanagement-5.0.0.tar.gz", hash = "sha256:0ab7fe17e70fe3154cd840ff47d19d7a4610217003eaa7c21acf3511a6e57999"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-applicationinsights" -version = "4.1.0" -description = "Microsoft Azure Application Insights Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_applicationinsights-4.1.0-py3-none-any.whl", hash = "sha256:9e71f29b01e505a773501451d12fd6a10482cf4b13e9ac2bff72f5380496d979"}, - {file = "azure_mgmt_applicationinsights-4.1.0.tar.gz", hash = "sha256:15531390f12ce3d767cd3f1949af36aa39077c145c952fec4d80303c86ec7b6c"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-authorization" -version = "4.0.0" -description = "Microsoft Azure Authorization Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-authorization-4.0.0.zip", hash = "sha256:69b85abc09ae64fc72975bd43431170d8c7eb5d166754b98aac5f3845de57dc4"}, - {file = "azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-compute" -version = "34.0.0" -description = "Microsoft Azure Compute Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_compute-34.0.0-py3-none-any.whl", hash = "sha256:f8f7b1c5c187a26fae4d1f099adf93561244242f28899484d9a42747bf0d5af4"}, - {file = "azure_mgmt_compute-34.0.0.tar.gz", hash = "sha256:58cd01d025efa02870b84dbfb69834a3b23501a135658c03854d2434e8dfee1e"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-containerregistry" -version = "12.0.0" -description = "Microsoft Azure Container Registry Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_containerregistry-12.0.0-py3-none-any.whl", hash = "sha256:464abd4d3d9ecc0456ed8f63a6b9b93afc2e3e194f2d34f26a758afb67ad3b5c"}, - {file = "azure_mgmt_containerregistry-12.0.0.tar.gz", hash = "sha256:f19f8faa7881deaf2b5015c0eb050a92e2380cd9d18dee33cdb5f27d44a06c03"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-containerservice" -version = "34.1.0" -description = "Microsoft Azure Container Service Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_containerservice-34.1.0-py3-none-any.whl", hash = "sha256:1faa1714e0100c6ee4cfb8d2eadb1c270b548a84b0070c74e9fe646056a5cb12"}, - {file = "azure_mgmt_containerservice-34.1.0.tar.gz", hash = "sha256:637a6cf8f06636c016ad151d76f9c7ba75bd05d4334b3dd7837eb8b517f30dbe"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-core" -version = "1.6.0" -description = "Microsoft Azure Management Core Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_mgmt_core-1.6.0-py3-none-any.whl", hash = "sha256:0460d11e85c408b71c727ee1981f74432bc641bb25dfcf1bb4e90a49e776dbc4"}, - {file = "azure_mgmt_core-1.6.0.tar.gz", hash = "sha256:b26232af857b021e61d813d9f4ae530465255cb10b3dde945ad3743f7a58e79c"}, -] - -[package.dependencies] -azure-core = ">=1.32.0" - -[[package]] -name = "azure-mgmt-cosmosdb" -version = "9.7.0" -description = "Microsoft Azure Cosmos DB Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_cosmosdb-9.7.0-py3-none-any.whl", hash = "sha256:be735a554d16995c8cefe413e62119985f8fabae1cb45a6f6ad2c3958bed14da"}, - {file = "azure_mgmt_cosmosdb-9.7.0.tar.gz", hash = "sha256:b5072d319f11953d8f12e22459aded1912d5f27e442e1d8b49596a85005410a1"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-databricks" -version = "2.0.0" -description = "Microsoft Azure Data Bricks Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-databricks-2.0.0.zip", hash = "sha256:70d11362dc2d17f5fb1db0cfe65c1af55b8f136f1a0db9a5b51e7acf760cf5b9"}, - {file = "azure_mgmt_databricks-2.0.0-py3-none-any.whl", hash = "sha256:0c29434a7339e74231bd171a6c08dcdf8153abaebd332658d7f66b8ea143fa17"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-keyvault" -version = "10.3.1" -description = "Microsoft Azure Key Vault Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure-mgmt-keyvault-10.3.1.tar.gz", hash = "sha256:34b92956aefbdd571cae5a03f7078e037d8087b2c00cfa6748835dc73abb5a30"}, - {file = "azure_mgmt_keyvault-10.3.1-py3-none-any.whl", hash = "sha256:a18a27a06551482d31f92bc43ac8b0846af02cd69511f80090865b4c5caa3c21"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-loganalytics" -version = "12.0.0" -description = "Microsoft Azure Log Analytics Management Client Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-mgmt-loganalytics-12.0.0.zip", hash = "sha256:da128a7e0291be7fa2063848df92a9180cf5c16d42adc09d2bc2efd711536bfb"}, - {file = "azure_mgmt_loganalytics-12.0.0-py2.py3-none-any.whl", hash = "sha256:75ac1d47dd81179905c40765be8834643d8994acff31056ddc1863017f3faa02"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.2.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-monitor" -version = "6.0.2" -description = "Microsoft Azure Monitor Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-monitor-6.0.2.tar.gz", hash = "sha256:5ffbf500e499ab7912b1ba6d26cef26480d9ae411532019bb78d72562196e07b"}, - {file = "azure_mgmt_monitor-6.0.2-py3-none-any.whl", hash = "sha256:fe4cf41e6680b74a228f81451dc5522656d599c6f343ecf702fc790fda9a357b"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-network" -version = "28.1.0" -description = "Microsoft Azure Network Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_network-28.1.0-py3-none-any.whl", hash = "sha256:8ddb0e9ec8f10c9c152d60fc945908d113e4591f397ea3e40b92290ec2b01658"}, - {file = "azure_mgmt_network-28.1.0.tar.gz", hash = "sha256:8c84bffb5ec75c6e0244e58ecf07c00d5fc421d616b0cb369c6fe585af33cf87"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-postgresqlflexibleservers" -version = "1.1.0" -description = "Microsoft Azure Postgresqlflexibleservers Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_postgresqlflexibleservers-1.1.0-py3-none-any.whl", hash = "sha256:87ddb5a5e6d12c45769485d234cfe0322140e3a0a7636d0e61fb00ac544b5d20"}, - {file = "azure_mgmt_postgresqlflexibleservers-1.1.0.tar.gz", hash = "sha256:9ede9d8ba63e9d2879cb74adc903c649af3bc5460a02787287b0cd18d754af14"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-rdbms" -version = "10.1.0" -description = "Microsoft Azure RDBMS Management Client Library for Python" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "azure-mgmt-rdbms-10.1.0.zip", hash = "sha256:a87d401c876c84734cdd4888af551e4a1461b4b328d9816af60cb8ac5979f035"}, - {file = "azure_mgmt_rdbms-10.1.0-py3-none-any.whl", hash = "sha256:8eac17d1341a91d7ed914435941ba917b5ef1568acabc3e65653603966a7cc88"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-recoveryservices" -version = "3.1.0" -description = "Microsoft Azure Recovery Services Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_mgmt_recoveryservices-3.1.0-py3-none-any.whl", hash = "sha256:21c58afdf4ae66806783e95f8cd17e3bec31be7178c48784db21f0b05de7fa66"}, - {file = "azure_mgmt_recoveryservices-3.1.0.tar.gz", hash = "sha256:7f2db98401708cf145322f50bc491caf7967bec4af3bf7b0984b9f07d3092687"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.5.0" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-recoveryservicesbackup" -version = "9.2.0" -description = "Microsoft Azure Recovery Services Backup Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_recoveryservicesbackup-9.2.0-py3-none-any.whl", hash = "sha256:c0002858d0166b6a10189a1fd580a49c83dc31b111e98010a5b2ea0f767dfff1"}, - {file = "azure_mgmt_recoveryservicesbackup-9.2.0.tar.gz", hash = "sha256:c402b3e22a6c3879df56bc37e0063142c3352c5102599ff102d19824f1b32b29"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-resource" -version = "23.3.0" -description = "Microsoft Azure Resource Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_resource-23.3.0-py3-none-any.whl", hash = "sha256:ab216ee28e29db6654b989746e0c85a1181f66653929d2cb6e48fba66d9af323"}, - {file = "azure_mgmt_resource-23.3.0.tar.gz", hash = "sha256:fc4f1fd8b6aad23f8af4ed1f913df5f5c92df117449dc354fea6802a2829fea4"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-search" -version = "9.1.0" -description = "Microsoft Azure Search Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-search-9.1.0.tar.gz", hash = "sha256:53bc6eeadb0974d21f120bb21bb5e6827df6d650e17347460fd83e2d68883599"}, - {file = "azure_mgmt_search-9.1.0-py3-none-any.whl", hash = "sha256:488ff81477e980e2b7abf0b857387c74ebbad419e6f6126044e3e6fad2da72b6"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -isodate = ">=0.6.1,<1.0.0" - -[[package]] -name = "azure-mgmt-security" -version = "7.0.0" -description = "Microsoft Azure Security Center Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure-mgmt-security-7.0.0.tar.gz", hash = "sha256:5912eed7e9d3758fdca8d26e1dc26b41943dc4703208a1184266e2c252e1ad66"}, - {file = "azure_mgmt_security-7.0.0-py3-none-any.whl", hash = "sha256:85a6d8b7a5cd74884a548ed53fed034449f54a9989edd64e9020c5837db96933"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" - -[[package]] -name = "azure-mgmt-sql" -version = "3.0.1" -description = "Microsoft Azure SQL Management Client Library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "azure-mgmt-sql-3.0.1.zip", hash = "sha256:129042cc011225e27aee6ef2697d585fa5722e5d1aeb0038af6ad2451a285457"}, - {file = "azure_mgmt_sql-3.0.1-py2.py3-none-any.whl", hash = "sha256:1d1dd940d4d41be4ee319aad626341251572a5bf4a2addec71779432d9a1381f"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.2.0,<2.0.0" -msrest = ">=0.6.21" - -[[package]] -name = "azure-mgmt-storage" -version = "22.1.1" -description = "Microsoft Azure Storage Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_storage-22.1.1-py3-none-any.whl", hash = "sha256:a4a4064918dcfa4f1cbebada5bf064935d66f2a3647a2f46a1f1c9348736f5d9"}, - {file = "azure_mgmt_storage-22.1.1.tar.gz", hash = "sha256:25aaa5ae8c40c30e2f91f8aae6f52906b0557e947d5c1b9817d4ff9decc11340"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-mgmt-subscription" -version = "3.1.1" -description = "Microsoft Azure Subscription Management Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "azure-mgmt-subscription-3.1.1.zip", hash = "sha256:4e255b4ce9b924357bb8c5009b3c88a2014d3203b2495e2256fa027bf84e800e"}, - {file = "azure_mgmt_subscription-3.1.1-py3-none-any.whl", hash = "sha256:38d4574a8d47fa17e3587d756e296cb63b82ad8fb21cd8543bcee443a502bf48"}, -] - -[package.dependencies] -azure-common = ">=1.1,<2.0" -azure-mgmt-core = ">=1.3.2,<2.0.0" -msrest = ">=0.7.1" - -[[package]] -name = "azure-mgmt-web" -version = "8.0.0" -description = "Microsoft Azure Web Apps Management Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_mgmt_web-8.0.0-py3-none-any.whl", hash = "sha256:0536aac05bfc673b56ed930f2966b77856e84df675d376e782a7af6bb92449af"}, - {file = "azure_mgmt_web-8.0.0.tar.gz", hash = "sha256:c8d9c042c09db7aacb20270a9effed4d4e651e365af32d80897b84dc7bf35098"}, -] - -[package.dependencies] -azure-common = ">=1.1" -azure-mgmt-core = ">=1.3.2" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-monitor-query" -version = "2.0.0" -description = "Microsoft Corporation Azure Monitor Query Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_monitor_query-2.0.0-py3-none-any.whl", hash = "sha256:8f52d581271d785e12f49cd5aaa144b8910fb843db2373855a7ef94c7fc462ea"}, - {file = "azure_monitor_query-2.0.0.tar.gz", hash = "sha256:7b05f2fcac4fb67fc9f77a7d4c5d98a0f3099fb73b57c69ec1b080773994671b"}, -] - -[package.dependencies] -azure-core = ">=1.30.0" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[[package]] -name = "azure-storage-blob" -version = "12.24.1" -description = "Microsoft Azure Blob Storage Client Library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "azure_storage_blob-12.24.1-py3-none-any.whl", hash = "sha256:77fb823fdbac7f3c11f7d86a5892e2f85e161e8440a7489babe2195bf248f09e"}, - {file = "azure_storage_blob-12.24.1.tar.gz", hash = "sha256:052b2a1ea41725ba12e2f4f17be85a54df1129e13ea0321f5a2fcc851cbf47d4"}, -] - -[package.dependencies] -azure-core = ">=1.30.0" -cryptography = ">=2.1.4" -isodate = ">=0.6.1" -typing-extensions = ">=4.6.0" - -[package.extras] -aio = ["azure-core[aio] (>=1.30.0)"] - -[[package]] -name = "bandit" -version = "1.8.3" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8"}, - {file = "bandit-1.8.3.tar.gz", hash = "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] -yaml = ["PyYAML"] - -[[package]] -name = "black" -version = "25.1.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, - {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, - {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, - {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, - {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, - {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, - {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, - {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, - {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, - {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, - {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, - {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, - {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, - {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, - {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, - {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, - {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, - {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, - {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, - {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, - {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, - {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "blinker" -version = "1.9.0" -description = "Fast, simple object-to-object and broadcast signaling" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, - {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, -] - -[[package]] -name = "boto3" -version = "1.40.61" -description = "The AWS SDK for Python" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, - {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, -] - -[package.dependencies] -botocore = ">=1.40.61,<1.41.0" -jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.14.0,<0.15.0" - -[package.extras] -crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] - -[[package]] -name = "botocore" -version = "1.40.61" -description = "Low-level, data-driven core of boto 3." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"}, - {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"}, -] - -[package.dependencies] -jmespath = ">=0.7.1,<2.0.0" -python-dateutil = ">=2.1,<3.0.0" -urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} - -[package.extras] -crt = ["awscrt (==0.27.6)"] - -[[package]] -name = "cachetools" -version = "5.5.2" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, - {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, -] - -[[package]] -name = "certifi" -version = "2025.7.14" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, - {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, -] - -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "cfn-lint" -version = "1.38.0" -description = "Checks CloudFormation templates for practices and behaviour that could potentially be improved" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "cfn_lint-1.38.0-py3-none-any.whl", hash = "sha256:336753eb5259022f6581e26cece84ef729ef3d06ca1445d02ade9a966474d915"}, - {file = "cfn_lint-1.38.0.tar.gz", hash = "sha256:356275ec13a1f9cd20f87ef4ff7396a34aefad633f4783126d8f5507400b925d"}, -] - -[package.dependencies] -aws-sam-translator = ">=1.97.0" -jsonpatch = "*" -networkx = ">=2.4,<4" -pyyaml = ">5.4" -regex = "*" -sympy = ">=1.0.0" -typing_extensions = "*" - -[package.extras] -full = ["jschema_to_python (>=1.2.3,<1.3.0)", "junit-xml (>=1.9,<2.0)", "pydot", "sarif-om (>=1.0.4,<1.1.0)"] -graph = ["pydot"] -junit = ["junit-xml (>=1.9,<2.0)"] -sarif = ["jschema_to_python (>=1.2.3,<1.3.0)", "sarif-om (>=1.0.4,<1.1.0)"] - -[[package]] -name = "charset-normalizer" -version = "3.4.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, - {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, - {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, -] - -[[package]] -name = "circuitbreaker" -version = "2.1.3" -description = "Python Circuit Breaker pattern implementation" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1"}, - {file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"}, -] - -[[package]] -name = "click" -version = "8.2.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "click-plugins" -version = "1.1.1.2" -description = "An extension module for click to enable registering CLI commands via setuptools entry-points." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6"}, - {file = "click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261"}, -] - -[package.dependencies] -click = ">=4.0" - -[package.extras] -dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] - -[[package]] -name = "cloudflare" -version = "4.3.1" -description = "The official Python library for the cloudflare API" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6"}, - {file = "cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258"}, -] - -[package.dependencies] -anyio = ">=3.5.0,<5" -distro = ">=1.7.0,<2" -httpx = ">=0.23.0,<1" -pydantic = ">=1.9.0,<3" -sniffio = "*" -typing-extensions = ">=4.10,<5" - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} - -[[package]] -name = "contextlib2" -version = "21.6.0" -description = "Backports and enhancements for the contextlib module" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, - {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, -] - -[[package]] -name = "coverage" -version = "7.6.12" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, - {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, - {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"}, - {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"}, - {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"}, - {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"}, - {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"}, - {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"}, - {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"}, - {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"}, - {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"}, - {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"}, - {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"}, - {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"}, - {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"}, - {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"}, - {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"}, - {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"}, - {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"}, - {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"}, - {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"}, - {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"}, - {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"}, - {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"}, - {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"}, - {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"}, - {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"}, - {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"}, - {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"}, - {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"}, - {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"}, - {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"}, - {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"}, - {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"}, - {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"}, - {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"}, - {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"}, - {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"}, - {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"}, - {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"}, - {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"}, - {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"}, - {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"}, - {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"}, - {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"}, - {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"}, - {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"}, - {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"}, - {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"}, - {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"}, - {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"}, - {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"}, - {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"}, - {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"}, - {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"}, - {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"}, - {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"}, - {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"}, - {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"}, - {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"}, - {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"}, - {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"}, - {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "cryptography" -version = "44.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main", "dev"] -files = [ - {file = "cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d"}, - {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904"}, - {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44"}, - {file = "cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d"}, - {file = "cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d"}, - {file = "cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f"}, - {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5"}, - {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b"}, - {file = "cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028"}, - {file = "cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c"}, - {file = "cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] -pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "darabonba-core" -version = "1.0.4" -description = "The darabonba module of alibabaCloud Python SDK." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "darabonba_core-1.0.4-py3-none-any.whl", hash = "sha256:4c3bc1d76d5af1087297b6afde8e960ea2f54f93e725e2df8453f0b4bb27dd24"}, - {file = "darabonba_core-1.0.4.tar.gz", hash = "sha256:6ede4e9bfd458148bab19ab2331716ae9b5c226ba5f6d221de6f88ee65704137"}, -] - -[package.dependencies] -aiohttp = ">=3.7.0,<4.0.0" -alibabacloud-tea = "*" -requests = ">=2.21.0,<3.0.0" - -[[package]] -name = "dash" -version = "3.1.1" -description = "A Python framework for building reactive web-apps. Developed by Plotly." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "dash-3.1.1-py3-none-any.whl", hash = "sha256:66fff37e79c6aa114cd55aea13683d1e9afe0e3f96b35388baca95ff6cfdad23"}, - {file = "dash-3.1.1.tar.gz", hash = "sha256:916b31cec46da0a3339da0e9df9f446126aa7f293c0544e07adf9fe4ba060b18"}, -] - -[package.dependencies] -Flask = ">=1.0.4,<3.2" -importlib-metadata = "*" -nest-asyncio = "*" -plotly = ">=5.0.0" -requests = "*" -retrying = "*" -setuptools = "*" -typing-extensions = ">=4.1.1" -Werkzeug = "<3.2" - -[package.extras] -async = ["flask[async]"] -celery = ["celery[redis] (>=5.1.2,<5.4.0)", "kombu (<5.4.0)", "redis (>=3.5.3,<=5.0.4)"] -ci = ["black (==22.3.0)", "flake8 (==7.0.0)", "flaky (==3.8.1)", "flask-talisman (==1.0.0)", "ipython (<9.0.0)", "jupyterlab (<4.0.0)", "mimesis (<=11.1.0)", "mock (==4.0.3)", "mypy (==1.15.0) ; python_version >= \"3.12\"", "numpy (<=1.26.3)", "openpyxl", "orjson (==3.10.3)", "pandas (>=1.4.0)", "pyarrow", "pylint (==3.0.3)", "pyright (==1.1.398) ; python_version >= \"3.7\"", "pytest-mock", "pytest-rerunfailures", "pytest-sugar (==0.9.6)", "pyzmq (==25.1.2)", "xlrd (>=2.0.1)"] -compress = ["flask-compress"] -dev = ["PyYAML (>=5.4.1)", "coloredlogs (>=15.0.1)", "fire (>=0.4.0)"] -diskcache = ["diskcache (>=5.2.1)", "multiprocess (>=0.70.12)", "psutil (>=5.8.0)"] -testing = ["beautifulsoup4 (>=4.8.2)", "cryptography", "dash-testing-stub (>=0.0.2)", "lxml (>=4.6.2)", "multiprocess (>=0.70.12)", "percy (>=2.0.2)", "psutil (>=5.8.0)", "pytest (>=6.0.2)", "requests[security] (>=2.21.0)", "selenium (>=3.141.0,<=4.2.0)", "waitress (>=1.4.4)"] - -[[package]] -name = "dash-bootstrap-components" -version = "2.0.3" -description = "Bootstrap themed components for use in Plotly Dash" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "dash_bootstrap_components-2.0.3-py3-none-any.whl", hash = "sha256:82754d3d001ad5482b8a82b496c7bf98a1c68d2669d607a89dda7ec627304af5"}, - {file = "dash_bootstrap_components-2.0.3.tar.gz", hash = "sha256:5c161b04a6e7ed19a7d54e42f070c29fd6c385d5a7797e7a82999aa2fc15b1de"}, -] - -[package.dependencies] -dash = ">=3.0.4" - -[package.extras] -pandas = ["numpy (>=2.0.2)", "pandas (>=2.2.3)"] - -[[package]] -name = "decorator" -version = "5.2.1" -description = "Decorators for Humans" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, - {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -description = "XML bomb protection for Python stdlib modules" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] -files = [ - {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, - {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, -] - -[[package]] -name = "detect-secrets" -version = "1.5.0" -description = "Tool for detecting secrets in the codebase" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060"}, - {file = "detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a"}, -] - -[package.dependencies] -pyyaml = "*" -requests = "*" - -[package.extras] -gibberish = ["gibberish-detector"] -word-list = ["pyahocorasick"] - -[[package]] -name = "dill" -version = "0.4.0" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, - {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "distlib" -version = "0.4.0" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, -] - -[[package]] -name = "distro" -version = "1.9.0" -description = "Distro - an OS platform information API" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, - {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, -] - -[[package]] -name = "dnspython" -version = "2.7.0" -description = "DNS toolkit" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, - {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, -] - -[package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=43)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=1.0.0)"] -idna = ["idna (>=3.7)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] - -[[package]] -name = "docker" -version = "7.1.0" -description = "A Python library for the Docker Engine API." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, - {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, -] - -[package.dependencies] -pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} -requests = ">=2.26.0" -urllib3 = ">=1.26.0" - -[package.extras] -dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] -docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] -ssh = ["paramiko (>=2.4.3)"] -websockets = ["websocket-client (>=1.3.0)"] - -[[package]] -name = "dogpile-cache" -version = "1.5.0" -description = "A caching front-end based on the Dogpile lock." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d"}, - {file = "dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec"}, -] - -[package.dependencies] -decorator = ">=4.0.0" -stevedore = ">=3.0.0" -typing_extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -bmemcached = ["python-binary-memcached"] -memcached = ["python-memcached"] -pifpaf = ["pifpaf (>=3.3.0)"] -pylibmc = ["pylibmc"] -pymemcache = ["pymemcache"] -redis = ["redis"] -valkey = ["valkey"] - -[[package]] -name = "dparse" -version = "0.6.4" -description = "A parser for Python dependency files" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"}, - {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"}, -] - -[package.dependencies] -packaging = "*" -tomli = {version = "*", markers = "python_version < \"3.11\""} - -[package.extras] -all = ["pipenv", "poetry", "pyyaml"] -conda = ["pyyaml"] -pipenv = ["pipenv"] -poetry = ["poetry"] - -[[package]] -name = "dulwich" -version = "0.23.0" -description = "Python Git Library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "dulwich-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c13b0d5a9009cde23ecb8cb201df6e23e2a7a82c5e2d6ba6443fbb322c9befc6"}, - {file = "dulwich-0.23.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a68faf8612bf93de1285048d6ad13160f0fb3c5596a86e694e78f4e212886fa5"}, - {file = "dulwich-0.23.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d971566826f16ec67c70641c1fbdb337323aa5b533799bc5a4641f4750e73b36"}, - {file = "dulwich-0.23.0-cp310-cp310-win32.whl", hash = "sha256:27d970adf539806dfc4fe3e4c9e8dc6ebf0318977a56e24d22f13413535a51ba"}, - {file = "dulwich-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:025178533e884ffdb0d9d8db4b8870745d438cbfecb782fd1b56c3b6438e86cf"}, - {file = "dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc"}, - {file = "dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527"}, - {file = "dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261"}, - {file = "dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a"}, - {file = "dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd"}, - {file = "dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e"}, - {file = "dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e"}, - {file = "dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9"}, - {file = "dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf"}, - {file = "dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59"}, - {file = "dulwich-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:624e2223c8b705b3a217f9c8d3bfed3a573093be0b0ba033c46cba8411fb9630"}, - {file = "dulwich-0.23.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b4eaf326d15bb3fc5316c777b0312f0fe02f6f82a4368cd971d0ce2167b7ec34"}, - {file = "dulwich-0.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d754afaf7c133a015c75cc2be11703138b4be932e0eeeb2c70add56083f31109"}, - {file = "dulwich-0.23.0-cp313-cp313-win32.whl", hash = "sha256:ac53ec438bde3c1f479782c34240479b36cd47230d091979137b7ecc12c0242e"}, - {file = "dulwich-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:50d3b4ba45671fb8b7d2afbd02c10b4edbc3290a1f92260e64098b409e9ca35c"}, - {file = "dulwich-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8e18ea3fa49f10932077f39c0b960b5045870c550c3d7c74f3cfaac09457cd6"}, - {file = "dulwich-0.23.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3e6df0eb8cca21f210e3ddce2ccb64482646893dbec2fee9f3411d037595bf7b"}, - {file = "dulwich-0.23.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:90c0064d7df8e7fe83d3a03c7d60b9e07a92698b18442f926199b2c3f0bf34d4"}, - {file = "dulwich-0.23.0-cp39-cp39-win32.whl", hash = "sha256:84eef513aba501cbc1f223863f3b4b351fe732d3fb590cab9bdf5d33eb1a1248"}, - {file = "dulwich-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:dce943da48217c26e15790fd6df62d27a7f1d067102780351ebf2635fc0ba482"}, - {file = "dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135"}, - {file = "dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae"}, -] - -[package.dependencies] -urllib3 = ">=1.25" - -[package.extras] -dev = ["dissolve (>=0.1.1)", "mypy (==1.16.0)", "ruff (==0.11.13)"] -fastimport = ["fastimport"] -https = ["urllib3 (>=1.24.1)"] -merge = ["merge3"] -paramiko = ["paramiko"] -pgp = ["gpg"] - -[[package]] -name = "durationpy" -version = "0.10" -description = "Module for converting between datetime.timedelta and Go's Duration strings." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286"}, - {file = "durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba"}, -] - -[[package]] -name = "email-validator" -version = "2.2.0" -description = "A robust email address syntax and deliverability validation library." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, - {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, -] - -[package.dependencies] -dnspython = ">=2.0.0" -idna = ">=2.0.0" - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "execnet" -version = "2.1.1" -description = "execnet: rapid multi-Python deployment" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, - {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, -] - -[package.extras] -testing = ["hatch", "pre-commit", "pytest", "tox"] - -[[package]] -name = "filelock" -version = "3.20.3" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, - {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, -] - -[[package]] -name = "flake8" -version = "7.1.2" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.8.1" -groups = ["dev"] -files = [ - {file = "flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a"}, - {file = "flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.12.0,<2.13.0" -pyflakes = ">=3.2.0,<3.3.0" - -[[package]] -name = "flask" -version = "3.1.1" -description = "A simple framework for building complex web applications." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c"}, - {file = "flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e"}, -] - -[package.dependencies] -blinker = ">=1.9.0" -click = ">=8.1.3" -itsdangerous = ">=2.2.0" -jinja2 = ">=3.1.2" -markupsafe = ">=2.1.1" -werkzeug = ">=3.1.0" - -[package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] - -[[package]] -name = "freezegun" -version = "1.5.1" -description = "Let your Python tests travel through time" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, - {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, -] - -[package.dependencies] -python-dateutil = ">=2.7" - -[[package]] -name = "frozenlist" -version = "1.7.0" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, - {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, - {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, - {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, - {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, - {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, - {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, - {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, - {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb"}, - {file = "frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e"}, - {file = "frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63"}, - {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, - {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, -] - -[[package]] -name = "google-api-core" -version = "2.25.1" -description = "Google API client core library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, - {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, -] - -[package.dependencies] -google-auth = ">=2.14.1,<3.0.0" -googleapis-common-protos = ">=1.56.2,<2.0.0" -proto-plus = ">=1.22.3,<2.0.0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" -requests = ">=2.18.0,<3.0.0" - -[package.extras] -async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""] -grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] - -[[package]] -name = "google-api-python-client" -version = "2.163.0" -description = "Google API Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_api_python_client-2.163.0-py2.py3-none-any.whl", hash = "sha256:080e8bc0669cb4c1fb8efb8da2f5b91a2625d8f0e7796cfad978f33f7016c6c4"}, - {file = "google_api_python_client-2.163.0.tar.gz", hash = "sha256:88dee87553a2d82176e2224648bf89272d536c8f04dcdda37ef0a71473886dd7"}, -] - -[package.dependencies] -google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" -google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -google-auth-httplib2 = ">=0.2.0,<1.0.0" -httplib2 = ">=0.19.0,<1.dev0" -uritemplate = ">=3.0.1,<5" - -[[package]] -name = "google-auth" -version = "2.40.3" -description = "Google Authentication Library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, - {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, -] - -[package.dependencies] -cachetools = ">=2.0.0,<6.0" -pyasn1-modules = ">=0.2.1" -rsa = ">=3.1.4,<5" - -[package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] -enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] -reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0)"] -testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] -urllib3 = ["packaging", "urllib3"] - -[[package]] -name = "google-auth-httplib2" -version = "0.2.0" -description = "Google Authentication Library: httplib2 transport" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, - {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, -] - -[package.dependencies] -google-auth = "*" -httplib2 = ">=0.19.0" - -[[package]] -name = "googleapis-common-protos" -version = "1.70.0" -description = "Common protobufs used in Google APIs" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, - {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, -] - -[package.dependencies] -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" - -[package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0)"] - -[[package]] -name = "graphemeu" -version = "0.7.2" -description = "Unicode grapheme helpers" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "graphemeu-0.7.2-py3-none-any.whl", hash = "sha256:1444520f6899fd30114fc2a39f297d86d10fa0f23bf7579f772f8bc7efaa2542"}, - {file = "graphemeu-0.7.2.tar.gz", hash = "sha256:42bbe373d7c146160f286cd5f76b1a8ad29172d7333ce10705c5cc282462a4f8"}, -] - -[package.extras] -dev = ["pytest"] -docs = ["sphinx", "sphinx-autobuild"] - -[[package]] -name = "graphql-core" -version = "3.2.6" -description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." -optional = false -python-versions = "<4,>=3.6" -groups = ["dev"] -files = [ - {file = "graphql_core-3.2.6-py3-none-any.whl", hash = "sha256:78b016718c161a6fb20a7d97bbf107f331cd1afe53e45566c59f776ed7f0b45f"}, - {file = "graphql_core-3.2.6.tar.gz", hash = "sha256:c08eec22f9e40f0bd61d805907e3b3b1b9a320bc606e23dc145eebca07c8fbab"}, -] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "h2" -version = "4.3.0" -description = "Pure-Python HTTP/2 protocol implementation" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, - {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, -] - -[package.dependencies] -hpack = ">=4.1,<5" -hyperframe = ">=6.1,<7" - -[[package]] -name = "hpack" -version = "4.1.0" -description = "Pure-Python HPACK header encoding" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, - {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.16" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httplib2" -version = "0.22.0" -description = "A comprehensive HTTP client library." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] -files = [ - {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, - {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, -] - -[package.dependencies] -pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "hyperframe" -version = "6.1.0" -description = "Pure-Python HTTP/2 framing" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, - {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, -] - -[[package]] -name = "iamdata" -version = "0.1.202507281" -description = "IAM data for AWS actions, resources, and conditions based on IAM policy documents. Checked for updates daily." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "iamdata-0.1.202507281-py3-none-any.whl", hash = "sha256:654b8deacf757cd16b9a5b7fc2175714aaff8343d4ed551194ed63242ae6c421"}, - {file = "iamdata-0.1.202507281.tar.gz", hash = "sha256:4050870068ca2fb044d03c46229bc8dbafb4f99db2f50c77297aafd437154ddd"}, -] - -[[package]] -name = "identify" -version = "2.6.12" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, - {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main", "dev"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - -[[package]] -name = "iniconfig" -version = "2.1.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, -] - -[[package]] -name = "iso8601" -version = "2.1.0" -description = "Simple module to parse ISO 8601 dates" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, - {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, -] - -[[package]] -name = "isodate" -version = "0.7.2" -description = "An ISO 8601 date/time/duration parser and formatter" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, - {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, -] - -[[package]] -name = "isort" -version = "6.0.1" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, - {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - -[[package]] -name = "itsdangerous" -version = "2.2.0" -description = "Safely pass data to untrusted environments and back." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, - {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jmespath" -version = "1.0.1" -description = "JSON Matching Expressions" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, - {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, -] - -[[package]] -name = "joblib" -version = "1.5.3" -description = "Lightweight pipelining with Python functions" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, - {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, -] - -[[package]] -name = "joserfc" -version = "1.2.2" -description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "joserfc-1.2.2-py3-none-any.whl", hash = "sha256:630cc36b2f11f749980401b0cd7305fab5735ee11d830d919bc207305d011358"}, - {file = "joserfc-1.2.2.tar.gz", hash = "sha256:0d2a84feecef96168635fd9bf288363fc75b4afef3d99691f77833c8e025d200"}, -] - -[package.dependencies] -cryptography = "*" - -[package.extras] -drafts = ["pycryptodome"] - -[[package]] -name = "jsonpatch" -version = "1.33" -description = "Apply JSON-Patches (RFC 6902)" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" -groups = ["main", "dev"] -files = [ - {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, - {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, -] - -[package.dependencies] -jsonpointer = ">=1.9" - -[[package]] -name = "jsonpath-ng" -version = "1.7.0" -description = "A final implementation of JSONPath for Python that aims to be standard compliant, including arithmetic and binary comparison operators and providing clear AST for metaprogramming." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"}, - {file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"}, - {file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"}, -] - -[package.dependencies] -ply = "*" - -[[package]] -name = "jsonpointer" -version = "3.0.0" -description = "Identify specific nodes in a JSON document (RFC 6901)" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, - {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, -] - -[[package]] -name = "jsonschema" -version = "4.23.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, - {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" -referencing = ">=0.28.4" -rpds-py = ">=0.7.1" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-path" -version = "0.3.4" -description = "JSONSchema Spec with object-oriented paths" -optional = false -python-versions = "<4.0.0,>=3.8.0" -groups = ["dev"] -files = [ - {file = "jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8"}, - {file = "jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001"}, -] - -[package.dependencies] -pathable = ">=0.4.1,<0.5.0" -PyYAML = ">=5.1" -referencing = "<0.37.0" -requests = ">=2.31.0,<3.0.0" - -[[package]] -name = "jsonschema-specifications" -version = "2025.4.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, - {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "keystoneauth1" -version = "5.13.0" -description = "Authentication Library for OpenStack Identity" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41"}, - {file = "keystoneauth1-5.13.0.tar.gz", hash = "sha256:57c9ca407207899b50d8ff1ca8abb4a4e7427461bfc1877eb8519c3989ce63ec"}, -] - -[package.dependencies] -iso8601 = ">=2.0.0" -os-service-types = ">=1.2.0" -pbr = ">=2.0.0" -requests = ">=2.14.2" -stevedore = ">=1.20.0" -typing-extensions = ">=4.12" - -[package.extras] -betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"] -kerberos = ["requests-kerberos (>=0.8.0)"] -oauth1 = ["oauthlib (>=0.6.2)"] -saml2 = ["lxml (>=4.2.0)"] - -[[package]] -name = "kubernetes" -version = "32.0.1" -description = "Kubernetes python client" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998"}, - {file = "kubernetes-32.0.1.tar.gz", hash = "sha256:42f43d49abd437ada79a79a16bd48a604d3471a117a8347e87db693f2ba0ba28"}, -] - -[package.dependencies] -certifi = ">=14.5.14" -durationpy = ">=0.7" -google-auth = ">=1.0.1" -oauthlib = ">=3.2.2" -python-dateutil = ">=2.5.3" -pyyaml = ">=5.4.1" -requests = "*" -requests-oauthlib = "*" -six = ">=1.9.0" -urllib3 = ">=1.24.2" -websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0" - -[package.extras] -adal = ["adal (>=1.0.2)"] - -[[package]] -name = "lazy-object-proxy" -version = "1.11.0" -description = "A fast and thorough lazy object proxy." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "lazy_object_proxy-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:132bc8a34f2f2d662a851acfd1b93df769992ed1b81e2b1fda7db3e73b0d5a18"}, - {file = "lazy_object_proxy-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:01261a3afd8621a1accb5682df2593dc7ec7d21d38f411011a5712dcd418fbed"}, - {file = "lazy_object_proxy-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:090935756cc041e191f22f4f9c7fd4fe9a454717067adf5b1bbd2ce3046b556e"}, - {file = "lazy_object_proxy-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:76ec715017f06410f57df442c1a8d66e6b5f7035077785b129817f5ae58810a4"}, - {file = "lazy_object_proxy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a9f39098e93a63618a79eef2889ae3cf0605f676cd4797fdfd49fcd7ddc318b"}, - {file = "lazy_object_proxy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ee13f67f4fcd044ef27bfccb1c93d39c100046fec1fad6e9a1fcdfd17492aeb3"}, - {file = "lazy_object_proxy-1.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4c84eafd8dd15ea16f7d580758bc5c2ce1f752faec877bb2b1f9f827c329cd"}, - {file = "lazy_object_proxy-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:d2503427bda552d3aefcac92f81d9e7ca631e680a2268cbe62cd6a58de6409b7"}, - {file = "lazy_object_proxy-1.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0613116156801ab3fccb9e2b05ed83b08ea08c2517fdc6c6bc0d4697a1a376e3"}, - {file = "lazy_object_proxy-1.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bb03c507d96b65f617a6337dedd604399d35face2cdf01526b913fb50c4cb6e8"}, - {file = "lazy_object_proxy-1.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28c174db37946f94b97a97b579932ff88f07b8d73a46b6b93322b9ac06794a3b"}, - {file = "lazy_object_proxy-1.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:d662f0669e27704495ff1f647070eb8816931231c44e583f4d0701b7adf6272f"}, - {file = "lazy_object_proxy-1.11.0-py3-none-any.whl", hash = "sha256:a56a5093d433341ff7da0e89f9b486031ccd222ec8e52ec84d0ec1cdc819674b"}, - {file = "lazy_object_proxy-1.11.0.tar.gz", hash = "sha256:18874411864c9fbbbaa47f9fc1dd7aea754c86cfde21278ef427639d1dd78e9c"}, -] - -[[package]] -name = "lz4" -version = "4.4.5" -description = "LZ4 Bindings for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d"}, - {file = "lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f"}, - {file = "lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3"}, - {file = "lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758"}, - {file = "lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1"}, - {file = "lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc"}, - {file = "lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd"}, - {file = "lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397"}, - {file = "lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4"}, - {file = "lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43"}, - {file = "lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7"}, - {file = "lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb"}, - {file = "lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989"}, - {file = "lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d"}, - {file = "lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004"}, - {file = "lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b"}, - {file = "lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e"}, - {file = "lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a"}, - {file = "lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5"}, - {file = "lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e"}, - {file = "lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e"}, - {file = "lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50"}, - {file = "lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33"}, - {file = "lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301"}, - {file = "lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c"}, - {file = "lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a"}, - {file = "lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d"}, - {file = "lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c"}, - {file = "lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64"}, - {file = "lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832"}, - {file = "lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22"}, - {file = "lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9"}, - {file = "lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f"}, - {file = "lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba"}, - {file = "lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d"}, - {file = "lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67"}, - {file = "lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d"}, - {file = "lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901"}, - {file = "lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb"}, - {file = "lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd"}, - {file = "lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f"}, - {file = "lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6"}, - {file = "lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9"}, - {file = "lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668"}, - {file = "lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f"}, - {file = "lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67"}, - {file = "lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be"}, - {file = "lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7"}, - {file = "lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad"}, - {file = "lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294"}, - {file = "lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86"}, - {file = "lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547"}, - {file = "lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581"}, - {file = "lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce"}, - {file = "lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7"}, - {file = "lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0"}, - {file = "lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0"}, -] - -[package.extras] -docs = ["sphinx (>=1.6.0)", "sphinx_bootstrap_theme"] -flake8 = ["flake8"] -tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"] - -[[package]] -name = "markdown" -version = "3.10.2" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, - {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, -] - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "3.0.2" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, -] - -[[package]] -name = "marshmallow" -version = "3.26.2" -description = "A lightweight library for converting complex datatypes to and from native Python datatypes." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, - {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, -] - -[package.dependencies] -packaging = ">=17.0" - -[package.extras] -dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] -docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] -tests = ["pytest", "simplejson"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "microsoft-kiota-abstractions" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_abstractions-1.9.2-py3-none-any.whl", hash = "sha256:a8853d272a84da59d6a2fe11a76c28e9c55bdab268a345ba48e918cb6822b607"}, - {file = "microsoft_kiota_abstractions-1.9.2.tar.gz", hash = "sha256:29cdafe8d0672f23099556e0b120dca6231c752cca9393e1e0092fa9ca594572"}, -] - -[package.dependencies] -opentelemetry-api = ">=1.27.0" -opentelemetry-sdk = ">=1.27.0" -std-uritemplate = ">=2.0.0" - -[[package]] -name = "microsoft-kiota-authentication-azure" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_authentication_azure-1.9.2-py3-none-any.whl", hash = "sha256:56840f8b15df8aedfd143fb2deb7cc7fae4ac0bafb1a50546b7313a7b3ab4ca0"}, - {file = "microsoft_kiota_authentication_azure-1.9.2.tar.gz", hash = "sha256:171045f522a93d9340fbddc4cabb218f14f1d9d289e82e535b3d9291986c3d5a"}, -] - -[package.dependencies] -aiohttp = ">=3.8.0" -azure-core = ">=1.21.1" -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" -opentelemetry-api = ">=1.27.0" -opentelemetry-sdk = ">=1.27.0" - -[[package]] -name = "microsoft-kiota-http" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_http-1.9.2-py3-none-any.whl", hash = "sha256:3a2d930a70d0184d9f4848473f929ee892462cae1acfaf33b2d193f1828c76c2"}, - {file = "microsoft_kiota_http-1.9.2.tar.gz", hash = "sha256:2ba3d04a3d1d5d600736eebc1e33533d54d87799ac4fbb92c9cce4a97809af61"}, -] - -[package.dependencies] -httpx = {version = ">=0.25,<1.0.0", extras = ["http2"]} -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" -opentelemetry-api = ">=1.27.0" -opentelemetry-sdk = ">=1.27.0" - -[[package]] -name = "microsoft-kiota-serialization-form" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_form-1.9.2-py3-none-any.whl", hash = "sha256:7b997efb2c8750b1d4fbc00878ba2a3e6e1df3fcefc8815226c90fcc9c54f218"}, - {file = "microsoft_kiota_serialization_form-1.9.2.tar.gz", hash = "sha256:badfbe65d8ec3369bd58b01022d13ef590edf14babeef94188efe3f4ec24fe41"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "microsoft-kiota-serialization-json" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_json-1.9.2-py3-none-any.whl", hash = "sha256:8f4ecf485607fff3df5ce8fa9b9c957bc7f4bff1658b183703e180af753098e3"}, - {file = "microsoft_kiota_serialization_json-1.9.2.tar.gz", hash = "sha256:19f7beb69c67b2cb77ca96f77824ee78a693929e20237bb5476ea54f69118bf1"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "microsoft-kiota-serialization-multipart" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_multipart-1.9.2-py3-none-any.whl", hash = "sha256:641ad374046f1c7adff90d110bdc68d77418adb1e479a716f4ffea3647f0ead6"}, - {file = "microsoft_kiota_serialization_multipart-1.9.2.tar.gz", hash = "sha256:b1851409205668d83f5c7a35a8b6fca974b341985b4a92841e95aaec93b7ca0a"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "microsoft-kiota-serialization-text" -version = "1.9.2" -description = "Core abstractions for kiota generated libraries in Python" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["main"] -files = [ - {file = "microsoft_kiota_serialization_text-1.9.2-py3-none-any.whl", hash = "sha256:6e63129ea29eb9b976f4ed56fc6595d204e29fc309958b639299e9f9f4e5edb4"}, - {file = "microsoft_kiota_serialization_text-1.9.2.tar.gz", hash = "sha256:4289508ebac0cefdc4fa21c545051769a9409913972355ccda9116b647f978f2"}, -] - -[package.dependencies] -microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" - -[[package]] -name = "mock" -version = "5.2.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, - {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "moto" -version = "5.1.11" -description = "A library that allows you to easily mock out tests based on AWS infrastructure" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "moto-5.1.11-py3-none-any.whl", hash = "sha256:d09429ed5f67f8568637700cd525997d6abe7f91439a6f900b4f98a9fe4ecac9"}, - {file = "moto-5.1.11.tar.gz", hash = "sha256:1330b6d9b91088e971469dfb67f297595541914b364e0b49047bb82622975ec7"}, -] - -[package.dependencies] -antlr4-python3-runtime = {version = "*", optional = true, markers = "extra == \"all\""} -aws-xray-sdk = {version = ">=0.93,<0.96 || >0.96", optional = true, markers = "extra == \"all\""} -boto3 = ">=1.9.201" -botocore = ">=1.20.88,<1.35.45 || >1.35.45,<1.35.46 || >1.35.46" -cfn-lint = {version = ">=0.40.0", optional = true, markers = "extra == \"all\""} -cryptography = ">=35.0.0" -docker = {version = ">=3.0.0", optional = true, markers = "extra == \"all\""} -graphql-core = {version = "*", optional = true, markers = "extra == \"all\""} -Jinja2 = ">=2.10.1" -joserfc = {version = ">=0.9.0", optional = true, markers = "extra == \"all\""} -jsonpath_ng = {version = "*", optional = true, markers = "extra == \"all\""} -jsonschema = {version = "*", optional = true, markers = "extra == \"all\""} -multipart = {version = "*", optional = true, markers = "extra == \"all\""} -openapi-spec-validator = {version = ">=0.5.0", optional = true, markers = "extra == \"all\""} -py-partiql-parser = {version = "0.6.1", optional = true, markers = "extra == \"all\""} -pyparsing = {version = ">=3.0.7", optional = true, markers = "extra == \"all\""} -python-dateutil = ">=2.1,<3.0.0" -PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"all\""} -requests = ">=2.5" -responses = ">=0.15.0,<0.25.5 || >0.25.5" -setuptools = {version = "*", optional = true, markers = "extra == \"all\""} -werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1" -xmltodict = "*" - -[package.extras] -all = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "jsonpath_ng", "jsonschema", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.1)", "pyparsing (>=3.0.7)", "setuptools"] -apigateway = ["PyYAML (>=5.1)", "joserfc (>=0.9.0)", "openapi-spec-validator (>=0.5.0)"] -apigatewayv2 = ["PyYAML (>=5.1)", "openapi-spec-validator (>=0.5.0)"] -appsync = ["graphql-core"] -awslambda = ["docker (>=3.0.0)"] -batch = ["docker (>=3.0.0)"] -cloudformation = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.1)", "pyparsing (>=3.0.7)", "setuptools"] -cognitoidp = ["joserfc (>=0.9.0)"] -dynamodb = ["docker (>=3.0.0)", "py-partiql-parser (==0.6.1)"] -dynamodbstreams = ["docker (>=3.0.0)", "py-partiql-parser (==0.6.1)"] -events = ["jsonpath_ng"] -glue = ["pyparsing (>=3.0.7)"] -proxy = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=2.5.1)", "graphql-core", "joserfc (>=0.9.0)", "jsonpath_ng", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.1)", "pyparsing (>=3.0.7)", "setuptools"] -quicksight = ["jsonschema"] -resourcegroupstaggingapi = ["PyYAML (>=5.1)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.1)", "pyparsing (>=3.0.7)"] -s3 = ["PyYAML (>=5.1)", "py-partiql-parser (==0.6.1)"] -s3crc32c = ["PyYAML (>=5.1)", "crc32c", "py-partiql-parser (==0.6.1)"] -server = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "flask (!=2.2.0,!=2.2.1)", "flask-cors", "graphql-core", "joserfc (>=0.9.0)", "jsonpath_ng", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.1)", "pyparsing (>=3.0.7)", "setuptools"] -ssm = ["PyYAML (>=5.1)"] -stepfunctions = ["antlr4-python3-runtime", "jsonpath_ng"] -xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] - -[[package]] -name = "mpmath" -version = "1.3.0" -description = "Python library for arbitrary-precision floating-point arithmetic" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, - {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, -] - -[package.extras] -develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] -docs = ["sphinx"] -gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] -tests = ["pytest (>=4.6)"] - -[[package]] -name = "msal" -version = "1.33.0" -description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "msal-1.33.0-py3-none-any.whl", hash = "sha256:c0cd41cecf8eaed733ee7e3be9e040291eba53b0f262d3ae9c58f38b04244273"}, - {file = "msal-1.33.0.tar.gz", hash = "sha256:836ad80faa3e25a7d71015c990ce61f704a87328b1e73bcbb0623a18cbf17510"}, -] - -[package.dependencies] -cryptography = ">=2.5,<48" -PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]} -requests = ">=2.0.0,<3" - -[package.extras] -broker = ["pymsalruntime (>=0.14,<0.19) ; python_version >= \"3.6\" and platform_system == \"Windows\"", "pymsalruntime (>=0.17,<0.19) ; python_version >= \"3.8\" and platform_system == \"Darwin\"", "pymsalruntime (>=0.18,<0.19) ; python_version >= \"3.8\" and platform_system == \"Linux\""] - -[[package]] -name = "msal-extensions" -version = "1.3.1" -description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, - {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, -] - -[package.dependencies] -msal = ">=1.29,<2" - -[package.extras] -portalocker = ["portalocker (>=1.4,<4)"] - -[[package]] -name = "msgraph-core" -version = "1.3.5" -description = "Core component of the Microsoft Graph Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "msgraph_core-1.3.5-py3-none-any.whl", hash = "sha256:bc496c6f99c626bc534012c6fe9afa35c37bcdce0f92acf26e4210f4ff9bb154"}, - {file = "msgraph_core-1.3.5.tar.gz", hash = "sha256:43aec9df1c011f1c6a1e14f2b5e9266c05a723ed750a5d3ea1eb0c0f1deb9975"}, -] - -[package.dependencies] -httpx = {version = ">=0.23.0", extras = ["http2"]} -microsoft-kiota-abstractions = ">=1.8.0,<2.0.0" -microsoft-kiota-authentication-azure = ">=1.8.0,<2.0.0" -microsoft-kiota-http = ">=1.8.0,<2.0.0" - -[package.extras] -dev = ["bumpver", "isort", "mypy", "pylint", "pytest", "yapf"] - -[[package]] -name = "msgraph-sdk" -version = "1.23.0" -description = "The Microsoft Graph Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "msgraph_sdk-1.23.0-py3-none-any.whl", hash = "sha256:58e0047b4ca59fd82022c02cd73fec0170a3d84f3b76721e3db2a0314df9a58a"}, - {file = "msgraph_sdk-1.23.0.tar.gz", hash = "sha256:6dd1ba9a46f5f0ce8599fd9610133adbd9d1493941438b5d3632fce9e55ed607"}, -] - -[package.dependencies] -azure-identity = ">=1.12.0" -microsoft-kiota-serialization-form = ">=1.8.0,<2.0.0" -microsoft-kiota-serialization-json = ">=1.8.0,<2.0.0" -microsoft-kiota-serialization-multipart = ">=1.8.0,<2.0.0" -microsoft-kiota-serialization-text = ">=1.8.0,<2.0.0" -msgraph_core = ">=1.3.1" - -[package.extras] -dev = ["bumpver", "isort", "mypy", "pylint", "pytest", "yapf"] - -[[package]] -name = "msrest" -version = "0.7.1" -description = "AutoRest swagger generator Python client runtime." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32"}, - {file = "msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9"}, -] - -[package.dependencies] -azure-core = ">=1.24.0" -certifi = ">=2017.4.17" -isodate = ">=0.6.0" -requests = ">=2.16,<3.0" -requests-oauthlib = ">=0.5.0" - -[package.extras] -async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] - -[[package]] -name = "multidict" -version = "6.6.3" -description = "multidict implementation" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817"}, - {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140"}, - {file = "multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b"}, - {file = "multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318"}, - {file = "multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485"}, - {file = "multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5"}, - {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c"}, - {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df"}, - {file = "multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183"}, - {file = "multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5"}, - {file = "multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2"}, - {file = "multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb"}, - {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6"}, - {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f"}, - {file = "multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10"}, - {file = "multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5"}, - {file = "multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17"}, - {file = "multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b"}, - {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55"}, - {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b"}, - {file = "multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6"}, - {file = "multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e"}, - {file = "multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9"}, - {file = "multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600"}, - {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134"}, - {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37"}, - {file = "multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c"}, - {file = "multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e"}, - {file = "multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d"}, - {file = "multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb"}, - {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c8161b5a7778d3137ea2ee7ae8a08cce0010de3b00ac671c5ebddeaa17cefd22"}, - {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1328201ee930f069961ae707d59c6627ac92e351ed5b92397cf534d1336ce557"}, - {file = "multidict-6.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b1db4d2093d6b235de76932febf9d50766cf49a5692277b2c28a501c9637f616"}, - {file = "multidict-6.6.3-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53becb01dd8ebd19d1724bebe369cfa87e4e7f29abbbe5c14c98ce4c383e16cd"}, - {file = "multidict-6.6.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41bb9d1d4c303886e2d85bade86e59885112a7f4277af5ad47ab919a2251f306"}, - {file = "multidict-6.6.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:775b464d31dac90f23192af9c291dc9f423101857e33e9ebf0020a10bfcf4144"}, - {file = "multidict-6.6.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d04d01f0a913202205a598246cf77826fe3baa5a63e9f6ccf1ab0601cf56eca0"}, - {file = "multidict-6.6.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d25594d3b38a2e6cabfdcafef339f754ca6e81fbbdb6650ad773ea9775af35ab"}, - {file = "multidict-6.6.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35712f1748d409e0707b165bf49f9f17f9e28ae85470c41615778f8d4f7d9609"}, - {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c8082e5814b662de8589d6a06c17e77940d5539080cbab9fe6794b5241b76d9"}, - {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:61af8a4b771f1d4d000b3168c12c3120ccf7284502a94aa58c68a81f5afac090"}, - {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:448e4a9afccbf297577f2eaa586f07067441e7b63c8362a3540ba5a38dc0f14a"}, - {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:233ad16999afc2bbd3e534ad8dbe685ef8ee49a37dbc2cdc9514e57b6d589ced"}, - {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bb933c891cd4da6bdcc9733d048e994e22e1883287ff7540c2a0f3b117605092"}, - {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37b09ca60998e87734699e88c2363abfd457ed18cfbf88e4009a4e83788e63ed"}, - {file = "multidict-6.6.3-cp39-cp39-win32.whl", hash = "sha256:f54cb79d26d0cd420637d184af38f0668558f3c4bbe22ab7ad830e67249f2e0b"}, - {file = "multidict-6.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:295adc9c0551e5d5214b45cf29ca23dbc28c2d197a9c30d51aed9e037cb7c578"}, - {file = "multidict-6.6.3-cp39-cp39-win_arm64.whl", hash = "sha256:15332783596f227db50fb261c2c251a58ac3873c457f3a550a95d5c0aa3c770d"}, - {file = "multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a"}, - {file = "multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "multipart" -version = "1.3.0" -description = "Parser for multipart/form-data" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "multipart-1.3.0-py3-none-any.whl", hash = "sha256:439bf4b00fd7cb2dbff08ae13f49f4f49798931ecd8d496372c63537fa19f304"}, - {file = "multipart-1.3.0.tar.gz", hash = "sha256:a46bd6b0eb4c1ba865beb88ddd886012a3da709b6e7b86084fc37e99087e5cf1"}, -] - -[package.extras] -dev = ["build", "pytest", "pytest-cov", "tox", "tox-uv", "twine"] -docs = ["sphinx (>=8,<9)", "sphinx-autobuild"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "narwhals" -version = "2.0.0" -description = "Extremely lightweight compatibility layer between dataframe libraries" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "narwhals-2.0.0-py3-none-any.whl", hash = "sha256:9c9fe8a969b090d783edbcb3b58e1d0d15f5100fdf85b53f5e76d38f4ce7f19a"}, - {file = "narwhals-2.0.0.tar.gz", hash = "sha256:d967bea54dfb6cd787abf3865ab4d72b8259d8f798c1c12c4eb693d5e9cebb24"}, -] - -[package.extras] -cudf = ["cudf (>=24.10.0)"] -dask = ["dask[dataframe] (>=2024.8)"] -duckdb = ["duckdb (>=1.0)"] -ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"] -modin = ["modin"] -pandas = ["pandas (>=1.1.3)"] -polars = ["polars (>=0.20.4)"] -pyarrow = ["pyarrow (>=13.0.0)"] -pyspark = ["pyspark (>=3.5.0)"] -pyspark-connect = ["pyspark[connect] (>=3.5.0)"] -sqlframe = ["sqlframe (>=3.22.0)"] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -description = "Patch asyncio to allow nested event loops" -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, - {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, -] - -[[package]] -name = "networkx" -version = "3.4.2" -description = "Python package for creating and manipulating graphs and networks" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f"}, - {file = "networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1"}, -] - -[package.extras] -default = ["matplotlib (>=3.7)", "numpy (>=1.24)", "pandas (>=2.0)", "scipy (>=1.10,!=1.11.0,!=1.11.1)"] -developer = ["changelist (==0.5)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] -doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.15)", "sphinx (>=7.3)", "sphinx-gallery (>=0.16)", "texext (>=0.6.7)"] -example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=1.9)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] -extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] - -[[package]] -name = "networkx" -version = "3.5" -description = "Python package for creating and manipulating graphs and networks" -optional = false -python-versions = ">=3.11" -groups = ["dev"] -markers = "python_version >= \"3.11\"" -files = [ - {file = "networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec"}, - {file = "networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037"}, -] - -[package.extras] -default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"] -developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"] -doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"] -example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] -extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] -test-extras = ["pytest-mpl", "pytest-randomly"] - -[[package]] -name = "nltk" -version = "3.9.4" -description = "Natural Language Toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f"}, - {file = "nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0"}, -] - -[package.dependencies] -click = "*" -joblib = "*" -regex = ">=2021.8.3" -tqdm = "*" - -[package.extras] -all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] -corenlp = ["requests"] -machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] -plot = ["matplotlib"] -tgrep = ["pyparsing"] -twitter = ["twython"] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "numpy" -version = "2.0.2" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, - {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, - {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, - {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, - {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, - {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, - {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, - {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, - {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, - {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, - {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, - {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, - {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, - {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, - {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, - {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, - {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, - {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, - {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, - {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, - {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, - {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, - {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, - {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, - {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, - {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, - {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, - {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, - {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, - {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, - {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, -] - -[[package]] -name = "oauthlib" -version = "3.3.1" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, - {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, -] - -[package.extras] -rsa = ["cryptography (>=3.0.0)"] -signals = ["blinker (>=1.4.0)"] -signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] - -[[package]] -name = "oci" -version = "2.160.3" -description = "Oracle Cloud Infrastructure Python SDK" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"}, - {file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"}, -] - -[package.dependencies] -certifi = "*" -circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""} -cryptography = ">=3.2.1,<46.0.0" -pyOpenSSL = ">=17.5.0,<25.0.0" -python-dateutil = ">=2.5.3,<3.0.0" -pytz = ">=2016.10" - -[package.extras] -adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""] - -[[package]] -name = "openapi-schema-validator" -version = "0.6.3" -description = "OpenAPI schema validation for Python" -optional = false -python-versions = "<4.0.0,>=3.8.0" -groups = ["dev"] -files = [ - {file = "openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3"}, - {file = "openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee"}, -] - -[package.dependencies] -jsonschema = ">=4.19.1,<5.0.0" -jsonschema-specifications = ">=2023.5.2" -rfc3339-validator = "*" - -[[package]] -name = "openapi-spec-validator" -version = "0.7.1" -description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" -optional = false -python-versions = ">=3.8.0,<4.0.0" -groups = ["dev"] -files = [ - {file = "openapi_spec_validator-0.7.1-py3-none-any.whl", hash = "sha256:3c81825043f24ccbcd2f4b149b11e8231abce5ba84f37065e14ec947d8f4e959"}, - {file = "openapi_spec_validator-0.7.1.tar.gz", hash = "sha256:8577b85a8268685da6f8aa30990b83b7960d4d1117e901d451b5d572605e5ec7"}, -] - -[package.dependencies] -jsonschema = ">=4.18.0,<5.0.0" -jsonschema-path = ">=0.3.1,<0.4.0" -lazy-object-proxy = ">=1.7.1,<2.0.0" -openapi-schema-validator = ">=0.6.0,<0.7.0" - -[[package]] -name = "openstacksdk" -version = "4.2.0" -description = "An SDK for building applications to work with OpenStack" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "openstacksdk-4.2.0-py3-none-any.whl", hash = "sha256:238be0fa5d9899872b00787ab38e84f92fd6dc87525fde0965dadcdc12196dc6"}, - {file = "openstacksdk-4.2.0.tar.gz", hash = "sha256:5cb9450dcce8054a2caf89d8be9e55057ddfa219a954e781032241eb29280445"}, -] - -[package.dependencies] -cryptography = ">=2.7" -decorator = ">=4.4.1" -"dogpile.cache" = ">=0.6.5" -iso8601 = ">=0.1.11" -jmespath = ">=0.9.0" -jsonpatch = ">=1.16,<1.20 || >1.20" -keystoneauth1 = ">=3.18.0" -os-service-types = ">=1.7.0" -pbr = ">=2.0.0,<2.1.0 || >2.1.0" -platformdirs = ">=3" -psutil = ">=3.2.2" -PyYAML = ">=3.13" -requestsexceptions = ">=1.2.0" - -[[package]] -name = "opentelemetry-api" -version = "1.35.0" -description = "OpenTelemetry Python API" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06"}, - {file = "opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe"}, -] - -[package.dependencies] -importlib-metadata = ">=6.0,<8.8.0" -typing-extensions = ">=4.5.0" - -[[package]] -name = "opentelemetry-sdk" -version = "1.35.0" -description = "OpenTelemetry Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800"}, - {file = "opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954"}, -] - -[package.dependencies] -opentelemetry-api = "1.35.0" -opentelemetry-semantic-conventions = "0.56b0" -typing-extensions = ">=4.5.0" - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.56b0" -description = "OpenTelemetry Semantic Conventions" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2"}, - {file = "opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea"}, -] - -[package.dependencies] -opentelemetry-api = "1.35.0" -typing-extensions = ">=4.5.0" - -[[package]] -name = "os-service-types" -version = "1.8.2" -description = "Python library for consuming OpenStack sevice-types-authority data" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e"}, - {file = "os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575"}, -] - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" -typing-extensions = ">=4.1.0" - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pandas" -version = "2.2.3" -description = "Powerful data structures for data analysis, time series, and statistics" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, - {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, - {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"}, - {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"}, - {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"}, - {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"}, - {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"}, - {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"}, - {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"}, - {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"}, - {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"}, - {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"}, - {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"}, - {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"}, - {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"}, - {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"}, - {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"}, - {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"}, - {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"}, - {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"}, - {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"}, - {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"}, - {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"}, - {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"}, - {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"}, - {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"}, - {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"}, - {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"}, - {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"}, - {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"}, - {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"}, - {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"}, -] - -[package.dependencies] -numpy = [ - {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, -] -python-dateutil = ">=2.8.2" -pytz = ">=2020.1" -tzdata = ">=2022.7" - -[package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] -aws = ["s3fs (>=2022.11.0)"] -clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] -compression = ["zstandard (>=0.19.0)"] -computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] -consortium-standard = ["dataframe-api-compat (>=0.1.7)"] -excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] -feather = ["pyarrow (>=10.0.1)"] -fss = ["fsspec (>=2022.11.0)"] -gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] -hdf5 = ["tables (>=3.8.0)"] -html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] -mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] -output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] -parquet = ["pyarrow (>=10.0.1)"] -performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] -plot = ["matplotlib (>=3.6.3)"] -postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] -pyarrow = ["pyarrow (>=10.0.1)"] -spss = ["pyreadstat (>=1.2.0)"] -sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] -test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] -xml = ["lxml (>=4.9.2)"] - -[[package]] -name = "pathable" -version = "0.4.4" -description = "Object-oriented paths" -optional = false -python-versions = "<4.0.0,>=3.7.0" -groups = ["dev"] -files = [ - {file = "pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2"}, - {file = "pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "pbr" -version = "6.1.1" -description = "Python Build Reasonableness" -optional = false -python-versions = ">=2.6" -groups = ["main", "dev"] -files = [ - {file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"}, - {file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"}, -] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "platformdirs" -version = "4.3.8" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, - {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] - -[[package]] -name = "plotly" -version = "6.2.0" -description = "An open-source interactive data visualization library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "plotly-6.2.0-py3-none-any.whl", hash = "sha256:32c444d4c940887219cb80738317040363deefdfee4f354498cc0b6dab8978bd"}, - {file = "plotly-6.2.0.tar.gz", hash = "sha256:9dfa23c328000f16c928beb68927444c1ab9eae837d1fe648dbcda5360c7953d"}, -] - -[package.dependencies] -narwhals = ">=1.15.1" -packaging = "*" - -[package.extras] -dev = ["plotly[dev-optional]"] -dev-build = ["build", "jupyter", "plotly[dev-core]"] -dev-core = ["pytest", "requests", "ruff (==0.11.12)"] -dev-optional = ["anywidget", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "numpy", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "plotly[dev-build]", "plotly[kaleido]", "polars[timezone]", "pyarrow", "pyshp", "pytz", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] -express = ["numpy"] -kaleido = ["kaleido (>=1.0.0)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "ply" -version = "3.11" -description = "Python Lex & Yacc" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, - {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, -] - -[[package]] -name = "pre-commit" -version = "4.2.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, - {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "propcache" -version = "0.3.2" -description = "Accelerated property cache" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c"}, - {file = "propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70"}, - {file = "propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e"}, - {file = "propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897"}, - {file = "propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1"}, - {file = "propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1"}, - {file = "propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43"}, - {file = "propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02"}, - {file = "propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330"}, - {file = "propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394"}, - {file = "propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe"}, - {file = "propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1"}, - {file = "propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9"}, - {file = "propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f"}, - {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, -] - -[[package]] -name = "proto-plus" -version = "1.26.1" -description = "Beautiful, Pythonic protocol buffers" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, - {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, -] - -[package.dependencies] -protobuf = ">=3.19.0,<7.0.0" - -[package.extras] -testing = ["google-api-core (>=1.31.5)"] - -[[package]] -name = "protobuf" -version = "6.31.1" -description = "" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, - {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, - {file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, - {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, - {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, - {file = "protobuf-6.31.1-cp39-cp39-win32.whl", hash = "sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16"}, - {file = "protobuf-6.31.1-cp39-cp39-win_amd64.whl", hash = "sha256:8764cf4587791e7564051b35524b72844f845ad0bb011704c3736cce762d8fe9"}, - {file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, - {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, -] - -[[package]] -name = "psutil" -version = "7.2.2" -description = "Cross-platform lib for process and system monitoring." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, - {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, - {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, - {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, - {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, - {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, - {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, - {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, - {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, -] - -[package.extras] -dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] -test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] - -[[package]] -name = "py-iam-expand" -version = "0.1.0" -description = "This is a Python package to expand and deobfuscate IAM policies." -optional = false -python-versions = "<3.14,>3.9.1" -groups = ["main"] -files = [ - {file = "py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510"}, - {file = "py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96"}, -] - -[package.dependencies] -iamdata = ">=0.1.202504091" - -[[package]] -name = "py-ocsf-models" -version = "0.8.1" -description = "This is a Python implementation of the OCSF models. The models are used to represent the data of the OCSF Schema defined in https://schema.ocsf.io/." -optional = false -python-versions = "<3.15,>3.9.1" -groups = ["main"] -files = [ - {file = "py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e"}, - {file = "py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c"}, -] - -[package.dependencies] -cryptography = ">=44.0.3,<47" -email-validator = "2.2.0" -pydantic = ">=2.12.0,<3.0.0" - -[[package]] -name = "py-partiql-parser" -version = "0.6.1" -description = "Pure Python PartiQL Parser" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "py_partiql_parser-0.6.1-py2.py3-none-any.whl", hash = "sha256:ff6a48067bff23c37e9044021bf1d949c83e195490c17e020715e927fe5b2456"}, - {file = "py_partiql_parser-0.6.1.tar.gz", hash = "sha256:8583ff2a0e15560ef3bc3df109a7714d17f87d81d33e8c38b7fed4e58a63215d"}, -] - -[package.extras] -dev = ["black (==22.6.0)", "flake8", "mypy", "pytest"] - -[[package]] -name = "pyasn1" -version = "0.6.2" -description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, - {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -description = "A collection of ASN.1-based protocols modules" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, - {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, -] - -[package.dependencies] -pyasn1 = ">=0.6.1,<0.7.0" - -[[package]] -name = "pycodestyle" -version = "2.12.1" -description = "Python style guide checker" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, - {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, -] - -[[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pyflakes" -version = "3.2.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, - {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, -] - -[[package]] -name = "pygithub" -version = "2.8.0" -description = "Use the full Github API v3" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"}, - {file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"}, -] - -[package.dependencies] -pyjwt = {version = ">=2.4.0", extras = ["crypto"]} -pynacl = ">=1.4.0" -requests = ">=2.14.0" -typing-extensions = ">=4.5.0" -urllib3 = ">=1.26.0" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjwt" -version = "2.10.1" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, -] - -[package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] - -[[package]] -name = "pylint" -version = "3.3.4" -description = "python code static checker" -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "pylint-3.3.4-py3-none-any.whl", hash = "sha256:289e6a1eb27b453b08436478391a48cd53bb0efb824873f949e709350f3de018"}, - {file = "pylint-3.3.4.tar.gz", hash = "sha256:74ae7a38b177e69a9b525d0794bd8183820bfa7eb68cc1bee6e8ed22a42be4ce"}, -] - -[package.dependencies] -astroid = ">=3.3.8,<=3.4.0.dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version == \"3.11\""}, -] -isort = ">=4.2.5,<5.13.0 || >5.13.0,<7" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -tomlkit = ">=0.10.1" - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "pynacl" -version = "1.6.2" -description = "Python binding to the Networking and Cryptography (NaCl) library" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, - {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, - {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, - {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, - {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, - {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, - {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, - {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, - {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, - {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, - {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, - {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, - {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, - {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, - {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} - -[package.extras] -docs = ["sphinx (<7)", "sphinx_rtd_theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] - -[[package]] -name = "pyopenssl" -version = "24.3.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"}, - {file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"}, -] - -[package.dependencies] -cryptography = ">=41.0.5,<45" - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] -test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] - -[[package]] -name = "pyparsing" -version = "3.2.3" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf"}, - {file = "pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pytest" -version = "8.3.5" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, - {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "6.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, - {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, -] - -[package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-env" -version = "1.1.5" -description = "pytest plugin that allows you to add environment variables." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30"}, - {file = "pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf"}, -] - -[package.dependencies] -pytest = ">=8.3.3" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] - -[[package]] -name = "pytest-randomly" -version = "3.16.0" -description = "Pytest plugin to randomly order tests and control random.seed." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6"}, - {file = "pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26"}, -] - -[package.dependencies] -pytest = "*" - -[[package]] -name = "pytest-xdist" -version = "3.6.1" -description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, - {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, -] - -[package.dependencies] -execnet = ">=2.1" -pytest = ">=7.0.0" - -[package.extras] -psutil = ["psutil (>=3.0)"] -setproctitle = ["setproctitle"] -testing = ["filelock"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pytz" -version = "2025.1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, - {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, -] - -[[package]] -name = "pywin32" -version = "311" -description = "Python for Window Extensions" -optional = false -python-versions = "*" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, - {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, - {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, - {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, - {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, - {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, - {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, - {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, - {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, - {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, - {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, - {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, - {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, - {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, - {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, - {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, - {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, - {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, - {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, - {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "referencing" -version = "0.36.2" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, - {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" -typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} - -[[package]] -name = "regex" -version = "2025.9.18" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "regex-2025.9.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:12296202480c201c98a84aecc4d210592b2f55e200a1d193235c4db92b9f6788"}, - {file = "regex-2025.9.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:220381f1464a581f2ea988f2220cf2a67927adcef107d47d6897ba5a2f6d51a4"}, - {file = "regex-2025.9.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87f681bfca84ebd265278b5daa1dcb57f4db315da3b5d044add7c30c10442e61"}, - {file = "regex-2025.9.18-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34d674cbba70c9398074c8a1fcc1a79739d65d1105de2a3c695e2b05ea728251"}, - {file = "regex-2025.9.18-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:385c9b769655cb65ea40b6eea6ff763cbb6d69b3ffef0b0db8208e1833d4e746"}, - {file = "regex-2025.9.18-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8900b3208e022570ae34328712bef6696de0804c122933414014bae791437ab2"}, - {file = "regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c204e93bf32cd7a77151d44b05eb36f469d0898e3fba141c026a26b79d9914a0"}, - {file = "regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3acc471d1dd7e5ff82e6cacb3b286750decd949ecd4ae258696d04f019817ef8"}, - {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6479d5555122433728760e5f29edb4c2b79655a8deb681a141beb5c8a025baea"}, - {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:431bd2a8726b000eb6f12429c9b438a24062a535d06783a93d2bcbad3698f8a8"}, - {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0cc3521060162d02bd36927e20690129200e5ac9d2c6d32b70368870b122db25"}, - {file = "regex-2025.9.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a021217b01be2d51632ce056d7a837d3fa37c543ede36e39d14063176a26ae29"}, - {file = "regex-2025.9.18-cp310-cp310-win32.whl", hash = "sha256:4a12a06c268a629cb67cc1d009b7bb0be43e289d00d5111f86a2efd3b1949444"}, - {file = "regex-2025.9.18-cp310-cp310-win_amd64.whl", hash = "sha256:47acd811589301298c49db2c56bde4f9308d6396da92daf99cba781fa74aa450"}, - {file = "regex-2025.9.18-cp310-cp310-win_arm64.whl", hash = "sha256:16bd2944e77522275e5ee36f867e19995bcaa533dcb516753a26726ac7285442"}, - {file = "regex-2025.9.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:51076980cd08cd13c88eb7365427ae27f0d94e7cebe9ceb2bb9ffdae8fc4d82a"}, - {file = "regex-2025.9.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:828446870bd7dee4e0cbeed767f07961aa07f0ea3129f38b3ccecebc9742e0b8"}, - {file = "regex-2025.9.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28821d5637866479ec4cc23b8c990f5bc6dd24e5e4384ba4a11d38a526e1414"}, - {file = "regex-2025.9.18-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726177ade8e481db669e76bf99de0b278783be8acd11cef71165327abd1f170a"}, - {file = "regex-2025.9.18-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5cca697da89b9f8ea44115ce3130f6c54c22f541943ac8e9900461edc2b8bd4"}, - {file = "regex-2025.9.18-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dfbde38f38004703c35666a1e1c088b778e35d55348da2b7b278914491698d6a"}, - {file = "regex-2025.9.18-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2f422214a03fab16bfa495cfec72bee4aaa5731843b771860a471282f1bf74f"}, - {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a295916890f4df0902e4286bc7223ee7f9e925daa6dcdec4192364255b70561a"}, - {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5db95ff632dbabc8c38c4e82bf545ab78d902e81160e6e455598014f0abe66b9"}, - {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb967eb441b0f15ae610b7069bdb760b929f267efbf522e814bbbfffdf125ce2"}, - {file = "regex-2025.9.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f04d2f20da4053d96c08f7fde6e1419b7ec9dbcee89c96e3d731fca77f411b95"}, - {file = "regex-2025.9.18-cp311-cp311-win32.whl", hash = "sha256:895197241fccf18c0cea7550c80e75f185b8bd55b6924fcae269a1a92c614a07"}, - {file = "regex-2025.9.18-cp311-cp311-win_amd64.whl", hash = "sha256:7e2b414deae99166e22c005e154a5513ac31493db178d8aec92b3269c9cce8c9"}, - {file = "regex-2025.9.18-cp311-cp311-win_arm64.whl", hash = "sha256:fb137ec7c5c54f34a25ff9b31f6b7b0c2757be80176435bf367111e3f71d72df"}, - {file = "regex-2025.9.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:436e1b31d7efd4dcd52091d076482031c611dde58bf9c46ca6d0a26e33053a7e"}, - {file = "regex-2025.9.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c190af81e5576b9c5fdc708f781a52ff20f8b96386c6e2e0557a78402b029f4a"}, - {file = "regex-2025.9.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4121f1ce2b2b5eec4b397cc1b277686e577e658d8f5870b7eb2d726bd2300ab"}, - {file = "regex-2025.9.18-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:300e25dbbf8299d87205e821a201057f2ef9aa3deb29caa01cd2cac669e508d5"}, - {file = "regex-2025.9.18-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b47fcf9f5316c0bdaf449e879407e1b9937a23c3b369135ca94ebc8d74b1742"}, - {file = "regex-2025.9.18-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:57a161bd3acaa4b513220b49949b07e252165e6b6dc910ee7617a37ff4f5b425"}, - {file = "regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f130c3a7845ba42de42f380fff3c8aebe89a810747d91bcf56d40a069f15352"}, - {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f96fa342b6f54dcba928dd452e8d8cb9f0d63e711d1721cd765bb9f73bb048d"}, - {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f0d676522d68c207828dcd01fb6f214f63f238c283d9f01d85fc664c7c85b56"}, - {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40532bff8a1a0621e7903ae57fce88feb2e8a9a9116d341701302c9302aef06e"}, - {file = "regex-2025.9.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:039f11b618ce8d71a1c364fdee37da1012f5a3e79b1b2819a9f389cd82fd6282"}, - {file = "regex-2025.9.18-cp312-cp312-win32.whl", hash = "sha256:e1dd06f981eb226edf87c55d523131ade7285137fbde837c34dc9d1bf309f459"}, - {file = "regex-2025.9.18-cp312-cp312-win_amd64.whl", hash = "sha256:3d86b5247bf25fa3715e385aa9ff272c307e0636ce0c9595f64568b41f0a9c77"}, - {file = "regex-2025.9.18-cp312-cp312-win_arm64.whl", hash = "sha256:032720248cbeeae6444c269b78cb15664458b7bb9ed02401d3da59fe4d68c3a5"}, - {file = "regex-2025.9.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a40f929cd907c7e8ac7566ac76225a77701a6221bca937bdb70d56cb61f57b2"}, - {file = "regex-2025.9.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c90471671c2cdf914e58b6af62420ea9ecd06d1554d7474d50133ff26ae88feb"}, - {file = "regex-2025.9.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a351aff9e07a2dabb5022ead6380cff17a4f10e4feb15f9100ee56c4d6d06af"}, - {file = "regex-2025.9.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc4b8e9d16e20ddfe16430c23468a8707ccad3365b06d4536142e71823f3ca29"}, - {file = "regex-2025.9.18-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b8cdbddf2db1c5e80338ba2daa3cfa3dec73a46fff2a7dda087c8efbf12d62f"}, - {file = "regex-2025.9.18-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a276937d9d75085b2c91fb48244349c6954f05ee97bba0963ce24a9d915b8b68"}, - {file = "regex-2025.9.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92a8e375ccdc1256401c90e9dc02b8642894443d549ff5e25e36d7cf8a80c783"}, - {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dc6893b1f502d73037cf807a321cdc9be29ef3d6219f7970f842475873712ac"}, - {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a61e85bfc63d232ac14b015af1261f826260c8deb19401c0597dbb87a864361e"}, - {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ef86a9ebc53f379d921fb9a7e42b92059ad3ee800fcd9e0fe6181090e9f6c23"}, - {file = "regex-2025.9.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d3bc882119764ba3a119fbf2bd4f1b47bc56c1da5d42df4ed54ae1e8e66fdf8f"}, - {file = "regex-2025.9.18-cp313-cp313-win32.whl", hash = "sha256:3810a65675845c3bdfa58c3c7d88624356dd6ee2fc186628295e0969005f928d"}, - {file = "regex-2025.9.18-cp313-cp313-win_amd64.whl", hash = "sha256:16eaf74b3c4180ede88f620f299e474913ab6924d5c4b89b3833bc2345d83b3d"}, - {file = "regex-2025.9.18-cp313-cp313-win_arm64.whl", hash = "sha256:4dc98ba7dd66bd1261927a9f49bd5ee2bcb3660f7962f1ec02617280fc00f5eb"}, - {file = "regex-2025.9.18-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fe5d50572bc885a0a799410a717c42b1a6b50e2f45872e2b40f4f288f9bce8a2"}, - {file = "regex-2025.9.18-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b9d9a2d6cda6621551ca8cf7a06f103adf72831153f3c0d982386110870c4d3"}, - {file = "regex-2025.9.18-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:13202e4c4ac0ef9a317fff817674b293c8f7e8c68d3190377d8d8b749f566e12"}, - {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874ff523b0fecffb090f80ae53dc93538f8db954c8bb5505f05b7787ab3402a0"}, - {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d13ab0490128f2bb45d596f754148cd750411afc97e813e4b3a61cf278a23bb6"}, - {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:05440bc172bc4b4b37fb9667e796597419404dbba62e171e1f826d7d2a9ebcef"}, - {file = "regex-2025.9.18-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5514b8e4031fdfaa3d27e92c75719cbe7f379e28cacd939807289bce76d0e35a"}, - {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:65d3c38c39efce73e0d9dc019697b39903ba25b1ad45ebbd730d2cf32741f40d"}, - {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae77e447ebc144d5a26d50055c6ddba1d6ad4a865a560ec7200b8b06bc529368"}, - {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3ef8cf53dc8df49d7e28a356cf824e3623764e9833348b655cfed4524ab8a90"}, - {file = "regex-2025.9.18-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9feb29817df349c976da9a0debf775c5c33fc1c8ad7b9f025825da99374770b7"}, - {file = "regex-2025.9.18-cp313-cp313t-win32.whl", hash = "sha256:168be0d2f9b9d13076940b1ed774f98595b4e3c7fc54584bba81b3cc4181742e"}, - {file = "regex-2025.9.18-cp313-cp313t-win_amd64.whl", hash = "sha256:d59ecf3bb549e491c8104fea7313f3563c7b048e01287db0a90485734a70a730"}, - {file = "regex-2025.9.18-cp313-cp313t-win_arm64.whl", hash = "sha256:dbef80defe9fb21310948a2595420b36c6d641d9bea4c991175829b2cc4bc06a"}, - {file = "regex-2025.9.18-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c6db75b51acf277997f3adcd0ad89045d856190d13359f15ab5dda21581d9129"}, - {file = "regex-2025.9.18-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8f9698b6f6895d6db810e0bda5364f9ceb9e5b11328700a90cae573574f61eea"}, - {file = "regex-2025.9.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29cd86aa7cb13a37d0f0d7c21d8d949fe402ffa0ea697e635afedd97ab4b69f1"}, - {file = "regex-2025.9.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c9f285a071ee55cd9583ba24dde006e53e17780bb309baa8e4289cd472bcc47"}, - {file = "regex-2025.9.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5adf266f730431e3be9021d3e5b8d5ee65e563fec2883ea8093944d21863b379"}, - {file = "regex-2025.9.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1137cabc0f38807de79e28d3f6e3e3f2cc8cfb26bead754d02e6d1de5f679203"}, - {file = "regex-2025.9.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cc9e5525cada99699ca9223cce2d52e88c52a3d2a0e842bd53de5497c604164"}, - {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bbb9246568f72dce29bcd433517c2be22c7791784b223a810225af3b50d1aafb"}, - {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6a52219a93dd3d92c675383efff6ae18c982e2d7651c792b1e6d121055808743"}, - {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ae9b3840c5bd456780e3ddf2f737ab55a79b790f6409182012718a35c6d43282"}, - {file = "regex-2025.9.18-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d488c236ac497c46a5ac2005a952c1a0e22a07be9f10c3e735bc7d1209a34773"}, - {file = "regex-2025.9.18-cp314-cp314-win32.whl", hash = "sha256:0c3506682ea19beefe627a38872d8da65cc01ffa25ed3f2e422dffa1474f0788"}, - {file = "regex-2025.9.18-cp314-cp314-win_amd64.whl", hash = "sha256:57929d0f92bebb2d1a83af372cd0ffba2263f13f376e19b1e4fa32aec4efddc3"}, - {file = "regex-2025.9.18-cp314-cp314-win_arm64.whl", hash = "sha256:6a4b44df31d34fa51aa5c995d3aa3c999cec4d69b9bd414a8be51984d859f06d"}, - {file = "regex-2025.9.18-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b176326bcd544b5e9b17d6943f807697c0cb7351f6cfb45bf5637c95ff7e6306"}, - {file = "regex-2025.9.18-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0ffd9e230b826b15b369391bec167baed57c7ce39efc35835448618860995946"}, - {file = "regex-2025.9.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec46332c41add73f2b57e2f5b642f991f6b15e50e9f86285e08ffe3a512ac39f"}, - {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b80fa342ed1ea095168a3f116637bd1030d39c9ff38dc04e54ef7c521e01fc95"}, - {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4d97071c0ba40f0cf2a93ed76e660654c399a0a04ab7d85472239460f3da84b"}, - {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0ac936537ad87cef9e0e66c5144484206c1354224ee811ab1519a32373e411f3"}, - {file = "regex-2025.9.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec57f96d4def58c422d212d414efe28218d58537b5445cf0c33afb1b4768571"}, - {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48317233294648bf7cd068857f248e3a57222259a5304d32c7552e2284a1b2ad"}, - {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:274687e62ea3cf54846a9b25fc48a04459de50af30a7bd0b61a9e38015983494"}, - {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a78722c86a3e7e6aadf9579e3b0ad78d955f2d1f1a8ca4f67d7ca258e8719d4b"}, - {file = "regex-2025.9.18-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06104cd203cdef3ade989a1c45b6215bf42f8b9dd705ecc220c173233f7cba41"}, - {file = "regex-2025.9.18-cp314-cp314t-win32.whl", hash = "sha256:2e1eddc06eeaffd249c0adb6fafc19e2118e6308c60df9db27919e96b5656096"}, - {file = "regex-2025.9.18-cp314-cp314t-win_amd64.whl", hash = "sha256:8620d247fb8c0683ade51217b459cb4a1081c0405a3072235ba43a40d355c09a"}, - {file = "regex-2025.9.18-cp314-cp314t-win_arm64.whl", hash = "sha256:b7531a8ef61de2c647cdf68b3229b071e46ec326b3138b2180acb4275f470b01"}, - {file = "regex-2025.9.18-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3dbcfcaa18e9480669030d07371713c10b4f1a41f791ffa5cb1a99f24e777f40"}, - {file = "regex-2025.9.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1e85f73ef7095f0380208269055ae20524bfde3f27c5384126ddccf20382a638"}, - {file = "regex-2025.9.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9098e29b3ea4ffffeade423f6779665e2a4f8db64e699c0ed737ef0db6ba7b12"}, - {file = "regex-2025.9.18-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90b6b7a2d0f45b7ecaaee1aec6b362184d6596ba2092dd583ffba1b78dd0231c"}, - {file = "regex-2025.9.18-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c81b892af4a38286101502eae7aec69f7cd749a893d9987a92776954f3943408"}, - {file = "regex-2025.9.18-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3b524d010973f2e1929aeb635418d468d869a5f77b52084d9f74c272189c251d"}, - {file = "regex-2025.9.18-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b498437c026a3d5d0be0020023ff76d70ae4d77118e92f6f26c9d0423452446"}, - {file = "regex-2025.9.18-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0716e4d6e58853d83f6563f3cf25c281ff46cf7107e5f11879e32cb0b59797d9"}, - {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:065b6956749379d41db2625f880b637d4acc14c0a4de0d25d609a62850e96d36"}, - {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d4a691494439287c08ddb9b5793da605ee80299dd31e95fa3f323fac3c33d9d4"}, - {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ef8d10cc0989565bcbe45fb4439f044594d5c2b8919d3d229ea2c4238f1d55b0"}, - {file = "regex-2025.9.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4baeb1b16735ac969a7eeecc216f1f8b7caf60431f38a2671ae601f716a32d25"}, - {file = "regex-2025.9.18-cp39-cp39-win32.whl", hash = "sha256:8e5f41ad24a1e0b5dfcf4c4e5d9f5bd54c895feb5708dd0c1d0d35693b24d478"}, - {file = "regex-2025.9.18-cp39-cp39-win_amd64.whl", hash = "sha256:50e8290707f2fb8e314ab3831e594da71e062f1d623b05266f8cfe4db4949afd"}, - {file = "regex-2025.9.18-cp39-cp39-win_arm64.whl", hash = "sha256:039a9d7195fd88c943d7c777d4941e8ef736731947becce773c31a1009cb3c35"}, - {file = "regex-2025.9.18.tar.gz", hash = "sha256:c5ba23274c61c6fef447ba6a39333297d0c247f53059dba0bca415cac511edc4"}, -] - -[[package]] -name = "requests" -version = "2.32.4" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "requests-file" -version = "2.1.0" -description = "File transport adapter for Requests" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c"}, - {file = "requests_file-2.1.0.tar.gz", hash = "sha256:0f549a3f3b0699415ac04d167e9cb39bccfb730cb832b4d20be3d9867356e658"}, -] - -[package.dependencies] -requests = ">=1.0.0" - -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -description = "OAuthlib authentication support for Requests." -optional = false -python-versions = ">=3.4" -groups = ["main"] -files = [ - {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, - {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, -] - -[package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" - -[package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] - -[[package]] -name = "requestsexceptions" -version = "1.4.0" -description = "Import exceptions from potentially bundled packages in requests." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3"}, - {file = "requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065"}, -] - -[[package]] -name = "responses" -version = "0.25.7" -description = "A utility library for mocking out the `requests` Python library." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c"}, - {file = "responses-0.25.7.tar.gz", hash = "sha256:8ebae11405d7a5df79ab6fd54277f6f2bc29b2d002d0dd2d5c632594d1ddcedb"}, -] - -[package.dependencies] -pyyaml = "*" -requests = ">=2.30.0,<3.0" -urllib3 = ">=1.25.10,<3.0" - -[package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] - -[[package]] -name = "retrying" -version = "1.4.1" -description = "Retrying" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "retrying-1.4.1-py3-none-any.whl", hash = "sha256:d736050c1adfc0a71fa022d9198ee130b0e66be318678a3fdd8b1b8872dc0997"}, - {file = "retrying-1.4.1.tar.gz", hash = "sha256:4d206e0ed2aff5ef2f3cd867abb9511e9e8f31127c5aca20f1d5246e476903b0"}, -] - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -description = "A pure python RFC3339 validator" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["dev"] -files = [ - {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, - {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, -] - -[package.dependencies] -six = "*" - -[[package]] -name = "rich" -version = "14.1.0" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, - {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "rpds-py" -version = "0.26.0" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "rpds_py-0.26.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4c70c70f9169692b36307a95f3d8c0a9fcd79f7b4a383aad5eaa0e9718b79b37"}, - {file = "rpds_py-0.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:777c62479d12395bfb932944e61e915741e364c843afc3196b694db3d669fcd0"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec671691e72dff75817386aa02d81e708b5a7ec0dec6669ec05213ff6b77e1bd"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a1cb5d6ce81379401bbb7f6dbe3d56de537fb8235979843f0d53bc2e9815a79"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f789e32fa1fb6a7bf890e0124e7b42d1e60d28ebff57fe806719abb75f0e9a3"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c55b0a669976cf258afd718de3d9ad1b7d1fe0a91cd1ab36f38b03d4d4aeaaf"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70d9ec912802ecfd6cd390dadb34a9578b04f9bcb8e863d0a7598ba5e9e7ccc"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3021933c2cb7def39d927b9862292e0f4c75a13d7de70eb0ab06efed4c508c19"}, - {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a7898b6ca3b7d6659e55cdac825a2e58c638cbf335cde41f4619e290dd0ad11"}, - {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:12bff2ad9447188377f1b2794772f91fe68bb4bbfa5a39d7941fbebdbf8c500f"}, - {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:191aa858f7d4902e975d4cf2f2d9243816c91e9605070aeb09c0a800d187e323"}, - {file = "rpds_py-0.26.0-cp310-cp310-win32.whl", hash = "sha256:b37a04d9f52cb76b6b78f35109b513f6519efb481d8ca4c321f6a3b9580b3f45"}, - {file = "rpds_py-0.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:38721d4c9edd3eb6670437d8d5e2070063f305bfa2d5aa4278c51cedcd508a84"}, - {file = "rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed"}, - {file = "rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3"}, - {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107"}, - {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a"}, - {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318"}, - {file = "rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a"}, - {file = "rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03"}, - {file = "rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41"}, - {file = "rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d"}, - {file = "rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323"}, - {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158"}, - {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3"}, - {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2"}, - {file = "rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44"}, - {file = "rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c"}, - {file = "rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8"}, - {file = "rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d"}, - {file = "rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1"}, - {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9"}, - {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9"}, - {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba"}, - {file = "rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b"}, - {file = "rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5"}, - {file = "rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256"}, - {file = "rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618"}, - {file = "rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed"}, - {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632"}, - {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c"}, - {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0"}, - {file = "rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9"}, - {file = "rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9"}, - {file = "rpds_py-0.26.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:da619979df60a940cd434084355c514c25cf8eb4cf9a508510682f6c851a4f7a"}, - {file = "rpds_py-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea89a2458a1a75f87caabefe789c87539ea4e43b40f18cff526052e35bbb4fdf"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feac1045b3327a45944e7dcbeb57530339f6b17baff154df51ef8b0da34c8c12"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b818a592bd69bfe437ee8368603d4a2d928c34cffcdf77c2e761a759ffd17d20"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a8b0dd8648709b62d9372fc00a57466f5fdeefed666afe3fea5a6c9539a0331"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d3498ad0df07d81112aa6ec6c95a7e7b1ae00929fb73e7ebee0f3faaeabad2f"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4146ccb15be237fdef10f331c568e1b0e505f8c8c9ed5d67759dac58ac246"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9a63785467b2d73635957d32a4f6e73d5e4df497a16a6392fa066b753e87387"}, - {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de4ed93a8c91debfd5a047be327b7cc8b0cc6afe32a716bbbc4aedca9e2a83af"}, - {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:caf51943715b12af827696ec395bfa68f090a4c1a1d2509eb4e2cb69abbbdb33"}, - {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a59e5bc386de021f56337f757301b337d7ab58baa40174fb150accd480bc953"}, - {file = "rpds_py-0.26.0-cp314-cp314-win32.whl", hash = "sha256:92c8db839367ef16a662478f0a2fe13e15f2227da3c1430a782ad0f6ee009ec9"}, - {file = "rpds_py-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:b0afb8cdd034150d4d9f53926226ed27ad15b7f465e93d7468caaf5eafae0d37"}, - {file = "rpds_py-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:ca3f059f4ba485d90c8dc75cb5ca897e15325e4e609812ce57f896607c1c0867"}, - {file = "rpds_py-0.26.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5afea17ab3a126006dc2f293b14ffc7ef3c85336cf451564a0515ed7648033da"}, - {file = "rpds_py-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69f0c0a3df7fd3a7eec50a00396104bb9a843ea6d45fcc31c2d5243446ffd7a7"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:801a71f70f9813e82d2513c9a96532551fce1e278ec0c64610992c49c04c2dad"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df52098cde6d5e02fa75c1f6244f07971773adb4a26625edd5c18fee906fa84d"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bc596b30f86dc6f0929499c9e574601679d0341a0108c25b9b358a042f51bca"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9dfbe56b299cf5875b68eb6f0ebaadc9cac520a1989cac0db0765abfb3709c19"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac64f4b2bdb4ea622175c9ab7cf09444e412e22c0e02e906978b3b488af5fde8"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:181ef9b6bbf9845a264f9aa45c31836e9f3c1f13be565d0d010e964c661d1e2b"}, - {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:49028aa684c144ea502a8e847d23aed5e4c2ef7cadfa7d5eaafcb40864844b7a"}, - {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5d524d68a474a9688336045bbf76cb0def88549c1b2ad9dbfec1fb7cfbe9170"}, - {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1851f429b822831bd2edcbe0cfd12ee9ea77868f8d3daf267b189371671c80e"}, - {file = "rpds_py-0.26.0-cp314-cp314t-win32.whl", hash = "sha256:7bdb17009696214c3b66bb3590c6d62e14ac5935e53e929bcdbc5a495987a84f"}, - {file = "rpds_py-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f14440b9573a6f76b4ee4770c13f0b5921f71dde3b6fcb8dabbefd13b7fe05d7"}, - {file = "rpds_py-0.26.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7a48af25d9b3c15684059d0d1fc0bc30e8eee5ca521030e2bffddcab5be40226"}, - {file = "rpds_py-0.26.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c71c2f6bf36e61ee5c47b2b9b5d47e4d1baad6426bfed9eea3e858fc6ee8806"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d815d48b1804ed7867b539236b6dd62997850ca1c91cad187f2ddb1b7bbef19"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84cfbd4d4d2cdeb2be61a057a258d26b22877266dd905809e94172dff01a42ae"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbaa70553ca116c77717f513e08815aec458e6b69a028d4028d403b3bc84ff37"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39bfea47c375f379d8e87ab4bb9eb2c836e4f2069f0f65731d85e55d74666387"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1533b7eb683fb5f38c1d68a3c78f5fdd8f1412fa6b9bf03b40f450785a0ab915"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5ab0ee51f560d179b057555b4f601b7df909ed31312d301b99f8b9fc6028284"}, - {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e5162afc9e0d1f9cae3b577d9c29ddbab3505ab39012cb794d94a005825bde21"}, - {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:43f10b007033f359bc3fa9cd5e6c1e76723f056ffa9a6b5c117cc35720a80292"}, - {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e3730a48e5622e598293eee0762b09cff34dd3f271530f47b0894891281f051d"}, - {file = "rpds_py-0.26.0-cp39-cp39-win32.whl", hash = "sha256:4b1f66eb81eab2e0ff5775a3a312e5e2e16bf758f7b06be82fb0d04078c7ac51"}, - {file = "rpds_py-0.26.0-cp39-cp39-win_amd64.whl", hash = "sha256:519067e29f67b5c90e64fb1a6b6e9d2ec0ba28705c51956637bac23a2f4ddae1"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3c0909c5234543ada2515c05dc08595b08d621ba919629e94427e8e03539c958"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c1fb0cda2abcc0ac62f64e2ea4b4e64c57dfd6b885e693095460c61bde7bb18e"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d142d2d6cf9b31c12aa4878d82ed3b2324226270b89b676ac62ccd7df52d08"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a547e21c5610b7e9093d870be50682a6a6cf180d6da0f42c47c306073bfdbbf6"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35e9a70a0f335371275cdcd08bc5b8051ac494dd58bff3bbfb421038220dc871"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dfa6115c6def37905344d56fb54c03afc49104e2ca473d5dedec0f6606913b4"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313cfcd6af1a55a286a3c9a25f64af6d0e46cf60bc5798f1db152d97a216ff6f"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7bf2496fa563c046d05e4d232d7b7fd61346e2402052064b773e5c378bf6f73"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa81873e2c8c5aa616ab8e017a481a96742fdf9313c40f14338ca7dbf50cb55f"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:68ffcf982715f5b5b7686bdd349ff75d422e8f22551000c24b30eaa1b7f7ae84"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6188de70e190847bb6db3dc3981cbadff87d27d6fe9b4f0e18726d55795cee9b"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1c962145c7473723df9722ba4c058de12eb5ebedcb4e27e7d902920aa3831ee8"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a90a13408a7a856b87be8a9f008fff53c5080eea4e4180f6c2e546e4a972fb5d"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ac51b65e8dc76cf4949419c54c5528adb24fc721df722fd452e5fbc236f5c40"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59b2093224a18c6508d95cfdeba8db9cbfd6f3494e94793b58972933fcee4c6d"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f01a5d6444a3258b00dc07b6ea4733e26f8072b788bef750baa37b370266137"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6e2c12160c72aeda9d1283e612f68804621f448145a210f1bf1d79151c47090"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb28c1f569f8d33b2b5dcd05d0e6ef7005d8639c54c2f0be824f05aedf715255"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1766b5724c3f779317d5321664a343c07773c8c5fd1532e4039e6cc7d1a815be"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b6d9e5a2ed9c4988c8f9b28b3bc0e3e5b1aaa10c28d210a594ff3a8c02742daf"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b5f7a446ddaf6ca0fad9a5535b56fbfc29998bf0e0b450d174bbec0d600e1d72"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:eed5ac260dd545fbc20da5f4f15e7efe36a55e0e7cf706e4ec005b491a9546a0"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:582462833ba7cee52e968b0341b85e392ae53d44c0f9af6a5927c80e539a8b67"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:69a607203441e07e9a8a529cff1d5b73f6a160f22db1097211e6212a68567d11"}, - {file = "rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0"}, -] - -[[package]] -name = "rsa" -version = "4.9.1" -description = "Pure-Python RSA implementation" -optional = false -python-versions = "<4,>=3.6" -groups = ["main"] -files = [ - {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, - {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - -[[package]] -name = "ruamel-yaml" -version = "0.18.14" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2"}, - {file = "ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7"}, -] - -[package.dependencies] -"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} - -[package.extras] -docs = ["mercurial (>5.7)", "ryd"] -jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.12" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "platform_python_implementation == \"CPython\"" -files = [ - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, - {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, -] - -[[package]] -name = "s3transfer" -version = "0.14.0" -description = "An Amazon S3 Transfer Manager" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, - {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, -] - -[package.dependencies] -botocore = ">=1.37.4,<2.0a0" - -[package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] - -[[package]] -name = "safety" -version = "3.7.0" -description = "Scan dependencies for known vulnerabilities and licenses." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"}, - {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"}, -] - -[package.dependencies] -authlib = ">=1.2.0" -click = ">=8.0.2" -dparse = ">=0.6.4" -filelock = ">=3.16.1,<4.0" -httpx = "*" -jinja2 = ">=3.1.0" -marshmallow = ">=3.15.0" -nltk = ">=3.9" -packaging = ">=21.0" -pydantic = ">=2.6.0" -requests = "*" -ruamel-yaml = ">=0.17.21" -safety-schemas = "0.0.16" -tenacity = ">=8.1.0" -tomli = {version = "*", markers = "python_version < \"3.11\""} -tomlkit = "*" -typer = ">=0.16.0" -typing-extensions = ">=4.7.1" - -[package.extras] -github = ["pygithub (>=1.43.3)"] -gitlab = ["python-gitlab (>=1.3.0)"] -spdx = ["spdx-tools (>=0.8.2)"] - -[[package]] -name = "safety-schemas" -version = "0.0.16" -description = "Schemas for Safety tools" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"}, - {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"}, -] - -[package.dependencies] -dparse = ">=0.6.4" -packaging = ">=21.0" -pydantic = ">=2.6.0" -ruamel-yaml = ">=0.17.21" -typing-extensions = ">=4.7.1" - -[[package]] -name = "schema" -version = "0.7.5" -description = "Simple data validation library" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"}, - {file = "schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197"}, -] - -[package.dependencies] -contextlib2 = ">=0.5.5" - -[[package]] -name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - -[[package]] -name = "shellingham" -version = "1.5.4" -description = "Tool to Detect Surrounding Shell" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, - {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, -] - -[[package]] -name = "shodan" -version = "1.31.0" -description = "Python library and command-line utility for Shodan (https://developer.shodan.io)" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "shodan-1.31.0.tar.gz", hash = "sha256:c73275386ea02390e196c35c660706a28dd4d537c5a21eb387ab6236fac251f6"}, -] - -[package.dependencies] -click = "*" -click-plugins = "*" -colorama = "*" -requests = ">=2.2.1" -tldextract = "*" -XlsxWriter = "*" - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "slack-sdk" -version = "3.39.0" -description = "The Slack API Platform SDK for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"}, - {file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"}, -] - -[package.extras] -optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "std-uritemplate" -version = "2.0.5" -description = "std-uritemplate implementation for Python" -optional = false -python-versions = "<4.0,>=3.8" -groups = ["main"] -files = [ - {file = "std_uritemplate-2.0.5-py3-none-any.whl", hash = "sha256:0f5184f8e6f315a01f92cfbed335f62f087e453e79cd586b67a724211e686c28"}, - {file = "std_uritemplate-2.0.5.tar.gz", hash = "sha256:7703a886cce59d155c21b5acf1ad8d48db9f3322de98fa783a8396fbf35cbc06"}, -] - -[[package]] -name = "stevedore" -version = "5.4.1" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"}, - {file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"}, -] - -[package.dependencies] -pbr = ">=2.0.0" - -[[package]] -name = "sympy" -version = "1.14.0" -description = "Computer algebra system (CAS) in Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, - {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, -] - -[package.dependencies] -mpmath = ">=1.1.0,<1.4" - -[package.extras] -dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] - -[[package]] -name = "tabulate" -version = "0.9.0" -description = "Pretty-print tabular data" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, - {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, -] - -[package.extras] -widechars = ["wcwidth"] - -[[package]] -name = "tenacity" -version = "9.1.2" -description = "Retry code until it succeeds" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, - {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, -] - -[package.extras] -doc = ["reno", "sphinx"] -test = ["pytest", "tornado (>=4.5)", "typeguard"] - -[[package]] -name = "tldextract" -version = "5.3.0" -description = "Accurately separates a URL's subdomain, domain, and public suffix, using the Public Suffix List (PSL). By default, this includes the public ICANN TLDs and their exceptions. You can optionally support the Public Suffix List's private domains as well." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "tldextract-5.3.0-py3-none-any.whl", hash = "sha256:f70f31d10b55c83993f55e91ecb7c5d84532a8972f22ec578ecfbe5ea2292db2"}, - {file = "tldextract-5.3.0.tar.gz", hash = "sha256:b3d2b70a1594a0ecfa6967d57251527d58e00bb5a91a74387baa0d87a0678609"}, -] - -[package.dependencies] -filelock = ">=3.0.8" -idna = "*" -requests = ">=2.1.0" -requests-file = ">=1.4" - -[package.extras] -release = ["build", "twine"] -testing = ["mypy", "pytest", "pytest-gitignore", "pytest-mock", "responses", "ruff", "syrupy", "tox", "tox-uv", "types-filelock", "types-requests"] - -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "tomlkit" -version = "0.13.3" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, - {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -description = "Fast, Extensible Progress Meter" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, - {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] -discord = ["requests"] -notebook = ["ipywidgets (>=6)"] -slack = ["slack-sdk"] -telegram = ["requests"] - -[[package]] -name = "typer" -version = "0.16.0" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, - {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, -] - -[package.dependencies] -click = ">=8.0.0" -rich = ">=10.11.0" -shellingham = ">=1.3.0" -typing-extensions = ">=3.7.4.3" - -[[package]] -name = "typing-extensions" -version = "4.14.1" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "tzdata" -version = "2025.2" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -groups = ["main"] -files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -description = "tzinfo object for the local timezone" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, - {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, -] - -[package.dependencies] -tzdata = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] - -[[package]] -name = "uritemplate" -version = "4.2.0" -description = "Implementation of RFC 6570 URI Templates" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"}, - {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, -] - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "uuid6" -version = "2024.7.10" -description = "New time-based UUID formats which are suited for use as a database key" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7"}, - {file = "uuid6-2024.7.10.tar.gz", hash = "sha256:2d29d7f63f593caaeea0e0d0dd0ad8129c9c663b29e19bdf882e864bedf18fb0"}, -] - -[[package]] -name = "virtualenv" -version = "20.32.0" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56"}, - {file = "virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "vulture" -version = "2.14" -description = "Find dead code" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9"}, - {file = "vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415"}, -] - -[package.dependencies] -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "websocket-client" -version = "1.8.0" -description = "WebSocket client for Python with low level API options" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, - {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, -] - -[package.extras] -docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] -optional = ["python-socks", "wsaccel"] -test = ["websockets"] - -[[package]] -name = "werkzeug" -version = "3.1.5" -description = "The comprehensive WSGI web application library." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, - {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, -] - -[package.dependencies] -markupsafe = ">=2.1.1" - -[package.extras] -watchdog = ["watchdog (>=2.3)"] - -[[package]] -name = "wrapt" -version = "1.17.2" -description = "Module for decorators, wrappers and monkey patching." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62"}, - {file = "wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563"}, - {file = "wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72"}, - {file = "wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317"}, - {file = "wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9"}, - {file = "wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9"}, - {file = "wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504"}, - {file = "wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a"}, - {file = "wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f"}, - {file = "wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555"}, - {file = "wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c803c401ea1c1c18de70a06a6f79fcc9c5acfc79133e9869e730ad7f8ad8ef9"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f917c1180fdb8623c2b75a99192f4025e412597c50b2ac870f156de8fb101119"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ecc840861360ba9d176d413a5489b9a0aff6d6303d7e733e2c4623cfa26904a6"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb87745b2e6dc56361bfde481d5a378dc314b252a98d7dd19a651a3fa58f24a9"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58455b79ec2661c3600e65c0a716955adc2410f7383755d537584b0de41b1d8a"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e42a40a5e164cbfdb7b386c966a588b1047558a990981ace551ed7e12ca9c2"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:91bd7d1773e64019f9288b7a5101f3ae50d3d8e6b1de7edee9c2ccc1d32f0c0a"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:bb90fb8bda722a1b9d48ac1e6c38f923ea757b3baf8ebd0c82e09c5c1a0e7a04"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:08e7ce672e35efa54c5024936e559469436f8b8096253404faeb54d2a878416f"}, - {file = "wrapt-1.17.2-cp38-cp38-win32.whl", hash = "sha256:410a92fefd2e0e10d26210e1dfb4a876ddaf8439ef60d6434f21ef8d87efc5b7"}, - {file = "wrapt-1.17.2-cp38-cp38-win_amd64.whl", hash = "sha256:95c658736ec15602da0ed73f312d410117723914a5c91a14ee4cdd72f1d790b3"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9"}, - {file = "wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb"}, - {file = "wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb"}, - {file = "wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8"}, - {file = "wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3"}, -] - -[[package]] -name = "xlsxwriter" -version = "3.2.5" -description = "A Python module for creating Excel XLSX files." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "xlsxwriter-3.2.5-py3-none-any.whl", hash = "sha256:4f4824234e1eaf9d95df9a8fe974585ff91d0f5e3d3f12ace5b71e443c1c6abd"}, - {file = "xlsxwriter-3.2.5.tar.gz", hash = "sha256:7e88469d607cdc920151c0ab3ce9cf1a83992d4b7bc730c5ffdd1a12115a7dbe"}, -] - -[[package]] -name = "xmltodict" -version = "0.14.2" -description = "Makes working with XML feel like you are working with JSON" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, - {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, -] - -[[package]] -name = "yarl" -version = "1.20.1" -description = "Yet another URL library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13"}, - {file = "yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8"}, - {file = "yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e"}, - {file = "yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773"}, - {file = "yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004"}, - {file = "yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5"}, - {file = "yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1"}, - {file = "yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7"}, - {file = "yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e"}, - {file = "yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d"}, - {file = "yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d"}, - {file = "yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06"}, - {file = "yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00"}, - {file = "yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77"}, - {file = "yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" -propcache = ">=0.2.1" - -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[[package]] -name = "zstd" -version = "1.5.7.2" -description = "ZSTD Bindings for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "zstd-1.5.7.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e17104d0e88367a7571dde4286e233126c8551691ceff11f9ae2e3a3ac1bb483"}, - {file = "zstd-1.5.7.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:d6ee5dfada4c8fa32f43cc092fcf7d8482da6ad242c22fdf780f7eebd0febcc7"}, - {file = "zstd-1.5.7.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:ae1100776cb400100e2d2f427b50dc983c005c38cd59502eb56d2cfea3402ad5"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:489a0ff15caf7640851e63f85b680c4279c99094cd500a29c7ed3ab82505fce0"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:92590cf54318849d492445c885f1a42b9dbb47cdc070659c7cb61df6e8531047"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux_2_4_i686.whl", hash = "sha256:2bc21650f7b9c058a3c4cb503e906fe9cce293941ec1b48bc5d005c3b4422b42"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux_2_4_x86_64.whl", hash = "sha256:7b13e7eef9aa192804d38bf413924d347c6f6c6ac07f5a0c1ae4a6d7b3af70f0"}, - {file = "zstd-1.5.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3f14c5c405ea353b68fe105236780494eb67c756ecd346fd295498f5eab6d24"}, - {file = "zstd-1.5.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07d2061df22a3efc06453089e6e8b96e58f5bb7a0c4074dcfd0b0ce243ddde72"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:27e55aa2043ba7d8a08aba0978c652d4d5857338a8188aa84522569f3586c7bb"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e97933addfd71ea9608306f18dc18e7d2a5e64212ba2bb9a4ccb6d714f9f280"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:27e2ed58b64001c9ef0a8e028625477f1a6ed4ca949412ff6548544945cc59c2"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:92f072819fc0c7e8445f51a232c9ad76642027c069d2f36470cdb5e663839cdb"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2a653cdd2c52d60c28e519d44bde8d759f2c1837f0ff8e8e1b0045ca62fcf70e"}, - {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:047803d87d910f4905f48d99aeff1e0539ec2e4f4bf17d077701b5d0b2392a95"}, - {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0d8c1dc947e5ccea3bd81043080213685faf1d43886c27c51851fabf325f05c0"}, - {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8291d393321fac30604c6bbf40067103fee315aa476647a5eaecf877ee53496f"}, - {file = "zstd-1.5.7.2-cp310-cp310-win32.whl", hash = "sha256:6922ceac5f2d60bb57a7875168c8aa442477b83e8951f2206cf1e9be788b0a6e"}, - {file = "zstd-1.5.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:346d1e4774d89a77d67fc70d53964bfca57c0abecfd885a4e00f87fd7c71e074"}, - {file = "zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b"}, - {file = "zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806"}, - {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58"}, - {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7"}, - {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29"}, - {file = "zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b"}, - {file = "zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218"}, - {file = "zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734"}, - {file = "zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583"}, - {file = "zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac"}, - {file = "zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9"}, - {file = "zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50"}, - {file = "zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38"}, - {file = "zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06"}, - {file = "zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb"}, - {file = "zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71"}, - {file = "zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174"}, - {file = "zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a"}, - {file = "zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6"}, - {file = "zstd-1.5.7.2-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:a6105b8fa21dbc59e05b6113e8e5d5aaf56c5d2886aa5778d61030af3256bbb7"}, - {file = "zstd-1.5.7.2-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:d0b0ca097efb5f67157c61a744c926848dcccf6e913df2f814e719aa78197a4b"}, - {file = "zstd-1.5.7.2-cp34-cp34m-manylinux_2_4_i686.whl", hash = "sha256:a371274668182ae06be2e321089b207fa0a75a58ae2fd4dfb7eafded9e041b2f"}, - {file = "zstd-1.5.7.2-cp34-cp34m-manylinux_2_4_x86_64.whl", hash = "sha256:74c3f006c9a3a191ed454183f0fb78172444f5cb431be04d85044a27f1b58c7b"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:f19a3e658d92b6b52020c4c6d4c159480bcd3b47658773ea0e8d343cee849f33"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d9d1bcb6441841c599883139c1b0e47bddb262cce04b37dc2c817da5802c1158"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:bb1cb423fc40468cc9b7ab51a5b33c618eefd2c910a5bffed6ed76fe1cbb20b0"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux_2_14_x86_64.whl", hash = "sha256:e2476ba12597e58c5fc7a3ae547ee1bef9dd6b9d5ea80cf8d4034930c5a336e0"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux_2_4_i686.whl", hash = "sha256:2bf6447373782a2a9df3015121715f6d0b80a49a884c2d7d4518c9571e9fca16"}, - {file = "zstd-1.5.7.2-cp35-cp35m-win32.whl", hash = "sha256:a59a136a9eaa1849d715c004e30344177e85ad6e7bc4a5d0b6ad2495c5402675"}, - {file = "zstd-1.5.7.2-cp35-cp35m-win_amd64.whl", hash = "sha256:114115af8c68772a3205414597f626b604c7879f6662a2a79c88312e0f50361f"}, - {file = "zstd-1.5.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f576ec00e99db124309dac1e1f34bc320eb69624189f5fdaf9ebe1dc81581a84"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f97d8593da0e23a47f148a1cb33300dccd513fb0df9f7911c274e228a8c1a300"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:a130243e875de5aeda6099d12b11bc2fcf548dce618cf6b17f731336ba5338e4"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:73cec37649fda383348dc8b3b5fba535f1dbb1bbaeb60fd36f4c145820208619"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux_2_14_x86_64.whl", hash = "sha256:883e7b77a3124011b8badd0c7c9402af3884700a3431d07877972e157d85afb8"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux_2_4_i686.whl", hash = "sha256:b5af6aa041b5515934afef2ef4af08566850875c3c890109088eedbe190eeefb"}, - {file = "zstd-1.5.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:53abf577aec7b30afa3c024143f4866676397c846b44f1b30d8097b5e4f5c7d7"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:660945ba16c16957c94dafc40aff1db02a57af0489aa3a896866239d47bb44b0"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3e220d2d7005822bb72a52e76410ca4634f941d8062c08e8e3285733c63b1db7"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_4_i686.whl", hash = "sha256:7e998f86a9d1e576c0158bf0b0a6a5c4685679d74ba0053a2e87f684f9bdc8eb"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_4_x86_64.whl", hash = "sha256:70d0c4324549073e05aa72e9eb6a593f89cba59da804b946d325d68467b93ad5"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:b9518caabf59405eddd667bbb161d9ae7f13dbf96967fd998d095589c8d41c86"}, - {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:30d339d8e5c4b14c2015b50371fcdb8a93b451ca6d3ef813269ccbb8b3b3ef7d"}, - {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:6f5539a10b838ee576084870eed65b63c13845e30a5b552cfe40f7e6b621e61a"}, - {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:5540ce1c99fa0b59dad2eff771deb33872754000da875be50ac8c2beab42b433"}, - {file = "zstd-1.5.7.2-cp37-cp37m-win32.whl", hash = "sha256:56c4b8cd0a88fd721213661c28b87b64fbd14b6019df39b21b0117a68162b0f2"}, - {file = "zstd-1.5.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:594f256fa72852ade60e3acb909f983d5cf6839b9fc79728dd4b48b31112058f"}, - {file = "zstd-1.5.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dc05618eb0abceb296b77e5f608669c12abc69cbf447d08151bcb14d290ab07"}, - {file = "zstd-1.5.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:70231ba799d681b6fc17456c3e39895c493b5dff400aa7842166322a952b7f2a"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5a73f0f20f71d4eef970a3fed7baac64d9a2a00b238acc4eca2bd7172bd7effb"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0a470f8938f69f632b8f88b96578a5e8825c18ddbbea7de63493f74874f963ef"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_4_i686.whl", hash = "sha256:d104f1cb2a7c142007c29a2a62dfe633155c648317a465674e583c295e5f792d"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_4_x86_64.whl", hash = "sha256:70f29e0504fc511d4b9f921e69637fca79c050e618ba23732a3f75c044814d89"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:a62c2f6f7b8fc69767392084828740bd6faf35ff54d4ccb2e90e199327c64140"}, - {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f2dda0c76f87723fb7f75d7ad3bbd90f7fb47b75051978d22535099325111b41"}, - {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9cf09c2aa6f67750fe9f33fdd122f021b1a23bf7326064a8e21f7af7e77faee"}, - {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:910bd9eac2488439f597504756b03c74aa63ed71b21e5d0aa2c7e249b3f1c13f"}, - {file = "zstd-1.5.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9838ec7eb9f1beb2f611b9bcac7a169cb3de708ccf779aead29787e4482fe232"}, - {file = "zstd-1.5.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:83a36bb1fd574422a77b36ccf3315ab687aef9a802b0c3312ca7006b74eeb109"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:6f8189bc58415758bbbd419695012194f5e5e22c34553712d9a3eb009c09808d"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:632e3c1b7e1ebb0580f6d92b781a8f7901d367cf72725d5642e6d3a32e404e45"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_4_i686.whl", hash = "sha256:df8083c40fdbfe970324f743f0b5ecc244c37736e5f3ad2670de61dde5e0b024"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_4_x86_64.whl", hash = "sha256:300db1ede4d10f8b9b3b99ca52b22f0e2303dc4f1cf6994d1f8345ce22dd5a7e"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:97b908ccb385047b0c020ce3dc55e6f51078c9790722fdb3620c076be4a69ecf"}, - {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c59218bd36a7431a40591504f299de836ea0d63bc68ea76d58c4cf5262f0fa3c"}, - {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4d5a85344193ec967d05da8e2c10aed400e2d83e16041d2fdfb713cfc8caceeb"}, - {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebf6c1d7f0ceb0af5a383d2a1edc8ab9ace655e62a41c8a4ed5a031ee2ef8006"}, - {file = "zstd-1.5.7.2-cp39-cp39-win32.whl", hash = "sha256:44a5142123d59a0dbbd9ba9720c23521be57edbc24202223a5e17405c3bdd4a6"}, - {file = "zstd-1.5.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:8dc542a9818712a9fb37563fa88cdbbbb2b5f8733111d412b718fa602b83ba45"}, - {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:24371a7b0475eef7d933c72067d363c5dc17282d2aa5d4f5837774378718509e"}, - {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:c21d44981b068551f13097be3809fadb7f81617d0c21b2c28a7d04653dde958f"}, - {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux_2_14_x86_64.whl", hash = "sha256:b011bf4cfad78cdf9116d6731234ff181deb9560645ffdcc8d54861ae5d1edfc"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:426e5c6b7b3e2401b734bfd08050b071e17c15df5e3b31e63651d1fd9ba4c751"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:53375b23f2f39359ade944169bbd88f8895eed91290ee608ccbc28810ac360ba"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:1b301b2f9dbb0e848093127fb10cbe6334a697dc3aea6740f0bb726450ee9a34"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5414c9ae27069ab3ec8420fe8d005cb1b227806cbc874a7b4c73a96b4697a633"}, - {file = "zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:9714d5642867fceb22e4ab74aebf81a2e62dc9206184d603cb39277b752d5885"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:6584fd081a6e7d92dffa8e7373d1fced6b3cbf473154b82c17a99438c5e1de51"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:52f27a198e2a72632bae12ec63ebaa31b10e3d5f3dd3df2e01376979b168e2e6"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-win32.whl", hash = "sha256:3b14793d2a2cb3a7ddd1cf083321b662dd20bc11143abc719456e9bfd22a32aa"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:faf3fd38ba26167c5a085c04b8c931a216f1baf072709db7a38e61dea52e316e"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:d17ac6d2584168247796174e599d4adbee00153246287e68881efaf8d48a6970"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9a24d492c63555b55e6bc73a9e82a38bf7c3e8f7cde600f079210ed19cb061f2"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c6abf4ab9a9d1feb14bc3cbcc32d723d340ce43b79b1812805916f3ac069b073"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d7131bb4e55d075cb7847555a1e17fca5b816a550c9b9ac260c01799b6f8e8d9"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a03608499794148f39c932c508d4eb3622e79ca2411b1d0438a2ee8cafdc0111"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:86e64c71b4d00bf28be50e4941586e7874bdfa74858274d9f7571dd5dda92086"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0f79492bf86aef6e594b11e29c5589ddd13253db3ada0c7a14fb176b132fb65e"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:8c3f4bb8508bc54c00532931da4a5261f08493363da14a5526c986765973e35d"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:787bcf55cefc08d27aca34c6dcaae1a24940963d1a73d4cec894ee458c541ac4"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f97f872cb78a4fd60b6c1024a65a4c52a971e9d991f33c7acd833ee73050f85"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5e530b75452fdcff4ea67268d9e7cb37a38e7abbac84fa845205f0b36da81aaf"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7c1cc65fc2789dd97a98202df840537de186ed04fd1804a17fcb15d1232442c4"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:05604a693fa53b60ca083992324b08dafd15a4ac37ac4cffe4b43b9eb93d4440"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:baf4e8b46d8934d4e85373f303eb048c63897fc4191d8ab301a1bbdf30b7a3cc"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:8cc35cc25e2d4a0f68020f05cba96912a2881ebaca890d990abe37aa3aa27045"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:ceae57e369e1b821b8f2b4c59bc08acd27d8e4bf9687bfa5211bc4cdb080fe7b"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5189fb44c44ab9b6c45f734bd7093a67686193110dc90dcfaf0e3a31b2385f38"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:f51a965871b25911e06d421212f9be7f7bcd3cedc43ea441a8a73fad9952baa0"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:624022851c51dd6d6b31dbfd793347c4bd6339095e8383e2f74faf4f990b04c6"}, - {file = "zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a"}, -] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.10,<3.13" -content-hash = "65f1f9833d61f90f1f89ed70b3677f76c0693bae275dd39699df01c05050bbe6" diff --git a/prowler/AGENTS.md b/prowler/AGENTS.md index b1d83fa200..ab3ba1ce67 100644 --- a/prowler/AGENTS.md +++ b/prowler/AGENTS.md @@ -7,22 +7,26 @@ > - [`prowler-compliance`](../skills/prowler-compliance/SKILL.md) - Compliance framework structure > - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns -### Auto-invoke Skills +## Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Action | Skill | |--------|-------| | Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` | | Adding new providers | `prowler-provider` | | Adding services to existing providers | `prowler-provider` | +| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` | | Create PR that requires changelog entry | `prowler-changelog` | | Creating new checks | `prowler-sdk-check` | | Creating/updating compliance frameworks | `prowler-compliance` | +| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` | | Mapping checks to compliance controls | `prowler-compliance` | | Mocking AWS with moto in tests | `prowler-test-sdk` | | Review changelog format and conventions | `prowler-changelog` | | Reviewing compliance framework PRs | `prowler-compliance-review` | +| Syncing compliance framework with upstream catalog | `prowler-compliance` | | Update CHANGELOG.md in any component | `prowler-changelog` | | Updating existing checks and metadata | `prowler-sdk-check` | | Writing Prowler SDK tests | `prowler-test-sdk` | @@ -40,7 +44,7 @@ The Prowler SDK is the core Python engine powering cloud security assessments ac ### Provider Architecture -``` +```text prowler/providers/{provider}/ ├── {provider}_provider.py # Main provider class ├── models.py # Provider-specific models @@ -81,13 +85,13 @@ class {check_name}(Check): ## TECH STACK -Python 3.10+ | Poetry 2+ | pytest | moto (AWS mocking) | Pre-commit hooks (black, flake8, pylint, bandit) +Python 3.10+ | uv | pytest | moto (AWS mocking) | Pre-commit hooks (black, flake8, pylint, bandit) --- ## PROJECT STRUCTURE -``` +```text prowler/ ├── __main__.py # CLI entry point ├── config/ # Global configuration @@ -108,20 +112,20 @@ prowler/ ```bash # Setup -poetry install --with dev -poetry run pre-commit install +uv sync +uv run pre-commit install # Run Prowler -poetry run python prowler-cli.py {provider} -poetry run python prowler-cli.py {provider} --check {check_name} -poetry run python prowler-cli.py {provider} --list-checks +uv run python prowler-cli.py {provider} +uv run python prowler-cli.py {provider} --check {check_name} +uv run python prowler-cli.py {provider} --list-checks # Testing -poetry run pytest -n auto -vvv tests/ -poetry run pytest tests/providers/{provider}/services/{service}/ -v +uv run pytest -n auto -vvv tests/ +uv run pytest tests/providers/{provider}/services/{service}/ -v # Code Quality -poetry run pre-commit run --all-files +uv run pre-commit run --all-files ``` --- @@ -141,8 +145,8 @@ poetry run pre-commit run --all-files ## QA CHECKLIST -- [ ] `poetry run pytest` passes -- [ ] `poetry run pre-commit run --all-files` passes +- [ ] `uv run pytest` passes +- [ ] `uv run pre-commit run --all-files` passes - [ ] Check metadata JSON is valid - [ ] Tests cover PASS, FAIL, and empty resource scenarios - [ ] Docstrings follow Google style diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 38a0ac0f34..9e21f3bfa0 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,446 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.23.0] (Prowler UNRELEASED) +## [5.32.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Per-requirement configuration validation for compliance frameworks via `ConfigRequirements`, so a requirement is reported as FAIL when its configurable checks ran with a configuration too loose to satisfy it (applied across all compliance outputs: CSV, OCSF, and console tables) [(#11669)](https://github.com/prowler-cloud/prowler/pull/11669) +- `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182) +- `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577) +- `stepfunctions_statemachine_encrypted_with_cmk` check for AWS provider, verifying that each Step Functions state machine uses a customer-managed KMS key for encryption at rest rather than the default AWS-owned key [(#11538)](https://github.com/prowler-cloud/prowler/pull/11538) +- CIS Controls v8.1 universal compliance framework mapping existing checks across 18 providers (AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway) to the 18 CIS Critical Security Controls and their Safeguards [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700) +- CIS Microsoft 365 Foundations Benchmark v7.0.0 compliance framework for the M365 provider [(#11699)](https://github.com/prowler-cloud/prowler/pull/11699) +- `waf_regional_webacl_logging_enabled` check for AWS provider, verifying that each AWS WAF Classic Regional Web ACL has logging enabled to a Kinesis Data Firehose stream [(#11539)](https://github.com/prowler-cloud/prowler/pull/11539) +- `sdk_only` provider property (default `true`) and `Provider.get_app_providers()`, so a provider (built-in or external) stays CLI/SDK-only and hidden from the app unless it declares `sdk_only = False` [(#11427)](https://github.com/prowler-cloud/prowler/pull/11427) +- `Provider.get_scan_arguments()`, `Provider.get_connection_arguments()` and `Provider.get_credentials_schema()` contract methods, so a provider persisted as a stored uid plus a secret dict can be constructed and validated programmatically (to be consumed by the API in a later change) [(#11578)](https://github.com/prowler-cloud/prowler/pull/11578) +- CIS Amazon Web Services Foundations Benchmark v7.0.0 compliance framework for the AWS provider, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations [(#11707)](https://github.com/prowler-cloud/prowler/pull/11707) +- CIS Microsoft Azure Foundations Benchmark v6.0.0 compliance framework for the Azure provider [(#11708)](https://github.com/prowler-cloud/prowler/pull/11708) +- CIS Google Cloud Platform Foundation Benchmark v5.0.0 compliance framework for the GCP provider [(#11714)](https://github.com/prowler-cloud/prowler/pull/11714) +- CIS Kubernetes Benchmark v2.0.1 compliance framework for the Kubernetes provider [(#11722)](https://github.com/prowler-cloud/prowler/pull/11722) +- CIS GitHub Benchmark v1.2.0 compliance framework for the GitHub provider [(#11719)](https://github.com/prowler-cloud/prowler/pull/11719) +- `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) + +### 🔄 Changed + +- Replaced the `detect-secrets` library with [Kingfisher](https://github.com/mongodb/kingfisher) as the engine for the secret-scanning checks; scans run fully offline by default and obvious placeholder values are no longer reported as findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Removed the `detect_secrets_plugins` configuration option, which is no longer used by the new secret-scanning engine [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) + +### 🐞 Fixed + +- Report secret-scanning checks as `MANUAL` instead of `PASS` when the scanner fails (non-zero exit, timeout, unparseable output or missing binary), so a scanner failure is no longer indistinguishable from "no secrets found" [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Avoid a false `FAIL` in `cloudwatch_log_group_no_secrets_in_logs` when a multiline event's secrets are all removed by `secrets_ignore_patterns` during the rescan [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Key the `cloudwatch_log_group_no_secrets_in_logs` secret scan by log group ARN instead of name, so same-named log groups and streams in different regions no longer collide and reuse each other's findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694) +- Compliance frameworks contributed by several external packages under the same provider are now merged instead of overwritten, so every entry-point directory a provider contributes is discovered [(#11578)](https://github.com/prowler-cloud/prowler/pull/11578) +- Azure PostgreSQL flexible server collection no longer drops the remaining servers in a subscription when one server fails to collect; the `connection_throttle.enable` parameter (removed in PostgreSQL 16+) is treated as absent only when the Azure SDK reports it as not found, so unexpected lookup failures are not silently reported as throttling disabled [(#11595)](https://github.com/prowler-cloud/prowler/pull/11595) +- Azure `keyvault_logging_enabled` now accepts Key Vault diagnostic settings that enable the explicit `AuditEvent` category, avoiding false failures when Azure returns category-based logs without category groups [(#11660)](https://github.com/prowler-cloud/prowler/pull/11660) +- GitHub default branch protection checks now evaluate repository rulesets in addition to classic branch protection, avoiding false positives for repositories that enforce protection through rulesets [(#11723)](https://github.com/prowler-cloud/prowler/pull/11723) +- Okta, Alibaba Cloud and OpenStack scan-config sections are now validated against a registered schema instead of being silently accepted, so their configurable thresholds (session/idle timeouts, retention days, image-sharing and secret-scanning settings) log a warning and fall back to the built-in default whenever a value is out of range [(#11725)](https://github.com/prowler-cloud/prowler/pull/11725) + +--- + +## [5.31.1] (Prowler v5.31.1) + +### 🐞 Fixed + +- Alibaba Cloud `ram_password_policy_number` and `cs_kubernetes_cluster_check_weekly` checks not being loaded due to missing implementation and package files [(#11683)](https://github.com/prowler-cloud/prowler/pull/11683) + +--- + +## [5.31.0] (Prowler v5.31.0) + +### 🚀 Added + +- Support for Python 3.13 [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) +- `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) +- `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024) +- `cloudfunction_function_inside_vpc` check for GCP provider, verifying Cloud Functions have a Serverless VPC Access connector for private egress [(#11021)](https://github.com/prowler-cloud/prowler/pull/11021) +- `cloudfunction_function_not_publicly_accessible` check for GCP provider, detecting Cloud Functions with `allUsers` or `allAuthenticatedUsers` IAM invocation bindings [(#11022)](https://github.com/prowler-cloud/prowler/pull/11022) +- `secretmanager_secret_not_publicly_accessible` check for GCP provider, detecting Secret Manager secrets with public IAM bindings [(#11025)](https://github.com/prowler-cloud/prowler/pull/11025) +- `secretmanager_secret_rotation_enabled` check for GCP provider, verifying Secret Manager secrets have automatic rotation configured within 90 days [(#11026)](https://github.com/prowler-cloud/prowler/pull/11026) +- `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523) +- `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031) +- `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032) +- `cosmosdb_account_minimum_tls_version` check for Azure provider, verifying Cosmos DB accounts enforce TLS 1.2 or higher for client connections [(#11033)](https://github.com/prowler-cloud/prowler/pull/11033) +- `cosmosdb_account_public_network_access_disabled` check for Azure provider, verifying Cosmos DB accounts have public network access disabled so connectivity is restricted to private endpoints or VNet service endpoints [(#11034)](https://github.com/prowler-cloud/prowler/pull/11034) +- `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) +- `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) +- `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037) +- `mysql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying MySQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11041)](https://github.com/prowler-cloud/prowler/pull/11041) +- `mysql_flexible_server_high_availability_enabled` check for Azure provider, verifying MySQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11042)](https://github.com/prowler-cloud/prowler/pull/11042) +- `postgresql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) +- `postgresql_flexible_server_high_availability_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11046)](https://github.com/prowler-cloud/prowler/pull/11046) +- `aks_cluster_azure_monitor_enabled` check for Azure provider, verifying AKS clusters have Azure Monitor (Container Insights) enabled for metrics, logs, and alerting [(#11029)](https://github.com/prowler-cloud/prowler/pull/11029) +- `aks_cluster_local_accounts_disabled` check for Azure provider, verifying AKS clusters have local accounts disabled so authentication is forced through Microsoft Entra ID [(#11030)](https://github.com/prowler-cloud/prowler/pull/11030) +- `network_subnet_nsg_associated` check for Azure provider, verifying virtual network subnets have a network security group associated to enforce traffic filtering [(#11043)](https://github.com/prowler-cloud/prowler/pull/11043) +- `network_vnet_ddos_protection_enabled` check for Azure provider, verifying virtual networks have Azure DDoS Network Protection enabled [(#11044)](https://github.com/prowler-cloud/prowler/pull/11044) +- `entra_app_registration_credential_not_expired` check for Azure provider, verifying Entra ID app registration secrets and certificates are not expired, expiring within 30 days, or without an expiration date [(#11038)](https://github.com/prowler-cloud/prowler/pull/11038) +- `entra_authentication_methods_policy_strong_auth_enforced` check for Azure provider, verifying the Entra ID authentication methods policy enforces MFA registration and enables at least one strong method (Microsoft Authenticator, FIDO2, or X.509 certificate) [(#11039)](https://github.com/prowler-cloud/prowler/pull/11039) +- `entra_user_with_recent_sign_in` check for Azure provider, detecting stale enabled accounts that have not signed in within the last 90 days (requires Entra ID P1/P2 licensing for sign-in activity) [(#11040)](https://github.com/prowler-cloud/prowler/pull/11040) +- `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) +- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) +- Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) +- TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603) +- Support for Linode cloud provider, with compute, networking and administration services [(#11633)](https://github.com/prowler-cloud/prowler/pull/11633) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Azure provider, mapping existing Azure checks across the five DORA pillars [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) +- `entra_conditional_access_policy_no_deleted_object_references` check for M365 provider [(#11236)](https://github.com/prowler-cloud/prowler/pull/11236) +- `aks_cluster_defender_enabled` check for Azure provider, verifying that AKS clusters have Microsoft Defender security monitoring enabled [(#11028)](https://github.com/prowler-cloud/prowler/pull/11028) +- `recovery_vault_has_protected_items` check for Azure provider, verifying that Recovery Services vaults have at least one protected backup item [(#11048)](https://github.com/prowler-cloud/prowler/pull/11048) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the GCP provider, mapping existing GCP checks across the five DORA pillars [(#11642)](https://github.com/prowler-cloud/prowler/pull/11642) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Cloudflare provider, mapping existing Cloudflare edge/network checks across the applicable DORA pillars [(#11645)](https://github.com/prowler-cloud/prowler/pull/11645) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the AlibabaCloud provider, mapping existing AlibabaCloud checks across the applicable DORA pillars [(#11646)](https://github.com/prowler-cloud/prowler/pull/11646) +- `cloudfront_distributions_pqc_tls_enabled` check for AWS provider to verify CloudFront distributions enforce a post-quantum TLS 1.3 security policy [(#11317)](https://github.com/prowler-cloud/prowler/pull/11317) +- `apigateway_domain_name_pqc_tls_enabled` check for AWS provider to verify API Gateway custom domain names use a post-quantum TLS security policy [(#11316)](https://github.com/prowler-cloud/prowler/pull/11316) +- `transfer_server_pqc_ssh_kex_enabled` check for AWS provider to verify Transfer Family servers use a post-quantum hybrid SSH key exchange security policy [(#11315)](https://github.com/prowler-cloud/prowler/pull/11315) +- `acmpca_certificate_authority_pqc_key_algorithm` check and new `acmpca` service for AWS provider to verify AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm [(#11318)](https://github.com/prowler-cloud/prowler/pull/11318) +- `rolesanywhere_trust_anchor_pqc_pki` check and new `rolesanywhere` service for AWS provider to verify IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI [(#11319)](https://github.com/prowler-cloud/prowler/pull/11319) +- Kubernetes core checks for container CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes [(#11373)](https://github.com/prowler-cloud/prowler/pull/11373) +- `recovery_vault_backup_policy_retention_adequate` check for Azure provider, verifying Recovery Services backup policies retain daily backups for at least 30 days [(#11047)](https://github.com/prowler-cloud/prowler/pull/11047) + +### 🔄 Changed + +- Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) + +### 🐞 Fixed + +- Azure PostgreSQL flexible server inventory no longer aborts the whole subscription when the `connection_throttle.enable` parameter is missing (e.g. PostgreSQL v18), and logs the expected "Entra ID authentication not enabled" case as a warning instead of an error, so servers are still scanned [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) +- `iam_policy_allows_privilege_escalation` now includes the `privilege-escalation` category [(#11648)](https://github.com/prowler-cloud/prowler/pull/11648) + +### 🔐 Security + +- `pytest` from 8.3.5 to 9.0.3, patching a known vulnerability in the SDK test dependency [(#11291)](https://github.com/prowler-cloud/prowler/pull/11291) +- `black` from 25.1.0 to 26.3.1, patching a known vulnerability in the SDK formatter dependency [(#11290)](https://github.com/prowler-cloud/prowler/pull/11290) +- `microsoft-kiota-*` to 1.9.9 and `aiohttp` to 3.14.0, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` bumped to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs); `.trivyignore` documents remaining bookworm criticals with no-fix or not-affected rationale [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [5.30.3] (Prowler v5.30.3) + +### 🐞 Fixed + +- CLI compliance summary tables no longer undercount findings mapped to multiple sections nor double-count a single finding mapped to several requirements within the same group/split, and the Provider column no longer leaks a value from another framework [(#11567)](https://github.com/prowler-cloud/prowler/pull/11567) + +--- + +## [5.30.2] (Prowler v5.30.2) + +### 🐞 Fixed + +- GCP `logging_log_metric_filter_and_alert_*` checks now credit org-level aggregated sinks filtered to the Admin Activity audit stream [(#11575)](https://github.com/prowler-cloud/prowler/pull/11575) +- A broken built-in provider no longer aborts the CLI when a different provider was invoked [(#11618)](https://github.com/prowler-cloud/prowler/pull/11618) +- GCP organization scans with `--organization-id` no longer silently fall back to the credentials' host project when the Cloud Asset API call fails [(#11280)](https://github.com/prowler-cloud/prowler/pull/11280) + +--- + +## [5.30.0] (Prowler v5.30.0) + +### 🚀 Added + +- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) +- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465) +- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463) +- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464) +- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) +- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) +- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) +- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490) +- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475) +- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) +- `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) +- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) +- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335) +- STACKIT ObjectStorage service with Object Lock, default retention policy, and access key expiration checks [(#11397)](https://github.com/prowler-cloud/prowler/pull/11397) + +### 🐞 Fixed + +- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) +- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511) +- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) +- GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) +- AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) +- AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526) + +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + +--- + +## [5.29.3] (Prowler v5.29.3) + +### 🐞 Fixed + +- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) +- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488) +- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) +- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) + +--- + +## [5.29.1] (Prowler v5.29.1) + +### 🐞 Fixed + +- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421) + +--- + +## [5.29.0] (Prowler v5.29.0) + +### 🚀 Added + +- `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358) +- AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353) +- `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334) +- StackIT provider with service account key authentication [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237) +- 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379) +- 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356) + +### ⚠️ Deprecated + +- `s3_bucket_default_encryption` check for AWS provider since SSE-S3 is automatically applied to all S3 buckets by AWS as of January 5, 2023 and can no longer be disabled [(#11230)](https://github.com/prowler-cloud/prowler/pull/11230) + +### 🐞 Fixed + +- Broken documentation URLs in Google Workspace check metadata [(#11405)](https://github.com/prowler-cloud/prowler/pull/11405) +- ENS RD 311/2022 (AWS) compliance mapping: `vpc_different_regions` was uncorrectly mapped under the `mp.com.4` family (Network segregation). That check is now mapped to a new `op.cont.2.aws.vpc.1` requirement under the Continuity of Service control [(#11372)](https://github.com/prowler-cloud/prowler/pull/11372) +- Compliance CSV row count now matches the UI per requirement by sourcing rows from the framework JSON's `requirement.Checks` instead of the stale `finding.compliance` snapshot [(#11370)](https://github.com/prowler-cloud/prowler/pull/11370) +- OpenStack provider exception codes moved from the `10000-10999` range, shared with the AlibabaCloud provider, to the free `17000-17999` range to keep error codes unambiguous [(#11382)](https://github.com/prowler-cloud/prowler/pull/11382) +- Azure provider authentication against sovereign clouds (`AzureChinaCloud`, `AzureUSGovernment`) [(#10284)](https://github.com/prowler-cloud/prowler/pull/10284) + +--- + +## [5.28.1] (Prowler v5.28.1) + +### 🐞 Fixed + +- `compute_project_os_login_enabled` and `compute_project_os_login_2fa_enabled` checks for GCP provider no longer false-FAIL on projects where the `enable-oslogin` / `enable-oslogin-2fa` metadata is not set explicitly but is inherited automatically from the `constraints/compute.requireOsLogin` org policy. The policy controller writes the inherited value in lowercase (`"true"`), but the service-layer parser compared it to the uppercase string literal `"TRUE"`. Comparison is now case-insensitive [(#11341)](https://github.com/prowler-cloud/prowler/pull/11341) +- `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider no longer passes when a storage account allows a weak SMB channel encryption algorithm (e.g. `AES-128-CCM`/`AES-128-GCM`) alongside `AES-256-GCM`; it now requires every enabled algorithm to be in the recommended list, configurable via `azure.recommended_smb_channel_encryption_algorithms` (defaults to `AES-256-GCM` only, as required by CIS) [(#11327)](https://github.com/prowler-cloud/prowler/pull/11327) +- Azure and M365 providers crashing with `RuntimeError: There is no current event loop` on Python 3.12 when called from threads without an active event loop (e.g. Celery workers) [(#11360)](https://github.com/prowler-cloud/prowler/pull/11360) + +--- + +## [5.28.0] (Prowler v5.28.0) + +### 🚀 Added + +- Sites, Additional Google services, and Marketplace checks for Google Workspace provider using the Cloud Identity Policy API [(#11281)](https://github.com/prowler-cloud/prowler/pull/11281) +- `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232) +- `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023) +- Google Workspace Groups service with 3 new checks [(#11186)](https://github.com/prowler-cloud/prowler/pull/11186) +- `ses_identity_dkim_enabled` check for AWS provider [(#10923)](https://github.com/prowler-cloud/prowler/pull/10923) +- `sagemaker_models_registry_in_use` check for AWS provider, verifying that at least one SageMaker Model Package Group has an approved model package to enforce ML governance workflows [(#11196)](https://github.com/prowler-cloud/prowler/pull/11196) +- `signon_dod_warning_banner_configured`, `signon_global_session_lifetime_18h`, `signon_global_session_cookies_not_persistent` and `signon_global_session_policy_network_zone_enforced` checks for Okta provider [(#11224)](https://github.com/prowler-cloud/prowler/pull/11224) + +### 🔄 Changed + +- `OktaProvider.test_connection` accepts an optional `provider_id` (org domain) and raises `OktaInvalidProviderIdError` (14007) when it doesn't match the authenticated org — guards against stored UID drifting from the credentials' org [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184) +- Use single-quoted strings for credential variables in the M365 provider PowerShell session, following PowerShell best practices for literal values [(#9997)](https://github.com/prowler-cloud/prowler/pull/9997) + +### 🐞 Fixed + +- OCI Audit service configuration lookup when the configured region differs from the tenancy home region [(#10347)](https://github.com/prowler-cloud/prowler/pull/10347) +- Container image now uses an absolute `ENTRYPOINT` (`/home/prowler/.venv/bin/prowler`) so it works under any runtime `--workdir`. The relative entrypoint was breaking the official GitHub Action (`prowler-cloud/prowler@v5.27.0`) and any `docker run` with a custom `-w` [(#11313)](https://github.com/prowler-cloud/prowler/pull/11313) + +--- + +## [5.27.1] (Prowler v5.27.1) + +### 🐞 Fixed + +- `s3_bucket_shadow_resource_vulnerability` no longer emits a tautological `PASS` finding for every bucket; a finding is now produced only when the bucket name matches one of the predictable service patterns (Glue, SageMaker, EMR, CodeStar) [(#11220)](https://github.com/prowler-cloud/prowler/pull/11220) +- `sqlserver_tde_encrypted_with_cmk` check for Azure provider no longer reports a false `FAIL` for SQL Servers whose user databases are correctly encrypted with a customer-managed key, by excluding the system `master` database (always reports TDE `Disabled` and is not customer-controllable) from the TDE evaluation [(#11233)](https://github.com/prowler-cloud/prowler/pull/11233) + +--- + +## [5.27.0] (Prowler v5.27.0) + +### 🚀 Added + +- 6 Chat file sharing, external messaging, spaces, and apps access checks for Google Workspace provider using the Cloud Identity Policy API [(#11126)](https://github.com/prowler-cloud/prowler/pull/11126) +- `entra_service_principal_no_secrets_for_permanent_tier0_roles` check for M365 provider [(#10788)](https://github.com/prowler-cloud/prowler/pull/10788) +- `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000) +- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858) +- Per-provider scan configuration schema with bounds validation that drops out-of-range values with a warning on config load [(#11518)](https://github.com/prowler-cloud/prowler/pull/11518) +- Okta provider with OAuth 2.0 authentication and `signon_global_session_idle_timeout_15min` check [(#11079)](https://github.com/prowler-cloud/prowler/pull/11079) +- `sagemaker_domain_sso_configured` check for AWS provider [(#11094)](https://github.com/prowler-cloud/prowler/pull/11094) +- Scaleway provider with `iam_api_keys_no_root_owned` check [(#11166)](https://github.com/prowler-cloud/prowler/pull/11166) + +### 🔄 Changed + +- `entra_emergency_access_exclusion` check for M365 provider now scopes the exclusion requirement to enabled Conditional Access policies with a `Block` grant control instead of every enabled policy, focusing on the lockout-relevant policy set [(#10849)](https://github.com/prowler-cloud/prowler/pull/10849) +- AWS IAM customer-managed policy checks no longer emit `FAIL` on unattached policies unless `--scan-unused-services` is enabled [(#11150)](https://github.com/prowler-cloud/prowler/pull/11150) +- Replace `poetry` with `uv` as package manager [(#11162)](https://github.com/prowler-cloud/prowler/pull/11162) +- Replace `safety` with `osv-scanner` for dependency vulnerability scanning in SDK CI and pre-commit [(#11167)](https://github.com/prowler-cloud/prowler/pull/11167) + +### 🐞 Fixed + +- Google Workspace Directory checks sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11176)](https://github.com/prowler-cloud/prowler/pull/11176) +- Google Workspace Calendar and Drive services sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11161)](https://github.com/prowler-cloud/prowler/pull/11161) +- `zone_waf_enabled` check for Cloudflare provider now appends a plan-aware hint to the FAIL `status_extended`: a possible-false-positive note on paid plans (Pro, Business, Enterprise) where the legacy `waf` zone setting can read `off` even though WAF managed rulesets are deployed via the dashboard, and a "not available on the Cloudflare Free plan" note on Free zones [(#9896)](https://github.com/prowler-cloud/prowler/pull/9896) +- Google Workspace Gmail checks sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11169)](https://github.com/prowler-cloud/prowler/pull/11169) +- Google Workspace Drive and Calendar services missing server-side policy filters [(#11195)](https://github.com/prowler-cloud/prowler/pull/11195) +- `entra_users_mfa_capable` and `entra_break_glass_account_fido2_security_key_registered` report a preventive FAIL per affected user (with the missing permission named) when the M365 service principal lacks `AuditLog.Read.All`, instead of mass false positives [(#10907)](https://github.com/prowler-cloud/prowler/pull/10907) +- Duplicated GCP CIS requirements IDs [(#11180)](https://github.com/prowler-cloud/prowler/pull/11180) +- `VercelSession.token` is now excluded from serialization and representation to prevent the Vercel API token from leaking through `.dict()`, `.json()` or logs [(#11198)](https://github.com/prowler-cloud/prowler/pull/11198) + +--- + +## [5.26.1] (Prowler v5.26.1) + +### 🐞 Fixed + +- `entra_users_mfa_capable` no longer flags disabled guest users by requesting `accountEnabled` and `userType` from Microsoft Graph via `$select` and using Graph as the source of truth for `account_enabled` (EXO `Get-User` does not return guest users) [(#11002)](https://github.com/prowler-cloud/prowler/pull/11002) + +--- + +## [5.26.0] (Prowler v5.26.0) + +### 🚀 Added + +- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844) +- Universal compliance with OCSF support [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301) +- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808) +- Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663) +- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878) +- 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980) +- `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905) + +### 🔄 Changed + +- Azure Network Watcher flow log checks now require workspace-backed Traffic Analytics for `network_flow_log_captured_sent` and align metadata with VNet-compatible flow log guidance [(#10645)](https://github.com/prowler-cloud/prowler/pull/10645) +- Azure compliance entries for legacy Network Watcher flow log controls now use retirement-aware guidance and point new deployments to VNet flow logs [(#10937)](https://github.com/prowler-cloud/prowler/pull/10937) +- AWS CodeBuild service now batches `BatchGetProjects` and `BatchGetBuilds` calls per region (up to 100 items per call) to reduce API call volume and prevent throttling-induced false positives in `codebuild_project_not_publicly_accessible` [(#10639)](https://github.com/prowler-cloud/prowler/pull/10639) +- `display_compliance_table` dispatch switched from substring `in` checks to `startswith` to prevent false matches between similarly named frameworks (e.g. `cisa` vs `cis`) [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301) +- Restore the `ec2-imdsv1` category for EC2 IMDS checks to keep Attack Surface and findings filters aligned [(#10998)](https://github.com/prowler-cloud/prowler/pull/10998) +- Container image CVE findings and IaC findings now use official CVE, Prowler Hub, or GitHub Security Advisory URLs instead of Aqua advisory URLs in remediation and references; Trivy rule IDs map to Prowler Hub without the `AVD-` prefix so links resolve [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853) + +### 🐞 Fixed + +- AWS SDK test isolation: autouse `mock_aws` fixture and leak detector in `conftest.py` to prevent tests from hitting real AWS endpoints, with idempotent organization setup for tests calling `set_mocked_aws_provider` multiple times [(#10605)](https://github.com/prowler-cloud/prowler/pull/10605) +- AWS `boto` user agent extra is now applied to every client [(#10944)](https://github.com/prowler-cloud/prowler/pull/10944) +- Image provider connection check no longer fails with a misleading `host='https'` resolution error when the registry URL includes an `http://` or `https://` scheme prefix [(#10950)](https://github.com/prowler-cloud/prowler/pull/10950) +- Azure subscriptions sharing the same display name are no longer collapsed into a single identity entry, so every subscription is scanned [(#10718)](https://github.com/prowler-cloud/prowler/pull/10718) + +### 🔐 Security + +- Parser-mismatch SSRF in image provider registry auth where crafted bearer-token realms and pagination links could force requests to internal addresses and leak credentials cross-origin [(#10945)](https://github.com/prowler-cloud/prowler/pull/10945) +- `cryptography` from 46.0.6 to 46.0.7 and `trivy` binary from 0.69.2 to 0.70.0 in the SDK image for CVE-2026-39892 and CVE-2026-33186 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978) + +--- + +## [5.25.3] (Prowler v5.25.3) + +### 🐞 Fixed + +- Oracle Cloud identity scans known or supplied regions to better support non Ashburn tenancies [(#10529)](https://github.com/prowler-cloud/prowler/pull/10529) + +--- + +## [5.25.2] (Prowler v5.25.2) + +### 🐞 Fixed + +- `route53_dangling_ip_subdomain_takeover` now also flags `CNAME` records pointing to S3 website endpoints whose buckets are missing from the account [(#10920)](https://github.com/prowler-cloud/prowler/pull/10920) +- Duplicate Kubernetes RBAC findings when the same User or Group subject appeared in multiple ClusterRoleBindings [(#10242)](https://github.com/prowler-cloud/prowler/pull/10242) +- Match K8s RBAC rules by `apiGroup` [(#10969)](https://github.com/prowler-cloud/prowler/pull/10969) +- Return a compact actor name from CloudTrail `userIdentity` events [(#10986)](https://github.com/prowler-cloud/prowler/pull/10986) + +--- + +## [5.25.1] (Prowler v5.25.1) + +### 🐞 Fixed + +- `KeyError` when generating compliance outputs after the CLI scan [#10919](https://github.com/prowler-cloud/prowler/pull/10919) +- Kubernetes OCSF `provider_uid` now uses the cluster name in in-cluster mode (so `--cluster-name` is correctly reflected in findings) and keeps the kubeconfig context in kubeconfig mode [(#10483)](https://github.com/prowler-cloud/prowler/pull/10483) + +--- + +## [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) + +--- + +## [5.24.3] (Prowler v5.24.3) + +### 🐞 Fixed + +- CloudTrail resource timeline uses resource name as fallback in `LookupEvents` [(#10828)](https://github.com/prowler-cloud/prowler/pull/10828) +- Exclude `me-south-1` and `me-central-1` from default AWS scans to prevent hangs when the host can't reach those regional endpoints [(#10837)](https://github.com/prowler-cloud/prowler/pull/10837) + +--- + +## [5.24.1] (Prowler v5.24.1) + +### 🔄 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) + +### 🐞 Fixed + +- Cloudflare account-scoped API tokens failing connection test in the App with `CloudflareUserTokenRequiredError` [(#10723)](https://github.com/prowler-cloud/prowler/pull/10723) +- `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) +- Google Workspace Calendar checks false FAIL on unconfigured settings with secure Google defaults [(#10726)](https://github.com/prowler-cloud/prowler/pull/10726) +- Google Workspace Drive checks false FAIL on unconfigured settings with secure Google defaults [(#10727)](https://github.com/prowler-cloud/prowler/pull/10727) +- Cloudflare `validate_credentials` can hang in an infinite pagination loop when the SDK repeats accounts, blocking connection tests [(#10771)](https://github.com/prowler-cloud/prowler/pull/10771) + +--- + +## [5.24.0] (Prowler v5.24.0) + +### 🚀 Added + +- `entra_conditional_access_policy_directory_sync_account_excluded` check for M365 provider [(#10620)](https://github.com/prowler-cloud/prowler/pull/10620) +- `intune_device_compliance_policy_unassigned_devices_not_compliant_by_default` check for M365 provider [(#10599)](https://github.com/prowler-cloud/prowler/pull/10599) +- `entra_conditional_access_policy_all_apps_all_users` check for M365 provider [(#10619)](https://github.com/prowler-cloud/prowler/pull/10619) +- `bedrock_full_access_policy_attached` check for AWS provider [(#10577)](https://github.com/prowler-cloud/prowler/pull/10577) +- `iam_role_access_not_stale_to_bedrock` and `iam_user_access_not_stale_to_bedrock` checks for AWS provider [(#10536)](https://github.com/prowler-cloud/prowler/pull/10536) +- `iam_policy_no_wildcard_marketplace_subscribe` and `iam_inline_policy_no_wildcard_marketplace_subscribe` checks for AWS provider [(#10525)](https://github.com/prowler-cloud/prowler/pull/10525) +- `bedrock_vpc_endpoints_configured` check for AWS provider [(#10591)](https://github.com/prowler-cloud/prowler/pull/10591) +- `exchange_organization_delicensing_resiliency_enabled` check for M365 provider [(#10608)](https://github.com/prowler-cloud/prowler/pull/10608) +- `entra_conditional_access_policy_mfa_enforced_for_guest_users` check for M365 provider [(#10616)](https://github.com/prowler-cloud/prowler/pull/10616) +- `entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced` check for M365 provider [(#10618)](https://github.com/prowler-cloud/prowler/pull/10618) +- `entra_conditional_access_policy_block_unknown_device_platforms` check for M365 provider [(#10615)](https://github.com/prowler-cloud/prowler/pull/10615) +- `--excluded-region` CLI flag, `PROWLER_AWS_DISALLOWED_REGIONS` environment variable, and `aws.disallowed_regions` config entry to skip specific AWS regions during scans [(#10688)](https://github.com/prowler-cloud/prowler/pull/10688) + +### 🔄 Changed + +- Bump Poetry to `2.3.4` and consolidate SDK workflows onto the `setup-python-poetry` composite action with opt-in lockfile regeneration [(#10681)](https://github.com/prowler-cloud/prowler/pull/10681) +- Normalize Conditional Access platform values in Entra models and simplify platform-based checks [(#10635)](https://github.com/prowler-cloud/prowler/pull/10635) + +### 🐞 Fixed + +- `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) + +--- + +## [5.23.0] (Prowler v5.23.0) ### 🚀 Added @@ -11,43 +450,54 @@ All notable changes to the **Prowler SDK** are documented in this file. - `glue_etl_jobs_no_secrets_in_arguments` check for plaintext secrets in AWS Glue ETL job arguments [(#10368)](https://github.com/prowler-cloud/prowler/pull/10368) - `awslambda_function_no_dead_letter_queue`, `awslambda_function_using_cross_account_layers`, and `awslambda_function_env_vars_not_encrypted_with_cmk` checks for AWS Lambda [(#10381)](https://github.com/prowler-cloud/prowler/pull/10381) - `entra_conditional_access_policy_mdm_compliant_device_required` check for M365 provider [(#10220)](https://github.com/prowler-cloud/prowler/pull/10220) +- `directory_super_admin_only_admin_roles` check for Google Workspace provider [(#10488)](https://github.com/prowler-cloud/prowler/pull/10488) - `ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip` check for AWS provider using `ipaddress.is_global` for accurate public IP detection [(#10335)](https://github.com/prowler-cloud/prowler/pull/10335) - `entra_conditional_access_policy_block_o365_elevated_insider_risk` check for M365 provider [(#10232)](https://github.com/prowler-cloud/prowler/pull/10232) - `--resource-group` and `--list-resource-groups` CLI flags to filter checks by resource group across all providers [(#10479)](https://github.com/prowler-cloud/prowler/pull/10479) - CISA SCuBA Google Workspace Baselines compliance [(#10466)](https://github.com/prowler-cloud/prowler/pull/10466) - CIS Google Workspace Foundations Benchmark v1.3.0 compliance [(#10462)](https://github.com/prowler-cloud/prowler/pull/10462) +- `calendar_external_sharing_primary_calendar`, `calendar_external_sharing_secondary_calendar`, and `calendar_external_invitations_warning` checks for Google Workspace provider using the Cloud Identity Policy API [(#10597)](https://github.com/prowler-cloud/prowler/pull/10597) +- 11 Drive and Docs checks for Google Workspace provider (`drive_external_sharing_warn_users`, `drive_publishing_files_disabled`, `drive_sharing_allowlisted_domains`, `drive_warn_sharing_with_allowlisted_domains`, `drive_access_checker_recipients_only`, `drive_internal_users_distribute_content`, `drive_shared_drive_creation_allowed`, `drive_shared_drive_managers_cannot_override`, `drive_shared_drive_members_only_access`, `drive_shared_drive_disable_download_print_copy`, `drive_desktop_access_disabled`) using the Cloud Identity Policy API [(#10648)](https://github.com/prowler-cloud/prowler/pull/10648) - `entra_conditional_access_policy_device_registration_mfa_required` check and `entra_intune_enrollment_sign_in_frequency_every_time` enhancement for M365 provider [(#10222)](https://github.com/prowler-cloud/prowler/pull/10222) - `entra_conditional_access_policy_block_elevated_insider_risk` check for M365 provider [(#10234)](https://github.com/prowler-cloud/prowler/pull/10234) - `Vercel` provider support with 30 checks [(#10189)](https://github.com/prowler-cloud/prowler/pull/10189) +- `internet-exposed` category for 13 AWS checks (CloudFront, CodeArtifact, EC2, EFS, RDS, SageMaker, Shield, VPC) [(#10502)](https://github.com/prowler-cloud/prowler/pull/10502) +- `stepfunctions_statemachine_no_secrets_in_definition` check for hardcoded secrets in AWS Step Functions state machine definitions [(#10570)](https://github.com/prowler-cloud/prowler/pull/10570) +- CCC improvements with the latest checks and new mappings [(#10625)](https://github.com/prowler-cloud/prowler/pull/10625) ### 🔄 Changed -- Added `internet-exposed` category to 13 AWS checks (CloudFront, CodeArtifact, EC2, EFS, RDS, SageMaker, Shield, VPC) [(#10502)](https://github.com/prowler-cloud/prowler/pull/10502) - Minimum Python version from 3.9 to 3.10 and updated classifiers to reflect supported versions (3.10, 3.11, 3.12) [(#10464)](https://github.com/prowler-cloud/prowler/pull/10464) +- Pin direct SDK dependencies to exact versions and rely on `poetry.lock` artifact hashes for reproducible installs [(#10593)](https://github.com/prowler-cloud/prowler/pull/10593) +- Sensitive CLI flags now warn when values are passed directly, recommending environment variables instead [(#10532)](https://github.com/prowler-cloud/prowler/pull/10532) ### 🐞 Fixed +- OCI mutelist support: pass `tenancy_id` to `is_finding_muted` and update `oraclecloud_mutelist_example.yaml` to use `Accounts` key [(#10566)](https://github.com/prowler-cloud/prowler/pull/10566) - `return` statements in `finally` blocks replaced across IAM, Organizations, GCP provider, and custom checks metadata to stop silently swallowing exceptions [(#10102)](https://github.com/prowler-cloud/prowler/pull/10102) - `JiraConnection` now includes issue types per project fetched during `test_connection`, fixing `JiraInvalidIssueTypeError` on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) +- `--list-checks` and `--list-checks-json` now include `threat-detection` category checks in their output [(#10578)](https://github.com/prowler-cloud/prowler/pull/10578) +- Missing `__init__.py` in `codebuild_project_uses_allowed_github_organizations` check preventing discovery by `--list-checks` [(#10584)](https://github.com/prowler-cloud/prowler/pull/10584) +- Azure Key Vault checks emitting incorrect findings for keys, secrets, and vault logging [(#10332)](https://github.com/prowler-cloud/prowler/pull/10332) +- `is_policy_public` now recognizes `kms:CallerAccount`, `kms:ViaService`, `aws:CalledVia`, `aws:CalledViaFirst`, and `aws:CalledViaLast` as restrictive condition keys, fixing false positives in `kms_key_policy_is_not_public` and other checks that use `is_condition_block_restrictive` [(#10600)](https://github.com/prowler-cloud/prowler/pull/10600) +- `_enabled_regions` empty-set bug in `AwsProvider.generate_regional_clients` creating boto3 clients for all 36 AWS regions instead of the audited ones, causing random CI timeouts and slow test runs [(#10598)](https://github.com/prowler-cloud/prowler/pull/10598) +- Retrieve only the latest version from a package in AWS CodeArtifact [(#10243)](https://github.com/prowler-cloud/prowler/pull/10243) +- AWS global services (CloudFront, Route53, Shield, FMS) now use the partition's global region instead of the profile's default region [(#10458)](https://github.com/prowler-cloud/prowler/pull/10458) +- Oracle Cloud `events_rule_idp_group_mapping_changes` now recognizes the CIS 3.1 `add/remove` event names to avoid false positives [(#10416)](https://github.com/prowler-cloud/prowler/pull/10416) +- Oracle Cloud password policy checks now exclude immutable system-managed policies (`SimplePasswordPolicy`, `StandardPasswordPolicy`) to avoid false positives [(#10453)](https://github.com/prowler-cloud/prowler/pull/10453) +- Oracle Cloud `kms_key_rotation_enabled` now checks current key version age to avoid false positives on vaults without auto-rotation support [(#10450)](https://github.com/prowler-cloud/prowler/pull/10450) +- OCI filestorage, blockstorage, KMS, and compute services now honor `--region` for scanning outside the tenancy home region [(#10472)](https://github.com/prowler-cloud/prowler/pull/10472) +- OCI provider now supports multi-region filtering via `--region` [(#10473)](https://github.com/prowler-cloud/prowler/pull/10473) +- `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10470)](https://github.com/prowler-cloud/prowler/pull/10470) +- OCI multi-region support for identity client configuration in blockstorage, identity, and filestorage services [(#10520)](https://github.com/prowler-cloud/prowler/pull/10520) +- Google Workspace Calendar checks now filter for customer-level policies only, skipping OU and group overrides that could produce incorrect audit results [(#10658)](https://github.com/prowler-cloud/prowler/pull/10658) ### 🔐 Security - Sensitive CLI flag values (tokens, keys, passwords) in HTML output "Parameters used" field now redacted to prevent credential leaks [(#10518)](https://github.com/prowler-cloud/prowler/pull/10518) - ---- - -## [5.22.1] (Prowler UNRELEASED) - -### 🐞 Fixed - -- AWS global services (CloudFront, Route53, Shield, FMS) now use the partition's global region instead of the profile's default region [(#10458)](https://github.com/prowler-cloud/prowler/issues/10458) -- Oracle Cloud `events_rule_idp_group_mapping_changes` now recognizes the CIS 3.1 `add/remove` event names to avoid false positives [(#10416)](https://github.com/prowler-cloud/prowler/pull/10416) -- Oracle Cloud password policy checks now exclude immutable system-managed policies (`SimplePasswordPolicy`, `StandardPasswordPolicy`) to avoid false positives [(#10453)](https://github.com/prowler-cloud/prowler/pull/10453) -- Oracle Cloud `kms_key_rotation_enabled` now checks current key version age to avoid false positives on vaults without auto-rotation support [(#10450)](https://github.com/prowler-cloud/prowler/pull/10450) -- Oracle Cloud patch for filestorage, blockstorage, kms, and compute services in OCI to allow for region scanning outside home [(#10455)](https://github.com/prowler-cloud/prowler/pull/10472) -- Oracle cloud provider now supports multi-region filtering [(#10435)](https://github.com/prowler-cloud/prowler/pull/10473) -- `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10457)](https://github.com/prowler-cloud/prowler/issues/10457) -- Oracle Cloud multi-region support for identity client configuration in blockstorage, identity, and filestorage services [(#10519)](https://github.com/prowler-cloud/prowler/pull/10520) +- `authlib` bumped from 1.6.5 to 1.6.9 to fix CVE-2026-28802 (JWT `alg: none` validation bypass) [(#10579)](https://github.com/prowler-cloud/prowler/pull/10579) +- `cryptography` bumped from 44.0.3 to 46.0.6 ([CVE-2026-26007](https://github.com/pyca/cryptography/security/advisories/GHSA-r6ph-v2qm-q3c2), [CVE-2026-34073](https://github.com/pyca/cryptography/security/advisories/GHSA-m959-cc7f-wv43)), `oci` to 2.169.0, and `alibabacloud-tea-openapi` to 0.4.4 [(#10535)](https://github.com/prowler-cloud/prowler/pull/10535) +- `aiohttp` bumped from 3.13.3 to 3.13.5 to fix CVE-2026-34520 (the C parser accepted null bytes and control characters in response headers) [(#10537)](https://github.com/prowler-cloud/prowler/pull/10537) --- @@ -753,7 +1203,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - S3 `test_connection` uses AWS S3 API `HeadBucket` instead of `GetBucketLocation` [(#8456)](https://github.com/prowler-cloud/prowler/pull/8456) - Add more validations to Azure Storage models when some values are None to avoid serialization issues [(#8325)](https://github.com/prowler-cloud/prowler/pull/8325) - `sns_topics_not_publicly_accessible` false positive with `aws:SourceArn` conditions [(#8326)](https://github.com/prowler-cloud/prowler/issues/8326) -- Remove typo from description req 1.2.3 - Prowler ThreatScore m365 [(#8384)](https://github.com/prowler-cloud/prowler/pull/8384) +- Remove typo from description req 1.2.3 - Prowler ThreatScore M365 [(#8384)](https://github.com/prowler-cloud/prowler/pull/8384) - Way of counting FAILED/PASS reqs from `kisa_isms_p_2023_aws` table [(#8382)](https://github.com/prowler-cloud/prowler/pull/8382) - Use default tenant domain instead of first domain in list for Azure and M365 providers [(#8402)](https://github.com/prowler-cloud/prowler/pull/8402) - Avoid multiple module error calls in M365 provider [(#8353)](https://github.com/prowler-cloud/prowler/pull/8353) @@ -794,7 +1244,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Title & description wording for `iam_user_accesskey_unused` check for AWS provider [(#8233)](https://github.com/prowler-cloud/prowler/pull/8233) - Add GitHub provider to lateral panel in documentation and change -h environment variable output [(#8246)](https://github.com/prowler-cloud/prowler/pull/8246) -- Show `m365_identity_type` and `m365_identity_id` in cloud reports [(#8247)](https://github.com/prowler-cloud/prowler/pull/8247) +- Show `M365_identity_type` and `M365_identity_id` in cloud reports [(#8247)](https://github.com/prowler-cloud/prowler/pull/8247) - Ensure `is_service_role` only returns `True` for service roles [(#8274)](https://github.com/prowler-cloud/prowler/pull/8274) - Update DynamoDB check metadata to fix broken link [(#8273)](https://github.com/prowler-cloud/prowler/pull/8273) - Show correct count of findings in Dashboard Security Posture page [(#8270)](https://github.com/prowler-cloud/prowler/pull/8270) @@ -916,9 +1366,9 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Fixed -- `m365_powershell test_credentials` to use sanitized credentials [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) +- `M365_powershell test_credentials` to use sanitized credentials [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) - `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779) -- `m365_powershell` to close the PowerShell sessions in msgraph services [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816) +- `M365_powershell` to close the PowerShell sessions in msgraph services [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816) - `defender_ensure_notify_alerts_severity_is_high`check to accept high or lower severity [(#7862)](https://github.com/prowler-cloud/prowler/pull/7862) - Replace `Directory.Read.All` permission with `Domain.Read.All` which is more restrictive [(#7888)](https://github.com/prowler-cloud/prowler/pull/7888) - Split calls to list Azure Functions attributes [(#7778)](https://github.com/prowler-cloud/prowler/pull/7778) @@ -992,7 +1442,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - New check `teams_meeting_chat_anonymous_users_disabled` [(#7579)](https://github.com/prowler-cloud/prowler/pull/7579) - Prowler Threat Score Compliance Framework [(#7603)](https://github.com/prowler-cloud/prowler/pull/7603) - Documentation for M365 provider [(#7622)](https://github.com/prowler-cloud/prowler/pull/7622) -- Support for m365 provider in Prowler Dashboard [(#7633)](https://github.com/prowler-cloud/prowler/pull/7633) +- Support for M365 provider in Prowler Dashboard [(#7633)](https://github.com/prowler-cloud/prowler/pull/7633) - New check for Modern Authentication enabled for Exchange Online in M365 [(#7636)](https://github.com/prowler-cloud/prowler/pull/7636) - New check `sharepoint_onedrive_sync_restricted_unmanaged_devices` [(#7589)](https://github.com/prowler-cloud/prowler/pull/7589) - New check for Additional Storage restricted for Exchange in M365 [(#7638)](https://github.com/prowler-cloud/prowler/pull/7638) @@ -1002,7 +1452,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - New check for MailTips full enabled for Exchange in M365 [(#7637)](https://github.com/prowler-cloud/prowler/pull/7637) - New check for Comprehensive Attachments Filter Applied for Defender in M365 [(#7661)](https://github.com/prowler-cloud/prowler/pull/7661) - Modified check `exchange_mailbox_properties_auditing_enabled` to make it configurable [(#7662)](https://github.com/prowler-cloud/prowler/pull/7662) -- snapshots to m365 documentation [(#7673)](https://github.com/prowler-cloud/prowler/pull/7673) +- snapshots to M365 documentation [(#7673)](https://github.com/prowler-cloud/prowler/pull/7673) - support for static credentials for sending findings to Amazon S3 and AWS Security Hub [(#7322)](https://github.com/prowler-cloud/prowler/pull/7322) - Prowler ThreatScore for M365 provider [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692) - Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681) diff --git a/prowler/__main__.py b/prowler/__main__.py index 8d4b3cb26e..d4c925f74f 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -10,7 +10,6 @@ from colorama import Fore, Style from colorama import init as colorama_init from prowler.config.config import ( - EXTERNAL_TOOL_PROVIDERS, cloud_api_base_url, csv_file_suffix, get_available_compliance_frameworks, @@ -18,8 +17,9 @@ from prowler.config.config import ( json_asff_file_suffix, json_ocsf_file_suffix, orange_color, + sarif_file_suffix, ) -from prowler.lib.banner import print_banner +from prowler.lib.banner import print_banner, print_prowler_cloud_banner from prowler.lib.check.check import ( exclude_checks_to_run, exclude_services_to_run, @@ -44,7 +44,10 @@ from prowler.lib.check.check import ( ) from prowler.lib.check.checks_loader import load_checks_to_execute from prowler.lib.check.compliance import update_checks_metadata_with_compliance -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) from prowler.lib.check.custom_checks_metadata import ( parse_custom_checks_metadata_file, update_checks_metadata, @@ -53,6 +56,9 @@ from prowler.lib.check.models import CheckMetadata from prowler.lib.cli.parser import ProwlerArgumentParser from prowler.lib.logger import logger, set_logging_config from prowler.lib.outputs.asff.asff import ASFF +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws import ( + ASDEssentialEightAWS, +) from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected import ( AWSWellArchitected, ) @@ -69,17 +75,15 @@ from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS from prowler.lib.outputs.compliance.cis.cis_googleworkspace import GoogleWorkspaceCIS from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS +from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS +from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import ( GoogleWorkspaceCISASCuBA, ) -from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS -from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS -from prowler.lib.outputs.compliance.compliance import display_compliance_table -from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA -from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA -from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA -from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA -from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA +from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + process_universal_compliance_frameworks, +) from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS @@ -98,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -122,6 +129,7 @@ from prowler.lib.outputs.html.html import HTML from prowler.lib.outputs.ocsf.ingestion import send_ocsf_to_api from prowler.lib.outputs.ocsf.ocsf import OCSF from prowler.lib.outputs.outputs import extract_findings_statistics, report +from prowler.lib.outputs.sarif.sarif import SARIF from prowler.lib.outputs.slack.slack import Slack from prowler.lib.outputs.summary_table import display_summary_table from prowler.providers.alibabacloud.models import AlibabaCloudOutputOptions @@ -139,12 +147,16 @@ from prowler.providers.iac.models import IACOutputOptions from prowler.providers.image.exceptions.exceptions import ImageBaseException from prowler.providers.image.models import ImageOutputOptions from prowler.providers.kubernetes.models import KubernetesOutputOptions +from prowler.providers.linode.models import LinodeOutputOptions from prowler.providers.llm.models import LLMOutputOptions from prowler.providers.m365.models import M365OutputOptions from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions from prowler.providers.nhn.models import NHNOutputOptions +from prowler.providers.okta.models import OktaOutputOptions from prowler.providers.openstack.models import OpenStackOutputOptions from prowler.providers.oraclecloud.models import OCIOutputOptions +from prowler.providers.scaleway.models import ScalewayOutputOptions +from prowler.providers.stackit.models import StackITOutputOptions from prowler.providers.vercel.models import VercelOutputOptions @@ -191,13 +203,15 @@ def prowler(): if not args.no_banner: legend = args.verbose or getattr(args, "fixer", None) - print_banner(legend) + print_banner(legend, provider) # We treat the compliance framework as another output format 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: + # If no input compliance framework, set all, unless a specific service or check is input. + # Skip for tool-wrapper providers (iac, llm, image, and any external plug-in + # declaring `is_external_tool_provider = True`) — they don't use compliance frameworks. + elif default_execution and not Provider.is_tool_wrapper_provider(provider): args.output_formats.extend(get_available_compliance_frameworks(provider)) # Set Logger configuration @@ -232,13 +246,17 @@ def prowler(): # Load compliance frameworks logger.debug("Loading compliance frameworks from .json files") + universal_frameworks = {} + # Skip compliance frameworks for external-tool providers - if provider not in EXTERNAL_TOOL_PROVIDERS: + if not Provider.is_tool_wrapper_provider(provider): bulk_compliance_frameworks = Compliance.get_bulk(provider) # Complete checks metadata with the compliance framework specification bulk_checks_metadata = update_checks_metadata_with_compliance( bulk_compliance_frameworks, bulk_checks_metadata ) + # Load universal compliance frameworks for new rendering pipeline + universal_frameworks = get_bulk_compliance_frameworks_universal(provider) # Update checks metadata if the --custom-checks-metadata-file is present custom_checks_metadata = None @@ -251,12 +269,12 @@ def prowler(): ) if args.list_compliance: - print_compliance_frameworks(bulk_compliance_frameworks) + all_frameworks = {**bulk_compliance_frameworks, **universal_frameworks} + print_compliance_frameworks(all_frameworks) sys.exit() if args.list_compliance_requirements: - print_compliance_requirements( - bulk_compliance_frameworks, args.list_compliance_requirements - ) + all_frameworks = {**bulk_compliance_frameworks, **universal_frameworks} + print_compliance_requirements(all_frameworks, args.list_compliance_requirements) sys.exit() # Load checks to execute @@ -271,6 +289,9 @@ def prowler(): categories=categories, resource_groups=resource_groups, provider=provider, + list_checks=getattr(args, "list_checks", False) + or getattr(args, "list_checks_json", False), + universal_frameworks=universal_frameworks, ) # if --list-checks-json, dump a json file and exit @@ -291,8 +312,12 @@ def prowler(): if not args.only_logs: global_provider.print_credentials() + # --registry-list: listing already printed during provider init, exit + if getattr(global_provider, "_listing_only", False): + sys.exit() + # Skip service and check loading for external-tool providers - if provider not in EXTERNAL_TOOL_PROVIDERS: + if not Provider.is_tool_wrapper_provider(provider): # Import custom checks from folder if checks_folder: custom_checks = parse_checks_from_folder(global_provider, checks_folder) @@ -391,6 +416,10 @@ def prowler(): output_options = OCIOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "stackit": + output_options = StackITOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) elif provider == "alibabacloud": output_options = AlibabaCloudOutputOptions( args, bulk_checks_metadata, global_provider.identity @@ -403,6 +432,32 @@ def prowler(): output_options = VercelOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "okta": + output_options = OktaOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) + elif provider == "scaleway": + output_options = ScalewayOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) + elif provider == "linode": + output_options = LinodeOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) + else: + # Dynamic fallback: any external/custom provider + try: + output_options = global_provider.get_output_options( + args, bulk_checks_metadata + ) + except NotImplementedError: + # No provider-specific OutputOptions: use the generic default so the + # run still produces output instead of aborting. + from prowler.providers.common.models import default_output_options + + output_options = default_output_options( + global_provider, args, bulk_checks_metadata + ) # Run the quick inventory for the provider if available if hasattr(args, "quick_inventory") and args.quick_inventory: @@ -412,7 +467,7 @@ def prowler(): # Execute checks findings = [] - if provider in EXTERNAL_TOOL_PROVIDERS: + if Provider.is_tool_wrapper_provider(provider): # For external-tool providers, run the scan directly if provider == "llm": @@ -422,14 +477,22 @@ def prowler(): findings = global_provider.run_scan(streaming_callback=streaming_callback) else: - # Original behavior for IAC or non-verbose LLM - try: + if provider == "image": + try: + findings = global_provider.run() + except ImageBaseException as error: + logger.critical(f"{error}") + sys.exit(1) + else: + # IAC and external tool-wrapper providers registered via entry + # points. Unexpected failures propagate to the outer except + # Exception backstop further down in this file — keeping the + # branch free of an Image-specific catch that would otherwise + # mislead plug-in authors reading this code. 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: @@ -546,6 +609,13 @@ def prowler(): html_output.batch_write_data_to_file( provider=global_provider, stats=stats ) + if mode == "sarif": + sarif_output = SARIF( + findings=finding_outputs, + file_path=f"{filename}{sarif_file_suffix}", + ) + generated_outputs["regular"].append(sarif_output) + sarif_output.batch_write_data_to_file() if getattr(args, "push_to_cloud", False): if not ocsf_output or not getattr(ocsf_output, "file_path", None): @@ -607,9 +677,29 @@ def prowler(): ) # Compliance Frameworks + # Source the framework listing from the union of `bulk_compliance_frameworks` + # and `universal_frameworks` so universal-only frameworks (e.g. + # `prowler/compliance/csa_ccm_4.0.json`) — which `Compliance.get_bulk(provider)` + # does not load — still reach `process_universal_compliance_frameworks` below. + # The provider-specific block subtracts the names handled by the universal + # processor so the legacy per-provider handlers only see frameworks that the + # bulk loader actually resolved. input_compliance_frameworks = set(output_options.output_modes).intersection( - get_available_compliance_frameworks(provider) + set(bulk_compliance_frameworks.keys()) | set(universal_frameworks.keys()) ) + + # ── Universal compliance frameworks (provider-agnostic) ── + universal_processed = process_universal_compliance_frameworks( + input_compliance_frameworks=input_compliance_frameworks, + universal_frameworks=universal_frameworks, + finding_outputs=finding_outputs, + output_directory=output_options.output_directory, + output_filename=output_options.output_filename, + provider=provider, + generated_outputs=generated_outputs, + ) + input_compliance_frameworks -= universal_processed + if provider == "aws": for compliance_name in input_compliance_frameworks: if compliance_name.startswith("cis_"): @@ -625,6 +715,18 @@ def prowler(): ) generated_outputs["compliance"].append(cis) cis.batch_write_data_to_file() + elif compliance_name.startswith("asd_essential_eight"): + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + asd_essential_eight = ASDEssentialEightAWS( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(asd_essential_eight) + asd_essential_eight.batch_write_data_to_file() elif compliance_name == "mitre_attack_aws": # Generate MITRE ATT&CK Finding Object filename = ( @@ -728,18 +830,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5) c5.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_aws": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_aws = AWSCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_aws) - csa_ccm_4_0_aws.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -843,18 +933,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5_azure) c5_azure.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_azure": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_azure = AzureCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_azure) - csa_ccm_4_0_azure.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -958,18 +1036,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5_gcp) c5_gcp.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_gcp": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_gcp = GCPCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_gcp) - csa_ccm_4_0_gcp.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1204,18 +1270,6 @@ def prowler(): ) generated_outputs["compliance"].append(cis) cis.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_oraclecloud": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_oraclecloud = OracleCloudCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_oraclecloud) - csa_ccm_4_0_oraclecloud.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1244,18 +1298,6 @@ def prowler(): ) generated_outputs["compliance"].append(cis) cis.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_alibabacloud": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_alibabacloud = AlibabaCloudCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_alibabacloud) - csa_ccm_4_0_alibabacloud.batch_write_data_to_file() elif compliance_name == "prowler_threatscore_alibabacloud": filename = ( f"{output_options.output_directory}/compliance/" @@ -1280,6 +1322,57 @@ def prowler(): ) generated_outputs["compliance"].append(generic_compliance) generic_compliance.batch_write_data_to_file() + elif provider == "okta": + for compliance_name in input_compliance_frameworks: + if compliance_name.startswith("okta_idaas_stig"): + # Generate Okta IDaaS STIG Finding Object + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + okta_idaas_stig = OktaIDaaSSTIG( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(okta_idaas_stig) + okta_idaas_stig.batch_write_data_to_file() + else: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() + else: + # Dynamic fallback: any external/custom provider + try: + global_provider.generate_compliance_output( + finding_outputs, + bulk_compliance_frameworks, + input_compliance_frameworks, + output_options, + generated_outputs, + ) + except NotImplementedError: + # Last resort: generic compliance + for compliance_name in input_compliance_frameworks: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() # AWS Security Hub Integration if provider == "aws": @@ -1309,8 +1402,12 @@ def prowler(): global_provider.identity.audited_regions, ) if not global_provider.identity.audited_regions - else global_provider.identity.audited_regions + else set(global_provider.identity.audited_regions) ) + if global_provider._enabled_regions is not None: + security_hub_regions = security_hub_regions.intersection( + global_provider._enabled_regions + ) security_hub = SecurityHub( aws_account_id=global_provider.identity.account, @@ -1375,12 +1472,19 @@ def prowler(): output_options.output_filename, output_options.output_directory, compliance_overview, + universal_frameworks=universal_frameworks, + provider=provider, + output_formats=args.output_formats, ) if compliance_overview: print( f"\nDetailed compliance results are in {Fore.YELLOW}{output_options.output_directory}/compliance/{Style.RESET_ALL}\n" ) + # Promote Prowler Cloud as the last thing the user sees after the results + if not args.no_banner and not args.only_logs: + print_prowler_cloud_banner(provider) + # If custom checks were passed, remove the modules if checks_folder: remove_custom_checks_module(checks_folder, provider) diff --git a/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json b/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json index 7cda08efbb..9a05c1997f 100644 --- a/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json +++ b/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json @@ -109,6 +109,14 @@ ], "Checks": [ "ram_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "ram_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -841,6 +849,14 @@ ], "Checks": [ "sls_logstore_retention_period" + ], + "ConfigRequirements": [ + { + "Check": "sls_logstore_retention_period", + "ConfigKey": "min_log_retention_days", + "Operator": "gte", + "Value": 365 + } ] }, { @@ -1353,6 +1369,14 @@ ], "Checks": [ "rds_instance_sql_audit_retention" + ], + "ConfigRequirements": [ + { + "Check": "rds_instance_sql_audit_retention", + "ConfigKey": "min_rds_audit_retention_days", + "Operator": "gte", + "Value": 180 + } ] }, { @@ -1551,6 +1575,14 @@ ], "Checks": [ "cs_kubernetes_cluster_check_recent" + ], + "ConfigRequirements": [ + { + "Check": "cs_kubernetes_cluster_check_recent", + "ConfigKey": "max_cluster_check_days", + "Operator": "lte", + "Value": 7 + } ] }, { diff --git a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json b/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json deleted file mode 100644 index 060b6e819e..0000000000 --- a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json +++ /dev/null @@ -1,7305 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "alibabacloud", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "sls_security_group_changes_alert_enabled", - "sls_vpc_changes_alert_enabled", - "sls_vpc_network_route_changes_alert_enabled", - "sls_customer_created_cmk_changes_alert_enabled", - "sls_cloud_firewall_changes_alert_enabled", - "sls_management_console_authentication_failures_alert_enabled", - "sls_rds_instance_configuration_changes_alert_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted", - "rds_instance_tde_enabled", - "rds_instance_ssl_enabled", - "oss_bucket_secure_transport_enabled" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_tde_key_custom" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_tde_key_custom" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "ram_rotate_access_key_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_all_assets_agent_installed" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_all_assets_agent_installed" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_not_publicly_accessible", - "rds_instance_no_public_access_whitelist" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_secure_transport_enabled", - "rds_instance_ssl_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "sls_logstore_retention_period", - "rds_instance_sql_audit_retention" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_not_publicly_accessible", - "rds_instance_no_public_access_whitelist", - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted", - "rds_instance_tde_enabled" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "ram_password_policy_minimum_length", - "ram_password_policy_lowercase", - "ram_password_policy_uppercase", - "ram_password_policy_number", - "ram_password_policy_symbol", - "ram_password_policy_password_reuse_prevention", - "ram_password_policy_max_password_age", - "ram_password_policy_max_login_attempts" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_attached_only_to_group_or_roles" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_no_administrative_privileges" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused", - "ram_rotate_access_key_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_attached_only_to_group_or_roles", - "ram_no_root_access_key" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "ram_no_root_access_key", - "ram_policy_no_administrative_privileges" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "ram_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "ram_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "ram_password_policy_minimum_length", - "ram_password_policy_password_reuse_prevention", - "ram_password_policy_max_password_age" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_no_administrative_privileges", - "cs_kubernetes_rbac_enabled" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_secure_transport_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "vpc_flow_logs_enabled", - "ecs_securitygroup_restrict_ssh_internet", - "ecs_securitygroup_restrict_rdp_internet" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_latest_os_patches_applied", - "ecs_instance_endpoint_protection_installed" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "cs_kubernetes_network_policy_enabled", - "cs_kubernetes_private_cluster_enabled", - "ecs_instance_no_legacy_network" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "sls_cloud_firewall_changes_alert_enabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible", - "sls_logstore_retention_period" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_notification_enabled_high_risk", - "sls_unauthorized_api_calls_alert_enabled", - "sls_root_account_usage_alert_enabled", - "sls_management_console_signin_without_mfa_alert_enabled" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "sls_unauthorized_api_calls_alert_enabled", - "sls_root_account_usage_alert_enabled", - "sls_management_console_signin_without_mfa_alert_enabled", - "sls_ram_role_changes_alert_enabled", - "sls_security_group_changes_alert_enabled", - "sls_vpc_changes_alert_enabled", - "sls_vpc_network_route_changes_alert_enabled", - "sls_management_console_authentication_failures_alert_enabled", - "sls_customer_created_cmk_changes_alert_enabled", - "sls_oss_bucket_policy_changes_alert_enabled", - "sls_oss_permission_changes_alert_enabled", - "sls_cloud_firewall_changes_alert_enabled", - "sls_rds_instance_configuration_changes_alert_enabled" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled", - "vpc_flow_logs_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled", - "vpc_flow_logs_enabled", - "oss_bucket_logging_enabled", - "rds_instance_sql_audit_enabled", - "cs_kubernetes_log_service_enabled", - "rds_instance_postgresql_log_connections_enabled", - "rds_instance_postgresql_log_disconnections_enabled", - "rds_instance_postgresql_log_duration_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "sls_customer_created_cmk_changes_alert_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_notification_enabled_high_risk" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_endpoint_protection_installed" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_latest_os_patches_applied" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled", - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json b/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json index ca7a030dc2..0bfd47df7e 100644 --- a/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json +++ b/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json @@ -47,6 +47,14 @@ "Checks": [ "ram_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "ram_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } + ], "Attributes": [ { "Title": "Inactive users disabled for console access", @@ -399,6 +407,14 @@ "LevelOfRisk": 3, "Weight": 10 } + ], + "ConfigRequirements": [ + { + "Check": "cs_kubernetes_cluster_check_weekly", + "ConfigKey": "max_cluster_check_days", + "Operator": "lte", + "Value": 7 + } ] }, { @@ -695,6 +711,14 @@ "Checks": [ "rds_instance_sql_audit_retention" ], + "ConfigRequirements": [ + { + "Check": "rds_instance_sql_audit_retention", + "ConfigKey": "min_rds_audit_retention_days", + "Operator": "gte", + "Value": 180 + } + ], "Attributes": [ { "Title": "RDS SQL audit retention configured", diff --git a/prowler/compliance/aws/asd_essential_eight_aws.json b/prowler/compliance/aws/asd_essential_eight_aws.json new file mode 100644 index 0000000000..dd39c44268 --- /dev/null +++ b/prowler/compliance/aws/asd_essential_eight_aws.json @@ -0,0 +1,1306 @@ +{ + "Framework": "ASD-Essential-Eight", + "Name": "ASD Essential Eight Maturity Model - Maturity Level One (AWS)", + "Version": "Nov 2023", + "Provider": "AWS", + "Description": "Literal mapping of the Australian Signals Directorate (ASD) Essential Eight Maturity Model (Last updated November 2023, Appendix A: Maturity Level One) to AWS infrastructure checks. Each Requirement is one literal clause from the ASD document, in canonical document order: (1) Patch applications, (2) Patch operating systems, (3) Multi-factor authentication, (4) Restrict administrative privileges, (5) Application control, (6) Restrict Microsoft Office macros, (7) User application hardening, (8) Regular backups. The Essential Eight is designed to protect internet-connected information technology networks (workstations, internet-facing servers, online services). Several controls are inherently endpoint or procedural and have no AWS infrastructure equivalent - those clauses are flagged with CloudApplicability `non-applicable` and AssessmentStatus `Manual`. ML2 and ML3 are out of scope of this framework.", + "Requirements": [ + { + "Id": "E8-1.1", + "Description": "An automated method of asset discovery is used at least fortnightly to support the detection of assets for subsequent vulnerability scanning activities.", + "Checks": [ + "ec2_instance_managed_by_ssm", + "config_recorder_all_regions_enabled", + "inspector2_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Shadow IT", + "Unmanaged vulnerable assets" + ], + "Description": "Asset discovery in AWS is delivered by AWS Config (resource recorder), SSM Inventory and Inspector v2's continuous coverage. The fortnightly cadence itself is not directly observable from AWS APIs - what is observable is whether the discovery mechanisms are enabled.", + "RationaleStatement": "Vulnerability scanning is only as good as the asset coverage it has. Continuous AWS-native discovery is the cloud equivalent of fortnightly asset discovery.", + "ImpactStatement": "Cadence verification is procedural - Prowler verifies the discovery mechanism is enabled, not that it ran in the last fortnight.", + "RemediationProcedure": "Enable AWS Config recorders in every region. Ensure all EC2 instances are managed by SSM. Enable Inspector v2.", + "AuditProcedure": "Verify the listed checks pass.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 1.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.2", + "Description": "A vulnerability scanner with an up-to-date vulnerability database is used for vulnerability scanning activities.", + "Checks": [ + "inspector2_is_enabled" + ], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Stale vulnerability data", + "Missed CVE coverage" + ], + "Description": "Inspector v2 is AWS's managed vulnerability scanner; AWS keeps its vulnerability database up to date.", + "RationaleStatement": "An out-of-date scanner cannot detect newly disclosed vulnerabilities.", + "ImpactStatement": "Coverage is limited to AWS-resident workloads (EC2, ECR, Lambda). Endpoints (workstations, browsers, Office) are out of scope.", + "RemediationProcedure": "Enable Inspector v2 across all regions and ensure all eligible resources are enrolled.", + "AuditProcedure": "Verify Inspector v2 is enabled.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 2.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.3", + "Description": "A vulnerability scanner is used at least daily to identify missing patches or updates for vulnerabilities in online services.", + "Checks": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist" + ], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Late detection of internet-facing vulnerabilities" + ], + "Description": "Inspector v2 performs continuous scanning of AWS-hosted online services (Lambda, ECR-backed containers, EC2 with internet exposure). The 'at least daily' cadence is implicit in continuous scanning.", + "RationaleStatement": "Internet-facing services are weaponised fastest after a vulnerability is disclosed.", + "ImpactStatement": "Cadence cannot be evidenced by a single scan; requires Inspector audit history.", + "RemediationProcedure": "Enable Inspector v2 with all scan types relevant to the service surface (EC2, ECR, Lambda).", + "AuditProcedure": "Verify Inspector v2 enabled and review finding age distribution.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 3.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.4", + "Description": "A vulnerability scanner is used at least weekly to identify missing patches or updates for vulnerabilities in office productivity suites, web browsers and their extensions, email clients, PDF software, and security products.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Endpoint application exploitation" + ], + "Description": "This clause targets endpoint-resident applications (Office, browsers, email, PDF, security products). AWS infrastructure scans cannot evidence endpoint vulnerability scanning.", + "RationaleStatement": "Endpoint applications are major attack vectors but live outside AWS infrastructure scope.", + "ImpactStatement": "Evidence must come from endpoint-management vulnerability scanners (e.g. Defender, Qualys, Tenable).", + "RemediationProcedure": "Run an endpoint vulnerability scanner with weekly cadence covering Office, browsers, email, PDF and security products.", + "AuditProcedure": "Manual review of endpoint vulnerability scanning evidence.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 4. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.5", + "Description": "Patches, updates or other vendor mitigations for vulnerabilities in online services are applied within 48 hours of release when vulnerabilities are assessed as critical by vendors or when working exploits exist.", + "Checks": [ + "inspector2_active_findings_exist", + "awslambda_function_using_supported_runtimes", + "rds_instance_deprecated_engine_version", + "rds_instance_extended_support" + ], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Active exploitation of disclosed CVEs", + "N-day exploit kits" + ], + "Description": "AWS managed services (RDS, ElastiCache, Lambda, ECS Fargate, etc.) consume vendor patches transparently when auto-upgrade is enabled. The 48-hour SLA itself cannot be evidenced from AWS APIs - it requires correlating Inspector v2 first-seen timestamps with remediation timestamps.", + "RationaleStatement": "Critical vulnerabilities with working exploits are weaponised within hours.", + "ImpactStatement": "Prowler verifies the absence of active critical findings (a proxy outcome) and the absence of unsupported runtimes (a precondition); SLA timing requires manual correlation.", + "RemediationProcedure": "Maintain auto-upgrade flags on managed services. Configure Inspector v2 alerting for critical findings on internet-facing resources with 48-hour escalation.", + "AuditProcedure": "Manual review of Inspector finding age + remediation timestamps for internet-facing critical findings.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 5. SLA timing out of Prowler scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.6", + "Description": "Patches, updates or other vendor mitigations for vulnerabilities in online services are applied within two weeks of release when vulnerabilities are assessed as non-critical by vendors and no working exploits exist.", + "Checks": [ + "ssm_managed_compliant_patching", + "awslambda_function_using_supported_runtimes", + "rds_instance_minor_version_upgrade_enabled", + "rds_cluster_minor_version_upgrade_enabled", + "elasticache_redis_cluster_auto_minor_version_upgrades", + "memorydb_cluster_auto_minor_version_upgrades", + "dms_instance_minor_version_upgrade_enabled", + "mq_broker_auto_minor_version_upgrades", + "redshift_cluster_automatic_upgrades", + "kafka_cluster_uses_latest_version", + "opensearch_service_domains_updated_to_the_latest_service_software_version", + "ecs_service_fargate_latest_platform_version" + ], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Accumulated technical debt", + "Eventual exploitation of stale CVEs" + ], + "Description": "Auto-upgrade flags on AWS managed services (RDS, Aurora, ElastiCache, MemoryDB, DMS, MQ, Redshift, OpenSearch, MSK, Fargate) and SSM Patch Manager for self-managed compute deliver the underlying capability. SLA timing itself is not surfaced.", + "RationaleStatement": "Two weeks is the standard ASD cadence for non-critical patching of internet-facing services.", + "ImpactStatement": "SLA window evidence requires patch logs / change-management correlation, not in Prowler scope.", + "RemediationProcedure": "Enable auto minor version upgrade on all managed data services. Enable SSM Patch Manager on all EC2.", + "AuditProcedure": "Run all listed checks. Manually review patch deployment timeline.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 6.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.7", + "Description": "Patches, updates or other vendor mitigations for vulnerabilities in office productivity suites, web browsers and their extensions, email clients, PDF software, and security products are applied within two weeks of release.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Endpoint application exploitation" + ], + "Description": "Endpoint-resident applications (Office, browsers, email clients, PDF, security products) are out of AWS infrastructure scope.", + "RationaleStatement": "Same as 1.4 - this is endpoint-management territory.", + "ImpactStatement": "Evidence must come from endpoint patch-management tooling.", + "RemediationProcedure": "Use endpoint patch-management (Intune, SCCM, third-party MDM) to enforce two-week SLA on these application classes.", + "AuditProcedure": "Manual review of endpoint patch reports.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 7. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.8", + "Description": "Online services that are no longer supported by vendors are removed.", + "Checks": [ + "awslambda_function_using_supported_runtimes", + "rds_instance_deprecated_engine_version", + "rds_instance_extended_support", + "eks_cluster_uses_a_supported_version", + "ecs_service_fargate_latest_platform_version", + "kafka_cluster_uses_latest_version", + "opensearch_service_domains_updated_to_the_latest_service_software_version" + ], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "MitigatedThreats": [ + "Use of unsupported software", + "Long-tail vulnerability accumulation" + ], + "Description": "Online services in AWS scope: Lambda runtimes, RDS engines, EKS Kubernetes versions, ECS/Fargate platform versions, Kafka, OpenSearch. Prowler can detect deprecated/unsupported versions across all of these.", + "RationaleStatement": "Unsupported services no longer receive security patches.", + "ImpactStatement": "", + "RemediationProcedure": "Migrate Lambda functions off deprecated runtimes; remove RDS instances on Extended Support or deprecated engines; upgrade EKS clusters to supported Kubernetes versions; use latest Fargate platform version; keep Kafka and OpenSearch at supported versions.", + "AuditProcedure": "Run all listed checks.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 8.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-1.9", + "Description": "Office productivity suites, web browsers and their extensions, email clients, PDF software, Adobe Flash Player, and security products that are no longer supported by vendors are removed.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Patch applications", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Endpoint compromise via unsupported applications" + ], + "Description": "Endpoint-resident applications. Out of AWS infrastructure scope.", + "RationaleStatement": "Adobe Flash Player end-of-life is the canonical example - removal must be enforced at the endpoint.", + "ImpactStatement": "Evidence must come from endpoint software inventory.", + "RemediationProcedure": "Inventory endpoint applications; enforce removal of EOL software via endpoint management.", + "AuditProcedure": "Manual review of endpoint inventory.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch applications - clause 9. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.1", + "Description": "An automated method of asset discovery is used at least fortnightly to support the detection of assets for subsequent vulnerability scanning activities.", + "Checks": [ + "ec2_instance_managed_by_ssm", + "config_recorder_all_regions_enabled", + "inspector2_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "MitigatedThreats": [ + "Unmanaged hosts", + "OS-level shadow IT" + ], + "Description": "Same asset-discovery clause as 1.1, applied here to operating systems. AWS Config + SSM Inventory continuously discovers EC2 instances and their OS metadata; Inspector v2 enrols them automatically.", + "RationaleStatement": "OS vulnerability scanning depends on asset coverage.", + "ImpactStatement": "", + "RemediationProcedure": "Enable AWS Config in every region; ensure all EC2 are SSM-managed; enable Inspector v2.", + "AuditProcedure": "Run all listed checks.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 1.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.2", + "Description": "A vulnerability scanner with an up-to-date vulnerability database is used for vulnerability scanning activities.", + "Checks": [ + "inspector2_is_enabled" + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "MitigatedThreats": [ + "Stale OS vulnerability data" + ], + "Description": "Inspector v2 is AWS's managed OS vulnerability scanner with continuously updated database.", + "RationaleStatement": "Same rationale as 1.2 applied to OS scanning.", + "ImpactStatement": "", + "RemediationProcedure": "Enable Inspector v2 in all regions.", + "AuditProcedure": "Verify Inspector v2 is enabled.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 2.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.3", + "Description": "A vulnerability scanner is used at least daily to identify missing patches or updates for vulnerabilities in operating systems of internet-facing servers and internet-facing network devices.", + "Checks": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist" + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Late detection of OS vulnerabilities on exposed hosts" + ], + "Description": "Internet-facing servers in AWS map to EC2 with public IP / behind public ALB / NLB. Inspector v2 performs continuous OS scanning. Network devices (firewalls, routers) inside customer VPC are typically appliance AMIs - scanned by Inspector when SSM-managed.", + "RationaleStatement": "Internet-facing OS surface is the primary entry point for opportunistic attacks.", + "ImpactStatement": "Daily cadence implicit in continuous scanning; not directly verifiable.", + "RemediationProcedure": "Enable Inspector v2 EC2 scanning. Ensure network appliances are SSM-managed where possible.", + "AuditProcedure": "Verify Inspector v2 enabled with EC2 scan type. Manually verify cadence via finding history.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 3.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.4", + "Description": "A vulnerability scanner is used at least fortnightly to identify missing patches or updates for vulnerabilities in operating systems of workstations, non-internet-facing servers and non-internet-facing network devices.", + "Checks": [ + "inspector2_is_enabled" + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Lateral movement via unpatched internal hosts" + ], + "Description": "Workstations are out of AWS infrastructure scope. Non-internet-facing EC2 servers and appliance AMIs are scanned by Inspector v2 when SSM-managed.", + "RationaleStatement": "Internal hosts still carry lateral-movement risk.", + "ImpactStatement": "Workstation scanning is endpoint-management territory.", + "RemediationProcedure": "Ensure internal EC2 are SSM-managed and Inspector v2 covers them. For workstations, use endpoint vulnerability tooling.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 4.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.5", + "Description": "Patches, updates or other vendor mitigations for vulnerabilities in operating systems of internet-facing servers and internet-facing network devices are applied within 48 hours of release when vulnerabilities are assessed as critical by vendors or when working exploits exist.", + "Checks": [ + "inspector2_active_findings_exist" + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Active exploitation of OS-level CVEs", + "Public-facing intrusion" + ], + "Description": "SLA for critical OS patches on internet-facing hosts. AWS APIs do not surface time-to-patch.", + "RationaleStatement": "Critical OS CVEs on internet-facing surface are weaponised fastest.", + "ImpactStatement": "Requires correlation between Inspector finding age and SSM patch logs.", + "RemediationProcedure": "Configure Inspector v2 critical-finding alerting; route to a 48-hour remediation queue. Configure SSM Patch Manager with appropriate baselines.", + "AuditProcedure": "Manual: review patch deployment timestamps for internet-facing critical OS findings.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 5. SLA timing out of Prowler scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.6", + "Description": "Patches, updates or other vendor mitigations for vulnerabilities in operating systems of internet-facing servers and internet-facing network devices are applied within two weeks of release when vulnerabilities are assessed as non-critical by vendors and no working exploits exist.", + "Checks": [ + "ssm_managed_compliant_patching", + "ec2_instance_managed_by_ssm" + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Stale OS exposure", + "Background CVE accumulation" + ], + "Description": "Two-week SLA for non-critical OS patches on internet-facing hosts. SSM Patch Manager delivers the capability; SLA evidence is procedural.", + "RationaleStatement": "Two-week cadence is the ASD baseline for non-critical OS patching of internet-facing hosts.", + "ImpactStatement": "SLA window evidence requires patch logs.", + "RemediationProcedure": "Configure SSM Patch Manager with a fortnightly maintenance window for internet-facing EC2.", + "AuditProcedure": "Verify SSM patch compliance + manually verify deployment cadence.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 6.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.7", + "Description": "Patches, updates or other vendor mitigations for vulnerabilities in operating systems of workstations, non-internet-facing servers and non-internet-facing network devices are applied within one month of release.", + "Checks": [ + "ssm_managed_compliant_patching", + "ec2_instance_managed_by_ssm" + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Lateral movement", + "Internal compromise" + ], + "Description": "One-month SLA for OS patches on workstations and non-internet-facing hosts. AWS infrastructure covers non-internet-facing EC2 via SSM Patch Manager. Workstations are endpoint-management territory.", + "RationaleStatement": "Internal hosts have a longer SLA but still need regular patching.", + "ImpactStatement": "Workstation patch evidence is endpoint-side.", + "RemediationProcedure": "Configure SSM Patch Manager with a monthly maintenance window for non-internet-facing EC2.", + "AuditProcedure": "Verify SSM patch compliance for non-internet-facing instances.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 7.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-2.8", + "Description": "Operating systems that are no longer supported by vendors are replaced.", + "Checks": [ + "ec2_instance_with_outdated_ami", + "ec2_instance_older_than_specific_days", + "ec2_ami_public", + "eks_cluster_uses_a_supported_version" + ], + "Attributes": [ + { + "Section": "2 Patch operating systems", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "MitigatedThreats": [ + "Use of unsupported operating systems", + "Loss of security updates" + ], + "Description": "Detect EC2 instances on outdated AMIs, EKS clusters on unsupported Kubernetes versions, and AMIs that may be unsupported.", + "RationaleStatement": "Unsupported OS receives no further security updates.", + "ImpactStatement": "Prowler verifies AMI freshness and EKS version; the operator must define the policy threshold for `ec2_instance_older_than_specific_days`.", + "RemediationProcedure": "Replace EC2 instances on unsupported AMIs. Upgrade EKS clusters to supported Kubernetes versions.", + "AuditProcedure": "Run all listed checks.", + "AdditionalInformation": "ASD Essential Eight ML1 - Patch operating systems - clause 8.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-3.1", + "Description": "Multi-factor authentication is used to authenticate users to their organisation's online services that process, store or communicate their organisation's sensitive data.", + "Checks": [ + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled", + "iam_administrator_access_with_mfa", + "directoryservice_supported_mfa_radius_enabled", + "cloudwatch_log_metric_filter_sign_in_without_mfa" + ], + "Attributes": [ + { + "Section": "3 Multi-factor authentication", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Credential stuffing", + "Password reuse compromise", + "Unauthorised access to internal services" + ], + "Description": "MFA on the organisation's own AWS-hosted online services that handle sensitive data. Prowler verifies MFA on root, IAM users with console access, IAM admins and Directory Service. Whether a given service handles 'sensitive data' is a classification call the operator must make.", + "RationaleStatement": "MFA prevents credential-only compromise.", + "ImpactStatement": "Sensitive-data classification is procedural; Prowler verifies MFA presence on the underlying identity surface.", + "RemediationProcedure": "Enable hardware MFA on root and admin IAM users. Require MFA for all IAM users with console access. Enable MFA on Directory Service. Configure CloudWatch metric filter for non-MFA sign-ins.", + "AuditProcedure": "Run the listed checks. Manually classify which services hold sensitive data and confirm MFA coverage.", + "AdditionalInformation": "ASD Essential Eight ML1 - Multi-factor authentication - clause 1.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-3.2", + "Description": "Multi-factor authentication is used to authenticate users to third-party online services that process, store or communicate their organisation's sensitive data.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Multi-factor authentication", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Third-party SaaS account compromise", + "Sensitive data leakage via SaaS" + ], + "Description": "Third-party online services (SaaS providers, external platforms) are outside the AWS account. AWS infrastructure scans cannot evidence MFA enforcement on those services.", + "RationaleStatement": "Third-party services are a major data-exfiltration vector.", + "ImpactStatement": "Evidence must come from each third-party provider's IAM / SSO configuration.", + "RemediationProcedure": "Enforce MFA in the third-party service's identity provider or SSO upstream (e.g. via AWS IAM Identity Center or external IdP).", + "AuditProcedure": "Manual review of third-party service IdP / SSO configuration.", + "AdditionalInformation": "ASD Essential Eight ML1 - Multi-factor authentication - clause 2. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-3.3", + "Description": "Multi-factor authentication (where available) is used to authenticate users to third-party online services that process, store or communicate their organisation's non-sensitive data.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Multi-factor authentication", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Account compromise on lower-sensitivity SaaS" + ], + "Description": "Same as 3.2, applied to non-sensitive third-party services. Out of AWS infrastructure scope.", + "RationaleStatement": "Defence in depth - even non-sensitive services can be staging ground for further attacks.", + "ImpactStatement": "Evidence must come from each third-party provider.", + "RemediationProcedure": "Where the third-party service supports it, enforce MFA upstream via SSO.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Multi-factor authentication - clause 3. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-3.4", + "Description": "Multi-factor authentication is used to authenticate users to their organisation's online customer services that process, store or communicate their organisation's sensitive customer data.", + "Checks": [ + "cognito_user_pool_mfa_enabled" + ], + "Attributes": [ + { + "Section": "3 Multi-factor authentication", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Customer-data exposure via account takeover" + ], + "Description": "Online customer services hosted by the organisation in AWS typically use Cognito. Whether a Cognito user pool serves users handling sensitive customer data is an operator classification.", + "RationaleStatement": "Insider users of customer-facing services need MFA when they handle sensitive customer data.", + "ImpactStatement": "Sensitive-data classification is procedural.", + "RemediationProcedure": "Enable MFA on every Cognito user pool that serves users handling sensitive customer data.", + "AuditProcedure": "Verify Cognito MFA per pool.", + "AdditionalInformation": "ASD Essential Eight ML1 - Multi-factor authentication - clause 4.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-3.5", + "Description": "Multi-factor authentication is used to authenticate users to third-party online customer services that process, store or communicate their organisation's sensitive customer data.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Multi-factor authentication", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Customer-data exposure via third-party platform" + ], + "Description": "Third-party customer-facing services. Out of AWS infrastructure scope.", + "RationaleStatement": "Sensitive customer data on third-party platforms still needs MFA.", + "ImpactStatement": "Evidence must come from the third-party provider.", + "RemediationProcedure": "Enforce MFA in the third-party customer service's IdP.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Multi-factor authentication - clause 5. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-3.6", + "Description": "Multi-factor authentication is used to authenticate customers to online customer services that process, store or communicate sensitive customer data.", + "Checks": [ + "cognito_user_pool_mfa_enabled" + ], + "Attributes": [ + { + "Section": "3 Multi-factor authentication", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Customer account takeover", + "Customer-data theft" + ], + "Description": "Customer-facing AWS-hosted services typically use Cognito user pools. Prowler can verify MFA is enabled on the pool.", + "RationaleStatement": "Customers handling sensitive data benefit from MFA the same as employees.", + "ImpactStatement": "Cognito MFA can be enforced or optional - the check verifies it is at least configured.", + "RemediationProcedure": "Enable MFA on customer-facing Cognito user pools.", + "AuditProcedure": "Verify Cognito MFA configuration.", + "AdditionalInformation": "ASD Essential Eight ML1 - Multi-factor authentication - clause 6.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-3.7", + "Description": "Multi-factor authentication uses either: something users have and something users know, or something users have that is unlocked by something users know or are.", + "Checks": [ + "iam_user_hardware_mfa_enabled", + "iam_root_hardware_mfa_enabled" + ], + "Attributes": [ + { + "Section": "3 Multi-factor authentication", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "limited", + "MitigatedThreats": [ + "Single-factor compromise", + "Knowledge-only authentication" + ], + "Description": "MFA factor type. AWS IAM API does not surface the underlying authenticator type granularly, only whether MFA is virtual or hardware. Hardware MFA on root + admin is the strongest signal Prowler can give.", + "RationaleStatement": "MFA must combine factor categories - knowledge alone doesn't qualify.", + "ImpactStatement": "Prowler cannot universally verify factor type for every identity. Hardware MFA detection is the closest proxy.", + "RemediationProcedure": "Prefer hardware MFA on privileged identities.", + "AuditProcedure": "Run the listed checks. Manually review IdP audit feed for factor distribution.", + "AdditionalInformation": "ASD Essential Eight ML1 - Multi-factor authentication - clause 7.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-4.1", + "Description": "Requests for privileged access to systems, applications and data repositories are validated when first requested.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Restrict administrative privileges", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Unauthorised privilege grant", + "Insider misuse" + ], + "Description": "Procedural control: privileged-access requests must go through a validation workflow. Prowler does not evaluate change-management workflows.", + "RationaleStatement": "Privileged access without validation is a primary insider-risk vector.", + "ImpactStatement": "Evidence comes from access-review tickets, change management or AWS IAM Identity Center request workflows.", + "RemediationProcedure": "Implement a request/approve workflow for AWS privileged role assumption (e.g. IAM Identity Center permission sets with approval) or rely on an external ITSM workflow.", + "AuditProcedure": "Manual review of change-management evidence.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict administrative privileges - clause 1. Procedural.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-4.2", + "Description": "Privileged users are assigned a dedicated privileged user account to be used solely for duties requiring privileged access.", + "Checks": [ + "iam_user_administrator_access_policy", + "iam_user_two_active_access_key", + "iam_user_no_setup_initial_access_key" + ], + "Attributes": [ + { + "Section": "4 Restrict administrative privileges", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Compromise of dual-purpose accounts", + "Privilege accumulation" + ], + "Description": "Privileged AWS access should be exercised through dedicated identities (separate IAM users / dedicated permission sets), not through a daily-driver identity. Prowler can detect IAM users carrying admin policies but cannot infer whether they are dedicated.", + "RationaleStatement": "Dual-purpose accounts (admin + daily) increase blast radius of credential theft.", + "ImpactStatement": "Prowler flags admin-bearing identities; the operator must verify each is dedicated to privileged duties.", + "RemediationProcedure": "Use IAM Identity Center permission sets or dedicated IAM users with admin policies, separated from daily-driver identities.", + "AuditProcedure": "Run listed checks; manually verify each admin identity is dedicated.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict administrative privileges - clause 2.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-4.3", + "Description": "Privileged user accounts (excluding those explicitly authorised to access online services) are prevented from accessing the internet, email and web services.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Restrict administrative privileges", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "limited", + "MitigatedThreats": [ + "Drive-by compromise of privileged accounts", + "Phishing-driven privileged compromise" + ], + "Description": "Network-level control: privileged accounts must not browse the internet or access email. In AWS this would map to SCPs / VPC egress controls / dedicated jump-host architecture - none directly verifiable by a posture scan.", + "RationaleStatement": "If a privileged account never touches the internet, it cannot be phished or drive-by compromised.", + "ImpactStatement": "Evidence comes from network architecture and SCP review.", + "RemediationProcedure": "Restrict privileged AWS sessions to a hardened bastion / Cloud Workspace with no general internet egress. Apply SCPs that deny privileged roles from launching workloads with unrestricted internet egress.", + "AuditProcedure": "Manual review of SCPs and network architecture.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict administrative privileges - clause 3.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-4.4", + "Description": "Privileged user accounts explicitly authorised to access online services are strictly limited to only what is required for users and services to undertake their duties.", + "Checks": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_allows_privilege_escalation", + "iam_policy_allows_privilege_escalation", + "iam_policy_attached_only_to_group_or_roles", + "iam_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_role_cross_account_readonlyaccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "4 Restrict administrative privileges", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "MitigatedThreats": [ + "Excessive standing privilege", + "Privilege escalation", + "Confused deputy" + ], + "Description": "Least-privilege enforcement on AWS IAM. This is the cleanest cloud-native mapping in the entire framework: Prowler can verify policy scoping, privilege escalation paths and Access Analyzer findings.", + "RationaleStatement": "Strictly-scoped privileged access reduces blast radius of compromise.", + "ImpactStatement": "", + "RemediationProcedure": "Audit and tighten managed/inline policies; remove privilege-escalation paths; attach policies only to groups/roles; enable Access Analyzer and resolve findings.", + "AuditProcedure": "Run all listed checks.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict administrative privileges - clause 4.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-4.5", + "Description": "Privileged users use separate privileged and unprivileged operating environments.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Restrict administrative privileges", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Cross-environment compromise", + "Privileged session hijack" + ], + "Description": "Operating-environment separation is an endpoint / virtualisation control (privileged jump host vs daily workstation). Out of AWS infrastructure scope.", + "RationaleStatement": "Operating-environment isolation prevents malware on the unprivileged side from attacking privileged sessions.", + "ImpactStatement": "Evidence comes from endpoint architecture (PAW / privileged jump hosts / WorkSpaces partitioning).", + "RemediationProcedure": "Provide privileged users with a dedicated PAW or privileged WorkSpaces image.", + "AuditProcedure": "Manual review of endpoint architecture.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict administrative privileges - clause 5. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-4.6", + "Description": "Unprivileged user accounts cannot logon to privileged operating environments.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Restrict administrative privileges", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Privileged-environment lateral access" + ], + "Description": "Endpoint logon-policy control. Out of AWS infrastructure scope.", + "RationaleStatement": "Even read-only access to a privileged environment is a foothold.", + "ImpactStatement": "Evidence comes from endpoint logon policy / Active Directory.", + "RemediationProcedure": "Enforce logon restrictions on privileged endpoints.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict administrative privileges - clause 6. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-4.7", + "Description": "Privileged user accounts (excluding local administrator accounts) cannot logon to unprivileged operating environments.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Restrict administrative privileges", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Privileged-credential exposure on unprivileged hosts" + ], + "Description": "Endpoint logon-policy control. Out of AWS infrastructure scope.", + "RationaleStatement": "Privileged credentials must never be entered on potentially-compromised unprivileged endpoints.", + "ImpactStatement": "Evidence comes from endpoint logon policy / Active Directory.", + "RemediationProcedure": "Enforce that privileged accounts can only logon to PAWs / jump hosts.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict administrative privileges - clause 7. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-5.1", + "Description": "Application control is implemented on workstations.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Application control", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Malware execution on workstations" + ], + "Description": "Application control is fundamentally an endpoint allowlist. AWS infrastructure has no direct equivalent for workstation app control.", + "RationaleStatement": "Application control on workstations is the highest-impact mitigation against malware.", + "ImpactStatement": "Evidence comes from endpoint allowlist tooling (AppLocker, WDAC, third-party EDR).", + "RemediationProcedure": "Deploy AppLocker / Windows Defender Application Control / equivalent on every workstation.", + "AuditProcedure": "Manual review of endpoint app-control coverage.", + "AdditionalInformation": "ASD Essential Eight ML1 - Application control - clause 1. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-5.2", + "Description": "Application control is applied to user profiles and temporary folders used by operating systems, web browsers and email clients.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Application control", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Drop-and-execute from user profile", + "Browser-cache execution", + "Email-attachment execution" + ], + "Description": "Endpoint app-control scope (user profiles, temp folders). Out of AWS infrastructure scope.", + "RationaleStatement": "User-writable folders are the most common malware drop locations.", + "ImpactStatement": "Evidence comes from endpoint app-control configuration.", + "RemediationProcedure": "Configure AppLocker / WDAC rules to cover %APPDATA%, %TEMP%, browser caches, email-client temp folders.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Application control - clause 2. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-5.3", + "Description": "Application control restricts the execution of executables, software libraries, scripts, installers, compiled HTML, HTML applications and control panel applets to an organisation-approved set.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Application control", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Unauthorised executable execution", + "Library hijacking", + "Script-based malware", + "HTA / CHM payload execution" + ], + "Description": "Endpoint allowlisting of executable file types. Out of AWS infrastructure scope.", + "RationaleStatement": "An effective allowlist must cover all execution surfaces, not only .exe.", + "ImpactStatement": "Evidence comes from endpoint app-control rule set.", + "RemediationProcedure": "Define an organisation-approved allowlist covering all listed file types and enforce it in AppLocker / WDAC / equivalent.", + "AuditProcedure": "Manual review of allowlist contents.", + "AdditionalInformation": "ASD Essential Eight ML1 - Application control - clause 3. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-6.1", + "Description": "Microsoft Office macros are disabled for users that do not have a demonstrated business requirement.", + "Checks": [], + "Attributes": [ + { + "Section": "6 Restrict Microsoft Office macros", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Macro-based malware delivery" + ], + "Description": "Endpoint / Microsoft 365 control. Out of AWS infrastructure scope.", + "RationaleStatement": "Most users never need Office macros. Disabling by default removes a major delivery vector.", + "ImpactStatement": "Evidence comes from Microsoft 365 / Group Policy.", + "RemediationProcedure": "Disable macros via Group Policy / Intune / M365 admin policies, with explicit allowlist for business-justified users.", + "AuditProcedure": "Manual review of M365 macro policy.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict Microsoft Office macros - clause 1. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-6.2", + "Description": "Microsoft Office macros in files originating from the internet are blocked.", + "Checks": [], + "Attributes": [ + { + "Section": "6 Restrict Microsoft Office macros", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Internet-sourced macro malware" + ], + "Description": "Endpoint / Microsoft 365 control (Mark-of-the-Web macro blocking). Out of AWS infrastructure scope.", + "RationaleStatement": "Internet-sourced documents are the primary macro-delivery vector.", + "ImpactStatement": "Evidence comes from Microsoft 365 / Group Policy.", + "RemediationProcedure": "Enable 'Block macros from running in Office files from the internet' via Group Policy / Intune.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict Microsoft Office macros - clause 2. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-6.3", + "Description": "Microsoft Office macro antivirus scanning is enabled.", + "Checks": [], + "Attributes": [ + { + "Section": "6 Restrict Microsoft Office macros", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Macro-borne malware not detected at file-open time" + ], + "Description": "Endpoint / Microsoft 365 control (AMSI integration with macros). Out of AWS infrastructure scope.", + "RationaleStatement": "Antivirus scanning of macros at run-time catches polymorphic payloads that static analysis misses.", + "ImpactStatement": "Evidence comes from endpoint AV / AMSI configuration.", + "RemediationProcedure": "Enable AMSI macro scanning via Group Policy.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict Microsoft Office macros - clause 3. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-6.4", + "Description": "Microsoft Office macro security settings cannot be changed by users.", + "Checks": [], + "Attributes": [ + { + "Section": "6 Restrict Microsoft Office macros", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "User-driven macro policy bypass" + ], + "Description": "Endpoint / Microsoft 365 control. Out of AWS infrastructure scope.", + "RationaleStatement": "If users can change macro security settings, the previous controls become advisory.", + "ImpactStatement": "Evidence comes from Group Policy lockdown.", + "RemediationProcedure": "Apply Group Policy that prevents users from changing Office macro security settings.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - Restrict Microsoft Office macros - clause 4. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-7.1", + "Description": "Internet Explorer 11 is disabled or removed.", + "Checks": [], + "Attributes": [ + { + "Section": "7 User application hardening", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Legacy-browser exploitation" + ], + "Description": "Endpoint browser control. Out of AWS infrastructure scope.", + "RationaleStatement": "Internet Explorer 11 is end-of-life and a recurring entry vector.", + "ImpactStatement": "Evidence comes from endpoint software inventory.", + "RemediationProcedure": "Remove or disable IE 11 via endpoint management.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - User application hardening - clause 1. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-7.2", + "Description": "Web browsers do not process Java from the internet.", + "Checks": [], + "Attributes": [ + { + "Section": "7 User application hardening", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Java-applet exploitation" + ], + "Description": "Endpoint browser control. Out of AWS infrastructure scope.", + "RationaleStatement": "Java in browsers is a long-standing exploitation surface.", + "ImpactStatement": "Evidence comes from browser policy enforced via endpoint management.", + "RemediationProcedure": "Block Java in browsers via Group Policy / browser policy templates.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - User application hardening - clause 2. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-7.3", + "Description": "Web browsers do not process web advertisements from the internet.", + "Checks": [], + "Attributes": [ + { + "Section": "7 User application hardening", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Malvertising delivery" + ], + "Description": "Endpoint browser control. Out of AWS infrastructure scope.", + "RationaleStatement": "Malicious advertising is a passive infection vector.", + "ImpactStatement": "Evidence comes from browser ad-blocking enforcement.", + "RemediationProcedure": "Deploy enterprise ad-blocking via browser policy or upstream DNS filtering.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - User application hardening - clause 3. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-7.4", + "Description": "Web browser security settings cannot be changed by users.", + "Checks": [], + "Attributes": [ + { + "Section": "7 User application hardening", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "User-driven browser hardening bypass" + ], + "Description": "Endpoint browser control. Out of AWS infrastructure scope.", + "RationaleStatement": "If users can change browser security settings, the previous controls become advisory.", + "ImpactStatement": "Evidence comes from browser policy enforcement.", + "RemediationProcedure": "Lock browser security settings via Group Policy / browser policy templates.", + "AuditProcedure": "Manual review.", + "AdditionalInformation": "ASD Essential Eight ML1 - User application hardening - clause 4. Out of AWS infrastructure scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-8.1", + "Description": "Backups of data, applications and settings are performed and retained in accordance with business criticality and business continuity requirements.", + "Checks": [ + "backup_plans_exist", + "backup_vaults_exist", + "backup_reportplans_exist", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "rds_instance_backup_enabled", + "dynamodb_tables_pitr_enabled", + "dynamodb_table_protected_by_backup_plan", + "efs_have_backup_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "elasticache_redis_cluster_backup_enabled", + "documentdb_cluster_backup_enabled", + "neptune_cluster_backup_enabled", + "redshift_cluster_automated_snapshot", + "dlm_ebs_snapshot_lifecycle_policy_exists" + ], + "Attributes": [ + { + "Section": "8 Regular backups", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Ransomware", + "Data destruction", + "Recovery failure" + ], + "Description": "Backup coverage across AWS data services. Whether the backup frequency and retention align with business criticality is an operator classification.", + "RationaleStatement": "Backups must exist and align with the criticality of the data they protect.", + "ImpactStatement": "Prowler verifies backup mechanisms exist; criticality alignment is procedural.", + "RemediationProcedure": "Define AWS Backup plans + vaults covering critical services; enable engine-native backups on RDS / ElastiCache / DocumentDB / Neptune / Redshift; enable EFS Backup, EBS Backup, DynamoDB PITR; configure DLM lifecycle.", + "AuditProcedure": "Run all listed checks. Manually verify cadence and retention match criticality.", + "AdditionalInformation": "ASD Essential Eight ML1 - Regular backups - clause 1.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-8.2", + "Description": "Backups of data, applications and settings are synchronised to enable restoration to a common point in time.", + "Checks": [ + "dynamodb_tables_pitr_enabled", + "rds_cluster_backtrack_enabled", + "s3_bucket_object_versioning", + "rds_instance_backup_enabled" + ], + "Attributes": [ + { + "Section": "8 Regular backups", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Inconsistent recovery state", + "Application-level corruption from partial restore" + ], + "Description": "Common-point-in-time recovery is delivered by DynamoDB PITR, Aurora backtrack, RDS automated backups and S3 versioning.", + "RationaleStatement": "Without coordinated PIT, restoration produces an inconsistent state across services.", + "ImpactStatement": "Coordinated multi-service PIT remains an architectural decision.", + "RemediationProcedure": "Enable DynamoDB PITR, Aurora backtrack, RDS automated backups and S3 versioning on all critical data stores.", + "AuditProcedure": "Run listed checks.", + "AdditionalInformation": "ASD Essential Eight ML1 - Regular backups - clause 2.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-8.3", + "Description": "Backups of data, applications and settings are retained in a secure and resilient manner.", + "Checks": [ + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "rds_snapshots_encrypted", + "rds_snapshots_public_access", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "documentdb_cluster_public_snapshot", + "neptune_cluster_public_snapshot", + "neptune_cluster_copy_tags_to_snapshots", + "rds_cluster_copy_tags_to_snapshots", + "rds_instance_copy_tags_to_snapshots", + "s3_bucket_cross_region_replication" + ], + "Attributes": [ + { + "Section": "8 Regular backups", + "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "MitigatedThreats": [ + "Backup tampering", + "Backup exfiltration", + "Regional outage destroying backups" + ], + "Description": "Secure: encryption + no public exposure. Resilient: cross-region replication / multi-AZ vault placement / lifecycle. Prowler covers both.", + "RationaleStatement": "A public or unencrypted backup is a data breach in waiting.", + "ImpactStatement": "", + "RemediationProcedure": "Enforce encryption on Backup vaults and recovery points, RDS snapshots. Block public access on EBS / RDS / DocumentDB / Neptune snapshots account-wide. Enable S3 cross-region replication for critical buckets.", + "AuditProcedure": "Run all listed checks.", + "AdditionalInformation": "ASD Essential Eight ML1 - Regular backups - clause 3.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-8.4", + "Description": "Restoration of data, applications and settings from backups to a common point in time is tested as part of disaster recovery exercises.", + "Checks": [], + "Attributes": [ + { + "Section": "8 Regular backups", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "MitigatedThreats": [ + "Untested recovery path", + "Disaster recovery failure" + ], + "Description": "Restoration testing is a procedural / operational control. AWS APIs do not surface evidence that a restoration test occurred.", + "RationaleStatement": "Untested backups are unreliable backups.", + "ImpactStatement": "Evidence comes from DR exercise reports.", + "RemediationProcedure": "Schedule and execute DR exercises that include backup restoration to a common point in time. Record results.", + "AuditProcedure": "Manual review of DR exercise evidence (last 12 months).", + "AdditionalInformation": "ASD Essential Eight ML1 - Regular backups - clause 4. Procedural; out of Prowler scope.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-8.5", + "Description": "Unprivileged user accounts cannot access backups belonging to other user accounts.", + "Checks": [ + "rds_snapshots_public_access", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "documentdb_cluster_public_snapshot", + "neptune_cluster_public_snapshot" + ], + "Attributes": [ + { + "Section": "8 Regular backups", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Cross-tenant backup access", + "Backup exfiltration via permissive IAM" + ], + "Description": "Cross-account / cross-user backup isolation. Public-snapshot blocking is the strongest cloud-side signal Prowler surfaces. Fine-grained per-user backup access scoping requires manual IAM/Vault policy review.", + "RationaleStatement": "Backups containing other users' data must never be readable by general users.", + "ImpactStatement": "Public-access blocks are partial - true 'cannot access other users' backups' requires IAM scoping review.", + "RemediationProcedure": "Block public access on EBS / RDS / DocumentDB / Neptune snapshots. Apply IAM policies that restrict backup-read APIs to dedicated backup-operator roles.", + "AuditProcedure": "Run listed checks. Manually review backup IAM scoping.", + "AdditionalInformation": "ASD Essential Eight ML1 - Regular backups - clause 5.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + }, + { + "Id": "E8-8.6", + "Description": "Unprivileged user accounts are prevented from modifying and deleting backups.", + "Checks": [], + "Attributes": [ + { + "Section": "8 Regular backups", + "MaturityLevel": "ML1", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "MitigatedThreats": [ + "Backup deletion by compromised user", + "Ransomware-driven backup destruction" + ], + "Description": "Backup immutability against unprivileged accounts. Cloud-native delivery: AWS Backup Vault Lock + IAM/SCP scoping. Prowler does not currently surface Vault Lock state, so this clause is largely operator-evidenced.", + "RationaleStatement": "Without write protection, ransomware that compromises any user can erase backups.", + "ImpactStatement": "Vault Lock is the strongest cloud control here but is not surfaced by Prowler today.", + "RemediationProcedure": "Apply AWS Backup Vault Lock with retention enforcement. Use SCPs to deny `backup:Delete*`, `rds:DeleteDB*Snapshot`, `s3:DeleteObjectVersion`, etc. for non-backup-operator principals.", + "AuditProcedure": "Manual review of Vault Lock + SCP coverage.", + "AdditionalInformation": "ASD Essential Eight ML1 - Regular backups - clause 6.", + "References": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model" + } + ] + } + ] +} diff --git a/prowler/compliance/aws/aws_account_security_onboarding_aws.json b/prowler/compliance/aws/aws_account_security_onboarding_aws.json index 1d038537f0..1910bac223 100644 --- a/prowler/compliance/aws/aws_account_security_onboarding_aws.json +++ b/prowler/compliance/aws/aws_account_security_onboarding_aws.json @@ -37,6 +37,26 @@ "guardduty_is_enabled", "accessanalyzer_enabled", "macie_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -259,6 +279,20 @@ "Checks": [ "guardduty_is_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -514,6 +548,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -530,6 +572,20 @@ "securityhub_enabled", "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -666,6 +722,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -680,6 +744,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -694,6 +766,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -708,6 +788,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -722,6 +810,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -736,6 +832,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -762,6 +866,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -777,6 +889,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -792,6 +912,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -807,6 +935,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -822,6 +958,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -837,6 +981,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -852,6 +1004,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -867,6 +1027,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -882,6 +1050,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -897,6 +1073,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -912,6 +1096,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/aws_ai_security_framework_aws.json b/prowler/compliance/aws/aws_ai_security_framework_aws.json new file mode 100644 index 0000000000..9b87f7464d --- /dev/null +++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json @@ -0,0 +1,1198 @@ +{ + "Framework": "AWS-AI-Security-Framework", + "Name": "AWS AI Security Framework", + "Version": "1.0", + "Provider": "AWS", + "Description": "Security compliance framework based on the AWS AI Security Framework blog post (2025). Organizes controls across three security layers (Infrastructure, Identity & Data, AI Application), three deployment phases (Foundational, Enhanced, Advanced), and three AI use cases (AI that Answers, AI that Connects, AI that Acts). Maps existing Prowler checks to AI workload security requirements and identifies gaps requiring new checks.", + "Requirements": [ + { + "Id": "AISF-INFRA-01", + "Description": "Ensure VPC endpoints provide private connectivity for Bedrock APIs, preventing AI traffic from traversing the public internet.", + "Name": "Bedrock VPC Private Connectivity", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Network Isolation", + "Service": "bedrock", + "Type": "Automated" + } + ], + "Checks": [ + "bedrock_vpc_endpoints_configured" + ] + }, + { + "Id": "AISF-INFRA-02", + "Description": "Ensure VPCs have Network Firewall enabled to inspect and filter AI workload traffic, with logging, multi-AZ deployment, and proper default actions for both full and fragmented packets.", + "Name": "Network Firewall for AI Workloads", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Network Firewall", + "Service": "networkfirewall", + "Type": "Automated" + } + ], + "Checks": [ + "networkfirewall_in_all_vpc", + "networkfirewall_logging_enabled", + "networkfirewall_multi_az", + "networkfirewall_policy_default_action_full_packets", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_rule_group_associated", + "networkfirewall_deletion_protection" + ] + }, + { + "Id": "AISF-INFRA-03", + "Description": "Ensure WAFv2 Web ACLs are configured with rules and logging to protect AI application endpoints from HTTP-based attacks including prompt injection patterns at the perimeter.", + "Name": "WAF Protection for AI Endpoints", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Web Application Firewall", + "Service": "wafv2", + "Type": "Automated" + } + ], + "Checks": [ + "wafv2_webacl_with_rules", + "wafv2_webacl_logging_enabled", + "wafv2_webacl_rule_logging_enabled", + "apigateway_restapi_waf_acl_attached", + "cognito_user_pool_waf_acl_attached" + ] + }, + { + "Id": "AISF-INFRA-04", + "Description": "Ensure AWS Shield Advanced is enabled to protect internet-facing AI application infrastructure from DDoS attacks.", + "Name": "DDoS Protection for AI Infrastructure", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "DDoS Protection", + "Service": "shield", + "Type": "Automated" + } + ], + "Checks": [ + "shield_advanced_protection_in_cloudfront_distributions", + "shield_advanced_protection_in_internet_facing_load_balancers", + "shield_advanced_protection_in_classic_load_balancers", + "shield_advanced_protection_in_route53_hosted_zones", + "shield_advanced_protection_in_associated_elastic_ips", + "shield_advanced_protection_in_global_accelerators" + ] + }, + { + "Id": "AISF-INFRA-05", + "Description": "Ensure all data at rest is encrypted with AES-256 across AI workload storage including S3 buckets, EBS volumes, RDS instances, SageMaker notebooks, and Bedrock prompts using customer-managed KMS keys where possible.", + "Name": "Encryption at Rest for AI Data", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Encryption at Rest", + "Service": "kms", + "Type": "Automated" + } + ], + "Checks": [ + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "ec2_ebs_default_encryption", + "ec2_ebs_volume_encryption", + "rds_instance_storage_encrypted", + "sagemaker_notebook_instance_encryption_enabled", + "sagemaker_training_jobs_volume_and_output_encryption_enabled", + "bedrock_model_invocation_logs_encryption_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled", + "sqs_queues_server_side_encryption_enabled" + ] + }, + { + "Id": "AISF-INFRA-06", + "Description": "Ensure all data in transit uses TLS 1.2 or higher, including API communications, inter-container traffic for ML training, and connections between AI application components.", + "Name": "Encryption in Transit for AI Workloads", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Encryption in Transit", + "Service": "multiple", + "Type": "Automated" + } + ], + "Checks": [ + "s3_bucket_secure_transport_policy", + "sagemaker_training_jobs_intercontainer_encryption_enabled", + "elbv2_ssl_listeners", + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "opensearch_service_domains_https_communications_enforced", + "opensearch_service_domains_node_to_node_encryption_enabled", + "apigateway_restapi_client_certificate_enabled" + ] + }, + { + "Id": "AISF-INFRA-07", + "Description": "Ensure customer-managed KMS keys are in use, rotation is enabled, and keys are not scheduled for unintentional deletion to maintain control over AI data encryption.", + "Name": "Customer-Managed Key Governance", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Key Management", + "Service": "kms", + "Type": "Automated" + } + ], + "Checks": [ + "kms_cmk_are_used", + "kms_cmk_rotation_enabled", + "kms_cmk_not_deleted_unintentionally", + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "AISF-INFRA-08", + "Description": "Ensure VPC endpoints enforce trust boundaries, subnets do not assign public IPs by default, and flow logs are enabled for all VPCs hosting AI workloads.", + "Name": "VPC Security for AI Workloads", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "VPC Security", + "Service": "vpc", + "Type": "Automated" + } + ], + "Checks": [ + "vpc_flow_logs_enabled", + "vpc_subnet_no_public_ip_by_default", + "vpc_subnet_separate_private_public", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_endpoint_for_ec2_enabled", + "vpc_peering_routing_tables_with_least_privilege" + ] + }, + { + "Id": "AISF-INFRA-09", + "Description": "Ensure hardware-enforced compute isolation is in use for AI workloads. AWS Nitro System provides isolation with no operator access to customer data during model inference and training.", + "Name": "Hardware-Enforced Compute Isolation", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Compute Isolation", + "Service": "ec2", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-IAM-01", + "Description": "Ensure MFA is enforced for all users accessing AI services, including root account hardware MFA, IAM user MFA for console access, and Cognito user pool MFA for AI application end users.", + "Name": "Multi-Factor Authentication for AI Access", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Authentication", + "Service": "iam", + "Type": "Automated" + } + ], + "Checks": [ + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled", + "iam_administrator_access_with_mfa", + "cognito_user_pool_mfa_enabled", + "cognito_user_pool_advanced_security_enabled", + "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", + "cognito_user_pool_blocks_potential_malicious_sign_in_attempts" + ] + }, + { + "Id": "AISF-IAM-02", + "Description": "Ensure least-privilege access is enforced for all AI service identities. No administrative privileges should be attached to IAM entities that interact with Bedrock, SageMaker, or other AI services.", + "Name": "Least Privilege for AI Identities", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Authorization", + "Service": "iam", + "Type": "Automated" + } + ], + "Checks": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_allows_privilege_escalation", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy", + "iam_group_administrator_access_policy", + "iam_policy_attached_only_to_group_or_roles", + "iam_no_custom_policy_permissive_role_assumption", + "bedrock_full_access_policy_attached", + "bedrock_api_key_no_administrative_privileges" + ] + }, + { + "Id": "AISF-IAM-03", + "Description": "Ensure temporary and scoped credentials are used for AI service access. Long-term access keys should be avoided, rotated within 90 days when necessary, and unused keys should be disabled.", + "Name": "Temporary Scoped Credentials", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Credential Management", + "Service": "iam", + "Type": "Automated" + } + ], + "Checks": [ + "iam_user_with_temporary_credentials", + "iam_rotate_access_key_90_days", + "iam_user_accesskey_unused", + "iam_user_no_setup_initial_access_key", + "iam_user_two_active_access_key", + "iam_user_console_access_unused", + "bedrock_api_key_no_long_term_credentials" + ] + }, + { + "Id": "AISF-IAM-04", + "Description": "Ensure root account is properly secured with no active access keys and minimal usage, as root credentials in AI environments could grant unrestricted access to all AI models, data, and agent configurations.", + "Name": "Root Account Security", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Root Account", + "Service": "iam", + "Type": "Automated" + } + ], + "Checks": [ + "iam_no_root_access_key", + "iam_avoid_root_usage", + "iam_root_credentials_management_enabled", + "cloudwatch_log_metric_filter_root_usage" + ] + }, + { + "Id": "AISF-IAM-05", + "Description": "Ensure IAM roles used for AI services prevent cross-service confused deputy attacks and stale access to Bedrock and SageMaker is reviewed regularly.", + "Name": "AI Service Role Security", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Service Roles", + "Service": "iam", + "Type": "Automated" + } + ], + "Checks": [ + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_role_cross_account_readonlyaccess_policy" + ] + }, + { + "Id": "AISF-IAM-06", + "Description": "Ensure strong password policies are enforced with minimum length, complexity requirements, expiration, and reuse prevention for all human identities accessing AI services.", + "Name": "Password Policy for AI Service Access", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Password Policy", + "Service": "iam", + "Type": "Automated" + } + ], + "Checks": [ + "iam_password_policy_minimum_length_14", + "iam_password_policy_uppercase", + "iam_password_policy_lowercase", + "iam_password_policy_number", + "iam_password_policy_symbol", + "iam_password_policy_reuse_24", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_uppercase", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol" + ] + }, + { + "Id": "AISF-IAM-07", + "Description": "Ensure Cognito user pools are properly configured for AI application user authentication with advanced security features, token revocation, self-registration controls, and WAF protection.", + "Name": "Cognito User Authentication for AI Apps", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "User Authentication", + "Service": "cognito", + "Type": "Automated" + } + ], + "Checks": [ + "cognito_user_pool_mfa_enabled", + "cognito_user_pool_advanced_security_enabled", + "cognito_user_pool_self_registration_disabled", + "cognito_user_pool_deletion_protection_enabled", + "cognito_user_pool_client_token_revocation_enabled", + "cognito_user_pool_client_prevent_user_existence_errors", + "cognito_user_pool_temporary_password_expiration", + "cognito_user_pool_waf_acl_attached", + "cognito_identity_pool_guest_access_disabled" + ] + }, + { + "Id": "AISF-IAM-08", + "Description": "Ensure API Gateway endpoints serving AI applications have proper authorization configured to authenticate and authorize every request to the model layer.", + "Name": "API Authorization for AI Endpoints", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "API Authorization", + "Service": "apigateway", + "Type": "Automated" + } + ], + "Checks": [ + "apigateway_restapi_authorizers_enabled", + "apigateway_restapi_public_with_authorizer", + "apigatewayv2_api_authorizers_enabled" + ] + }, + { + "Id": "AISF-DATA-01", + "Description": "Ensure Amazon Macie is enabled with automated sensitive data discovery to classify and protect enterprise data before it is made available to AI systems through RAG or other patterns.", + "Name": "Data Classification for AI", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Data Classification", + "Service": "macie", + "Type": "Automated" + } + ], + "Checks": [ + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled" + ] + }, + { + "Id": "AISF-DATA-02", + "Description": "Ensure IAM Access Analyzer is enabled to validate access policies and identify unintended access to resources used by AI workloads.", + "Name": "Access Analysis for AI Resources", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Access Analysis", + "Service": "accessanalyzer", + "Type": "Automated" + } + ], + "Checks": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "AISF-DATA-03", + "Description": "Ensure Secrets Manager is used for AI application credentials with automatic rotation enabled, restrictive resource policies, and no public access.", + "Name": "Secrets Management for AI Workloads", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Secrets Management", + "Service": "secretsmanager", + "Type": "Automated" + } + ], + "Checks": [ + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_secret_rotated_periodically", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_not_publicly_accessible", + "secretsmanager_secret_unused" + ] + }, + { + "Id": "AISF-DATA-04", + "Description": "Ensure S3 buckets used for AI training data, model artifacts, RAG knowledge bases, and inference logs have public access blocked, encryption enabled, secure transport enforced, and access logging configured.", + "Name": "S3 Data Protection for AI", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Storage Security", + "Service": "s3", + "Type": "Automated" + } + ], + "Checks": [ + "s3_account_level_public_access_blocks", + "s3_bucket_level_public_access_block", + "s3_bucket_public_access", + "s3_bucket_policy_public_write_access", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "s3_bucket_secure_transport_policy", + "s3_bucket_server_access_logging_enabled", + "s3_bucket_object_versioning", + "s3_bucket_acl_prohibited", + "s3_bucket_cross_account_access" + ] + }, + { + "Id": "AISF-DATA-05", + "Description": "Ensure no secrets or credentials are hardcoded in Lambda functions, ECS task definitions, or EC2 instances used as part of AI application architectures.", + "Name": "No Hardcoded Secrets in AI Workloads", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Secret Hygiene", + "Service": "multiple", + "Type": "Automated" + } + ], + "Checks": [ + "awslambda_function_no_secrets_in_code", + "awslambda_function_no_secrets_in_variables", + "ecs_task_definitions_no_environment_secrets", + "ec2_instance_secrets_user_data", + "cloudwatch_log_group_no_secrets_in_logs" + ] + }, + { + "Id": "AISF-DATA-06", + "Description": "Ensure the AWS Organization has opted out of all AI services data usage and child accounts cannot override this policy, preventing AWS from using customer data for AI service improvement.", + "Name": "AI Services Data Opt-Out", + "Attributes": [ + { + "Section": "Identity and Data Security", + "SubSection": "Data Governance", + "Service": "organizations", + "Type": "Automated" + } + ], + "Checks": [ + "organizations_opt_out_ai_services_policy" + ] + }, + { + "Id": "AISF-AI-01", + "Description": "Ensure Amazon Bedrock has at least one guardrail configured to provide content filtering, prompt injection defense, PII filtering, and topic restrictions for foundation model interactions.", + "Name": "Bedrock Guardrails Configuration", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Content Filtering", + "Service": "bedrock", + "Type": "Automated" + } + ], + "Checks": [ + "bedrock_guardrails_configured" + ] + }, + { + "Id": "AISF-AI-02", + "Description": "Ensure Bedrock guardrails have prompt attack filter strength set to HIGH to detect and block prompt injection attempts, the #1 risk in OWASP Top 10 for LLM Applications.", + "Name": "Prompt Injection Defense", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Prompt Security", + "Service": "bedrock", + "Type": "Automated" + } + ], + "Checks": [ + "bedrock_guardrail_prompt_attack_filter_enabled" + ] + }, + { + "Id": "AISF-AI-03", + "Description": "Ensure Bedrock guardrails block or mask sensitive information (PII) in both model inputs and outputs to prevent data leakage through AI responses.", + "Name": "PII and Sensitive Data Filtering", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Output Filtering", + "Service": "bedrock", + "Type": "Automated" + } + ], + "Checks": [ + "bedrock_guardrail_sensitive_information_filter_enabled" + ] + }, + { + "Id": "AISF-AI-04", + "Description": "Ensure all Bedrock agents have guardrails enabled to protect agent sessions from prompt injection, data exfiltration, and unauthorized actions during agentic workflows.", + "Name": "Agent Guardrail Protection", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Agent Security", + "Service": "bedrock", + "Type": "Automated" + } + ], + "Checks": [ + "bedrock_agent_guardrail_enabled" + ] + }, + { + "Id": "AISF-AI-05", + "Description": "Ensure Bedrock model invocation logging is enabled to maintain an immutable audit trail of all model interactions, enabling incident investigation and behavioral analysis.", + "Name": "Model Invocation Logging", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "AI Audit Logging", + "Service": "bedrock", + "Type": "Automated" + } + ], + "Checks": [ + "bedrock_model_invocation_logging_enabled", + "bedrock_model_invocation_logs_encryption_enabled" + ] + }, + { + "Id": "AISF-AI-06", + "Description": "Ensure CloudTrail is configured to log all Bedrock API calls for security auditing, enabling detection of unauthorized model access, configuration changes, and potential LLM jacking.", + "Name": "Bedrock API Audit Trail", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "AI Audit Logging", + "Service": "cloudtrail", + "Type": "Automated" + } + ], + "Checks": [ + "cloudtrail_threat_detection_llm_jacking" + ] + }, + { + "Id": "AISF-AI-07", + "Description": "Ensure Bedrock prompts are encrypted at rest with customer-managed KMS keys and Prompt Management is used for centralized prompt governance.", + "Name": "Prompt Encryption and Management", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Prompt Management", + "Service": "bedrock", + "Type": "Automated" + } + ], + "Checks": [ + "bedrock_prompt_encrypted_with_cmk", + "bedrock_prompt_management_exists" + ] + }, + { + "Id": "AISF-AI-08", + "Description": "Ensure Bedrock Automated Reasoning Checks are configured to provide formal verification of model responses against source documents, achieving up to 99% verification accuracy against hallucinations.", + "Name": "Automated Reasoning for Output Validation", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Output Validation", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AI-09", + "Description": "Ensure Bedrock Contextual Grounding is configured to validate semantic consistency of model responses against sanctioned source documents, preventing hallucinated or fabricated outputs.", + "Name": "Contextual Grounding for RAG Validation", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Output Validation", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AI-10", + "Description": "Ensure Bedrock Knowledge Bases used for RAG patterns have proper security controls including encryption with customer-managed keys and VPC configuration for private data access.", + "Name": "Knowledge Base Security for RAG", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "RAG Security", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AI-11", + "Description": "Ensure WAF AI Activity Dashboard is configured to monitor and analyze AI-specific traffic patterns, providing visibility into potential attacks targeting AI endpoints.", + "Name": "WAF AI Activity Monitoring", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "AI Traffic Monitoring", + "Service": "wafv2", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AGENT-01", + "Description": "Ensure every AI agent has its own identity with scoped credentials and independent authorization per request, following zero-trust principles. Agent identities must be separate from human user identities.", + "Name": "Agent Identity and Authentication", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Agent Governance", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AGENT-02", + "Description": "Ensure Bedrock AgentCore Cedar Policies enforce provable least-privilege authorization on every tool call and data access made by AI agents.", + "Name": "Agent Least-Privilege Authorization", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Agent Governance", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AGENT-03", + "Description": "Ensure AI agents have behavioral monitoring and observability configured to detect scope violations, anomalous actions, and drift from expected behavior patterns.", + "Name": "Agent Behavioral Monitoring", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Agent Governance", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AGENT-04", + "Description": "Ensure a central agent registry exists to catalog all AI agents, their permissions, data access patterns, and operational boundaries for governance at scale.", + "Name": "Agent Registry and Catalog", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Agent Governance", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-AGENT-05", + "Description": "Ensure human-in-the-loop approval is required for high-consequence agent actions such as financial transactions, data deletion, or privilege changes.", + "Name": "Human Approval for Critical Agent Actions", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Agent Governance", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-ML-01", + "Description": "Ensure SageMaker models have network isolation enabled to prevent models from making unauthorized network calls during inference.", + "Name": "ML Model Network Isolation", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "ML Platform Security", + "Service": "sagemaker", + "Type": "Automated" + } + ], + "Checks": [ + "sagemaker_models_network_isolation_enabled", + "sagemaker_models_vpc_settings_configured" + ] + }, + { + "Id": "AISF-ML-02", + "Description": "Ensure SageMaker notebook instances are secured with encryption, VPC settings, no direct internet access, and root access disabled.", + "Name": "SageMaker Notebook Security", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "ML Platform Security", + "Service": "sagemaker", + "Type": "Automated" + } + ], + "Checks": [ + "sagemaker_notebook_instance_encryption_enabled", + "sagemaker_notebook_instance_vpc_settings_configured", + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "sagemaker_notebook_instance_root_access_disabled" + ] + }, + { + "Id": "AISF-ML-03", + "Description": "Ensure SageMaker training jobs have network isolation, VPC configuration, inter-container traffic encryption, and volume encryption enabled to protect training data and model weights.", + "Name": "ML Training Job Security", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "ML Platform Security", + "Service": "sagemaker", + "Type": "Automated" + } + ], + "Checks": [ + "sagemaker_training_jobs_network_isolation_enabled", + "sagemaker_training_jobs_vpc_settings_configured", + "sagemaker_training_jobs_intercontainer_encryption_enabled", + "sagemaker_training_jobs_volume_and_output_encryption_enabled" + ] + }, + { + "Id": "AISF-ML-04", + "Description": "Ensure SageMaker Model Registry is in use with approved model packages and SSO authentication is configured for SageMaker domains.", + "Name": "Model Governance and Registry", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "ML Platform Security", + "Service": "sagemaker", + "Type": "Automated" + } + ], + "Checks": [ + "sagemaker_models_registry_in_use", + "sagemaker_domain_sso_configured" + ] + }, + { + "Id": "AISF-ML-05", + "Description": "Ensure SageMaker Model Monitor is configured for continuous model quality and bias monitoring, and SageMaker Clarify is used for bias detection in AI/ML workloads.", + "Name": "Model Monitoring and Bias Detection", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "ML Platform Security", + "Service": "sagemaker", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-DETECT-01", + "Description": "Ensure CloudTrail is enabled in all regions with multi-region logging, management event recording, log file validation, and CloudWatch integration for comprehensive AI workload audit trails.", + "Name": "Comprehensive Audit Logging", + "Attributes": [ + { + "Section": "Threat Detection and Monitoring", + "SubSection": "Audit Logging", + "Service": "cloudtrail", + "Type": "Automated" + } + ], + "Checks": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_insights_exist" + ] + }, + { + "Id": "AISF-DETECT-02", + "Description": "Ensure GuardDuty is enabled across all regions with delegated admin, S3 protection, EKS monitoring, Lambda protection, and malware protection to detect AI-specific threat patterns.", + "Name": "GuardDuty Threat Detection", + "Attributes": [ + { + "Section": "Threat Detection and Monitoring", + "SubSection": "Threat Detection", + "Service": "guardduty", + "Type": "Automated" + } + ], + "Checks": [ + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions", + "guardduty_s3_protection_enabled", + "guardduty_eks_audit_log_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "guardduty_lambda_protection_enabled", + "guardduty_rds_protection_enabled", + "guardduty_ec2_malware_protection_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "AISF-DETECT-03", + "Description": "Ensure CloudTrail-based threat detection is monitoring for LLM jacking, privilege escalation, and enumeration activity that could indicate attacks against AI infrastructure.", + "Name": "AI-Specific Threat Detection", + "Attributes": [ + { + "Section": "Threat Detection and Monitoring", + "SubSection": "AI Threat Detection", + "Service": "cloudtrail", + "Type": "Automated" + } + ], + "Checks": [ + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_threat_detection_enumeration" + ] + }, + { + "Id": "AISF-DETECT-04", + "Description": "Ensure Security Hub is enabled with standards and integrations configured to aggregate and prioritize security findings across all AI workload services.", + "Name": "Security Hub Centralized Findings", + "Attributes": [ + { + "Section": "Threat Detection and Monitoring", + "SubSection": "Security Aggregation", + "Service": "securityhub", + "Type": "Automated" + } + ], + "Checks": [ + "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "AISF-DETECT-05", + "Description": "Ensure CloudWatch metric filters and alarms are configured for critical security events including IAM policy changes, unauthorized API calls, console sign-in without MFA, KMS key deletion, and network changes.", + "Name": "Security Event Alerting", + "Attributes": [ + { + "Section": "Threat Detection and Monitoring", + "SubSection": "Security Alerting", + "Service": "cloudwatch", + "Type": "Automated" + } + ], + "Checks": [ + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes" + ] + }, + { + "Id": "AISF-DETECT-06", + "Description": "Ensure Amazon Detective is enabled for AI security incident investigation, providing full decision chain reconstruction from prompt to data access to action.", + "Name": "AI Incident Investigation", + "Attributes": [ + { + "Section": "Threat Detection and Monitoring", + "SubSection": "Incident Investigation", + "Service": "detective", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-DETECT-07", + "Description": "Ensure GuardDuty Extended Threat Detection is enabled for AI-specific patterns including anomalous Bedrock API usage, model access from unusual locations, and potential data exfiltration through AI channels.", + "Name": "GuardDuty AI Threat Patterns", + "Attributes": [ + { + "Section": "Threat Detection and Monitoring", + "SubSection": "AI Threat Detection", + "Service": "guardduty", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-GOV-01", + "Description": "Ensure AWS Config recorder is enabled in all regions to continuously monitor and record AI resource configurations, detect drift, and enforce compliance rules.", + "Name": "Configuration Compliance Monitoring", + "Attributes": [ + { + "Section": "Governance and Compliance", + "SubSection": "Configuration Management", + "Service": "config", + "Type": "Automated" + } + ], + "Checks": [ + "config_recorder_all_regions_enabled", + "config_recorder_using_aws_service_role" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "AISF-GOV-02", + "Description": "Ensure the AWS account is part of an AWS Organization with proper governance controls including SCPs to restrict operations to approved regions and delegated administrators are trusted.", + "Name": "Organization Governance", + "Attributes": [ + { + "Section": "Governance and Compliance", + "SubSection": "Account Governance", + "Service": "organizations", + "Type": "Automated" + } + ], + "Checks": [ + "organizations_account_part_of_organizations", + "organizations_scp_check_deny_regions", + "organizations_delegated_administrators", + "organizations_tags_policies_enabled_and_attached" + ] + }, + { + "Id": "AISF-GOV-03", + "Description": "Ensure security contact information is registered and current for AI workload incident response communication.", + "Name": "Security Contact Information", + "Attributes": [ + { + "Section": "Governance and Compliance", + "SubSection": "Incident Response", + "Service": "account", + "Type": "Automated" + } + ], + "Checks": [ + "account_maintain_current_contact_details", + "account_maintain_different_contact_details_to_security_billing_and_operations", + "account_security_contact_information_is_registered" + ] + }, + { + "Id": "AISF-GOV-04", + "Description": "Ensure AWS Control Tower is enabled with landing zone configured to automate account governance and enforce security baselines across all accounts hosting AI workloads.", + "Name": "Control Tower Automated Governance", + "Attributes": [ + { + "Section": "Governance and Compliance", + "SubSection": "Automated Governance", + "Service": "controltower", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-GOV-05", + "Description": "Maintain an inventory of all AI workloads including approved and shadow AI usage. Document model selections, their governance requirements, and security evaluations.", + "Name": "AI Workload Inventory and Audit", + "Attributes": [ + { + "Section": "Governance and Compliance", + "SubSection": "AI Governance", + "Service": "multiple", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-GOV-06", + "Description": "Ensure AI-specific threat models are developed before production deployment, covering prompt injection, jailbreaks, data exfiltration, model poisoning, and adversarial attacks.", + "Name": "AI Threat Modeling", + "Attributes": [ + { + "Section": "Governance and Compliance", + "SubSection": "AI Governance", + "Service": "multiple", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-GOV-07", + "Description": "Ensure incident response plans include AI-specific scenarios covering prompt injection, model manipulation, data exfiltration through AI, LLM jacking, and agent scope violations.", + "Name": "AI Incident Response Planning", + "Attributes": [ + { + "Section": "Governance and Compliance", + "SubSection": "Incident Response", + "Service": "multiple", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-RUNTIME-01", + "Description": "Ensure EKS clusters used for AI agent runtimes are properly secured with private endpoints, network policies, supported versions, control plane logging, and secrets encryption.", + "Name": "EKS Security for AI Runtimes", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Container Runtime Security", + "Service": "eks", + "Type": "Automated" + } + ], + "Checks": [ + "eks_cluster_not_publicly_accessible", + "eks_cluster_private_nodes_enabled", + "eks_cluster_network_policy_enabled", + "eks_cluster_uses_a_supported_version", + "eks_control_plane_logging_all_types_enabled", + "eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "eks_cluster_deletion_protection_enabled" + ] + }, + { + "Id": "AISF-RUNTIME-02", + "Description": "Ensure ECS tasks used for AI workloads have no public IPs, no privileged containers, read-only root filesystems, logging enabled, and no secrets in environment variables.", + "Name": "ECS Security for AI Workloads", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Container Runtime Security", + "Service": "ecs", + "Type": "Automated" + } + ], + "Checks": [ + "ecs_service_no_assign_public_ip", + "ecs_task_set_no_assign_public_ip", + "ecs_task_definitions_no_privileged_containers", + "ecs_task_definitions_containers_readonly_access", + "ecs_task_definitions_logging_enabled", + "ecs_task_definitions_no_environment_secrets", + "ecs_task_definitions_host_namespace_not_shared", + "ecs_cluster_container_insights_enabled" + ] + }, + { + "Id": "AISF-RUNTIME-03", + "Description": "Ensure Lambda functions used in AI architectures are deployed in VPCs, not publicly accessible, use supported runtimes, have no secrets in code or variables, and use CMK-encrypted environment variables.", + "Name": "Lambda Security for AI Functions", + "Attributes": [ + { + "Section": "Infrastructure Security", + "SubSection": "Serverless Runtime Security", + "Service": "awslambda", + "Type": "Automated" + } + ], + "Checks": [ + "awslambda_function_inside_vpc", + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "awslambda_function_no_secrets_in_code", + "awslambda_function_no_secrets_in_variables", + "awslambda_function_using_supported_runtimes", + "awslambda_function_env_vars_not_encrypted_with_cmk", + "awslambda_function_url_cors_policy", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled" + ] + }, + { + "Id": "AISF-MODEL-01", + "Description": "Perform security evaluation of foundation models before deployment, including assessment of input sanitization, access controls, bias audits, privacy disclosure, data poisoning resilience, adversarial resilience, and prompt injection defenses.", + "Name": "Model Security Evaluation", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Model Governance", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + }, + { + "Id": "AISF-MODEL-02", + "Description": "Ensure model selection is appropriate for the use case with CISO involvement in evaluation. Customer-facing agents require different model security profiles than internal summarization tools.", + "Name": "Model Selection Governance", + "Attributes": [ + { + "Section": "AI Application Security", + "SubSection": "Model Governance", + "Service": "bedrock", + "Type": "Manual" + } + ], + "Checks": [] + } + ] +} diff --git a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json index a64a421c8a..b58a74bcaa 100644 --- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json +++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json @@ -20,6 +20,14 @@ "SectionDescription": "This section contains recommendations for configuring ACM resources.", "Service": "ACM" } + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 30 + } ] }, { @@ -29,6 +37,17 @@ "Checks": [ "acm_certificates_with_secure_key_algorithms" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "ItemId": "ACM.2", @@ -777,6 +796,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "Config.1", @@ -892,6 +919,14 @@ "Checks": [ "documentdb_cluster_backup_enabled" ], + "ConfigRequirements": [ + { + "Check": "documentdb_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7 + } + ], "Attributes": [ { "ItemId": "DocumentDB.2", @@ -1863,7 +1898,9 @@ "Id": "ELB.4", "Name": "Application load balancers should be configured to drop HTTP headers", "Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.", - "Checks": [], + "Checks": [ + "elbv2_alb_drop_invalid_header_fields_enabled" + ], "Attributes": [ { "ItemId": "ELB.4", @@ -1957,6 +1994,14 @@ "SectionDescription": "This section contains recommendations for configuring ELB resources.", "Service": "ELB" } + ], + "ConfigRequirements": [ + { + "Check": "elb_is_in_multiple_az", + "ConfigKey": "elb_min_azs", + "Operator": "gte", + "Value": 2 + } ] }, { @@ -1991,6 +2036,14 @@ "SectionDescription": "This section contains recommendations for configuring ELB resources.", "Service": "ELB" } + ], + "ConfigRequirements": [ + { + "Check": "elbv2_is_in_multiple_az", + "ConfigKey": "elbv2_min_azs", + "Operator": "gte", + "Value": 2 + } ] }, { @@ -2368,6 +2421,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "GuardDuty.1", @@ -2545,6 +2606,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } + ], "Attributes": [ { "ItemId": "IAM.8", @@ -2633,6 +2708,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "ItemId": "IAM.22", @@ -2789,6 +2878,40 @@ "SectionDescription": "This section contains recommendations for configuring Lambda resources.", "Service": "Lambda" } + ], + "ConfigRequirements": [ + { + "Check": "awslambda_function_using_supported_runtimes", + "ConfigKey": "obsolete_lambda_runtimes", + "Operator": "superset", + "Value": [ + "java8", + "go1.x", + "provided", + "python3.6", + "python2.7", + "python3.7", + "python3.8", + "nodejs4.3", + "nodejs4.3-edge", + "nodejs6.10", + "nodejs", + "nodejs8.10", + "nodejs10.x", + "nodejs12.x", + "nodejs14.x", + "nodejs16.x", + "dotnet5.0", + "dotnet6", + "dotnet7", + "dotnetcore1.0", + "dotnetcore2.0", + "dotnetcore2.1", + "dotnetcore3.1", + "ruby2.5", + "ruby2.7" + ] + } ] }, { @@ -2949,6 +3072,14 @@ "Checks": [ "neptune_cluster_backup_enabled" ], + "ConfigRequirements": [ + { + "Check": "neptune_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7 + } + ], "Attributes": [ { "ItemId": "Neptune.5", diff --git a/prowler/compliance/aws/aws_foundational_technical_review_aws.json b/prowler/compliance/aws/aws_foundational_technical_review_aws.json index 691a8ba7f1..9d8e3cfdc8 100644 --- a/prowler/compliance/aws/aws_foundational_technical_review_aws.json +++ b/prowler/compliance/aws/aws_foundational_technical_review_aws.json @@ -176,6 +176,14 @@ "iam_user_with_temporary_credentials", "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json index 496ef9cabd..a025bb3a3c 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json @@ -344,6 +344,9 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", + "bedrock_full_access_policy_attached", "ec2_instance_profile_attached", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", @@ -547,6 +550,7 @@ "apigatewayv2_api_access_logging_enabled", "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_logs_s3_bucket_access_logging_enabled", "directoryservice_directory_log_forwarding_enabled", @@ -581,6 +585,14 @@ "cloudtrail_multi_region_enabled", "vpc_flow_logs_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -642,6 +654,20 @@ "guardduty_no_high_severity_findings", "macie_is_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -671,6 +697,7 @@ "sagemaker_notebook_instance_vpc_settings_configured", "sagemaker_training_jobs_network_isolation_enabled", "sagemaker_training_jobs_vpc_settings_configured", + "bedrock_vpc_endpoints_configured", "vpc_endpoint_connections_trust_boundaries", "vpc_endpoint_services_allowed_principals_trust_boundaries" ] @@ -773,6 +800,14 @@ "guardduty_is_enabled", "vpc_flow_logs_enabled", "apigateway_restapi_authorizers_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1146,6 +1181,9 @@ "elb_insecure_ssl_ciphers", "elb_ssl_listeners", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_ssl_listeners", "s3_bucket_secure_transport_policy" ] diff --git a/prowler/compliance/aws/c5_aws.json b/prowler/compliance/aws/c5_aws.json index cf553bef70..269a5ce308 100644 --- a/prowler/compliance/aws/c5_aws.json +++ b/prowler/compliance/aws/c5_aws.json @@ -382,6 +382,14 @@ "cloudtrail_multi_region_enabled", "config_recorder_all_regions_enabled", "s3_multi_region_access_point_public_access_block" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2234,6 +2242,14 @@ "vpc_different_regions", "autoscaling_group_multiple_az", "storagegateway_gateway_fault_tolerant" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2261,6 +2277,14 @@ "organizations_scp_check_deny_regions", "s3_multi_region_access_point_public_access_block", "vpc_different_regions" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2308,6 +2332,14 @@ "organizations_scp_check_deny_regions", "s3_multi_region_access_point_public_access_block", "vpc_different_regions" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2978,6 +3010,14 @@ "guardduty_is_enabled", "athena_workgroup_enforce_configuration", "shield_advanced_protection_in_global_accelerators" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -3461,6 +3501,7 @@ ], "Checks": [ "kinesis_stream_data_retention_period", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_multi_region_enabled_logging_management_events" ] }, @@ -3480,6 +3521,14 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -3669,6 +3718,7 @@ "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", "bedrock_model_invocation_logging_enabled", "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_logs_s3_bucket_access_logging_enabled", "cloudtrail_multi_region_enabled_logging_management_events", @@ -4297,6 +4347,14 @@ "guardduty_no_high_severity_findings", "guardduty_rds_protection_enabled", "guardduty_s3_protection_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4918,6 +4976,17 @@ "elbv2_nlb_tls_termination_enabled", "transfer_server_in_transit_encryption_enabled", "kafka_cluster_mutual_tls_authentication_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -4944,6 +5013,17 @@ "elbv2_nlb_tls_termination_enabled", "transfer_server_in_transit_encryption_enabled", "kafka_cluster_mutual_tls_authentication_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -5130,6 +5210,8 @@ "iam_support_role_created", "iam_user_with_temporary_credentials", "bedrock_api_key_no_administrative_privileges", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "fms_policy_compliant", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", @@ -5200,6 +5282,8 @@ "iam_support_role_created", "iam_user_with_temporary_credentials", "bedrock_api_key_no_administrative_privileges", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "fms_policy_compliant", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", @@ -5214,6 +5298,14 @@ "rds_instance_default_admin", "accessanalyzer_enabled", "efs_access_point_enforce_user_identity" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5282,6 +5374,9 @@ "iam_password_policy_reuse_24", "cognito_user_pool_blocks_potential_malicious_sign_in_attempts", "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_secret_unused" @@ -5568,6 +5663,7 @@ } ], "Checks": [ + "bedrock_full_access_policy_attached", "iam_policy_allows_privilege_escalation", "iam_role_administratoraccess_policy", "iam_policy_cloudshell_admin_not_attached", @@ -5727,6 +5823,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6090,6 +6194,17 @@ "cloudfront_distributions_origin_traffic_encrypted", "glue_development_endpoints_job_bookmark_encryption_enabled", "cloudtrail_kms_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6186,6 +6301,17 @@ "elb_ssl_listeners_use_acm_certificate", "iam_no_expired_server_certificates_stored", "rds_instance_certificate_expiration" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6297,6 +6423,17 @@ "elb_ssl_listeners_use_acm_certificate", "iam_no_expired_server_certificates_stored", "rds_instance_certificate_expiration" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6350,6 +6487,9 @@ "iam_role_administratoraccess_policy", "iam_role_cross_account_readonlyaccess_policy", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_administrator_access_policy", "iam_user_console_access_unused", @@ -6380,6 +6520,14 @@ "sns_topics_not_publicly_accessible", "sqs_queues_not_publicly_accessible", "vpc_peering_routing_tables_with_least_privilege" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6399,6 +6547,14 @@ "ec2_instance_profile_attached", "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6464,6 +6620,7 @@ "backup_recovery_point_encrypted", "backup_vaults_encrypted", "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_encrypted_with_cmk", "cloudfront_distributions_field_level_encryption_enabled", "cloudfront_distributions_origin_traffic_encrypted", "cloudtrail_kms_encryption_enabled", @@ -6573,6 +6730,17 @@ "kms_cmk_not_multi_region", "kms_key_not_publicly_accessible", "ec2_ebs_volume_encryption" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6721,6 +6889,7 @@ "backup_recovery_point_encrypted", "backup_vaults_encrypted", "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_encrypted_with_cmk", "cloudfront_distributions_field_level_encryption_enabled", "cloudfront_distributions_origin_traffic_encrypted", "cloudtrail_kms_encryption_enabled", @@ -6794,6 +6963,17 @@ "secretsmanager_not_publicly_accessible", "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6827,6 +7007,17 @@ ], "Checks": [ "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6900,6 +7091,17 @@ "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused", "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -6922,6 +7124,17 @@ "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused", "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -7209,6 +7422,7 @@ ], "Checks": [ "workspaces_vpc_2private_1public_subnets_nat", + "bedrock_vpc_endpoints_configured", "vpc_endpoint_connections_trust_boundaries", "networkfirewall_multi_az" ] @@ -8026,6 +8240,14 @@ "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled_logging_management_events", "cloudtrail_log_file_validation_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -8794,6 +9016,14 @@ "guardduty_is_enabled", "cloudtrail_log_file_validation_enabled", "ssmincidents_enabled_with_plans" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -9716,6 +9946,14 @@ "accessanalyzer_enabled_without_findings", "cloudfront_distributions_s3_origin_access_control", "cloudtrail_logs_s3_bucket_access_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -10351,6 +10589,14 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -10441,6 +10687,14 @@ "ec2_instance_profile_attached", "iam_role_cross_account_readonlyaccess_policy", "iam_securityaudit_role_created" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/ccc_aws.json b/prowler/compliance/aws/ccc_aws.json index 1f6da6d5f6..7935424193 100644 --- a/prowler/compliance/aws/ccc_aws.json +++ b/prowler/compliance/aws/ccc_aws.json @@ -1,10 +1,1884 @@ { "Framework": "CCC", - "Version": "", + "Version": "v2025.10", "Provider": "AWS", "Name": "Common Cloud Controls Catalog (CCC)", "Description": "Common Cloud Controls Catalog (CCC) for AWS", "Requirements": [ + { + "Id": "CCC.Core.CN01.AR01", + "Description": "When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Most cloud services enable TLS 1.3 by default. Where it is not already set, ensure that your services are configured or updated accordingly.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "elb_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", + "elbv2_ssl_listeners", + "elbv2_nlb_tls_termination_enabled", + "s3_bucket_secure_transport_policy", + "opensearch_service_domains_https_communications_enforced", + "opensearch_service_domains_node_to_node_encryption_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "dms_endpoint_ssl_enabled", + "dms_endpoint_redis_in_transit_encryption_enabled", + "kafka_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "redshift_cluster_in_transit_encryption_enabled", + "rds_instance_transport_encrypted", + "transfer_server_in_transit_encryption_enabled", + "glue_database_connections_ssl_enabled", + "sns_subscription_not_using_http_endpoints" + ] + }, + { + "Id": "CCC.Core.CN01.AR02", + "Description": "When a port is exposed for SSH network traffic, all traffic MUST include a SSH handshake AND be encrypted using SSHv2 or higher.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Any time port 22 is exposed, ensure that it has a properly implemented SSH server with SSHv2 enabled and configured with strong ciphers.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "ec2_instance_port_ssh_exposed_to_internet", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_networkacl_allow_ingress_tcp_port_22" + ] + }, + { + "Id": "CCC.Core.CN01.AR03", + "Description": "When the service receives unencrypted traffic, then it MUST either block the request or automatically redirect it to the secure equivalent.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Review firewall, load balancer, and application configurations to ensure insecure protocols such as HTTP, FTP, and Telnet are not exposed. Where possible, implement automatic redirection to secure protocols such as HTTPS, SFTP, SSH, and regularly scan for protocol drift.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + "opensearch_service_domains_https_communications_enforced", + "transfer_server_in_transit_encryption_enabled", + "s3_bucket_secure_transport_policy", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" + ] + }, + { + "Id": "CCC.Core.CN01.AR07", + "Description": "When a port is exposed, the service MUST ensure that the protocol and service officially assigned to that port number by the IANA Service Name and Transport Protocol Port Number Registry, and no other, is run on that port.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Reference the IANA Service Name and Transport Protocol Port Number Registry for more information about correct protocol-to-port assignments. Avoid running non-standard services on well-known ports.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN01.AR08", + "Description": "When a service transmits data using TLS, mutual TLS (mTLS) MUST be implemented to require both client and server certificate authentication for all connections.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Configure mTLS for all endpoints that process or transmit sensitive data. Ensure both client and server certificates are validated and managed securely. Regularly review certificate authorities and automate certificate rotation where possible.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "apigateway_restapi_client_certificate_enabled", + "kafka_cluster_mutual_tls_authentication_enabled" + ] + }, + { + "Id": "CCC.Core.CN13.AR01", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST only use valid, unexpired certificates issued by a trusted certificate authority.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "acm_certificates_expiration_check", + "acm_certificates_with_secure_key_algorithms", + "acm_certificates_transparency_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ] + }, + { + "Id": "CCC.Core.CN13.AR02", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 180 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-amber" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "acm_certificates_expiration_check" + ] + }, + { + "Id": "CCC.Core.CN13.AR03", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 90 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "acm_certificates_expiration_check" + ] + }, + { + "Id": "CCC.Core.CN06.AR01", + "Description": "When the service is running, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate the service's deployment location is included in this list.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-19" + ] + } + ] + } + ], + "Checks": [ + "organizations_scp_check_deny_regions" + ] + }, + { + "Id": "CCC.Core.CN06.AR02", + "Description": "When a child resource is deployed, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate that child resources can only be deployed to locations included in this list.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-19" + ] + } + ] + } + ], + "Checks": [ + "organizations_scp_check_deny_regions" + ] + }, + { + "Id": "CCC.Core.CN08.AR01", + "Description": "When data is created or modified, the data MUST have a complete and recoverable duplicate that is stored in a physically separate data center.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "SubSection": "", + "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement automated data replication processes to ensure that data is consistently duplicated in another region or availability zone. Regularly test data recovery from the replicated location to ensure integrity and availability.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "BCR-08", + "BCR-10", + "BCR-11" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_cross_region_replication", + "backup_plans_exist", + "backup_vaults_exist", + "dynamodb_table_protected_by_backup_plan", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "rds_cluster_multi_az", + "rds_instance_multi_az", + "efs_multi_az_enabled", + "neptune_cluster_multi_az", + "documentdb_cluster_multi_az_enabled", + "elasticache_redis_cluster_multi_az_enabled" + ] + }, + { + "Id": "CCC.Core.CN08.AR02", + "Description": "When data is replicated into a second location, the service MUST be able to accurately represent the replication locations, replication status, and data synchronization status.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "SubSection": "", + "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "BCR-08", + "BCR-10", + "BCR-11" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_cross_region_replication" + ] + }, + { + "Id": "CCC.Core.CN09.AR01", + "Description": "When the service is operational, its logs and any child resource logs MUST NOT be accessible from the resource they record access to.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudwatch_log_group_not_publicly_accessible", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_bucket_requires_mfa_delete" + ] + }, + { + "Id": "CCC.Core.CN09.AR02", + "Description": "When the service is operational, disabling the logs for the service or its child resources MUST NOT be possible without also disabling the corresponding resource.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "No normal business operations should disable logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging mechanisms are tightly integrated with service operations, so that logging cannot be disabled without stopping the service itself.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" + ] + }, + { + "Id": "CCC.Core.CN09.AR03", + "Description": "When the service is operational, any attempt to redirect logs for the service or its child resources MUST NOT be possible without halting operation of the corresponding resource and publishing corresponding events to monitored channels.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "No normal business operations should result in the redirection of logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging configurations are immutable during service operation so that any changes require stopping the service and publishing corresponding events to monitored channels.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_log_file_validation_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured" + ] + }, + { + "Id": "CCC.Core.CN10.AR01", + "Description": "When data is replicated, the service MUST ensure that replication only occurs to destinations that are explicitly included within the defined trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN10 Restrict Data Replication to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that data is only replicated on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-10", + "DSP-19" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_cross_region_replication" + ] + }, + { + "Id": "CCC.Core.CN02.AR01", + "Description": "When data is stored, it MUST be encrypted using the latest industry-standard encryption methods.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN02 Encrypt Data for Storage", + "SubSection": "", + "SubSectionObjective": "Ensure that all data stored is encrypted at rest using strong encryption algorithms.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "UEM-08", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "ec2_ebs_default_encryption", + "ec2_ebs_volume_encryption", + "ec2_ebs_snapshots_encrypted", + "efs_encryption_at_rest_enabled", + "storagegateway_fileshare_encryption_enabled", + "rds_instance_storage_encrypted", + "rds_cluster_storage_encrypted", + "rds_snapshots_encrypted", + "redshift_cluster_encrypted_at_rest", + "documentdb_cluster_storage_encrypted", + "neptune_cluster_storage_encrypted", + "neptune_cluster_snapshot_encrypted", + "dynamodb_tables_kms_cmk_encryption_enabled", + "dynamodb_accelerator_cluster_encryption_enabled", + "kafka_cluster_encryption_at_rest_uses_cmk", + "kinesis_stream_encrypted_at_rest", + "firehose_stream_encrypted_at_rest", + "sns_topics_kms_encryption_at_rest_enabled", + "sqs_queues_server_side_encryption_enabled", + "opensearch_service_domains_encryption_at_rest_enabled", + "athena_workgroup_encryption", + "glue_data_catalogs_metadata_encryption_enabled", + "glue_data_catalogs_connection_passwords_encryption_enabled", + "glue_etl_jobs_amazon_s3_encryption_enabled", + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "cloudtrail_kms_encryption_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "sagemaker_notebook_instance_encryption_enabled", + "apigateway_restapi_cache_encrypted" + ] + }, + { + "Id": "CCC.Core.CN11.AR01", + "Description": "When encryption keys are used, the service MUST verify that all encryption keys use the latest industry-standard cryptographic algorithms.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ] + }, + { + "Id": "CCC.Core.CN11.AR02", + "Description": "When encryption keys are used, the service MUST rotate active keys within 180 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_cmk_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR03", + "Description": "When encrypting data, the service MUST verify that customer-managed encryption keys (CMEKs) are used.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_cmk_are_used" + ] + }, + { + "Id": "CCC.Core.CN11.AR04", + "Description": "When encryption keys are accessed, the service MUST verify that access to encryption keys is restricted to authorized personnel and services, following the principle of least privilege.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "iam_inline_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_kms", + "kms_cmk_not_deleted_unintentionally" + ] + }, + { + "Id": "CCC.Core.CN11.AR05", + "Description": "When encryption keys are used, the service MUST rotate active keys within 365 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-clear", + "tlp-green" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_cmk_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR06", + "Description": "When encryption keys are used, the service MUST rotate active keys within 90 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_cmk_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN14.AR01", + "Description": "When backups are created for disaster recovery purposes, the storage mechanism MUST NOT allow modification or deletion within 30 days of creation.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Use immutable storage solutions where possible. Implement backup retention policies that enforce a minimum retention period of 30 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "backup_vaults_exist", + "backup_plans_exist", + "rds_instance_backup_enabled", + "neptune_cluster_backup_enabled", + "documentdb_cluster_backup_enabled" + ] + }, + { + "Id": "CCC.Core.CN14.AR02", + "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 30 days.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber" + ], + "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 30 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "backup_vaults_exist", + "backup_plans_exist", + "backup_reportplans_exist", + "backup_recovery_point_encrypted", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "rds_cluster_protected_by_backup_plan", + "neptune_cluster_backup_enabled", + "documentdb_cluster_backup_enabled", + "dynamodb_table_protected_by_backup_plan", + "efs_have_backup_enabled" + ] + }, + { + "Id": "CCC.Core.CN14.AR03", + "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 14 days.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 14 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "backup_vaults_exist", + "backup_plans_exist", + "rds_instance_backup_enabled", + "neptune_cluster_backup_enabled", + "documentdb_cluster_backup_enabled" + ] + }, + { + "Id": "CCC.Core.CN03.AR01", + "Description": "When an entity attempts to modify the service through a user interface, the authentication process MUST require multiple identifying factors for authentication.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled", + "iam_administrator_access_with_mfa", + "cognito_user_pool_mfa_enabled" + ] + }, + { + "Id": "CCC.Core.CN03.AR02", + "Description": "When an entity attempts to modify the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "iam_no_root_access_key", + "iam_root_credentials_management_enabled", + "iam_user_no_setup_initial_access_key", + "iam_administrator_access_with_mfa", + "apigateway_restapi_authorizers_enabled", + "apigatewayv2_api_authorizers_enabled", + "apigateway_restapi_public_with_authorizer" + ] + }, + { + "Id": "CCC.Core.CN03.AR03", + "Description": "When an entity attempts to view information on the service through a user interface, the authentication process MUST require multiple identifying factors from the user.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_administrator_access_with_mfa", + "cognito_user_pool_mfa_enabled" + ] + }, + { + "Id": "CCC.Core.CN03.AR04", + "Description": "When an entity attempts to view information on the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "iam_no_root_access_key", + "iam_user_no_setup_initial_access_key", + "apigateway_restapi_authorizers_enabled", + "apigatewayv2_api_authorizers_enabled", + "apigateway_restapi_public_with_authorizer" + ] + }, + { + "Id": "CCC.Core.CN05.AR01", + "Description": "When an attempt is made to modify data on the service or a child resource, the service MUST block requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "apigateway_restapi_authorizers_enabled", + "apigateway_restapi_public", + "apigateway_restapi_public_with_authorizer", + "apigatewayv2_api_authorizers_enabled", + "awslambda_function_url_public", + "awslambda_function_not_publicly_accessible", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", + "s3_bucket_cross_account_access", + "s3_account_level_public_access_blocks", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_role_administratoraccess_policy", + "iam_group_administrator_access_policy", + "iam_user_administrator_access_policy", + "iam_policy_attached_only_to_group_or_roles", + "iam_role_cross_account_readonlyaccess_policy", + "iam_role_cross_service_confused_deputy_prevention" + ] + }, + { + "Id": "CCC.Core.CN05.AR02", + "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_avoid_root_usage", + "iam_user_mfa_enabled_console_access", + "iam_administrator_access_with_mfa", + "iam_group_administrator_access_policy", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_allows_privilege_escalation", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_aws_attached_policy_no_administrative_privileges", + "iam_password_policy_minimum_length_14", + "iam_password_policy_uppercase", + "iam_password_policy_lowercase", + "iam_password_policy_symbol", + "iam_password_policy_number", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_reuse_24" + ] + }, + { + "Id": "CCC.Core.CN05.AR03", + "Description": "When administrative access or configuration change is attempted on the service or a child resource in a multi-tenant environment, the service MUST refuse requests across tenant boundaries unless the origin is explicitly included in a pre-approved allowlist.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_endpoint_connections_trust_boundaries", + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_cross_account_readonlyaccess_policy", + "s3_bucket_cross_account_access", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access" + ] + }, + { + "Id": "CCC.Core.CN05.AR04", + "Description": "When data is requested from outside the trust perimeter, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "s3_bucket_cross_account_access", + "s3_bucket_public_access", + "iam_administrator_access_with_mfa", + "iam_inline_policy_no_full_access_to_kms", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_attached_only_to_group_or_roles" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "CCC.Core.CN05.AR05", + "Description": "When any request is made from outside the trust perimeter, the service MUST NOT provide any response that may indicate the service exists.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "awslambda_function_url_public", + "apigateway_restapi_public", + "s3_bucket_public_access", + "s3_bucket_policy_public_write_access", + "sns_topics_not_publicly_accessible", + "sqs_queues_not_publicly_accessible", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_networkacl_allow_ingress_any_port", + "ec2_securitygroup_default_restrict_traffic", + "vpc_endpoint_for_ec2_enabled", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries" + ] + }, + { + "Id": "CCC.Core.CN05.AR06", + "Description": "When any request is made to the service or a child resource, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_no_root_access_key", + "iam_administrator_access_with_mfa", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled", + "iam_root_credentials_management_enabled", + "iam_check_saml_providers_sts", + "iam_policy_attached_only_to_group_or_roles", + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_cross_account_readonlyaccess_policy", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries" + ] + }, + { + "Id": "CCC.Core.CN04.AR01", + "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "CCC.Core.CN04.AR02", + "Description": "When any attempt is made to modify data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_cloudwatch_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", + "vpc_flow_logs_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled" + ] + }, + { + "Id": "CCC.Core.CN04.AR03", + "Description": "When any attempt is made to read data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_insights_exist", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_multi_region_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "s3_bucket_server_access_logging_enabled" + ] + }, + { + "Id": "CCC.Core.CN07.AR01", + "Description": "When enumeration activities are detected, the service MUST publish an event to a monitored channel which includes the client identity, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement event publication mechanisms and alerts for patterns indicative of enumeration activities, such as repeated access attempts, requests, or liveness probes. Configure alerts to notify security teams of any activities that merit further investigation.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH15" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-05", + "SEF-05" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_threat_detection_enumeration", + "guardduty_is_enabled", + "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "CCC.Core.CN07.AR02", + "Description": "When enumeration activities are detected, the service MUST log the client identity, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement logging mechanisms to capture details of enumeration activities, including client identity, timestamps, and activity nature. Retain logs according to organizational policies, and occasionally review them for patterns that may indicate reconnaissance activities.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH15" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-05", + "SEF-05" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_threat_detection_enumeration" + ] + }, { "Id": "CCC.AuditLog.CN01.AR01", "Description": "When the signature validation process is performed, then it MUST detect any modification of data.", @@ -18,13 +1892,13 @@ "Applicability": [ "tlp-red" ], - "Recommendation": "Ensure hash of data is included in digital signature. ", + "Recommendation": "Ensure hash of data is included in digital signature.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -61,13 +1935,13 @@ "Applicability": [ "tlp-red" ], - "Recommendation": "Ensure verification process includes a chained hash function. ", + "Recommendation": "Ensure verification process includes a chained hash function.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -110,7 +1984,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06" + "CCC.Core.TH06" ] } ], @@ -133,19 +2007,13 @@ } ], "Checks": [ + "cloudtrail_bedrock_logging_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled_logging_management_events", - "cloudtrail_insights_exist", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_threat_detection_enumeration", - "cloudtrail_threat_detection_llm_jacking", - "cloudtrail_threat_detection_privilege_escalation" + "cloudtrail_insights_exist" ] }, { @@ -162,12 +2030,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Ensure alerting is correctly configured ", + "Recommendation": "Ensure alerting is correctly configured", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -189,14 +2057,7 @@ } ], "Checks": [ - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_insights_exist", - "s3_bucket_object_lock", - "cloudtrail_multi_region_enabled_logging_management_events" + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" ] }, { @@ -213,12 +2074,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Ensure alerting is correctly configured ", + "Recommendation": "Ensure alerting is correctly configured", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -240,17 +2101,7 @@ } ], "Checks": [ - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", - "cloudwatch_log_metric_filter_unauthorized_api_calls" + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes" ] }, { @@ -267,13 +2118,13 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure the audit log bucket to enable server access logging. Ensure the target logging bucket is configured for appropriate security, including restricted access and immutability. ", + "Recommendation": "Configure the audit log bucket to enable server access logging. Ensure the target logging bucket is configured for appropriate security, including restricted access and immutability.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01", - "CCC.TH09" + "CCC.Core.TH01", + "CCC.Core.TH09" ] } ], @@ -296,9 +2147,6 @@ ], "Checks": [ "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_bucket_requires_mfa_delete", - "cloudtrail_kms_encryption_enabled", "s3_bucket_server_access_logging_enabled" ] }, @@ -316,12 +2164,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure audit log exporting. ", + "Recommendation": "Configure audit log exporting.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -344,16 +2192,10 @@ } ], "Checks": [ - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_bucket_requires_mfa_delete", + "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled_logging_management_events", - "s3_bucket_cross_region_replication", - "s3_bucket_cross_account_access" + "s3_bucket_cross_region_replication" ] }, { @@ -371,13 +2213,13 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure the audit log bucket's lifecycle rules or object retention settings to enforce the required data retention period. ", + "Recommendation": "Configure the audit log bucket's lifecycle rules or object retention settings to enforce the required data retention period.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -417,13 +2259,13 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Enable MFA Delete (or equivalent multi-factor authentication for delete operations) on the audit log bucket. ", + "Recommendation": "Enable MFA Delete (or equivalent multi-factor authentication for delete operations) on the audit log bucket.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -462,12 +2304,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure object lock policy. ", + "Recommendation": "Configure object lock policy.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -506,12 +2348,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Review field level access controls on audit data. ", + "Recommendation": "Review field level access controls on audit data.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -537,11 +2379,8 @@ } ], "Checks": [ - "cloudtrail_kms_encryption_enabled", "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_bucket_requires_mfa_delete", - "cloudwatch_log_group_not_publicly_accessible", - "s3_bucket_public_access" + "cloudwatch_log_group_not_publicly_accessible" ] }, { @@ -559,12 +2398,12 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -587,7 +2426,7 @@ ], "Checks": [ "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_kms_encryption_enabled", + "s3_bucket_public_access", "s3_bucket_public_list_acl", "s3_bucket_public_write_acl" ] @@ -607,12 +2446,12 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -635,220 +2474,34 @@ ], "Checks": [ "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "s3_bucket_public_write_acl", - "s3_bucket_public_list_acl", "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", "s3_bucket_policy_public_write_access" ] }, { - "Id": "CCC.Build.CN01.AR01", - "Description": "Attempt to initiate a build using an unauthorized build agent and verify that the build is rejected.", - "Attributes": [ - { - "FamilyName": "Access Control", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.Build.CN01 Restrict Allowed Build Agents", - "SubSection": "", - "SubSectionObjective": "Ensure that builds are executed only on authorized build agents to maintain control over the build environment and prevent unauthorized code execution.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - } - ] - } - ], - "Checks": [ - "codebuild_project_user_controlled_buildspec", - "codebuild_project_source_repo_url_no_sensitive_credentials", - "codebuild_project_uses_allowed_github_organizations", - "codebuild_project_not_publicly_accessible", - "codebuild_project_logging_enabled", - "codebuild_project_s3_logs_encrypted", - "codebuild_project_no_secrets_in_variables", - "codebuild_project_older_90_days" - ] - }, - { - "Id": "CCC.Build.CN02.AR01", - "Description": "Attempt to trigger a build from an unauthorized external service or repository and verify that the build does not start.", - "Attributes": [ - { - "FamilyName": "Access Control", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.Build.CN02 Restrict Allowed External Services for Build Triggers", - "SubSection": "", - "SubSectionObjective": "Ensure that builds can only be triggered by authorized external services or repositories to prevent unauthorized code execution or tampering.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - } - ] - } - ], - "Checks": [ - "codebuild_project_uses_allowed_github_organizations", - "codebuild_project_user_controlled_buildspec", - "codebuild_project_source_repo_url_no_sensitive_credentials", - "codebuild_project_not_publicly_accessible" - ] - }, - { - "Id": "CCC.Build.CN03.AR01", - "Description": "Attempt to access the build environment from an external network and verify that access is denied.", - "Attributes": [ - { - "FamilyName": "Network Security", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.Build.CN03 Deny External Network Access for Build Environments", - "SubSection": "", - "SubSectionObjective": "Ensure that build environments do not have external network access to prevent unauthorized external access and data exfiltration.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02", - "CCC.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-7", - "SC-5" - ] - } - ] - } - ], - "Checks": [ - "codebuild_project_not_publicly_accessible" - ] - }, - { - "Id": "CCC.CntrReg.CN01.AR01", - "Description": "Attempt to push an artifact with known vulnerabilities to the registry and observe if it is flagged or rejected by the vulnerability scanning process.", - "Attributes": [ - { - "FamilyName": "Risk Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CntrReg.CN01 Implement Vulnerability Scanning for Artifacts", - "SubSection": "", - "SubSectionObjective": "Ensure that container images and artifacts stored in the container registry are scanned for vulnerabilities to identify and remediate security issues before deployment.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.CntrReg.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "ID.RA-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "RA-5", - "SI-5" - ] - } - ] - } - ], - "Checks": [ - "ecr_registry_scan_images_on_push_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image" - ] - }, - { - "Id": "CCC.DataWar.CN01.AR01", - "Description": "Attempt to access underlying database tables directly without using managed views and verify that access is denied.", + "Id": "CCC.Logging.CN01.AR01", + "Description": "When a new cloud account is created, provider-level audit and network flow logging MUST be enabled by default and directed to the central sink.", "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN01 Enforce Use of Managed Views for Data Access", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", "SubSection": "", - "SubSectionObjective": "Ensure that data access is provided through managed views, restricting users from accessing underlying tables directly and enforcing consistent security policies.", + "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", "Applicability": [ - "tlp-red", - "tlp-amber" + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Logging.TH07" ] } ], @@ -856,14 +2509,490 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-4" + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-2", + "AU-3" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "vpc_flow_logs_enabled" + ] + }, + { + "Id": "CCC.Logging.CN01.AR02", + "Description": "When a new cloud compute resource is deployed, it MUST be configured to forward all relevant logs (e.g., OS, application, service logs) to the central log sink.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", + "SubSection": "", + "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH07" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-2", + "AU-3" + ] + } + ] + } + ], + "Checks": [ + "vpc_flow_logs_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled" + ] + }, + { + "Id": "CCC.Logging.CN02.AR01", + "Description": "When a new log bucket or stream is created, its retention policy MUST be configured in accordance with organisation's data retention policy.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "GV.PO-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-11" + ] + } + ] + } + ], + "Checks": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ] + }, + { + "Id": "CCC.Logging.CN02.AR02", + "Description": "When a query is performed to retrieve log events older than the number of days defined in the organisation's data retention policy, it MUST return an empty result.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "GV.PO-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-11" + ] + } + ] + } + ], + "Checks": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ] + }, + { + "Id": "CCC.Logging.CN03.AR01", + "Description": "When an attempt is made to modify or delete data before the object lock period expires, then the action MUST be denied.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN03 Enable Object Lock On Log Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure log immutability by enabling Write Once, Read Many (WORM) protection using object lock on log storage buckets. This prevents logs from being modified or deleted during the defined retention period, supporting compliance and forensic integrity.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "Configure object lock policy.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-9", + "AU-11" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_lock" + ] + }, + { + "Id": "CCC.Logging.CN04.AR01", + "Description": "When restricted fields are accessed by unauthorized users, then those fields MUST remain masked.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify logs.", + "Section": "CCC.Logging.CN04 Restrict Field And Log Type Access", + "SubSection": "", + "SubSectionObjective": "Configure access to logs to follow the principle of least privilege in particular where technically possible limit the log fields users have access to to prevent accidental exposure to sensitive information such as PII.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "Review field level access controls on log data.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6", + "AU-9", + "AC-3", + "PT-2", + "PT-3", + "PT-3" + ] + } + ] + } + ], + "Checks": [ + "cloudwatch_log_group_not_publicly_accessible", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible" + ] + }, + { + "Id": "CCC.Logging.CN05.AR01", + "Description": "When a log storage bucket is created, the bucket's access control settings MUST explicitly deny public read and write access.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify logs.", + "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", + "SubSection": "", + "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green" + ], + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ "AC-3", - "AC-6" + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", + "s3_account_level_public_access_blocks", + "s3_bucket_level_public_access_block" + ] + }, + { + "Id": "CCC.Logging.CN05.AR02", + "Description": "When the URL of a log storage bucket's object is accessed publicly, the action MUST be denied by bucket policy.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify logs.", + "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", + "SubSection": "", + "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green" + ], + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "s3_account_level_public_access_blocks" + ] + }, + { + "Id": "CCC.Logging.CN06.AR01", + "Description": "When a single principal executes an anomalously high number of log queries, an alert MUST be generated.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain logging-related events.", + "Section": "CCC.Logging.CN06 Detect and Alert on Potential Log Exfiltration", + "SubSection": "", + "SubSectionObjective": "Identify and alert on anomalous data access patterns that may indicate an attempt to exfiltrate log data.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "CA-7", + "AU-6" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_threat_detection_enumeration" + ] + }, + { + "Id": "CCC.Logging.CN07.AR01", + "Description": "When an audit log event is recorded that corresponds to a modification of the logging service configuration such as disabling a log trail, deleting a log sink, or altering a log forwarding rule, an alert MUST be generated.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain logging-related events.", + "Section": "CCC.Logging.CN07 Detect and Alert on Log Service Tampering", + "SubSection": "", + "SubSectionObjective": "Alert when any component of the critical logging infrastructure is disabled, modified, or deleted, indicating a defense evasion attempt.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "CA-7", + "AU-6" + ] + } + ] + } + ], + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" + ] + }, + { + "Id": "CCC.Monitor.CN01.AR01", + "Description": "When an External Monitoring system exceeds the anticipated rate of monitoring checks then Rate Limiting MUST be applied and an Audit Alert MUST be generated.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", + "Section": "CCC.Monitor.CN01 Rate Limiting on External Monitoring", + "SubSection": "", + "SubSectionObjective": "Prevent DoS attacks using External Monitoring tools.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IR-01", + "DE.CM-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-5", + "SC-7" ] } ] @@ -872,25 +3001,27 @@ "Checks": [] }, { - "Id": "CCC.DataWar.CN02.AR01", - "Description": "Attempt to query sensitive columns without the necessary permissions and verify that access is denied or data is masked.", + "Id": "CCC.Monitor.CN02.AR01", + "Description": "When an Custom or User-Defined Metric starts to flood a collector, then a rate limit MUST be applied to reduce the network impact of traffic and an alert must triggered.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN02 Enforce Column-Level Security Policies", + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", + "Section": "CCC.Monitor.CN02 Rate Limiting on Metric Generation", "SubSection": "", - "SubSectionObjective": "Ensure that access to sensitive data columns is restricted based on user roles, preventing unauthorized access to sensitive information.", + "SubSectionObjective": "Prevent Malicious Actor or misconfiguration from flooding services with metric data.", "Applicability": [ - "tlp-red", - "tlp-amber" + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Monitor.TH06" ] } ], @@ -898,14 +3029,15 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-4" + "DE.CM-01" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-3", - "AC-6" + "SC-5(2)", + "CA-7", + "SI-4" ] } ] @@ -914,25 +3046,27 @@ "Checks": [] }, { - "Id": "CCC.DataWar.CN03.AR01", - "Description": "Attempt to query data rows that the user should not have access to and verify that access is denied or data is not returned.", + "Id": "CCC.Monitor.CN03.AR01", + "Description": "When external systems have approved access to internal systems not normally available for public access then they MUST be secured to prevent unauthorised access jumping through to the internal systems and only allow access to specific internal services.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN03 Enforce Row-Level Security Policies", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN03 Access External Monitoring", "SubSection": "", - "SubSectionObjective": "Ensure that access to data rows is restricted based on user roles or attributes, preventing unauthorized access to specific subsets of data.", + "SubSectionObjective": "Control access to Synthetic monitoring solutions using API keys or Certificate based authentication to ensure they don't become an attack path, preventing monitoring systems from forging network requests to gain access to internal systems.", "Applicability": [ - "tlp-red", - "tlp-amber" + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Monitor.TH04" ] } ], @@ -940,14 +3074,111 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-4" + "DE.CM-06", + "PR.IR-01", + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-3", - "AC-6" + "AC-3" + ] + } + ] + } + ], + "Checks": [ + "apigateway_restapi_authorizers_enabled", + "apigateway_restapi_client_certificate_enabled", + "apigatewayv2_api_authorizers_enabled", + "apigateway_restapi_public_with_authorizer" + ] + }, + { + "Id": "CCC.Monitor.CN04.AR01", + "Description": "When monitoring dashboards display degraded services which may become potential targets then the dashboard MUST be protected from unauthorised access.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN04 Restrict access to Monitoring Dashboards", + "SubSection": "", + "SubSectionObjective": "Control access to Monitoring Dashboards and reports to ensure they don't highlight an attack path.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-09", + "DE.AE-03" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "AC-3" + ] + } + ] + } + ], + "Checks": [ + "cloudwatch_log_group_not_publicly_accessible" + ] + }, + { + "Id": "CCC.Monitor.CN05.AR01", + "Description": "When monitoring services have generated an alert, the service MUST ensure only authorised responders silence or acknowledge the alert.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN05 Restrict access to silence or acknowledge an alert", + "SubSection": "", + "SubSectionObjective": "Ensure only a subset of users can silence or acknowledge alerts to prevent attackers hiding their activity.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IR-01", + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3" ] } ] @@ -956,179 +3187,762 @@ "Checks": [] }, { - "Id": "CCC.KeyMgmt.CN01.AR01", - "Description": "When a key version is scheduled for deletion or disabled, an alert MUST be generated within five minutes.", + "Id": "CCC.Monitor.CN06.AR01", + "Description": "When systems push metrics or traces they MUST be authenticated for that particular type of metric or trace", "Attributes": [ { - "FamilyName": "Logging and Metrics Publication", - "FamilyDescription": "Controls that collect, alert, and retain key-management events.", - "Section": "CCC.KeyMgmt.CN01 Alert on Key-version Changes", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN06 Metrics pushed for authorised services only", "SubSection": "", - "SubSectionObjective": "Generate near-real-time alerts when a KMS key version is disabled or scheduled for deletion, enabling rapid investigation and recovery.", + "SubSectionObjective": "Use IAM to control which types of metrics or traces can be pushed by different system to avoid a compromised system pushing fabricated metrics about a different service", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-5" + ] + } + ] + } + ], + "Checks": [ + "apigateway_restapi_authorizers_enabled", + "apigatewayv2_api_authorizers_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR01", + "Description": "When a request is made to read a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", "Applicability": [ "tlp-amber", "tlp-red" ], - "Recommendation": "Use native event services (e.g., CloudWatch Events, Azure Monitor, Cloud Audit Logs) to route notifications to an incident-response channel.", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.KeyMgmt.TH01" + "CCC.Core.TH01", + "CCC.Core.TH06" ] } ], "SectionGuidelineMappings": [ { - "ReferenceId": "NIST-CSF", + "ReferenceId": "CCM", "Identifiers": [ - "RS.AN-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IR-5" + "IAM-01", + "IAM-03", + "DSP-17" ] } ] } ], "Checks": [ - "kms_cmk_not_deleted_unintentionally" + "s3_bucket_kms_encryption", + "iam_policy_no_full_access_to_kms", + "iam_inline_policy_no_full_access_to_kms", + "kms_key_not_publicly_accessible" ] }, { - "Id": "CCC.KeyMgmt.CN02.AR01", - "Description": "When IAM roles and key policies are reviewed, Decrypt permission MUST be granted exclusively to documented authorised principals.", + "Id": "CCC.ObjStor.CN01.AR02", + "Description": "When a request is made to read an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_kms_encryption", + "iam_policy_no_full_access_to_kms", + "iam_inline_policy_no_full_access_to_kms", + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR03", + "Description": "When a request is made to write to a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_kms_encryption", + "iam_policy_no_full_access_to_kms", + "iam_inline_policy_no_full_access_to_kms", + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR04", + "Description": "When a request is made to write to an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_kms_encryption", + "iam_policy_no_full_access_to_kms", + "iam_inline_policy_no_full_access_to_kms", + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "CCC.ObjStor.CN03.AR01", + "Description": "When an object storage bucket deletion is attempted, the bucket MUST be fully recoverable for a set time-frame after deletion is requested.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", + "SubSection": "", + "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_lock", + "s3_bucket_object_versioning" + ] + }, + { + "Id": "CCC.ObjStor.CN03.AR02", + "Description": "When an attempt is made to modify the retention policy for an object storage bucket, the service MUST prevent the policy from being modified.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", + "SubSection": "", + "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_lock" + ] + }, + { + "Id": "CCC.ObjStor.CN04.AR01", + "Description": "When an object is uploaded to the object storage system, the object MUST automatically receive a default retention policy that prevents premature deletion or modification.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN04 Objects have an Effective Retention Policy by Default", + "SubSection": "", + "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + }, + { + "ReferenceId": "CCC.ObjStor", + "Identifiers": [ + "CCC.ObjStor.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_lock" + ] + }, + { + "Id": "CCC.ObjStor.CN04.AR02", + "Description": "When an attempt is made to delete or modify an object that is subject to an active retention policy, the service MUST prevent the action from being completed.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN04 Objects have an Effective Retention Policy by Default", + "SubSection": "", + "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + }, + { + "ReferenceId": "CCC.ObjStor", + "Identifiers": [ + "CCC.ObjStor.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_lock" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR01", + "Description": "When an object is uploaded to the object storage bucket, the object MUST be stored with a unique identifier.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_versioning" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR02", + "Description": "When an object is modified, the service MUST assign a new unique identifier to the modified object to differentiate it from the previous version.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_versioning" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR03", + "Description": "When an object is modified, the service MUST allow for recovery of previous versions of the object.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_versioning" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR04", + "Description": "When an object is deleted, the service MUST retain other versions of the object to allow for recovery of previous versions.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_object_versioning" + ] + }, + { + "Id": "CCC.ObjStor.CN07.AR01", + "Description": "The object storage service MUST support a configuration option that requires MFA to be successfully completed before any object deletion can be attempted, regardless of the request interface.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_no_mfa_delete" + ] + }, + { + "Id": "CCC.ObjStor.CN07.AR02", + "Description": "When MFA deletion protection is enabled on a bucket or object namespace, the service MUST deny any deletion request from an identity that has not satisfied the MFA requirement at the time of the request.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_no_mfa_delete" + ] + }, + { + "Id": "CCC.ObjStor.CN07.AR03", + "Description": "When an attempt is made to delete an object, the service's audit logs MUST clearly record each deletion attempt, including whether MFA was required and whether validation was met.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [ + "s3_bucket_no_mfa_delete" + ] + }, + { + "Id": "CCC.ObjStor.CN02.AR01", + "Description": "When a permission set is allowed for an object in a bucket, the service MUST allow the same permission set to access all objects in the same bucket.", "Attributes": [ { "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that enforce least-privilege use of KMS operations.", - "Section": "CCC.KeyMgmt.CN02 Limit Decrypt Permissions", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources.", + "Section": "CCC.ObjStor.CN02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", "SubSection": "", - "SubSectionObjective": "Restrict the Decrypt operation to authorised principals only, applying the principle of least privilege to protect sensitive data.", + "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", "Applicability": [ - "tlp-green" + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" ], - "Recommendation": "Periodically audit policy documents via automated tooling and report any deviations.", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.KeyMgmt.TH02" + "CCC.Core.TH01" ] } ], "SectionGuidelineMappings": [ { - "ReferenceId": "NIST-CSF", + "ReferenceId": "CCM", "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-6" + "IAM-08" ] } ] } ], "Checks": [ - "kms_cmk_not_deleted_unintentionally", - "kms_cmk_not_multi_region", - "kms_cmk_rotation_enabled", - "kms_cmk_are_used", - "iam_inline_policy_no_full_access_to_kms" + "s3_bucket_acl_prohibited" ] }, { - "Id": "CCC.KeyMgmt.CN03.AR01", - "Description": "When rotation settings are examined, rotation MUST be enabled with an interval not exceeding 365 days.", + "Id": "CCC.ObjStor.CN02.AR02", + "Description": "When a permission set is denied for an object in a bucket, the service MUST deny the same permission set to access all objects in the same bucket.", "Attributes": [ { - "FamilyName": "Key Lifecycle Management", - "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", - "Section": "CCC.KeyMgmt.CN03 Enforce Automatic Rotation", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources.", + "Section": "CCC.ObjStor.CN02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", "SubSection": "", - "SubSectionObjective": "Ensure symmetric keys rotate automatically within policy intervals to reduce exposure of key material.", + "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", "Applicability": [ - "tlp-green" + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" ], - "Recommendation": "Use cloud-provider rotation features and verify via configuration scanning.", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.KeyMgmt.TH03" + "CCC.Core.TH01" ] } ], "SectionGuidelineMappings": [ { - "ReferenceId": "NIST-CSF", + "ReferenceId": "CCM", "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12" + "IAM-08" ] } ] } ], "Checks": [ - "kms_cmk_rotation_enabled" - ] - }, - { - "Id": "CCC.KeyMgmt.CN04.AR01", - "Description": "When a key import request is processed, the key MUST use an approved algorithm (RSA-2048+, EC-P256+) and originate from a certified HSM.", - "Attributes": [ - { - "FamilyName": "Key Lifecycle Management", - "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", - "Section": "CCC.KeyMgmt.CN04 Validate Imported Keys", - "SubSection": "", - "SubSectionObjective": "Accept only externally generated keys that meet approved cryptographic strength and provenance requirements.", - "Applicability": [ - "tlp-green" - ], - "Recommendation": "Implement an approval workflow that validates attestation data before import.", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.KeyMgmt.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_not_deleted_unintentionally", - "kms_cmk_rotation_enabled", - "kms_cmk_not_multi_region", - "kms_cmk_are_used" + "s3_bucket_acl_prohibited" ] }, { @@ -1136,8 +3950,8 @@ "Description": "When a single client sends more than 2000 requests within any 5-minute sliding window, the load balancer MUST throttle all subsequent requests from that client for at least 60 seconds.", "Attributes": [ { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", "SubSection": "", "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", @@ -1146,7 +3960,7 @@ "tlp-amber", "tlp-red" ], - "Recommendation": "Implement per-IP token-bucket limits with and verify via synthetic traffic tests. ", + "Recommendation": "Implement per-IP token-bucket limits with and verify via synthetic traffic tests.", "SectionThreatMappings": [ { "ReferenceId": "LB", @@ -1177,9 +3991,9 @@ } ], "Checks": [ - "elbv2_logging_enabled", - "elb_logging_enabled", - "vpc_flow_logs_enabled" + "wafv2_webacl_with_rules", + "waf_regional_webacl_with_rules", + "waf_global_webacl_with_rules" ] }, { @@ -1187,8 +4001,8 @@ "Description": "When throttling is invoked, the load balancer MUST record the event in the access log within 5 minutes for alerting and trend analysis.", "Attributes": [ { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", "SubSection": "", "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", @@ -1197,7 +4011,7 @@ "tlp-amber", "tlp-red" ], - "Recommendation": "Enable access logging and configure metric filters on HTTP 429 counts to trigger alerts. ", + "Recommendation": "Enable access logging and configure metric filters on HTTP 429 counts to trigger alerts.", "SectionThreatMappings": [ { "ReferenceId": "LB", @@ -1228,9 +4042,10 @@ } ], "Checks": [ + "wafv2_webacl_logging_enabled", + "waf_global_webacl_logging_enabled", "elbv2_logging_enabled", - "elb_logging_enabled", - "vpc_flow_logs_enabled" + "elb_logging_enabled" ] }, { @@ -1238,8 +4053,8 @@ "Description": "When more than 10 percent of targets change from healthy to unhealthy within five minutes, an alert MUST be issued.", "Attributes": [ { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", "Section": "CCC.LB.CN06 Secure Health-Check Telemetry", "SubSection": "", "SubSectionObjective": "Monitor health-check endpoints for tampering and alert on abnormal status changes.", @@ -1248,7 +4063,7 @@ "tlp-amber", "tlp-red" ], - "Recommendation": "Instrument metrics for health check results and target removal events. Configure monitoring alarms to alert on abnormal spikes in unhealthy targets. ", + "Recommendation": "Instrument metrics for health check results and target removal events. Configure monitoring alarms to alert on abnormal spikes in unhealthy targets.", "SectionThreatMappings": [ { "ReferenceId": "LB", @@ -1276,9 +4091,7 @@ "Checks": [ "elbv2_logging_enabled", "elb_logging_enabled", - "cloudwatch_alarm_actions_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "vpc_flow_logs_enabled" + "cloudwatch_alarm_actions_enabled" ] }, { @@ -1287,7 +4100,7 @@ "Attributes": [ { "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", "Section": "CCC.LB.CN04 Enforce Distribution Policies", "SubSection": "", "SubSectionObjective": "Ensure traffic-splitting weights and algorithms are modified only by trusted identities.", @@ -1296,7 +4109,7 @@ "tlp-amber", "tlp-red" ], - "Recommendation": "Define a list of trusted principals allowed to modify routing configurations. Enforce via conditional access policies, and log changes using audit logging. ", + "Recommendation": "Define a list of trusted principals allowed to modify routing configurations. Enforce via conditional access policies, and log changes using audit logging.", "SectionThreatMappings": [ { "ReferenceId": "LB", @@ -1323,11 +4136,7 @@ ], "Checks": [ "cloudtrail_cloudwatch_logging_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "iam_policy_attached_only_to_group_or_roles", - "iam_group_administrator_access_policy", - "iam_role_administratoraccess_policy", - "iam_user_administrator_access_policy" + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" ] }, { @@ -1336,7 +4145,7 @@ "Attributes": [ { "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", "Section": "CCC.LB.CN05 Validate Session Affinity", "SubSection": "", "SubSectionObjective": "Configure session persistence to minimise fixation and hijacking risks.", @@ -1345,7 +4154,7 @@ "tlp-amber", "tlp-red" ], - "Recommendation": "Audit CCC.LB.F15 parameters via configuration scans.", + "Recommendation": "Audit CCC.LB.CP15 parameters via configuration scans.", "SectionThreatMappings": [ { "ReferenceId": "LB", @@ -1370,22 +4179,7 @@ ] } ], - "Checks": [ - "iam_user_administrator_access_policy", - "iam_group_administrator_access_policy", - "iam_role_administratoraccess_policy", - "iam_inline_policy_allows_privilege_escalation", - "iam_inline_policy_no_full_access_to_cloudtrail", - "iam_inline_policy_no_full_access_to_kms", - "iam_inline_policy_no_administrative_privileges", - "iam_policy_allows_privilege_escalation", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_aws_attached_policy_no_administrative_privileges", - "iam_policy_no_full_access_to_cloudtrail", - "iam_policy_no_full_access_to_kms", - "iam_customer_unattached_policy_no_administrative_privileges", - "iam_policy_attached_only_to_group_or_roles" - ] + "Checks": [] }, { "Id": "CCC.LB.CN09.AR01", @@ -1393,7 +4187,7 @@ "Attributes": [ { "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", "Section": "CCC.LB.CN09 Restrict Management API Access", "SubSection": "", "SubSectionObjective": "Limit load-balancer API calls to authorised identities and trusted networks.", @@ -1428,9 +4222,9 @@ } ], "Checks": [ + "bedrock_vpc_endpoints_configured", "vpc_endpoint_services_allowed_principals_trust_boundaries", - "vpc_endpoint_connections_trust_boundaries", - "vpc_endpoint_for_ec2_enabled" + "vpc_endpoint_connections_trust_boundaries" ] }, { @@ -1439,7 +4233,7 @@ "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer. ", + "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer.", "Section": "CCC.LB.CN02 Auto-Scale Load Balancer Capacity", "SubSection": "", "SubSectionObjective": "Expand load-balancer capacity to maintain availability during traffic spikes.", @@ -1476,9 +4270,7 @@ "Checks": [ "autoscaling_group_capacity_rebalance_enabled", "autoscaling_group_elb_health_check_enabled", - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types", - "autoscaling_group_using_ec2_launch_template" + "autoscaling_group_multiple_az" ] }, { @@ -1487,7 +4279,7 @@ "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer. ", + "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer.", "Section": "CCC.LB.CN07 Scrub Sensitive Headers", "SubSection": "", "SubSectionObjective": "Remove headers that disclose internal details or software versions from HTTP responses.", @@ -1501,7 +4293,7 @@ { "ReferenceId": "LB", "Identifiers": [ - "CCC.TH15" + "CCC.Core.TH15" ] } ], @@ -1564,1733 +4356,15 @@ } ], "Checks": [ - "acm_certificates_expiration_check", - "acm_certificates_transparency_logs_enabled", - "acm_certificates_with_secure_key_algorithms" - ] - }, - { - "Id": "CCC.Logging.CN01.AR01", - "Description": "When a new cloud account is created, provider-level audit and network flow logging MUST be enabled by default and directed to the central sink.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", - "SubSection": "", - "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH07" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3" - ] - } - ] - } + "acm_certificates_expiration_check" ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_log_file_validation_enabled", - "vpc_flow_logs_enabled", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "apigateway_restapi_logging_enabled", - "apigateway_restapi_authorizers_enabled", - "apigateway_restapi_public", - "apigatewayv2_api_access_logging_enabled" - ] - }, - { - "Id": "CCC.Logging.CN01.AR02", - "Description": "When a new cloud compute resource is deployed, it MUST be configured to forward all relevant logs (e.g., OS, application, service logs) to the central log sink.", - "Attributes": [ + "ConfigRequirements": [ { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", - "SubSection": "", - "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH07" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3" - ] - } - ] + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 30 } - ], - "Checks": [ - "vpc_flow_logs_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible" - ] - }, - { - "Id": "CCC.Logging.CN02.AR01", - "Description": "When a new log bucket or stream is created, its retention policy MUST be configured in accordance with organisation's data retention policy.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", - "SubSection": "", - "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "GV.PO-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-11" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_group_retention_policy_specific_days_enabled" - ] - }, - { - "Id": "CCC.Logging.CN02.AR02", - "Description": "When a query is performed to retrieve log events older than the number of days defined in the organisation's data retention policy, it MUST return an empty result.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", - "SubSection": "", - "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "GV.PO-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-11" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_group_retention_policy_specific_days_enabled" - ] - }, - { - "Id": "CCC.AuditLog.CN08.AR01", - "Description": "When an attempt is made to modify or delete data before the object lock period expires, then the action MUST be denied.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN03 Enable Object Lock On Log Bucket", - "SubSection": "", - "SubSectionObjective": "Ensure log immutability by enabling Write Once, Read Many (WORM) protection using object lock on log storage buckets. This prevents logs from being modified or deleted during the defined retention period, supporting compliance and forensic integrity.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "Configure object lock policy. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH07" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-9", - "AU-11" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_object_lock" - ] - }, - { - "Id": "CCC.AuditLog.CN04.AR01", - "Description": "When restricted fields are accessed by unauthorized users, then those fields MUST remain masked.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", - "Section": "CCC.Logging.CN04 Restrict Field And Log Type Access", - "SubSection": "", - "SubSectionObjective": "Configure access to logs to follow the principle of least privilege in particular where technically possible limit the log fields users have access to to prevent accidental exposure to sensitive information such as PII.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "Review field level access controls on log data. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-6", - "AU-9", - "AC-3", - "PT-2", - "PT-3", - "PT-3" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_group_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible" - ] - }, - { - "Id": "CCC.Logging.CN05.AR01", - "Description": "When a log storage bucket is created, the bucket's access control settings MUST explicitly deny public read and write access.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", - "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", - "SubSection": "", - "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", - "Applicability": [ - "tlp-red", - "tlp-amber", - "tlp-green" - ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_multi_region_enabled", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "s3_bucket_public_list_acl", - "s3_bucket_public_write_acl", - "s3_bucket_public_access", - "s3_account_level_public_access_blocks", - "s3_bucket_level_public_access_block" - ] - }, - { - "Id": "CCC.Logging.CN05.AR02", - "Description": "When the URL of a log storage bucket's object is accessed publicly, the action MUST be denied by bucket policy.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", - "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", - "SubSection": "", - "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", - "Applicability": [ - "tlp-red", - "tlp-amber", - "tlp-green" - ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_public_access", - "s3_bucket_public_list_acl", - "s3_bucket_public_write_acl", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "s3_bucket_cross_region_replication", - "s3_account_level_public_access_blocks" - ] - }, - { - "Id": "CCC.Logging.CN06.AR01", - "Description": "When a single principal executes an anomalously high number of log queries, an alert MUST be generated.", - "Attributes": [ - { - "FamilyName": "Logging and Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain logging-related events. ", - "Section": "CCC.Logging.CN06 Detect and Alert on Potential Log Exfiltration", - "SubSection": "", - "SubSectionObjective": "Identify and alert on anomalous data access patterns that may indicate an attempt to exfiltrate log data.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-03", - "DE.CM-09" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4", - "CA-7", - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_threat_detection_enumeration", - "cloudtrail_threat_detection_privilege_escalation" - ] - }, - { - "Id": "CCC.Logging.CN07.AR01", - "Description": "When an audit log event is recorded that corresponds to a modification of the logging service configuration such as disabling a log trail, deleting a log sink, or altering a log forwarding rule, an alert MUST be generated.", - "Attributes": [ - { - "FamilyName": "Logging and Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain logging-related events. ", - "Section": "CCC.Logging.CN07 Detect and Alert on Log Service Tampering", - "SubSection": "", - "SubSectionObjective": "Alert when any component of the critical logging infrastructure is disabled, modified, or deleted, indicating a defense evasion attempt.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-03", - "DE.CM-09" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4", - "CA-7", - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_insights_exist", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudtrail_multi_region_enabled_logging_management_events", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR01", - "Description": "When a request is made to read a protected bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_kms_encryption", - "kms_key_not_publicly_accessible", - "iam_policy_no_full_access_to_kms", - "storagegateway_fileshare_encryption_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR02", - "Description": "When a request is made to read a protected object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "kms_key_not_publicly_accessible", - "kms_cmk_not_deleted_unintentionally", - "iam_policy_no_full_access_to_kms", - "iam_inline_policy_no_full_access_to_kms" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR03", - "Description": "When a request is made to write to a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_kms_encryption", - "iam_policy_no_full_access_to_kms", - "kms_key_not_publicly_accessible", - "kms_cmk_not_deleted_unintentionally", - "storagegateway_fileshare_encryption_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR04", - "Description": "When a request is made to write to an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "iam_policy_no_full_access_to_kms", - "iam_inline_policy_no_full_access_to_kms", - "kms_cmk_not_deleted_unintentionally", - "kms_key_not_publicly_accessible", - "kms_cmk_not_multi_region" - ] - }, - { - "Id": "CCC.ObjStor.CN03.AR01", - "Description": "When an object storage bucket deletion is attempted, the bucket MUST be fully recoverable for a set time-frame after deletion is requested.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "CCC.ObjStor.C03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", - "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_object_versioning", - "s3_bucket_object_lock", - "s3_bucket_lifecycle_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN03.AR02", - "Description": "When an attempt is made to modify the retention policy for an object storage bucket, the service MUST prevent the policy from being modified.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "CCC.ObjStor.C03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", - "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_object_versioning", - "s3_bucket_lifecycle_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN04.AR01", - "Description": "When an object is uploaded to the object storage system, the object MUST automatically receive a default retention policy that prevents premature deletion or modification.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN04 Log All Access and Changes", - "SubSection": "CCC.ObjStor.C04 Objects have an Effective Retention Policy by Default", - "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects and ensuring compliance with data retention regulations.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "kinesis_stream_data_retention_period", - "s3_bucket_object_versioning", - "s3_bucket_object_lock" - ] - }, - { - "Id": "CCC.ObjStor.CN04.AR02", - "Description": "When an attempt is made to delete or modify an object that is subject to an active retention policy, the service MUST prevent the action from being completed.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN04 Log All Access and Changes", - "SubSection": "CCC.ObjStor.C04 Objects have an Effective Retention Policy by Default", - "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects and ensuring compliance with data retention regulations.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "kinesis_stream_data_retention_period", - "dynamodb_table_deletion_protection_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR01", - "Description": "When an object is uploaded to the object storage bucket, the object MUST be stored with a unique identifier.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_object_versioning", - "s3_bucket_object_lock" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR02", - "Description": "When an object is modified, the service MUST assign a new unique identifier to the modified object to differentiate it from the previous version.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_object_versioning", - "s3_bucket_object_lock", - "iam_rotate_access_key_90_days", - "ecr_repositories_tag_immutability" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR03", - "Description": "When an object is modified, the service MUST allow for recovery of previous versions of the object.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_object_versioning", - "dynamodb_tables_pitr_enabled", - "backup_recovery_point_encrypted" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR04", - "Description": "When an object is deleted, the service MUST retain other versions of the object to allow for recovery of previous versions.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_object_versioning", - "kinesis_stream_data_retention_period", - "kms_cmk_not_deleted_unintentionally" - ] - }, - { - "Id": "CCC.ObjStor.CN06.AR01", - "Description": "When an object storage bucket is accessed, the service MUST store access logs in a separate data store.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN06 Prevent Deployment in Restricted Regions", - "SubSection": "CCC.ObjStor.C06 Access Logs are Stored in a Separate Data Store", - "SubSectionObjective": "Ensure that access logs for object storage buckets are stored in a separate data store to protect against unauthorized access, tampering, or deletion of logs (Logbuckets are exempt from this requirement, but must be tlp-red).", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH07", - "CCC.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-6" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-07", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.15.0" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-9", - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "s3_bucket_server_access_logging_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN02.AR01", - "Description": "When a permission set is allowed for an object in a bucket, the service MUST allow the same permission set to access all objects in the same bucket.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN02 Ensure Data Encryption at Rest for All Stored Data", - "SubSection": "CCC.ObjStor.C02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", - "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-09" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_public_write_acl" - ] - }, - { - "Id": "CCC.ObjStor.CN02.AR02", - "Description": "When a permission set is denied for an object in a bucket, the service MUST deny the same permission set to access all objects in the same bucket.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN02 Ensure Data Encryption at Rest for All Stored Data", - "SubSection": "CCC.ObjStor.C02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", - "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-09" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_public_write_acl", - "s3_bucket_acl_prohibited", - "s3_bucket_public_access" - ] - }, - { - "Id": "CCC.Monitor.CN01.AR01", - "Description": "When an External Monitoring system exceeds the anticipated rate of monitoring checks then Rate Limiting MUST be applied and an Audit Alert MUST be generated.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", - "Section": "CCC.Monitor.CN01 Rate Limiting on External Monitoring", - "SubSection": "", - "SubSectionObjective": "Prevent DoS attacks using External Monitoring tools.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH03" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.IR-01", - "DE.CM-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-5", - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_threat_detection_enumeration", - "cloudtrail_threat_detection_llm_jacking", - "cloudtrail_threat_detection_privilege_escalation", - "cloudtrail_cloudwatch_logging_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_alarm_actions_alarm_state_configured", - "cloudtrail_multi_region_enabled_logging_management_events" - ] - }, - { - "Id": "CCC.Monitor.CN02.AR01", - "Description": "When an Custom or User-Defined Metric starts to flood a collector, then a rate limit MUST be applied to reduce the network impact of traffic and an alert must triggered.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", - "Section": "CCC.Monitor.CN02 Rate Limiting on Metric Generation", - "SubSection": "", - "SubSectionObjective": "Prevent Malicious Actor or misconfiguration from flooding services with metric data.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-5(2)", - "CA-7", - "SI-4" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured", - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes" - ] - }, - { - "Id": "CCC.Monitor.CN03.AR01", - "Description": "When external systems have approved access to internal systems not normally available for public access then they MUST be secured to prevent unauthorised access jumping through to the internal systems and only allow access to specific internal services.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN03 Access External Monitoring", - "SubSection": "", - "SubSectionObjective": "Control access to Synthetic monitoring solutions using API keys or Certificate based authentication to ensure they don't become an attack path, preventing monitoring systems from forging network requests to gain access to internal systems.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-06", - "PR.IR-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "awslambda_function_url_public", - "apigateway_restapi_public", - "apigateway_restapi_authorizers_enabled", - "apigateway_restapi_public_with_authorizer", - "apigateway_restapi_client_certificate_enabled", - "apigatewayv2_api_authorizers_enabled" - ] - }, - { - "Id": "CCC.Monitor.CN04.AR01", - "Description": "When monitoring dashboards display degraded services which may become potential targets then the dashboard MUST be protected from unauthorised access.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN04 Restrict access to Monitoring Dashboards", - "SubSection": "", - "SubSectionObjective": "Control access to Monitoring Dashboards and reports to ensure they don't highlight an attack path.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-09", - "DE.AE-03" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4", - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_group_not_publicly_accessible", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_cloudwatch_logging_enabled" - ] - }, - { - "Id": "CCC.Monitor.CN05.AR01", - "Description": "When monitoring services have generated an alert, the service MUST ensure only authorised responders silence or acknowledge the alert.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN05 Restrict access to silence or acknowledge an alert", - "SubSection": "", - "SubSectionObjective": "Ensure only a subset of users can silence or acknowledge alerts to prevent attackers hiding their activity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH10" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.IR-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "iam_administrator_access_with_mfa", - "iam_root_mfa_enabled", - "iam_group_administrator_access_policy", - "iam_policy_attached_only_to_group_or_roles", - "iam_inline_policy_allows_privilege_escalation", - "iam_inline_policy_no_full_access_to_cloudtrail" - ] - }, - { - "Id": "CCC.Monitor.CN06.AR01", - "Description": "When systems push metrics or traces they MUST be authenticated for that particular type of metric or trace", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN06 Metrics pushed for authorised services only", - "SubSection": "", - "SubSectionObjective": "Use IAM to control which types of metrics or traces can be pushed by different system to avoid a compromised system pushing fabricated metrics about a different service", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-5" - ] - } - ] - } - ], - "Checks": [ - "awslambda_function_url_public", - "awslambda_function_not_publicly_accessible", - "apigateway_restapi_authorizers_enabled", - "apigatewayv2_api_authorizers_enabled", - "apigateway_restapi_public_with_authorizer", - "apigateway_restapi_public", - "cloudwatch_log_group_not_publicly_accessible" ] }, { @@ -3345,11 +4419,61 @@ } ], "Checks": [ - "ec2_securitygroup_default_restrict_traffic", - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "ec2_securitygroup_allow_ingress_from_internet_to_any_port" + "ec2_securitygroup_default_restrict_traffic" ] }, + { + "Id": "CCC.VPC.CN02.AR01", + "Description": "When a resource is created in a public subnet, that resource MUST NOT be assigned an external IP address by default.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.VPC.CN02 Limit Resource Creation in Public Subnet", + "SubSection": "", + "SubSectionObjective": "Restrict the creation of resources in the public subnet with direct access to the internet to minimize attack surfaces.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-4" + ] + } + ] + } + ], + "Checks": [] + }, { "Id": "CCC.VPC.CN03.AR01", "Description": "When a VPC peering connection is requested, the service MUST prevent connections from VPCs that are not explicitly allowed.", @@ -3461,6 +4585,390 @@ "vpc_flow_logs_enabled" ] }, + { + "Id": "CCC.KeyMgmt.CN01.AR01", + "Description": "When a key version is scheduled for deletion or disabled, an alert MUST be generated within five minutes.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain key-management events.", + "Section": "CCC.KeyMgmt.CN01 Alert on Key-version Changes", + "SubSection": "", + "SubSectionObjective": "Generate near-real-time alerts when a KMS key version is disabled or scheduled for deletion, enabling rapid investigation and recovery.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Use native event services (e.g., CloudWatch Events, Azure Monitor, Cloud Audit Logs) to route notifications to an incident-response channel.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "RS.AN-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IR-5" + ] + } + ] + } + ], + "Checks": [ + "kms_cmk_not_deleted_unintentionally", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk" + ] + }, + { + "Id": "CCC.KeyMgmt.CN02.AR01", + "Description": "When IAM roles and key policies are reviewed, Decrypt permission MUST be granted exclusively to documented authorised principals.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that enforce least-privilege use of KMS operations.", + "Section": "CCC.KeyMgmt.CN02 Limit Decrypt Permissions", + "SubSection": "", + "SubSectionObjective": "Restrict the Decrypt operation to authorised principals only, applying the principle of least privilege to protect sensitive data.", + "Applicability": [ + "tlp-green" + ], + "Recommendation": "Periodically audit policy documents via automated tooling and report any deviations.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_inline_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_kms" + ] + }, + { + "Id": "CCC.KeyMgmt.CN03.AR01", + "Description": "When rotation settings are examined, rotation MUST be enabled with an interval not exceeding 365 days.", + "Attributes": [ + { + "FamilyName": "Key Lifecycle Management", + "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", + "Section": "CCC.KeyMgmt.CN03 Enforce Automatic Rotation", + "SubSection": "", + "SubSectionObjective": "Ensure symmetric keys rotate automatically within policy intervals to reduce exposure of key material.", + "Applicability": [ + "tlp-green" + ], + "Recommendation": "Use cloud-provider rotation features and verify via configuration scanning.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-12" + ] + } + ] + } + ], + "Checks": [ + "kms_cmk_rotation_enabled" + ] + }, + { + "Id": "CCC.KeyMgmt.CN04.AR01", + "Description": "When a key import request is processed, the key MUST use an approved algorithm (RSA-2048+, EC-P256+) and originate from a certified HSM.", + "Attributes": [ + { + "FamilyName": "Key Lifecycle Management", + "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", + "Section": "CCC.KeyMgmt.CN04 Validate Imported Keys", + "SubSection": "", + "SubSectionObjective": "Accept only externally generated keys that meet approved cryptographic strength and provenance requirements.", + "Applicability": [ + "tlp-green" + ], + "Recommendation": "Implement an approval workflow that validates attestation data before import.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-28" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.SecMgmt.CN01.AR01", + "Description": "Attempt to use an outdated version of a secret after its rotation period has passed and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SecMgmt.CN01 Enforce Automatic Secret Rotation", + "SubSection": "", + "SubSectionObjective": "Ensure that secrets are automatically rotated on a defined schedule to reduce the risk of secret compromise and unauthorized access.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH14" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-12", + "SC-28" + ] + } + ] + } + ], + "Checks": [ + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_secret_rotated_periodically" + ] + }, + { + "Id": "CCC.SecMgmt.CN02.AR01", + "Description": "Attempt to retrieve a secret from an unauthorized region and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SecMgmt.CN02 Enforce Secret Replication Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that secrets are replicated only to authorized locations as per organizational data residency and compliance requirements.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "SC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.DataWar.CN01.AR01", + "Description": "Attempt to access underlying database tables directly without using managed views and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN01 Enforce Use of Managed Views for Data Access", + "SubSection": "", + "SubSectionObjective": "Ensure that data access is provided through managed views, restricting users from accessing underlying tables directly and enforcing consistent security policies.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.DataWar.CN02.AR01", + "Description": "Attempt to query sensitive columns without the necessary permissions and verify that access is denied or data is masked.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN02 Enforce Column-Level Security Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that access to sensitive data columns is restricted based on user roles, preventing unauthorized access to sensitive information.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.DataWar.CN03.AR01", + "Description": "Attempt to query data rows that the user should not have access to and verify that access is denied or data is not returned.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN03 Enforce Row-Level Security Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that access to data rows is restricted based on user roles or attributes, preventing unauthorized access to specific subsets of data.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, { "Id": "CCC.Vector.CN01.AR01", "Description": "When a vector embedding is submitted for indexing, the system MUST validate that it matches expected schema, dimension, and format profiles.", @@ -3484,7 +4992,7 @@ "Identifiers": [ "CCC.Vector.TH02", "CCC.Vector.TH05", - "CCC.TH12" + "CCC.Core.TH12" ] } ], @@ -3523,7 +5031,7 @@ "Identifiers": [ "CCC.Vector.TH02", "CCC.Vector.TH04", - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -3538,17 +5046,9 @@ } ], "Checks": [ - "iam_group_administrator_access_policy", - "iam_user_administrator_access_policy", - "iam_role_administratoraccess_policy", - "iam_administrator_access_with_mfa", - "iam_inline_policy_allows_privilege_escalation", - "iam_inline_policy_no_full_access_to_cloudtrail", - "iam_inline_policy_no_full_access_to_kms", - "iam_policy_attached_only_to_group_or_roles", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_customer_unattached_policy_no_administrative_privileges", - "iam_no_custom_policy_permissive_role_assumption" + "opensearch_service_domains_access_control_enabled", + "opensearch_service_domains_internal_user_database_enabled", + "iam_role_administratoraccess_policy" ] }, { @@ -3571,7 +5071,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH03", - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -3610,7 +5110,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH02", - "CCC.TH12" + "CCC.Core.TH12" ] } ], @@ -3646,8 +5146,8 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH04", - "CCC.TH09", - "CCC.TH04" + "CCC.Core.TH09", + "CCC.Core.TH04" ] } ], @@ -3685,7 +5185,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH05", - "CCC.TH06" + "CCC.Core.TH06" ] } ], @@ -3730,457 +5230,25 @@ "Checks": [] }, { - "Id": "CCC.Core.CN01.AR01", - "Description": "When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + "Id": "CCC.RDMS.CN01.AR02", + "Description": "When an attempt is made to authenticate to the database using known default credentials, the authentication attempt must fail and no access should be granted.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN01 Password Management", "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Most cloud services enable TLS 1.3 by default. Where it is not already set, ensure that your services are configured or updated accordingly. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "cloudfront_distributions_origin_traffic_encrypted", - "cloudfront_distributions_https_enabled", - "cloudfront_distributions_https_sni_enabled", - "s3_bucket_secure_transport_policy", - "dms_endpoint_redis_in_transit_encryption_enabled", - "kafka_cluster_in_transit_encryption_enabled", - "transfer_server_in_transit_encryption_enabled", - "redshift_cluster_in_transit_encryption_enabled", - "rds_instance_transport_encrypted" - ] - }, - { - "Id": "CCC.Core.CN01.AR02", - "Description": "When a port is exposed for SSH network traffic, all traffic MUST include a SSH handshake AND be encrypted using SSHv2 or higher.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Any time port 22 is exposed, ensure that it has a properly implemented SSH server with SSHv2 enabled and configured with strong ciphers. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "ec2_instance_port_ssh_exposed_to_internet", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "ec2_networkacl_allow_ingress_tcp_port_22" - ] - }, - { - "Id": "CCC.Core.CN01.AR03", - "Description": "When the service receives unencrypted traffic, then it MUST either block the request or automatically redirect it to the secure equivalent.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Review firewall, load balancer, and application configurations to ensure insecure protocols such as HTTP, FTP, and Telnet are not exposed. Where possible, implement automatic redirection to secure protocols such as HTTPS, SFTP, SSH, and regularly scan for protocol drift. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "cloudfront_distributions_https_enabled", - "cloudfront_distributions_https_sni_enabled", - "cloudfront_distributions_origin_traffic_encrypted", - "opensearch_service_domains_https_communications_enforced", - "transfer_server_in_transit_encryption_enabled", - "s3_bucket_secure_transport_policy", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" - ] - }, - { - "Id": "CCC.Core.CN01.AR07", - "Description": "When a port is exposed, the service MUST ensure that the protocol and service officially assigned to that port number by the IANA Service Name and Transport Protocol Port Number Registry, and no other, is run on that port.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Reference the IANA Service Name and Transport Protocol Port Number Registry for more information about correct protocol-to-port assignments. Avoid running non-standard services on well-known ports. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "cloudfront_distributions_origin_traffic_encrypted", - "rds_instance_transport_encrypted", - "redshift_cluster_in_transit_encryption_enabled", - "kafka_cluster_in_transit_encryption_enabled", - "transfer_server_in_transit_encryption_enabled", - "dms_endpoint_redis_in_transit_encryption_enabled", - "dms_endpoint_ssl_enabled", - "s3_bucket_secure_transport_policy" - ] - }, - { - "Id": "CCC.Core.CN01.AR08", - "Description": "When a service transmits data using TLS, mutual TLS (mTLS) MUST be implemented to require both client and server certificate authentication for all connections.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Configure mTLS for all endpoints that process or transmit sensitive data. Ensure both client and server certificates are validated and managed securely. Regularly review certificate authorities and automate certificate rotation where possible. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "apigateway_restapi_client_certificate_enabled", - "cloudfront_distributions_custom_ssl_certificate", - "cloudfront_distributions_https_sni_enabled", - "cloudfront_distributions_origin_traffic_encrypted", - "acm_certificates_expiration_check", - "acm_certificates_with_secure_key_algorithms", - "acm_certificates_transparency_logs_enabled", - "s3_bucket_secure_transport_policy", - "dms_endpoint_ssl_enabled", - "dms_endpoint_redis_in_transit_encryption_enabled", - "transfer_server_in_transit_encryption_enabled", - "kafka_cluster_in_transit_encryption_enabled", - "kafka_cluster_mutual_tls_authentication_enabled" - ] - }, - { - "Id": "CCC.Core.CN13.AR01", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST only use valid, unexpired certificates issued by a trusted certificate authority.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "acm_certificates_expiration_check", - "acm_certificates_transparency_logs_enabled", - "acm_certificates_with_secure_key_algorithms" - ] - }, - { - "Id": "CCC.Core.CN13.AR02", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 180 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "SubSectionObjective": "Ensure default vendor-supplied DB administrator credentials are replaced with strong, unique passwords and that these credentials are properly managed using a secure password or secrets management solution.", "Applicability": [ + "tlp-red", "tlp-amber" ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "acm_certificates_expiration_check" - ] - }, - { - "Id": "CCC.Core.CN13.AR03", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 90 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "acm_certificates_expiration_check" - ] - }, - { - "Id": "CCC.Core.CN06.AR01", - "Description": "When the service is running, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", - "SubSection": "", - "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate the service's deployment location is included in this list. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH03" + "CCC.RDMS.TH01" ] } ], @@ -4188,19 +5256,92 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.AA-01" ] }, { - "ReferenceId": "CCM", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "DSP-19" + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "rds_cluster_default_admin", + "rds_instance_default_admin" + ] + }, + { + "Id": "CCC.RDMS.CN02.AR01", + "Description": "When repeated failed login attempts are made in a short timeframe, the account must be locked out or rate-limited to prevent further login attempts.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN02 Account Lockout and Rate-Limiting", + "SubSection": "", + "SubSectionObjective": "Ensure the database enforces lockouts or rate-limiting after a specified number of failed authentication attempts. This prevents brute force or password-guessing attacks from succeeding.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-1" ] }, { - "ReferenceId": "ISO_27001", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "2013 A.11.1.1" + "AC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.RDMS.CN04.AR01", + "Description": "When there is an attempt to perform a backup or restore, then the attempt must fail with an access denied message if credentials or roles that are not explicitly authorized for backup/restore functions.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN04 Access Control for Backup and Restore Operations", + "SubSection": "", + "SubSectionObjective": "Restrict who can initiate, manage, and validate database backup or restore operations through strict role-based or least-privilege access. Prevents accidental or malicious restorations, protecting data integrity and availability.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" ] }, { @@ -4213,33 +5354,30 @@ } ], "Checks": [ - "organizations_scp_check_deny_regions", - "vpc_endpoint_connections_trust_boundaries", - "vpc_endpoint_services_allowed_principals_trust_boundaries" + "iam_inline_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges" ] }, { - "Id": "CCC.Core.CN06.AR02", - "Description": "When a child resource is deployed, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Id": "CCC.RDMS.CN05.AR01", + "Description": "When an attempt is made to share a snapshot with an unauthorized account, the sharing request must be denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN05 Restrict Snapshot Sharing to Authorized Accounts", "SubSection": "", - "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "SubSectionObjective": "Ensure database snapshots can only be shared with explicitly authorized accounts, thereby minimizing the risk of data exposure or exfiltration.", "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], - "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate that child resources can only be deployed to locations included in this list. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH03" + "CCC.RDMS.TH05" ] } ], @@ -4247,24 +5385,105 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-19" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.11.1.1" + "PR.DS-10" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ + "AC-4" + ] + } + ] + } + ], + "Checks": [ + "rds_snapshots_public_access", + "neptune_cluster_public_snapshot", + "documentdb_cluster_public_snapshot", + "ec2_ami_public" + ] + }, + { + "Id": "CCC.RDMS.CN03.AR01", + "Description": "When backups are disabled, paused, or fail to run as scheduled, an alert must be triggered and logged.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN03 Enforce and Monitor Automated Backups", + "SubSection": "", + "SubSectionObjective": "Ensure database backups are automatically scheduled, actively monitored, and promptly reported if any disruptions occur. This helps maintain data integrity, facilitates disaster recovery, and supports business continuity when a system failure or breach occurs.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "CP-9" + ] + } + ] + } + ], + "Checks": [ + "rds_instance_backup_enabled", + "rds_cluster_critical_event_subscription", + "neptune_cluster_backup_enabled", + "documentdb_cluster_backup_enabled" + ] + }, + { + "Id": "CCC.Build.CN01.AR01", + "Description": "Attempt to initiate a build using an unauthorized build agent and verify that the build is rejected.", + "Attributes": [ + { + "FamilyName": "Access Control", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN01 Restrict Allowed Build Agents", + "SubSection": "", + "SubSectionObjective": "Ensure that builds are executed only on authorized build agents to maintain control over the build environment and prevent unauthorized code execution.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", "AC-6" ] } @@ -4272,92 +5491,31 @@ } ], "Checks": [ - "organizations_scp_check_deny_regions", - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "vpc_endpoint_connections_trust_boundaries" + "codebuild_project_uses_allowed_github_organizations", + "codebuild_project_user_controlled_buildspec", + "codebuild_project_no_secrets_in_variables" ] }, { - "Id": "CCC.Core.CN08.AR01", - "Description": "When data is created or modified, the data MUST have a complete and recoverable duplicate that is stored in a physically separate data center.", + "Id": "CCC.Build.CN02.AR01", + "Description": "Attempt to trigger a build from an unauthorized external service or repository and verify that the build does not start.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "FamilyName": "Access Control", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN02 Restrict Allowed External Services for Build Triggers", "SubSection": "", - "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "SubSectionObjective": "Ensure that builds can only be triggered by authorized external services or repositories to prevent unauthorized code execution or tampering.", "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement automated data replication processes to ensure that data is consistently duplicated in another region or availability zone. Regularly test data recovery from the replicated location to ensure integrity and availability. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PT-5" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "BCR-08", - "BCR-10", - "BCR-11" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "CP-2", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_cross_region_replication", - "cloudtrail_multi_region_enabled", - "backup_plans_exist", - "backup_vaults_exist", - "backup_vaults_encrypted", - "dynamodb_table_protected_by_backup_plan", - "rds_cluster_protected_by_backup_plan", - "rds_instance_protected_by_backup_plan" - ] - }, - { - "Id": "CCC.Core.CN08.AR02", - "Description": "When data is replicated into a second location, the service MUST be able to accurately represent the replication locations, replication status, and data synchronization status.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", - "SubSection": "", - "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06" + "CCC.Core.TH01" ] } ], @@ -4365,41 +5523,165 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.PT-5" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "BCR-08", - "BCR-10", - "BCR-11" + "PR.AC-4" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "CP-2", - "CP-10" + "AC-3", + "AC-6" ] } ] } ], "Checks": [ - "s3_bucket_cross_region_replication" + "codebuild_project_uses_allowed_github_organizations", + "codebuild_project_source_repo_url_no_sensitive_credentials" ] }, { - "Id": "CCC.Core.CN09.AR01", - "Description": "When the service is operational, its logs and any child resource logs MUST NOT be accessible from the resource they record access to.", + "Id": "CCC.Build.CN03.AR01", + "Description": "Attempt to access the build environment from an external network and verify that access is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN03 Deny External Network Access for Build Environments", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Ensure that build environments do not have external network access to prevent unauthorized external access and data exfiltration.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02", + "CCC.Core.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7", + "SC-5" + ] + } + ] + } + ], + "Checks": [ + "codebuild_project_not_publicly_accessible" + ] + }, + { + "Id": "CCC.CntrReg.CN01.AR01", + "Description": "Attempt to push an artifact with known vulnerabilities to the registry and observe if it is flagged or rejected by the vulnerability scanning process.", + "Attributes": [ + { + "FamilyName": "Risk Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.CntrReg.CN01 Implement Vulnerability Scanning for Artifacts", + "SubSection": "", + "SubSectionObjective": "Ensure that container images and artifacts stored in the container registry are scanned for vulnerabilities to identify and remediate security issues before deployment.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.CntrReg.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "ID.RA-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "RA-5", + "SI-5" + ] + } + ] + } + ], + "Checks": [ + "ecr_registry_scan_images_on_push_enabled", + "ecr_repositories_scan_vulnerabilities_in_latest_image" + ] + }, + { + "Id": "CCC.CntrReg.CN02.AR01", + "Description": "Confirm that artifacts older than the specified retention period are automatically deleted from the registry.", + "Attributes": [ + { + "FamilyName": "Data Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.CntrReg.CN02 Implement Cleanup Policies for Artifacts", + "SubSection": "", + "SubSectionObjective": "Ensure that unused or outdated artifacts are cleaned up according to defined policies to manage storage effectively and reduce security risks associated with outdated versions.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH14" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN01.AR01", + "Description": "When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating credentials or generating temporary session tokens.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN01 Restrict IAM User Credentials Creation", + "SubSection": "", + "SubSectionObjective": "Prevent non-administrative principals from creating new long-lived credentials like access keys or generating temporary session tokens. This blocks a common privilege escalation and persistence vector.", "Applicability": [ "tlp-clear", "tlp-green", @@ -4411,9 +5693,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH03" ] } ], @@ -4421,61 +5701,52 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" ] } ] } ], "Checks": [ - "cloudtrail_bucket_requires_mfa_delete", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled" + "iam_no_root_access_key", + "iam_user_no_setup_initial_access_key", + "iam_user_two_active_access_key", + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges" ] }, { - "Id": "CCC.Core.CN09.AR02", - "Description": "When the service is operational, disabling the logs for the service or its child resources MUST NOT be possible without also disabling the corresponding resource.", + "Id": "CCC.IAM.CN01.AR02", + "Description": "When a non-administrative principal attempts to create new credentials or a temporary session token, the service MUST deny the action.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN01 Restrict IAM User Credentials Creation", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Prevent non-administrative principals from creating new long-lived credentials like access keys or generating temporary session tokens. This blocks a common privilege escalation and persistence vector.", "Applicability": [ "tlp-clear", "tlp-green", "tlp-amber", "tlp-red" ], - "Recommendation": "No normal business operations should disable logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging mechanisms are tightly integrated with service operations, so that logging cannot be disabled without stopping the service itself. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH03" ] } ], @@ -4483,63 +5754,52 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" ] } ] } ], "Checks": [ - "cloudtrail_kms_encryption_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_log_file_validation_enabled", - "vpc_flow_logs_enabled", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled", - "cloudwatch_log_group_not_publicly_accessible", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" + "iam_no_root_access_key", + "iam_user_no_setup_initial_access_key", + "iam_user_two_active_access_key", + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges" ] }, { - "Id": "CCC.Core.CN09.AR03", - "Description": "When the service is operational, any attempt to redirect logs for the service or its child resources MUST NOT be possible without halting operation of the corresponding resource and publishing corresponding events to monitored channels.", + "Id": "CCC.IAM.CN02.AR01", + "Description": "When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating, updating, or attaching policies.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN02 Restrict IAM Policies Modification", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Ensure that only designated administrative accounts have the ability to create, modify, or attach policies that define permissions for other identities.", "Applicability": [ + "tlp-clear", + "tlp-green", "tlp-amber", "tlp-red" ], - "Recommendation": "No normal business operations should result in the redirection of logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging configurations are immutable during service operation so that any changes require stopping the service and publishing corresponding events to monitored channels. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH06" ] } ], @@ -4547,55 +5807,91 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" ] } ] } ], "Checks": [ - "cloudtrail_insights_exist", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_log_file_validation_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "s3_bucket_server_access_logging_enabled" + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_allows_privilege_escalation" ] }, { - "Id": "CCC.Core.CN10.AR01", - "Description": "When data is replicated, the service MUST ensure that replication only occurs to destinations that are explicitly included within the defined trust perimeter.", + "Id": "CCC.IAM.CN02.AR02", + "Description": "When a non-administrative principal attempts to create, update, or attach policies, the service MUST deny the action.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN10 Restrict Data Replication to Trust Perimeter", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN02 Restrict IAM Policies Modification", "SubSection": "", - "SubSectionObjective": "Ensure that data is only replicated on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "SubSectionObjective": "Ensure that only designated administrative accounts have the ability to create, modify, or attach policies that define permissions for other identities.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-5", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_allows_privilege_escalation" + ] + }, + { + "Id": "CCC.IAM.CN03.AR01", + "Description": "When a policy is created or updated that grants a principal permission to assume a role or impersonate a service identity, the principal MUST NOT contain a wildcard or be public/anonymous.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN03 Restrict Role Assumption / Delegation", + "SubSection": "", + "SubSectionObjective": "Limit which principals can assume a role or impersonate a service identity to only those required. This prevents unintended cross-account or public access by securing the \"who can act as this identity\" boundary.", "Applicability": [ "tlp-green", "tlp-amber", @@ -4606,7 +5902,1541 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH04" + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_role_cross_account_readonlyaccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "iam_no_custom_policy_permissive_role_assumption" + ] + }, + { + "Id": "CCC.IAM.CN03.AR02", + "Description": "When an external or unauthenticated principal tries to assume a role or impersonate a service identity, the service MUST deny the action.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN03 Restrict Role Assumption / Delegation", + "SubSection": "", + "SubSectionObjective": "Limit which principals can assume a role or impersonate a service identity to only those required. This prevents unintended cross-account or public access by securing the \"who can act as this identity\" boundary.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_role_cross_account_readonlyaccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "iam_no_custom_policy_permissive_role_assumption" + ] + }, + { + "Id": "CCC.IAM.CN04.AR01", + "Description": "When an IAM policy is created or updated, it MUST NOT contain allow statements with wildcard permissions, unless the statement is restricted by a condition.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN04 Restrict Wildcard Usage in IAM Policies", + "SubSection": "", + "SubSectionObjective": "Limit the use of wildcard permissions in IAM policies to prevent overly broad access from being granted by default.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01", + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_inline_policy_no_full_access_to_cloudtrail" + ] + }, + { + "Id": "CCC.IAM.CN05.AR01", + "Description": "When a new cloud account is provisioned, a password policy MUST be configured for IAM users following the minimum PCI DSS v4.0.1 configurations.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN05 Strong Password Policies for IAM Users", + "SubSection": "", + "SubSectionObjective": "Ensure that the password policies for IAM users have strong configurations.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "When a new cloud account is provisioned, a password policy must be configured for all IAM users to align with the minimum requirements defined in PCI DSS v4.0.1. This includes, at a minimum: strength: 0 # Not yet specified - reference-id: A password length of at least 12 characters. strength: 0 # Not yet specified - reference-id: A mix of upper- and lower-case letters, numbers, and special characters. strength: 0 # Not yet specified - reference-id: Prevention of the use of previously used passwords (password history). strength: 0 # Not yet specified - reference-id: Password expiration at a defined interval (e.g., every 90 days). strength: 0 # Not yet specified - reference-id: Account lockout after a defined number of failed login attempts.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IA-5" + ] + }, + { + "ReferenceId": "PCI-DSS", + "Identifiers": [ + "8.3.9", + "8.6.3" + ] + } + ] + } + ], + "Checks": [ + "iam_password_policy_minimum_length_14", + "iam_password_policy_uppercase", + "iam_password_policy_lowercase", + "iam_password_policy_symbol", + "iam_password_policy_number", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_reuse_24" + ] + }, + { + "Id": "CCC.IAM.CN06.AR01", + "Description": "When a static credential such as an access key has existed for 90 days or more, it MUST be rotated.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN06 Maximum Age for Long-Term Static Credentials", + "SubSection": "", + "SubSectionObjective": "Ensure that long-lived static credentials like access keys are programmatically rotated within a defined time period to limit the window of opportunity if compromised.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "When a static credential such as an access key has existed for 90 days or more, it must be automatically rotated to reduce the risk of compromise due to long-term exposure. Organizations should implement automated checks to identify aging credentials and enforce rotation policies. Additionally, access key usage should be regularly monitored, and credentials that are no longer in use should be deactivated or deleted promptly. Where possible, prefer temporary, short-lived credentials over long-lived static ones to further minimize risk.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH09", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "iam_rotate_access_key_90_days" + ] + }, + { + "Id": "CCC.IAM.CN07.AR01", + "Description": "When a user account is disabled or deleted in the organization's IdP, the corresponding cloud identity and its access policies MUST be disabled or deleted within 24 hours.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN07 Automate Identity De-provisioning", + "SubSection": "", + "SubSectionObjective": "Ensure that when an identity is terminated in the central Identity Provider (IdP), ts corresponding access to cloud resources is revoked automatically.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH10", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "iam_user_console_access_unused", + "iam_user_accesskey_unused" + ] + }, + { + "Id": "CCC.IAM.CN08.AR01", + "Description": "When an IAM user has credentials, such as passwords or access keys, that have not been used for 90 days or more, the unused credentials MUST be removed or deactivated.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN08 Maximum Age for Unused Credentials", + "SubSection": "", + "SubSectionObjective": "Ensure that unused IAM credentals are removed to reduce exposure in the event of potential compromise.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "IAM user credentials (such as passwords or access keys) that have not been used for 90 days or more must be automatically removed or deactivated.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH11", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } + ] + }, + { + "Id": "CCC.IAM.CN09.AR01", + "Description": "When a human user accesses the cloud environment, they MUST authenticate through the organization's federated IdP via a standard protocol (e.g., SAML, OIDC).", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN09 Enforce Federated Single Sign-On (SSO) for Human Users", + "SubSection": "", + "SubSectionObjective": "Ensure that all human users must authenticate through a central, federated Identity Provider (IdP) to access the cloud environment. This eliminates cloud-native user accounts with long-lived passwords, centralizes authentication controls, and simplifies lifecycle management.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01", + "CCC.IAM.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IA-2" + ] + } + ] + } + ], + "Checks": [ + "iam_check_saml_providers_sts" + ] + }, + { + "Id": "CCC.IAM.CN10.AR01", + "Description": "When suspicious API requests are detected, real time alerts MUST be generated to notify security personnel.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN10 Alert On Anomalous Behaviour", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when anomalous API requests are made by a single identity, such as API requests commonly associated with privilege escalation tactics, originating from an external or malicious IP address or performed by a previously dormant identity, which may indicate that credentals may be compromised, as well as for password brute-force attempts and account lockouts.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "SI-5", + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "CCC.IAM.CN10.AR02", + "Description": "When suspicious API requests are detected, the associated events MUST be logged, including the source details, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN10 Alert On Anomalous Behaviour", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when anomalous API requests are made by a single identity, such as API requests commonly associated with privilege escalation tactics, originating from an external or malicious IP address or performed by a previously dormant identity, which may indicate that credentals may be compromised, as well as for password brute-force attempts and account lockouts.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "SI-5", + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ] + }, + { + "Id": "CCC.IAM.CN11.AR01", + "Description": "When a cloud account or organization is provisioned, the native automated access and usage analysis services MUST be enabled to continuously monitor for external or public access to resources, and unused access.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN11 Enable Continuous IAM Access and Usage Analysis", + "SubSection": "", + "SubSectionObjective": "Enable and configure the cloud provider's native access and usage analysis services to continuously monitor for external access paths and internal unused access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH02", + "CCC.IAM.TH10", + "CCC.IAM.TH11" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "ID.RA-01", + "ID.IM-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "CA-7", + "RA-5" + ] + } + ] + } + ], + "Checks": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "Id": "CCC.GenAI.CN01.AR01", + "Description": "Untrusted input such as user queries, RAG data or tool output MUST be validated before it is passed to a GenAI model.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN01 Model Input Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate input before it is passed to a GenAI model in order to filter or sanitise adversarial queries and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Input Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0021", + "AML.M0015" + ] + } + ] + } + ], + "Checks": [ + "bedrock_agent_guardrail_enabled", + "bedrock_guardrail_prompt_attack_filter_enabled", + "bedrock_guardrail_sensitive_information_filter_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN01.AR02", + "Description": "If malicious patterns such as prompt injection or sensitive data are detected during input validation, the input MUST be blocked or sanitised.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN01 Model Input Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate input before it is passed to a GenAI model in order to filter or sanitise adversarial queries and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Input Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0021", + "AML.M0015" + ] + } + ] + } + ], + "Checks": [ + "bedrock_agent_guardrail_enabled", + "bedrock_guardrail_prompt_attack_filter_enabled", + "bedrock_guardrail_sensitive_information_filter_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN02.AR01", + "Description": "GenAI model output MUST be validated for format conformance, malicious patterns, sensitive data and inapropriate content before being passed to users, application or plugins.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN02 Model Output Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate GenAI model output before passing it to users, applications or plugins in order to filter or sanitise insecure or unreliable output and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03", + "CCC.GenAI.TH04", + "CCC.GenAI.TH05", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Output Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0002" + ] + } + ] + } + ], + "Checks": [ + "bedrock_agent_guardrail_enabled", + "bedrock_guardrail_sensitive_information_filter_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN02.AR02", + "Description": "In the event of policy violations, the AI-generated content MUST be redacted, encoded or rejected.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN02 Model Output Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate GenAI model output before passing it to users, applications or plugins in order to filter or sanitise insecure or unreliable output and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03", + "CCC.GenAI.TH04", + "CCC.GenAI.TH05", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Output Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0002" + ] + } + ] + } + ], + "Checks": [ + "bedrock_agent_guardrail_enabled", + "bedrock_guardrail_sensitive_information_filter_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN03.AR01", + "Description": "When data is designated for model training or RAG ingestion, then its source MUST be explicitly approved and its provenance documented.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN03 Data Provenance and Source Vetting", + "SubSection": "", + "SubSectionObjective": "Ensure that all data for training, fine-tuning or RAG comes from trusted, approved sources and is authorised for the intended purposes in order to prevent the initial introduction of malicious content or leaked sensitive data.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-006" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Management" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0025" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN03.AR02", + "Description": "Data from unvetted sources MUST NOT be used in production systems.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN03 Data Provenance and Source Vetting", + "SubSection": "", + "SubSectionObjective": "Ensure that all data for training, fine-tuning or RAG comes from trusted, approved sources and is authorised for the intended purposes in order to prevent the initial introduction of malicious content or leaked sensitive data.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-006" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Management" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0025" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN04.AR01", + "Description": "When data is ingested for training, fine-tuning or conversion to vector embeddings, it MUST be validated for sensitive information or malicious content.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN04 Sanitisation of Ingested Data", + "SubSection": "", + "SubSectionObjective": "Validate and sanitise all data ingested by GenAI systems from extenal sources or internal knowledge bases, whether for training, conversion to vector embeddings, or real-time retireval, in order to remove or redact poisoned or sensitive data before further processing.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-002" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0007" + ] + } + ] + } + ], + "Checks": [ + "bedrock_guardrail_sensitive_information_filter_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN04.AR02", + "Description": "If sensitive data or malicious content is detected, it must be rejected, redacted or flagged for manual review.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN04 Sanitisation of Ingested Data", + "SubSection": "", + "SubSectionObjective": "Validate and sanitise all data ingested by GenAI systems from extenal sources or internal knowledge bases, whether for training, conversion to vector embeddings, or real-time retireval, in order to remove or redact poisoned or sensitive data before further processing.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-002" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0007" + ] + } + ] + } + ], + "Checks": [ + "bedrock_guardrail_sensitive_information_filter_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN05.AR01", + "Description": "When a RAG-enabled system generates a response containing information retrieved from its knowledge base, then the response MUST include a verifiable citation that links back to the specific source document.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN05 Citations and Source Traceability", + "SubSection": "", + "SubSectionObjective": "Require the GenAI system to provide citations or direct links back to the source documents used to generate a response, in to enhance the transparency, trustworthiness, and verifiability of AI-generated content.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH09", + "CCC.GenAI.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-DET-013" + ] + } + ] + } + ], + "Checks": [ + "bedrock_model_invocation_logging_enabled", + "bedrock_model_invocation_logs_encryption_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN06.AR01", + "Description": "When an LLM invokes an external tool (e.g., an API, a plugin), then the tool MUST operate with the least privileges required for performing its intended functionality.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.GenAI.CN06 Least Privilege for Plugins", + "SubSection": "", + "SubSectionObjective": "Restricts the permissions of any external tools the GenAI system can call to limit the potential damage if an agent is coerced to perform unintended actions or vulnerabilities in the tools are exploited.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH07", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Agent Permissions" + ] + } + ] + } + ], + "Checks": [ + "bedrock_api_key_no_administrative_privileges", + "bedrock_api_key_no_long_term_credentials" + ] + }, + { + "Id": "CCC.GenAI.CN07.AR01", + "Description": "When an application makes an API call to a foundational model in a production environment, then it MUST specify an explicit version identifier.", + "Attributes": [ + { + "FamilyName": "Configuration Management", + "FamilyDescription": "The Configuration Management control family involves establishing, maintaining and monitoring the configuration of the service and related applications and infrastructure to ensure consistency, secure defaults and compliance.", + "Section": "CCC.GenAI.CN07 Model Version Pinning", + "SubSection": "", + "SubSectionObjective": "Mandate that applications are locked (\"pinned\") to a specific, tested version of a foundational model to prevent unexpected behaviour changes introduced by provider-side updates.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-010" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN08.AR01", + "Description": "When a new AI model is considered for production deployment, it MUST undergo a formal red teaming and quality assurance review.", + "Attributes": [ + { + "FamilyName": "Model Assurance and Evaluation", + "FamilyDescription": "The Model Assurance and Evaluation control family encompasses the proactiveand continuous processes of testing and validating the AI model's behavior to ensure it aligns with safety, ethical, and quality standards.", + "Section": "CCC.GenAI.CN08 Quality Control and Red Teaming", + "SubSection": "", + "SubSectionObjective": "Establish a formal program for quality evaluation and adversarial testing (red teaming) to ensure GenAI system meet all business, quality, security and compliance requirements before getting deployed into production environments.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH02", + "CCC.GenAI.TH04", + "CCC.GenAI.TH08", + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-005" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Adversarial Training and Testing", + "Red Teaming", + "Product Governance" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0008" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN08.AR02", + "Description": "If model quality review or red teaming identifies an issue that exceeds the organization's risk tolerance, the model MUST NOT be deployed until the issue is remediated.", + "Attributes": [ + { + "FamilyName": "Model Assurance and Evaluation", + "FamilyDescription": "The Model Assurance and Evaluation control family encompasses the proactiveand continuous processes of testing and validating the AI model's behavior to ensure it aligns with safety, ethical, and quality standards.", + "Section": "CCC.GenAI.CN08 Quality Control and Red Teaming", + "SubSection": "", + "SubSectionObjective": "Establish a formal program for quality evaluation and adversarial testing (red teaming) to ensure GenAI system meet all business, quality, security and compliance requirements before getting deployed into production environments.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH02", + "CCC.GenAI.TH04", + "CCC.GenAI.TH08", + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-005" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Adversarial Training and Testing", + "Red Teaming", + "Product Governance" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0008" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN01.AR01", + "Description": "Verify that only authorized users can access MLDE resources, and that access modes are properly defined and enforced.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN01 Define Access Mode for ML Development Environments", + "SubSection": "", + "SubSectionObjective": "Ensure that access to Machine Learning Development Environment (MLDE) resources is strictly defined and controlled. Only authorized users with appropriate permissions can access these environments, mitigating the risk of unauthorized access, data leakage, or service disruption.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.1", + "2013 A.9.2.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-02" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_root_access_disabled", + "sagemaker_notebook_instance_vpc_settings_configured" + ] + }, + { + "Id": "CCC.MLDE.CN03.AR01", + "Description": "Verify that root access is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN03 Disable Root Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from obtaining root access on MLDE instances to reduce the risk of unauthorized system modifications and potential security breaches.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08", + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_root_access_disabled" + ] + }, + { + "Id": "CCC.MLDE.CN03.AR02", + "Description": "For MLDE instances without sensitive data, ensure that root access is only enabled when necessary and properly authorized.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN03 Disable Root Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from obtaining root access on MLDE instances to reduce the risk of unauthorized system modifications and potential security breaches.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08", + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_root_access_disabled" + ] + }, + { + "Id": "CCC.MLDE.CN04.AR01", + "Description": "Verify that terminal access is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN04 Disable Terminal Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from accessing the terminal on MLDE instances to limit the risk of unauthorized commands and potential system compromise.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_root_access_disabled" + ] + }, + { + "Id": "CCC.MLDE.CN04.AR02", + "Description": "For MLDE instances without sensitive data, ensure that terminal access is only enabled when necessary and properly authorized.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN04 Disable Terminal Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from accessing the terminal on MLDE instances to limit the risk of unauthorized commands and potential system compromise.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_root_access_disabled" + ] + }, + { + "Id": "CCC.MLDE.CN02.AR01", + "Description": "Confirm that file download functionality is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN02 Disable File Downloads on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent unauthorized file downloads from MLDE instances to protect sensitive data from being exfiltrated.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.Core.TH02" ] } ], @@ -4620,166 +7450,98 @@ { "ReferenceId": "CCM", "Identifiers": [ - "DSP-10", - "DSP-19" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-4" - ] - } - ] - } - ], - "Checks": [ - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "vpc_endpoint_connections_trust_boundaries", - "s3_bucket_cross_account_access" - ] - }, - { - "Id": "CCC.Core.CN02.AR01", - "Description": "When data is stored, it MUST be encrypted using the latest industry-standard encryption methods.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN02 Encrypt Data for Storage", - "SubSection": "", - "SubSectionObjective": "Ensure that all data stored is encrypted at rest using strong encryption algorithms.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "UEM-08", - "DSP-17" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-13", - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_default_encryption", - "s3_bucket_kms_encryption", - "firehose_stream_encrypted_at_rest", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "cloudtrail_kms_encryption_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "opensearch_service_domains_encryption_at_rest_enabled", - "opensearch_service_domains_node_to_node_encryption_enabled", - "kafka_cluster_encryption_at_rest_uses_cmk", - "kinesis_stream_encrypted_at_rest", - "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_accelerator_cluster_encryption_enabled", - "ec2_ebs_default_encryption", - "ec2_ebs_volume_encryption", - "storagegateway_fileshare_encryption_enabled" - ] - }, - { - "Id": "CCC.Core.CN11.AR01", - "Description": "When encryption keys are used, the service MUST verify that all encryption keys use the latest industry-standard cryptographic algorithms.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "DSI-05", + "DSI-07" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.13.2.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "SC-7", + "SC-8" ] } ] } ], - "Checks": [ - "kms_cmk_rotation_enabled", - "kms_cmk_not_deleted_unintentionally", - "kms_cmk_not_multi_region", - "kms_cmk_are_used" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR02", - "Description": "When encryption keys are used, the service MUST rotate active keys within 180 days of issuance.", + "Id": "CCC.MLDE.CN02.AR02", + "Description": "For MLDE instances without sensitive data, ensure that file downloads are monitored and logged.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN02 Disable File Downloads on MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Prevent unauthorized file downloads from MLDE instances to protect sensitive data from being exfiltrated.", "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-5" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSI-05", + "DSI-07" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.2.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7", + "SC-8" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN05.AR01", + "Description": "Verify that only approved VM and container images can be selected when creating MLDE instances.", + "Attributes": [ + { + "FamilyName": "Configuration Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN05 Restrict Environment Options on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual machine and container image options available when creating new MLDE instances to approved and secure configurations.", + "Applicability": [ + "tlp-red", "tlp-amber" ], "Recommendation": "", @@ -4787,7 +7549,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.MLDE.TH04" ] } ], @@ -4795,52 +7557,43 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.IP-1" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "TVM-02" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.12.5.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "CM-2" ] } ] } ], - "Checks": [ - "kms_cmk_rotation_enabled", - "kms_cmk_not_deleted_unintentionally", - "kms_cmk_not_multi_region", - "kms_cmk_are_used" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR03", - "Description": "When encrypting data, the service MUST verify that customer-managed encryption keys (CMEKs) are used.", + "Id": "CCC.MLDE.CN05.AR02", + "Description": "Attempt to create an MLDE instance with an unapproved image and confirm that it is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "", - "SubSection": "CCC.Core.CN11 Protect Encryption Keys", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "FamilyName": "Configuration Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN05 Restrict Environment Options on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual machine and container image options available when creating new MLDE instances to approved and secure configurations.", "Applicability": [ - "tlp-amber", "tlp-red" ], "Recommendation": "", @@ -4848,7 +7601,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.MLDE.TH04" ] } ], @@ -4856,62 +7609,109 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.IP-1" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "TVM-02" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.12.5.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "CM-2" ] } ] } ], - "Checks": [ - "kms_cmk_not_deleted_unintentionally", - "kms_cmk_rotation_enabled", - "kms_cmk_not_multi_region", - "kms_cmk_are_used" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR04", - "Description": "When encryption keys are accessed, the service MUST verify that access to encryption keys is restricted to authorized personnel and services, following the principle of least privilege.", + "Id": "CCC.MLDE.CN06.AR01", + "Description": "Verify that automatic scheduled upgrades are enabled on user-managed MLDE instances containing sensitive data.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Vulnerability Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN06 Require Automatic Scheduled Upgrades on User-Managed MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Ensure that MLDE instances are kept up-to-date with the latest security patches by enforcing automatic scheduled upgrades.", "Applicability": [ - "tlp-clear", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH04", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-12" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "TVM-01", + "TVM-02" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.12.6.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN06.AR02", + "Description": "Ensure that the upgrade schedule is appropriately configured and does not interfere with critical operations.", + "Attributes": [ + { + "FamilyName": "Vulnerability Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN06 Require Automatic Scheduled Upgrades on User-Managed MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Ensure that MLDE instances are kept up-to-date with the latest security patches by enforcing automatic scheduled upgrades.", + "Applicability": [ + "tlp-red", + "tlp-amber", "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-clear" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.MLDE.TH04", + "CCC.Core.TH06" ] } ], @@ -4919,109 +7719,43 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.IP-12" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "TVM-01", + "TVM-02" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.12.6.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "SI-2" ] } ] } ], - "Checks": [ - "kms_cmk_rotation_enabled", - "kms_cmk_not_deleted_unintentionally", - "kms_cmk_not_multi_region", - "kms_cmk_are_used" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR05", - "Description": "When encryption keys are used, the service MUST rotate active keys within 365 days of issuance.", + "Id": "CCC.MLDE.CN07.AR01", + "Description": "Verify that MLDE instances containing sensitive data cannot be accessed via public IP addresses.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN07 Restrict Public IP Access on MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", - "Applicability": [ - "tlp-clear", - "tlp-green" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12", - "SC-17" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled", - "kms_cmk_not_multi_region" - ] - }, - { - "Id": "CCC.Core.CN11.AR06", - "Description": "When encryption keys are used, the service MUST rotate active keys within 90 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Prevent public IP access to MLDE instances to reduce exposure to the internet and enhance security.", "Applicability": [ "tlp-red" ], @@ -5030,444 +7764,8 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12", - "SC-17" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled" - ] - }, - { - "Id": "CCC.Core.CN14.AR01", - "Description": "When backups are created for disaster recovery purposes, the storage mechanism MUST NOT allow modification or deletion within 30 days of creation.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Use immutable storage solutions where possible. Implement backup retention policies that enforce a minimum retention period of 30 days. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "neptune_cluster_backup_enabled" - ] - }, - { - "Id": "CCC.Core.CN14.AR02", - "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 30 days.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber" - ], - "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 30 days. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "backup_vaults_exist", - "backup_plans_exist", - "backup_reportplans_exist", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "neptune_cluster_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_protected_by_backup_plan", - "rds_cluster_protected_by_backup_plan", - "dynamodb_table_protected_by_backup_plan" - ] - }, - { - "Id": "CCC.Core.CN14.AR02", - "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 14 days.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 14 days. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "backup_vaults_exist", - "backup_vaults_encrypted", - "backup_plans_exist", - "backup_reportplans_exist", - "backup_recovery_point_encrypted", - "neptune_cluster_backup_enabled" - ] - }, - { - "Id": "CCC.Core.CN03.AR01", - "Description": "When an entity attempts to modify the service through a user interface, the authentication process MUST require multiple identifying factors for authentication.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_administrator_access_with_mfa", - "cognito_user_pool_mfa_enabled" - ] - }, - { - "Id": "CCC.Core.CN03.AR02", - "Description": "When an entity attempts to modify the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_administrator_access_with_mfa", - "cognito_user_pool_mfa_enabled" - ] - }, - { - "Id": "CCC.Core.CN03.AR03", - "Description": "When an entity attempts to view information on the service through a user interface, the authentication process MUST require multiple identifying factors from the user.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "cognito_user_pool_mfa_enabled", - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_user_hardware_mfa_enabled", - "iam_administrator_access_with_mfa" - ] - }, - { - "Id": "CCC.Core.CN03.AR04", - "Description": "When an entity attempts to view information on the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "iam_user_mfa_enabled_console_access", - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_user_hardware_mfa_enabled", - "iam_administrator_access_with_mfa", - "apigateway_restapi_authorizers_enabled", - "apigateway_restapi_public_with_authorizer" - ] - }, - { - "Id": "CCC.Core.CN05.AR01", - "Description": "When an attempt is made to modify data on the service or a child resource, the service MUST block requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" + "CCC.MLDE.TH02", + "CCC.VPC.TH02" ] } ], @@ -5481,75 +7779,317 @@ { "ReferenceId": "CCM", "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" + "SEF-05" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.13.1.3" + "2013 A.13.1.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-3" + "SC-7" ] } ] } ], "Checks": [ - "apigateway_restapi_authorizers_enabled", - "apigateway_restapi_public", - "apigateway_restapi_public_with_authorizer", - "apigatewayv2_api_authorizers_enabled", - "awslambda_function_url_public", + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ] + }, + { + "Id": "CCC.MLDE.CN07.AR02", + "Description": "For MLDE instances without sensitive data requiring public access, ensure that appropriate security controls are in place and access is approved.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN07 Restrict Public IP Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent public IP access to MLDE instances to reduce exposure to the internet and enhance security.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_without_direct_internet_access_configured" + ] + }, + { + "Id": "CCC.MLDE.CN08.AR01", + "Description": "Verify that MLDE instances containing sensitive data can only be deployed in approved virtual networks with appropriate security controls.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN08 Restrict Virtual Networks for MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual networks that can be used when creating new MLDE instances to ensure they are deployed within approved and secure network environments.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_vpc_settings_configured", + "sagemaker_models_vpc_settings_configured", + "sagemaker_training_jobs_vpc_settings_configured" + ] + }, + { + "Id": "CCC.MLDE.CN08.AR02", + "Description": "Ensure that MLDE instances without sensitive data are deployed in networks that meet organizational security standards.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN08 Restrict Virtual Networks for MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual networks that can be used when creating new MLDE instances to ensure they are deployed within approved and secure network environments.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "sagemaker_notebook_instance_vpc_settings_configured", + "sagemaker_models_vpc_settings_configured", + "sagemaker_training_jobs_vpc_settings_configured" + ] + }, + { + "Id": "CCC.Message.CN01.AR01", + "Description": "Attempt to publish a message without using a customer-managed encryption key and verify that the message is rejected or not stored.", + "Attributes": [ + { + "FamilyName": "Encryption", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Message.CN01 Use Customer-Managed Encryption Keys (CMEK) for Messages", + "SubSection": "", + "SubSectionObjective": "Ensure that messages are encrypted using customer-managed encryption keys (CMEK) to provide enhanced control over encryption processes and keys, meeting compliance and security requirements.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-12", + "SC-13" + ] + } + ] + } + ], + "Checks": [ + "sns_topics_kms_encryption_at_rest_enabled", + "sqs_queues_server_side_encryption_enabled", + "kafka_cluster_encryption_at_rest_uses_cmk" + ] + }, + { + "Id": "CCC.SvlsComp.CN01.AR01", + "Description": "Attempt to access the serverless function over the public internet and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SvlsComp.CN01 Enforce Use of Private Endpoints for Serverless Function", + "SubSection": "", + "SubSectionObjective": "Ensure that the serverless function is accessible only through a private endpoint, allowing it to communicate securely within a virtual private network and preventing unauthorized external access.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7", + "SC-8" + ] + } + ] + } + ], + "Checks": [ + "awslambda_function_inside_vpc", "awslambda_function_not_publicly_accessible", - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "s3_bucket_public_access", - "s3_bucket_public_list_acl", - "s3_bucket_public_write_acl", - "s3_bucket_cross_account_access", - "s3_account_level_public_access_blocks", - "iam_policy_no_full_access_to_cloudtrail", - "iam_policy_no_full_access_to_kms", - "iam_inline_policy_no_full_access_to_cloudtrail", - "iam_inline_policy_no_full_access_to_kms", - "iam_role_administratoraccess_policy", - "iam_group_administrator_access_policy", - "iam_user_administrator_access_policy", - "iam_policy_attached_only_to_group_or_roles", - "iam_role_cross_account_readonlyaccess_policy", - "iam_role_cross_service_confused_deputy_prevention" + "awslambda_function_url_public" ] }, { - "Id": "CCC.Core.CN05.AR02", - "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST refuse requests from unauthorized entities.", + "Id": "CCC.SvlsComp.CN02.AR01", + "Description": "Send requests to invoke the function up to the allowed threshold and confirm they are successful; then send additional requests exceeding the threshold from the same entity and verify that they are denied.", "Attributes": [ { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "FamilyName": "Availability", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SvlsComp.CN02 Implement Function Invocation Rate Limits", "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "SubSectionObjective": "Ensure that function invocation is limited to a specified threshold from any single entity, preventing resource exhaustion and denial of service attacks.", "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH12" ] } ], @@ -5557,650 +8097,19 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" + "PR.DS-4" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-3" + "SC-5" ] } ] } ], - "Checks": [ - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_avoid_root_usage", - "iam_user_mfa_enabled_console_access", - "iam_administrator_access_with_mfa", - "iam_group_administrator_access_policy", - "iam_role_administratoraccess_policy", - "iam_inline_policy_no_full_access_to_cloudtrail", - "iam_inline_policy_no_full_access_to_kms", - "iam_policy_no_full_access_to_cloudtrail", - "iam_policy_no_full_access_to_kms", - "iam_policy_allows_privilege_escalation", - "iam_inline_policy_allows_privilege_escalation", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_customer_unattached_policy_no_administrative_privileges", - "iam_aws_attached_policy_no_administrative_privileges", - "iam_password_policy_minimum_length_14", - "iam_password_policy_uppercase", - "iam_password_policy_lowercase", - "iam_password_policy_symbol", - "iam_password_policy_number", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "iam_password_policy_reuse_24", - "iam_check_saml_providers_sts", - "iam_policy_attached_only_to_group_or_roles" - ] - }, - { - "Id": "CCC.Core.CN05.AR03", - "Description": "When administrative access or configuration change is attempted on the service or a child resource in a multi-tenant environment, the service MUST refuse requests across tenant boundaries unless the origin is explicitly included in a pre-approved allowlist.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "vpc_endpoint_connections_trust_boundaries", - "iam_role_cross_service_confused_deputy_prevention", - "iam_role_cross_account_readonlyaccess_policy", - "s3_bucket_cross_account_access", - "eventbridge_bus_cross_account_access", - "eventbridge_schema_registry_cross_account_access" - ] - }, - { - "Id": "CCC.Core.CN05.AR04", - "Description": "When data is requested from outside the trust perimeter, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "", - "SubSection": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "accessanalyzer_enabled", - "accessanalyzer_enabled_without_findings", - "vpc_endpoint_connections_trust_boundaries", - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "s3_bucket_cross_account_access", - "s3_bucket_public_access", - "iam_administrator_access_with_mfa", - "iam_group_administrator_access_policy", - "iam_user_administrator_access_policy", - "iam_inline_policy_allows_privilege_escalation", - "iam_inline_policy_no_full_access_to_kms", - "iam_inline_policy_no_full_access_to_cloudtrail", - "iam_policy_no_full_access_to_kms", - "iam_policy_no_full_access_to_cloudtrail", - "iam_policy_attached_only_to_group_or_roles", - "iam_user_mfa_enabled_console_access" - ] - }, - { - "Id": "CCC.Core.CN05.AR05", - "Description": "When any request is made from outside the trust perimeter, the service MUST NOT provide any response that may indicate the service exists.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "awslambda_function_url_public", - "apigateway_restapi_public", - "apigateway_restapi_public_with_authorizer", - "s3_bucket_public_list_acl", - "s3_bucket_public_write_acl", - "s3_bucket_public_access", - "s3_bucket_policy_public_write_access", - "sns_topics_not_publicly_accessible", - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_tcp_port_22", - "ec2_networkacl_allow_ingress_tcp_port_3389", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", - "ec2_securitygroup_default_restrict_traffic", - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "vpc_endpoint_connections_trust_boundaries", - "vpc_endpoint_for_ec2_enabled" - ] - }, - { - "Id": "CCC.Core.CN05.AR06", - "Description": "When any request is made to the service or a child resource, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "vpc_endpoint_connections_trust_boundaries", - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "iam_role_cross_service_confused_deputy_prevention", - "iam_role_cross_account_readonlyaccess_policy", - "iam_policy_attached_only_to_group_or_roles", - "iam_group_administrator_access_policy", - "iam_user_mfa_enabled_console_access", - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_no_root_access_key", - "iam_administrator_access_with_mfa", - "iam_root_credentials_management_enabled", - "iam_check_saml_providers_sts", - "iam_user_hardware_mfa_enabled" - ] - }, - { - "Id": "CCC.Core.CN04.AR01", - "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_log_file_validation_enabled", - "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "vpc_flow_logs_enabled", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled" - ] - }, - { - "Id": "CCC.Core.CN04.AR02", - "Description": "When any attempt is made to modify data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", - "cloudtrail_multi_region_enabled_logging_management_events", - "cloudtrail_cloudwatch_logging_enabled", - "vpc_flow_logs_enabled", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled", - "cloudtrail_threat_detection_enumeration", - "cloudtrail_threat_detection_privilege_escalation", - "cloudtrail_threat_detection_llm_jacking" - ] - }, - { - "Id": "CCC.Core.CN04.AR03", - "Description": "When any attempt is made to read data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_insights_exist", - "cloudtrail_multi_region_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_log_metric_filter_authentication_failures", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled", - "vpc_flow_logs_enabled" - ] - }, - { - "Id": "CCC.Core.CN07.AR01", - "Description": "When enumeration activities are detected, the service MUST publish an event to a monitored channel which includes the client identity, time, and nature of the activity.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", - "SubSection": "", - "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement event publication mechanisms and alerts for patterns indicative of enumeration activities, such as repeated access attempts, requests, or liveness probes. Configure alerts to notify security teams of any activities that merit further investigation. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-05", - "SEF-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_threat_detection_enumeration" - ] - }, - { - "Id": "CCC.Core.CN07.AR02", - "Description": "When enumeration activities are detected, the service MUST log the client identity, time, and nature of the activity.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", - "SubSection": "", - "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement logging mechanisms to capture details of enumeration activities, including client identity, timestamps, and activity nature. Retain logs according to organizational policies, and occasionally review them for patterns that may indicate reconnaissance activities. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-05", - "SEF-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_threat_detection_enumeration" - ] + "Checks": [] } ] } diff --git a/prowler/compliance/aws/cis_1.4_aws.json b/prowler/compliance/aws/cis_1.4_aws.json index 3efc29fd5b..b373a04665 100644 --- a/prowler/compliance/aws/cis_1.4_aws.json +++ b/prowler/compliance/aws/cis_1.4_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -736,6 +758,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", diff --git a/prowler/compliance/aws/cis_1.5_aws.json b/prowler/compliance/aws/cis_1.5_aws.json index f7307bb7f4..6a60d786a2 100644 --- a/prowler/compliance/aws/cis_1.5_aws.json +++ b/prowler/compliance/aws/cis_1.5_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -802,6 +824,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1054,6 +1084,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_2.0_aws.json b/prowler/compliance/aws/cis_2.0_aws.json index 4f8c4b2c23..e255bd43e1 100644 --- a/prowler/compliance/aws/cis_2.0_aws.json +++ b/prowler/compliance/aws/cis_2.0_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -802,6 +824,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1054,6 +1084,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_3.0_aws.json b/prowler/compliance/aws/cis_3.0_aws.json index dd4c75a2f7..5540bc40cf 100644 --- a/prowler/compliance/aws/cis_3.0_aws.json +++ b/prowler/compliance/aws/cis_3.0_aws.json @@ -75,6 +75,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -265,6 +279,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -756,6 +778,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1008,6 +1038,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_4.0_aws.json b/prowler/compliance/aws/cis_4.0_aws.json index 0c40f8ac09..c8787ef409 100644 --- a/prowler/compliance/aws/cis_4.0_aws.json +++ b/prowler/compliance/aws/cis_4.0_aws.json @@ -254,6 +254,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -431,6 +445,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -750,6 +772,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1234,6 +1264,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_5.0_aws.json b/prowler/compliance/aws/cis_5.0_aws.json index e870878c6b..0c8d46e170 100644 --- a/prowler/compliance/aws/cis_5.0_aws.json +++ b/prowler/compliance/aws/cis_5.0_aws.json @@ -232,6 +232,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -409,6 +423,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "1 Identity and Access Management", @@ -728,6 +750,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 Logging", @@ -1212,6 +1242,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Monitoring", diff --git a/prowler/compliance/aws/cis_6.0_aws.json b/prowler/compliance/aws/cis_6.0_aws.json index 643c192b7b..7ad62a4b65 100644 --- a/prowler/compliance/aws/cis_6.0_aws.json +++ b/prowler/compliance/aws/cis_6.0_aws.json @@ -232,6 +232,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Section": "2 Identity and Access Management", @@ -409,6 +423,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "2 Identity and Access Management", @@ -728,6 +750,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "4 Logging", @@ -1212,6 +1242,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "5 Monitoring", diff --git a/prowler/compliance/aws/cis_7.0_aws.json b/prowler/compliance/aws/cis_7.0_aws.json new file mode 100644 index 0000000000..f4f7fedff8 --- /dev/null +++ b/prowler/compliance/aws/cis_7.0_aws.json @@ -0,0 +1,1610 @@ +{ + "Framework": "CIS", + "Name": "CIS Amazon Web Services Foundations Benchmark v7.0.0", + "Version": "7.0", + "Provider": "AWS", + "Description": "The CIS Amazon Web Services Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Amazon Web Services with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "2.1.1", + "Description": "Ensure centralized root access in AWS Organizations", + "Checks": [ + "iam_root_credentials_management_enabled" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure centralized root access management is enabled to manage and secure root user credentials for member accounts in AWS Organizations. This allows the management account and an optional delegated administrator account to centrally delete, prevent recovery of, and if necessary, perform short-lived, scoped root-required actions in member accounts without maintaining long-term root user credentials in each account.", + "RationaleStatement": "The AWS account root user is a powerful, default administrative identity that is difficult to manage safely across many accounts. When each member account manages its own root credentials, organizations often end up with numerous long-lived root passwords, access keys, and MFA devices that are hard to inventory, rotate, and protect. Centralized root access management lets security teams remove or avoid creating root user credentials in member accounts, centrally review and manage any remaining root credentials, and perform necessary root-only tasks via short-term, task-scoped root sessions. This significantly reduces privileged credential sprawl, supports least privilege and dedicated administrator models, and improves visibility and auditability of root-level activity across the organization.", + "ImpactStatement": "Enabling centralized root access management changes how root user access is obtained and used in member accounts, but it does not automatically remove existing root credentials. Organizations must plan when and how to delete or disable any existing root passwords, access keys, signing certificates, and MFA devices in member accounts and update any workflows that still rely on direct root sign-in. Security and operations teams will need to use centrally initiated, short-lived root sessions for exceptional tasks that truly require root. This may require procedural changes and additional training, but it significantly reduces long-lived privileged credential sprawl across the organization.", + "RemediationProcedure": "1. Sign in to the AWS Management Console with the management account. 2. In the console search bar, type Organizations and open AWS Organizations. - On the Overview page, confirm that an Organization exists and that this account is listed as the Management account. 3. In AWS Organizations, choose Services. Locate AWS Identity and Access Management in the list and, if it is not already enabled, choose Enable trusted access and confirm. - This allows IAM to integrate with AWS Organizations to manage root access centrally. 4. In the console search bar, type IAM and open IAM. In the left navigation pane, choose Root access management. If you see Root access management is disabled, choose Enable. - In the enable dialog, confirm that you want to - \"Root credentials management\" and if desired - \"Privileged root actions in member accounts\" - In the Delegated administrator field, enter the account ID of the account that will manage root user access and take privileged actions on member accounts. AWS recommends using an account intended for security or management purposes, not a general workload account. - When you enable centralized root access in the console, IAM also enables trusted access for IAM in AWS Organizations if it isn't already enabled. - Choose Enable to save the configuration.", + "AuditProcedure": "1. Sign in to the AWS Management Console with the management account. 2. In the console search bar, type Organizations and open AWS Organizations. - On the Overview page, confirm that an Organization exists and that this account is listed as the Management account. 3. In AWS Organizations, choose Services. - Confirm that AWS Identity and Access Management appears in the list of services with trusted access enabled. 4. In the console search bar, type IAM and open IAM. In the left navigation pane, choose Root access management. Check the status banner. - If you see that Root access management is enabled and the feature card shows that root credentials management is turned on for member accounts, the organization has centralized root access management enabled. - If you see Root access management is disabled with an option to Enable, centralized root access is not yet enabled. 5. (Optional) On the same Root access management page, review the Delegated administrator information (if shown). - Confirm that the delegated account (if present) is a security or management-focused account, not a general workload account.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.2", + "Description": "Ensure authorization guardrails for all AWS Organization accounts", + "Checks": [], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that one or more baseline authorization policies such as Service Control Policies (SCPs) and/or Resource Control Policies (RCPs) are attached to all member accounts in AWS Organizations in accordance with organizational security requirements. Authorization policies act as preventive permission guardrails: SCPs define the maximum available permissions for IAM principals within accounts, while RCPs define the maximum available permissions for resources within accounts. These policies can enforce security invariants such as preventing disabling of key security services, restricting use of unapproved AWS Regions, or blocking external access to sensitive resources.", + "RationaleStatement": "Authorization policies do not grant permissions but instead set organization-wide limits on what actions principals can perform (SCPs) and what access can be granted to resources (RCPs), regardless of local IAM or resource-based policies. Without baseline guardrail authorization policies, each account can grant excessive or inconsistent permissions that disable logging, weaken security services, allow use of unapproved Regions and services, or permit unintended external access to resources. Attaching standard authorization policies to all member accounts enforces preventive, centralized control over high-risk actions and access patterns, supports least-privilege and role-based access control at scale, and helps ensure that all accounts and resources operate within the organization's defined security baseline.", + "ImpactStatement": "Enforcing baseline authorization policies for all member accounts can initially block some existing patterns, such as use of unapproved Regions, disabling security services, or granting broader permissions than the guardrails allow. Teams may need to adjust IAM policies, deployment pipelines, and exception processes so legitimate use cases remain possible within the new guardrails. This can introduce short-term operational overhead and require careful testing, especially when attaching new policies at the root or OU level.", + "RemediationProcedure": "Design or confirm baseline guardrail SCPs. 1. From the AWS Organizations console, go to Policies → Service control policies. - If you already have standard guardrail SCPs that implement your security baseline, note their names. - If you do not have such policies, choose Create policy and create at least one baseline guardrail SCP that encodes non-negotiable security requirements. 2. Do the same step as above but for RCPs if needed. From the AWS Organizations console, go to Policies → Resource control policies. 3. Attach guardrail authorization policies to the root and/or OUs. In AWS Organizations, choose AWS accounts, then select the Root of the organization. - Go to the Policies tab, then within section for Service control policies, choose Attach, and select the baseline guardrail SCP(s) you identified or created in step 1. - If using RCPs, then within section for Resource control policies, choose Attach, and select the baseline guardrail RCP(s) you identified or created in step 2. - If your design uses different guardrails per OU (for example, stricter policies for production OU), select each OU in turn and attach the appropriate guardrail SCPs and RCPs to those OUs. - AWS recommends testing authorization policies in a staging OU before attaching them broadly to the root to avoid unintended service disruption.", + "AuditProcedure": "Pre-requisite: you must run these CLI commands in the management account for the AWS Organization. 1. Before auditing, document or confirm your organization's baseline guardrail requirements. Common examples include: - Prevent disabling CloudTrail, AWS Config, GuardDuty, or Security Hub - Restrict usage to approved AWS Regions only - Protect central security or logging roles from modification - Deny external principal access to sensitive resources 2. List all SCPs and RCPs in the organization: ``` aws organizations list-policies --filter SERVICE_CONTROL_POLICY aws organizations list-policies --filter RESOURCE_CONTROL_POLICY ``` This returns a list of SCP/RCP policy IDs and names. 3. For each SCP/RCP, retrieve and review the policy document to determine if it implements your baseline guardrail requirements: ``` aws organizations describe-policy --policy-id ``` Review the `Content` field in the output to confirm the policy enforces organizational security requirements. If no SCPs/RCPs exist that implement your documented baseline guardrail requirements, note this as a gap and proceed to remediation. 4. List all accounts in the organization and note the account IDs: ``` aws organizations list-accounts --query 'Accounts[?Status==`ACTIVE`].[Id,Name]' --output table ``` 5. For each baseline guardrail Authorization policy identified in Step 2, list the accounts and OUs to which it is attached: ``` aws organizations list-targets-for-policy --policy-id ``` The output shows `TargetId` values representing accounts, OUs, or the root. 6. Compare the list of attached targets to your full account list. - If the policy is attached to the root, all accounts in the organization inherit it and this policy passes for coverage. - If the policy is attached to specific OUs, verify that all active member accounts belong to those OUs. - If the policy is attached to individual accounts, verify that all active member accounts are included. The environment passes this recommendation if: - All baseline guardrail authorization policies required by organizational security requirements exist. - Each baseline guardrail authorization policy is attached to all active member accounts either directly or via OU/root inheritance.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.3", + "Description": "Ensure Organizations management account is not used for workloads", + "Checks": [], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that the AWS Organizations management account is used only for organizational governance tasks and does not host production workloads, applications, or business data. The management account is the most privileged account in an AWS Organization and performs sensitive administrative functions such as creating and managing member accounts, applying service control policies (SCPs), and managing consolidated billing. Workloads, applications, and associated data should be deployed in dedicated member accounts, not in the management account.", + "RationaleStatement": "The management account has unique privileges that cannot be restricted by SCPs, making it the highest-risk account in an organization. Deploying workloads or storing business data in the management account increases the attack surface and blast radius of a compromise. If a workload vulnerability or misconfiguration occurs in the management account, it could grant attackers access to organization-wide administrative capabilities.", + "ImpactStatement": "Restricting the management account to governance-only use may require creating new member accounts, redesigning existing account boundaries, and migrating workloads and data out of the management account. This can introduce short-term complexity and operational overhead. However, it reduces the blast radius of a compromise, simplifies security controls in the most privileged account, and aligns the environment with AWS multi-account and workload-isolation best practices.", + "RemediationProcedure": "1. Inventory all workload resources currently in the management account (compute, storage, databases, application services). 2. For each class of workload resource (for example, production, non-production, shared services), create or confirm dedicated member accounts within the organization and place them into the appropriate OUs. 3. For each workload resource, design a migration plan to the appropriate member account. - Execute the migrations in phases, starting with lower-risk environments (for example, development/test) before production. 4. Review and adjust IAM roles and permissions in the management account so that only personnel responsible for organization governance and security have access 5. Update architecture diagrams, runbooks, and onboarding processes to state that new workloads must be deployed only into designated workload accounts, not the management account.", + "AuditProcedure": "1. Confirm which AWS account is the management account for the organization (for example, via AWS Organizations \"Overview\" page or organizational documentation). 2. Ensure you have read-only access to review resources in this account. 3. Use your organization's standard discovery methods (for example, AWS Config, CMDB/asset inventory, or CSPM) to obtain a list of services and resources running in the management account. - At a minimum, identify compute, storage, database, and application services (for example, EC2, Lambda, ECS, S3, RDS, DynamoDB, API Gateway, load balancers). 4. For each identified resource, determine whether it is: - Governance/security: resources that support centralized management, logging, audit, or security (for example, org-wide CloudTrail, Config aggregator, Security Hub or GuardDuty delegated admin, billing/cost tooling). - Workload/business: resources that support business applications, production or non-production workloads, or customer-facing systems. 5. If any workload/business resources are present in the management account, record this as a gap and document the affected services and resource types", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.4", + "Description": "Ensure Organizational Units are structured by environment and sensitivity", + "Checks": [], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that AWS Organizations Organizational Units (OUs) are structured primarily by environment (for example, production, non-production, sandbox) and sensitivity (for example, security, logging, shared services, regulated workloads), rather than mirroring the corporate org chart. OUs should group accounts that share similar security requirements and controls so that appropriate authorization policies and other guardrails can be applied consistently at the OU level.", + "RationaleStatement": "A clear OU structure based on environment and sensitivity makes it easier to apply consistent guardrails and centralized security controls to accounts that have similar risk profiles and compliance needs. Poorly defined or ad-hoc OU structures complicate policy management, increase the chance of misapplied controls, and can lead to mixing workloads with different data sensitivities under the same set of controls.", + "ImpactStatement": "Restructuring OUs by environment and sensitivity can require moving accounts, changing inherited policies, and updating automation that assumes existing OU paths. This may introduce short-term operational overhead, including policy revalidation, testing of workloads under new guardrails, and coordination with application and platform teams to avoid unintended service disruption.", + "RemediationProcedure": "1. Work with security, platform, and application teams to agree on a small set of top-level OUs such as: - Security / Management - Shared Services / Infrastructure - Prod - Non-Prod (dev, test, staging) - You may also define dedicated OUs for highly regulated workloads. 2. In the AWS Organizations console (management account), navigate to AWS Accounts. Under the root, create the agreed top-level OUs. If needed, create child OUs under these. 3. Export or list all existing accounts and their current OUs. Create a simple mapping from each account to its target OU based on environment and sensitivity. 4. In the AWS Organizations console (management account), navigate to AWS Accounts. Move accounts into the new environment/sensitivity-based OUs according to your mapping. - Start with low-risk accounts (for example, sandbox and non-production) to validate effects of inherited policies and guardrails before moving production and high-sensitivity accounts. 5. After accounts have been moved, remove old OUs that no longer reflect the target structure. - Ensure no active accounts remain directly under the root unless explicitly justified and documented. 6. Update architecture docs, onboarding runbooks, and account request processes to require new accounts to be created in the correct OU based on environment and sensitivity.", + "AuditProcedure": "1. From the management account, use AWS Organizations console to obtain: - The full OU hierarchy (root, top-level and child OUs). - The list of accounts in each OU. 2. Review top-level and key OUs and determine whether they are clearly aligned to: - Environment (for example, production, non-production, sandbox). - Sensitivity/function (for example, security, logging, shared services, regulated). - Note any OUs whose purpose is unclear or that appear to be organized mainly by department or owner rather than environment/sensitivity. 3. For each environment/sensitivity OU, select a sample of accounts and verify that their primary workloads match the OU's stated purpose. - Note any accounts that mix production and non-production workloads in the same OU when separate OUs are defined. - Note any accounts that place highly sensitive or regulated workloads in OUs that are intended for lower-sensitivity use.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.5", + "Description": "Ensure delegated admin manages AWS Organizations policies", + "Checks": [ + "organizations_delegated_administrators" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a dedicated member account is configured as a delegated administrator for AWS Organizations to manage organization policies (SCPs, RCPs, tag policies, backup policies, AI opt-out policies) and other Organizations features, instead of performing these tasks directly from the management account. The delegated administrator for AWS Organizations is configured via a resource-based delegation policy in the management account, which grants specific member accounts limited permissions to perform Organizations policy and account management actions across the organization. This allows policy management, OU operations, and other governance tasks to be handled from purpose-built accounts without requiring broad access to the management account.", + "RationaleStatement": "The management account has unique and high privileges to manage AWS Organizations (for example, creating/deleting accounts, managing org structures) and is not subject to guardrails like SCPs. Without a delegated administrator for Organizations, all policy management, OU changes, and account governance must be performed directly from the management account. This results in concentrating operational activity in the most powerful account. Configuring a dedicated member account as a delegated administrator for Organizations policy management distributes these tasks to a purpose-built AWS account that can be protected by SCPs and other controls, reduces the number of users and roles that need management-account access, and supports separation of duties while maintaining centralized control over organization-wide features.", + "ImpactStatement": "Configuring a delegated administrator for AWS Organizations requires creating or identifying a dedicated member account for policy management and granting it specific permissions via a resource-based delegation policy. Existing workflows, automation, and user access patterns that currently perform Organizations policy tasks directly from the management account must be updated to use the delegated account instead. This introduces short-term operational overhead and testing to ensure policy creation, attachment, and management continue to function correctly from the new account.", + "RemediationProcedure": "1. Identify a dedicated member account for governance/policy management (for example, create a new \"Policy Management\" account or use an existing Security account) 2. You must be in the management account with permissions to manage Organizations resource policies. Navigate to AWS Organizations console, then click on Settings and browse to \"Delegated administrator for AWS Organizations\" section. 3. If no policy exists, click on Delegate. If a policy exists, choose Edit policy. - In the policy editor, paste or construct a delegation policy statement mentioning the Principal as the AWS account Root which is being delegated access to, and the Actions with the list of least-privileged permissions that could be performed by the delegated AWS account. - Save and validate the delegation policy. 4. Sign in to the delegated administrator account and open the AWS Organizations console. - Confirm that policy management (Policies, Attach/Detach, etc.) is accessible and that users/roles in this account can perform Organizations tasks without management-account access. 5. Grant IAM roles/users in the delegated admin account only the permissions needed for Organizations policy management. 6. Update procedures so that routine Organizations policy tasks are performed from the delegated account, reserving the management account for tasks that only it can perform", + "AuditProcedure": "1. Sign in to the AWS Organizations console. From the AWS Accounts section, verify that this is the management account for the organization. 2. In the AWS Organizations console, navigate to Settings. Scroll to the Delegated administrator for AWS Organizations section 3. Review the delegation policy status: - If a delegation policy is configured and shows one or more member accounts registered to manage Organizations policies, proceed to step 4. - If no delegation policy is configured or the section shows No delegated administrator (or equivalent), the audit fails because Organizations management is performed directly from the management account. 4. In the Delegated administrator section, note the account IDs registered for Organizations policy management. Confirm that the delegated accounts are purpose-built governance, security, or policy management accounts, not general workload, sandbox, or development accounts. 5. View the delegation policy details to confirm it grants appropriate least-privilege permissions for policy types (for example, SCPs, tag policies, backup policies) and actions (CreatePolicy, AttachPolicy, UpdatePolicy, etc.).", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.6", + "Description": "Ensure delegated admins manage AWS Organizations-integrated services", + "Checks": [ + "organizations_delegated_administrators" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that AWS services (such as AWS CloudTrail) which integrate with AWS Organizations and support delegated administration are managed through delegated administrator member accounts instead of directly from the Organizations management account. For each such service, the management account should enable trusted access and register a purpose-built member account as the delegated administrator, so that this account can perform service-level administration across all organization accounts.", + "RationaleStatement": "The management account has unique and high privileges to manage AWS Organizations (for example, creating/deleting accounts, managing org structures) and is not subject to guardrails like SCPs. Without delegated administrators, organization-wide security, logging, and management services must be operated directly from the management account, concentrating operational activity and credentials in the most privileged account in the organization. Registering member accounts as delegated administrators for AWS services distributes service-specific administration to dedicated security, logging, or operations accounts that can be restricted by SCPs, monitored like other workload accounts, and aligned with team responsibilities, while reducing day-to-day use of the management account.", + "ImpactStatement": "Configuring a delegated administrator for AWS Services that integrate with AWS Organizations requires creating or identifying a dedicated member account for policy management and granting it specific permissions. Existing workflows, automation, and user access patterns that currently perform tasks directly from the management account must be updated to use the delegated account instead. This introduces short-term operational overhead and testing to ensure policy creation, attachment, and management continue to function correctly from the new account.", + "RemediationProcedure": "Note: This remediation section uses AWS CloudTrail as a concrete example. You must perform similar procedure for all other AWS services that integrate with AWS Organizations and support delegated administration that are in use in your environment. 1. In the management account, verify that trusted access for CloudTrail is enabled in AWS Organizations (AWS Organizations → Services). 2. In the management account CloudTrail console, choose Settings in the left navigation pane. Scroll to Organization delegated administrators. 3. Click on \"Register administrator\" - Enter the account ID of the designated Logging or Security account. - Click on Register administrator. CloudTrail will automatically create the necessary service-linked roles and register the account. 4. In the delegated administrator account, open the CloudTrail console and confirm that the organization trail is visible and administrative actions are accessible. 5. Update operational runbooks so that routine CloudTrail administration is performed from the delegated admin account, not the management account.", + "AuditProcedure": "Note: This audit uses AWS CloudTrail as a concrete example. You must perform similar audits for all other AWS services that integrate with AWS Organizations and support delegated administration that are in use in your environment. 1. Sign in to the management account and open the CloudTrail console. 2. In the left navigation pane, choose Trails. - Verify that there is at least one organization trail (trail with Apply trail to all accounts in my organization or equivalent setting enabled) - If CloudTrail is only configured as single-account trails and no organization trail is in use, note that delegated admin for CloudTrail is not in scope and this recommendation is not applicable for CloudTrail in this environment. 3. In the same management account CloudTrail console, choose Settings in the left navigation pane, and scroll to the Organization delegated administrators section. 4. Verify the configuration for Organization delegated administrators: - Verify that at least one member account ID (not the management account) is listed as a delegated administrator for CloudTrail. - Verify that the account(s) are appropriate for security/logging operations (for example, a named Security or Logging account, not a sandbox or general workload account). - If the section shows \"No delegated administrators\" when an organization trail is in use, CloudTrail is effectively administered from the management account and this is a gap.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2", + "Description": "Maintain current AWS account contact details", + "Checks": [ + "account_maintain_current_contact_details" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of the Acceptable Use Policy or indicative of a likely security compromise is observed by the AWS Abuse team. Contact details should not be for a single individual, as circumstances may arise where that individual is unavailable. Email contact details should point to a mail alias which forwards email to multiple individuals within the organization; where feasible, phone contact details should point to a PABX hunt group or other call-forwarding system.", + "RationaleStatement": "If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question, so it is in both the customers' and AWS's best interests that prompt contact can be established. This is best achieved by setting AWS account contact details to point to resources which have multiple individuals as recipients, such as email aliases and PABX hunt groups.", + "ImpactStatement": "", + "RemediationProcedure": "This activity can only be performed via the AWS Console, with a user who has permission to read and write Billing information (aws-portal:\\*Billing). **From Console:** 1. Sign in to the AWS Management Console and open the `Billing and Cost Management` console at https://console.aws.amazon.com/billing/home#/. 2. On the navigation bar, choose your account name, and then choose `Account`. 3. On the `Account Settings` page, next to `Account Settings`, choose `Edit`. 4. Next to the field that you need to update, choose `Edit`. 5. After you have entered your changes, choose `Save changes`. 6. After you have made your changes, choose `Done`. 7. To edit your contact information, under `Contact Information`, choose `Edit`. 8. For the fields that you want to change, type your updated information, and then choose `Update`. **From Command Line:** 1. Run the following command: ``` aws account put-contact-information --contact-information '{\"AddressLine1\": \"\", \"AddressLine2\": \"\", \"City\": \"\", \"CompanyName\": \"\", \"CountryCode\": \"\", \"FullName\": \"\", \"PhoneNumber\": \"\", \"PostalCode\": \"\", \"StateOrRegion\": \"\"}' ```", + "AuditProcedure": "This activity can only be performed via the AWS Console, with a user who has permission to read and write Billing information (aws-portal:\\*Billing). 1. Sign in to the AWS Management Console and open the `Billing and Cost Management` console at https://console.aws.amazon.com/billing/home#/. 2. On the navigation bar, choose your account name, and then choose `Account`. 3. On the `Account Settings` page, review and verify the current details. 4. Under `Contact Information`, review and verify the current details.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/manage-account-payment.html#contact-info", + "DefaultValue": "By default, AWS account contact information (email and telephone) is set to the values provided at account creation. These usually reference a single individual rather than a shared alias or group contact." + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure security contact information is registered", + "Checks": [ + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "AWS provides customers with the option of specifying the contact information for account's security team. It is recommended that this information be provided.", + "RationaleStatement": "Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to establish security contact information: **From Console:** 1. Click on your account name at the top right corner of the console. 2. From the drop-down menu Click `My Account` 3. Scroll down to the `Alternate Contacts` section 4. Enter contact information in the `Security` section **From Command Line:** Run the following command with the following input parameters: --email-address, --name, and --phone-number. ``` aws account put-alternate-contact --alternate-contact-type SECURITY ``` **Note:** Consider specifying an internal email distribution list to ensure emails are regularly monitored by more than one individual.", + "AuditProcedure": "Perform the following to determine if security contact information is present: **From Console:** 1. Click on your account name at the top right corner of the console 2. From the drop-down menu Click `My Account` 3. Scroll down to the `Alternate Contacts` section 4. Ensure contact information is specified in the `Security` section **From Command Line:** 1. Run the following command: ``` aws account get-alternate-contact --alternate-contact-type SECURITY ``` 2. Ensure proper contact information is specified for the `Security` contact.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure no 'root' user account access key exists", + "Checks": [ + "iam_no_root_access_key" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The 'root' user account is the most privileged user in an AWS account. AWS Access Keys provide programmatic access to a given AWS account. It is recommended that all access keys associated with the 'root' user account be deleted.", + "RationaleStatement": "Deleting access keys associated with the 'root' user account limits vectors by which the account can be compromised. Additionally, deleting the 'root' access keys encourages the creation and use of role based accounts that are least privileged.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to delete active 'root' user access keys. **From Console:** 1. Sign in to the AWS Management Console as 'root' and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. Click on `` at the top right and select `My Security Credentials` from the drop down list. 3. On the pop out screen Click on `Continue to Security Credentials`. 4. Click on `Access Keys` (Access Key ID and Secret Access Key). 5. If there are active keys, under `Status`, click `Delete` (Note: Deleted keys cannot be recovered). Note: While a key can be made inactive, this inactive key will still show up in the CLI command from the audit procedure, and may lead to the root user being falsely flagged as being non-compliant.", + "AuditProcedure": "Perform the following to determine if the 'root' user account has access keys: **From Console:** 1. Login to the AWS Management Console. 2. Click `Services`. 3. Click `IAM`. 4. Click on `Credential Report`. 5. This will download a `.csv` file which contains credential usage for all IAM users within an AWS Account - open this file. 6. For the `` user, ensure the `access_key_1_active` and `access_key_2_active` fields are set to `FALSE`. **From Command Line:** Run the following command: ``` aws iam get-account-summary | grep AccountAccessKeysPresent ``` If no 'root' access keys exist the output will show `AccountAccessKeysPresent: 0,`. If the output shows a 1, then 'root' keys exist and should be deleted.", + "AdditionalInformation": "- IAM User account root for us-gov cloud regions is not enabled by default. However, on request to AWS support enables 'root' access only through access-keys (CLI, API methods) for us-gov cloud region. - Implement regular checks and alerts for any creation of new root access keys to promptly address any unauthorized or accidental creation.", + "References": "http://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html:http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html:http://docs.aws.amazon.com/IAM/latest/APIReference/API_GetAccountSummary.html:https://aws.amazon.com/blogs/security/an-easier-way-to-determine-the-presence-of-aws-account-access-keys/", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure MFA is enabled for the 'root' user account", + "Checks": [ + "iam_root_mfa_enabled" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The 'root' user account is the most privileged user in an AWS account. Multi-factor Authentication (MFA) adds an extra layer of protection on top of a username and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their username and password as well as for an authentication code from their AWS MFA device. **Note:** When virtual MFA is used for 'root' accounts, it is recommended that the device used is NOT a personal device, but rather a dedicated mobile device (tablet or phone) that is kept charged and secured, independent of any individual personal devices (non-personal virtual MFA). This lessens the risks of losing access to the MFA due to device loss, device trade-in, or if the individual owning the device is no longer employed at the company. Where an AWS Organization is using centralized root access, root credentials can be removed from member accounts. In that case it is neither possible nor necessary to configure root MFA in the member account.", + "RationaleStatement": "Enabling MFA provides increased security for console access as it requires the authenticating principal to possess a device that emits a time-sensitive key and have knowledge of a credential.", + "ImpactStatement": "", + "RemediationProcedure": "**Note:** To manage MFA devices for the 'root' AWS account, you must use your 'root' account credentials to sign in to AWS. You cannot manage MFA devices for the 'root' account using other credentials. Perform the following to establish MFA for the 'root' user account: 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. Choose `Dashboard` , and under `Security Status` , expand `Activate MFA` on your root account. 3. Choose `Activate MFA` 4. In the wizard, choose `A virtual MFA` device and then choose `Next Step` . 5. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the 'secret configuration key' that is available for manual entry on devices that do not support QR codes. 6. Open your virtual MFA application. (For a list of apps that you can use for hosting virtual MFA devices, see [Virtual MFA Applications](http://aws.amazon.com/iam/details/mfa/#Virtual_MFA_Applications).) If the virtual MFA application supports multiple accounts (multiple virtual MFA devices), choose the option to create a new account (a new virtual MFA device). 7. Determine whether the MFA app supports QR codes, and then do one of the following: - Use the app to scan the QR code. For example, you might choose the camera icon or choose an option similar to Scan code, and then use the device's camera to scan the code. - In the Manage MFA Device wizard, choose Show secret key for manual configuration, and then type the secret configuration key into your MFA application. When you are finished, the virtual MFA device starts generating one-time passwords. In the Manage MFA Device wizard, in the Authentication Code 1 box, type the one-time password that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second one-time password into the Authentication Code 2 box. Choose Assign Virtual MFA.", + "AuditProcedure": "Perform the following to determine if the 'root' user account is enabled and has MFA setup: **From Console:** 1. Login to the AWS Management Console 2. Click `Services` 3. Click `IAM` 4. Click on `Credential Report` 5. This will download a `.csv` file which contains credential usage for all IAM users within an AWS Account - open this file 6. For the `` user, ensure the `mfa_active` field is set to `TRUE` or the `password_enabled` field is set to `FALSE` **From Command Line:** 1. Run the following command: ``` aws iam get-account-summary | grep AccountMFAEnabled aws iam get-account-summary | grep AccountPasswordPresent ``` 2. Ensure the AccountMFAEnabled property is set to 1 or the AccountPasswordPresent property is set to 0", + "AdditionalInformation": "IAM User account root for us-gov cloud regions does not have console access. This recommendation is not applicable for us-gov cloud regions.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html#id_root-user_manage_mfa:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html#enable-virt-mfa-for-root:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure hardware MFA is enabled for the 'root' user account", + "Checks": [ + "iam_root_hardware_mfa_enabled" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "The 'root' user account is the most privileged user in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their user name and password as well as for an authentication code from their AWS MFA device. For Level 2, it is recommended that the 'root' user account be protected with a hardware MFA. Where an AWS Organization is using centralized root access, root credentials can be removed from member accounts. In that case it is neither possible nor necessary to configure root MFA in the member account.", + "RationaleStatement": "A hardware MFA has a smaller attack surface than a virtual MFA. For example, a hardware MFA does not suffer the attack surface introduced by the mobile smartphone on which a virtual MFA resides. **Note**: Using hardware MFA for numerous AWS accounts may create a logistical device management issue. If this is the case, consider implementing this Level 2 recommendation selectively for the highest security AWS accounts, while applying the Level 1 recommendation to the remaining accounts.", + "ImpactStatement": "", + "RemediationProcedure": "**Note:** To manage MFA devices for the AWS 'root' user account, you must use your 'root' account credentials to sign in to AWS. You cannot manage MFA devices for the 'root' account using other credentials. Perform the following to establish a hardware MFA for the 'root' user account: 1. Open the AWS Management Console and sign in using your root user credentials. 2. On the right side of the navigation bar, choose your account name, and choose Security credentials. 3. In the Multi-Factor Authentication (MFA) section, choose Assign MFA device. 4. In the wizard, type a Device name, choose Authenticator app, and then choose Next. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the secret configuration key that is available for manual entry on devices that do not support QR codes. 5. Open the virtual MFA app on the device. If the virtual MFA app supports multiple virtual MFA devices or accounts, choose the option to create a new virtual MFA device or account. 6. The easiest way to configure the app is to use the app to scan the QR code. If you cannot scan the code, you can type the configuration information manually. The QR code and secret configuration key generated by IAM are tied to your AWS account. To use the QR code to configure the virtual MFA device, from the wizard, choose Show QR code. Then follow the app instructions for scanning the code. For example, you might need to choose the camera icon or choose a command like Scan account barcode, and then use the device's camera to scan the QR code. To manual entry secret key on devices, in the Set up device wizard, choose Show secret key, and then type the secret key into your MFA app. 7. In the wizard, in the MFA code 1 box, type the one-time password that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second one-time password into the MFA code 2 box. Choose Add MFA. Remediation for this recommendation is not available through AWS CLI.", + "AuditProcedure": "Perform the following to determine if the 'root' user account has a hardware MFA setup: 1. Run the following command to determine if the 'root' account has MFA setup: ``` aws iam get-account-summary | grep \"AccountMFAEnabled\" aws iam get-account-summary | grep \"AccountPasswordPresent\" ``` The `AccountMFAEnabled` property is set to `1` will ensure that the 'root' user account has MFA (Virtual or Hardware) Enabled. `AccountPasswordPresent` set to `0` indicates that the `root` console credential has been removed. If `AccountMFAEnabled` property is set to `0` and `AccountPasswordPresent` is set to `1` the account is not compliant with this recommendation. 2. If `AccountMFAEnabled` property is set to `1`, determine 'root' account has Hardware MFA enabled. Run the following command to list all virtual MFA devices: ``` aws iam list-virtual-mfa-devices ``` If the output contains one MFA with the following Serial Number, it means the MFA is virtual, not hardware and the account is not compliant with this recommendation: `SerialNumber: arn:aws:iam::__:mfa/root-account-mfa-device`", + "AdditionalInformation": "IAM User account 'root' for us-gov cloud regions does not have console access. This control is not applicable for us-gov cloud regions.", + "References": "CCE-78911-5:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_physical.html#enable-hw-mfa-for-root:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/enable-virt-mfa-for-root.html", + "DefaultValue": "By default, the AWS root user does not have a hardware MFA device assigned. MFA must be explicitly configured, and if enabled by default it will be virtual (software-based), not hardware." + } + ] + }, + { + "Id": "2.7", + "Description": "Eliminate use of the 'root' user for administrative and daily tasks", + "Checks": [ + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "With the creation of an AWS account, a 'root user' is created that cannot be disabled or deleted. That user has unrestricted access to and control over all resources in the AWS account. It is highly recommended that the use of this account be avoided for everyday tasks.", + "RationaleStatement": "The 'root user' has unrestricted access to and control over all account resources. Use of it is inconsistent with the principles of least privilege and separation of duties, and can lead to unnecessary harm due to error or account compromise.", + "ImpactStatement": "", + "RemediationProcedure": "If you find that the 'root' user account is being used for daily activities, including administrative tasks that do not require the 'root' user: 1. Change the 'root' user password. 2. Deactivate or delete any access keys associated with the 'root' user. Remember, anyone who has 'root' user credentials for your AWS account has unrestricted access to and control of all the resources in your account, including billing information.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console at `https://console.aws.amazon.com/iam/`. 2. In the left pane, click `Credential Report`. 3. Click on `Download Report`. 4. Open or Save the file locally. 5. Locate the `` under the user column. 6. Review `password_last_used, access_key_1_last_used_date, access_key_2_last_used_date` to determine when the 'root user' was last used. **From Command Line:** Run the following CLI commands to provide a credential report for determining the last time the 'root user' was used: ``` aws iam generate-credential-report ``` ``` aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,5,11,16 | grep -B1 '' ``` Review `password_last_used`, `access_key_1_last_used_date`, `access_key_2_last_used_date` to determine when the _root user_ was last used. **Note:** There are a few conditions under which the use of the 'root' user account is required. Please see the reference links for all of the tasks that require use of the 'root' user.", + "AdditionalInformation": "The 'root' user for us-gov cloud regions is not enabled by default. However, on request to AWS support, they can enable the 'root' user and grant access only through access-keys (CLI, API methods) for us-gov cloud region. If the 'root' user for us-gov cloud regions is enabled, this recommendation is applicable. Monitoring usage of the 'root' user can be accomplished by implementing recommendation 3.3 Ensure a log metric filter and alarm exist for usage of the 'root' user.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html:https://docs.aws.amazon.com/general/latest/gr/aws_tasks-that-require-root.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.8", + "Description": "Ensure IAM password policy requires minimum length of 14 or greater", + "Checks": [ + "iam_password_policy_minimum_length_14" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Password policies are, in part, used to enforce password complexity requirements. IAM password policies can be used to ensure passwords are at least a given length. It is recommended that the password policy require a minimum password length 14.", + "RationaleStatement": "Setting a password complexity policy increases account resiliency against brute force login attempts.", + "ImpactStatement": "Enforcing a minimum password length of 14 characters enhances security by making passwords more resistant to brute force attacks. However, it may require users to create longer and potentially more complex passwords, which could impact user convenience.", + "RemediationProcedure": "Perform the following to set the password policy as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Set Minimum password length to `14` or greater. 5. Click Apply password policy **From Command Line:** ``` aws iam update-account-password-policy --minimum-password-length 14 ``` Note: All commands starting with aws iam update-account-password-policy can be combined into a single command.", + "AuditProcedure": "Perform the following to ensure the password policy is configured as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Ensure Minimum password length is set to 14 or greater. **From Command Line:** ``` aws iam get-account-password-policy ``` Ensure the output of the above command includes MinimumPasswordLength: 14 (or higher)", + "AdditionalInformation": "Ensure the password policy also includes requirements for password complexity, such as the inclusion of uppercase letters, lowercase letters, numbers, and special characters: ``` aws iam update-account-password-policy --require-uppercase-characters --require-lowercase-characters --require-numbers --require-symbols ```", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#configure-strong-password-policy", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.9", + "Description": "Ensure IAM password policy prevents password reuse", + "Checks": [ + "iam_password_policy_reuse_24" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "IAM password policies can prevent the reuse of a given password by the same user. It is recommended that the password policy prevent the reuse of passwords.", + "RationaleStatement": "Preventing password reuse increases account resiliency against brute force login attempts.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to set the password policy as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Check Prevent password reuse 5. Set Number of passwords to remember is set to `24` **From Command Line:** ``` aws iam update-account-password-policy --password-reuse-prevention 24 ``` Note: All commands starting with aws iam update-account-password-policy can be combined into a single command.", + "AuditProcedure": "Perform the following to ensure the password policy is configured as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Ensure Prevent password reuse is checked 5. Ensure Number of passwords to remember is set to 24 **From Command Line:** ``` aws iam get-account-password-policy ``` Ensure the output of the above command includes PasswordReusePrevention: 24", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#configure-strong-password-policy", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.10", + "Description": "Ensure multi-factor authentication (MFA) is enabled for all IAM users that have a console password", + "Checks": [ + "iam_user_mfa_enabled_console_access" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Multi-Factor Authentication (MFA) adds an extra layer of authentication assurance beyond traditional credentials. With MFA enabled, when a user signs in to the AWS Console, they will be prompted for their user name and password as well as for an authentication code from their physical or virtual MFA token. It is recommended that MFA be enabled for all accounts that have a console password.", + "RationaleStatement": "Enabling MFA provides increased security for console access as it requires the authenticating principal to possess a device that displays a time-sensitive key and have knowledge of a credential.", + "ImpactStatement": "AWS will soon end support for SMS multi-factor authentication (MFA). New customers are not allowed to use this feature. We recommend that existing customers switch to an alternative method of MFA.", + "RemediationProcedure": "Perform the following to enable MFA: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at 'https://console.aws.amazon.com/iam/' 2. In the left pane, select `Users`. 3. In the `User Name` list, choose the name of the intended MFA user. 4. Choose the `Security Credentials` tab, and then choose `Manage MFA Device`. 5. In the `Manage MFA Device wizard`, choose `Virtual MFA` device, and then choose `Continue`. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the 'secret configuration key' that is available for manual entry on devices that do not support QR codes. 6. Open your virtual MFA application. (For a list of apps that you can use for hosting virtual MFA devices, see Virtual MFA Applications at https://aws.amazon.com/iam/details/mfa/#Virtual_MFA_Applications). If the virtual MFA application supports multiple accounts (multiple virtual MFA devices), choose the option to create a new account (a new virtual MFA device). 7. Determine whether the MFA app supports QR codes, and then do one of the following: - Use the app to scan the QR code. For example, you might choose the camera icon or choose an option similar to Scan code, and then use the device's camera to scan the code. - In the Manage MFA Device wizard, choose Show secret key for manual configuration, and then type the secret configuration key into your MFA application. When you are finished, the virtual MFA device starts generating one-time passwords. 8. In the `Manage MFA Device wizard`, in the `MFA Code 1 box`, type the `one-time password` that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second `one-time password` into the `MFA Code 2 box`. 9. Click `Assign MFA`.", + "AuditProcedure": "Perform the following to determine if a MFA device is enabled for all IAM users having a console password: **From Console:** 1. Open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. In the left pane, select `Users` 3. If the `MFA` or `Password age` columns are not visible in the table, click the gear icon at the upper right corner of the table and ensure a checkmark is next to both, then click `Close`. 4. Ensure that for each user where the `Password age` column shows a password age, the `MFA` column shows `Virtual`, `U2F Security Key`, or `Hardware`. **From Command Line:** 1. Run the following command (OSX/Linux/UNIX) to generate a list of all IAM users along with their password and MFA status: ``` aws iam generate-credential-report ``` ``` aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,4,8 ``` 2. The output of this command will produce a table similar to the following: ``` user,password_enabled,mfa_active elise,false,false brandon,true,true rakesh,false,false helene,false,false paras,true,true anitha,false,false ``` 3. For any column having `password_enabled` set to `true` , ensure `mfa_active` is also set to `true.`", + "AdditionalInformation": "**Forced IAM User Self-Service Remediation** Amazon has published a pattern that requires users to set up MFA through self-service before they gain access to their complete set of permissions. Until they complete this step, they cannot access their full permissions. This pattern can be used for new AWS accounts. It can also be applied to existing accounts; it is recommended that users receive instructions and a grace period to complete MFA enrollment before active enforcement on existing AWS accounts.", + "References": "https://tools.ietf.org/html/rfc6238:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#enable-mfa-for-privileged-users:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html:https://blogs.aws.amazon.com/security/post/Tx2SJJYE082KBUK/How-to-Delegate-Management-of-Multi-Factor-Authentication-to-AWS-IAM-Users", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.11", + "Description": "Ensure credentials unused for 45 days or more are disabled", + "Checks": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "AWS IAM users can access AWS resources using different types of credentials, such as passwords or access keys. It is recommended that all credentials that have been unused for 45 days or more be deactivated or removed.", + "RationaleStatement": "Disabling or removing unnecessary credentials will reduce the window of opportunity for credentials associated with a compromised or abandoned account to be used.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to manage Unused Password (IAM user console access) 1. Login to the AWS Management Console: 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click on `Security Credentials` 6. Select user whose `Console last sign-in` is greater than 45 days 7. Click `Security credentials` 8. In section `Sign-in credentials`, `Console password` click `Manage` 9. Under Console Access select `Disable` 10. Click `Apply` Perform the following to deactivate Access Keys: 1. Login to the AWS Management Console: 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click on `Security Credentials` 6. Select any access keys that are over 45 days old and that have been used and - Click on `Make Inactive` 7. Select any access keys that are over 45 days old and that have not been used and - Click the X to `Delete`", + "AuditProcedure": "Perform the following to determine if unused credentials exist: **From Console:** 1. Login to the AWS Management Console 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click the `Settings` (gear) icon. 6. Select `Console last sign-in`, `Access key last used`, and `Access Key Id` 7. Click on `Close` 8. Check and ensure that `Console last sign-in` is less than 45 days ago. **Note** - `Never` means the user has never logged in. 9. Check and ensure that `Access key age` is less than 45 days and that `Access key last used` does not say `None` If the user hasn't signed into the Console in the last 45 days or Access keys are over 45 days old refer to the remediation. **From Command Line:** **Download Credential Report:** 1. Run the following commands: ``` aws iam generate-credential-report aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,4,5,6,9,10,11,14,15,16 | grep -v '^' ``` **Ensure unused credentials do not exist:** 2. For each user having `password_enabled` set to `TRUE` , ensure `password_last_used_date` is less than `45` days ago. - When `password_enabled` is set to `TRUE` and `password_last_used` is set to `No_Information` , ensure `password_last_changed` is less than 45 days ago. 3. For each user having an `access_key_1_active` or `access_key_2_active` to `TRUE` , ensure the corresponding `access_key_n_last_used_date` is less than `45` days ago. - When a user having an `access_key_x_active` (where x is 1 or 2) to `TRUE` and corresponding access_key_x_last_used_date is set to `N/A`, ensure `access_key_x_last_rotated` is less than 45 days ago.", + "AdditionalInformation": " is excluded in the audit since the root account should not be used for day-to-day business and would likely be unused for more than 45 days.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_finding-unused.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_admin-change-user.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.12", + "Description": "Ensure access keys are rotated every 90 days or less", + "Checks": [ + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Access keys consist of an access key ID and secret access key, which are used to sign programmatic requests that you make to AWS. AWS users need their own access keys to make programmatic calls to AWS from the AWS Command Line Interface (AWS CLI), Tools for Windows PowerShell, the AWS SDKs, or direct HTTP calls using the APIs for individual AWS services. It is recommended that all access keys be rotated regularly.", + "RationaleStatement": "Rotating access keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. Access keys should be rotated to ensure that data cannot be accessed with an old key which might have been lost, cracked, or stolen.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to rotate access keys: **From Console:** 1. Go to the Management Console (https://console.aws.amazon.com/iam) 2. Click on `Users` 3. Click on `Security Credentials` 4. As an Administrator - Click on `Make Inactive` for keys that have not been rotated in `90` Days 5. As an IAM User - Click on `Make Inactive` or `Delete` for keys which have not been rotated or used in `90` Days 6. Click on `Create Access Key` 7. Update programmatic calls with new Access Key credentials **From Command Line:** 1. While the first access key is still active, create a second access key, which is active by default. Run the following command: ``` aws iam create-access-key --user-name ``` At this point, the user has two active access keys. 2. Update all applications and tools to use the new access key. 3. Determine whether the first access key is still in use by using this command: ``` aws iam get-access-key-last-used --access-key-id ``` 4. One approach is to wait several days and then check the old access key for any use before proceeding. Even if step 3 indicates no use of the old key, it is recommended that you do not immediately delete the first access key. Instead, change the state of the first access key to Inactive using this command: ``` aws iam update-access-key --user-name --access-key-id --status Inactive ``` 5. Use only the new access key to confirm that your applications are working. Any applications and tools that still use the original access key will stop working at this point because they no longer have access to AWS resources. If you find such an application or tool, you can switch its state back to Active to reenable the first access key. Then return to step 2 and update this application to use the new key. 6. After you wait some period of time to ensure that all applications and tools have been updated, you can delete the first access key with this command: ``` aws iam delete-access-key --user-name --access-key-id ```", + "AuditProcedure": "Perform the following to determine if access keys are rotated as prescribed: **From Console:** 1. Go to the Management Console (https://console.aws.amazon.com/iam) 2. Click on `Users` 3. For each user, go to `Security Credentials` 4. Review each key under `Access Keys` 5. For each key that shows `Active` for status, ensure that `Created` is less than or equal to `90 days ago`. **From Command Line:** ``` aws iam generate-credential-report aws iam get-credential-report --query 'Content' --output text | base64 -d ``` The `access_key_1_last_rotated` and the `access_key_2_last_rotated` fields in this file notes the date and time, in ISO 8601 date-time format, when the user's access key was created or last changed. If the user does not have an active access key, the value in this field is N/A (not applicable).", + "AdditionalInformation": "", + "References": "CCE-78902-4:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_finding-unused.html:https://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html", + "DefaultValue": "By default, AWS does not enforce access key rotation. Access keys remain valid until they are manually deactivated or deleted." + } + ] + }, + { + "Id": "2.13", + "Description": "Ensure IAM users receive permissions only through groups", + "Checks": [ + "iam_policy_attached_only_to_group_or_roles" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "IAM users are granted access to services, functions, and data through IAM policies. There are four ways to define policies for a user: 1) Edit the user policy directly, also known as an inline or user policy; 2) attach a policy directly to a user; 3) add the user to an IAM group that has an attached policy; 4) add the user to an IAM group that has an inline policy. Only the third implementation is recommended.", + "RationaleStatement": "Assigning IAM policies solely through groups unifies permissions management into a single, flexible layer that is consistent with organizational functional roles. By unifying permissions management, the likelihood of excessive permissions is reduced.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to create an IAM group and assign a policy to it: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the navigation pane, click `Groups` and then click `Create New Group`. 3. In the `Group Name` box, type the name of the group and then click `Next Step`. 4. In the list of policies, select the check box for each policy that you want to apply to all members of the group. Then click `Next Step`. 5. Click `Create Group`. Perform the following to add a user to a given group: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the navigation pane, click `Groups`. 3. Select the group to add a user to. 4. Click `Add Users To Group`. 5. Select the users to be added to the group. 6. Click `Add Users`. Perform the following to remove a direct association between a user and policy: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the left navigation pane, click on Users. 3. For each user: - Select the user - Click on the `Permissions` tab - Expand `Permissions policies` - Click `X` for each policy; then click Detach or Remove (depending on policy type) **From Command Line:** 1. Create the IAM user group: ``` aws iam create-group --group-name ``` 2. Attach the policy to the IAM user group: ``` aws iam attach-group-policy --group-name --policy-arn ``` 3. Perform the following to add a user to a given group: ``` aws iam add-user-to-group --user-name --group-name ``` 4. Perform the following to remove a direct association between a user and policy: ``` aws iam detach-user-policy --user-name --policy-arn ``` 5. Delete an inline policy from an IAM user: ``` aws iam delete-user-policy --user-name --policy-name ```", + "AuditProcedure": "Perform the following to determine if an inline policy is set or a policy is directly attached to users: 1. Run the following to get a list of IAM users: ``` aws iam list-users --query 'Users[*].UserName' --output text ``` 2. For each user returned, run the following command to determine if any policies are attached to them: ``` aws iam list-attached-user-policies --user-name aws iam list-user-policies --user-name ``` 3. If any policies are returned, the user has an inline policy or direct policy attachment.", + "AdditionalInformation": "", + "References": "http://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:CCE-78912-3", + "DefaultValue": "By default, AWS allows IAM policies to be attached directly to users, groups, or roles. There is no restriction preventing direct user policies unless explicitly enforced by organizational standards." + } + ] + }, + { + "Id": "2.14", + "Description": "Ensure IAM policies that allow full \"*:*\" administrative privileges are not attached", + "Checks": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "IAM policies are the means by which privileges are granted to users, groups, or roles. It is recommended and considered standard security advice to grant least privilege—that is, granting only the permissions required to perform a task. Determine what users need to do, and then craft policies for them that allow the users to perform only those tasks, instead of granting full administrative privileges.", + "RationaleStatement": "It's more secure to start with a minimum set of permissions and grant additional permissions as necessary, rather than starting with permissions that are too lenient and then attempting to tighten them later. Providing full administrative privileges instead of restricting access to the minimum set of permissions required for the user exposes resources to potentially unwanted actions. IAM policies that contain a statement with `Effect: Allow` and `Action: *` over `Resource: *` should be removed.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to detach the policy that has full administrative privileges: 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. In the navigation pane, click Policies and then search for the policy name found in the audit step. 3. Select the policy that needs to be deleted. 4. In the policy action menu, select `Detach`. 5. Select all Users, Groups, Roles that have this policy attached. 6. Click `Detach Policy`. 7. Select the newly detached policy and select `Delete`. **From Command Line:** Perform the following to detach the policy that has full administrative privileges as found in the audit step: 1. Lists all IAM users, groups, and roles that the specified managed policy is attached to. ``` aws iam list-entities-for-policy --policy-arn ``` 2. Detach the policy from all IAM Users: ``` aws iam detach-user-policy --user-name --policy-arn ``` 3. Detach the policy from all IAM Groups: ``` aws iam detach-group-policy --group-name --policy-arn ``` 4. Detach the policy from all IAM Roles: ``` aws iam detach-role-policy --role-name --policy-arn ```", + "AuditProcedure": "Perform the following to determine existing policies: **From Command Line:** 1. Run the following to get a list of IAM policies: ``` aws iam list-policies --only-attached --output text ``` 2. For each policy returned, run the following command to determine if any policy is allowing full administrative privileges on the account: ``` aws iam get-policy-version --policy-arn --version-id ``` 3. In the output, the policy should not contain any Statement block with `Effect: Allow` and `Action` set to `*` and `Resource` set to `*`.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:https://docs.aws.amazon.com/cli/latest/reference/iam/index.html#cli-aws-iam", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.15", + "Description": "Ensure a support role has been created to manage incidents with AWS Support", + "Checks": [ + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "AWS provides a support center that can be used for incident notification and response, as well as technical support and customer services. Create an IAM Role, with the appropriate policy assigned, to allow authorized users to manage incidents with AWS Support.", + "RationaleStatement": "By implementing least privilege for access control, an IAM Role will require an appropriate IAM Policy to allow Support Center Access in order to manage Incidents with AWS Support.", + "ImpactStatement": "All AWS Support plans include an unlimited number of account and billing support cases, with no long-term contracts. Support billing calculations are performed on a per-account basis for all plans. Enterprise Support plan customers have the option to include multiple enabled accounts in an aggregated monthly billing calculation. Monthly charges for the Business and Enterprise support plans are based on each month's AWS usage charges, subject to a monthly minimum, billed in advance. When assigning rights, keep in mind that other policies may grant access to Support as well. This may include AdministratorAccess and other policies including customer managed policies. Utilizing the AWS managed 'AWSSupportAccess' role is one simple way of ensuring that this permission is properly granted. To better support the principle of separation of duties, it would be best to only attach this role where necessary.", + "RemediationProcedure": "**From Command Line:** 1. Create an IAM role for managing incidents with AWS: - Create a trust relationship policy document that allows to manage AWS incidents, and save it locally as /tmp/TrustPolicy.json: ``` { Version: 2012-10-17, Statement: [ { Effect: Allow, Principal: { AWS: }, Action: sts:AssumeRole } ] } ``` 2. Create the IAM role using the above trust policy: ``` aws iam create-role --role-name --assume-role-policy-document file:///tmp/TrustPolicy.json ``` 3. Attach 'AWSSupportAccess' managed policy to the created IAM role: ``` aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AWSSupportAccess --role-name ```", + "AuditProcedure": "**From Command Line:** 1. List IAM policies, filter for the 'AWSSupportAccess' managed policy, and note the Arn element value: ``` aws iam list-policies --query Policies[?PolicyName == 'AWSSupportAccess'] ``` 2. Check if the 'AWSSupportAccess' policy is attached to any role: ``` aws iam list-entities-for-policy --policy-arn arn:aws:iam::aws:policy/AWSSupportAccess ``` 3. In the output, ensure `PolicyRoles` does not return empty. 'Example: Example: PolicyRoles: [ ]' If it returns empty refer to the remediation below.", + "AdditionalInformation": "AWSSupportAccess policy is a global AWS resource. It has same ARN as `arn:aws:iam::aws:policy/AWSSupportAccess` for every account.", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:https://aws.amazon.com/premiumsupport/pricing/:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/list-policies.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/attach-role-policy.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/list-entities-for-policy.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.16", + "Description": "Ensure IAM instance roles are used for AWS resource access from instances", + "Checks": [ + "ec2_instance_profile_attached" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS access from within AWS instances can be done by either encoding AWS keys into AWS API calls or by assigning the instance to a role which has an appropriate permissions policy for the required access. AWS Access means accessing the APIs of AWS in order to access AWS resources or manage AWS account resources.", + "RationaleStatement": "AWS IAM roles reduce the risks associated with sharing and rotating credentials that can be used outside of AWS itself. Compromised credentials can be used from outside the AWS account to which they provide access. In contrast, to leverage role permissions, an attacker would need to gain and maintain access to a specific instance to use the privileges associated with it. Additionally, if credentials are encoded into compiled applications or other hard-to-change mechanisms, they are even less likely to be properly rotated due to the risks of service disruption. As time passes, credentials that cannot be rotated are more likely to be known by an increasing number of individuals who no longer work for the organization that owns the credentials.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at `https://console.aws.amazon.com/ec2/`. 2. In the left navigation panel, choose `Instances`. 3. Select the EC2 instance you want to modify. 4. Click `Actions`. 5. Click `Security`. 6. Click `Modify IAM role`. 7. Click `Create new IAM role` if a new IAM role is required. 8. Select the IAM role you want to attach to your instance in the `IAM role` dropdown. 9. Click `Update IAM role`. 10. Repeat steps 3 to 9 for each EC2 instance in your AWS account that requires an IAM role to be attached. **From Command Line:** 1. Run the `describe-instances` command to list all EC2 instance IDs in the selected AWS region: ``` aws ec2 describe-instances --region --query 'Reservations[*].Instances[*].InstanceId' ``` 2. Run the `associate-iam-instance-profile` command to attach an instance profile (which is attached to an IAM role) to the EC2 instance: ``` aws ec2 associate-iam-instance-profile --region --instance-id --iam-instance-profile Name=Instance-Profile-Name ``` 3. Run the `describe-instances` command again for the recently modified EC2 instance. The command output should return the instance profile ARN and ID: ``` aws ec2 describe-instances --region --instance-id --query 'Reservations[*].Instances[*].IamInstanceProfile' ``` 4. Repeat steps 2 and 3 for each EC2 instance in your AWS account that requires an IAM role to be attached.", + "AuditProcedure": "First, check if the instance has any API secrets stored using Secret Scanning. Currently, AWS does not have a solution for this. You can use open-source tools like TruffleHog to scan for secrets in the EC2 instance. If a secret is found, then assign the role to the instance. **From Console:** 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at `https://console.aws.amazon.com/ec2/`. 2. In the left navigation panel, choose `Instances`. 3. Select the EC2 instance you want to examine. 4. Select `Actions`. 5. Select `View details`. 6. Select `Security` in the lower panel. - If the value for **Instance profile arn** is an instance profile ARN, then an instance profile (that contains an IAM role) is attached. - If the value for **IAM Role** is blank, no role is attached. - If the value for **IAM Role** contains a role, a role is attached. - If the value for **IAM Role** is No roles attached to instance profile: , then an instance profile is attached to the instance, but it does not contain an IAM role. 7. Repeat steps 3 to 6 for each EC2 instance in your AWS account. **From Command Line:** 1. Run the `describe-instances` command to list all EC2 instance IDs in the selected AWS region: ``` aws ec2 describe-instances --region --query 'Reservations[*].Instances[*].InstanceId' ``` 2. Run the `describe-instances` command again for each EC2 instance using the `IamInstanceProfile` identifier in the query filter to check if an IAM role is attached: ``` aws ec2 describe-instances --region --instance-id --query 'Reservations[*].Instances[*].IamInstanceProfile' ``` 3. If an IAM role is attached, the command output will show the IAM instance profile ARN and ID. 4. Repeat steps 2 and 3 for each EC2 instance in your AWS account.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html:https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.17", + "Description": "Ensure that all expired SSL/TLS certificates stored in AWS IAM are removed", + "Checks": [ + "iam_no_expired_server_certificates_stored" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "To enable HTTPS connections to your website or application in AWS, you need an SSL/TLS server certificate. You can use AWS Certificate Manager (ACM) or IAM to store and deploy server certificates. Use IAM as a certificate manager only when you must support HTTPS connections in a region that is not supported by ACM. IAM securely encrypts your private keys and stores the encrypted version in IAM SSL certificate storage. IAM supports deploying server certificates in all regions, but you must obtain your certificate from an external provider for use with AWS. You cannot upload an ACM certificate to IAM. Additionally, you cannot manage your certificates from the IAM Console.", + "RationaleStatement": "Removing expired SSL/TLS certificates eliminates the risk that an invalid certificate will be deployed accidentally to a resource such as AWS Elastic Load Balancer (ELB), which can damage the credibility of the application/website behind the ELB. As a best practice, it is recommended to delete expired certificates.", + "ImpactStatement": "Deleting the certificate could have implications for your application if you are using an expired server certificate with Elastic Load Balancing, CloudFront, etc. You must make configurations in the respective services to ensure there is no interruption in application functionality.", + "RemediationProcedure": "**From Console:** Removing expired certificates via AWS Management Console is not currently supported. To delete SSL/TLS certificates stored in IAM through the AWS API, use the Command Line Interface (CLI). **From Command Line:** To delete an expired certificate, run the following command by replacing with the name of the certificate to delete: ``` aws iam delete-server-certificate --server-certificate-name ``` When the preceding command is successful, it does not return any output.", + "AuditProcedure": "**From Console:** Getting the certificate expiration information via the AWS Management Console is not currently supported. To request information about the SSL/TLS certificates stored in IAM through the AWS API, use the Command Line Interface (CLI). **From Command Line:** Run the `list-server-certificates` command to list all the IAM-stored server certificates: ``` aws iam list-server-certificates ``` The command output should return an array that contains all the SSL/TLS certificates currently stored in IAM and their metadata (name, ID, expiration date, etc): ``` { ServerCertificateMetadataList: [ { ServerCertificateId: EHDGFRW7EJFYTE88D, ServerCertificateName: MyServerCertificate, Expiration: 2018-07-10T23:59:59Z, Path: /, Arn: arn:aws:iam::012345678910:server-certificate/MySSLCertificate, UploadDate: 2018-06-10T11:56:08Z } ] } ``` Verify the `ServerCertificateName` and `Expiration` parameter value (expiration date) for each SSL/TLS certificate returned by the list-server-certificates command and determine if there are any expired server certificates currently stored in AWS IAM. If so, use the AWS API to remove them. If this command returns: ``` { { ServerCertificateMetadataList: [] } ``` This means that there are no expired certificates; it **does not** mean that no certificates exist.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_server-certs.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/delete-server-certificate.html", + "DefaultValue": "By default, expired certificates will not be deleted." + } + ] + }, + { + "Id": "2.18", + "Description": "Ensure that IAM External Access Analyzer is enabled for all regions", + "Checks": [ + "accessanalyzer_enabled" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable the IAM External Access Analyzer regarding all resources in each active AWS region. IAM Access Analyzer is a technology introduced at AWS reinvent 2019. After the Analyzer is enabled in IAM, scan results are displayed on the console showing the accessible resources. Scans show resources that other accounts and federated users can access, such as KMS keys and IAM roles. The results allow you to determine whether an unintended user is permitted, making it easier for administrators to monitor least privilege access. Access Analyzer analyzes only the policies that are applied to resources in the same AWS Region.", + "RationaleStatement": "AWS IAM External Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with external entities. This allows you to identify unintended access to your resources and data. Access Analyzer identifies resources that are shared with external principals by using logic-based reasoning to analyze the resource-based policies in your AWS environment. IAM External Access Analyzer continuously monitors all policies for S3 buckets, IAM roles, KMS (Key Management Service) keys, AWS Lambda functions, Amazon SQS (Simple Queue Service) queues and more", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following to enable IAM Access Analyzer for IAM policies: 1. Open the IAM console at `https://console.aws.amazon.com/iam/.` 2. Choose `Access analyzer`. 3. Choose `Create external access analyzer`. 4. On the `Create analyzer` page, confirm that the `Region` displayed is the Region where you want to enable Access Analyzer. 5. Optionally enter a name for the analyzer. 6. Optionally add any tags that you want to apply to the analyzer. 7. Choose `Create Analyzer`. 8. Repeat these step for each active region. **From Command Line:** Run the following command: ``` aws accessanalyzer list-analyzers --type ORGANIZATION ``` Repeat this command for each active region. **Note:** The IAM Access Analyzer is successfully configured only when the account you use has the necessary permissions.", + "AuditProcedure": "**From Console:** 1. Open the IAM console at `https://console.aws.amazon.com/iam/` 2. Under `Access analyzer` choose `Analyzer Settings` 3. On the `Analyzer Settings` page, there will be a list of analyzers. 4. Look for analyzers where the `Finding type` is `External Access`. **From Command Line:** 1. Run the following command: ``` aws accessanalyzer list-analyzers --type ORGANIZATION | grep status ``` 2. Ensure that at least one Analyzer's `status` is set to `ACTIVE`. 3. Repeat the steps above for each active region. If an Access Analyzer is not listed for each region or the status is not set to active refer to the remediation procedure below.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/accessanalyzer/get-analyzer.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/accessanalyzer/create-analyzer.html", + "DefaultValue": "By default, IAM External Access Analyzer is not enabled in any region. An analyzer must be explicitly created and activated for each region where monitoring is required." + } + ] + }, + { + "Id": "2.19", + "Description": "Ensure IAM users are managed centrally via identity federation or AWS Organizations for multi-account environments", + "Checks": [ + "iam_check_saml_providers_sts" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "In multi-account environments, IAM user centralization facilitates greater user control. User access beyond the initial account is then provided via role assumption. Centralization of users can be accomplished through federation with an external identity provider or through the use of AWS Organizations.", + "RationaleStatement": "Centralizing IAM user management to a single identity store reduces complexity and thus the likelihood of access management errors.", + "ImpactStatement": "", + "RemediationProcedure": "The remediation procedure will vary based on each individual organization's implementation of identity federation and/or AWS Organizations, with the acceptance criteria that no non-service IAM users and non-root accounts are present outside the account providing centralized IAM user management.", + "AuditProcedure": "For multi-account AWS environments with an external identity provider: 1. Determine the master account for identity federation or IAM user management 2. Login to that account through the AWS Management Console 3. Click `Services` 4. Click `IAM` 5. Click `Identity providers` 6. Verify the configuration For multi-account AWS environments with an external identity provider, as well as for those implementing AWS Organizations without an external identity provider: 1. Determine all accounts that should not have local users present 2. Log into the AWS Management Console 3. Switch role into each identified account 4. Click `Services` 5. Click `IAM` 6. Click `Users` 7. Confirm that no IAM users representing individuals are present", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.20", + "Description": "Ensure access to AWSCloudShellFullAccess is restricted", + "Checks": [ + "iam_policy_cloudshell_admin_not_attached" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "AWS CloudShell is a convenient way of running CLI commands against AWS services; a managed IAM policy ('AWSCloudShellFullAccess') provides full access to CloudShell, which allows file upload and download capability between a user's local system and the CloudShell environment. Within the CloudShell environment, a user has sudo permissions and can access the internet. Therefore, it is feasible to install file transfer software, for example, and move data from CloudShell to external internet servers.", + "RationaleStatement": "Access to this policy should be restricted, as it presents a potential channel for data exfiltration by malicious cloud admins who are given full permissions to the service. AWS documentation describes how to create a more restrictive IAM policy that denies file transfer permissions.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console** 1. Open the IAM console at https://console.aws.amazon.com/iam/ 2. In the left pane, select Policies 3. Search for and select AWSCloudShellFullAccess 4. On the Entities attached tab, for each item, check the box and select Detach", + "AuditProcedure": "**From Console** 1. Open the IAM console at https://console.aws.amazon.com/iam/ 2. In the left pane, select Policies 3. Search for and select AWSCloudShellFullAccess 4. On the Entities attached tab, ensure that there are no entities using this policy **From Command Line** 1. List IAM policies, filter for the 'AWSCloudShellFullAccess' managed policy, and note the Arn element value: ``` aws iam list-policies --query Policies[?PolicyName == 'AWSCloudShellFullAccess'] ``` 2. Check if the 'AWSCloudShellFullAccess' policy is attached to any role: ``` aws iam list-entities-for-policy --policy-arn arn:aws:iam::aws:policy/AWSCloudShellFullAccess ``` 3. In the output, ensure PolicyRoles returns empty. 'Example: Example: PolicyRoles: [ ]' If it does not return empty, refer to the remediation below. **Note:** Keep in mind that other policies may grant access.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/cloudshell/latest/userguide/sec-auth-with-identities.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.21", + "Description": "Ensure AWS resource policies do not allow unrestricted access using 'Principal': '*'", + "Checks": [ + "s3_bucket_policy_public_write_access", + "sqs_queues_not_publicly_accessible", + "sns_topics_not_publicly_accessible", + "awslambda_function_not_publicly_accessible", + "kms_key_not_publicly_accessible", + "glacier_vaults_policy_public_access", + "secretsmanager_not_publicly_accessible", + "eventbridge_bus_exposed" + ], + "Attributes": [ + { + "Section": "2 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure AWS resource-based policies, such as Amazon S3 bucket policies, Amazon SQS queue policies, Amazon SNS topic policies, and AWS Lambda resource policies, do not grant unrestricted access using \"Principal\": \"*\" with \"Effect\": \"Allow\" unless the policy includes restrictive conditions that limit access to specific trusted identities, accounts, services, or network boundaries.", + "RationaleStatement": "Resource-based policies are evaluated alongside identity-based IAM policies during authorization decisions. When a policy statement specifies \"Principal\": \"*\" with \"Effect\": \"Allow\", it grants the specified permissions to any AWS principal unless additional conditions restrict the request. This may unintentionally allow access from users, roles, or services in any AWS account. Such broad access significantly increases the risk of unauthorized data access, resource abuse, or data exfiltration.", + "ImpactStatement": "Unrestricted resource-based policies may expose data or services to unauthorized access, potentially leading to data breaches, service misuse, or unintended public exposure.", + "RemediationProcedure": "If a resource policy contains `\"Principal\": \"*\"` with `\"Effect\": \"Allow\"` and lacks sufficient restrictions, modify the policy to limit access. **OPTION 1 - Restrict the Principal:** Replace the wildcard principal (`\"Principal\": \"*\"`) with a specific account, role, user, or service. Example Non-Compliant Policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowPublicAccess\", \"Effect\": \"Allow\", \"Principal\": \"*\", \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\"}]} ``` Steps: 1. Retrieve the current policy: ``` aws sqs get-queue-attributes --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue --attribute-names Policy --query 'Attributes.Policy' ``` 2. Update the policy with a specific principal: ``` aws sqs set-queue-attributes --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue --attributes '{\"Policy\": \"{\\\"Version\\\":\\\"2012-10-17\\\",\\\"Statement\\\":[{\\\"Sid\\\":\\\"AllowSpecificAccount\\\",\\\"Effect\\\":\\\"Allow\\\",\\\"Principal\\\":{\\\"AWS\\\":\\\"arn:aws:iam::345678901234:root\\\"},\\\"Action\\\":\\\"sqs:SendMessage\\\",\\\"Resource\\\":\\\"arn:aws:sqs:us-east-1:123456789012:my-queue\\\"}]}\"}' ``` Resulting Compliant Policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowSpecificAccount\", \"Effect\": \"Allow\", \"Principal\": {\"AWS\": \"arn:aws:iam::345678901234:root\"}, \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\"}]} ``` **OPTION 2 - Restrict Using Conditions:** If a wildcard principal is required, add restrictive conditions. Example compliant policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowServiceIntegration\", \"Effect\": \"Allow\", \"Principal\": \"*\", \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\", \"Condition\": {\"StringEquals\": {\"aws:SourceAccount\": \"345678901234\"}}}]} ```", + "AuditProcedure": "1. Identify resources that support resource-based policies within the AWS account, such as S3 buckets, SQS queues, SNS topics, and Lambda functions. 2. Retrieve the resource policies for each resource. Example CLI commands: SQS Queue Policies: ``` aws sqs get-queue-attributes --queue-url https://sqs.region.amazonaws.com/account/QUEUE --attribute-names Policy ``` S3 Bucket Policies: ``` aws s3api get-bucket-policy --bucket YOUR-BUCKET-NAME ``` SNS Topic Policies: ``` aws sns get-topic-attributes --topic-arn TOPIC-ARN --query \"Attributes.Policy\" --output text ``` 3. Inspect the retrieved policies and identify statements containing: - `\"Effect\": \"Allow\"` AND `\"Principal\": \"*\"` OR - `\"Principal\": {\"AWS\": \"*\"}` 4. Evaluate whether the statement includes restrictive conditions such as: - `aws:SourceArn` - `aws:SourceAccount` - `aws:PrincipalArn` - Other service-specific condition keys 5. Determine audit status: - Compliant: Wildcard principals are present only when restrictive conditions limit access to trusted principals or services - Non-Compliant: Wildcard principals are used without sufficient restrictions", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, AWS does not prevent the use of \"Principal\": \"*\" in resource-based policies. Policies may allow unrestricted access unless explicitly restricted through policy definitions or organizational controls. It is the responsibility of the customer to ensure that resource policies are properly scoped and do not grant unintended public or cross-account access." + } + ] + }, + { + "Id": "3.1.1", + "Description": "Ensure S3 Bucket Policy is set to deny HTTP requests", + "Checks": [ + "s3_bucket_secure_transport_policy" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "At the Amazon S3 bucket level, you can configure permissions through a bucket policy, making the objects accessible only through HTTPS.", + "RationaleStatement": "By default, Amazon S3 allows both HTTP and HTTPS requests. To ensure that access to Amazon S3 objects is only permitted through HTTPS, you must explicitly deny HTTP requests. Bucket policies that allow HTTPS requests without explicitly denying HTTP requests will not comply with this recommendation.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to the Bucket. 3. Click on 'Permissions'. 4. Click 'Bucket Policy'. 5. Add either of the following to the existing policy, filling in the required information: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` 6. Save 7. Repeat for all the buckets in your AWS account that contain sensitive data. **From Console** Using AWS Policy Generator: 1. Repeat steps 1-4 above. 2. Click on `Policy Generator` at the bottom of the Bucket Policy Editor. 3. Select Policy Type `S3 Bucket Policy`. 4. Add Statements: - `Effect` = Deny - `Principal` = * - `AWS Service` = Amazon S3 - `Actions` = * - `Amazon Resource Name` = 5. Generate Policy. 6. Copy the text and add it to the Bucket Policy. **From Command Line:** 1. Export the bucket policy to a json file: ``` aws s3api get-bucket-policy --bucket --query Policy --output text > policy.json ``` 2. Modify the policy.json file by adding either of the following: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` 3. Apply this modified policy back to the S3 bucket: ``` aws s3api put-bucket-policy --bucket --policy file://policy.json ```", + "AuditProcedure": "To allow access to HTTPS, you can use a bucket policy with the effect `allow` and a condition that checks for the key `aws:SecureTransport: true`. This means that HTTPS requests are allowed, but it does not deny HTTP requests. To explicitly deny HTTP access, ensure that there is also a bucket policy with the effect `deny` that contains the key `aws:SecureTransport: false`. You may also require TLS by setting a policy to deny any version lower than the one you wish to require, using the condition `NumericLessThan` and the key `s3:TlsVersion: 1.2`. **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to the Bucket. 3. Click on 'Permissions', then click on `Bucket Policy`. 4. Ensure that a policy is listed that matches either: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` `` and `` will be specific to your account, and TLS version will be site/policy specific to your organisation. 5. Repeat for all the buckets in your AWS account. **From Command Line:** 1. List all of the S3 Buckets ``` aws s3 ls ``` 2. Using the list of buckets, run this command on each of them: ``` aws s3api get-bucket-policy --bucket | grep aws:SecureTransport ``` or ``` aws s3api get-bucket-policy --bucket | grep s3:TlsVersion ``` NOTE : If an error is thrown by the CLI, it means no policy has been configured for the specified S3 bucket, and that by default it is allowing both HTTP and HTTPS requests. 3. Confirm that `aws:SecureTransport` is set to false (such as `aws:SecureTransport:false`) or that `s3:TlsVersion` has a site-specific value. 4. Confirm that the policy line has Effect set to Deny 'Effect:Deny'", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/:https://aws.amazon.com/blogs/security/how-to-use-bucket-policies-and-apply-defense-in-depth-to-help-secure-your-amazon-s3-data/:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3api/get-bucket-policy.html", + "DefaultValue": "Both HTTP and HTTPS requests are allowed." + } + ] + }, + { + "Id": "3.1.2", + "Description": "Ensure MFA Delete is enabled on S3 buckets", + "Checks": [ + "s3_bucket_no_mfa_delete" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Once MFA Delete is enabled on your sensitive and classified S3 bucket, it requires the user to provide two forms of authentication.", + "RationaleStatement": "Adding MFA delete to an S3 bucket requires additional authentication when you change the version state of your bucket or delete an object version, adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.", + "ImpactStatement": "Enabling MFA delete on an S3 bucket could require additional administrator oversight. Enabling MFA delete may impact other services that automate the creation and/or deletion of S3 buckets.", + "RemediationProcedure": "Perform the steps below to enable MFA delete on an S3 bucket: **Note:** - You cannot enable MFA Delete using the AWS Management Console; you must use the AWS CLI or API. - You must use your 'root' account to enable MFA Delete on S3 buckets. **From Command line:** 1. Run the s3api `put-bucket-versioning` command: ``` aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa “arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode” ```", + "AuditProcedure": "Perform the steps below to confirm that MFA delete is configured on an S3 bucket: **From Console:** 1. Login to the S3 console at `https://console.aws.amazon.com/s3/`. 2. Click the `check` box next to the name of the bucket you want to confirm. 3. In the window under `Properties`: - Confirm that Versioning is `Enabled` - Confirm that MFA Delete is `Enabled` **From Command Line:** 1. Run the `get-bucket-versioning` command: ``` aws s3api get-bucket-versioning --bucket my-bucket ``` Example output: ``` Enabled Enabled ``` If the console or CLI output does not show that Versioning and MFA Delete are `enabled`, please refer to the remediation below.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.3", + "Description": "Ensure all data in Amazon S3 has been discovered, classified, and secured when necessary", + "Checks": [ + "macie_is_enabled" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Amazon S3 buckets can contain sensitive data that, for security purposes, should be discovered, monitored, classified, and protected. Macie, along with other third-party tools, can automatically provide an inventory of Amazon S3 buckets.", + "RationaleStatement": "Using a cloud service or third-party software to continuously monitor and automate the process of data discovery and classification for S3 buckets through machine learning and pattern matching is a strong defense in protecting that information. Amazon Macie is a fully managed data security and privacy service that uses machine learning and pattern matching to discover and protect your sensitive data in AWS.", + "ImpactStatement": "There is a cost associated with using Amazon Macie, and there is typically a cost associated with third-party tools that perform similar processes and provide protection.", + "RemediationProcedure": "Perform the steps below to enable and configure Amazon Macie: **From Console:** 1. Log on to the Macie console at `https://console.aws.amazon.com/macie/`. 2. Click `Get started`. 3. Click `Enable Macie`. Set up a repository for sensitive data discovery results: 1. In the left pane, under Settings, click `Discovery results`. 2. Make sure `Create bucket` is selected. 3. Create a bucket and enter a name for it. The name must be unique across all S3 buckets, and it must start with a lowercase letter or a number. 4. Click `Advanced`. 5. For block all public access, make sure `Yes` is selected. 6. For KMS encryption, specify the AWS KMS key that you want to use to encrypt the results. The key must be a symmetric customer master key (CMK) that is in the same region as the S3 bucket. 7. Click `Save`. Create a job to discover sensitive data: 1. In the left pane, click `S3 buckets`. Macie displays a list of all the S3 buckets for your account. 2. Check the box for each bucket that you want Macie to analyze as part of the job. 3. Click `Create job`. 4. Click `Quick create`. 5. For the Name and Description step, enter a name and, optionally, a description of the job. 6. Click `Next`. 7. For the Review and create step, click `Submit`. Review your findings: 1. In the left pane, click `Findings`. 2. To view the details of a specific finding, choose any field other than the check box for the finding. If you are using a third-party tool to manage and protect your S3 data, follow the vendor documentation for implementing and configuring that tool.", + "AuditProcedure": "Perform the following steps to determine if Macie is running: **From Console:** 1. Login to the Macie console at https://console.aws.amazon.com/macie/. 2. In the left hand pane, click on `By job` under findings. 3. Confirm that you have a job set up for your S3 buckets. When you log into the Macie console, if you are not taken to the summary page and do not have a job set up and running, then refer to the remediation procedure below. If you are using a third-party tool to manage and protect your S3 data, you meet this recommendation.", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/macie/getting-started/:https://docs.aws.amazon.com/workspaces/latest/adminguide/data-protection.html:https://docs.aws.amazon.com/macie/latest/user/data-classification.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.4", + "Description": "Ensure that S3 is configured with 'Block Public Access' enabled", + "Checks": [ + "s3_bucket_level_public_access_block", + "s3_account_level_public_access_blocks" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.1 Simple Storage Service (S3)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Amazon S3 provides `Block public access (bucket settings)` and `Block public access (account settings)` to help you manage public access to Amazon S3 resources. By default, S3 buckets and objects are created with public access disabled. However, an IAM principal with sufficient S3 permissions can enable public access at the bucket and/or object level. While enabled, `Block public access (bucket settings)` prevents an individual bucket and its contained objects from becoming publicly accessible. Similarly, `Block public access (account settings)` prevents all buckets and their contained objects from becoming publicly accessible across the entire account.", + "RationaleStatement": "Amazon S3 `Block public access (bucket settings)` prevents the accidental or malicious public exposure of data contained within the respective bucket(s). Amazon S3 `Block public access (account settings)` prevents the accidental or malicious public exposure of data contained within all buckets of the respective AWS account. Whether to block public access to all or some buckets is an organizational decision that should be based on data sensitivity, least privilege, and use case.", + "ImpactStatement": "When you apply Block Public Access settings to an account, the settings apply to all AWS regions globally. The settings may not take effect in all regions immediately or simultaneously, but they will eventually propagate to all regions.", + "RemediationProcedure": "**If utilizing Block Public Access (bucket settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to a bucket. 3. Click 'Edit public access settings'. 4. Click 'Block all public access' 5. Repeat for all the buckets in your AWS account that contain sensitive data. **From Command Line:** 1. List all of the S3 buckets: ``` aws s3 ls ``` 2. Enable Block Public Access on a specific bucket: ``` aws s3api put-public-access-block --bucket --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true ``` **If utilizing Block Public Access (account settings)** **From Console:** If the output reads `true` for the separate configuration settings, then Block Public Access is enabled on the account. 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Click `Block Public Access (account settings)`. 3. Click `Edit` to change the block public access settings for all the buckets in your AWS account. 4. Update the settings and click `Save`. For details about each setting, pause on the `i` icons. 5. When you're asked for confirmation, enter `confirm`. Then click `Confirm` to save your changes. **From Command Line:** To enable Block Public Access for this account, run the following command: ``` aws s3control put-public-access-block --public-access-block-configuration BlockPublicAcls=true, IgnorePublicAcls=true, BlockPublicPolicy=true, RestrictPublicBuckets=true --account-id ```", + "AuditProcedure": "**If utilizing Block Public Access (bucket settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to a bucket. 3. Click on 'Edit public access settings'. 4. Ensure that the block public access settings are configured appropriately for this bucket. 5. Repeat for all the buckets in your AWS account. **From Command Line:** 1. List all of the S3 buckets: ``` aws s3 ls ``` 2. Find the public access settings for a specific bucket: ``` aws s3api get-public-access-block --bucket ``` Output if Block Public Access is enabled: ``` { PublicAccessBlockConfiguration: { BlockPublicAcls: true, IgnorePublicAcls: true, BlockPublicPolicy: true, RestrictPublicBuckets: true } } ``` If the output reads `false` for the separate configuration settings, then proceed with the remediation. **If utilizing Block Public Access (account settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Choose `Block public access (account settings)`. 3. Ensure that the block public access settings are configured appropriately for your AWS account. **From Command Line:** To check the block public access settings for this account, run the following command: `aws s3control get-public-access-block --account-id --region ` Output if Block Public Access is enabled: ``` { PublicAccessBlockConfiguration: { IgnorePublicAcls: true, BlockPublicPolicy: true, BlockPublicAcls: true, RestrictPublicBuckets: true } } ``` If the output reads `false` for the separate configuration settings, then proceed with the remediation.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access-account.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.1", + "Description": "Ensure that encryption-at-rest is enabled for RDS instances", + "Checks": [ + "rds_instance_storage_encrypted" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Amazon RDS encrypted DB instances use the industry-standard AES-256 encryption algorithm to encrypt your data on the server that hosts your Amazon RDS DB instances. After your data is encrypted, Amazon RDS handles the authentication of access and the decryption of your data transparently, with minimal impact on performance.", + "RationaleStatement": "Databases are likely to hold sensitive and critical data; therefore, it is highly recommended to implement encryption to protect your data from unauthorized access or disclosure. With RDS encryption enabled, the data stored on the instance's underlying storage, the automated backups, read replicas, and snapshots are all encrypted.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the left navigation panel, click on `Databases`. 3. Select the Database instance that needs to be encrypted. 4. Click the `Actions` button placed at the top right and select `Take Snapshot`. 5. On the Take Snapshot page, enter the name of the database for which you want to take a snapshot in the `Snapshot Name` field and click on `Take Snapshot`. 6. Select the newly created snapshot, click the `Action` button placed at the top right, and select `Copy snapshot` from the Action menu. 7. On the Make Copy of DB Snapshot page, perform the following: - In the `New DB Snapshot Identifier` field, enter a name for the new snapshot. - Check `Copy Tags`. The new snapshot must have the same tags as the source snapshot. - Select `Yes` from the `Enable Encryption` dropdown list to enable encryption. You can choose to use the AWS default encryption key or a custom key from the Master Key dropdown list. 8. Click `Copy Snapshot` to create an encrypted copy of the selected instance's snapshot. 9. Select the new Snapshot Encrypted Copy and click the `Action` button located at the top right. Then, select the `Restore Snapshot` option from the Action menu. This will restore the encrypted snapshot to a new database instance. 10. On the Restore DB Instance page, enter a unique name for the new database instance in the DB Instance Identifier field. 11. Review the instance configuration details and click `Restore DB Instance`. 12. As the new instance provisioning process is completed, you can update the application configuration to refer to the endpoint of the new encrypted database instance. Once the database endpoint is changed at the application level, you can remove the unencrypted instance. **From Command Line:** 1. Run the `describe-db-instances` command to list the names of all RDS database instances in the selected AWS region. The command output should return database instance identifiers: ``` aws rds describe-db-instances --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. Check if the specified RDS instance is encrypted. If it shows false, it means it is not yet encrypted: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].StorageEncrypted' ``` 3. Run the `create-db-snapshot` command to create a snapshot for a selected database instance. The command output will return the `new snapshot` with name DB Snapshot Name: ``` aws rds create-db-snapshot --region --db-snapshot-identifier --db-instance-identifier ``` 4. Now run the `list-aliases` command to list the KMS key aliases available in a specified region. The command output should return each `key alias currently available`. For our RDS encryption activation process, locate the ID of the AWS default KMS key: ``` aws kms list-aliases --region ``` 5. Run the `copy-db-snapshot` command using the default KMS key ID for the RDS instances returned earlier to create an encrypted copy of the database instance snapshot. The command output will return the `encrypted instance snapshot configuration`: ``` aws rds copy-db-snapshot --region --source-db-snapshot-identifier --target-db-snapshot-identifier --copy-tags --kms-key-id ``` 6. Run the `restore-db-instance-from-db-snapshot` command to restore the encrypted snapshot created in the previous step to a new database instance. If successful, the command output should return the configuration of the new encrypted database instance. If using the default VPC for the database network: ``` aws rds restore-db-instance-from-db-snapshot --region --db-instance-identifier --db-snapshot-identifier ``` If you created your own VPC and Subnets, you need to create a DB subnet group: ``` aws rds create-db-subnet-group --db-subnet-group-name --db-subnet-group-description --subnet-ids '[\"\",\"\",\"\"]' ``` Restore the encrypted snapshot to an RDS database instance using the specified DB subnet group. The new instance will be encrypted using the KMS key specified during the snapshot copy: ``` aws rds restore-db-instance-from-db-snapshot --region --db-subnet-group-name --db-instance-identifier --db-snapshot-identifier ``` 7. Run the `describe-db-instances` command to list all RDS database names available in the selected AWS region. The output will return the database instance identifier names. Select the encrypted database name that we just created, `db-name-encrypted`: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 8. Run the `describe-db-instances` command again using the RDS instance identifier returned earlier to determine if the selected database instance is encrypted. The command output should indicate that the encryption status is `True`: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].StorageEncrypted' ```", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the navigation pane, under RDS dashboard, click `Databases`. 3. Select the RDS instance that you want to examine. 4. Click `Instance Name` to see details, then select the `Configuration` tab. 5. Under Configuration Details, in the Storage pane, search for the `Encryption Enabled` status. 6. If the current status is set to `Disabled`, encryption is not enabled for the selected RDS database instance. 7. Repeat steps 2 to 6 to verify the encryption status of other RDS instances in the same region. 8. Change the region from the top of the navigation bar, and repeat the audit steps for other regions. **From Command Line:** 1. Run the `describe-db-instances` command to list all the RDS database instance names available in the selected AWS region. The output will return each database instance identifier (name): ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. Run the `describe-db-instances` command again, using an RDS instance identifier returned from step 1, to determine if the selected database instance is encrypted. The output should return the encryption status `True` or `False`: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].StorageEncrypted' ``` 3. If the StorageEncrypted parameter value is `False`, encryption is not enabled for the selected RDS database instance. 4. Repeat steps 1 to 3 to audit each RDS instance, and change the region to verify RDS instances in other regions.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html:https://aws.amazon.com/blogs/database/selecting-the-right-encryption-options-for-amazon-rds-and-amazon-aurora-database-engines/#:~:text=With%20RDS%2Dencrypted%20resources%2C%20data,transparent%20to%20your%20database%20engine.:https://aws.amazon.com/rds/features/security/:https://docs.aws.amazon.com/cli/latest/reference/rds/create-db-subnet-group.html", + "DefaultValue": "By default, Amazon RDS instances are created without encryption at rest. Encryption must be explicitly enabled at instance creation or by restoring from an encrypted snapshot." + } + ] + }, + { + "Id": "3.2.2", + "Description": "Ensure the Auto Minor Version Upgrade feature is enabled for RDS instances", + "Checks": [ + "rds_instance_minor_version_upgrade_enabled" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that RDS database instances have the Auto Minor Version Upgrade flag enabled to automatically receive minor engine upgrades during the specified maintenance window. This way, RDS instances can obtain new features, bug fixes, and security patches for their database engines.", + "RationaleStatement": "AWS RDS will occasionally deprecate minor engine versions and provide new ones for upgrades. When the last version number within a release is replaced, the changed version is considered minor. With the Auto Minor Version Upgrade feature enabled, version upgrades will occur automatically during the specified maintenance window, allowing your RDS instances to receive new features, bug fixes, and security patches for their database engines.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the left navigation panel, click `Databases`. 3. Select the RDS instance that you want to update. 4. Click on the `Modify` button located at the top right side. 5. On the `Modify DB Instance: ` page, In the `Maintenance` section, select `Auto minor version upgrade` and click the `Yes` radio button. 6. At the bottom of the page, click `Continue`, and check `Apply Immediately` to apply the changes immediately, or select `Apply during the next scheduled maintenance window` to avoid any downtime. 7. Review the changes and click `Modify DB Instance`. The instance status should change from available to modifying and back to available. Once the feature is enabled, the `Auto Minor Version Upgrade` status should change to `Yes`. **From Command Line:** 1. Run the `describe-db-instances` command to list all RDS database instance names available in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance identifier. 3. Run the `modify-db-instance` command to modify the configuration of a selected RDS instance. This command will apply the changes immediately. Remove `--apply-immediately` to apply changes during the next scheduled maintenance window and avoid any downtime: ``` aws rds modify-db-instance --region --db-instance-identifier --auto-minor-version-upgrade --apply-immediately ``` 4. The command output should reveal the new configuration metadata for the RDS instance, including the `AutoMinorVersionUpgrade` parameter value. 5. Run the `describe-db-instances` command to check if the Auto Minor Version Upgrade feature has been successfully enabled: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].AutoMinorVersionUpgrade' ``` 6. The command output should return the feature's current status set to `true`, indicating that the feature is `enabled`, and that the minor engine upgrades will be applied to the selected RDS instance.", + "AuditProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the left navigation panel, click `Databases`. 3. Select the RDS instance that you want to examine. 4. Click on the `Maintenance and backups` panel. 5. Under the `Maintenance` section, search for the Auto Minor Version Upgrade status. - If the current status is `Disabled`, it means that the feature is not enabled, and the minor engine upgrades released will not be applied to the selected RDS instance. **From Command Line:** 1. Run the `describe-db-instances` command to list all RDS database names available in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance identifier. 3. Run the `describe-db-instances` command again using a RDS instance identifier returned earlier to determine the Auto Minor Version Upgrade status for the selected instance: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].AutoMinorVersionUpgrade' ``` 4. The command output should return the current status of the feature. If the current status is set to `true`, the feature is enabled and the minor engine upgrades will be applied to the selected RDS instance.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_RDS_Managing.html:https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_UpgradeDBInstance.Upgrading.html:https://aws.amazon.com/rds/faqs/", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.3", + "Description": "Ensure that RDS instances are not publicly accessible", + "Checks": [ + "rds_instance_no_public_access" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure and verify that the RDS database instances provisioned in your AWS account restrict unauthorized access in order to minimize security risks. To restrict access to any RDS database instance, you must disable the Publicly Accessible flag for the database and update the VPC security group associated with the instance.", + "RationaleStatement": "Ensure that no public-facing RDS database instances are provisioned in your AWS account, and restrict unauthorized access in order to minimize security risks. When the RDS instance allows unrestricted access (0.0.0.0/0), anyone and anything on the Internet can establish a connection to your database, which can increase the opportunity for malicious activities such as brute force attacks, PostgreSQL injections, or DoS/DDoS attacks.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. Under the navigation panel, on the RDS dashboard, click `Databases`. 3. Select the RDS instance that you want to update. 4. Click `Modify` from the dashboard top menu. 5. On the Modify DB Instance panel, under the `Connectivity` section, click on `Additional connectivity configuration` and update the value for `Publicly Accessible` to `Not publicly accessible` to restrict public access. 6. Follow the below steps to update subnet configurations: - Select the `Connectivity and security` tab, and click the VPC attribute value inside the `Networking` section. - Select the `Details` tab from the VPC dashboard's bottom panel and click the Route table configuration attribute value. - On the Route table details page, select the Routes tab from the dashboard's bottom panel and click `Edit routes`. - On the Edit routes page, update the Destination of Target which is set to `igw-xxxxx` and click `Save` routes. 7. On the Modify DB Instance panel, click `Continue`, and in the Scheduling of modifications section, perform one of the following actions based on your requirements: - Select `Apply during the next scheduled maintenance window` to apply the changes automatically during the next scheduled maintenance window. - Select `Apply immediately` to apply the changes right away. With this option, any pending modifications will be asynchronously applied as soon as possible, regardless of the maintenance window setting for this RDS database instance. Note that any changes available in the pending modifications queue are also applied. If any of the pending modifications require downtime, choosing this option can cause unexpected downtime for the application. 8. Repeat steps 3-7 for each RDS instance in the current region. 9. Change the AWS region from the navigation bar to repeat the process for other regions. **From Command Line:** 1. Run the `describe-db-instances` command to list all available RDS database identifiers in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance identifier. 3. Run the `modify-db-instance` command to modify the configuration of a selected RDS instance, disabling the `Publicly Accessible` flag for that instance. This command uses the `apply-immediately` flag. If you want to avoid any downtime, the `--no-apply-immediately` flag can be used: ``` aws rds modify-db-instance --region --db-instance-identifier --no-publicly-accessible --apply-immediately ``` 4. The command output should reveal the `PubliclyAccessible` configuration under pending values, to be applied at the specified time. 5. Updating the Internet Gateway destination via the AWS CLI is not currently supported. To update information about the Internet Gateway, please use the AWS Console procedure. 6. Repeat steps 1-5 for each RDS instance provisioned in the current region. 7. Change the AWS region by using the --region filter to repeat the process for other regions.", + "AuditProcedure": "**From Console:** 1. Log in to the AWS management console and navigate to the RDS dashboard at https://console.aws.amazon.com/rds/. 2. Under the navigation panel, on the RDS dashboard, click `Databases`. 3. Select the RDS instance that you want to examine. 4. Click `Instance Name` from the dashboard, under `Connectivity and Security`. 5. In the `Security` section, check if the Publicly Accessible flag status is set to `Yes`. 6. Follow the steps below to check database subnet access: - In the `networking` section, click the subnet link under `Subnets`. - The link will redirect you to the VPC Subnets page. - Select the subnet listed on the page and click the `Route Table` tab from the dashboard bottom panel. - If the route table contains any entries with the destination CIDR block set to `0.0.0.0/0` and an `Internet Gateway` attached, the selected RDS database instance was provisioned inside a public subnet; therefore, it is not running within a logically isolated environment and can be accessed from the Internet. 7. Repeat steps 3-6 to determine the configuration of other RDS database instances provisioned in the current region. 8. Change the AWS region from the navigation bar and repeat the audit process for other regions. **From Command Line:** 1. Run the `describe-db-instances` command to list all available RDS database names in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. The command output should return each database instance `identifier`. 3. Run the `describe-db-instances` command again, using the `PubliclyAccessible` parameter as a query filter to reveal the status of the database instance's Publicly Accessible flag: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].PubliclyAccessible' ``` 4. Check the Publicly Accessible parameter status. If the Publicly Accessible flag is set to `Yes`, then the selected RDS database instance is publicly accessible and insecure. Follow the steps mentioned below to check database subnet access. 5. Run the `describe-db-instances` command again using the RDS database instance identifier that you want to check, along with the appropriate filtering to describe the VPC subnet(s) associated with the selected instance: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].DBSubnetGroup.Subnets[]' ``` - The command output should list the subnets available in the selected database subnet group. 6. Run the `describe-route-tables` command using the ID of the subnet returned in the previous step to describe the routes of the VPC route table associated with the selected subnet: ``` aws ec2 describe-route-tables --region --filters Name=association.subnet-id,Values= --query 'RouteTables[*].Routes[]' ``` - If the command returns the route table associated with the database instance subnet ID, check the values of the `GatewayId` and `DestinationCidrBlock` attributes returned in the output. If the route table contains any entries with the `GatewayId` value set to `igw-xxxxxxxx` and the `DestinationCidrBlock` value set to `0.0.0.0/0`, the selected RDS database instance was provisioned within a public subnet. - Or, if the command returns empty results, the route table is implicitly associated with the subnet; therefore, the audit process continues with the next step. 7. Run the `describe-db-instances` command again using the RDS database instance identifier that you want to check, along with the appropriate filtering to describe the VPC ID associated with the selected instance: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].DBSubnetGroup.VpcId' ``` - The command output should show the VPC ID in the selected database subnet group. 8. Now run the `describe-route-tables` command using the ID of the VPC returned in the previous step to describe the routes of the VPC's main route table that is implicitly associated with the selected subnet: ``` aws ec2 describe-route-tables --region --filters Name=vpc-id,Values= Name=association.main,Values=true --query 'RouteTables[*].Routes[]' ``` - The command output returns the VPC main route table implicitly associated with the database instance subnet ID. Check the values of the `GatewayId` and `DestinationCidrBlock` attributes returned in the output. If the route table contains any entries with the `GatewayId` value set to `igw-xxxxxxxx` and the `DestinationCidrBlock` value set to `0.0.0.0/0`, the selected RDS database instance was provisioned inside a public subnet; therefore, it is not running within a logically isolated environment and does not adhere to AWS security best practices.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.html:https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Scenario2.html:https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_VPC.WorkingWithRDSInstanceinaVPC.html:https://aws.amazon.com/rds/faqs/", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.4", + "Description": "Ensure Multi-AZ deployments are used for enhanced availability in Amazon RDS", + "Checks": [ + "rds_cluster_multi_az", + "rds_instance_multi_az" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.2 Relational Database Service (RDS)", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Amazon RDS offers Multi-AZ deployments that provide enhanced availability and durability for your databases, using synchronous replication to replicate data to a standby instance in a different Availability Zone (AZ). In the event of an infrastructure failure, Amazon RDS automatically fails over to the standby to minimize downtime and ensure business continuity.", + "RationaleStatement": "Database availability is crucial for maintaining service uptime, particularly for applications that are critical to the business. Implementing Multi-AZ deployments with Amazon RDS ensures that your databases are protected against unplanned outages due to hardware failures, network issues, or other disruptions. This configuration enhances both the availability and durability of your database, making it a highly recommended practice for production environments.", + "ImpactStatement": "Multi-AZ deployments may increase costs due to the additional resources required to maintain a standby instance; however, the benefits of increased availability and reduced risk of downtime outweigh these costs for critical applications.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at [AWS RDS Console](https://console.aws.amazon.com/rds/). 2. In the left navigation pane, click on `Databases`. 3. Select the database instance that needs Multi-AZ deployment to be enabled. 4. Click the `Modify` button at the top right. 5. Scroll down to the `Availability & Durability` section. 6. Under `Multi-AZ deployment`, select `Yes` to enable. 7. Review the changes and click `Continue`. 8. On the `Review` page, choose `Apply immediately` to make the change without waiting for the next maintenance window, or `Apply during the next scheduled maintenance window`. 9. Click `Modify DB Instance` to apply the changes. **From Command Line:** 1. Run the following command to modify the RDS instance and enable Multi-AZ: ``` aws rds modify-db-instance --region --db-instance-identifier --multi-az --apply-immediately ``` 2. Confirm that the Multi-AZ deployment is enabled by running the following command: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].MultiAZ' ``` - The output should return `True`, indicating that Multi-AZ is enabled. 3. Repeat the procedure for other instances as necessary.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at [AWS RDS Console](https://console.aws.amazon.com/rds/). 2. In the navigation pane, under `Databases`, select the RDS instance you want to examine. 3. Click the `Instance Name` to see details, then navigate to the `Configuration` tab. 4. Under the `Availability & Durability` section, check the `Multi-AZ` status. - If Multi-AZ deployment is enabled, it will display `Yes`. - If it is disabled, the status will display `No`. 5. Repeat steps 2-4 to verify the Multi-AZ status of other RDS instances in the same region. 6. Change the region from the top of the navigation bar and repeat the audit for other regions. **From Command Line:** 1. Run the following command to list all RDS instances in the selected AWS region: ``` aws rds describe-db-instances --region --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. Run the following command using the instance identifier returned earlier to check the Multi-AZ status: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].MultiAZ' ``` - If the output is `True`, Multi-AZ is enabled. - If the output is `False`, Multi-AZ is not enabled. 3. Repeat steps 1 and 2 to audit each RDS instance, and change regions to verify in other regions.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.3.1", + "Description": "Ensure that encryption is enabled for EFS file systems", + "Checks": [ + "efs_encryption_at_rest_enabled" + ], + "Attributes": [ + { + "Section": "3 Storage", + "SubSection": "3.3 Elastic File System (EFS)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "EFS data should be encrypted at rest using AWS KMS (Key Management Service).", + "RationaleStatement": "Data should be encrypted at rest to reduce the risk of a data breach via direct access to the storage device.", + "ImpactStatement": "", + "RemediationProcedure": "**It is important to note that EFS file system data-at-rest encryption must be turned on when creating the file system. If an EFS file system has been created without data-at-rest encryption enabled, then you must create another EFS file system with the correct configuration and transfer the data.** **Steps to create an EFS file system with data encrypted at rest:** **From Console:** 1. Login to the AWS Management Console and Navigate to the `Elastic File System (EFS)` dashboard. 2. Select `File Systems` from the left navigation panel. 3. Click the `Create File System` button from the dashboard top menu to start the file system setup process. 4. On the `Configure file system access` configuration page, perform the following actions: - Choose an appropriate VPC from the VPC dropdown list. - Within the `Create mount targets` section, check the boxes for all of the Availability Zones (AZs) within the selected VPC. These will be your mount targets. - Click `Next step` to continue. 5. Perform the following on the `Configure optional settings` page: - Create `tags` to describe your new file system. - Choose `performance mode` based on your requirements. - Check the `Enable encryption` box and choose `aws/elasticfilesystem` from the `Select KMS master key` dropdown list to enable encryption for the new file system, using the default master key provided and managed by AWS KMS. - Click `Next step` to continue. 6. Review the file system configuration details on the `review and create` page and then click `Create File System` to create your new AWS EFS file system. 7. Copy the data from the old unencrypted EFS file system onto the newly created encrypted file system. 8. Remove the unencrypted file system as soon as your data migration to the newly created encrypted file system is completed. 9. Change the AWS region from the navigation bar and repeat the entire process for the other AWS regions. **From CLI:** 1. Run the `describe-file-systems` command to view the configuration information for the selected unencrypted file system identified in the Audit steps: ``` aws efs describe-file-systems --region --file-system-id ``` 2. The command output should return the configuration information. 3. To provision a new AWS EFS file system, you need to generate a universally unique identifier (UUID) to create the token required by the `create-file-system` command. To create the required token, you can use a randomly generated UUID from https://www.uuidgenerator.net. 4. Run the `create-file-system` command using the unique token created at the previous step: ``` aws efs create-file-system --region --creation-token --performance-mode generalPurpose --encrypted ``` 5. The command output should return the new file system configuration metadata. 6. Run the `create-mount-target` command using the EFS file system ID returned from step 4 as the identifier and the ID of the Availability Zone (AZ) that will represent the mount target: ``` aws efs create-mount-target --region --file-system-id --subnet-id ``` 7. The command output should return the new mount target metadata. 8. Now you can mount your file system from an EC2 instance. 9. Copy the data from the old unencrypted EFS file system to the newly created encrypted file system. 10. Remove the unencrypted file system as soon as your data migration to the newly created encrypted file system is completed: ``` aws efs delete-file-system --region --file-system-id ``` 11. Change the AWS region by updating the --region and repeat the entire process for the other AWS regions.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and Navigate to the Elastic File System (EFS) dashboard. 2. Select `File Systems` from the left navigation panel. 3. Each item on the list has a visible Encrypted field that displays data at rest encryption status. 4. Validate that this field reads `Encrypted` for all EFS file systems in all AWS regions. **From CLI:** 1. Run the `describe-file-systems` command using custom query filters to list the identifiers of all AWS EFS file systems currently available within the selected region: ``` aws efs describe-file-systems --region --output table --query 'FileSystems[*].FileSystemId' ``` 2. The command output should return a table with the requested file system IDs. 3. Run the `describe-file-systems` command using the ID of the file system that you want to examine as `file-system-id` and the necessary query filters: ``` aws efs describe-file-systems --region --file-system-id --query 'FileSystems[*].Encrypted' ``` 4. The command output should return the file system encryption status as `true` or `false`. If the returned value is `false`, the selected AWS EFS file system is not encrypted and if the returned value is `true`, the selected AWS EFS file system is encrypted.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/efs/latest/ug/encryption-at-rest.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/efs/index.html#efs", + "DefaultValue": "EFS file system data is encrypted at rest by default when creating a file system through the Console. However, encryption at rest is not enabled by default when creating a new file system using the AWS CLI, API, or SDKs." + } + ] + }, + { + "Id": "4.1", + "Description": "Ensure CloudTrail is enabled in all regions", + "Checks": [ + "cloudtrail_multi_region_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "AWS CloudTrail is a web service that records AWS API calls for your account and delivers log files to you. The recorded information includes the identity of the API caller, the time of the API call, the source IP address of the API caller, the request parameters, and the response elements returned by the AWS service. CloudTrail provides a history of AWS API calls for an account, including API calls made via the Management Console, SDKs, command line tools, and higher-level AWS services (such as CloudFormation).", + "RationaleStatement": "The AWS API call history produced by CloudTrail enables security analysis, resource change tracking, and compliance auditing. Additionally, - ensuring that a multi-region trail exists will help detect unexpected activity occurring in otherwise unused regions - ensuring that a multi-region trail exists will ensure that `Global Service Logging` is enabled for a trail by default to capture recordings of events generated on AWS global services - for a multi-region trail, ensuring that management events are configured for all types of Read/Writes ensures the recording of management operations that are performed on all resources in an AWS account", + "ImpactStatement": "S3 lifecycle features can be used to manage the accumulation and management of logs over time. See the following AWS resource for more information on these features: 1. https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lifecycle-mgmt.html", + "RemediationProcedure": "Perform the following to enable global (Multi-region) CloudTrail logging: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. Click on `Trails` in the left navigation pane. 3. Click `Get Started Now` if it is presented, then: - Click `Add new trail`. - Enter a trail name in the `Trail name` box. - A trail created in the console is a multi-region trail by default. - Specify an S3 bucket name in the `S3 bucket` box. - Specify the AWS KMS alias under the `Log file SSE-KMS encryption` section, or create a new key. - Click `Next`. 4. Ensure the `Management events` check box is selected. 5. Ensure both `Read` and `Write` are checked under API activity. 6. Click `Next`. 7. Review your trail settings and click `Create trail`. **From Command Line:** Create a multi-region trail: ``` aws cloudtrail create-trail --name --bucket-name --is-multi-region-trail ``` Enable multi-region on an existing trail: ``` aws cloudtrail update-trail --name --is-multi-region-trail ``` **Note:** Creating a CloudTrail trail via the CLI without providing any overriding options configures all `read` and `write` `Management Events` to be logged by default.", + "AuditProcedure": "Perform the following to determine if CloudTrail is enabled for all regions: **From Console:** 1. Sign in to the AWS Management Console and open the CloudTrail console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail) 2. Click on `Trails` in the left navigation pane - You will be presented with a list of trails across all regions 3. Ensure that at least one Trail has `Yes` specified in the `Multi-region trail` column 4. Click on a trail via the link in the `Name` column 5. Ensure `Logging` is set to `ON` 6. Ensure `Multi-region trail` is set to `Yes` 7. In the section `Management Events`, ensure that `API activity` set to `ALL` **From Command Line:** 1. List all trails: ``` aws cloudtrail describe-trails ``` 2. Ensure `IsMultiRegionTrail` is set to `true`: ``` aws cloudtrail get-trail-status --name ``` 3. Ensure `IsLogging` is set to `true`: ``` aws cloudtrail get-event-selectors --trail-name ``` 4. Ensure there is at least one `fieldSelector` for a trail that equals `Management`: - This should NOT output any results for Field: readOnly. If either `true` or `false` is returned, one of the checkboxes (`read` or `write`) is not selected. Example of correct output: ``` TrailARN: , AdvancedEventSelectors: [ { Name: Management events selector, FieldSelectors: [ { Field: eventCategory, Equals: [ Management ] ```", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-concepts.html#cloudtrail-concepts-management-events:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-management-and-data-events-with-cloudtrail.html?icmpid=docs_cloudtrail_console#logging-management-events:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-supported-services.html#cloud-trail-supported-services-data-events", + "DefaultValue": "Not Enabled" + } + ] + }, + { + "Id": "4.2", + "Description": "Ensure CloudTrail log file validation is enabled", + "Checks": [ + "cloudtrail_log_file_validation_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "CloudTrail log file validation creates a digitally signed digest file containing a hash of each log that CloudTrail writes to S3. These digest files can be used to determine whether a log file was changed, deleted, or remained unchanged after CloudTrail delivered the log. It is recommended that file validation be enabled for all CloudTrails.", + "RationaleStatement": "Enabling log file validation will provide additional integrity checks for CloudTrail logs.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to enable log file validation on a given trail: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. Click on `Trails` in the left navigation pane. 3. Click on the target trail. 4. Within the `General details` section, click `edit`. 5. Under `Advanced settings`, check the `enable` box under `Log file validation`. 6. Click `Save changes`. **From Command Line:** Enable log file validation on a trail: ``` aws cloudtrail update-trail --name --enable-log-file-validation ``` Note that periodic validation of logs using these digests can be carried out by running the following command: ``` aws cloudtrail validate-logs --trail-arn --start-time --end-time ```", + "AuditProcedure": "Perform the following on each trail to determine if log file validation is enabled: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. Click on `Trails` in the left navigation pane. 3. For every trail: - Click on a trail via the link in the `Name` column. - Under the `General details` section, ensure `Log file validation` is set to `Enabled`. **From Command Line:** List all trails: ``` aws cloudtrail describe-trails ``` Ensure `LogFileValidationEnabled` is set to `true` for each trail.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-validation-enabling.html", + "DefaultValue": "Not Enabled" + } + ] + }, + { + "Id": "4.3", + "Description": "Ensure AWS Config is enabled in all regions", + "Checks": [ + "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS Config is a web service that performs configuration management of supported AWS resources within your account and delivers log files to you. The recorded information includes the configuration items (AWS resources), relationships between configuration items (AWS resources), and any configuration changes between resources. It is recommended that AWS Config be enabled in all regions.", + "RationaleStatement": "The AWS configuration item history captured by AWS Config enables security analysis, resource change tracking, and compliance auditing.", + "ImpactStatement": "Enabling AWS Config in all regions provides comprehensive visibility into resource configurations, enhancing security and compliance monitoring. However, this may incur additional costs and require proper configuration management.", + "RemediationProcedure": "To implement AWS Config configuration: **From Console:** 1. Select the region you want to focus on in the top right of the console. 2. Click `Services`. 3. Click `Config`. 4. If a Config Recorder is enabled in this region, navigate to the Settings page from the navigation menu on the left-hand side. If a Config Recorder is not yet enabled in this region, select Get Started. 5. Select Record all resources supported in this region. 6. Choose to include global resources (IAM resources). 7. Specify an S3 bucket in the same account or in another managed AWS account. 8. Create an SNS Topic from the same AWS account or another managed AWS account. **From Command Line:** 1. Ensure there is an appropriate S3 bucket, SNS topic, and IAM role per the [AWS Config Service prerequisites](http://docs.aws.amazon.com/config/latest/developerguide/gs-cli-prereq.html). 2. Run this command to create a new configuration recorder: ``` aws configservice put-configuration-recorder --configuration-recorder name=,roleARN=arn:aws:iam:::role/ --recording-group allSupported=true,includeGlobalResourceTypes=true ``` 3. Create a delivery channel configuration file locally which specifies the channel attributes, populated from the prerequisites set up previously: ``` { name: , s3BucketName: , snsTopicARN: arn:aws:sns:::, configSnapshotDeliveryProperties: { deliveryFrequency: Twelve_Hours } } ``` 4. Run this command to create a new delivery channel, referencing the json configuration file made in the previous step: ``` aws configservice put-delivery-channel --delivery-channel file://.json ``` 5. Start the configuration recorder by running the following command: ``` aws configservice start-configuration-recorder --configuration-recorder-name ```", + "AuditProcedure": "Process to evaluate AWS Config configuration per region: **From Console:** 1. Sign in to the AWS Management Console and open the AWS Config console at [https://console.aws.amazon.com/config/](https://console.aws.amazon.com/config/). 1. On the top right of the console select the target region. 1. If a Config Recorder is enabled in this region, you should navigate to the Settings page from the navigation menu on the left-hand side. If a Config Recorder is not yet enabled in this region, proceed to the remediation steps. 1. Ensure Record all resources supported in this region is checked. 1. Ensure Include global resources (e.g., AWS IAM resources) is checked, unless it is enabled in another region (this is only required in one region). 1. Ensure the correct S3 bucket has been defined. 1. Ensure the correct SNS topic has been defined. 1. Repeat steps 2 to 7 for each region. **From Command Line:** 1. Run this command to show all AWS Config Recorders and their properties: ``` aws configservice describe-configuration-recorders ``` 2. Evaluate the output to ensure that all recorders have a `recordingGroup` object which includes `allSupported: true`. Additionally, ensure that at least one recorder has `includeGlobalResourceTypes: true`. **Note:** There is one more parameter, ResourceTypes, in the recordingGroup object. We don't need to check it, as whenever we set allSupported to true, AWS enforces the resource types to be empty (ResourceTypes: []). Sample output: ``` { ConfigurationRecorders: [ { recordingGroup: { allSupported: true, resourceTypes: [], includeGlobalResourceTypes: true }, roleARN: arn:aws:iam:::role/service-role/, name: default } ] } ``` 3. Run this command to show the status for all AWS Config Recorders: ``` aws configservice describe-configuration-recorder-status ``` 4. In the output, find recorders with `name` key matching the recorders that were evaluated in step 2. Ensure that they include `recording: true` and `lastStatus: SUCCESS`.", + "AdditionalInformation": "", + "References": "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/configservice/describe-configuration-recorder-status.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/configservice/describe-configuration-recorders.html:https://docs.aws.amazon.com/config/latest/developerguide/gs-cli-prereq.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.4", + "Description": "Ensure that server access logging is enabled on the CloudTrail S3 bucket", + "Checks": [ + "cloudtrail_logs_s3_bucket_access_logging_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Server access logging generates a log that contains access records for each request made to your S3 bucket. An access log record contains details about the request, such as the request type, the resources specified in the request worked, and the time and date the request was processed. It is recommended that server access logging be enabled on the CloudTrail S3 bucket.", + "RationaleStatement": "By enabling server access logging on target S3 buckets, it is possible to capture all events that may affect objects within any target bucket. Configuring the logs to be placed in a separate bucket allows access to log information that can be useful in security and incident response workflows.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to enable server access logging: **From Console:** 1. Sign in to the AWS Management Console and open the S3 console at [https://console.aws.amazon.com/s3](https://console.aws.amazon.com/s3). 2. Under `All Buckets` click on the target S3 bucket. 3. Click on `Properties` in the top right of the console. 4. Under `Bucket: `, click `Logging`. 5. Configure bucket logging: - Check the `Enabled` box. - Select a Target Bucket from the list. - Enter a Target Prefix. 6. Click `Save`. **From Command Line:** 1. Get the name of the S3 bucket that CloudTrail is logging to: ``` aws cloudtrail describe-trails --region --query trailList[*].S3BucketName ``` 2. Copy and add the target bucket name at ``, the prefix for the log file at ``, and optionally add an email address in the following template, then save it as `.json`: ``` { LoggingEnabled: { TargetBucket: , TargetPrefix: , TargetGrants: [ { Grantee: { Type: AmazonCustomerByEmail, EmailAddress: }, Permission: FULL_CONTROL } ] } } ``` 3. Run the `put-bucket-logging` command with bucket name and `.json` as input; for more information, refer to [put-bucket-logging](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-logging.html): ``` aws s3api put-bucket-logging --bucket --bucket-logging-status file://.json ```", + "AuditProcedure": "Perform the following ensure that the CloudTrail S3 bucket has access logging is enabled: **From Console:** 1. Go to the Amazon CloudTrail console at [https://console.aws.amazon.com/cloudtrail/home](https://console.aws.amazon.com/cloudtrail/home). 2. In the API activity history pane on the left, click `Trails`. 3. In the Trails pane, note the bucket names in the S3 bucket column. 4. Sign in to the AWS Management Console and open the S3 console at [https://console.aws.amazon.com/s3](https://console.aws.amazon.com/s3). 5. Under `All Buckets` click on a target S3 bucket. 6. Click on `Properties` in the top right of the console. 7. Under `Bucket: `, click `Logging`. 8. Ensure `Enabled` is checked. **From Command Line:** 1. Get the name of the S3 bucket that CloudTrail is logging to: ``` aws cloudtrail describe-trails --query 'trailList[*].S3BucketName' ``` 2. Ensure logging is enabled on the bucket: ``` aws s3api get-bucket-logging --bucket ``` Ensure the command does not return an empty output. Sample output for a bucket with logging enabled: ``` { LoggingEnabled: { TargetPrefix: , TargetBucket: } } ```", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/dev/ServerLogs.html:https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html", + "DefaultValue": "Logging is disabled." + } + ] + }, + { + "Id": "4.5", + "Description": "Ensure CloudTrail logs are encrypted at rest using KMS CMKs", + "Checks": [ + "cloudtrail_kms_encryption_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS CloudTrail is a web service that records AWS API calls for an account and makes those logs available to users and resources in accordance with IAM policies. AWS Key Management Service (KMS) is a managed service that helps create and control the encryption keys used to encrypt account data, and uses Hardware Security Modules (HSMs) to protect the security of encryption keys. CloudTrail logs can be configured to leverage server side encryption (SSE) and KMS customer-created master keys (CMK) to further protect CloudTrail logs. It is recommended that CloudTrail be configured to use SSE-KMS.", + "RationaleStatement": "Configuring CloudTrail to use SSE-KMS provides additional confidentiality controls on log data, as a given user must have S3 read permission on the corresponding log bucket and must be granted decrypt permission by the CMK policy.", + "ImpactStatement": "Customer-created keys incur an additional cost. See https://aws.amazon.com/kms/pricing/ for more information.", + "RemediationProcedure": "Perform the following to configure CloudTrail to use SSE-KMS: **From Console:** 1. Sign in to the AWS Management Console and open the CloudTrail console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. In the left navigation pane, choose `Trails`. 3. Click on a trail. 4. Under the `S3` section, click the edit button (pencil icon). 5. Click `Advanced`. 6. Select an existing CMK from the `KMS key Id` drop-down menu. - **Note:** Ensure the CMK is located in the same region as the S3 bucket. - **Note:** You will need to apply a KMS key policy on the selected CMK in order for CloudTrail, as a service, to encrypt and decrypt log files using the CMK provided. View the AWS documentation for [editing the selected CMK Key policy](https://docs.aws.amazon.com/awscloudtrail/latest/userguide/create-kms-key-policy-for-cloudtrail.html). 7. Click `Save`. 8. You will see a notification message stating that you need to have decryption permissions on the specified KMS key to decrypt log files. 9. Click `Yes`. **From Command Line:** Run the following command to specify a KMS key ID to use with a trail: ``` aws cloudtrail update-trail --name --kms-key-id ``` Run the following command to attach a key policy to a specified KMS key: ``` aws kms put-key-policy --key-id --policy ```", + "AuditProcedure": "Perform the following to determine if CloudTrail is configured to use SSE-KMS: **From Console:** 1. Sign in to the AWS Management Console and open the CloudTrail console at [https://console.aws.amazon.com/cloudtrail](https://console.aws.amazon.com/cloudtrail). 2. In the left navigation pane, choose `Trails`. 3. Select a trail. 4. In the `General details` section, select `Edit` to edit the trail configuration. 5. Ensure the box at `Log file SSE-KMS encryption` is checked and that a valid `AWS KMS alias` of a KMS key is entered in the respective text box. **From Command Line:** 1. Run the following command: ``` aws cloudtrail describe-trails ``` 2. For each trail listed, SSE-KMS is enabled if the trail has a `KmsKeyId` property defined.", + "AdditionalInformation": "Three statements that need to be added to the CMK policy: 1. Enable CloudTrail to describe CMK properties: ``` { \"Sid\": \"Allow CloudTrail access\", \"Effect\": \"Allow\", \"Principal\": { \"Service\": \"cloudtrail.amazonaws.com\" }, \"Action\": \"kms:DescribeKey\", \"Resource\": \"*\" } ``` 2. Granting encrypt permissions: ``` { \"Sid\": \"Allow CloudTrail to encrypt logs\", \"Effect\": \"Allow\", \"Principal\": { \"Service\": \"cloudtrail.amazonaws.com\" }, \"Action\": \"kms:GenerateDataKey*\", \"Resource\": \"*\", \"Condition\": { \"StringLike\": { \"kms:EncryptionContext:aws:cloudtrail:arn\": [ \"arn:aws:cloudtrail:*:aws-account-id:trail/*\" ] } } } ``` 3. Granting decrypt permissions: ``` { \"Sid\": \"Enable CloudTrail log decrypt permissions\", \"Effect\": \"Allow\", \"Principal\": { \"AWS\": \"arn:aws:iam::aws-account-id:user/username\" }, \"Action\": \"kms:Decrypt\", \"Resource\": \"*\", \"Condition\": { \"Null\": { \"kms:EncryptionContext:aws:cloudtrail:arn\": \"false\" } } } ```", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/encrypting-cloudtrail-log-files-with-aws-kms.html:https://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html:CCE-78919-8:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudtrail/update-trail.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/kms/put-key-policy.html", + "DefaultValue": "By default, CloudTrail logs are not encrypted with a KMS CMK. Logs may be encrypted with SSE-S3, but this does not provide the same level of control or auditing as KMS CMKs." + } + ] + }, + { + "Id": "4.6", + "Description": "Ensure rotation for customer-created symmetric CMKs is enabled", + "Checks": [ + "kms_cmk_rotation_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "AWS Key Management Service (KMS) allows customers to rotate the backing key, which is key material stored within the KMS that is tied to the key ID of the customer-created customer master key (CMK). The backing key is used to perform cryptographic operations such as encryption and decryption. Automated key rotation currently retains all prior backing keys so that decryption of encrypted data can occur transparently. It is recommended that CMK key rotation be enabled for symmetric keys. Key rotation cannot be enabled for any asymmetric CMK.", + "RationaleStatement": "Rotating encryption keys helps reduce the potential impact of a compromised key, as data encrypted with a new key cannot be accessed with a previous key that may have been exposed. Keys should be rotated every year or upon an event that could result in the compromise of that key.", + "ImpactStatement": "Creation, management, and storage of CMKs may require additional time from an administrator.", + "RemediationProcedure": "**From Console:** 1. Sign in to the AWS Management Console and open the KMS console at: [https://console.aws.amazon.com/kms](https://console.aws.amazon.com/kms). 2. In the left navigation pane, click `Customer-managed keys`. 3. Select a key with `Key spec = SYMMETRIC_DEFAULT` that does not have automatic rotation enabled. 4. Select the `Key rotation` tab. 5. Check the `Automatically rotate this KMS key every year` box. 6. Click `Save`. 7. Repeat steps 3–6 for all customer-managed CMKs that do not have automatic rotation enabled. **From Command Line:** 1. Run the following command to enable key rotation: ``` aws kms enable-key-rotation --key-id ```", + "AuditProcedure": "**From Console:** 1. Sign in to the AWS Management Console and open the KMS console at: [https://console.aws.amazon.com/kms](https://console.aws.amazon.com/kms). 2. In the left navigation pane, click `Customer-managed keys`. 3. Select a customer-managed CMK where `Key spec = SYMMETRIC_DEFAULT`. 4. Select the `Key rotation` tab. 5. Ensure the `Automatically rotate this KMS key every year` box is checked. 6. Repeat steps 3–5 for all customer-managed CMKs where `Key spec = SYMMETRIC_DEFAULT`. **From Command Line:** 1. Run the following command to get a list of all keys and their associated `KeyIds`: ``` aws kms list-keys ``` 2. For each key, note the KeyId and run the following command: ``` describe-key --key-id ``` 3. If the response contains `KeySpec = SYMMETRIC_DEFAULT`, run the following command: ``` aws kms get-key-rotation-status --key-id ``` 4. Ensure `KeyRotationEnabled` is set to `true`. 5. Repeat steps 2–4 for all remaining CMKs.", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/kms/pricing/:https://csrc.nist.gov/publications/detail/sp/800-57-part-1/rev-5/final", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.7", + "Description": "Ensure VPC flow logging is enabled in all VPCs", + "Checks": [ + "vpc_flow_logs_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "VPC Flow Logs is a feature that enables you to capture information about the IP traffic going to and from network interfaces in your VPC. After you've created a flow log, you can view and retrieve its data in Amazon CloudWatch Logs. It is recommended that VPC Flow Logs be enabled for packet Rejects for VPCs.", + "RationaleStatement": "VPC Flow Logs provide visibility into network traffic that traverses the VPC and can be used to detect anomalous traffic or gain insights during security workflows.", + "ImpactStatement": "By default, CloudWatch Logs will store logs indefinitely unless a specific retention period is defined for the log group. When choosing the number of days to retain, keep in mind that the average time it takes for an organization to realize they have been breached is 210 days (at the time of this writing). Since additional time is required to research a breach, a minimum retention policy of 365 days allows for detection and investigation. You may also wish to archive the logs to a cheaper storage service rather than simply deleting them. See the following AWS resource to manage CloudWatch Logs retention periods: 1. https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/SettingLogRetention.html", + "RemediationProcedure": "Perform the following to enable VPC Flow Logs: **From Console:** 1. Sign into the management console. 2. Select `Services`, then select `VPC`. 3. In the left navigation pane, select `Your VPCs`. 4. Select a VPC. 5. In the right pane, select the `Flow Logs` tab. 6. If no Flow Log exists, click `Create Flow Log`. 7. For Filter, select `Reject`. 8. Enter a `Role` and `Destination Log Group`. 9. Click `Create Log Flow`. 10. Click on `CloudWatch Logs Group`. **Note:** Setting the filter to Reject will dramatically reduce the accumulation of logging data for this recommendation and provide sufficient information for the purposes of breach detection, research, and remediation. However, during periods of least privilege security group engineering, setting the filter to All can be very helpful in discovering existing traffic flows required for the proper operation of an already running environment. **From Command Line:** 1. Create a policy document, name it `role_policy_document.json`, and paste the following content: ``` { Version: 2012-10-17, Statement: [ { Sid: test, Effect: Allow, Principal: { Service: ec2.amazonaws.com }, Action: sts:AssumeRole } ] } ``` 2. Create another policy document, name it `iam_policy.json`, and paste the following content: ``` { Version: 2012-10-17, Statement: [ { Effect: Allow, Action:[ logs:CreateLogGroup, logs:CreateLogStream, logs:DescribeLogGroups, logs:DescribeLogStreams, logs:PutLogEvents, logs:GetLogEvents, logs:FilterLogEvents ], Resource: * } ] } ``` 3. Run the following command to create an IAM role: ``` aws iam create-role --role-name --assume-role-policy-document file://role_policy_document.json ``` 4. Run the following command to create an IAM policy: ``` aws iam create-policy --policy-name --policy-document file://iam-policy.json ``` 5. Run the `attach-group-policy` command, using the IAM policy ARN returned from the previous step to attach the policy to the IAM role: ``` aws iam attach-group-policy --policy-arn arn:aws:iam:::policy/ --group-name ``` - If the command succeeds, no output is returned. 6. Run the `describe-vpcs` command to get a list of VPCs in the selected region: ``` aws ec2 describe-vpcs --region ``` - The command output should return a list of VPCs in the selected region. 7. Run the `create-flow-logs` command to create a flow log for a VPC: ``` aws ec2 create-flow-logs --resource-type VPC --resource-ids --traffic-type REJECT --log-group-name --deliver-logs-permission-arn ``` 8. Repeat step 7 for other VPCs in the selected region. 9. Change the region by updating --region, and repeat the remediation procedure for each region.", + "AuditProcedure": "Perform the following to determine if VPC Flow logs are enabled: **From Console:** 1. Sign into the management console. 2. Select `Services`, then select `VPC`. 3. In the left navigation pane, select `Your VPCs`. 4. Select a VPC. 5. In the right pane, select the `Flow Logs` tab. 6. Ensure a Log Flow exists that has `Active` in the `Status` column. **From Command Line:** 1. Run the `describe-vpcs` command (OSX/Linux/UNIX) to list the VPC networks available in the current AWS region: ``` aws ec2 describe-vpcs --region --query Vpcs[].VpcId ``` 2. The command output returns the `VpcId` of VPCs available in the selected region. 3. Run the `describe-flow-logs` command (OSX/Linux/UNIX) using the VPC ID to determine if the selected virtual network has the Flow Logs feature enabled: ``` aws ec2 describe-flow-logs --filter Name=resource-id,Values= ``` - If there are no Flow Logs created for the selected VPC, the command output will return an empty list `[]`. 4. Repeat step 3 for other VPCs in the same region. 5. Change the region by updating `--region`, and repeat steps 1-4 for each region.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/flow-logs.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.8", + "Description": "Ensure that object-level logging for write events is enabled for S3 buckets", + "Checks": [ + "cloudtrail_s3_dataevents_write_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "S3 object-level API operations, such as GetObject, DeleteObject, and PutObject, are referred to as data events. By default, CloudTrail trails do not log data events, so it is recommended to enable object-level logging for S3 buckets.", + "RationaleStatement": "Enabling object-level logging will help you meet data compliance requirements within your organization, perform comprehensive security analyses, monitor specific patterns of user behavior in your AWS account, or take immediate actions on any object-level API activity within your S3 buckets using Amazon CloudWatch Events.", + "ImpactStatement": "Enabling logging for these object-level events may significantly increase the number of events logged and may incur additional costs.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to the S3 dashboard at `https://console.aws.amazon.com/s3/`. 2. In the left navigation panel, click `buckets`, and then click the name of the S3 bucket you want to examine. 3. Click the `Properties` tab to see the bucket configuration in detail. 4. In the `AWS CloudTrail data events` section, select the trail name for recording activity. You can choose an existing trail or create a new one by clicking the `Configure in CloudTrail` button or navigating to the [CloudTrail console](https://console.aws.amazon.com/cloudtrail/). 5. Once the trail is selected, select the `Data Events` check box. 6. Select `S3` from the `Data event type` drop-down. 7. Select `Log all events` from the `Log selector template` drop-down. 8. Repeat steps 2-7 to enable object-level logging of write events for other S3 buckets. **From Command Line:** 1. To enable `object-level` data events logging for S3 buckets within your AWS account, run the `put-event-selectors` command using the name of the trail that you want to reconfigure as identifier: ``` aws cloudtrail put-event-selectors --region --trail-name --event-selectors '[{ ReadWriteType: WriteOnly, IncludeManagementEvents:true, DataResources: [{ Type: AWS::S3::Object, Values: [arn:aws:s3:::/] }] }]' ``` 2. The command output will be `object-level` event trail configuration. 3. If you want to enable it for all buckets at once, change the Values parameter to `[arn:aws:s3]` in the previous command. 4. Repeat step 1 for each s3 bucket to update `object-level` logging of write events. 5. Change the AWS region by updating the `--region` command parameter, and perform the process for the other regions.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to the CloudTrail dashboard at `https://console.aws.amazon.com/cloudtrail/`. 2. In the left panel, click `Trails`, and then click the name of the trail that you want to examine. 3. Review `General details`. 4. Confirm that `Multi-region trail` is set to `Yes`. 5. Scroll down to `Data events` and confirm the configuration: - If `advanced event selectors` is being used, it should read: ``` Data Events: S3 Log selector template Log all events ``` - If `basic event selectors` is being used, it should read: ``` Data events: S3 Bucket Name: All current and future S3 buckets Write: Enabled ``` 6. Repeat steps 2-5 to verify that each trail has multi-region enabled and is configured to log data events. If a trail does not have multi-region enabled and data event logging configured, refer to the remediation steps. **From Command Line:** 1. Run the `list-trails` command to list all trails: ``` aws cloudtrail list-trails ``` 2. The command output will be a list of trails: ``` TrailARN: arn:aws:cloudtrail:::trail/, Name: , HomeRegion: ``` 3. Run the `get-trail` command to determine whether a trail is a multi-region trail: ``` aws cloudtrail get-trail --name --region ``` 4. The command output should include: `IsMultiRegionTrail: true`. 5. Run the `get-event-selectors` command, using the `Name` of the trail and the `region` returned in step 2, to determine if data event logging is configured: ``` aws cloudtrail get-event-selectors --region --trail-name --query EventSelectors[*].DataResources[] ``` 6. The command output should be an array that includes the S3 bucket defined for data event logging: ``` Type: AWS::S3::Object, Values: [ arn:aws:s3 ``` 7. If the `get-event-selectors` command returns an empty array, data events are not included in the trail's logging configuration; therefore, object-level API operations performed on S3 buckets within your AWS account are not being recorded. 8. Repeat steps 1-7 to verify that each trail has multi-region enabled and is configured to log data events. If a trail does not have multi-region enabled and data event logging configured, refer to the remediation steps.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/user-guide/enable-cloudtrail-events.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.9", + "Description": "Ensure that object-level logging for read events is enabled for S3 buckets", + "Checks": [ + "cloudtrail_s3_dataevents_read_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "S3 object-level API operations, such as GetObject, DeleteObject, and PutObject, are referred to as data events. By default, CloudTrail trails do not log data events, so it is recommended to enable object-level logging for S3 buckets.", + "RationaleStatement": "Enabling object-level logging will help you meet data compliance requirements within your organization, perform comprehensive security analyses, monitor specific patterns of user behavior in your AWS account, or take immediate actions on any object-level API activity within your S3 buckets using Amazon CloudWatch Events.", + "ImpactStatement": "Enabling logging for these object-level events may significantly increase the number of events logged and may incur additional costs.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to S3 dashboard at `https://console.aws.amazon.com/s3/`. 2. In the left navigation panel, click `buckets` and then click the name of the S3 bucket that you want to examine. 3. Click the `Properties` tab to see the bucket configuration in detail. 4. In the `AWS Cloud Trail data events` section, select the trail name for recording activity. You can choose an existing trail or create a new one by clicking the `Configure in CloudTrail` button or navigating to the [CloudTrail console](https://console.aws.amazon.com/cloudtrail/). 5. Once the trail is selected, select the `Data Events` check box. 6. Select `S3` from the `Data event type` drop-down. 7. Select `Log all events` from the `Log selector template` drop-down. 8. Repeat steps 2-7 to enable object-level logging of read events for other S3 buckets. **From Command Line:** 1. To enable `object-level` data events logging for S3 buckets within your AWS account, run the `put-event-selectors` command using the name of the trail that you want to reconfigure as identifier: ``` aws cloudtrail put-event-selectors --region --trail-name --event-selectors '[{ ReadWriteType: ReadOnly, IncludeManagementEvents:true, DataResources: [{ Type: AWS::S3::Object, Values: [arn:aws:s3:::/] }] }]' ``` 2. The command output will be `object-level` event trail configuration. 3. If you want to enable it for all buckets at once, change the Values parameter to `[arn:aws:s3]` in the previous command. 4. Repeat step 1 for each s3 bucket to update `object-level` logging of read events. 5. Change the AWS region by updating the `--region` command parameter, and perform the process for the other regions.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and navigate to the CloudTrail dashboard at `https://console.aws.amazon.com/cloudtrail/`. 2. In the left panel, click `Trails`, and then click the name of the trail that you want to examine. 3. Review `General details`. 4. Confirm that `Multi-region trail` is set to `Yes` 5. Scroll down to `Data events` 5. Scroll down to `Data events` and confirm the configuration: - If `advanced event selectors` is being used, it should read: ``` Data Events: S3 Log selector template Log all events ``` - If `basic event selectors` is being used, it should read: ``` Data events: S3 Bucket Name: All current and future S3 buckets Read: Enabled ``` 6. Repeat steps 2-5 to verify that each trail has multi-region enabled and is configured to log data events. If a trail does not have multi-region enabled and data event logging configured, refer to the remediation steps. **From Command Line:** 1. Run the `describe-trails` command to list all trail names: ``` aws cloudtrail describe-trails --region --output table --query trailList[*].Name ``` 2. The command output will be table of the trail names. 3. Run the `get-event-selectors` command using the name of a trail returned at the previous step and custom query filters to determine if data event logging is configured: ``` aws cloudtrail get-event-selectors --region --trail-name --query EventSelectors[*].DataResources[] ``` 4. The command output should be an array that includes the S3 bucket defined for data event logging. 5. If the `get-event-selectors` command returns an empty array, data events are not included in the trail's logging configuration; therefore, object-level API operations performed on S3 buckets within your AWS account are not being recorded. 6. Repeat steps 1-5 to verify the configuration of each trail. 7. Change the AWS region by updating the `--region` command parameter, and perform the audit process for other regions.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AmazonS3/latest/user-guide/enable-cloudtrail-events.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.10", + "Description": "Ensure all AWS-managed web front-end services have access logging enabled", + "Checks": [ + "cloudfront_distributions_logging_enabled", + "elbv2_logging_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled" + ], + "Attributes": [ + { + "Section": "4 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that access logging is enabled for all AWS-managed web front-end services that terminate or front HTTP(S) traffic, including Amazon CloudFront distributions, Application Load Balancers (ALB), Network Load Balancers (NLB), and Amazon API Gateway REST/HTTP API stages with public endpoints. Access logs must be enabled with delivery to a designated S3 bucket or CloudWatch Logs destination that is protected with appropriate access controls. This control requires logging of request details such as client IP address, timestamp, HTTP method, requested URI, response status code, bytes transferred, and user agent for every request processed by these services. CloudTrail provides management event logging for these resources, but access logs are required to capture the actual HTTP request/response activity at the network edge layers.", + "RationaleStatement": "AWS-managed web front-end services (CloudFront, ALB/NLB, API Gateway) represent the primary HTTP(S) ingress points into AWS accounts and are the first line of defense against web attacks, reconnaissance, and abuse attempts. CloudTrail logs management actions (create/update/delete) and data events but does not capture the content of HTTP requests/responses or client activity, leaving a critical visibility gap for security monitoring and incident response. Access logs from these services enable reconstruction of all web traffic, detection of anomalous patterns, forensic analysis of incidents, and compliance proof that internet-facing entry points were monitored. Without these logs, security teams cannot distinguish legitimate traffic from attacks or prove access patterns during audits.", + "ImpactStatement": "Enabling access logging incurs additional storage costs for log delivery and retention, as well as minor configuration overhead for creating dedicated logging buckets, IAM roles, and retention policies. Costs can be managed through lifecycle policies, log sampling, and tiered storage classes.", + "RemediationProcedure": "Following instructions enable standard access logging for CloudFront distributions using the AWS Management Console. 1. Open the CloudFront console from the AWS Management Console. 2. Click Distributions in the left navigation and click on the Distribution ID needing remediation. 3. Go to the \"Logging\" tab and click on \"Create access log delivery\" - Select \"Deliver to\" for your preferred location: S3 or CloudWatch log group - Select the ARN of your log destination resource - Click on Submit 4. Confirm if you see the access log destination in the logging tab", + "AuditProcedure": "As an example with CloudFront, verify following the below steps if access logging is enabled: 1. Open the CloudFront console from the AWS Management Console. 2. Click Distributions in the left navigation. 3. For each Distribution ID (e.g., E123ABC...), click the Distribution ID and go to the \"Logging\" tab 4. Check if one or more \"Access log destinations\" are present with a destination type of S3 or CloudWatch log group.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1", + "Description": "Ensure unauthorized API calls are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for unauthorized API calls.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring unauthorized API calls will help reduce the time it takes to detect malicious activity and can alert you to potential security incidents.", + "ImpactStatement": "This alert may be triggered by normal read-only console activities that attempt to opportunistically gather optional information but gracefully fail if they lack the necessary permissions. If an excessive number of alerts are generated, then an organization may wish to consider adding read access to the limited IAM user permissions solely to reduce the number of alerts. In some cases, doing this may allow users to actually view some areas of the system; any additional access granted should be reviewed for alignment with the original limited IAM user intent.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for unauthorized API calls and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=unauthorized_api_calls_metric,metricNamespace=CISBenchmark,metricValue=1 --filter-pattern { ($.errorCode =*UnauthorizedOperation) || ($.errorCode =AccessDenied*) && ($.sourceIPAddress!=delivery.logs.amazonaws.com) && ($.eventName!=HeadBucket) } ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name unauthorized_api_calls_alarm --metric-name unauthorized_api_calls_metric --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace CISBenchmark --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.errorCode =*UnauthorizedOperation) || ($.errorCode =AccessDenied*) && ($.sourceIPAddress!=delivery.logs.amazonaws.com) && ($.eventName!=HeadBucket) }, ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query MetricAlarms[?MetricName == ] ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://aws.amazon.com/sns/:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2", + "Description": "Ensure management console sign-in without MFA is monitored", + "Checks": [ + "cloudwatch_log_metric_filter_sign_in_without_mfa" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for console logins that are not protected by multi-factor authentication (MFA).", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring for single-factor console logins will increase visibility into accounts that are not protected by MFA. These type of accounts are more susceptible to compromise and unauthorized access.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS Management Console sign-ins without MFA and uses the `` taken from audit step 1. ``` aws logs put-metric-filter --log-group-name --filter-name `` --metric-transformations metricName= ``,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) }' ``` Or, to reduce false positives in case Single Sign-On (SSO) is used in the organization: ``` aws logs put-metric-filter --log-group-name --filter-name `` --metric-transformations metricName= ``,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) && ($.userIdentity.type = IAMUser) && ($.responseElements.ConsoleLogin = Success) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name `` --metric-name `` --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: ``` aws cloudtrail describe-trails ``` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: ``` aws cloudtrail get-trail-status --name ``` - ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: ``` aws cloudtrail get-event-selectors --trail-name ``` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) } ``` Or, to reduce false positives in case Single Sign-On (SSO) is used in the organization: ``` filterPattern: { ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) && ($.userIdentity.type = IAMUser) && ($.responseElements.ConsoleLogin = Success) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4. ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName== ]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored Filter pattern set to `{ ($.eventName = ConsoleLogin) && ($.additionalEventData.MFAUsed != Yes) && ($.userIdentity.type = IAMUser) && ($.responseElements.ConsoleLogin = Success}`: - reduces false alarms raised when a user logs in via SSO", + "References": "https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/viewing_metrics_with_cloudwatch.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3", + "Description": "Ensure usage of the 'root' account is monitored", + "Checks": [ + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for 'root' login attempts to detect unauthorized use or attempts to use the root account.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring 'root' account logins will provide visibility into the use of a fully privileged account and the opportunity to reduce its usage.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for 'root' account usage and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name `` --filter-name `` --metric-transformations metricName= `` ,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ $.userIdentity.type = Root && $.userIdentity.invokedBy NOT EXISTS && $.eventType != AwsServiceEvent }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name `` --metric-name `` --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: ``` aws cloudtrail describe-trails ``` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: ``` aws cloudtrail get-trail-status --name ``` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: ``` aws cloudtrail get-event-selectors --trail-name ``` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { $.userIdentity.type = Root && $.userIdentity.invokedBy NOT EXISTS && $.eventType != AwsServiceEvent } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.4", + "Description": "Ensure IAM policy changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_policy_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for changes made to Identity and Access Management (IAM) policies.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to IAM policies will help ensure authentication and authorization controls remain intact.", + "ImpactStatement": "Monitoring these changes may result in a number of false positives, especially in larger environments. This alert may require more tuning than others to eliminate some of those erroneous notifications.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for IAM policy changes and the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name `` --filter-name `` --metric-transformations metricName= ``,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name `` --metric-name `` --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrails: ``` aws cloudtrail describe-trails ``` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: ``` aws cloudtrail get-trail-status --name ``` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: ``` aws cloudtrail get-event-selectors --trail-name ``` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: {($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)} ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.5", + "Description": "Ensure CloudTrail configuration changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be used to detect changes to CloudTrail's configurations.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to CloudTrail's configuration will help ensure sustained visibility into the activities performed in the AWS account.", + "ImpactStatement": "Ensuring that changes to CloudTrail configurations are monitored enhances security by maintaining the integrity of logging mechanisms. Automated monitoring can provide real-time alerts; however, it may require additional setup and resources to configure and manage these alerts effectively. These steps can be performed manually within a company's existing SIEM platform in cases where CloudTrail logs are monitored outside of the AWS monitoring tools in CloudWatch.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for CloudTrail configuration changes and the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.6", + "Description": "Ensure AWS Management Console authentication failures are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_authentication_failures" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for failed console authentication attempts.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring failed console logins may decrease the lead time to detect an attempt to brute-force a credential, which may provide an indicator, such as the source IP address, that can be used in other event correlations.", + "ImpactStatement": "Monitoring for these failures may generate a large number of alerts, especially in larger environments.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS management Console login failures and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = ConsoleLogin) && ($.errorMessage = Failed authentication) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = ConsoleLogin) && ($.errorMessage = Failed authentication) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.7", + "Description": "Ensure disabling or scheduled deletion of customer created CMKs is monitored", + "Checks": [ + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for customer-created CMKs that have changed state to disabled or are scheduled for deletion.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Data encrypted with disabled or deleted keys will no longer be accessible. Changes in the state of a CMK should be monitored to ensure that the change is intentional.", + "ImpactStatement": "Creation, storage, and management of CMK may require additional labor compared to the use of AWS-managed keys.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for CMKs that have been disabled or scheduled for deletion and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: {($.eventSource = kms.amazonaws.com) && (($.eventName=DisableKey)||($.eventName=ScheduleKeyDeletion)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.8", + "Description": "Ensure S3 bucket policy changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for changes to S3 bucket policies.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to S3 bucket policies may reduce the time it takes to detect and correct permissive policies on sensitive S3 buckets.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for changes to S3 bucket policies and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.9", + "Description": "Ensure AWS Config configuration changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for detecting changes to AWS Config's configurations.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to the AWS Config configuration will help ensure sustained visibility of the configuration items within the AWS account.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS Configuration changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.10", + "Description": "Ensure security group changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. Security groups are stateful packet filters that control ingress and egress traffic within a VPC. It is recommended that a metric filter and alarm be established to detect changes to security groups.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to security groups will help ensure that resources and services are not unintentionally exposed.", + "ImpactStatement": "This may require additional 'tuning' to eliminate false positives and filter out expected activity so that anomalies are easier to detect.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for security groups changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace=CISBenchmark,metricValue=1 --filter-pattern { ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) || ($.eventName = ModifySecurityGroupRules) } ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace CISBenchmark --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) || ($.eventName = ModifySecurityGroupRules) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query MetricAlarms[?MetricName==] ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored AWS has recently introduced a new API, ModifySecurityGroupRules, which modifies the rules of a security group.", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html:https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_ModifySecurityGroupRules.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.11", + "Description": "Ensure Network Access Control List (NACL) changes are monitored", + "Checks": [ + "cloudwatch_changes_to_network_acls_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. NACLs are used as a stateless packet filter to control ingress and egress traffic for subnets within a VPC. It is recommended that a metric filter and alarm be established for any changes made to NACLs.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to NACLs will help ensure that AWS resources and services are not unintentionally exposed.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for NACL changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.12", + "Description": "Ensure changes to network gateways are monitored", + "Checks": [ + "cloudwatch_changes_to_network_gateways_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. Network gateways are required to send and receive traffic to a destination outside of a VPC. It is recommended that a metric filter and alarm be established for changes to network gateways.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to network gateways will help ensure that all ingress/egress traffic traverses the VPC border via a controlled path.", + "ImpactStatement": "Monitoring changes to network gateways helps detect unauthorized modifications that could compromise network security. Implementing automated monitoring and alerts can improve incident response times, but it may require additional configuration and maintenance efforts.", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for network gateways changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ``` 5. Implement logging and alerting mechanisms: ``` aws sns create-topic --name NetworkGatewayChangesAlerts ```` ``` aws sns subscribe --topic-arn --protocol email --notification-endpoint ``` ``` aws cloudwatch put-metric-alarm --alarm-name NetworkGatewayChangesAlarm --metric-name GatewayChanges --namespace AWS/EC2 --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::` 8. Ensure automated monitoring is enabled: ``` aws cloudwatch put-metric-alarm --alarm-name NetworkGatewayChanges --metric-name GatewayChanges --namespace AWS/EC2 --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --alarm-actions ```", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.13", + "Description": "Ensure route table changes are monitored", + "Checks": [ + "cloudwatch_changes_to_network_route_tables_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. Routing tables are used to route network traffic between subnets and to network gateways. It is recommended that a metric filter and alarm be established for changes to route tables.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring changes to route tables will help ensure that all VPC traffic flows through the expected path and prevent any accidental or intentional modifications that may lead to uncontrolled network traffic. An alarm should be triggered every time an AWS API call is performed to create, replace, delete, or disassociate a route table.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for route table changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-pattern '{ ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: {($.eventSource = ec2.amazonaws.com) && ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.14", + "Description": "Ensure VPC changes are monitored", + "Checks": [ + "cloudwatch_changes_to_vpcs_alarm_configured" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is possible to have more than one VPC within an account; additionally, it is also possible to create a peer connection between two VPCs, enabling network traffic to route between them. It is recommended that a metric filter and alarm be established for changes made to VPCs.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. VPCs in AWS are logically isolated virtual networks that can be used to launch AWS resources. Monitoring changes to VPC configurations will help ensure that VPC traffic flow is not negatively impacted. Changes to VPCs can affect network accessibility from the public internet and additionally impact VPC traffic flow to and from the resources launched in the VPC.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for VPC changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/receive-cloudtrail-log-files-from-multiple-regions.html:https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/sns/latest/dg/SubscribeTopic.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.15", + "Description": "Ensure AWS Organizations changes are monitored", + "Checks": [ + "cloudwatch_log_metric_filter_aws_organizations_changes" + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs or an external Security Information and Event Management (SIEM) environment, and establishing corresponding metric filters and alarms. It is recommended that a metric filter and alarm be established for changes made to AWS Organizations in the master AWS account.", + "RationaleStatement": "CloudWatch is an AWS native service that allows you to observe and monitor resources and applications. CloudTrail logs can also be sent to an external Security Information and Event Management (SIEM) environment for monitoring and alerting. Monitoring AWS Organizations changes can help you prevent unwanted, accidental, or intentional modifications that may lead to unauthorized access or other security breaches. This monitoring technique helps ensure that any unexpected changes made within your AWS Organizations can be investigated and that any unwanted changes can be rolled back.", + "ImpactStatement": "", + "RemediationProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following steps to set up the metric filter, alarm, SNS topic, and subscription: 1. Create a metric filter based on the provided filter pattern that checks for AWS Organizations changes and uses the `` taken from audit step 1: ``` aws logs put-metric-filter --log-group-name --filter-name --metric-transformations metricName=,metricNamespace='CISBenchmark',metricValue=1 --filter-pattern '{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = AcceptHandshake) || ($.eventName = AttachPolicy) || ($.eventName = CreateAccount) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreatePolicy) || ($.eventName = DeclineHandshake) || ($.eventName = DeleteOrganization) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeletePolicy) || ($.eventName = DetachPolicy) || ($.eventName = DisablePolicyType) || ($.eventName = EnablePolicyType) || ($.eventName = InviteAccountToOrganization) || ($.eventName = LeaveOrganization) || ($.eventName = MoveAccount) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit)) }' ``` **Note**: You can choose your own `metricName` and `metricNamespace` strings. Using the same `metricNamespace` for all Foundations Benchmark metrics will group them together. 2. Create an SNS topic that the alarm will notify: ``` aws sns create-topic --name ``` **Note**: You can execute this command once and then reuse the same topic for all monitoring alarms. **Note**: Capture the `TopicArn` that is displayed when creating the SNS topic in step 2. 3. Create an SNS subscription for the topic created in step 2: ``` aws sns subscribe --topic-arn --protocol --notification-endpoint ``` **Note**: You can execute this command once and then reuse the same subscription for all monitoring alarms. 4. Create an alarm that is associated with the CloudWatch Logs metric filter created in step 1 and the SNS topic created in step 2: ``` aws cloudwatch put-metric-alarm --alarm-name --metric-name --statistic Sum --period 300 --threshold 1 --comparison-operator GreaterThanOrEqualToThreshold --evaluation-periods 1 --namespace 'CISBenchmark' --alarm-actions ```", + "AuditProcedure": "If you are using CloudTrail trails and CloudWatch, perform the following to ensure that there is at least one active multi-region CloudTrail trail with the prescribed metric filters and alarms configured: 1. Identify the log group name that is configured for use with the active multi-region CloudTrail trail: - List all CloudTrail trails: `aws cloudtrail describe-trails` - Identify multi-region CloudTrail trails: `Trails with IsMultiRegionTrail set to true` - Note the value associated with Name:`` - Note the `` within the value associated with CloudWatchLogsLogGroupArn - Example: `arn:aws:logs:::log-group::*` - Ensure the identified multi-region CloudTrail trail is active: - `aws cloudtrail get-trail-status --name ` - Ensure `IsLogging` is set to `TRUE` - Ensure the identified multi-region CloudTrail trail captures all management events: - `aws cloudtrail get-event-selectors --trail-name ` - Ensure there is at least one `event selector` for a trail with `IncludeManagementEvents` set to `true` and `ReadWriteType` set to `All` 2. Get a list of all associated metric filters for the `` captured in step 1: ``` aws logs describe-metric-filters --log-group-name ``` 3. Ensure the output from the above command contains the following: ``` filterPattern: { ($.eventSource = organizations.amazonaws.com) && (($.eventName = AcceptHandshake) || ($.eventName = AttachPolicy) || ($.eventName = CreateAccount) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreatePolicy) || ($.eventName = DeclineHandshake) || ($.eventName = DeleteOrganization) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeletePolicy) || ($.eventName = DetachPolicy) || ($.eventName = DisablePolicyType) || ($.eventName = EnablePolicyType) || ($.eventName = InviteAccountToOrganization) || ($.eventName = LeaveOrganization) || ($.eventName = MoveAccount) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit)) } ``` 4. Note the `` value associated with the `filterPattern` from step 3. 5. Get a list of CloudWatch alarms, and filter on the `` captured in step 4: ``` aws cloudwatch describe-alarms --query 'MetricAlarms[?MetricName==]' ``` 6. Note the `AlarmActions` value; this will provide the SNS topic ARN value. 7. Ensure there is at least one active subscriber to the SNS topic: ``` aws sns list-subscriptions-by-topic --topic-arn ``` - At least one subscription should have SubscriptionArn with a valid AWS ARN. - Example of valid SubscriptionArn: `arn:aws:sns::::`", + "AdditionalInformation": "Configuring a log metric filter and alarm on a multi-region (global) CloudTrail trail: - ensures that activities from all regions (both used and unused) are monitored - ensures that activities on all supported global services are monitored - ensures that all management events across all regions are monitored", + "References": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html:https://docs.aws.amazon.com/organizations/latest/userguide/orgs_security_incident-response.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.16", + "Description": "Ensure AWS Security Hub is enabled", + "Checks": [ + "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], + "Attributes": [ + { + "Section": "5 Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Security Hub collects security data from various AWS accounts, services, and supported third-party partner products, helping you analyze your security trends and identify the highest-priority security issues. When you enable Security Hub, it begins to consume, aggregate, organize, and prioritize findings from the AWS services that you have enabled, such as Amazon GuardDuty, Amazon Inspector, and Amazon Macie. You can also enable integrations with AWS partner security products.", + "RationaleStatement": "AWS Security Hub provides you with a comprehensive view of your security state in AWS and helps you check your environment against security industry standards and best practices, enabling you to quickly assess the security posture across your AWS accounts.", + "ImpactStatement": "It is recommended that AWS Security Hub be enabled in all regions. AWS Security Hub requires that AWS Config be enabled.", + "RemediationProcedure": "To grant the permissions required to enable Security Hub, attach the Security Hub managed policy `AWSSecurityHubFullAccess` to an IAM user, group, or role. Enabling Security Hub: **From Console:** 1. Use the credentials of the IAM identity to sign in to the Security Hub console. 2. When you open the Security Hub console for the first time, choose `Go to Security Hub`. 3. The `Security standards` section on the welcome page lists supported security standards. Check the box for a standard to enable it. 3. Choose `Enable Security Hub`. **From Command Line:** 1. Run the `enable-security-hub` command, including `--enable-default-standards` to enable the default standards: ``` aws securityhub enable-security-hub --enable-default-standards ``` 2. To enable Security Hub without the default standards, include `--no-enable-default-standards`: ``` aws securityhub enable-security-hub --no-enable-default-standards ```", + "AuditProcedure": "Follow this process to evaluate AWS Security Hub configuration per region: **From Console:** 1. Sign in to the AWS Management Console and open the AWS Security Hub console at https://console.aws.amazon.com/securityhub/. 2. On the top right of the console, select the target Region. 3. If the Security Hub > Summary page is displayed, then Security Hub is set up for the selected region. 4. If presented with Setup Security Hub or Get Started With Security Hub, refer to the remediation steps. 5. Repeat steps 2 to 4 for each region. **From Command Line:** Run the following command to verify the Security Hub status: ``` aws securityhub describe-hub ``` This will list the Security Hub status by region. Check for a 'SubscribedAt' value. Example output: ``` { HubArn: , SubscribedAt: 2022-08-19T17:06:42.398Z, AutoEnableControls: true } ``` An error will be returned if Security Hub is not enabled. Example error: ``` An error occurred (InvalidAccessException) when calling the DescribeHub operation: Account is not subscribed to AWS Security Hub ```", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-get-started.html:https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-enable.html#securityhub-enable-api:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/securityhub/enable-security-hub.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.1", + "Description": "Ensure EBS volume encryption is enabled in all regions", + "Checks": [ + "ec2_ebs_default_encryption" + ], + "Attributes": [ + { + "Section": "6 Networking", + "SubSection": "6.1 Elastic Compute Cloud (EC2)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Elastic Compute Cloud (EC2) supports encryption at rest when using the Elastic Block Store (EBS) service. While disabled by default, forcing encryption at EBS volume creation is supported.", + "RationaleStatement": "Encrypting data at rest reduces the likelihood of unintentional exposure and can nullify the impact of disclosure if the encryption remains unbroken.", + "ImpactStatement": "Losing access to or removing the KMS key used by the EBS volumes will result in the inability to access the volumes.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the Amazon EC2 console using https://console.aws.amazon.com/ec2/. 2. Under `Account attributes`, click `EBS encryption`. 3. Click `Manage`. 4. Check the `Enable` box. 5. Click `Update EBS encryption`. 6. Repeat for each region in which EBS volume encryption is not enabled by default. **Note:** EBS volume encryption is configured per region. **From Command Line:** 1. Run the following command: ``` aws --region ec2 enable-ebs-encryption-by-default ``` 2. Verify that `EbsEncryptionByDefault: true` is displayed. 3. Repeat for each region in which EBS volume encryption is not enabled by default. **Note:** EBS volume encryption is configured per region.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console and open the Amazon EC2 console using https://console.aws.amazon.com/ec2/. 2. Under `Settings`, click `EBS encryption`. 3. Verify `Always encrypt new EBS volumes` displays `Enabled`. 4. Repeat for each region in use. **Note:** EBS volume encryption is configured per region. **From Command Line:** 1. Run the following command: ``` aws --region ec2 get-ebs-encryption-by-default ``` 2. Verify that `EbsEncryptionByDefault: true` is displayed. 3. Repeat for each region in use. **Note:** EBS volume encryption is configured per region.", + "AdditionalInformation": "Default EBS volume encryption only applies to newly created EBS volumes; existing EBS volumes are **not** converted automatically.", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html:https://aws.amazon.com/blogs/aws/new-opt-in-to-default-encryption-for-new-ebs-volumes/", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.2", + "Description": "Ensure CIFS access is restricted to trusted networks to prevent unauthorized access", + "Checks": [ + "ec2_instance_port_cifs_exposed_to_internet" + ], + "Attributes": [ + { + "Section": "6 Networking", + "SubSection": "6.1 Elastic Compute Cloud (EC2)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Common Internet File System (CIFS) is a network file-sharing protocol that allows systems to share files over a network. However, unrestricted CIFS access can expose your data to unauthorized users, leading to potential security risks. It is important to restrict CIFS access to only trusted networks and users to prevent unauthorized access and data breaches.", + "RationaleStatement": "Allowing unrestricted CIFS access can lead to significant security vulnerabilities, as it may allow unauthorized users to access sensitive files and data. By restricting CIFS access to known and trusted networks, you can minimize the risk of unauthorized access and protect sensitive data from exposure to potential attackers. Implementing proper network access controls and permissions is essential for maintaining the security and integrity of your file-sharing systems.", + "ImpactStatement": "Restricting CIFS access may require additional configuration and management effort. However, the benefits of enhanced security and reduced risk of unauthorized access to sensitive data far outweigh the potential challenges.", + "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console. 2. Navigate to the EC2 Dashboard and select the Security Groups section under `Network & Security`. 3. Identify the security group that allows unrestricted ingress on port 445. 4. Select the security group and click the `Edit Inbound Rules` button. 5. Locate the rule allowing unrestricted access on port 445 (typically listed as `0.0.0.0/0` or `::/0`). 6. Modify the rule to restrict access to specific IP ranges or trusted networks only. 7. Save the changes to the security group. **From Command Line:** 1. Run the following command to remove or modify the unrestricted rule for CIFS access: ``` aws ec2 revoke-security-group-ingress --region --group-id --protocol tcp --port 445 --cidr 0.0.0.0/0 ``` - Optionally, run the `authorise-security-group-ingress` command to create a new rule, specifying a trusted CIDR range instead of `0.0.0.0/0`. 2. Confirm the changes by describing the security group again and ensuring the unrestricted access rule has been removed or appropriately restricted: ``` aws ec2 describe-security-groups --region --group-ids --query 'SecurityGroups[*].IpPermissions[?ToPort==`445`].{CIDR:IpRanges[*].CidrIp,Port:ToPort}' ``` 3. Repeat the remediation for other security groups and regions as necessary.", + "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console. 2. Navigate to the EC2 Dashboard and select the Security Groups section under `Network & Security`. 3. Identify the security groups associated with instances or resources that may be using CIFS. 4. Review the inbound rules of each security group to check for rules that allow unrestricted access on port 445 (the port used by CIFS). - Specifically, look for inbound rules that allow access from `0.0.0.0/0` or `::/0` on port 445. 5. Document any instances where unrestricted access is allowed and verify whether it is necessary for the specific use case. **From Command Line:** 1. Run the following command to list all security groups and identify those associated with CIFS: ``` aws ec2 describe-security-groups --region --query 'SecurityGroups[*].GroupId' ``` 2. Check for any inbound rules that allow unrestricted access on port 445 using the following command: ``` aws ec2 describe-security-groups --region --group-ids --query 'SecurityGroups[*].IpPermissions[?ToPort==`445`].{CIDR:IpRanges[*].CidrIp,Port:ToPort}' ``` - Look for `0.0.0.0/0` or `::/0` in the output, which indicates unrestricted access. 3. Repeat the audit for other regions and security groups as necessary.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.2", + "Description": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to remote server administration ports", + "Checks": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The Network Access Control List (NACL) function provides stateless filtering of ingress and egress network traffic to AWS resources. It is recommended that no NACL allows unrestricted ingress access to remote server administration ports, such as SSH on port `22` and RDP on port `3389`, using either the TCP (6), UDP (17), or ALL (-1) protocols.", + "RationaleStatement": "Public access to remote server administration ports, such as 22 (when used for SSH, not SFTP) and 3389, increases the attack surface of resources and unnecessarily raises the risk of resource compromise.", + "ImpactStatement": "", + "RemediationProcedure": "**From Console:** Perform the following steps to remediate a network ACL: 1. Login to the AWS VPC Console at https://console.aws.amazon.com/vpc/home. 2. In the left pane, click `Network ACLs`. 3. For each network ACL that needs remediation, perform the following: - Select the network ACL. - Click the `Inbound Rules` tab. - Click `Edit inbound rules`. - Either A) update the Source field to a range other than 0.0.0.0/0, or B) click `Delete` to remove the offending inbound rule. - Click `Save`.", + "AuditProcedure": "**From Console:** Perform the following steps to determine if the account is configured as prescribed: 1. Login to the AWS VPC Console at https://console.aws.amazon.com/vpc/home. 2. In the left pane, click `Network ACLs`. 3. For each network ACL, perform the following: - Select the network ACL. - Click the `Inbound Rules` tab. - Ensure that no rule exists which has a port range that includes port `22` or `3389`, uses the protocols TCP (6), UDP (17), or ALL (-1), or other remote server administration ports for your environment, has a `Source` of `0.0.0.0/0`, and shows `ALLOW`. **Note:** A port value of `ALL` or a port range such as `0-3389` includes port `22`, `3389`, and potentially other remote server administration ports.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html:https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Security.html#VPC_Security_Comparison", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.3", + "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 to remote server administration ports", + "Checks": [ + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Security groups provide stateful filtering of ingress and egress network traffic to AWS resources. It is recommended that no security group allows unrestricted ingress access to remote server administration ports, such as SSH on port `22` and RDP on port `3389`, using either the TCP (6), UDP (17), or ALL (-1) protocols.", + "RationaleStatement": "Public access to remote server administration ports, such as 22 (when used for SSH, not SFTP) and 3389, increases the attack surface of resources and unnecessarily raises the risk of resource compromise.", + "ImpactStatement": "When updating an existing environment, ensure that administrators have access to remote server administration ports through another mechanism before removing access by deleting the 0.0.0.0/0 inbound rule.", + "RemediationProcedure": "Perform the following to implement the prescribed state: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Click the `Edit inbound rules` button. - Identify the rules to be edited or removed. - Either A) update the Source field to a range other than 0.0.0.0/0, or B) click `Delete` to remove the offending inbound rule. - Click `Save rules`.", + "AuditProcedure": "Perform the following to determine if the account is configured as prescribed: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Ensure that no rule exists which has a port range including port `22` or `3389`, uses the protocols TCP (6), UDP (17), or ALL (-1), or other remote server administration ports for your environment, and has a `Source` of `0.0.0.0/0`. **Note:** A port value of `ALL` or a port range such as `0-3389` includes port `22`, `3389`, and potentially other remote server administration ports.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security-groups.html#deleting-security-group-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.4", + "Description": "Ensure no security groups allow ingress from ::/0 to remote server administration ports", + "Checks": [ + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Security groups provide stateful filtering of ingress and egress network traffic to AWS resources. It is recommended that no security group allows unrestricted ingress access to remote server administration ports, such as SSH on port `22` and RDP on port `3389`.", + "RationaleStatement": "Public access to remote server administration ports, such as 22 (when used for SSH, not SFTP) and 3389, increases attack surface of resources and unnecessarily raises the risk of resource compromise.", + "ImpactStatement": "When updating an existing environment, ensure that administrators have access to remote server administration ports through another mechanism before removing access by deleting the ::/0 inbound rule.", + "RemediationProcedure": "Perform the following to implement the prescribed state: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Click the `Edit inbound rules` button. - Identify the rules to be edited or removed. - Either A) update the Source field to a range other than ::/0, or B) Click `Delete` to remove the offending inbound rule. - Click `Save rules`.", + "AuditProcedure": "Perform the following to determine if the account is configured as prescribed: 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. In the left pane, click `Security Groups`. 3. For each security group, perform the following: - Select the security group. - Click the `Inbound Rules` tab. - Ensure that no rule exists which has a port range including port `22`, `3389`, or other remote server administration ports for your environment, and has a `Source` of `::/0`. **Note:** A port value of `ALL` or a port range such as `0-3389` includes port `22`, `3389`, and potentially other remote server administration ports.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security-groups.html#deleting-security-group-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.5", + "Description": "Ensure the default security group of every VPC restricts all traffic", + "Checks": [ + "ec2_securitygroup_default_restrict_traffic" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "A VPC comes with a default security group whose initial settings deny all inbound traffic, allow all outbound traffic, and allow all traffic between instances assigned to the security group. If a security group is not specified when an instance is launched, it is automatically assigned to this default security group. Security groups provide stateful filtering of ingress/egress network traffic to AWS resources. It is recommended that the default security group restrict all traffic, both inbound and outbound. The default VPC in every region should have its default security group updated to comply with the following: - No inbound rules. - No outbound rules. Any newly created VPCs will automatically contain a default security group that will need remediation to comply with this recommendation. **Note:** When implementing this recommendation, VPC flow logging is invaluable in determining the least privilege port access required by systems to work properly, as it can log all packet acceptances and rejections occurring under the current security groups. This dramatically reduces the primary barrier to least privilege engineering by discovering the minimum ports required by systems in the environment. Even if the VPC flow logging recommendation in this benchmark is not adopted as a permanent security measure, it should be used during any period of discovery and engineering for least privileged security groups.", + "RationaleStatement": "Configuring all VPC default security groups to restrict all traffic will encourage the development of least privilege security groups and promote the mindful placement of AWS resources into security groups, which will, in turn, reduce the exposure of those resources.", + "ImpactStatement": "Implementing this recommendation in an existing VPC that contains operating resources requires extremely careful migration planning, as the default security groups are likely enabling many ports that are unknown. Enabling VPC flow logging (for accepted connections) in an existing environment that is known to be breach-free will reveal the current pattern of ports being used for each instance to communicate successfully. The migration process should include: - Analyzing VPC flow logs to understand current traffic patterns. - Creating least privilege security groups based on the analyzed data. - Testing the new security group rules in a staging environment before applying them to production.", + "RemediationProcedure": "Perform the following to implement the prescribed state: **Security Group Members** 1. Identify AWS resources that exist within the default security group. 2. Create a set of least-privilege security groups for those resources. 3. Place the resources in those security groups, removing the resources noted in step 1 from the default security group. **Security Group State** 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. Repeat the following steps for all VPCs, including the default VPC in each AWS region: 3. In the left pane, click `Security Groups`. 4. For each default security group, perform the following: - Select the `default` security group. - Click the `Inbound Rules` tab. - Remove any inbound rules. - Click the `Outbound Rules` tab. - Remove any Outbound rules. **Recommended** IAM groups allow you to edit the name field. After remediating default group rules for all VPCs in all regions, edit this field to add text similar to DO NOT USE. DO NOT ADD RULES.", + "AuditProcedure": "Perform the following to determine if the account is configured as prescribed: **Security Group State** 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. Repeat the following steps for all VPCs, including the default VPC in each AWS region: 3. In the left pane, click `Security Groups`. 4. For each default security group, perform the following: - Select the `default` security group. - Click the `Inbound Rules` tab and ensure no rules exist. - Click the `Outbound Rules` tab and ensure no rules exist. **Security Group Members** 1. Login to the AWS VPC Console at [https://console.aws.amazon.com/vpc/home](https://console.aws.amazon.com/vpc/home). 2. Repeat the following steps for all default groups in all VPCs, including the default VPC in each AWS region: 3. In the left pane, click `Security Groups`. 4. Copy the ID of the default security group. 5. Change to the EC2 Management Console at https://console.aws.amazon.com/ec2/v2/home. 6. In the filter column type `Security Group ID : `.", + "AdditionalInformation": "", + "References": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-network-security.html:https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security-groups.html#default-security-group", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.6", + "Description": "Ensure routing tables for VPC peering are \"least access\"", + "Checks": [ + "vpc_peering_routing_tables_with_least_privilege" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Once a VPC peering connection is established, routing tables must be updated to enable any connections between the peered VPCs. These routes can be as specific as desired, even allowing for the peering of a VPC to only a single host on the other side of the connection.", + "RationaleStatement": "Being highly selective in peering routing tables is a very effective way to minimize the impact of a breach, as resources outside of these routes are inaccessible to the peered VPC.", + "ImpactStatement": "", + "RemediationProcedure": "Remove and add route table entries to ensure that the least number of subnets or hosts required to accomplish the purpose of peering are routable. **From Command Line:** 1. For each `` that contains routes that are non-compliant with your routing policy (granting more access than desired), delete the non-compliant route: ``` aws ec2 delete-route --route-table-id --destination-cidr-block ``` 2. Create a new compliant route: ``` aws ec2 create-route --route-table-id --destination-cidr-block --vpc-peering-connection-id ```", + "AuditProcedure": "Review the routing tables of peered VPCs to determine whether they route all subnets of each VPC and whether this is necessary to accomplish the intended purposes of peering the VPCs. **From Command Line:** 1. List all the route tables from a VPC and check if the GatewayId is pointing to a `` (e.g., pcx-1a2b3c4d) and if the DestinationCidrBlock is as specific as desired: ``` aws ec2 describe-route-tables --filter Name=vpc-id,Values= --query RouteTables[*].{RouteTableId:RouteTableId, VpcId:VpcId, Routes:Routes, AssociatedSubnets:Associations[*].SubnetId} ```", + "AdditionalInformation": "If an organization has an AWS Transit Gateway implemented in its VPC architecture, it should look to apply the recommendation above for a least access routing architecture at the AWS Transit Gateway level, in combination with what must be implemented at the standard VPC route table. More specifically, to route traffic between two or more VPCs via a Transit Gateway, VPCs must have an attachment to a Transit Gateway route table as well as a route. Therefore, to avoid routing traffic between VPCs, an attachment to the Transit Gateway route table should only be added where there is an intention to route traffic between the VPCs. As Transit Gateways are capable of hosting multiple route tables, it is possible to group VPCs by attaching them to a common route table.", + "References": "https://docs.aws.amazon.com/AmazonVPC/latest/PeeringGuide/peering-configurations-partial-access.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/create-vpc-peering-connection.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.7", + "Description": "Ensure that the EC2 Metadata Service only allows IMDSv2", + "Checks": [ + "ec2_instance_imdsv2_enabled" + ], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "When enabling the Metadata Service on AWS EC2 instances, users have the option of using either Instance Metadata Service Version 1 (IMDSv1; a request/response method) or Instance Metadata Service Version 2 (IMDSv2; a session-oriented method).", + "RationaleStatement": "Instance metadata is data about your instance that you can use to configure or manage the running instance. Instance metadata is divided into [categories](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html), such as host name, events, and security groups. When enabling the Metadata Service on AWS EC2 instances, users have the option of using either Instance Metadata Service Version 1 (IMDSv1; a request/response method) or Instance Metadata Service Version 2 (IMDSv2; a session-oriented method). With IMDSv2, every request is now protected by session authentication. A session begins and ends a series of requests that software running on an EC2 instance uses to access the locally stored EC2 instance metadata and credentials. Allowing Version 1 of the service may open EC2 instances to Server-Side Request Forgery (SSRF) attacks, so Amazon recommends utilizing Version 2 for better instance security.", + "ImpactStatement": "", + "RemediationProcedure": "From Console: 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at [https://console.aws.amazon.com/ec2/](https://console.aws.amazon.com/ec2/). 2. In the left navigation panel, under the `INSTANCES` section, choose `Instances`. 3. Select the EC2 instance that you want to examine. 4. Choose `Actions > Instance Settings > Modify instance metadata options`. 5. Set `Instance metadata service` to `Enable`. 6. Set `IMDSv2` to `Required`. 7. Repeat steps 1-6 to perform the remediation process for other EC2 instances in all applicable AWS region(s). From Command Line: 1. Run the `describe-instances` command, applying the appropriate filters to list the IDs of all existing EC2 instances currently available in the selected region: ``` aws ec2 describe-instances --region --output table --query Reservations[*].Instances[*].InstanceId ``` 2. The command output should return a table with the requested instance IDs. 3. Run the `modify-instance-metadata-options` command with an instance ID obtained from the previous step to update the Instance Metadata Version: ``` aws ec2 modify-instance-metadata-options --instance-id --http-tokens required --region ``` 4. Repeat steps 1-3 to perform the remediation process for other EC2 instances in the same AWS region. 5. Change the region by updating `--region` and repeat the process for other regions.", + "AuditProcedure": "From Console: 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at https://console.aws.amazon.com/ec2/. 2. In the left navigation panel, under the `INSTANCES` section, choose `Instances`. 3. Select the EC2 instance that you want to examine. 4. Check the `IMDSv2` status, and ensure that it is set to `Required`. From Command Line: 1. Run the `describe-instances` command using appropriate filters to list the IDs of all existing EC2 instances currently available in the selected region: ``` aws ec2 describe-instances --region --output table --query Reservations[*].Instances[*].InstanceId ``` 2. The command output should return a table with the requested instance IDs. 3. Run the `describe-instances` command using the instance ID returned in the previous step and apply custom filtering to determine whether the selected instance is using IMDSv2: ``` aws ec2 describe-instances --region --instance-ids --query Reservations[*].Instances[*].MetadataOptions --output table ``` 4. Ensure that for all EC2 instances, `HttpTokens` is set to `required` and `State` is set to `applied`. 5. Repeat steps 3 and 4 to verify the other EC2 instances provisioned within the current region. 6. Repeat steps 1–5 to perform the audit process for other AWS regions.", + "AdditionalInformation": "", + "References": "https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/:https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.8", + "Description": "Ensure VPC Endpoints are used for access to AWS Services", + "Checks": [], + "Attributes": [ + { + "Section": "6 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Amazon VPCs use VPC endpoints (gateway or interface endpoints) for access to AWS services such as Amazon S3 and DynamoDB, so that traffic from workloads to AWS services stays on the Amazon private network instead of traversing the public internet. VPC endpoints provide private connectivity between VPCs and supported AWS services without requiring an internet gateway, NAT gateway, or public IP addresses.", + "RationaleStatement": "Accessing AWS services over the public internet increases exposure to network-level threats, relies on internet routing, and makes it harder to tightly control egress paths. Using VPC endpoints allows workloads to reach AWS services over the Amazon private network, which reduces reliance on internet gateways and NAT gateways, simplifies egress filtering, and helps enforce data-perimeter and \"private-only\" patterns for sensitive workloads.", + "ImpactStatement": "Enforcing the use of VPC endpoints may require changes to existing network architectures, including creating and managing endpoints in each VPC, updating route tables, adjusting security groups, and potentially removing or tightening some internet/NAT gateway paths. This can introduce additional operational overhead and cost (per-endpoint charges for interface endpoints) and may require updates to IaC templates and deployment pipelines.", + "RemediationProcedure": "In this example, we are going to add S3 gateway endpoint and SQS interface endpoint to a VPC. You can follow similar remediation instructions for other services. 1. Create S3 Gateway Endpoint ``` aws ec2 create-vpc-endpoint --region REGION --route-table-ids ROUTE_TABLE_ID --vpc-id VPC_ID --service-name com.amazonaws.REGION.s3 --vpc-endpoint-type Gateway --query \"VpcEndpoint.VpcEndpointId\" --output text ``` - Provide values for REGION, ROUTE_TABLE_ID, VPC_ID - AWS automatically creates the routes for the AWS service in the route table provided as part of above command. 2. Verify that the gateway routes have been adequately created ``` aws ec2 describe-route-tables --region REGION --route-table-ids ROUTE_TABLE_ID --query \"RouteTables[0].Routes[?DestinationPrefixListId=='pl-xxxxxxxx']\" ``` - Provide values for REGION, ROUTE_TABLE_ID - pl-xxxxxxxx: replace with the specific prefix list for S3 in that region 3. Create an SQS Interface Endpoint ``` aws ec2 create-vpc-endpoint --vpc-id VPC_ID --service-name com.amazonaws.REGION.sqs --vpc-endpoint-type Interface --subnet-ids PRIVATE_SUBNET_1_ID PRIVATE_SUBNET_2_ID --security-group-ids SECURITY_GROUP_ID --vpc-endpoint-policy VPC_ENDPOINT_POLICY --query \"VpcEndpoint.VpcEndpointId\" --output text ``` - SECURITY_GROUP_ID: Update security groups for interface endpoint. Ensure the interface endpoint security group allows inbound traffic from your workloads. - VPC_ENDPOINT_POLICY: Create a restrictive Endpoint policy to ensure only certain AWS services could be reached and only specific actions can be performed. - AWS automatically creates Elastic Network Interfaces (ENIs) for the interface endpoint which allows any traffic from PRIVATE_SUBNET_1_ID PRIVATE_SUBNET_2_ID intended for SQS to be routed through the Interface Gateway. 4. Test and validate endpoint connectivity from an EC2 instance in a private subnet: - Test S3 (gateway endpoint) ``` aws s3 ls s3://your-test-bucket --region REGION ``` - Test SQS (interface endpoint) ``` aws sqs list-queues --region REGION ```", + "AuditProcedure": "1. Identify in-scope VPCs and services. - Determine which VPCs host production or sensitive workloads that should access AWS services securely via endpoints. - For those VPCs, identify the AWS services they depend on (for example, S3 for data storage, DynamoDB for database, etc.). 2. For each in-scope VPC, check for existing VPC endpoints. ``` aws ec2 describe-vpc-endpoints --region REGION --filters \"Name=vpc-id,Values=VPC_ID\" --query \"VpcEndpoints[*].[VpcEndpointId,VpcEndpointType,ServiceName,State]\" --output table ``` - Provide the REGION and VPC_ID - `VpcEndpointType` tells you whether the endpoint is Gateway or Interface. - `ServiceName` shows which AWS service the endpoint is for (for example, com.amazonaws.us-east-1.s3, com.amazonaws.us-east-1.dynamodb, com.amazonaws.us-east-1.ssm). 3. For each interface endpoint, verify subnet attachment across relevant AZs/subnets. ``` aws ec2 describe-vpc-endpoints --region REGION --vpc-endpoint-ids INTERFACE_ENDPOINT_ID --query \"VpcEndpoints[*].[VpcEndpointId,ServiceName,SubnetIds,State]\" --output json ``` - Provide the REGION and INTERFACE_ENDPOINT_ID 4. For each gateway endpoint, verify that the route tables for the relevant subnets send traffic to the endpoint (via the AWS-managed prefix list), not via internet/NAT gateways. - Identify relevant subnets in the VPC that need to have a route to gateway endpoint: ``` aws ec2 describe-subnets --region REGION --filters \"Name=vpc-id,Values=,VPC_ID\" --query \"Subnets[*].[SubnetId,AvailabilityZone,MapPublicIpOnLaunch,CidrBlock]\" --output table ``` - Provide the REGION and VPC_ID - For each relevant subnet, identify the route table associated with it: ``` aws ec2 describe-route-tables --region REGION --filters \"Name=association.subnet-id,Values=SUBNET_ID\" --query \"RouteTables[*].RouteTableId\" --output text ``` - Provide the REGION and SUBNET_ID - For each route table associated with relevant subnets, inspect routes: ``` aws ec2 describe-route-tables --region REGION --route-table-ids ROUTE_TABLE_ID --query \"RouteTables[0].Routes[*].[DestinationPrefixListId,GatewayId,NatGatewayId,State]\" --output table ``` - Provide the REGION and ROUTE_TABLE_ID For S3/DynamoDB gateway endpoints, you should see a `DestinationPrefixListId` (for example, pl-xxxxxxxx) with `GatewayId` equal to the endpoint (vpce-xxxx). If S3/DynamoDB are used by workloads in those subnets but traffic is only routed via igw-xxxx or nat-xxxx (and no prefix-list/endpoint route exists), then VPC endpoints are not being used for securing network traffic for these services.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + } + ] +} \ No newline at end of file diff --git a/prowler/compliance/aws/cisa_aws.json b/prowler/compliance/aws/cisa_aws.json index 6dc8edab68..27b06a1ea4 100644 --- a/prowler/compliance/aws/cisa_aws.json +++ b/prowler/compliance/aws/cisa_aws.json @@ -136,6 +136,20 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_securitygroup_allow_ingress_from_internet_to_all_ports" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -183,6 +197,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "elbv2_ssl_listeners", "iam_no_custom_policy_permissive_role_assumption", "iam_aws_attached_policy_no_administrative_privileges", @@ -365,6 +381,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/csa_ccm_4.0_aws.json b/prowler/compliance/aws/csa_ccm_4.0_aws.json deleted file mode 100644 index 6d0a019c2b..0000000000 --- a/prowler/compliance/aws/csa_ccm_4.0_aws.json +++ /dev/null @@ -1,7604 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "AWS", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled", - "config_recorder_all_regions_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "codebuild_project_source_repo_url_no_sensitive_credentials", - "codebuild_project_no_secrets_in_variables" - ] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_registry_scan_images_on_push_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "backup_plans_exist", - "backup_vaults_exist", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "dynamodb_tables_pitr_enabled", - "dynamodb_table_protected_by_backup_plan", - "ec2_ebs_volume_snapshots_exists", - "ec2_ebs_volume_protected_by_backup_plan", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_protected_by_backup_plan", - "rds_cluster_protected_by_backup_plan", - "redshift_cluster_automated_snapshot", - "s3_bucket_object_versioning", - "documentdb_cluster_backup_enabled", - "neptune_cluster_backup_enabled", - "elasticache_redis_cluster_backup_enabled", - "fsx_file_system_copy_tags_to_backups_enabled", - "lightsail_instance_automated_snapshots", - "dlm_ebs_snapshot_lifecycle_policy_exists" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [ - "drs_job_exist", - "ssmincidents_enabled_with_plans" - ] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_multi_az", - "rds_cluster_multi_az", - "elbv2_is_in_multiple_az", - "elb_is_in_multiple_az", - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types", - "ec2_ebs_volume_protected_by_backup_plan", - "elasticache_redis_cluster_multi_az_enabled", - "elasticache_redis_cluster_automatic_failover_enabled", - "opensearch_service_domains_fault_tolerant_data_nodes", - "opensearch_service_domains_fault_tolerant_master_nodes", - "dynamodb_accelerator_cluster_multi_az", - "documentdb_cluster_multi_az_enabled", - "neptune_cluster_multi_az", - "efs_multi_az_enabled", - "vpc_vpn_connection_tunnels_up", - "directconnect_connection_redundancy", - "directconnect_virtual_interface_redundancy", - "networkfirewall_multi_az", - "vpc_endpoint_multi_az_enabled", - "awslambda_function_vpc_multi_az", - "redshift_cluster_multi_az_enabled" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_log_file_validation_enabled", - "s3_bucket_object_lock", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "servicecatalog_portfolio_shared_within_organization_only" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "guardduty_is_enabled", - "securityhub_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_volume_encryption", - "ec2_ebs_default_encryption", - "ec2_ebs_snapshots_encrypted", - "s3_bucket_default_encryption", - "s3_bucket_kms_encryption", - "s3_bucket_secure_transport_policy", - "rds_instance_storage_encrypted", - "rds_cluster_storage_encrypted", - "rds_instance_transport_encrypted", - "rds_snapshots_encrypted", - "efs_encryption_at_rest_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_accelerator_cluster_in_transit_encryption_enabled", - "kinesis_stream_encrypted_at_rest", - "firehose_stream_encrypted_at_rest", - "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queues_server_side_encryption_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "opensearch_service_domains_encryption_at_rest_enabled", - "opensearch_service_domains_node_to_node_encryption_enabled", - "opensearch_service_domains_https_communications_enforced", - "redshift_cluster_encrypted_at_rest", - "redshift_cluster_in_transit_encryption_enabled", - "documentdb_cluster_storage_encrypted", - "neptune_cluster_storage_encrypted", - "neptune_cluster_snapshot_encrypted", - "elasticache_redis_cluster_rest_encryption_enabled", - "elasticache_redis_cluster_in_transit_encryption_enabled", - "kafka_cluster_in_transit_encryption_enabled", - "kafka_cluster_encryption_at_rest_uses_cmk", - "kafka_connector_in_transit_encryption_enabled", - "dms_endpoint_ssl_enabled", - "dms_endpoint_redis_in_transit_encryption_enabled", - "elb_ssl_listeners", - "elbv2_ssl_listeners", - "elbv2_insecure_ssl_ciphers", - "elbv2_nlb_tls_termination_enabled", - "cloudfront_distributions_https_enabled", - "cloudfront_distributions_origin_traffic_encrypted", - "cloudfront_distributions_custom_ssl_certificate", - "transfer_server_in_transit_encryption_enabled", - "sagemaker_notebook_instance_encryption_enabled", - "sagemaker_training_jobs_volume_and_output_encryption_enabled", - "workspaces_volume_encryption_enabled", - "storagegateway_fileshare_encryption_enabled", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "athena_workgroup_encryption", - "glue_data_catalogs_connection_passwords_encryption_enabled", - "glue_data_catalogs_metadata_encryption_enabled", - "glue_etl_jobs_amazon_s3_encryption_enabled", - "glue_etl_jobs_cloudwatch_logs_encryption_enabled", - "glue_etl_jobs_job_bookmark_encryption_enabled", - "glue_development_endpoints_s3_encryption_enabled", - "glue_development_endpoints_cloudwatch_logs_encryption_enabled", - "glue_development_endpoints_job_bookmark_encryption_enabled", - "glue_ml_transform_encrypted_at_rest", - "bedrock_model_invocation_logs_encryption_enabled", - "codebuild_project_s3_logs_encrypted", - "codebuild_report_group_export_encrypted" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "acm_certificates_with_secure_key_algorithms", - "elb_insecure_ssl_ciphers", - "elbv2_insecure_ssl_ciphers", - "cloudfront_distributions_using_deprecated_ssl_protocols" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_are_used", - "s3_bucket_kms_encryption", - "dynamodb_tables_kms_cmk_encryption_enabled", - "kms_key_not_publicly_accessible", - "kms_cmk_not_multi_region" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_are_used" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled", - "iam_rotate_access_key_90_days", - "secretsmanager_automatic_rotation_enabled", - "secretsmanager_secret_rotated_periodically" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_not_deleted_unintentionally" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "resourceexplorer2_indexes_found" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_lifecycle_enabled", - "dlm_ebs_snapshot_lifecycle_policy_exists", - "ecr_repositories_lifecycle_policy_enabled" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled", - "config_recorder_all_regions_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled" - ] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_default_encryption", - "s3_account_level_public_access_blocks", - "s3_bucket_level_public_access_block", - "ec2_ebs_snapshot_account_block_public_access", - "rds_instance_no_public_access", - "rds_snapshots_public_access" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_secure_transport_policy", - "opensearch_service_domains_https_communications_enforced", - "redshift_cluster_in_transit_encryption_enabled", - "rds_instance_transport_encrypted", - "transfer_server_in_transit_encryption_enabled", - "kafka_cluster_mutual_tls_authentication_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_lifecycle_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "kinesis_stream_data_retention_period", - "ecr_repositories_lifecycle_policy_enabled" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "s3_account_level_public_access_blocks", - "s3_bucket_public_access", - "s3_bucket_policy_public_write_access", - "ec2_ebs_public_snapshot", - "rds_snapshots_public_access", - "rds_instance_no_public_access", - "ec2_ebs_volume_encryption", - "s3_bucket_default_encryption", - "rds_instance_storage_encrypted", - "secretsmanager_not_publicly_accessible", - "macie_is_enabled" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled", - "guardduty_is_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "iam_password_policy_minimum_length_14", - "iam_password_policy_lowercase", - "iam_password_policy_uppercase", - "iam_password_policy_number", - "iam_password_policy_symbol", - "iam_password_policy_reuse_24", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "cognito_user_pool_password_policy_minimum_length_14", - "cognito_user_pool_password_policy_lowercase", - "cognito_user_pool_password_policy_uppercase", - "cognito_user_pool_password_policy_number", - "cognito_user_pool_password_policy_symbol" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_user_two_active_access_key" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_policy_attached_only_to_group_or_roles", - "iam_securityaudit_role_created", - "iam_support_role_created" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "iam_customer_unattached_policy_no_administrative_privileges", - "iam_policy_allows_privilege_escalation", - "iam_inline_policy_allows_privilege_escalation", - "iam_no_custom_policy_permissive_role_assumption", - "iam_role_administratoraccess_policy", - "iam_user_administrator_access_policy", - "iam_group_administrator_access_policy", - "iam_administrator_access_with_mfa" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_user_no_setup_initial_access_key" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_rotate_access_key_90_days", - "secretsmanager_secret_unused" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "iam_policy_attached_only_to_group_or_roles", - "iam_role_administratoraccess_policy", - "iam_avoid_root_usage", - "iam_no_root_access_key" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_avoid_root_usage", - "iam_no_root_access_key", - "iam_role_cross_account_readonlyaccess_policy", - "iam_role_cross_service_confused_deputy_prevention", - "iam_inline_policy_allows_privilege_escalation", - "iam_policy_allows_privilege_escalation" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_bucket_requires_mfa_delete" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_user_mfa_enabled_console_access", - "iam_check_saml_providers_sts" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_user_hardware_mfa_enabled", - "cognito_user_pool_mfa_enabled" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "iam_password_policy_minimum_length_14", - "iam_password_policy_reuse_24", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "cognito_user_pool_password_policy_minimum_length_14", - "cognito_user_pool_temporary_password_expiration" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "apigateway_restapi_authorizers_enabled", - "apigatewayv2_api_authorizers_enabled", - "awslambda_function_not_publicly_accessible", - "awslambda_function_url_public", - "cognito_user_pool_waf_acl_attached" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_secure_transport_policy" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "vpc_flow_logs_enabled", - "ec2_securitygroup_default_restrict_traffic", - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "ec2_securitygroup_allow_ingress_from_internet_to_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", - "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", - "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_tcp_port_22", - "ec2_networkacl_allow_ingress_tcp_port_3389", - "ec2_securitygroup_allow_wide_open_public_ipv4", - "vpc_peering_routing_tables_with_least_privilege", - "vpc_subnet_no_public_ip_by_default" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "ec2_instance_imdsv2_enabled", - "ec2_instance_account_imdsv2_enabled", - "ec2_launch_template_imdsv2_required", - "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "ec2_securitygroup_default_restrict_traffic", - "vpc_subnet_separate_private_public", - "vpc_peering_routing_tables_with_least_privilege", - "ec2_instance_public_ip", - "awslambda_function_inside_vpc", - "sagemaker_notebook_instance_vpc_settings_configured", - "sagemaker_models_vpc_settings_configured", - "sagemaker_training_jobs_vpc_settings_configured" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "dms_endpoint_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "networkfirewall_in_all_vpc", - "networkfirewall_logging_enabled", - "networkfirewall_policy_rule_group_associated", - "wafv2_webacl_with_rules", - "wafv2_webacl_logging_enabled", - "elbv2_waf_acl_attached", - "cloudfront_distributions_using_waf", - "guardduty_is_enabled", - "shield_advanced_protection_in_cloudfront_distributions", - "shield_advanced_protection_in_internet_facing_load_balancers" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_bucket_requires_mfa_delete", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible", - "s3_bucket_object_lock" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "securityhub_enabled", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured", - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudwatch_log_group_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "guardduty_no_high_severity_findings" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled_logging_management_events", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "vpc_flow_logs_enabled", - "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "vpc_flow_logs_enabled", - "s3_bucket_server_access_logging_enabled", - "elb_logging_enabled", - "elbv2_logging_enabled", - "cloudfront_distributions_logging_enabled", - "route53_public_hosted_zones_cloudwatch_logging_enabled", - "wafv2_webacl_logging_enabled", - "redshift_cluster_audit_logging", - "rds_cluster_integration_cloudwatch_logs", - "rds_instance_integration_cloudwatch_logs", - "opensearch_service_domains_audit_logging_enabled", - "eks_control_plane_logging_all_types_enabled", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled", - "networkfirewall_logging_enabled", - "mq_broker_logging_enabled", - "documentdb_cluster_cloudwatch_log_export", - "neptune_cluster_integration_cloudwatch_logs", - "codebuild_project_logging_enabled", - "glue_etl_jobs_logging_enabled", - "stepfunctions_statemachine_logging_enabled", - "datasync_task_logging_enabled", - "ec2_client_vpn_endpoint_connection_logging_enabled", - "elasticbeanstalk_environment_cloudwatch_logging_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled", - "acm_certificates_expiration_check" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled_logging_management_events" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "guardduty_no_high_severity_findings", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [ - "ssmincidents_enabled_with_plans" - ] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "securityhub_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "account_maintain_current_contact_details", - "account_security_contact_information_is_registered", - "account_maintain_different_contact_details_to_security_billing_and_operations" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_ec2_malware_protection_enabled" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "ssm_managed_compliant_patching", - "rds_instance_minor_version_upgrade_enabled", - "rds_cluster_minor_version_upgrade_enabled", - "redshift_cluster_automatic_upgrades", - "elasticbeanstalk_environment_managed_updates_enabled", - "dms_instance_minor_version_upgrade_enabled", - "elasticache_redis_cluster_auto_minor_version_upgrades", - "memorydb_cluster_auto_minor_version_upgrades", - "mq_broker_auto_minor_version_upgrades", - "opensearch_service_domains_updated_to_the_latest_service_software_version", - "kafka_cluster_uses_latest_version" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "inspector2_is_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_registry_scan_images_on_push_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "guardduty_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_volume_encryption", - "ec2_ebs_default_encryption", - "workspaces_volume_encryption_enabled" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled" - ] - } - ] -} diff --git a/prowler/compliance/aws/ens_rd2022_aws.json b/prowler/compliance/aws/ens_rd2022_aws.json index 1d25aeb3b3..144437ce52 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -542,6 +542,9 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -595,6 +598,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -621,6 +632,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -752,6 +771,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -778,6 +805,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -910,6 +945,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -937,6 +980,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -963,6 +1014,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1740,6 +1799,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1818,6 +1885,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1870,6 +1945,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1922,6 +2005,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1948,6 +2039,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1974,6 +2073,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2000,6 +2107,14 @@ ], "Checks": [ "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2053,6 +2168,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2079,6 +2202,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2363,7 +2494,10 @@ } ], "Checks": [ - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { @@ -2386,7 +2520,10 @@ } ], "Checks": [ - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { @@ -2536,8 +2673,7 @@ } ], "Checks": [ - "vpc_subnet_separate_private_public", - "vpc_different_regions" + "vpc_subnet_separate_private_public" ] }, { @@ -2590,8 +2726,8 @@ } ], "Checks": [ - "vpc_subnet_different_az", - "vpc_different_regions" + "vpc_different_regions", + "vpc_subnet_different_az" ] }, { @@ -4259,6 +4395,29 @@ ], "Checks": [] }, + { + "Id": "op.cont.2.aws.vpc.1", + "Description": "Plan de continuidad", + "Attributes": [ + { + "IdGrupoControl": "op.cont.2", + "Marco": "operacional", + "Categoria": "continuidad del servicio", + "DescripcionControl": "Distribución de las VPCs entre múltiples regiones y zonas de disponibilidad de AWS para garantizar la continuidad del servicio ante fallos regionales o zonales.", + "Nivel": "alto", + "Tipo": "requisito", + "Dimensiones": [ + "disponibilidad" + ], + "ModoEjecucion": "automático", + "Dependencias": [] + } + ], + "Checks": [ + "vpc_different_regions", + "vpc_subnet_different_az" + ] + }, { "Id": "op.cont.3.aws.drs.1", "Description": "Pruebas periódicas", @@ -4279,6 +4438,14 @@ ], "Checks": [ "drs_job_exist" + ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json index 60afe9d4fd..15763ef48e 100644 --- a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json +++ b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json @@ -37,6 +37,14 @@ "ssm_managed_compliant_patching", "ssm_managed_instance_compliance_association_compliant", "ssm_managed_instance_compliance_patch_compliant" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -86,6 +94,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "iam_administrator_access_with_mfa", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", @@ -105,6 +115,9 @@ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_user_hardware_mfa_enabled", @@ -141,6 +154,20 @@ "inspector2_active_findings_exist", "securityhub_enabled", "sns_topics_kms_encryption_at_rest_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -200,6 +227,14 @@ "resourceexplorer_indexes_found", "ssm_managed_instance_compliance_association_compliant", "trustedadvisor_premium_support_plan_subscribed" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -319,6 +354,9 @@ "iam_no_root_access_key", "iam_policy_attached_only_to_group_or_roles", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "organizations_delegated_administrators" @@ -341,6 +379,14 @@ "config_recorder_all_regions_enabled", "inspector2_is_enabled", "resourceexplorer_indexes_found" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] } ] diff --git a/prowler/compliance/aws/fedramp_low_revision_4_aws.json b/prowler/compliance/aws/fedramp_low_revision_4_aws.json index e06a2a75ad..059de69675 100644 --- a/prowler/compliance/aws/fedramp_low_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_low_revision_4_aws.json @@ -37,12 +37,29 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_hardware_mfa_enabled", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -112,6 +129,20 @@ "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_networkacl_allow_ingress_any_port" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -170,6 +201,14 @@ ], "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 90 + } ] }, { @@ -195,6 +234,20 @@ "rds_instance_enhanced_monitoring_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -248,6 +301,14 @@ "guardduty_is_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -333,6 +394,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -370,6 +445,14 @@ "rds_instance_multi_az", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index 7ca5160ff3..eaa3ea25dc 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -30,9 +30,26 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -62,6 +79,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -79,6 +110,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -105,6 +150,9 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -134,6 +182,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -159,6 +221,9 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -177,8 +242,43 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -300,6 +400,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "ec2_ebs_public_snapshot", "ec2_instance_public_ip", "ec2_instance_imdsv2_enabled", @@ -308,6 +410,9 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -354,6 +459,20 @@ "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_networkacl_allow_ingress_any_port" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -422,6 +541,7 @@ "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "cloudtrail_multi_region_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -489,6 +609,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -557,6 +691,14 @@ ], "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 90 + } ] }, { @@ -577,6 +719,7 @@ "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "cloudtrail_multi_region_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -612,6 +755,20 @@ "rds_instance_enhanced_monitoring_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -701,6 +858,14 @@ "guardduty_is_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -868,6 +1033,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -890,6 +1069,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -908,6 +1101,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -926,6 +1133,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -942,6 +1163,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -976,6 +1205,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1042,6 +1285,14 @@ "guardduty_is_enabled", "rds_instance_multi_az", "s3_bucket_object_versioning" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1126,6 +1377,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" @@ -1145,6 +1399,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" @@ -1260,6 +1517,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1282,6 +1547,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1309,6 +1588,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1336,6 +1629,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1363,6 +1670,20 @@ "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1389,6 +1710,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/ffiec_aws.json b/prowler/compliance/aws/ffiec_aws.json index d1bb22f4cc..8a50b79925 100644 --- a/prowler/compliance/aws/ffiec_aws.json +++ b/prowler/compliance/aws/ffiec_aws.json @@ -37,6 +37,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -74,6 +82,20 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -119,6 +141,7 @@ ], "Checks": [ "apigateway_restapi_logging_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", @@ -147,6 +170,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -165,6 +202,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -182,6 +233,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -236,6 +301,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -253,6 +332,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -366,6 +459,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -385,6 +486,20 @@ "guardduty_is_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -403,6 +518,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -418,6 +547,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "ec2_instance_profile_attached", "iam_policy_attached_only_to_group_or_roles", "iam_aws_attached_policy_no_administrative_privileges", @@ -484,6 +615,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "s3_bucket_secure_transport_policy" ] @@ -820,6 +954,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -865,6 +1013,20 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/gdpr_aws.json b/prowler/compliance/aws/gdpr_aws.json index 930af1db58..a97a11e3dc 100644 --- a/prowler/compliance/aws/gdpr_aws.json +++ b/prowler/compliance/aws/gdpr_aws.json @@ -59,6 +59,14 @@ "cloudwatch_log_metric_filter_security_group_changes", "cloudwatch_log_metric_filter_unauthorized_api_calls", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -85,6 +93,14 @@ "kms_cmk_rotation_enabled", "redshift_cluster_audit_logging", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json index 39534c4fdc..871af9e726 100644 --- a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json +++ b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json @@ -266,6 +266,9 @@ "ec2_ebs_default_encryption", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_encryption_at_rest_enabled", "opensearch_service_domains_node_to_node_encryption_enabled", @@ -347,6 +350,20 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] } ] diff --git a/prowler/compliance/aws/gxp_eu_annex_11_aws.json b/prowler/compliance/aws/gxp_eu_annex_11_aws.json index 1ceb3f817b..fdca6d1747 100644 --- a/prowler/compliance/aws/gxp_eu_annex_11_aws.json +++ b/prowler/compliance/aws/gxp_eu_annex_11_aws.json @@ -19,6 +19,14 @@ "Checks": [ "cloudtrail_multi_region_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -146,6 +154,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -238,6 +254,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -253,6 +277,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/hipaa_aws.json b/prowler/compliance/aws/hipaa_aws.json index 79d8b16f53..9eb243e6cc 100644 --- a/prowler/compliance/aws/hipaa_aws.json +++ b/prowler/compliance/aws/hipaa_aws.json @@ -19,6 +19,20 @@ "Checks": [ "config_recorder_all_regions_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -87,6 +101,7 @@ ], "Checks": [ "apigateway_restapi_logging_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", @@ -101,6 +116,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -160,6 +189,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -265,6 +308,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges" @@ -325,6 +370,20 @@ "guardduty_is_enabled", "cloudwatch_log_metric_filter_authentication_failures", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -370,6 +429,20 @@ "cloudwatch_log_metric_filter_authentication_failures", "cloudwatch_log_metric_filter_root_usage", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -399,6 +472,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -511,6 +598,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -630,6 +731,7 @@ ], "Checks": [ "apigateway_restapi_logging_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", @@ -645,6 +747,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -752,6 +868,20 @@ "s3_bucket_secure_transport_policy", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/iso27001_2013_aws.json b/prowler/compliance/aws/iso27001_2013_aws.json index a817ec22bd..1de8c23db8 100644 --- a/prowler/compliance/aws/iso27001_2013_aws.json +++ b/prowler/compliance/aws/iso27001_2013_aws.json @@ -35,7 +35,10 @@ ], "Checks": [ "elb_insecure_ssl_ciphers", - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { @@ -308,6 +311,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -867,8 +878,43 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -1046,6 +1092,20 @@ "Checks": [ "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -1255,6 +1315,20 @@ "Checks": [ "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { diff --git a/prowler/compliance/aws/iso27001_2022_aws.json b/prowler/compliance/aws/iso27001_2022_aws.json index 3dbac3482c..563b856317 100644 --- a/prowler/compliance/aws/iso27001_2022_aws.json +++ b/prowler/compliance/aws/iso27001_2022_aws.json @@ -20,6 +20,14 @@ "Checks": [ "securityhub_enabled", "wellarchitected_workload_no_high_or_medium_risks" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -245,6 +253,9 @@ "iam_policy_attached_only_to_group_or_roles", "iam_user_mfa_enabled_console_access", "iam_root_mfa_enabled", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_rotate_access_key_90_days", "iam_user_accesskey_unused", "iam_user_console_access_unused", @@ -274,6 +285,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -306,6 +325,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges" @@ -326,6 +347,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -357,6 +386,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -373,6 +410,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -419,6 +464,14 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -467,6 +520,14 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -485,6 +546,14 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "guardduty_centrally_managed" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -970,6 +1039,9 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", + "bedrock_full_access_policy_attached", "ec2_instance_profile_attached", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", @@ -996,6 +1068,14 @@ "organizations_account_part_of_organizations", "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1072,6 +1152,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1103,6 +1191,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1286,6 +1382,7 @@ "bedrock_model_invocation_logging_enabled", "bedrock_model_invocation_logs_encryption_enabled", "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_kms_encryption_enabled", "cloudtrail_log_file_validation_enabled", @@ -1454,6 +1551,7 @@ "Checks": [ "awslambda_function_inside_vpc", "awslambda_function_vpc_multi_az", + "bedrock_vpc_endpoints_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "ec2_transitgateway_auto_accept_vpc_attachments", "networkfirewall_in_all_vpc", @@ -1548,6 +1646,7 @@ "Checks": [ "awslambda_function_inside_vpc", "awslambda_function_vpc_multi_az", + "bedrock_vpc_endpoints_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "ec2_transitgateway_auto_accept_vpc_attachments", "networkfirewall_in_all_vpc", @@ -1642,6 +1741,7 @@ "Checks": [ "awslambda_function_inside_vpc", "awslambda_function_vpc_multi_az", + "bedrock_vpc_endpoints_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "ec2_transitgateway_auto_accept_vpc_attachments", "networkfirewall_in_all_vpc", @@ -1737,6 +1837,14 @@ "vpc_default_security_group_closed", "vpc_flow_logs_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1757,6 +1865,7 @@ "backup_recovery_point_encrypted", "backup_vaults_encrypted", "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_encrypted_with_cmk", "cloudfront_distributions_field_level_encryption_enabled", "cloudfront_distributions_origin_traffic_encrypted", "cloudtrail_kms_encryption_enabled", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json index 5c7d31e82e..7b0446ac3f 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json @@ -1211,6 +1211,14 @@ "rds_instance_default_admin", "redshift_cluster_non_default_database_name" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -1389,6 +1397,9 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", + "bedrock_full_access_policy_attached", "iam_administrator_access_with_mfa", "iam_avoid_root_usage", "iam_aws_attached_policy_no_administrative_privileges", @@ -1413,6 +1424,14 @@ "iam_user_administrator_access_policy", "organizations_delegated_administrators" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -1483,6 +1502,14 @@ "ssm_documents_set_as_public", "vpc_endpoint_services_allowed_principals_trust_boundaries" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -1614,6 +1641,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "sagemaker_training_jobs_network_isolation_enabled", "sagemaker_training_jobs_vpc_settings_configured", + "bedrock_vpc_endpoints_configured", "vpc_endpoint_connections_trust_boundaries", "vpc_endpoint_for_ec2_enabled", "vpc_peering_routing_tables_with_least_privilege", @@ -2036,6 +2064,9 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_nlb_tls_termination_enabled", "elbv2_ssl_listeners", "glue_data_catalogs_connection_passwords_encryption_enabled", @@ -2075,6 +2106,17 @@ "transfer_server_in_transit_encryption_enabled", "workspaces_volume_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -2111,6 +2153,7 @@ "Checks": [ "backup_vaults_encrypted", "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_encrypted_with_cmk", "cloudtrail_kms_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", @@ -2535,6 +2578,7 @@ "bedrock_model_invocation_logging_enabled", "bedrock_model_invocation_logs_encryption_enabled", "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_bucket_requires_mfa_delete", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_insights_exist", @@ -2810,6 +2854,20 @@ "wafv2_webacl_rule_logging_enabled", "wafv2_webacl_with_rules" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -2890,8 +2948,10 @@ "bedrock_agent_guardrail_enabled", "bedrock_guardrail_prompt_attack_filter_enabled", "bedrock_guardrail_sensitive_information_filter_enabled", + "bedrock_guardrails_configured", "bedrock_model_invocation_logging_enabled", "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_management_exists", "cloudformation_stack_outputs_find_secrets", "cloudfront_distributions_custom_ssl_certificate", "cloudfront_distributions_default_root_object", @@ -3082,6 +3142,9 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_internet_facing", "elbv2_listeners_underneath", "elbv2_logging_enabled", @@ -3305,6 +3368,47 @@ "workspaces_volume_encryption_enabled", "workspaces_vpc_2private_1public_subnets_nat" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -3697,6 +3801,14 @@ "s3_bucket_event_notifications_enabled", "trustedadvisor_errors_and_warnings" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -3815,6 +3927,14 @@ "s3_bucket_object_lock", "s3_bucket_object_versioning" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", @@ -3852,6 +3972,14 @@ "Checks": [ "drs_job_exist" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. Control Measures Requirements", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json index b539fc9073..40b338ce41 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json @@ -1211,6 +1211,14 @@ "rds_instance_default_admin", "redshift_cluster_non_default_database_name" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -1389,6 +1397,9 @@ "Checks": [ "accessanalyzer_enabled", "accessanalyzer_enabled_without_findings", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", + "bedrock_full_access_policy_attached", "iam_administrator_access_with_mfa", "iam_avoid_root_usage", "iam_aws_attached_policy_no_administrative_privileges", @@ -1413,6 +1424,14 @@ "iam_user_administrator_access_policy", "organizations_delegated_administrators" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -1482,6 +1501,14 @@ "ssm_documents_set_as_public", "vpc_endpoint_services_allowed_principals_trust_boundaries" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -1613,6 +1640,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "sagemaker_training_jobs_network_isolation_enabled", "sagemaker_training_jobs_vpc_settings_configured", + "bedrock_vpc_endpoints_configured", "vpc_endpoint_connections_trust_boundaries", "vpc_endpoint_for_ec2_enabled", "vpc_peering_routing_tables_with_least_privilege", @@ -2038,6 +2066,9 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_nlb_tls_termination_enabled", "elbv2_ssl_listeners", "glue_data_catalogs_connection_passwords_encryption_enabled", @@ -2077,6 +2108,17 @@ "transfer_server_in_transit_encryption_enabled", "workspaces_volume_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -2113,6 +2155,7 @@ "Checks": [ "backup_vaults_encrypted", "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_encrypted_with_cmk", "cloudtrail_kms_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", @@ -2814,6 +2857,20 @@ "wafv2_webacl_rule_logging_enabled", "wafv2_webacl_with_rules" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -2894,8 +2951,10 @@ "bedrock_agent_guardrail_enabled", "bedrock_guardrail_prompt_attack_filter_enabled", "bedrock_guardrail_sensitive_information_filter_enabled", + "bedrock_guardrails_configured", "bedrock_model_invocation_logging_enabled", "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_management_exists", "cloudformation_stack_outputs_find_secrets", "cloudfront_distributions_custom_ssl_certificate", "cloudfront_distributions_default_root_object", @@ -3086,6 +3145,9 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_internet_facing", "elbv2_listeners_underneath", "elbv2_logging_enabled", @@ -3309,6 +3371,47 @@ "workspaces_volume_encryption_enabled", "workspaces_vpc_2private_1public_subnets_nat" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -3701,6 +3804,14 @@ "s3_bucket_event_notifications_enabled", "trustedadvisor_errors_and_warnings" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -3819,6 +3930,14 @@ "s3_bucket_object_lock", "s3_bucket_object_versioning" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", @@ -3856,6 +3975,14 @@ "Checks": [ "drs_job_exist" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Domain": "2. 보호대책 요구사항", diff --git a/prowler/compliance/aws/mitre_attack_aws.json b/prowler/compliance/aws/mitre_attack_aws.json index e6bcad5870..3ac8cf0432 100644 --- a/prowler/compliance/aws/mitre_attack_aws.json +++ b/prowler/compliance/aws/mitre_attack_aws.json @@ -35,6 +35,32 @@ "awslambda_function_not_publicly_accessible", "ec2_instance_public_ip" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -169,6 +195,9 @@ "iam_customer_unattached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_expired_server_certificates_stored", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_no_root_access_key", @@ -197,6 +226,26 @@ "organizations_scp_check_deny_regions", "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "Amazon GuardDuty", @@ -345,6 +394,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -390,6 +447,26 @@ "guardduty_is_enabled", "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -441,6 +518,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -554,6 +639,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -631,6 +724,26 @@ "inspector2_is_enabled", "inspector2_active_findings_exist" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -818,6 +931,26 @@ "inspector2_is_enabled", "inspector2_active_findings_exist" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -981,6 +1114,14 @@ "cloudfront_distributions_https_enabled", "s3_bucket_secure_transport_policy" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudWatch", @@ -1054,6 +1195,14 @@ "ssm_document_secrets", "secretsmanager_automatic_rotation_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudHSM", @@ -1140,6 +1289,14 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Network Firewall", @@ -1215,6 +1372,14 @@ "s3_bucket_default_encryption", "rds_instance_storage_encrypted" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -1261,6 +1426,20 @@ "securityhub_enabled", "macie_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -1438,6 +1617,20 @@ "s3_bucket_object_versioning", "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1515,6 +1708,20 @@ "efs_have_backup_enabled", "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1563,6 +1770,20 @@ "drs_job_exist", "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1636,6 +1857,14 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Shield", @@ -1683,6 +1912,14 @@ "drs_job_exist", "rds_instance_backup_enabled" ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudEndure Disaster Recovery", @@ -1740,6 +1977,20 @@ "cloudwatch_log_metric_filter_sign_in_without_mfa", "cloudwatch_log_metric_filter_unauthorized_api_calls" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS CloudWatch", @@ -1816,6 +2067,20 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Config", @@ -1907,6 +2172,20 @@ "iam_policy_no_full_access_to_cloudtrail", "iam_policy_no_full_access_to_kms" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS Organizations", @@ -1990,6 +2269,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "Amazon GuardDuty", @@ -2068,6 +2355,14 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "AWSService": "AWS IoT Device Defender", diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json index dc6b75657f..3a4d567d25 100644 --- a/prowler/compliance/aws/nis2_aws.json +++ b/prowler/compliance/aws/nis2_aws.json @@ -597,6 +597,14 @@ "accessanalyzer_enabled", "cloudwatch_log_metric_filter_root_usage" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", @@ -1368,9 +1376,10 @@ "Id": "6.8.2.a", "Description": "consider the functional, logical and physical relationship, including location, between trustworthy systems and services;", "Checks": [ + "bedrock_vpc_endpoints_configured", "iam_role_cross_service_confused_deputy_prevention", - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "vpc_endpoint_connections_trust_boundaries" + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries" ], "Attributes": [ { @@ -1510,6 +1519,17 @@ "Checks": [ "acm_certificates_with_secure_key_algorithms" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", @@ -1527,6 +1547,17 @@ "route53_domains_privacy_protection_enabled", "iam_no_expired_server_certificates_stored" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", @@ -1644,6 +1675,14 @@ "efs_access_point_enforce_user_identity", "efs_not_publicly_accessible" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", @@ -1675,6 +1714,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", @@ -1725,6 +1772,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", @@ -1910,6 +1965,9 @@ "Id": "11.5.4", "Description": "The relevant entities shall regularly review the identities for network and information systems and their users and, if no longer needed, deactivate them without delay.", "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ], diff --git a/prowler/compliance/aws/nist_800_171_revision_2_aws.json b/prowler/compliance/aws/nist_800_171_revision_2_aws.json index f11f49635d..921bd33a53 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -30,6 +30,9 @@ "iam_no_root_access_key", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -72,6 +75,9 @@ "iam_no_root_access_key", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -152,10 +158,15 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -219,6 +230,20 @@ "rds_instance_integration_cloudwatch_logs", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -310,6 +335,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -333,6 +372,14 @@ "guardduty_is_enabled", "rds_instance_integration_cloudwatch_logs", "s3_bucket_server_access_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -372,6 +419,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -389,6 +450,20 @@ "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -579,6 +654,9 @@ "Checks": [ "iam_password_policy_reuse_24", "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -639,6 +717,9 @@ "apigateway_restapi_client_certificate_enabled", "ec2_ebs_volume_encryption", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy" @@ -670,6 +751,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -698,6 +793,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -715,6 +824,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -732,6 +855,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -755,6 +892,20 @@ "guardduty_is_enabled", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -792,6 +943,20 @@ "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", "ec2_networkacl_allow_ingress_any_port" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1011,6 +1176,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1030,6 +1209,20 @@ "securityhub_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1047,6 +1240,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1062,6 +1269,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1088,6 +1303,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1114,6 +1343,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] } ] diff --git a/prowler/compliance/aws/nist_800_53_revision_4_aws.json b/prowler/compliance/aws/nist_800_53_revision_4_aws.json index 45639cce62..8bd36a3910 100644 --- a/prowler/compliance/aws/nist_800_53_revision_4_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_4_aws.json @@ -21,9 +21,26 @@ "guardduty_is_enabled", "iam_password_policy_reuse_24", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -39,8 +56,43 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90 + } ] }, { @@ -67,6 +119,20 @@ "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -84,6 +150,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -110,12 +190,29 @@ "iam_root_mfa_enabled", "iam_no_root_access_key", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -222,6 +319,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "ec2_ebs_public_snapshot", "ec2_instance_public_ip", "ec2_instance_imdsv2_enabled", @@ -230,6 +329,9 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_url_public", @@ -256,6 +358,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -385,6 +501,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -407,6 +537,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -520,6 +664,20 @@ "guardduty_is_enabled", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -813,6 +971,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -846,6 +1012,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1096,6 +1276,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1119,6 +1307,20 @@ "ec2_instance_imdsv2_enabled", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1141,6 +1343,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1163,6 +1379,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1180,6 +1410,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1204,6 +1448,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/nist_800_53_revision_5_aws.json b/prowler/compliance/aws/nist_800_53_revision_5_aws.json index a83dc9f331..e0ef936229 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -29,6 +29,9 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -49,6 +52,9 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -68,6 +74,9 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -87,6 +96,9 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -106,6 +118,9 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -124,6 +139,9 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -202,6 +220,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -233,6 +259,9 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -269,6 +298,9 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -843,6 +875,9 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -917,6 +952,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1168,6 +1211,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "ec2_ebs_public_snapshot", "ec2_instance_public_ip", "ec2_instance_imdsv2_enabled", @@ -1177,6 +1222,9 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -1572,6 +1620,7 @@ "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "cloudtrail_multi_region_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1596,6 +1645,14 @@ "Checks": [ "cloudtrail_multi_region_enabled", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1795,6 +1852,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1873,6 +1944,20 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2130,6 +2215,7 @@ "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "cloudtrail_multi_region_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -2157,6 +2243,7 @@ "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "cloudtrail_multi_region_enabled", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -2255,6 +2342,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2317,6 +2418,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2352,6 +2467,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2431,6 +2560,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2452,6 +2595,20 @@ "guardduty_is_enabled", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2487,6 +2644,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -2869,6 +3040,14 @@ "guardduty_is_enabled", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4044,6 +4223,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4060,6 +4247,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4114,6 +4309,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4149,6 +4358,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4164,6 +4387,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4235,6 +4466,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4251,6 +4496,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4268,6 +4521,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4285,6 +4546,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4301,6 +4570,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4318,6 +4595,14 @@ "Checks": [ "guardduty_is_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4334,6 +4619,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4350,6 +4643,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4366,6 +4667,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4383,6 +4692,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4400,6 +4717,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4481,6 +4806,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4523,6 +4856,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4540,6 +4881,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4556,6 +4905,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -4572,6 +4929,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5227,6 +5592,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" @@ -5514,6 +5882,9 @@ ], "Checks": [ "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners" ] }, @@ -5642,6 +6013,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5809,6 +6188,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5849,6 +6236,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5866,6 +6261,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5883,6 +6286,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5899,6 +6310,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5915,6 +6334,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5947,6 +6374,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5971,6 +6406,14 @@ "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -5987,6 +6430,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6004,6 +6455,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6021,6 +6480,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6037,6 +6504,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6073,6 +6548,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6089,6 +6572,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6156,6 +6647,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6172,6 +6671,14 @@ ], "Checks": [ "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6192,6 +6699,14 @@ "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -6212,6 +6727,14 @@ "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/nist_csf_1.1_aws.json b/prowler/compliance/aws/nist_csf_1.1_aws.json index b7e5bbe6e5..9921efce56 100644 --- a/prowler/compliance/aws/nist_csf_1.1_aws.json +++ b/prowler/compliance/aws/nist_csf_1.1_aws.json @@ -48,6 +48,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -99,6 +113,20 @@ "guardduty_no_high_severity_findings", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -144,6 +172,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -179,6 +221,26 @@ "cloudwatch_log_metric_filter_unauthorized_api_calls", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -201,6 +263,20 @@ "guardduty_is_enabled", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -218,6 +294,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -243,6 +333,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -265,6 +369,20 @@ "guardduty_is_enabled", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -291,6 +409,20 @@ "s3_bucket_server_access_logging_enabled", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -316,6 +448,20 @@ "guardduty_is_enabled", "guardduty_no_high_severity_findings", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -349,6 +495,14 @@ "Checks": [ "config_recorder_all_regions_enabled", "ec2_instance_managed_by_ssm" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -454,6 +608,20 @@ "guardduty_is_enabled", "securityhub_enabled", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -471,6 +639,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -488,6 +670,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -523,6 +719,26 @@ "cloudwatch_log_metric_filter_unauthorized_api_calls", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -554,6 +770,26 @@ "cloudwatch_log_metric_filter_unauthorized_api_calls", "rds_instance_enhanced_monitoring_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -575,6 +811,9 @@ "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -628,10 +867,15 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -819,6 +1063,20 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "securityhub_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -873,6 +1131,14 @@ "Checks": [ "ec2_instance_managed_by_ssm", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1027,6 +1293,14 @@ "ec2_instance_managed_by_ssm", "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/nist_csf_2.0_aws.json b/prowler/compliance/aws/nist_csf_2.0_aws.json index 591e558bb1..c06eeec11d 100644 --- a/prowler/compliance/aws/nist_csf_2.0_aws.json +++ b/prowler/compliance/aws/nist_csf_2.0_aws.json @@ -72,6 +72,20 @@ "securityhub_enabled", "wellarchitected_workload_no_high_or_medium_risks", "servicecatalog_portfolio_shared_within_organization_only" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -322,6 +336,26 @@ "wellarchitected_workload_no_high_or_medium_risks", "organizations_delegated_administrators", "organizations_tags_policies_enabled_and_attached" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -352,6 +386,26 @@ "vpc_flow_logs_enabled", "iam_root_mfa_enabled", "iam_root_credentials_management_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -408,6 +462,20 @@ "accessanalyzer_enabled", "guardduty_no_high_severity_findings", "trustedadvisor_errors_and_warnings" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -442,6 +510,26 @@ "organizations_scp_check_deny_regions", "organizations_tags_policies_enabled_and_attached", "organizations_delegated_administrators" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -574,6 +662,14 @@ "opensearch_service_domains_encryption_at_rest_enabled", "redshift_cluster_encrypted_at_rest", "sns_topics_kms_encryption_at_rest_enabled" + ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -610,6 +706,17 @@ "iam_inline_policy_allows_privilege_escalation", "ssm_documents_set_as_public", "s3_bucket_shadow_resource_vulnerability" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -705,6 +812,9 @@ "iam_root_mfa_enabled", "iam_no_root_access_key", "iam_user_console_access_unused", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_two_active_access_key", "iam_root_credentials_management_enabled", @@ -723,6 +833,14 @@ "iam_role_administratoraccess_policy", "iam_policy_no_full_access_to_cloudtrail", "iam_policy_no_full_access_to_kms" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -774,6 +892,8 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", "iam_administrator_access_with_mfa", "iam_group_administrator_access_policy", "iam_user_administrator_access_policy", @@ -784,6 +904,7 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_customer_unattached_policy_no_administrative_privileges", "accessanalyzer_enabled_without_findings", + "bedrock_full_access_policy_attached", "eventbridge_bus_cross_account_access", "eventbridge_bus_exposed", "iam_policy_no_full_access_to_cloudtrail", @@ -805,6 +926,7 @@ "Checks": [ "vpc_subnet_different_az", "vpc_subnet_separate_private_public", + "bedrock_vpc_endpoints_configured", "vpc_endpoint_connections_trust_boundaries", "vpc_peering_routing_tables_with_least_privilege", "ec2_networkacl_unused", @@ -846,6 +968,14 @@ "iam_customer_unattached_policy_no_administrative_privileges", "accessanalyzer_enabled", "cognito_user_pool_password_policy_symbol" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -897,6 +1027,7 @@ "Checks": [ "backup_vaults_encrypted", "backup_recovery_point_encrypted", + "bedrock_prompt_encrypted_with_cmk", "cloudtrail_kms_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "s3_bucket_kms_encryption", @@ -1216,6 +1347,14 @@ "inspector2_active_findings_exist", "secretsmanager_automatic_rotation_enabled", "secretsmanager_secret_rotated_periodically" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1257,6 +1396,14 @@ "Checks": [ "ssmincidents_enabled_with_plans", "drs_job_exist" + ], + "ConfigRequirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1275,6 +1422,14 @@ "inspector2_is_enabled", "guardduty_is_enabled", "inspector2_active_findings_exist" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1304,6 +1459,7 @@ } ], "Checks": [ + "cloudtrail_bedrock_logging_enabled", "cloudtrail_kms_encryption_enabled", "cloudtrail_log_file_validation_enabled", "cloudtrail_logs_s3_bucket_access_logging_enabled", @@ -1320,6 +1476,14 @@ "vpc_flow_logs_enabled", "config_recorder_all_regions_enabled", "config_recorder_using_aws_service_role" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1467,6 +1631,7 @@ "cloudtrail_threat_detection_enumeration", "cloudtrail_threat_detection_privilege_escalation", "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_multi_region_enabled_logging_management_events" ] @@ -1530,6 +1695,14 @@ "guardduty_is_enabled", "inspector2_is_enabled", "accessanalyzer_enabled_without_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1563,6 +1736,7 @@ "cloudtrail_threat_detection_llm_jacking", "cloudtrail_threat_detection_enumeration", "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_metric_filter_unauthorized_api_calls", "cloudwatch_log_metric_filter_authentication_failures", @@ -1651,6 +1825,14 @@ "guardduty_rds_protection_enabled", "guardduty_lambda_protection_enabled", "guardduty_eks_runtime_monitoring_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/pci_3.2.1_aws.json b/prowler/compliance/aws/pci_3.2.1_aws.json index 649f32a94c..ca8e968bf9 100644 --- a/prowler/compliance/aws/pci_3.2.1_aws.json +++ b/prowler/compliance/aws/pci_3.2.1_aws.json @@ -276,6 +276,7 @@ "ec2_ebs_public_snapshot", "rds_instance_no_public_access", "eks_endpoints_not_publicly_accessible", + "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled", "s3_account_level_public_access_blocks", "awslambda_function_not_publicly_accessible", @@ -627,6 +628,14 @@ "ssm_managed_compliant_patching", "ec2_elastic_ip_unassigned" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "2.4", @@ -642,6 +651,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "2.4.a", @@ -1560,6 +1577,9 @@ "Name": "Observe user accounts to verify that any inactive accounts over 90 days old are either removed or disabled", "Description": "Accounts that are not used regularly are often targets of attack since it is less likely that any changes (such as a changed password) will be noticed. As such, these accounts may be more easily exploited and used to access cardholder data.", "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_password_policy_reuse_24", "iam_user_accesskey_unused", "iam_user_console_access_unused" @@ -2409,6 +2429,14 @@ "cloudtrail_log_file_validation_enabled", "s3_bucket_cross_region_replication" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "10.5", @@ -2426,6 +2454,14 @@ "s3_bucket_object_versioning", "cloudtrail_log_file_validation_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "10.5.2", @@ -2612,6 +2648,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4", @@ -2627,6 +2671,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4.a", @@ -2642,6 +2694,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4.b", @@ -2657,6 +2717,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.4.c", @@ -2672,6 +2740,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.5", @@ -2687,6 +2763,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.5.a", @@ -2702,6 +2786,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "ItemId": "11.5.b", diff --git a/prowler/compliance/aws/pci_4.0_aws.json b/prowler/compliance/aws/pci_4.0_aws.json index 8367bc4cb6..e21b543556 100644 --- a/prowler/compliance/aws/pci_4.0_aws.json +++ b/prowler/compliance/aws/pci_4.0_aws.json @@ -754,6 +754,7 @@ "Description": "Checks if Service Endpoint for the service provided in rule parameter is created for each Amazon Virtual Private Cloud (Amazon VPC)", "Name": "vpc", "Checks": [ + "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled" ], "Attributes": [ @@ -1454,6 +1455,7 @@ "Description": "Checks if Service Endpoint for the service provided in rule parameter is created for each Amazon Virtual Private Cloud (Amazon VPC)", "Name": "vpc", "Checks": [ + "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled" ], "Attributes": [ @@ -2168,6 +2170,7 @@ "Description": "Checks if Service Endpoint for the service provided in rule parameter is created for each Amazon Virtual Private Cloud (Amazon VPC)", "Name": "vpc", "Checks": [ + "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled" ], "Attributes": [ @@ -2988,6 +2991,7 @@ "Description": "Checks if Service Endpoint for the service provided in rule parameter is created for each Amazon Virtual Private Cloud (Amazon VPC)", "Name": "vpc", "Checks": [ + "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled" ], "Attributes": [ @@ -3850,6 +3854,7 @@ "Description": "Checks if Service Endpoint for the service provided in rule parameter is created for each Amazon Virtual Private Cloud (Amazon VPC)", "Name": "vpc", "Checks": [ + "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled" ], "Attributes": [ @@ -4398,6 +4403,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.2.1.1: Audit logs are implemented to support the detection of anomalies and suspicious activity, and the forensic analysis of events. ", @@ -9276,6 +9289,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.4.1.1: Audit logs are reviewed to identify anomalies or suspicious activity. ", @@ -9358,6 +9379,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.4.1: Audit logs are reviewed to identify anomalies or suspicious activity. ", @@ -9454,6 +9483,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.4.2: Audit logs are reviewed to identify anomalies or suspicious activity. ", @@ -9546,6 +9583,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "10.5.1: Audit log history is retained and available for analysis. ", @@ -10174,6 +10219,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.6.3: Time-synchronization mechanisms support consistent time settings across all systems. ", @@ -10338,6 +10391,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.7.1: Failures of critical security control systems are detected, reported, and responded to promptly. ", @@ -10446,6 +10507,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "10.7.2: Failures of critical security control systems are detected, reported, and responded to promptly. ", @@ -10620,6 +10689,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11.5.1.1: Network intrusions and unexpected file changes are detected and responded to. ", @@ -10648,6 +10725,14 @@ "Checks": [ "guardduty_is_enabled" ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "11.5.1: Network intrusions and unexpected file changes are detected and responded to. ", @@ -11440,6 +11525,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.2.1: Storage of account data is kept to a minimum. ", @@ -11562,6 +11655,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.1.1: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -11684,6 +11785,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.1.3: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -11806,6 +11915,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.2: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -11928,6 +12045,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.3: Sensitive authentication data (SAD) is not stored after authorization. ", @@ -13568,6 +13693,17 @@ "Checks": [ "acm_certificates_with_secure_key_algorithms" ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } + ], "Attributes": [ { "Section": "3.7.1: Where cryptography is used to protect stored account data, key management processes and procedures covering all aspects of the key lifecycle are defined and implemented. ", @@ -14996,6 +15132,14 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled" ], + "ConfigRequirements": [ + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "5.3.4: Anti-malware mechanisms and processes are active, maintained, and monitored. ", @@ -21686,6 +21830,7 @@ "Description": "Checks if Service Endpoint for the service provided in rule parameter is created for each Amazon Virtual Private Cloud (Amazon VPC)", "Name": "vpc", "Checks": [ + "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled" ], "Attributes": [ @@ -22498,6 +22643,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "A3.3.1: PCI DSS is incorporated into business-as-usual (BAU) activities. ", @@ -22994,6 +23147,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Section": "A3.5.1: Suspicious events are identified and responded to. ", diff --git a/prowler/compliance/aws/prowler_threatscore_aws.json b/prowler/compliance/aws/prowler_threatscore_aws.json index 902beb8abd..c8c093907a 100644 --- a/prowler/compliance/aws/prowler_threatscore_aws.json +++ b/prowler/compliance/aws/prowler_threatscore_aws.json @@ -174,6 +174,20 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused" ], + "ConfigRequirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45 + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45 + } + ], "Attributes": [ { "Title": "IAM credentials unused disabled", @@ -336,6 +350,14 @@ "Checks": [ "accessanalyzer_enabled" ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Title": "Access Analyzer enabled", @@ -1541,6 +1563,14 @@ "Checks": [ "config_recorder_all_regions_enabled" ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Title": "AWS Config is enabled", @@ -1829,6 +1859,14 @@ "Checks": [ "securityhub_enabled" ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ], "Attributes": [ { "Title": "Security Hub enabled", diff --git a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json index b4566a8929..5de1f5ca8a 100644 --- a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json +++ b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json @@ -40,6 +40,9 @@ "ec2_instance_public_ip", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "ec2_ebs_default_encryption", "emr_cluster_master_nodes_no_public_ip", @@ -182,6 +185,14 @@ "securityhub_enabled", "vpc_flow_logs_enabled", "opensearch_service_domains_audit_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/secnumcloud_3.2_aws.json b/prowler/compliance/aws/secnumcloud_3.2_aws.json index 1476ad87e5..701f931b05 100644 --- a/prowler/compliance/aws/secnumcloud_3.2_aws.json +++ b/prowler/compliance/aws/secnumcloud_3.2_aws.json @@ -202,6 +202,14 @@ "Checks": [ "config_recorder_all_regions_enabled", "ec2_instance_managed_by_ssm" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -293,6 +301,9 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_no_expired_server_certificates_stored" @@ -311,14 +322,23 @@ } ], "Checks": [ - "iam_policy_allows_privilege_escalation", + "accessanalyzer_enabled", + "bedrock_full_access_policy_attached", + "iam_group_administrator_access_policy", "iam_inline_policy_allows_privilege_escalation", "iam_no_custom_policy_permissive_role_assumption", - "iam_user_two_active_access_key", - "accessanalyzer_enabled", + "iam_policy_allows_privilege_escalation", "iam_role_administratoraccess_policy", "iam_user_administrator_access_policy", - "iam_group_administrator_access_policy" + "iam_user_two_active_access_key" + ], + "ConfigRequirements": [ + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -335,6 +355,9 @@ ], "Checks": [ "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", "iam_user_accesskey_unused", "iam_user_console_access_unused", "accessanalyzer_enabled_without_findings" @@ -467,6 +490,9 @@ "elbv2_ssl_listeners", "elb_insecure_ssl_ciphers", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "redshift_cluster_in_transit_encryption_enabled", "elasticache_redis_cluster_in_transit_encryption_enabled", "dynamodb_accelerator_cluster_in_transit_encryption_enabled", @@ -553,6 +579,17 @@ "Checks": [ "acm_certificates_expiration_check", "acm_certificates_with_secure_key_algorithms" + ], + "ConfigRequirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + } ] }, { @@ -725,6 +762,14 @@ "config_recorder_all_regions_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled_logging_management_events" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -764,6 +809,14 @@ "guardduty_lambda_protection_enabled", "guardduty_eks_audit_log_enabled", "guardduty_eks_runtime_monitoring_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -811,6 +864,7 @@ } ], "Checks": [ + "cloudtrail_bedrock_logging_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled_logging_management_events", "cloudtrail_s3_dataevents_read_enabled", @@ -900,6 +954,20 @@ "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1003,6 +1071,14 @@ "config_recorder_all_regions_enabled", "config_recorder_using_aws_service_role", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1027,6 +1103,7 @@ "ec2_networkacl_allow_ingress_any_port", "vpc_subnet_separate_private_public", "vpc_peering_routing_tables_with_least_privilege", + "bedrock_vpc_endpoints_configured", "vpc_endpoint_connections_trust_boundaries", "vpc_endpoint_services_allowed_principals_trust_boundaries", "elbv2_waf_acl_attached", @@ -1048,6 +1125,14 @@ "Checks": [ "guardduty_is_enabled", "vpc_flow_logs_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1079,6 +1164,14 @@ "Checks": [ "config_recorder_all_regions_enabled", "cloudtrail_multi_region_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1256,6 +1349,20 @@ "guardduty_is_enabled", "securityhub_enabled", "cloudwatch_alarm_actions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1406,6 +1513,14 @@ "Checks": [ "backup_plans_exist", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1469,6 +1584,20 @@ "Checks": [ "securityhub_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -1486,6 +1615,14 @@ "Checks": [ "inspector2_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/aws/soc2_aws.json b/prowler/compliance/aws/soc2_aws.json index 44d4038528..c0041a9dae 100644 --- a/prowler/compliance/aws/soc2_aws.json +++ b/prowler/compliance/aws/soc2_aws.json @@ -43,6 +43,14 @@ "cloudtrail_s3_dataevents_write_enabled", "cloudtrail_multi_region_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -61,6 +69,26 @@ "guardduty_is_enabled", "securityhub_enabled", "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -80,6 +108,14 @@ "ssm_managed_compliant_patching", "guardduty_no_high_severity_findings", "guardduty_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -116,6 +152,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -133,6 +177,14 @@ "Checks": [ "guardduty_is_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -205,6 +257,9 @@ } ], "Checks": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_policy_no_wildcard_marketplace_subscribe", + "bedrock_full_access_policy_attached", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges" @@ -309,6 +364,20 @@ "Checks": [ "guardduty_is_enabled", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -328,6 +397,20 @@ "securityhub_enabled", "ec2_instance_managed_by_ssm", "ssm_managed_compliant_patching" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -343,6 +426,7 @@ } ], "Checks": [ + "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -363,6 +447,20 @@ "guardduty_is_enabled", "apigateway_restapi_logging_enabled", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" + ], + "ConfigRequirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -395,6 +493,20 @@ "cloudwatch_log_group_retention_policy_specific_days_enabled", "vpc_flow_logs_enabled", "guardduty_no_high_severity_findings" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -422,6 +534,20 @@ "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning", "securityhub_enabled" + ], + "ConfigRequirements": [ + { + "Check": "guardduty_is_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -459,6 +585,14 @@ ], "Checks": [ "config_recorder_all_regions_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { @@ -596,6 +730,14 @@ "rds_cluster_integration_cloudwatch_logs", "glue_etl_jobs_logging_enabled", "stepfunctions_statemachine_logging_enabled" + ], + "ConfigRequirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } ] }, { diff --git a/prowler/compliance/azure/c5_azure.json b/prowler/compliance/azure/c5_azure.json index 4ac3b4dd53..6fff7a3691 100644 --- a/prowler/compliance/azure/c5_azure.json +++ b/prowler/compliance/azure/c5_azure.json @@ -2681,6 +2681,17 @@ "app_function_latest_runtime_version", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -2705,6 +2716,17 @@ "app_function_latest_runtime_version", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -3903,6 +3925,17 @@ "app_ensure_php_version_is_latest", "storage_ensure_minimum_tls_version_12", "storage_smb_protocol_version_is_latest" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -4352,6 +4385,17 @@ "sqlserver_recommended_minimal_tls_version", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -5743,6 +5787,17 @@ "storage_ensure_minimum_tls_version_12", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -5770,6 +5825,17 @@ "storage_ensure_minimum_tls_version_12", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -6513,6 +6579,17 @@ "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version", "storage_ensure_minimum_tls_version_12" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { diff --git a/prowler/compliance/azure/ccc_azure.json b/prowler/compliance/azure/ccc_azure.json index 004137ad53..661d38302f 100644 --- a/prowler/compliance/azure/ccc_azure.json +++ b/prowler/compliance/azure/ccc_azure.json @@ -1,10 +1,1727 @@ { "Framework": "CCC", - "Version": "", + "Version": "v2025.10", "Provider": "Azure", "Name": "Common Cloud Controls Catalog (CCC)", "Description": "Common Cloud Controls Catalog (CCC) for Azure", "Requirements": [ + { + "Id": "CCC.Core.CN01.AR01", + "Description": "When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Most cloud services enable TLS 1.3 by default. Where it is not already set, ensure that your services are configured or updated accordingly.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "storage_secure_transfer_required_is_enabled", + "storage_ensure_minimum_tls_version_12", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest", + "postgresql_flexible_server_enforce_ssl_enabled", + "mysql_flexible_server_ssl_connection_enabled", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "app_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_ftps_deployment_disabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] + }, + { + "Id": "CCC.Core.CN01.AR02", + "Description": "When a port is exposed for SSH network traffic, all traffic MUST include a SSH handshake AND be encrypted using SSHv2 or higher.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Any time port 22 is exposed, ensure that it has a properly implemented SSH server with SSHv2 enabled and configured with strong ciphers.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "network_ssh_internet_access_restricted", + "vm_linux_enforce_ssh_authentication" + ] + }, + { + "Id": "CCC.Core.CN01.AR03", + "Description": "When the service receives unencrypted traffic, then it MUST either block the request or automatically redirect it to the secure equivalent.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Review firewall, load balancer, and application configurations to ensure insecure protocols such as HTTP, FTP, and Telnet are not exposed. Where possible, implement automatic redirection to secure protocols such as HTTPS, SFTP, SSH, and regularly scan for protocol drift.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "app_ensure_http_is_redirected_to_https", + "storage_secure_transfer_required_is_enabled", + "app_ftp_deployment_disabled", + "app_function_ftps_deployment_disabled" + ] + }, + { + "Id": "CCC.Core.CN01.AR07", + "Description": "When a port is exposed, the service MUST ensure that the protocol and service officially assigned to that port number by the IANA Service Name and Transport Protocol Port Number Registry, and no other, is run on that port.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Reference the IANA Service Name and Transport Protocol Port Number Registry for more information about correct protocol-to-port assignments. Avoid running non-standard services on well-known ports.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN01.AR08", + "Description": "When a service transmits data using TLS, mutual TLS (mTLS) MUST be implemented to require both client and server certificate authentication for all connections.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Configure mTLS for all endpoints that process or transmit sensitive data. Ensure both client and server certificates are validated and managed securely. Regularly review certificate authorities and automate certificate rotation where possible.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "app_client_certificates_on" + ] + }, + { + "Id": "CCC.Core.CN13.AR01", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST only use valid, unexpired certificates issued by a trusted certificate authority.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_secret_expiration_set" + ] + }, + { + "Id": "CCC.Core.CN13.AR02", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 180 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-amber" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "keyvault_key_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN13.AR03", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 90 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "keyvault_key_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN06.AR01", + "Description": "When the service is running, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate the service's deployment location is included in this list.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-19" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN06.AR02", + "Description": "When a child resource is deployed, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate that child resources can only be deployed to locations included in this list.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-19" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN08.AR01", + "Description": "When data is created or modified, the data MUST have a complete and recoverable duplicate that is stored in a physically separate data center.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "SubSection": "", + "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement automated data replication processes to ensure that data is consistently duplicated in another region or availability zone. Regularly test data recovery from the replicated location to ensure integrity and availability.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "BCR-08", + "BCR-10", + "BCR-11" + ] + } + ] + } + ], + "Checks": [ + "storage_geo_redundant_enabled", + "vm_backup_enabled" + ] + }, + { + "Id": "CCC.Core.CN08.AR02", + "Description": "When data is replicated into a second location, the service MUST be able to accurately represent the replication locations, replication status, and data synchronization status.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "SubSection": "", + "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "BCR-08", + "BCR-10", + "BCR-11" + ] + } + ] + } + ], + "Checks": [ + "storage_geo_redundant_enabled" + ] + }, + { + "Id": "CCC.Core.CN09.AR01", + "Description": "When the service is operational, its logs and any child resource logs MUST NOT be accessible from the resource they record access to.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "monitor_storage_account_with_activity_logs_is_private", + "monitor_storage_account_with_activity_logs_cmk_encrypted" + ] + }, + { + "Id": "CCC.Core.CN09.AR02", + "Description": "When the service is operational, disabling the logs for the service or its child resources MUST NOT be possible without also disabling the corresponding resource.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "No normal business operations should disable logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging mechanisms are tightly integrated with service operations, so that logging cannot be disabled without stopping the service itself.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories" + ] + }, + { + "Id": "CCC.Core.CN09.AR03", + "Description": "When the service is operational, any attempt to redirect logs for the service or its child resources MUST NOT be possible without halting operation of the corresponding resource and publishing corresponding events to monitored channels.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "No normal business operations should result in the redirection of logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging configurations are immutable during service operation so that any changes require stopping the service and publishing corresponding events to monitored channels.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists" + ] + }, + { + "Id": "CCC.Core.CN10.AR01", + "Description": "When data is replicated, the service MUST ensure that replication only occurs to destinations that are explicitly included within the defined trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN10 Restrict Data Replication to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that data is only replicated on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-10", + "DSP-19" + ] + } + ] + } + ], + "Checks": [ + "storage_cross_tenant_replication_disabled" + ] + }, + { + "Id": "CCC.Core.CN02.AR01", + "Description": "When data is stored, it MUST be encrypted using the latest industry-standard encryption methods.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN02 Encrypt Data for Storage", + "SubSection": "", + "SubSectionObjective": "Ensure that all data stored is encrypted at rest using strong encryption algorithms.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "UEM-08", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_infrastructure_encryption_is_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted" + ] + }, + { + "Id": "CCC.Core.CN11.AR01", + "Description": "When encryption keys are used, the service MUST verify that all encryption keys use the latest industry-standard cryptographic algorithms.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "storage_smb_channel_encryption_with_secure_algorithm" + ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] + }, + { + "Id": "CCC.Core.CN11.AR02", + "Description": "When encryption keys are used, the service MUST rotate active keys within 180 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "keyvault_key_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR03", + "Description": "When encrypting data, the service MUST verify that customer-managed encryption keys (CMEKs) are used.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_encryption_with_customer_managed_keys", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR04", + "Description": "When encryption keys are accessed, the service MUST verify that access to encryption keys is restricted to authorized personnel and services, following the principle of least privilege.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "keyvault_rbac_enabled", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "keyvault_logging_enabled", + "keyvault_recoverable" + ] + }, + { + "Id": "CCC.Core.CN11.AR05", + "Description": "When encryption keys are used, the service MUST rotate active keys within 365 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-clear", + "tlp-green" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "keyvault_key_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR06", + "Description": "When encryption keys are used, the service MUST rotate active keys within 90 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "storage_key_rotation_90_days", + "keyvault_key_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN14.AR01", + "Description": "When backups are created for disaster recovery purposes, the storage mechanism MUST NOT allow modification or deletion within 30 days of creation.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Use immutable storage solutions where possible. Implement backup retention policies that enforce a minimum retention period of 30 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ] + }, + { + "Id": "CCC.Core.CN14.AR02", + "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 30 days.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber" + ], + "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 30 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ] + }, + { + "Id": "CCC.Core.CN14.AR03", + "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 14 days.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 14 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ] + }, + { + "Id": "CCC.Core.CN03.AR01", + "Description": "When an entity attempts to modify the service through a user interface, the authentication process MUST require multiple identifying factors for authentication.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled" + ] + }, + { + "Id": "CCC.Core.CN03.AR02", + "Description": "When an entity attempts to modify the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "app_function_access_keys_configured", + "app_function_identity_is_configured" + ] + }, + { + "Id": "CCC.Core.CN03.AR03", + "Description": "When an entity attempts to view information on the service through a user interface, the authentication process MUST require multiple identifying factors from the user.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled" + ] + }, + { + "Id": "CCC.Core.CN03.AR04", + "Description": "When an entity attempts to view information on the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "app_function_access_keys_configured" + ] + }, + { + "Id": "CCC.Core.CN05.AR01", + "Description": "When an attempt is made to modify data on the service or a child resource, the service MUST block requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "aks_cluster_rbac_enabled", + "aks_clusters_public_access_disabled", + "app_ensure_auth_is_set_up", + "app_register_with_identity", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_not_publicly_accessible", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "keyvault_rbac_enabled", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "entra_global_admin_in_less_than_five_users", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "vm_jit_access_enabled" + ] + }, + { + "Id": "CCC.Core.CN05.AR02", + "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "entra_global_admin_in_less_than_five_users", + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_conditional_access_policy_require_mfa_for_management_api", + "aks_cluster_rbac_enabled", + "containerregistry_admin_user_disabled", + "keyvault_rbac_enabled" + ] + }, + { + "Id": "CCC.Core.CN05.AR03", + "Description": "When administrative access or configuration change is attempted on the service or a child resource in a multi-tenant environment, the service MUST refuse requests across tenant boundaries unless the origin is explicitly included in a pre-approved allowlist.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_cross_tenant_replication_disabled", + "storage_default_to_entra_authorization_enabled", + "entra_trusted_named_locations_exists" + ] + }, + { + "Id": "CCC.Core.CN05.AR04", + "Description": "When data is requested from outside the trust perimeter, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "cosmosdb_account_use_private_endpoints", + "cosmosdb_account_firewall_use_selected_networks", + "sqlserver_unrestricted_inbound_access", + "network_http_internet_access_restricted" + ] + }, + { + "Id": "CCC.Core.CN05.AR05", + "Description": "When any request is made from outside the trust perimeter, the service MUST NOT provide any response that may indicate the service exists.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "keyvault_private_endpoints", + "storage_ensure_private_endpoints_in_storage_accounts", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted" + ] + }, + { + "Id": "CCC.Core.CN05.AR06", + "Description": "When any request is made to the service or a child resource, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_global_admin_in_less_than_five_users", + "entra_conditional_access_policy_require_mfa_for_management_api", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "keyvault_rbac_enabled", + "vm_jit_access_enabled" + ] + }, + { + "Id": "CCC.Core.CN04.AR01", + "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_alert_create_update_nsg", + "monitor_alert_delete_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_sqlserver_fr" + ] + }, + { + "Id": "CCC.Core.CN04.AR02", + "Description": "When any attempt is made to modify data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "keyvault_logging_enabled", + "app_http_logs_enabled" + ] + }, + { + "Id": "CCC.Core.CN04.AR03", + "Description": "When any attempt is made to read data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "keyvault_logging_enabled", + "app_http_logs_enabled" + ] + }, + { + "Id": "CCC.Core.CN07.AR01", + "Description": "When enumeration activities are detected, the service MUST publish an event to a monitored channel which includes the client identity, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement event publication mechanisms and alerts for patterns indicative of enumeration activities, such as repeated access attempts, requests, or liveness probes. Configure alerts to notify security teams of any activities that merit further investigation.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH15" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-05", + "SEF-05" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN07.AR02", + "Description": "When enumeration activities are detected, the service MUST log the client identity, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement logging mechanisms to capture details of enumeration activities, including client identity, timestamps, and activity nature. Retain logs according to organizational policies, and occasionally review them for patterns that may indicate reconnaissance activities.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH15" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-05", + "SEF-05" + ] + } + ] + } + ], + "Checks": [] + }, { "Id": "CCC.AuditLog.CN01.AR01", "Description": "When the signature validation process is performed, then it MUST detect any modification of data.", @@ -18,13 +1735,13 @@ "Applicability": [ "tlp-red" ], - "Recommendation": "Ensure hash of data is included in digital signature. ", + "Recommendation": "Ensure hash of data is included in digital signature.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -59,13 +1776,13 @@ "Applicability": [ "tlp-red" ], - "Recommendation": "Ensure verification process includes a chained hash function. ", + "Recommendation": "Ensure verification process includes a chained hash function.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -106,7 +1823,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06" + "CCC.Core.TH06" ] } ], @@ -130,9 +1847,7 @@ ], "Checks": [ "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" + "monitor_diagnostic_setting_with_appropriate_categories" ] }, { @@ -149,12 +1864,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Ensure alerting is correctly configured ", + "Recommendation": "Ensure alerting is correctly configured", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -175,12 +1890,7 @@ ] } ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private" - ] + "Checks": [] }, { "Id": "CCC.AuditLog.CN03.AR02", @@ -196,12 +1906,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Ensure alerting is correctly configured ", + "Recommendation": "Ensure alerting is correctly configured", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -222,22 +1932,7 @@ ] } ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_alert_service_health_exists", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_policy_assignment", - "monitor_alert_delete_policy_assignment" - ] + "Checks": [] }, { "Id": "CCC.AuditLog.CN04.AR01", @@ -253,13 +1948,13 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure the audit log bucket to enable server access logging. Ensure the target logging bucket is configured for appropriate security, including restricted access and immutability. ", + "Recommendation": "Configure the audit log bucket to enable server access logging. Ensure the target logging bucket is configured for appropriate security, including restricted access and immutability.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01", - "CCC.TH09" + "CCC.Core.TH01", + "CCC.Core.TH09" ] } ], @@ -281,11 +1976,7 @@ } ], "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "storage_blob_public_access_level_is_disabled" + "monitor_diagnostic_settings_exists" ] }, { @@ -302,12 +1993,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure audit log exporting. ", + "Recommendation": "Configure audit log exporting.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -331,9 +2022,7 @@ ], "Checks": [ "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" + "monitor_diagnostic_setting_with_appropriate_categories" ] }, { @@ -351,13 +2040,13 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure the audit log bucket's lifecycle rules or object retention settings to enforce the required data retention period. ", + "Recommendation": "Configure the audit log bucket's lifecycle rules or object retention settings to enforce the required data retention period.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -378,10 +2067,7 @@ ] } ], - "Checks": [ - "storage_ensure_soft_delete_is_enabled", - "monitor_diagnostic_settings_exists" - ] + "Checks": [] }, { "Id": "CCC.AuditLog.CN07.AR01", @@ -398,13 +2084,13 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Enable MFA Delete (or equivalent multi-factor authentication for delete operations) on the audit log bucket. ", + "Recommendation": "Enable MFA Delete (or equivalent multi-factor authentication for delete operations) on the audit log bucket.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -441,12 +2127,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure object lock policy. ", + "Recommendation": "Configure object lock policy.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -467,12 +2153,7 @@ ] } ], - "Checks": [ - "storage_ensure_soft_delete_is_enabled", - "monitor_diagnostic_settings_exists", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private" - ] + "Checks": [] }, { "Id": "CCC.AuditLog.CN09.AR01", @@ -488,12 +2169,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Review field level access controls on audit data. ", + "Recommendation": "Review field level access controls on audit data.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -519,9 +2200,7 @@ } ], "Checks": [ - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_diagnostic_settings_exists" + "monitor_storage_account_with_activity_logs_is_private" ] }, { @@ -539,12 +2218,12 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -568,7 +2247,6 @@ "Checks": [ "storage_blob_public_access_level_is_disabled", "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted", "storage_ensure_private_endpoints_in_storage_accounts" ] }, @@ -587,12 +2265,12 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -616,944 +2294,16 @@ "Checks": [ "storage_blob_public_access_level_is_disabled", "storage_default_network_access_rule_is_denied", - "storage_ensure_private_endpoints_in_storage_accounts", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private" + "storage_ensure_private_endpoints_in_storage_accounts" ] }, - { - "Id": "CCC.Build.CN01.AR01", - "Description": "Attempt to initiate a build using an unauthorized build agent and verify that the build is rejected.", - "Attributes": [ - { - "FamilyName": "Access Control", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.Build.CN01 Restrict Allowed Build Agents", - "SubSection": "", - "SubSectionObjective": "Ensure that builds are executed only on authorized build agents to maintain control over the build environment and prevent unauthorized code execution.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.Build.CN02.AR01", - "Description": "Attempt to trigger a build from an unauthorized external service or repository and verify that the build does not start.", - "Attributes": [ - { - "FamilyName": "Access Control", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.Build.CN02 Restrict Allowed External Services for Build Triggers", - "SubSection": "", - "SubSectionObjective": "Ensure that builds can only be triggered by authorized external services or repositories to prevent unauthorized code execution or tampering.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.Build.CN03.AR01", - "Description": "Attempt to access the build environment from an external network and verify that access is denied.", - "Attributes": [ - { - "FamilyName": "Network Security", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.Build.CN03 Deny External Network Access for Build Environments", - "SubSection": "", - "SubSectionObjective": "Ensure that build environments do not have external network access to prevent unauthorized external access and data exfiltration.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02", - "CCC.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-7", - "SC-5" - ] - } - ] - } - ], - "Checks": [ - "aks_clusters_public_access_disabled", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "app_function_not_publicly_accessible", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted" - ] - }, - { - "Id": "CCC.CntrReg.CN01.AR01", - "Description": "Attempt to push an artifact with known vulnerabilities to the registry and observe if it is flagged or rejected by the vulnerability scanning process.", - "Attributes": [ - { - "FamilyName": "Risk Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CntrReg.CN01 Implement Vulnerability Scanning for Artifacts", - "SubSection": "", - "SubSectionObjective": "Ensure that container images and artifacts stored in the container registry are scanned for vulnerabilities to identify and remediate security issues before deployment.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.CntrReg.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "ID.RA-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "RA-5", - "SI-5" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_scan_enabled", - "defender_container_images_resolved_vulnerabilities" - ] - }, - { - "Id": "CCC.DataWar.CN01.AR01", - "Description": "Attempt to access underlying database tables directly without using managed views and verify that access is denied.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN01 Enforce Use of Managed Views for Data Access", - "SubSection": "", - "SubSectionObjective": "Ensure that data access is provided through managed views, restricting users from accessing underlying tables directly and enforcing consistent security policies.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.DataWar.CN02.AR01", - "Description": "Attempt to query sensitive columns without the necessary permissions and verify that access is denied or data is masked.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN02 Enforce Column-Level Security Policies", - "SubSection": "", - "SubSectionObjective": "Ensure that access to sensitive data columns is restricted based on user roles, preventing unauthorized access to sensitive information.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.DataWar.CN03.AR01", - "Description": "Attempt to query data rows that the user should not have access to and verify that access is denied or data is not returned.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN03 Enforce Row-Level Security Policies", - "SubSection": "", - "SubSectionObjective": "Ensure that access to data rows is restricted based on user roles or attributes, preventing unauthorized access to specific subsets of data.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - } - ] - } - ], - "Checks": [ - "cosmosdb_account_use_aad_and_rbac", - "cosmosdb_account_firewall_use_selected_networks", - "cosmosdb_account_use_private_endpoints", - "sqlserver_unrestricted_inbound_access", - "postgresql_flexible_server_allow_access_services_disabled" - ] - }, - { - "Id": "CCC.KeyMgmt.CN01.AR01", - "Description": "When a key version is scheduled for deletion or disabled, an alert MUST be generated within five minutes.", - "Attributes": [ - { - "FamilyName": "Logging and Metrics Publication", - "FamilyDescription": "Controls that collect, alert, and retain key-management events.", - "Section": "CCC.KeyMgmt.CN01 Alert on Key-version Changes", - "SubSection": "", - "SubSectionObjective": "Generate near-real-time alerts when a KMS key version is disabled or scheduled for deletion, enabling rapid investigation and recovery.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Use native event services (e.g., CloudWatch Events, Azure Monitor, Cloud Audit Logs) to route notifications to an incident-response channel.", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.KeyMgmt.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "RS.AN-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IR-5" - ] - } - ] - } - ], - "Checks": [ - "keyvault_logging_enabled", - "monitor_diagnostic_settings_exists", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_policy_assignment", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "CCC.KeyMgmt.CN02.AR01", - "Description": "When IAM roles and key policies are reviewed, Decrypt permission MUST be granted exclusively to documented authorised principals.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that enforce least-privilege use of KMS operations.", - "Section": "CCC.KeyMgmt.CN02 Limit Decrypt Permissions", - "SubSection": "", - "SubSectionObjective": "Restrict the Decrypt operation to authorised principals only, applying the principle of least privilege to protect sensitive data.", - "Applicability": [ - "tlp-green" - ], - "Recommendation": "Periodically audit policy documents via automated tooling and report any deviations.", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.KeyMgmt.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-6" - ] - } - ] - } - ], - "Checks": [ - "keyvault_rbac_enabled", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set", - "keyvault_key_expiration_set_in_non_rbac" - ] - }, - { - "Id": "CCC.KeyMgmt.CN03.AR01", - "Description": "When rotation settings are examined, rotation MUST be enabled with an interval not exceeding 365 days.", - "Attributes": [ - { - "FamilyName": "Key Lifecycle Management", - "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", - "Section": "CCC.KeyMgmt.CN03 Enforce Automatic Rotation", - "SubSection": "", - "SubSectionObjective": "Ensure symmetric keys rotate automatically within policy intervals to reduce exposure of key material.", - "Applicability": [ - "tlp-green" - ], - "Recommendation": "Use cloud-provider rotation features and verify via configuration scanning.", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.KeyMgmt.TH03" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_rotation_enabled" - ] - }, - { - "Id": "CCC.KeyMgmt.CN04.AR01", - "Description": "When a key import request is processed, the key MUST use an approved algorithm (RSA-2048+, EC-P256+) and originate from a certified HSM.", - "Attributes": [ - { - "FamilyName": "Key Lifecycle Management", - "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", - "Section": "CCC.KeyMgmt.CN04 Validate Imported Keys", - "SubSection": "", - "SubSectionObjective": "Accept only externally generated keys that meet approved cryptographic strength and provenance requirements.", - "Applicability": [ - "tlp-green" - ], - "Recommendation": "Implement an approval workflow that validates attestation data before import.", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.KeyMgmt.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_rotation_enabled", - "keyvault_rbac_enabled", - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints", - "keyvault_recoverable", - "keyvault_logging_enabled", - "keyvault_non_rbac_secret_expiration_set" - ] - }, - { - "Id": "CCC.LB.CN01.AR01", - "Description": "When a single client sends more than 2000 requests within any 5-minute sliding window, the load balancer MUST throttle all subsequent requests from that client for at least 60 seconds.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", - "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", - "SubSection": "", - "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement per-IP token-bucket limits with and verify via synthetic traffic tests. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH01", - "CCC.LB.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-1", - "PR.AC-7", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6", - "SC-5", - "AC-7" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.LB.CN01.AR02", - "Description": "When throttling is invoked, the load balancer MUST record the event in the access log within 5 minutes for alerting and trend analysis.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", - "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", - "SubSection": "", - "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Enable access logging and configure metric filters on HTTP 429 counts to trigger alerts. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH01", - "CCC.LB.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-1", - "PR.AC-7", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6", - "SC-5", - "AC-7" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "network_flow_log_captured_sent", - "network_watcher_enabled", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" - ] - }, - { - "Id": "CCC.LB.CN06.AR01", - "Description": "When more than 10 percent of targets change from healthy to unhealthy within five minutes, an alert MUST be issued.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", - "Section": "CCC.LB.CN06 Secure Health-Check Telemetry", - "SubSection": "", - "SubSectionObjective": "Monitor health-check endpoints for tampering and alert on abnormal status changes.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Instrument metrics for health check results and target removal events. Configure monitoring alarms to alert on abnormal spikes in unhealthy targets. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_alert_service_health_exists", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_policy_assignment", - "network_flow_log_captured_sent", - "network_watcher_enabled", - "vm_scaleset_associated_with_load_balancer" - ] - }, - { - "Id": "CCC.LB.CN04.AR01", - "Description": "When routing weights change, the request MUST originate from an explicitly defined and trusted identity and MUST be logged.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", - "Section": "CCC.LB.CN04 Enforce Distribution Policies", - "SubSection": "", - "SubSectionObjective": "Ensure traffic-splitting weights and algorithms are modified only by trusted identities.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Define a list of trusted principals allowed to modify routing configurations. Enforce via conditional access policies, and log changes using audit logging. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH03" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_nsg", - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_security_solution", - "entra_global_admin_in_less_than_five_users", - "entra_non_privileged_user_has_mfa", - "iam_role_user_access_admin_restricted", - "iam_custom_role_has_permissions_to_administer_resource_locks" - ] - }, - { - "Id": "CCC.LB.CN05.AR01", - "Description": "When stickiness is enabled, session cookies MUST expire within 30 minutes of inactivity.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", - "Section": "CCC.LB.CN05 Validate Session Affinity", - "SubSection": "", - "SubSectionObjective": "Configure session persistence to minimise fixation and hijacking risks.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Audit CCC.LB.F15 parameters via configuration scans.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-23" - ] - } - ] - } - ], - "Checks": [ - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "iam_custom_role_has_permissions_to_administer_resource_locks" - ] - }, - { - "Id": "CCC.LB.CN09.AR01", - "Description": "When an API call originates outside the approved CIDR set, the request MUST be denied.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", - "Section": "CCC.LB.CN09 Restrict Management API Access", - "SubSection": "", - "SubSectionObjective": "Limit load-balancer API calls to authorised identities and trusted networks.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Combine VPC endpoints with IAM condition-key filters for protected APIs.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH08" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_privileged_user_has_mfa", - "iam_role_user_access_admin_restricted", - "iam_custom_role_has_permissions_to_administer_resource_locks" - ] - }, - { - "Id": "CCC.LB.CN02.AR01", - "Description": "When concurrent connections reach 80 percent of capacity, the autoscaling group MUST add at least one instance within five minutes.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer. ", - "Section": "CCC.LB.CN02 Auto-Scale Load Balancer Capacity", - "SubSection": "", - "SubSectionObjective": "Expand load-balancer capacity to maintain availability during traffic spikes.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Enable autoscaling policies.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "CP-10" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.LB.CN07.AR01", - "Description": "When responses pass through the load balancer, the \"Server\" header MUST be replaced with \"lb\".", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer. ", - "Section": "CCC.LB.CN07 Scrub Sensitive Headers", - "SubSection": "", - "SubSectionObjective": "Remove headers that disclose internal details or software versions from HTTP responses.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Configure header-transformation rules.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-13" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.LB.CN08.AR01", - "Description": "When a certificate is within 30 days of expiry, automated renewal MUST complete and deploy a new certificate within 24 hours.", - "Attributes": [ - { - "FamilyName": "Encryption", - "FamilyDescription": "Controls that ensure trustworthy TLS certificates and ciphers.", - "Section": "CCC.LB.CN08 Automate Certificate Renewal", - "SubSection": "", - "SubSectionObjective": "Maintain valid TLS certificates by automating renewal and deployment before expiry.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Use certificate-manager auto-renewal workflows.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH07" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-17" - ] - } - ] - } - ], - "Checks": [] - }, { "Id": "CCC.Logging.CN01.AR01", "Description": "When a new cloud account is created, provider-level audit and network flow logging MUST be enabled by default and directed to the central sink.", "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", "SubSection": "", "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", @@ -1591,10 +2341,7 @@ ], "Checks": [ "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "network_flow_log_captured_sent", - "app_http_logs_enabled", - "appinsights_ensure_is_configured" + "network_flow_log_captured_sent" ] }, { @@ -1603,7 +2350,7 @@ "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", "SubSection": "", "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", @@ -1643,8 +2390,7 @@ "monitor_diagnostic_settings_exists", "monitor_diagnostic_setting_with_appropriate_categories", "app_http_logs_enabled", - "keyvault_logging_enabled", - "network_flow_log_captured_sent" + "keyvault_logging_enabled" ] }, { @@ -1653,52 +2399,7 @@ "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", - "SubSection": "", - "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "GV.PO-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-11" - ] - } - ] - } - ], - "Checks": [ - "network_flow_log_more_than_90_days" - ] - }, - { - "Id": "CCC.Logging.CN02.AR02", - "Description": "When a query is performed to retrieve log events older than the number of days defined in the organisation's data retention policy, it MUST return an empty result.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", "SubSection": "", "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", @@ -1740,12 +2441,59 @@ ] }, { - "Id": "CCC.AuditLog.CN08.AR01", + "Id": "CCC.Logging.CN02.AR02", + "Description": "When a query is performed to retrieve log events older than the number of days defined in the organisation's data retention policy, it MUST return an empty result.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "GV.PO-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-11" + ] + } + ] + } + ], + "Checks": [ + "network_flow_log_more_than_90_days", + "sqlserver_auditing_retention_90_days", + "postgresql_flexible_server_log_retention_days_greater_3" + ] + }, + { + "Id": "CCC.Logging.CN03.AR01", "Description": "When an attempt is made to modify or delete data before the object lock period expires, then the action MUST be denied.", "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", "Section": "CCC.Logging.CN03 Enable Object Lock On Log Bucket", "SubSection": "", "SubSectionObjective": "Ensure log immutability by enabling Write Once, Read Many (WORM) protection using object lock on log storage buckets. This prevents logs from being modified or deleted during the defined retention period, supporting compliance and forensic integrity.", @@ -1753,12 +2501,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure object lock policy. ", + "Recommendation": "Configure object lock policy.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -1782,12 +2530,12 @@ "Checks": [] }, { - "Id": "CCC.AuditLog.CN04.AR01", + "Id": "CCC.Logging.CN04.AR01", "Description": "When restricted fields are accessed by unauthorized users, then those fields MUST remain masked.", "Attributes": [ { "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", + "FamilyDescription": "Controls that restrict who can access and modify logs.", "Section": "CCC.Logging.CN04 Restrict Field And Log Type Access", "SubSection": "", "SubSectionObjective": "Configure access to logs to follow the principle of least privilege in particular where technically possible limit the log fields users have access to to prevent accidental exposure to sensitive information such as PII.", @@ -1795,7 +2543,7 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Review field level access controls on log data. ", + "Recommendation": "Review field level access controls on log data.", "SectionThreatMappings": [ { "ReferenceId": "CCC", @@ -1826,10 +2574,7 @@ } ], "Checks": [ - "monitor_diagnostic_settings_exists", - "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" + "monitor_storage_account_with_activity_logs_is_private" ] }, { @@ -1838,7 +2583,7 @@ "Attributes": [ { "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", + "FamilyDescription": "Controls that restrict who can access and modify logs.", "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", "SubSection": "", "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", @@ -1847,12 +2592,12 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -1875,8 +2620,6 @@ ], "Checks": [ "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_diagnostic_settings_exists", "storage_blob_public_access_level_is_disabled" ] }, @@ -1886,7 +2629,7 @@ "Attributes": [ { "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", + "FamilyDescription": "Controls that restrict who can access and modify logs.", "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", "SubSection": "", "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", @@ -1895,12 +2638,12 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -1923,10 +2666,7 @@ ], "Checks": [ "storage_blob_public_access_level_is_disabled", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_diagnostic_settings_exists", - "storage_geo_redundant_enabled" + "monitor_storage_account_with_activity_logs_is_private" ] }, { @@ -1935,7 +2675,7 @@ "Attributes": [ { "FamilyName": "Logging and Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain logging-related events. ", + "FamilyDescription": "Controls that collect, alert, and retain logging-related events.", "Section": "CCC.Logging.CN06 Detect and Alert on Potential Log Exfiltration", "SubSection": "", "SubSectionObjective": "Identify and alert on anomalous data access patterns that may indicate an attempt to exfiltrate log data.", @@ -1972,9 +2712,7 @@ ] } ], - "Checks": [ - "apim_threat_detection_llm_jacking" - ] + "Checks": [] }, { "Id": "CCC.Logging.CN07.AR01", @@ -1982,7 +2720,7 @@ "Attributes": [ { "FamilyName": "Logging and Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain logging-related events. ", + "FamilyDescription": "Controls that collect, alert, and retain logging-related events.", "Section": "CCC.Logging.CN07 Detect and Alert on Log Service Tampering", "SubSection": "", "SubSectionObjective": "Alert when any component of the critical logging infrastructure is disabled, modified, or deleted, indicating a defense evasion attempt.", @@ -1997,7 +2735,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.Core.TH16" ] } ], @@ -2020,921 +2758,14 @@ ] } ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_alert_create_update_nsg", - "monitor_alert_delete_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_delete_security_solution", - "monitor_alert_create_policy_assignment", - "monitor_alert_delete_policy_assignment", - "monitor_alert_service_health_exists", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_sqlserver_fr" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR01", - "Description": "When a request is made to read a protected bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "databricks_workspace_cmk_encryption_enabled", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR02", - "Description": "When a request is made to read a protected object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "keyvault_rbac_enabled", - "keyvault_private_endpoints", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR03", - "Description": "When a request is made to write to a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "storage_ensure_private_endpoints_in_storage_accounts", - "keyvault_rbac_enabled", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR04", - "Description": "When a request is made to write to an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "databricks_workspace_cmk_encryption_enabled", - "monitor_storage_account_with_activity_logs_cmk_encrypted" - ] - }, - { - "Id": "CCC.ObjStor.CN03.AR01", - "Description": "When an object storage bucket deletion is attempted, the bucket MUST be fully recoverable for a set time-frame after deletion is requested.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "CCC.ObjStor.C03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", - "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN03.AR02", - "Description": "When an attempt is made to modify the retention policy for an object storage bucket, the service MUST prevent the policy from being modified.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "CCC.ObjStor.C03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", - "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], "Checks": [] }, - { - "Id": "CCC.ObjStor.CN04.AR01", - "Description": "When an object is uploaded to the object storage system, the object MUST automatically receive a default retention policy that prevents premature deletion or modification.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN04 Log All Access and Changes", - "SubSection": "CCC.ObjStor.C04 Objects have an Effective Retention Policy by Default", - "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects and ensuring compliance with data retention regulations.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled", - "storage_ensure_soft_delete_is_enabled", - "storage_ensure_file_shares_soft_delete_is_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN04.AR02", - "Description": "When an attempt is made to delete or modify an object that is subject to an active retention policy, the service MUST prevent the action from being completed.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN04 Log All Access and Changes", - "SubSection": "CCC.ObjStor.C04 Objects have an Effective Retention Policy by Default", - "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects and ensuring compliance with data retention regulations.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_soft_delete_is_enabled", - "storage_ensure_file_shares_soft_delete_is_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR01", - "Description": "When an object is uploaded to the object storage bucket, the object MUST be stored with a unique identifier.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR02", - "Description": "When an object is modified, the service MUST assign a new unique identifier to the modified object to differentiate it from the previous version.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR03", - "Description": "When an object is modified, the service MUST allow for recovery of previous versions of the object.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR04", - "Description": "When an object is deleted, the service MUST retain other versions of the object to allow for recovery of previous versions.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN06.AR01", - "Description": "When an object storage bucket is accessed, the service MUST store access logs in a separate data store.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN06 Prevent Deployment in Restricted Regions", - "SubSection": "CCC.ObjStor.C06 Access Logs are Stored in a Separate Data Store", - "SubSectionObjective": "Ensure that access logs for object storage buckets are stored in a separate data store to protect against unauthorized access, tampering, or deletion of logs (Logbuckets are exempt from this requirement, but must be tlp-red).", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH07", - "CCC.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-6" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-07", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.15.0" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-9", - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" - ] - }, - { - "Id": "CCC.ObjStor.CN02.AR01", - "Description": "When a permission set is allowed for an object in a bucket, the service MUST allow the same permission set to access all objects in the same bucket.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN02 Ensure Data Encryption at Rest for All Stored Data", - "SubSection": "CCC.ObjStor.C02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", - "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-09" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_public_access_level_is_disabled", - "storage_default_network_access_rule_is_denied", - "storage_ensure_private_endpoints_in_storage_accounts" - ] - }, - { - "Id": "CCC.ObjStor.CN02.AR02", - "Description": "When a permission set is denied for an object in a bucket, the service MUST deny the same permission set to access all objects in the same bucket.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN02 Ensure Data Encryption at Rest for All Stored Data", - "SubSection": "CCC.ObjStor.C02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", - "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-09" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_public_access_level_is_disabled", - "storage_account_key_access_disabled", - "storage_default_to_entra_authorization_enabled", - "storage_ensure_private_endpoints_in_storage_accounts" - ] - }, { "Id": "CCC.Monitor.CN01.AR01", "Description": "When an External Monitoring system exceeds the anticipated rate of monitoring checks then Rate Limiting MUST be applied and an Audit Alert MUST be generated.", "Attributes": [ { - "FamilyName": "Logging & Monitoring", + "FamilyName": "Logging and Monitoring", "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", "Section": "CCC.Monitor.CN01 Rate Limiting on External Monitoring", "SubSection": "", @@ -2972,25 +2803,14 @@ ] } ], - "Checks": [ - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_update_nsg", - "monitor_alert_service_health_exists", - "monitor_diagnostic_settings_exists" - ] + "Checks": [] }, { "Id": "CCC.Monitor.CN02.AR01", "Description": "When an Custom or User-Defined Metric starts to flood a collector, then a rate limit MUST be applied to reduce the network impact of traffic and an alert must triggered.", "Attributes": [ { - "FamilyName": "Logging & Monitoring", + "FamilyName": "Logging and Monitoring", "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", "Section": "CCC.Monitor.CN02 Rate Limiting on Metric Generation", "SubSection": "", @@ -3028,9 +2848,7 @@ ] } ], - "Checks": [ - "apim_threat_detection_llm_jacking" - ] + "Checks": [] }, { "Id": "CCC.Monitor.CN03.AR01", @@ -3075,10 +2893,7 @@ ] } ], - "Checks": [ - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" - ] + "Checks": [] }, { "Id": "CCC.Monitor.CN04.AR01", @@ -3146,7 +2961,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH10" + "CCC.Core.TH10" ] } ], @@ -3212,11 +3027,1111 @@ ], "Checks": [ "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_function_access_keys_configured", - "app_function_not_publicly_accessible", - "app_register_with_identity", - "app_ensure_auth_is_set_up" + "app_function_access_keys_configured" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR01", + "Description": "When a request is made to read a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_encryption_with_customer_managed_keys", + "keyvault_rbac_enabled", + "keyvault_private_endpoints" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR02", + "Description": "When a request is made to read an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_encryption_with_customer_managed_keys", + "keyvault_rbac_enabled", + "keyvault_private_endpoints" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR03", + "Description": "When a request is made to write to a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_encryption_with_customer_managed_keys", + "keyvault_rbac_enabled", + "keyvault_private_endpoints" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR04", + "Description": "When a request is made to write to an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_encryption_with_customer_managed_keys", + "keyvault_rbac_enabled", + "keyvault_private_endpoints" + ] + }, + { + "Id": "CCC.ObjStor.CN03.AR01", + "Description": "When an object storage bucket deletion is attempted, the bucket MUST be fully recoverable for a set time-frame after deletion is requested.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", + "SubSection": "", + "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN03.AR02", + "Description": "When an attempt is made to modify the retention policy for an object storage bucket, the service MUST prevent the policy from being modified.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", + "SubSection": "", + "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_soft_delete_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN04.AR01", + "Description": "When an object is uploaded to the object storage system, the object MUST automatically receive a default retention policy that prevents premature deletion or modification.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN04 Objects have an Effective Retention Policy by Default", + "SubSection": "", + "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + }, + { + "ReferenceId": "CCC.ObjStor", + "Identifiers": [ + "CCC.ObjStor.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_soft_delete_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN04.AR02", + "Description": "When an attempt is made to delete or modify an object that is subject to an active retention policy, the service MUST prevent the action from being completed.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN04 Objects have an Effective Retention Policy by Default", + "SubSection": "", + "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + }, + { + "ReferenceId": "CCC.ObjStor", + "Identifiers": [ + "CCC.ObjStor.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_ensure_soft_delete_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR01", + "Description": "When an object is uploaded to the object storage bucket, the object MUST be stored with a unique identifier.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_blob_versioning_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR02", + "Description": "When an object is modified, the service MUST assign a new unique identifier to the modified object to differentiate it from the previous version.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_blob_versioning_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR03", + "Description": "When an object is modified, the service MUST allow for recovery of previous versions of the object.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_blob_versioning_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR04", + "Description": "When an object is deleted, the service MUST retain other versions of the object to allow for recovery of previous versions.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "storage_blob_versioning_is_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN07.AR01", + "Description": "The object storage service MUST support a configuration option that requires MFA to be successfully completed before any object deletion can be attempted, regardless of the request interface.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.ObjStor.CN07.AR02", + "Description": "When MFA deletion protection is enabled on a bucket or object namespace, the service MUST deny any deletion request from an identity that has not satisfied the MFA requirement at the time of the request.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.ObjStor.CN07.AR03", + "Description": "When an attempt is made to delete an object, the service's audit logs MUST clearly record each deletion attempt, including whether MFA was required and whether validation was met.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.ObjStor.CN02.AR01", + "Description": "When a permission set is allowed for an object in a bucket, the service MUST allow the same permission set to access all objects in the same bucket.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources.", + "Section": "CCC.ObjStor.CN02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", + "SubSection": "", + "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + } + ] + } + ], + "Checks": [ + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN02.AR02", + "Description": "When a permission set is denied for an object in a bucket, the service MUST deny the same permission set to access all objects in the same bucket.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources.", + "Section": "CCC.ObjStor.CN02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", + "SubSection": "", + "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + } + ] + } + ], + "Checks": [ + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled" + ] + }, + { + "Id": "CCC.LB.CN01.AR01", + "Description": "When a single client sends more than 2000 requests within any 5-minute sliding window, the load balancer MUST throttle all subsequent requests from that client for at least 60 seconds.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", + "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", + "SubSection": "", + "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement per-IP token-bucket limits with and verify via synthetic traffic tests.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH01", + "CCC.LB.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-1", + "PR.AC-7", + "PR.PT-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-6", + "SC-5", + "AC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.LB.CN01.AR02", + "Description": "When throttling is invoked, the load balancer MUST record the event in the access log within 5 minutes for alerting and trend analysis.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", + "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", + "SubSection": "", + "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Enable access logging and configure metric filters on HTTP 429 counts to trigger alerts.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH01", + "CCC.LB.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-1", + "PR.AC-7", + "PR.PT-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-6", + "SC-5", + "AC-7" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists" + ] + }, + { + "Id": "CCC.LB.CN06.AR01", + "Description": "When more than 10 percent of targets change from healthy to unhealthy within five minutes, an alert MUST be issued.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", + "Section": "CCC.LB.CN06 Secure Health-Check Telemetry", + "SubSection": "", + "SubSectionObjective": "Monitor health-check endpoints for tampering and alert on abnormal status changes.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Instrument metrics for health check results and target removal events. Configure monitoring alarms to alert on abnormal spikes in unhealthy targets.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.AE-2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4" + ] + } + ] + } + ], + "Checks": [ + "monitor_alert_service_health_exists" + ] + }, + { + "Id": "CCC.LB.CN04.AR01", + "Description": "When routing weights change, the request MUST originate from an explicitly defined and trusted identity and MUST be logged.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", + "Section": "CCC.LB.CN04 Enforce Distribution Policies", + "SubSection": "", + "SubSectionObjective": "Ensure traffic-splitting weights and algorithms are modified only by trusted identities.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Define a list of trusted principals allowed to modify routing configurations. Enforce via conditional access policies, and log changes using audit logging.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists" + ] + }, + { + "Id": "CCC.LB.CN05.AR01", + "Description": "When stickiness is enabled, session cookies MUST expire within 30 minutes of inactivity.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", + "Section": "CCC.LB.CN05 Validate Session Affinity", + "SubSection": "", + "SubSectionObjective": "Configure session persistence to minimise fixation and hijacking risks.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Audit CCC.LB.CP15 parameters via configuration scans.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-7" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-23" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.LB.CN09.AR01", + "Description": "When an API call originates outside the approved CIDR set, the request MUST be denied.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", + "Section": "CCC.LB.CN09 Restrict Management API Access", + "SubSection": "", + "SubSectionObjective": "Limit load-balancer API calls to authorised identities and trusted networks.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Combine VPC endpoints with IAM condition-key filters for protected APIs.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH08" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api" + ] + }, + { + "Id": "CCC.LB.CN02.AR01", + "Description": "When concurrent connections reach 80 percent of capacity, the autoscaling group MUST add at least one instance within five minutes.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer.", + "Section": "CCC.LB.CN02 Auto-Scale Load Balancer Capacity", + "SubSection": "", + "SubSectionObjective": "Expand load-balancer capacity to maintain availability during traffic spikes.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Enable autoscaling policies.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "ID.BE-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "CP-10" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.LB.CN07.AR01", + "Description": "When responses pass through the load balancer, the \"Server\" header MUST be replaced with \"lb\".", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer.", + "Section": "CCC.LB.CN07 Scrub Sensitive Headers", + "SubSection": "", + "SubSectionObjective": "Remove headers that disclose internal details or software versions from HTTP responses.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Configure header-transformation rules.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.Core.TH15" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-13" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.LB.CN08.AR01", + "Description": "When a certificate is within 30 days of expiry, automated renewal MUST complete and deploy a new certificate within 24 hours.", + "Attributes": [ + { + "FamilyName": "Encryption", + "FamilyDescription": "Controls that ensure trustworthy TLS certificates and ciphers.", + "Section": "CCC.LB.CN08 Automate Certificate Renewal", + "SubSection": "", + "SubSectionObjective": "Maintain valid TLS certificates by automating renewal and deployment before expiry.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Use certificate-manager auto-renewal workflows.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH07" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-17" + ] + } + ] + } + ], + "Checks": [ + "keyvault_key_rotation_enabled" ] }, { @@ -3272,6 +4187,58 @@ ], "Checks": [] }, + { + "Id": "CCC.VPC.CN02.AR01", + "Description": "When a resource is created in a public subnet, that resource MUST NOT be assigned an external IP address by default.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.VPC.CN02 Limit Resource Creation in Public Subnet", + "SubSection": "", + "SubSectionObjective": "Restrict the creation of resources in the public subnet with direct access to the internet to minimize attack surfaces.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-4" + ] + } + ] + } + ], + "Checks": [] + }, { "Id": "CCC.VPC.CN03.AR01", "Description": "When a VPC peering connection is requested, the service MUST prevent connections from VPCs that are not explicitly allowed.", @@ -3379,10 +4346,393 @@ ], "Checks": [ "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days", "network_watcher_enabled" ] }, + { + "Id": "CCC.KeyMgmt.CN01.AR01", + "Description": "When a key version is scheduled for deletion or disabled, an alert MUST be generated within five minutes.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain key-management events.", + "Section": "CCC.KeyMgmt.CN01 Alert on Key-version Changes", + "SubSection": "", + "SubSectionObjective": "Generate near-real-time alerts when a KMS key version is disabled or scheduled for deletion, enabling rapid investigation and recovery.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Use native event services (e.g., CloudWatch Events, Azure Monitor, Cloud Audit Logs) to route notifications to an incident-response channel.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "RS.AN-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IR-5" + ] + } + ] + } + ], + "Checks": [ + "keyvault_logging_enabled", + "keyvault_recoverable" + ] + }, + { + "Id": "CCC.KeyMgmt.CN02.AR01", + "Description": "When IAM roles and key policies are reviewed, Decrypt permission MUST be granted exclusively to documented authorised principals.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that enforce least-privilege use of KMS operations.", + "Section": "CCC.KeyMgmt.CN02 Limit Decrypt Permissions", + "SubSection": "", + "SubSectionObjective": "Restrict the Decrypt operation to authorised principals only, applying the principle of least privilege to protect sensitive data.", + "Applicability": [ + "tlp-green" + ], + "Recommendation": "Periodically audit policy documents via automated tooling and report any deviations.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "keyvault_rbac_enabled", + "keyvault_access_only_through_private_endpoints" + ] + }, + { + "Id": "CCC.KeyMgmt.CN03.AR01", + "Description": "When rotation settings are examined, rotation MUST be enabled with an interval not exceeding 365 days.", + "Attributes": [ + { + "FamilyName": "Key Lifecycle Management", + "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", + "Section": "CCC.KeyMgmt.CN03 Enforce Automatic Rotation", + "SubSection": "", + "SubSectionObjective": "Ensure symmetric keys rotate automatically within policy intervals to reduce exposure of key material.", + "Applicability": [ + "tlp-green" + ], + "Recommendation": "Use cloud-provider rotation features and verify via configuration scanning.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-12" + ] + } + ] + } + ], + "Checks": [ + "keyvault_key_rotation_enabled" + ] + }, + { + "Id": "CCC.KeyMgmt.CN04.AR01", + "Description": "When a key import request is processed, the key MUST use an approved algorithm (RSA-2048+, EC-P256+) and originate from a certified HSM.", + "Attributes": [ + { + "FamilyName": "Key Lifecycle Management", + "FamilyDescription": "Controls that govern creation, rotation, import, and retirement of cryptographic keys.", + "Section": "CCC.KeyMgmt.CN04 Validate Imported Keys", + "SubSection": "", + "SubSectionObjective": "Accept only externally generated keys that meet approved cryptographic strength and provenance requirements.", + "Applicability": [ + "tlp-green" + ], + "Recommendation": "Implement an approval workflow that validates attestation data before import.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.KeyMgmt.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-28" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.SecMgmt.CN01.AR01", + "Description": "Attempt to use an outdated version of a secret after its rotation period has passed and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SecMgmt.CN01 Enforce Automatic Secret Rotation", + "SubSection": "", + "SubSectionObjective": "Ensure that secrets are automatically rotated on a defined schedule to reduce the risk of secret compromise and unauthorized access.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH14" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-12", + "SC-28" + ] + } + ] + } + ], + "Checks": [ + "keyvault_rbac_secret_expiration_set", + "keyvault_non_rbac_secret_expiration_set" + ] + }, + { + "Id": "CCC.SecMgmt.CN02.AR01", + "Description": "Attempt to retrieve a secret from an unauthorized region and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SecMgmt.CN02 Enforce Secret Replication Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that secrets are replicated only to authorized locations as per organizational data residency and compliance requirements.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "SC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.DataWar.CN01.AR01", + "Description": "Attempt to access underlying database tables directly without using managed views and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN01 Enforce Use of Managed Views for Data Access", + "SubSection": "", + "SubSectionObjective": "Ensure that data access is provided through managed views, restricting users from accessing underlying tables directly and enforcing consistent security policies.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.DataWar.CN02.AR01", + "Description": "Attempt to query sensitive columns without the necessary permissions and verify that access is denied or data is masked.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN02 Enforce Column-Level Security Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that access to sensitive data columns is restricted based on user roles, preventing unauthorized access to sensitive information.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.DataWar.CN03.AR01", + "Description": "Attempt to query data rows that the user should not have access to and verify that access is denied or data is not returned.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN03 Enforce Row-Level Security Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that access to data rows is restricted based on user roles or attributes, preventing unauthorized access to specific subsets of data.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, { "Id": "CCC.Vector.CN01.AR01", "Description": "When a vector embedding is submitted for indexing, the system MUST validate that it matches expected schema, dimension, and format profiles.", @@ -3406,7 +4756,7 @@ "Identifiers": [ "CCC.Vector.TH02", "CCC.Vector.TH05", - "CCC.TH12" + "CCC.Core.TH12" ] } ], @@ -3445,7 +4795,7 @@ "Identifiers": [ "CCC.Vector.TH02", "CCC.Vector.TH04", - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -3459,13 +4809,7 @@ ] } ], - "Checks": [ - "iam_role_user_access_admin_restricted", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created", - "app_function_identity_without_admin_privileges", - "app_function_identity_is_configured" - ] + "Checks": [] }, { "Id": "CCC.Vector.CN03.AR01", @@ -3487,7 +4831,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH03", - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -3526,7 +4870,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH02", - "CCC.TH12" + "CCC.Core.TH12" ] } ], @@ -3562,8 +4906,8 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH04", - "CCC.TH09", - "CCC.TH04" + "CCC.Core.TH09", + "CCC.Core.TH04" ] } ], @@ -3601,7 +4945,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH05", - "CCC.TH06" + "CCC.Core.TH06" ] } ], @@ -3646,451 +4990,25 @@ "Checks": [] }, { - "Id": "CCC.Core.CN01.AR01", - "Description": "When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + "Id": "CCC.RDMS.CN01.AR02", + "Description": "When an attempt is made to authenticate to the database using known default credentials, the authentication attempt must fail and no access should be granted.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN01 Password Management", "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Most cloud services enable TLS 1.3 by default. Where it is not already set, ensure that your services are configured or updated accordingly. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "app_ensure_http_is_redirected_to_https", - "storage_secure_transfer_required_is_enabled", - "storage_ensure_minimum_tls_version_12" - ] - }, - { - "Id": "CCC.Core.CN01.AR02", - "Description": "When a port is exposed for SSH network traffic, all traffic MUST include a SSH handshake AND be encrypted using SSHv2 or higher.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Any time port 22 is exposed, ensure that it has a properly implemented SSH server with SSHv2 enabled and configured with strong ciphers. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "network_ssh_internet_access_restricted", - "vm_linux_enforce_ssh_authentication", - "app_minimum_tls_version_12", - "storage_secure_transfer_required_is_enabled", - "storage_ensure_minimum_tls_version_12", - "app_ensure_http_is_redirected_to_https" - ] - }, - { - "Id": "CCC.Core.CN01.AR03", - "Description": "When the service receives unencrypted traffic, then it MUST either block the request or automatically redirect it to the secure equivalent.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Review firewall, load balancer, and application configurations to ensure insecure protocols such as HTTP, FTP, and Telnet are not exposed. Where possible, implement automatic redirection to secure protocols such as HTTPS, SFTP, SSH, and regularly scan for protocol drift. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_http_is_redirected_to_https", - "app_minimum_tls_version_12", - "storage_secure_transfer_required_is_enabled", - "storage_ensure_minimum_tls_version_12", - "app_ftp_deployment_disabled" - ] - }, - { - "Id": "CCC.Core.CN01.AR07", - "Description": "When a port is exposed, the service MUST ensure that the protocol and service officially assigned to that port number by the IANA Service Name and Transport Protocol Port Number Registry, and no other, is run on that port.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Reference the IANA Service Name and Transport Protocol Port Number Registry for more information about correct protocol-to-port assignments. Avoid running non-standard services on well-known ports. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "app_ensure_http_is_redirected_to_https", - "app_ensure_using_http20", - "storage_smb_channel_encryption_with_secure_algorithm", - "storage_secure_transfer_required_is_enabled", - "storage_ensure_minimum_tls_version_12", - "storage_smb_protocol_version_is_latest" - ] - }, - { - "Id": "CCC.Core.CN01.AR08", - "Description": "When a service transmits data using TLS, mutual TLS (mTLS) MUST be implemented to require both client and server certificate authentication for all connections.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Configure mTLS for all endpoints that process or transmit sensitive data. Ensure both client and server certificates are validated and managed securely. Regularly review certificate authorities and automate certificate rotation where possible. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "app_client_certificates_on", - "app_minimum_tls_version_12", - "app_ensure_http_is_redirected_to_https" - ] - }, - { - "Id": "CCC.Core.CN13.AR01", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST only use valid, unexpired certificates issued by a trusted certificate authority.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "app_client_certificates_on", - "app_minimum_tls_version_12", - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set", - "keyvault_non_rbac_secret_expiration_set" - ] - }, - { - "Id": "CCC.Core.CN13.AR02", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 180 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "SubSectionObjective": "Ensure default vendor-supplied DB administrator credentials are replaced with strong, unique passwords and that these credentials are properly managed using a secure password or secrets management solution.", "Applicability": [ + "tlp-red", "tlp-amber" ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "keyvault_key_rotation_enabled", - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_rbac_key_expiration_set", - "keyvault_non_rbac_secret_expiration_set", - "keyvault_rbac_secret_expiration_set", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "CCC.Core.CN13.AR03", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 90 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "app_client_certificates_on", - "app_ensure_http_is_redirected_to_https", - "app_minimum_tls_version_12" - ] - }, - { - "Id": "CCC.Core.CN06.AR01", - "Description": "When the service is running, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", - "SubSection": "", - "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate the service's deployment location is included in this list. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH03" + "CCC.RDMS.TH01" ] } ], @@ -4098,19 +5016,89 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.AA-01" ] }, { - "ReferenceId": "CCM", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "DSP-19" + "AC-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.RDMS.CN02.AR01", + "Description": "When repeated failed login attempts are made in a short timeframe, the account must be locked out or rate-limited to prevent further login attempts.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN02 Account Lockout and Rate-Limiting", + "SubSection": "", + "SubSectionObjective": "Ensure the database enforces lockouts or rate-limiting after a specified number of failed authentication attempts. This prevents brute force or password-guessing attacks from succeeding.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-1" ] }, { - "ReferenceId": "ISO_27001", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "2013 A.11.1.1" + "AC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.RDMS.CN04.AR01", + "Description": "When there is an attempt to perform a backup or restore, then the attempt must fail with an access denied message if credentials or roles that are not explicitly authorized for backup/restore functions.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN04 Access Control for Backup and Restore Operations", + "SubSection": "", + "SubSectionObjective": "Restrict who can initiate, manage, and validate database backup or restore operations through strict role-based or least-privilege access. Prevents accidental or malicious restorations, protecting data integrity and availability.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" ] }, { @@ -4122,44 +5110,28 @@ ] } ], - "Checks": [ - "aks_clusters_public_access_disabled", - "aks_clusters_created_with_private_nodes", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints", - "storage_ensure_private_endpoints_in_storage_accounts", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted", - "network_watcher_enabled", - "entra_trusted_named_locations_exists" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN06.AR02", - "Description": "When a child resource is deployed, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Id": "CCC.RDMS.CN05.AR01", + "Description": "When an attempt is made to share a snapshot with an unauthorized account, the sharing request must be denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN05 Restrict Snapshot Sharing to Authorized Accounts", "SubSection": "", - "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "SubSectionObjective": "Ensure database snapshots can only be shared with explicitly authorized accounts, thereby minimizing the risk of data exposure or exfiltration.", "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], - "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate that child resources can only be deployed to locations included in this list. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH03" + "CCC.RDMS.TH05" ] } ], @@ -4167,24 +5139,312 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-19" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.11.1.1" + "PR.DS-10" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ + "AC-4" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.RDMS.CN03.AR01", + "Description": "When backups are disabled, paused, or fail to run as scheduled, an alert must be triggered and logged.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN03 Enforce and Monitor Automated Backups", + "SubSection": "", + "SubSectionObjective": "Ensure database backups are automatically scheduled, actively monitored, and promptly reported if any disruptions occur. This helps maintain data integrity, facilitates disaster recovery, and supports business continuity when a system failure or breach occurs.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "CP-9" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Build.CN01.AR01", + "Description": "Attempt to initiate a build using an unauthorized build agent and verify that the build is rejected.", + "Attributes": [ + { + "FamilyName": "Access Control", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN01 Restrict Allowed Build Agents", + "SubSection": "", + "SubSectionObjective": "Ensure that builds are executed only on authorized build agents to maintain control over the build environment and prevent unauthorized code execution.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Build.CN02.AR01", + "Description": "Attempt to trigger a build from an unauthorized external service or repository and verify that the build does not start.", + "Attributes": [ + { + "FamilyName": "Access Control", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN02 Restrict Allowed External Services for Build Triggers", + "SubSection": "", + "SubSectionObjective": "Ensure that builds can only be triggered by authorized external services or repositories to prevent unauthorized code execution or tampering.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Build.CN03.AR01", + "Description": "Attempt to access the build environment from an external network and verify that access is denied.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN03 Deny External Network Access for Build Environments", + "SubSection": "", + "SubSectionObjective": "Ensure that build environments do not have external network access to prevent unauthorized external access and data exfiltration.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02", + "CCC.Core.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7", + "SC-5" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.CntrReg.CN01.AR01", + "Description": "Attempt to push an artifact with known vulnerabilities to the registry and observe if it is flagged or rejected by the vulnerability scanning process.", + "Attributes": [ + { + "FamilyName": "Risk Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.CntrReg.CN01 Implement Vulnerability Scanning for Artifacts", + "SubSection": "", + "SubSectionObjective": "Ensure that container images and artifacts stored in the container registry are scanned for vulnerabilities to identify and remediate security issues before deployment.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.CntrReg.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "ID.RA-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "RA-5", + "SI-5" + ] + } + ] + } + ], + "Checks": [ + "defender_container_images_scan_enabled", + "defender_container_images_resolved_vulnerabilities" + ] + }, + { + "Id": "CCC.CntrReg.CN02.AR01", + "Description": "Confirm that artifacts older than the specified retention period are automatically deleted from the registry.", + "Attributes": [ + { + "FamilyName": "Data Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.CntrReg.CN02 Implement Cleanup Policies for Artifacts", + "SubSection": "", + "SubSectionObjective": "Ensure that unused or outdated artifacts are cleaned up according to defined policies to manage storage effectively and reduce security risks associated with outdated versions.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH14" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN01.AR01", + "Description": "When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating credentials or generating temporary session tokens.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN01 Restrict IAM User Credentials Creation", + "SubSection": "", + "SubSectionObjective": "Prevent non-administrative principals from creating new long-lived credentials like access keys or generating temporary session tokens. This blocks a common privilege escalation and persistence vector.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-5", "AC-6" ] } @@ -4192,127 +5452,20 @@ } ], "Checks": [ - "entra_trusted_named_locations_exists" + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps" ] }, { - "Id": "CCC.Core.CN08.AR01", - "Description": "When data is created or modified, the data MUST have a complete and recoverable duplicate that is stored in a physically separate data center.", + "Id": "CCC.IAM.CN01.AR02", + "Description": "When a non-administrative principal attempts to create new credentials or a temporary session token, the service MUST deny the action.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN01 Restrict IAM User Credentials Creation", "SubSection": "", - "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement automated data replication processes to ensure that data is consistently duplicated in another region or availability zone. Regularly test data recovery from the replicated location to ensure integrity and availability. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PT-5" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "BCR-08", - "BCR-10", - "BCR-11" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "CP-2", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "storage_geo_redundant_enabled", - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "CCC.Core.CN08.AR02", - "Description": "When data is replicated into a second location, the service MUST be able to accurately represent the replication locations, replication status, and data synchronization status.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", - "SubSection": "", - "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PT-5" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "BCR-08", - "BCR-10", - "BCR-11" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "CP-2", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "storage_geo_redundant_enabled" - ] - }, - { - "Id": "CCC.Core.CN09.AR01", - "Description": "When the service is operational, its logs and any child resource logs MUST NOT be accessible from the resource they record access to.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", - "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Prevent non-administrative principals from creating new long-lived credentials like access keys or generating temporary session tokens. This blocks a common privilege escalation and persistence vector.", "Applicability": [ "tlp-clear", "tlp-green", @@ -4324,9 +5477,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH03" ] } ], @@ -4334,57 +5485,48 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" ] } ] } ], "Checks": [ - "monitor_diagnostic_settings_exists", - "network_flow_log_captured_sent", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps" ] }, { - "Id": "CCC.Core.CN09.AR02", - "Description": "When the service is operational, disabling the logs for the service or its child resources MUST NOT be possible without also disabling the corresponding resource.", + "Id": "CCC.IAM.CN02.AR01", + "Description": "When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating, updating, or attaching policies.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN02 Restrict IAM Policies Modification", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Ensure that only designated administrative accounts have the ability to create, modify, or attach policies that define permissions for other identities.", "Applicability": [ "tlp-clear", "tlp-green", "tlp-amber", "tlp-red" ], - "Recommendation": "No normal business operations should disable logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging mechanisms are tightly integrated with service operations, so that logging cannot be disabled without stopping the service itself. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH06" ] } ], @@ -4392,57 +5534,49 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" ] } ] } ], "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "keyvault_logging_enabled", - "app_http_logs_enabled" + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "iam_custom_role_has_permissions_to_administer_resource_locks" ] }, { - "Id": "CCC.Core.CN09.AR03", - "Description": "When the service is operational, any attempt to redirect logs for the service or its child resources MUST NOT be possible without halting operation of the corresponding resource and publishing corresponding events to monitored channels.", + "Id": "CCC.IAM.CN02.AR02", + "Description": "When a non-administrative principal attempts to create, update, or attach policies, the service MUST deny the action.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN02 Restrict IAM Policies Modification", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Ensure that only designated administrative accounts have the ability to create, modify, or attach policies that define permissions for other identities.", "Applicability": [ + "tlp-clear", + "tlp-green", "tlp-amber", "tlp-red" ], - "Recommendation": "No normal business operations should result in the redirection of logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging configurations are immutable during service operation so that any changes require stopping the service and publishing corresponding events to monitored channels. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH06" ] } ], @@ -4450,46 +5584,37 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" ] } ] } ], "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "network_flow_log_captured_sent", - "app_http_logs_enabled", - "keyvault_logging_enabled" + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "iam_custom_role_has_permissions_to_administer_resource_locks" ] }, { - "Id": "CCC.Core.CN10.AR01", - "Description": "When data is replicated, the service MUST ensure that replication only occurs to destinations that are explicitly included within the defined trust perimeter.", + "Id": "CCC.IAM.CN03.AR01", + "Description": "When a policy is created or updated that grants a principal permission to assume a role or impersonate a service identity, the principal MUST NOT contain a wildcard or be public/anonymous.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN10 Restrict Data Replication to Trust Perimeter", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN03 Restrict Role Assumption / Delegation", "SubSection": "", - "SubSectionObjective": "Ensure that data is only replicated on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "SubSectionObjective": "Limit which principals can assume a role or impersonate a service identity to only those required. This prevents unintended cross-account or public access by securing the \"who can act as this identity\" boundary.", "Applicability": [ "tlp-green", "tlp-amber", @@ -4500,7 +5625,1438 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH04" + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_role_user_access_admin_restricted" + ] + }, + { + "Id": "CCC.IAM.CN03.AR02", + "Description": "When an external or unauthenticated principal tries to assume a role or impersonate a service identity, the service MUST deny the action.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN03 Restrict Role Assumption / Delegation", + "SubSection": "", + "SubSectionObjective": "Limit which principals can assume a role or impersonate a service identity to only those required. This prevents unintended cross-account or public access by securing the \"who can act as this identity\" boundary.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_role_user_access_admin_restricted" + ] + }, + { + "Id": "CCC.IAM.CN04.AR01", + "Description": "When an IAM policy is created or updated, it MUST NOT contain allow statements with wildcard permissions, unless the statement is restricted by a condition.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN04 Restrict Wildcard Usage in IAM Policies", + "SubSection": "", + "SubSectionObjective": "Limit the use of wildcard permissions in IAM policies to prevent overly broad access from being granted by default.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01", + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN05.AR01", + "Description": "When a new cloud account is provisioned, a password policy MUST be configured for IAM users following the minimum PCI DSS v4.0.1 configurations.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN05 Strong Password Policies for IAM Users", + "SubSection": "", + "SubSectionObjective": "Ensure that the password policies for IAM users have strong configurations.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "When a new cloud account is provisioned, a password policy must be configured for all IAM users to align with the minimum requirements defined in PCI DSS v4.0.1. This includes, at a minimum: strength: 0 # Not yet specified - reference-id: A password length of at least 12 characters. strength: 0 # Not yet specified - reference-id: A mix of upper- and lower-case letters, numbers, and special characters. strength: 0 # Not yet specified - reference-id: Prevention of the use of previously used passwords (password history). strength: 0 # Not yet specified - reference-id: Password expiration at a defined interval (e.g., every 90 days). strength: 0 # Not yet specified - reference-id: Account lockout after a defined number of failed login attempts.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IA-5" + ] + }, + { + "ReferenceId": "PCI-DSS", + "Identifiers": [ + "8.3.9", + "8.6.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN06.AR01", + "Description": "When a static credential such as an access key has existed for 90 days or more, it MUST be rotated.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN06 Maximum Age for Long-Term Static Credentials", + "SubSection": "", + "SubSectionObjective": "Ensure that long-lived static credentials like access keys are programmatically rotated within a defined time period to limit the window of opportunity if compromised.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "When a static credential such as an access key has existed for 90 days or more, it must be automatically rotated to reduce the risk of compromise due to long-term exposure. Organizations should implement automated checks to identify aging credentials and enforce rotation policies. Additionally, access key usage should be regularly monitored, and credentials that are no longer in use should be deactivated or deleted promptly. Where possible, prefer temporary, short-lived credentials over long-lived static ones to further minimize risk.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH09", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN07.AR01", + "Description": "When a user account is disabled or deleted in the organization's IdP, the corresponding cloud identity and its access policies MUST be disabled or deleted within 24 hours.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN07 Automate Identity De-provisioning", + "SubSection": "", + "SubSectionObjective": "Ensure that when an identity is terminated in the central Identity Provider (IdP), ts corresponding access to cloud resources is revoked automatically.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH10", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN08.AR01", + "Description": "When an IAM user has credentials, such as passwords or access keys, that have not been used for 90 days or more, the unused credentials MUST be removed or deactivated.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN08 Maximum Age for Unused Credentials", + "SubSection": "", + "SubSectionObjective": "Ensure that unused IAM credentals are removed to reduce exposure in the event of potential compromise.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "IAM user credentials (such as passwords or access keys) that have not been used for 90 days or more must be automatically removed or deactivated.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH11", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN09.AR01", + "Description": "When a human user accesses the cloud environment, they MUST authenticate through the organization's federated IdP via a standard protocol (e.g., SAML, OIDC).", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN09 Enforce Federated Single Sign-On (SSO) for Human Users", + "SubSection": "", + "SubSectionObjective": "Ensure that all human users must authenticate through a central, federated Identity Provider (IdP) to access the cloud environment. This eliminates cloud-native user accounts with long-lived passwords, centralizes authentication controls, and simplifies lifecycle management.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01", + "CCC.IAM.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IA-2" + ] + } + ] + } + ], + "Checks": [ + "entra_security_defaults_enabled" + ] + }, + { + "Id": "CCC.IAM.CN10.AR01", + "Description": "When suspicious API requests are detected, real time alerts MUST be generated to notify security personnel.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN10 Alert On Anomalous Behaviour", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when anomalous API requests are made by a single identity, such as API requests commonly associated with privilege escalation tactics, originating from an external or malicious IP address or performed by a previously dormant identity, which may indicate that credentals may be compromised, as well as for password brute-force attempts and account lockouts.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "SI-5", + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists" + ] + }, + { + "Id": "CCC.IAM.CN10.AR02", + "Description": "When suspicious API requests are detected, the associated events MUST be logged, including the source details, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN10 Alert On Anomalous Behaviour", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when anomalous API requests are made by a single identity, such as API requests commonly associated with privilege escalation tactics, originating from an external or malicious IP address or performed by a previously dormant identity, which may indicate that credentals may be compromised, as well as for password brute-force attempts and account lockouts.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "SI-5", + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories" + ] + }, + { + "Id": "CCC.IAM.CN11.AR01", + "Description": "When a cloud account or organization is provisioned, the native automated access and usage analysis services MUST be enabled to continuously monitor for external or public access to resources, and unused access.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN11 Enable Continuous IAM Access and Usage Analysis", + "SubSection": "", + "SubSectionObjective": "Enable and configure the cloud provider's native access and usage analysis services to continuously monitor for external access paths and internal unused access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH02", + "CCC.IAM.TH10", + "CCC.IAM.TH11" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "ID.RA-01", + "ID.IM-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "CA-7", + "RA-5" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN01.AR01", + "Description": "Untrusted input such as user queries, RAG data or tool output MUST be validated before it is passed to a GenAI model.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN01 Model Input Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate input before it is passed to a GenAI model in order to filter or sanitise adversarial queries and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Input Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0021", + "AML.M0015" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN01.AR02", + "Description": "If malicious patterns such as prompt injection or sensitive data are detected during input validation, the input MUST be blocked or sanitised.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN01 Model Input Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate input before it is passed to a GenAI model in order to filter or sanitise adversarial queries and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Input Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0021", + "AML.M0015" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN02.AR01", + "Description": "GenAI model output MUST be validated for format conformance, malicious patterns, sensitive data and inapropriate content before being passed to users, application or plugins.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN02 Model Output Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate GenAI model output before passing it to users, applications or plugins in order to filter or sanitise insecure or unreliable output and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03", + "CCC.GenAI.TH04", + "CCC.GenAI.TH05", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Output Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0002" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN02.AR02", + "Description": "In the event of policy violations, the AI-generated content MUST be redacted, encoded or rejected.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN02 Model Output Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate GenAI model output before passing it to users, applications or plugins in order to filter or sanitise insecure or unreliable output and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03", + "CCC.GenAI.TH04", + "CCC.GenAI.TH05", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Output Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0002" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN03.AR01", + "Description": "When data is designated for model training or RAG ingestion, then its source MUST be explicitly approved and its provenance documented.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN03 Data Provenance and Source Vetting", + "SubSection": "", + "SubSectionObjective": "Ensure that all data for training, fine-tuning or RAG comes from trusted, approved sources and is authorised for the intended purposes in order to prevent the initial introduction of malicious content or leaked sensitive data.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-006" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Management" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0025" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN03.AR02", + "Description": "Data from unvetted sources MUST NOT be used in production systems.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN03 Data Provenance and Source Vetting", + "SubSection": "", + "SubSectionObjective": "Ensure that all data for training, fine-tuning or RAG comes from trusted, approved sources and is authorised for the intended purposes in order to prevent the initial introduction of malicious content or leaked sensitive data.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-006" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Management" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0025" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN04.AR01", + "Description": "When data is ingested for training, fine-tuning or conversion to vector embeddings, it MUST be validated for sensitive information or malicious content.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN04 Sanitisation of Ingested Data", + "SubSection": "", + "SubSectionObjective": "Validate and sanitise all data ingested by GenAI systems from extenal sources or internal knowledge bases, whether for training, conversion to vector embeddings, or real-time retireval, in order to remove or redact poisoned or sensitive data before further processing.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-002" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0007" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN04.AR02", + "Description": "If sensitive data or malicious content is detected, it must be rejected, redacted or flagged for manual review.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN04 Sanitisation of Ingested Data", + "SubSection": "", + "SubSectionObjective": "Validate and sanitise all data ingested by GenAI systems from extenal sources or internal knowledge bases, whether for training, conversion to vector embeddings, or real-time retireval, in order to remove or redact poisoned or sensitive data before further processing.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-002" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0007" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN05.AR01", + "Description": "When a RAG-enabled system generates a response containing information retrieved from its knowledge base, then the response MUST include a verifiable citation that links back to the specific source document.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN05 Citations and Source Traceability", + "SubSection": "", + "SubSectionObjective": "Require the GenAI system to provide citations or direct links back to the source documents used to generate a response, in to enhance the transparency, trustworthiness, and verifiability of AI-generated content.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH09", + "CCC.GenAI.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-DET-013" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN06.AR01", + "Description": "When an LLM invokes an external tool (e.g., an API, a plugin), then the tool MUST operate with the least privileges required for performing its intended functionality.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.GenAI.CN06 Least Privilege for Plugins", + "SubSection": "", + "SubSectionObjective": "Restricts the permissions of any external tools the GenAI system can call to limit the potential damage if an agent is coerced to perform unintended actions or vulnerabilities in the tools are exploited.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH07", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Agent Permissions" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN07.AR01", + "Description": "When an application makes an API call to a foundational model in a production environment, then it MUST specify an explicit version identifier.", + "Attributes": [ + { + "FamilyName": "Configuration Management", + "FamilyDescription": "The Configuration Management control family involves establishing, maintaining and monitoring the configuration of the service and related applications and infrastructure to ensure consistency, secure defaults and compliance.", + "Section": "CCC.GenAI.CN07 Model Version Pinning", + "SubSection": "", + "SubSectionObjective": "Mandate that applications are locked (\"pinned\") to a specific, tested version of a foundational model to prevent unexpected behaviour changes introduced by provider-side updates.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-010" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN08.AR01", + "Description": "When a new AI model is considered for production deployment, it MUST undergo a formal red teaming and quality assurance review.", + "Attributes": [ + { + "FamilyName": "Model Assurance and Evaluation", + "FamilyDescription": "The Model Assurance and Evaluation control family encompasses the proactiveand continuous processes of testing and validating the AI model's behavior to ensure it aligns with safety, ethical, and quality standards.", + "Section": "CCC.GenAI.CN08 Quality Control and Red Teaming", + "SubSection": "", + "SubSectionObjective": "Establish a formal program for quality evaluation and adversarial testing (red teaming) to ensure GenAI system meet all business, quality, security and compliance requirements before getting deployed into production environments.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH02", + "CCC.GenAI.TH04", + "CCC.GenAI.TH08", + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-005" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Adversarial Training and Testing", + "Red Teaming", + "Product Governance" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0008" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN08.AR02", + "Description": "If model quality review or red teaming identifies an issue that exceeds the organization's risk tolerance, the model MUST NOT be deployed until the issue is remediated.", + "Attributes": [ + { + "FamilyName": "Model Assurance and Evaluation", + "FamilyDescription": "The Model Assurance and Evaluation control family encompasses the proactiveand continuous processes of testing and validating the AI model's behavior to ensure it aligns with safety, ethical, and quality standards.", + "Section": "CCC.GenAI.CN08 Quality Control and Red Teaming", + "SubSection": "", + "SubSectionObjective": "Establish a formal program for quality evaluation and adversarial testing (red teaming) to ensure GenAI system meet all business, quality, security and compliance requirements before getting deployed into production environments.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH02", + "CCC.GenAI.TH04", + "CCC.GenAI.TH08", + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-005" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Adversarial Training and Testing", + "Red Teaming", + "Product Governance" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0008" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN01.AR01", + "Description": "Verify that only authorized users can access MLDE resources, and that access modes are properly defined and enforced.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN01 Define Access Mode for ML Development Environments", + "SubSection": "", + "SubSectionObjective": "Ensure that access to Machine Learning Development Environment (MLDE) resources is strictly defined and controlled. Only authorized users with appropriate permissions can access these environments, mitigating the risk of unauthorized access, data leakage, or service disruption.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.1", + "2013 A.9.2.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-02" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN03.AR01", + "Description": "Verify that root access is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN03 Disable Root Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from obtaining root access on MLDE instances to reduce the risk of unauthorized system modifications and potential security breaches.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08", + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN03.AR02", + "Description": "For MLDE instances without sensitive data, ensure that root access is only enabled when necessary and properly authorized.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN03 Disable Root Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from obtaining root access on MLDE instances to reduce the risk of unauthorized system modifications and potential security breaches.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08", + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN04.AR01", + "Description": "Verify that terminal access is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN04 Disable Terminal Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from accessing the terminal on MLDE instances to limit the risk of unauthorized commands and potential system compromise.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN04.AR02", + "Description": "For MLDE instances without sensitive data, ensure that terminal access is only enabled when necessary and properly authorized.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN04 Disable Terminal Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from accessing the terminal on MLDE instances to limit the risk of unauthorized commands and potential system compromise.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN02.AR01", + "Description": "Confirm that file download functionality is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN02 Disable File Downloads on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent unauthorized file downloads from MLDE instances to protect sensitive data from being exfiltrated.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.Core.TH02" ] } ], @@ -4514,168 +7070,98 @@ { "ReferenceId": "CCM", "Identifiers": [ - "DSP-10", - "DSP-19" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-4" - ] - } - ] - } - ], - "Checks": [ - "storage_cross_tenant_replication_disabled", - "cosmosdb_account_firewall_use_selected_networks", - "cosmosdb_account_use_private_endpoints", - "containerregistry_uses_private_link", - "keyvault_private_endpoints", - "storage_ensure_private_endpoints_in_storage_accounts" - ] - }, - { - "Id": "CCC.Core.CN02.AR01", - "Description": "When data is stored, it MUST be encrypted using the latest industry-standard encryption methods.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN02 Encrypt Data for Storage", - "SubSection": "", - "SubSectionObjective": "Ensure that all data stored is encrypted at rest using strong encryption algorithms.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "UEM-08", - "DSP-17" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-13", - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "storage_infrastructure_encryption_is_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "databricks_workspace_cmk_encryption_enabled" - ] - }, - { - "Id": "CCC.Core.CN11.AR01", - "Description": "When encryption keys are used, the service MUST verify that all encryption keys use the latest industry-standard cryptographic algorithms.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "DSI-05", + "DSI-07" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.13.2.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "SC-7", + "SC-8" ] } ] } ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "databricks_workspace_cmk_encryption_enabled", - "keyvault_key_rotation_enabled", - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set", - "keyvault_non_rbac_secret_expiration_set", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "storage_smb_channel_encryption_with_secure_algorithm", - "storage_secure_transfer_required_is_enabled", - "storage_ensure_minimum_tls_version_12" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR02", - "Description": "When encryption keys are used, the service MUST rotate active keys within 180 days of issuance.", + "Id": "CCC.MLDE.CN02.AR02", + "Description": "For MLDE instances without sensitive data, ensure that file downloads are monitored and logged.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN02 Disable File Downloads on MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Prevent unauthorized file downloads from MLDE instances to protect sensitive data from being exfiltrated.", "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-5" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSI-05", + "DSI-07" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.2.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7", + "SC-8" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN05.AR01", + "Description": "Verify that only approved VM and container images can be selected when creating MLDE instances.", + "Attributes": [ + { + "FamilyName": "Configuration Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN05 Restrict Environment Options on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual machine and container image options available when creating new MLDE instances to approved and secure configurations.", + "Applicability": [ + "tlp-red", "tlp-amber" ], "Recommendation": "", @@ -4683,7 +7169,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.MLDE.TH04" ] } ], @@ -4691,51 +7177,43 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.IP-1" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "TVM-02" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.12.5.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "CM-2" ] } ] } ], - "Checks": [ - "keyvault_key_rotation_enabled", - "storage_key_rotation_90_days", - "databricks_workspace_cmk_encryption_enabled" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR03", - "Description": "When encrypting data, the service MUST verify that customer-managed encryption keys (CMEKs) are used.", + "Id": "CCC.MLDE.CN05.AR02", + "Description": "Attempt to create an MLDE instance with an unapproved image and confirm that it is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "", - "SubSection": "CCC.Core.CN11 Protect Encryption Keys", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "FamilyName": "Configuration Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN05 Restrict Environment Options on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual machine and container image options available when creating new MLDE instances to approved and secure configurations.", "Applicability": [ - "tlp-amber", "tlp-red" ], "Recommendation": "", @@ -4743,7 +7221,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.MLDE.TH04" ] } ], @@ -4751,52 +7229,371 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.IP-1" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "TVM-02" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.12.5.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "CM-2" ] } ] } ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk", - "sqlserver_tde_encrypted_with_cmk", - "monitor_storage_account_with_activity_logs_cmk_encrypted" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR04", - "Description": "When encryption keys are accessed, the service MUST verify that access to encryption keys is restricted to authorized personnel and services, following the principle of least privilege.", + "Id": "CCC.MLDE.CN06.AR01", + "Description": "Verify that automatic scheduled upgrades are enabled on user-managed MLDE instances containing sensitive data.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Vulnerability Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN06 Require Automatic Scheduled Upgrades on User-Managed MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Ensure that MLDE instances are kept up-to-date with the latest security patches by enforcing automatic scheduled upgrades.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH04", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-12" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "TVM-01", + "TVM-02" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.12.6.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN06.AR02", + "Description": "Ensure that the upgrade schedule is appropriately configured and does not interfere with critical operations.", + "Attributes": [ + { + "FamilyName": "Vulnerability Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN06 Require Automatic Scheduled Upgrades on User-Managed MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Ensure that MLDE instances are kept up-to-date with the latest security patches by enforcing automatic scheduled upgrades.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH04", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-12" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "TVM-01", + "TVM-02" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.12.6.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN07.AR01", + "Description": "Verify that MLDE instances containing sensitive data cannot be accessed via public IP addresses.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN07 Restrict Public IP Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent public IP access to MLDE instances to reduce exposure to the internet and enhance security.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN07.AR02", + "Description": "For MLDE instances without sensitive data requiring public access, ensure that appropriate security controls are in place and access is approved.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN07 Restrict Public IP Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent public IP access to MLDE instances to reduce exposure to the internet and enhance security.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN08.AR01", + "Description": "Verify that MLDE instances containing sensitive data can only be deployed in approved virtual networks with appropriate security controls.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN08 Restrict Virtual Networks for MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual networks that can be used when creating new MLDE instances to ensure they are deployed within approved and secure network environments.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN08.AR02", + "Description": "Ensure that MLDE instances without sensitive data are deployed in networks that meet organizational security standards.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN08 Restrict Virtual Networks for MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual networks that can be used when creating new MLDE instances to ensure they are deployed within approved and secure network environments.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Message.CN01.AR01", + "Description": "Attempt to publish a message without using a customer-managed encryption key and verify that the message is rejected or not stored.", + "Attributes": [ + { + "FamilyName": "Encryption", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Message.CN01 Use Customer-Managed Encryption Keys (CMEK) for Messages", + "SubSection": "", + "SubSectionObjective": "Ensure that messages are encrypted using customer-managed encryption keys (CMEK) to provide enhanced control over encryption processes and keys, meeting compliance and security requirements.", "Applicability": [ "tlp-clear", "tlp-green", @@ -4808,7 +7605,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.Core.TH01" ] } ], @@ -4819,546 +7616,82 @@ "PR.DS-1" ] }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, { "ReferenceId": "NIST_800_53", "Identifiers": [ "SC-12", - "SC-17" + "SC-13" ] } ] } ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "keyvault_rbac_enabled", - "keyvault_key_rotation_enabled", - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_rbac_key_expiration_set", - "keyvault_access_only_through_private_endpoints", - "keyvault_private_endpoints", - "keyvault_logging_enabled", - "keyvault_recoverable", - "storage_ensure_encryption_with_customer_managed_keys" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR05", - "Description": "When encryption keys are used, the service MUST rotate active keys within 365 days of issuance.", + "Id": "CCC.SvlsComp.CN01.AR01", + "Description": "Attempt to access the serverless function over the public internet and verify that access is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SvlsComp.CN01 Enforce Use of Private Endpoints for Serverless Function", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Ensure that the serverless function is accessible only through a private endpoint, allowing it to communicate securely within a virtual private network and preventing unauthorized external access.", "Applicability": [ - "tlp-clear", - "tlp-green" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12", - "SC-17" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_rotation_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "databricks_workspace_cmk_encryption_enabled", - "storage_key_rotation_90_days", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set", - "keyvault_key_expiration_set_in_non_rbac" - ] - }, - { - "Id": "CCC.Core.CN11.AR06", - "Description": "When encryption keys are used, the service MUST rotate active keys within 90 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12", - "SC-17" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_rotation_enabled" - ] - }, - { - "Id": "CCC.Core.CN14.AR01", - "Description": "When backups are created for disaster recovery purposes, the storage mechanism MUST NOT allow modification or deletion within 30 days of creation.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Use immutable storage solutions where possible. Implement backup retention policies that enforce a minimum retention period of 30 days. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "CCC.Core.CN14.AR02", - "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 30 days.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-clear", - "tlp-green", + "tlp-red", "tlp-amber" ], - "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 30 days. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06" + "CCC.Core.TH01" ] } ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "CCC.Core.CN14.AR02", - "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 14 days.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 14 days. ", - "SectionThreatMappings": [ + "SectionGuidelineMappings": [ { - "ReferenceId": "CCC", + "ReferenceId": "NIST-CSF", "Identifiers": [ - "CCC.TH06" + "PR.AC-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7", + "SC-8" ] } - ], - "SectionGuidelineMappings": [] + ] } ], "Checks": [ - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" + "app_function_not_publicly_accessible" ] }, { - "Id": "CCC.Core.CN03.AR01", - "Description": "When an entity attempts to modify the service through a user interface, the authentication process MUST require multiple identifying factors for authentication.", + "Id": "CCC.SvlsComp.CN02.AR01", + "Description": "Send requests to invoke the function up to the allowed threshold and confirm they are successful; then send additional requests exceeding the threshold from the same entity and verify that they are denied.", "Attributes": [ { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "FamilyName": "Availability", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SvlsComp.CN02 Implement Function Invocation Rate Limits", "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "SubSectionObjective": "Ensure that function invocation is limited to a specified threshold from any single entity, preventing resource exhaustion and denial of service attacks.", "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_security_defaults_enabled", - "entra_user_with_vm_access_has_mfa" - ] - }, - { - "Id": "CCC.Core.CN03.AR02", - "Description": "When an entity attempts to modify the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "CCC.Core.CN03.AR03", - "Description": "When an entity attempts to view information on the service through a user interface, the authentication process MUST require multiple identifying factors from the user.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "CCC.Core.CN03.AR04", - "Description": "When an entity attempts to view information on the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_user_with_vm_access_has_mfa" - ] - }, - { - "Id": "CCC.Core.CN05.AR01", - "Description": "When an attempt is made to modify data on the service or a child resource, the service MUST block requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH12" ] } ], @@ -5366,782 +7699,19 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" + "PR.DS-4" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-3" + "SC-5" ] } ] } ], - "Checks": [ - "aks_cluster_rbac_enabled", - "aks_network_policy_enabled", - "aks_clusters_public_access_disabled", - "app_client_certificates_on", - "app_ensure_auth_is_set_up", - "app_register_with_identity", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_function_not_publicly_accessible", - "app_function_access_keys_configured", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created", - "iam_role_user_access_admin_restricted", - "keyvault_rbac_enabled", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints", - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_non_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "entra_trusted_named_locations_exists", - "entra_user_with_vm_access_has_mfa", - "vm_jit_access_enabled", - "vm_linux_enforce_ssh_authentication" - ] - }, - { - "Id": "CCC.Core.CN05.AR02", - "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "aks_cluster_rbac_enabled", - "app_register_with_identity", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created", - "iam_role_user_access_admin_restricted", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "containerregistry_admin_user_disabled", - "cosmosdb_account_use_private_endpoints", - "cosmosdb_account_firewall_use_selected_networks", - "cosmosdb_account_use_aad_and_rbac", - "keyvault_rbac_enabled", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints", - "storage_account_key_access_disabled", - "storage_ensure_private_endpoints_in_storage_accounts", - "storage_ensure_azure_services_are_trusted_to_access_is_enabled", - "storage_default_to_entra_authorization_enabled", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted", - "aks_clusters_public_access_disabled", - "app_function_not_publicly_accessible", - "entra_global_admin_in_less_than_five_users", - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_trusted_named_locations_exists", - "entra_user_with_vm_access_has_mfa" - ] - }, - { - "Id": "CCC.Core.CN05.AR03", - "Description": "When administrative access or configuration change is attempted on the service or a child resource in a multi-tenant environment, the service MUST refuse requests across tenant boundaries unless the origin is explicitly included in a pre-approved allowlist.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_non_privileged_user_has_mfa", - "iam_role_user_access_admin_restricted", - "entra_trusted_named_locations_exists", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints", - "keyvault_rbac_enabled", - "cosmosdb_account_firewall_use_selected_networks", - "cosmosdb_account_use_private_endpoints", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "containerregistry_admin_user_disabled", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted", - "app_function_not_publicly_accessible", - "storage_default_network_access_rule_is_denied", - "storage_ensure_private_endpoints_in_storage_accounts", - "storage_blob_public_access_level_is_disabled", - "storage_cross_tenant_replication_disabled" - ] - }, - { - "Id": "CCC.Core.CN05.AR04", - "Description": "When data is requested from outside the trust perimeter, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "", - "SubSection": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "aks_cluster_rbac_enabled", - "aks_network_policy_enabled", - "aks_clusters_public_access_disabled", - "app_register_with_identity", - "app_function_access_keys_configured", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_ensure_auth_is_set_up", - "app_function_not_publicly_accessible", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "containerregistry_admin_user_disabled", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints", - "keyvault_rbac_enabled", - "storage_account_key_access_disabled", - "storage_default_to_entra_authorization_enabled", - "storage_ensure_azure_services_are_trusted_to_access_is_enabled", - "storage_default_network_access_rule_is_denied", - "storage_secure_transfer_required_is_enabled", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "cosmosdb_account_use_aad_and_rbac", - "sqlserver_azuread_administrator_enabled", - "sqlserver_unrestricted_inbound_access", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted", - "vm_jit_access_enabled", - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_trusted_named_locations_exists", - "entra_user_with_vm_access_has_mfa" - ] - }, - { - "Id": "CCC.Core.CN05.AR05", - "Description": "When any request is made from outside the trust perimeter, the service MUST NOT provide any response that may indicate the service exists.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "aks_clusters_created_with_private_nodes", - "aks_clusters_public_access_disabled", - "aks_network_policy_enabled", - "app_function_not_publicly_accessible", - "app_register_with_identity", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_client_certificates_on", - "app_ensure_auth_is_set_up", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "containerregistry_admin_user_disabled", - "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints", - "keyvault_rbac_enabled", - "keyvault_logging_enabled", - "storage_account_key_access_disabled", - "storage_default_to_entra_authorization_enabled", - "storage_ensure_private_endpoints_in_storage_accounts", - "storage_ensure_azure_services_are_trusted_to_access_is_enabled", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "vm_jit_access_enabled", - "vm_linux_enforce_ssh_authentication", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted" - ] - }, - { - "Id": "CCC.Core.CN05.AR06", - "Description": "When any request is made to the service or a child resource, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "aks_cluster_rbac_enabled", - "app_register_with_identity", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_function_access_keys_configured", - "app_function_not_publicly_accessible", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "cosmosdb_account_use_aad_and_rbac", - "entra_global_admin_in_less_than_five_users", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_conditional_access_policy_require_mfa_for_management_api", - "keyvault_rbac_enabled", - "vm_jit_access_enabled", - "vm_linux_enforce_ssh_authentication" - ] - }, - { - "Id": "CCC.Core.CN04.AR01", - "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "keyvault_logging_enabled", - "network_flow_log_captured_sent", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "CCC.Core.CN04.AR02", - "Description": "When any attempt is made to modify data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "network_flow_log_captured_sent" - ] - }, - { - "Id": "CCC.Core.CN04.AR03", - "Description": "When any attempt is made to read data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "keyvault_logging_enabled", - "app_http_logs_enabled", - "network_flow_log_captured_sent", - "monitor_storage_account_with_activity_logs_is_private", - "monitor_storage_account_with_activity_logs_cmk_encrypted" - ] - }, - { - "Id": "CCC.Core.CN07.AR01", - "Description": "When enumeration activities are detected, the service MUST publish an event to a monitored channel which includes the client identity, time, and nature of the activity.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", - "SubSection": "", - "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement event publication mechanisms and alerts for patterns indicative of enumeration activities, such as repeated access attempts, requests, or liveness probes. Configure alerts to notify security teams of any activities that merit further investigation. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-05", - "SEF-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "apim_threat_detection_llm_jacking" - ] - }, - { - "Id": "CCC.Core.CN07.AR02", - "Description": "When enumeration activities are detected, the service MUST log the client identity, time, and nature of the activity.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", - "SubSection": "", - "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement logging mechanisms to capture details of enumeration activities, including client identity, timestamps, and activity nature. Retain logs according to organizational policies, and occasionally review them for patterns that may indicate reconnaissance activities. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-05", - "SEF-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_settings_exists", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_service_health_exists", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private" - ] + "Checks": [] } ] } diff --git a/prowler/compliance/azure/cis_2.0_azure.json b/prowler/compliance/azure/cis_2.0_azure.json index abd088eae1..59bd153352 100644 --- a/prowler/compliance/azure/cis_2.0_azure.json +++ b/prowler/compliance/azure/cis_2.0_azure.json @@ -2276,9 +2276,9 @@ "Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace.", "RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting thegeneration of visual flow diagrams to aid with analyzing for lateral movement, etc.", "ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.", - "RemediationProcedure": "From Azure Portal 1. Navigate to Network Watcher. 2. Select NSG flow logs. 3. Select + Create. 4. Select the desired Subscription. 5. Select + Select NSG. 6. Select a network security group. 7. Click Confirm selection. 8. Select or create a new Storage Account. 9. Input the retention in days to retain the log. 10. Click Next. 11. Under Configuration, select Version 2. 12. If rich analytics are required, select Enable Traffic Analytics, a processing interval, and a Log Analytics Workspace. 13. Select Next. 14. Optionally add Tags. 15. Select Review + create. 16. Select Create. Warning The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.", - "AuditProcedure": "From Azure Portal 1. Navigate to Network Watcher. 2. Select NSG flow logs 3. For each log you wish to audit select it from this view.", - "AdditionalInformation": "", + "RemediationProcedure": "From Azure Portal Existing NSG flow logs can still be reviewed under Network Watcher > Flow logs. If you already have NSG flow logs configured, ensure they remain enabled and that Traffic Analytics sends data to a Log Analytics Workspace until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create Virtual network flow logs instead: 1. Navigate to Network Watcher. 2. Select Flow logs. 3. Select + Create. 4. Select the desired Subscription. 5. For Flow log type, select Virtual network. 6. Select + Select target resource. 7. Select a virtual network. 8. Click Confirm selection. 9. Select or create a new Storage Account. 10. Input the retention in days to retain the log. 11. Click Next. 12. Under Analytics, select Version 2, enable Traffic Analytics, and select a Log Analytics Workspace. 13. Select Next. 14. Optionally add Tags. 15. Select Review + create. 16. Select Create.", + "AuditProcedure": "From Azure Portal 1. Navigate to Network Watcher. 2. Select Flow logs. 3. Review existing Network security group flow logs, if any remain, to ensure they are enabled and configured to send logs to a Log Analytics Workspace. 4. Review Virtual network flow logs for new or migrated coverage.", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.", "DefaultValue": "By default Network Security Group logs are not sent to Log Analytics.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation" } @@ -2702,9 +2702,9 @@ "Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days.", "RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.", "ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.", - "RemediationProcedure": "From Azure Portal 1. Go to Network Watcher 2. Select NSG flow logs blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure Status is set to On 5. Ensure Retention (days) setting greater than 90 days 6. Select your storage account in the Storage account field 7. Select Save From Azure CLI Enable the NSG flow logs and set the Retention (days) to greater than or equal to 90 days. az network watcher flow-log configure --nsg --enabled true --resource-group --retention 91 -- storage-account ", - "AuditProcedure": "From Azure Portal 1. Go to Network Watcher 2. Select NSG flow logs blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure Status is set to On 5. Ensure Retention (days) setting greater than 90 days From Azure CLI az network watcher flow-log show --resource-group --nsg --query 'retentionPolicy' Ensure that enabled is set to true and days is set to greater then or equal to 90.", - "AdditionalInformation": "", + "RemediationProcedure": "From Azure Portal Existing NSG flow logs can still be reviewed under Network Watcher > Flow logs. If you already have NSG flow logs configured, ensure Status is set to On and Retention (days) is set to 0, 90, or a number greater than 90 until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure Virtual network flow logs instead and set Retention days to 0, 90, or a number greater than 90. From Azure CLI Update an existing flow log retention policy with az network watcher flow-log update --location --name --retention .", + "AuditProcedure": "From Azure Portal 1. Go to Network Watcher. 2. Select Flow logs. 3. Review existing Network security group flow logs, if any remain, and ensure Status is set to On and Retention (days) is set to 0, 90, or a number greater than 90. 4. Review Virtual network flow logs for new or migrated coverage. From Azure CLI az network watcher flow-log list --location --query [*].[name,retentionPolicy,targetResourceId] Ensure each relevant flow log has retention days set to 0, 90, or a number greater than 90.", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.", "DefaultValue": "By default, Network Security Group Flow Logs are disabled.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-6-configure-log-storage-retention" } @@ -2714,7 +2714,8 @@ "Id": "6.6", "Description": "Ensure that Network Watcher is 'Enabled'", "Checks": [ - "network_watcher_enabled" + "network_watcher_enabled", + "network_vnet_ddos_protection_enabled" ], "Attributes": [ { diff --git a/prowler/compliance/azure/cis_2.1_azure.json b/prowler/compliance/azure/cis_2.1_azure.json index d7556f1424..a663e5be18 100644 --- a/prowler/compliance/azure/cis_2.1_azure.json +++ b/prowler/compliance/azure/cis_2.1_azure.json @@ -1383,7 +1383,7 @@ "Id": "3.7", "Description": "Ensure that 'Public Network Access' is `Disabled' for storage accounts", "Checks": [ - "storage_blob_public_access_level_is_disabled" + "storage_account_public_network_access_disabled" ], "Attributes": [ { @@ -2241,9 +2241,9 @@ "Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace.", "RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.", "ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.", - "RemediationProcedure": "**From Azure Portal** 1. Navigate to `Network Watcher`. 1. Select `NSG flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. Select `+ Select NSG`. 1. Select a network security group. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. Input the retention in days to retain the log. 1. Click `Next`. 1. Under `Configuration`, select `Version 2`. 1. If rich analytics are required, select `Enable Traffic Analytics`, a processing interval, and a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`. ***Warning*** The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.", - "AuditProcedure": "**From Azure Portal** 1. Navigate to `Network Watcher`. 1. Select `NSG flow logs` 1. For each log you wish to audit select it from this view. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", - "AdditionalInformation": "", + "RemediationProcedure": "**From Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. Input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.", + "AuditProcedure": "**From Azure Portal** 1. Navigate to `Network Watcher`. 1. Select `Flow logs`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.", "DefaultValue": "By default Network Security Group logs are not sent to Log Analytics.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation" } @@ -2627,9 +2627,9 @@ "Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days.", "RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.", "ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.", - "RemediationProcedure": "**From Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` 6. Select your storage account in the `Storage account` field 7. Select `Save` **From Azure CLI** Enable the `NSG flow logs` and set the Retention (days) to greater than or equal to 90 days. ``` az network watcher flow-log configure --nsg --enabled true --resource-group --retention 91 --storage-account ```", - "AuditProcedure": "**From Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` **From Azure CLI** ``` az network watcher flow-log show --resource-group --nsg --query 'retentionPolicy' ``` Ensure that `enabled` is set to `true` and `days` is set to `greater then or equal to 90`. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'", - "AdditionalInformation": "", + "RemediationProcedure": "**From Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure `Virtual network` flow logs instead and set `Retention days` to `0`, `90`, or a number greater than `90`. **From Azure CLI** Update an existing flow log retention policy with: ``` az network watcher flow-log update --location --name --retention ```", + "AuditProcedure": "**From Azure Portal** 1. Go to `Network Watcher`. 1. Select `Flow logs`. 1. Review existing `Network security group` flow logs, if any remain, and ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90`. 1. Review `Virtual network` flow logs for new or migrated coverage. **From Azure CLI** ``` az network watcher flow-log list --location --query [*].[name,retentionPolicy,targetResourceId] ``` Ensure each relevant flow log has retention days set to `0`, `90`, or a number greater than `90`. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.", "DefaultValue": "By default, Network Security Group Flow Logs are `disabled`.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-6-configure-log-storage-retention" } diff --git a/prowler/compliance/azure/cis_3.0_azure.json b/prowler/compliance/azure/cis_3.0_azure.json index 90fa5bfea0..107c495d05 100644 --- a/prowler/compliance/azure/cis_3.0_azure.json +++ b/prowler/compliance/azure/cis_3.0_azure.json @@ -1651,7 +1651,7 @@ "Id": "4.6", "Description": "Ensure that 'Public Network Access' is 'Disabled' for storage accounts", "Checks": [ - "storage_blob_public_access_level_is_disabled" + "storage_account_public_network_access_disabled" ], "Attributes": [ { @@ -2548,9 +2548,9 @@ "Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace.", "RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.", "ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace.This will increase the amount of data stored and used by Azure Monitor.", - "RemediationProcedure": "**Remediate from Azure Portal**1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Select `+ Create`.1. Select the desired Subscription.1. For `Flow log type`, select `Network security group`.1. Select `+ Select target resource`.1. Select `Network security group`.1. Select a network security group.1. Click `Confirm selection`.1. Select or create a new Storage Account.1. If using a v2 storage account, input the retention in days to retain the log.1. Click `Next`.1. Under `Analytics`, for `Flow log version`, select `Version 2`.1. Check the box next to `Enable traffic analytics`.1. Select a processing interval.1. Select a `Log Analytics Workspace`.1. Select `Next`.1. Optionally add Tags.1. Select `Review + create`.1. Select `Create`.***Warning***The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.", - "AuditProcedure": "**Audit from Azure Portal**1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Click `Add filter`.1. From the `Filter` drop-down, select `Flow log type`.1. From the `Value` drop-down, check `Network security group` only.1. Click `Apply`.1. Ensure that at least one network security group flow log is listed and is configured to send logs to a `Log Analytics Workspace`.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state'- **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group'- **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", - "AdditionalInformation": "", + "RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Select `+ Create`.1. Select the desired Subscription.1. For `Flow log type`, select `Virtual network`.1. Select `+ Select target resource`.1. Select `Virtual network`.1. Select a virtual network.1. Click `Confirm selection`.1. Select or create a new Storage Account.1. If using a v2 storage account, input the retention in days to retain the log.1. Click `Next`.1. Under `Analytics`, for `Flow log version`, select `Version 2`.1. Check the box next to `Enable traffic analytics`.1. Select a processing interval.1. Select a `Log Analytics Workspace`.1. Select `Next`.1. Optionally add Tags.1. Select `Review + create`.1. Select `Create`.", + "AuditProcedure": "**Audit from Azure Portal**1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Click `Add filter`.1. From the `Filter` drop-down, select `Flow log type`.1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`.1. Review `Virtual network` flow logs for new or migrated coverage.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state'- **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group'- **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.", "DefaultValue": "By default Network Security Group logs are not sent to Log Analytics.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation" } @@ -2934,9 +2934,9 @@ "Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days.", "RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.", "ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.", - "RemediationProcedure": "**Remediate from Azure Portal**1. Go to `Network Watcher`2. Select `NSG flow logs` blade in the Logs section3. Select each Network Security Group from the list4. Ensure `Status` is set to `On`5. Ensure `Retention (days)` setting `greater than 90 days`6. Select your storage account in the `Storage account` field7. Select `Save`**Remediate from Azure CLI**Enable the `NSG flow logs` and set the Retention (days) to greater than or equal to 90 days.```az network watcher flow-log configure --nsg --enabled true --resource-group --retention 91 --storage-account ```", - "AuditProcedure": "**Audit from Azure Portal**1. Go to `Network Watcher`2. Select `NSG flow logs` blade in the Logs section3. Select each Network Security Group from the list4. Ensure `Status` is set to `On`5. Ensure `Retention (days)` setting `greater than 90 days`**Audit from Azure CLI**```az network watcher flow-log show --resource-group --nsg --query 'retentionPolicy'```Ensure that `enabled` is set to `true` and `days` is set to `greater then or equal to 90`.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'", - "AdditionalInformation": "", + "RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure `Virtual network` flow logs instead and set `Retention days` to `0`, `90`, or a number greater than `90`.**Remediate from Azure CLI**Update an existing flow log retention policy with:```az network watcher flow-log update --location --name --retention ```", + "AuditProcedure": "**Audit from Azure Portal**1. Go to `Network Watcher`.1. Select `Flow logs`.1. Review existing `Network security group` flow logs, if any remain, and ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90`.1. Review `Virtual network` flow logs for new or migrated coverage.**Audit from Azure CLI**```az network watcher flow-log list --location --query [*].[name,retentionPolicy,targetResourceId]```Ensure each relevant flow log has retention days set to `0`, `90`, or a number greater than `90`.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.", "DefaultValue": "By default, Network Security Group Flow Logs are `disabled`.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-6-configure-log-storage-retention" } diff --git a/prowler/compliance/azure/cis_4.0_azure.json b/prowler/compliance/azure/cis_4.0_azure.json index 3777d24663..5f3e4502f1 100644 --- a/prowler/compliance/azure/cis_4.0_azure.json +++ b/prowler/compliance/azure/cis_4.0_azure.json @@ -253,6 +253,18 @@ "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications:https://learn.microsoft.com/en-us/azure/defender-for-cloud/how-to-manage-attack-path:https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-attack-path", "DefaultValue": "" } + ], + "ConfigRequirements": [ + { + "Check": "defender_attack_path_notifications_properly_configured", + "ConfigKey": "defender_attack_path_minimal_risk_level", + "Operator": "in", + "Value": [ + "Low", + "Medium", + "High" + ] + } ] }, { @@ -375,6 +387,16 @@ "Checks": [ "storage_smb_channel_encryption_with_secure_algorithm" ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ], "Attributes": [ { "Section": "10 Storage Services", @@ -1302,9 +1324,9 @@ "Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace. **Retirement Notice** On September 30, 2027, network security group (NSG) flow logs will be retired. Starting June 30, 2025, it will no longer be possible to create new NSG flow logs. Azure recommends migrating to virtual network flow logs. Review https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement for more information. For virtual network flow logs, consider applying the recommendation `Ensure that virtual network flow logs are captured and sent to Log Analytics` in this section.", "RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.", "ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.", - "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Network security group`. 1. Select `+ Select target resource`. 1. Select `Network security group`. 1. Select a network security group. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`. ***Warning*** The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.", - "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. From the `Value` drop-down, check `Network security group` only. 1. Click `Apply`. 1. Ensure that at least one network security group flow log is listed and is configured to send logs to a `Log Analytics Workspace`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", - "AdditionalInformation": "", + "RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation", "DefaultValue": "By default Network Security Group logs are not sent to Log Analytics." } @@ -1789,9 +1811,9 @@ "Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days. **Retirement Notice** On September 30, 2027, network security group (NSG) flow logs will be retired. Starting June 30, 2025, it will no longer be possible to create new NSG flow logs. Azure recommends migrating to virtual network flow logs. Review https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement for more information. For virtual network flow logs, consider applying the recommendation `Ensure that virtual network flow log retention days is set to greater than or equal to 90` in this section.", "RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.", "ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.", - "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` 6. Select your storage account in the `Storage account` field 7. Select `Save` **Remediate from Azure CLI** Enable the `NSG flow logs` and set the Retention (days) to greater than or equal to 90 days. ``` az network watcher flow-log configure --nsg --enabled true --resource-group --retention 91 --storage-account ```", - "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` **Audit from Azure CLI** ``` az network watcher flow-log show --resource-group --nsg --query 'retentionPolicy' ``` Ensure that `enabled` is set to `true` and `days` is set to `greater then or equal to 90`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'", - "AdditionalInformation": "", + "RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure `Virtual network` flow logs instead and set `Retention days` to `0`, `90`, or a number greater than `90`. **Remediate from Azure CLI** Update an existing flow log retention policy with: ``` az network watcher flow-log update --location --name --retention ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Select `Flow logs`. 1. Review existing `Network security group` flow logs, if any remain, and ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure CLI** ``` az network watcher flow-log list --location --query [*].[name,retentionPolicy,targetResourceId] ``` Ensure each relevant flow log has retention days set to `0`, `90`, or a number greater than `90`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-6-configure-log-storage-retention", "DefaultValue": "By default, Network Security Group Flow Logs are `disabled`." } @@ -3021,7 +3043,7 @@ "Id": "10.3.2.2", "Description": "Ensure that 'Public Network Access' is 'Disabled' for storage accounts", "Checks": [ - "storage_blob_public_access_level_is_disabled" + "storage_account_public_network_access_disabled" ], "Attributes": [ { diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index f786ebd3e2..71e43aeee9 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -1292,9 +1292,9 @@ "Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace. **Retirement Notice** On September 30, 2027, network security group (NSG) flow logs will be retired. Starting June 30, 2025, it will no longer be possible to create new NSG flow logs. Azure recommends migrating to virtual network flow logs. Review https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement for more information. For virtual network flow logs, consider applying the recommendation `Ensure that virtual network flow logs are captured and sent to Log Analytics` in this section.", "RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.", "ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.", - "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Network security group`. 1. Select `+ Select target resource`. 1. Select `Network security group`. 1. Select a network security group. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`. ***Warning*** The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.", - "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. From the `Value` drop-down, check `Network security group` only. 1. Click `Apply`. 1. Ensure that at least one network security group flow log is listed and is configured to send logs to a `Log Analytics Workspace`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", - "AdditionalInformation": "", + "RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.", "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation", "DefaultValue": "By default Network Security Group logs are not sent to Log Analytics." } @@ -1974,7 +1974,9 @@ { "Id": "7.11", "Description": "Ensure subnets are associated with network security groups", - "Checks": [], + "Checks": [ + "network_subnet_nsg_associated" + ], "Attributes": [ { "Section": "7 Networking Services", @@ -2094,7 +2096,9 @@ { "Id": "8.1.1.1", "Description": "Ensure Microsoft Defender CSPM is set to 'On'", - "Checks": [], + "Checks": [ + "defender_ensure_defender_cspm_is_on" + ], "Attributes": [ { "Section": "8 Security Services", @@ -2610,6 +2614,18 @@ "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications:https://learn.microsoft.com/en-us/azure/defender-for-cloud/how-to-manage-attack-path:https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-attack-path", "DefaultValue": "" } + ], + "ConfigRequirements": [ + { + "Check": "defender_attack_path_notifications_properly_configured", + "ConfigKey": "defender_attack_path_minimal_risk_level", + "Operator": "in", + "Value": [ + "Low", + "Medium", + "High" + ] + } ] }, { @@ -2931,7 +2947,9 @@ { "Id": "8.5", "Description": "Ensure Azure DDoS Network Protection is enabled on virtual networks", - "Checks": [], + "Checks": [ + "network_vnet_ddos_protection_enabled" + ], "Attributes": [ { "Section": "8 Security Services", @@ -3000,6 +3018,16 @@ "Checks": [ "storage_smb_channel_encryption_with_secure_algorithm" ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ], "Attributes": [ { "Section": "9 Storage Services", @@ -3182,7 +3210,7 @@ "Id": "9.3.2.2", "Description": "Ensure that 'Public Network Access' is 'Disabled' for storage accounts", "Checks": [ - "storage_blob_public_access_level_is_disabled" + "storage_account_public_network_access_disabled" ], "Attributes": [ { diff --git a/prowler/compliance/azure/cis_6.0_azure.json b/prowler/compliance/azure/cis_6.0_azure.json new file mode 100644 index 0000000000..625a0c18fa --- /dev/null +++ b/prowler/compliance/azure/cis_6.0_azure.json @@ -0,0 +1,2863 @@ +{ + "Framework": "CIS", + "Name": "CIS Microsoft Azure Foundations Benchmark v6.0.0", + "Version": "6.0", + "Provider": "Azure", + "Description": "The CIS Azure Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Azure with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "2.1.1", + "Description": "Ensure that Azure Databricks is deployed in a customer-managed virtual network (VNet)", + "Checks": [ + "databricks_workspace_vnet_injection_enabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Azure Databricks is deployed in a customer-managed virtual network (VNet)", + "RationaleStatement": "Using a customer-managed VNet ensures better control over network security and aligns with zero-trust architecture principles. It allows for: - Restricted outbound internet access to prevent unauthorized data exfiltration. - Integration with on-premises networks via VPN or ExpressRoute for hybrid connectivity. - Fine-grained NSG policies to restrict access at the subnet level. - Private Link for secure API access, avoiding public internet exposure.", + "ImpactStatement": "- Requires additional configuration during Databricks workspace deployment. - Might increase operational overhead for network maintenance. - May impact connectivity if misconfigured (e.g., restrictive NSG rules or missing routes).", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Delete the existing Databricks workspace (migration required). 1. Create a new Databricks workspace with VNet Injection: 1. Go to Azure Portal Create Databricks Workspace. 1. Select Advanced Networking. 1. Choose Deploy into your own Virtual Network. 1. Specify a customer-managed VNet and associated subnets. 1. Enable Private Link for secure API access. **Remediate from Azure CLI** Deploy a new Databricks workspace in a custom VNet: ``` az databricks workspace create --name \\ --resource-group \\ --location \\ --managed-resource-group \\ --enable-no-public-ip true \\ --network-security-group-rule NoAzureServices \\ --public-network-access Disabled \\ --custom-virtual-network-id /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/ ``` Ensure NSG Rules are correctly configured: ``` az network nsg rule create --resource-group \\ --nsg-name \\ --name DenyAllOutbound \\ --direction Outbound \\ --access Deny \\ --priority 4096 ``` **Remediate from PowerShell** ``` New-AzDatabricksWorkspace -ResourceGroupName -Name -Location -ManagedResourceGroupName -CustomVirtualNetworkId /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/ ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to Azure Portal Search for Databricks Workspaces. 1. Select the Databricks Workspace to audit. 1. Under Networking, check if the workspace is deployed in a Customer-Managed VNet. 1. If the Virtual Network field shows Databricks-Managed VNet, it is non-compliant. 1. Verify NSG rules and Private Endpoints for fine-grained access control. **Audit from Azure CLI** Run the following command to check if Databricks is using a customer-managed VNet: ``` az network vnet show --resource-group --name ``` Ensure that Databricks subnets are present in the VNet configuration. Validate NSG rules attached to the Databricks subnets. **Audit from PowerShell** ``` Get-AzDatabricksWorkspace -ResourceGroupName -Name | Select-Object VirtualNetworkId ``` If VirtualNetworkId is null or shows a Databricks-Managed VNet, it is non-compliant. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [9c25c9e4-ee12-4882-afd2-11fb9d87893f](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%9c25c9e4-ee12-4882-afd2-11fb9d87893f) **- Name:** 'Azure Databricks Workspaces should be in a virtual network'", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, Azure Databricks uses a Databricks-Managed VNet." + } + ] + }, + { + "Id": "2.1.2", + "Description": "Ensure that Network Security Groups are Configured for Databricks Subnets", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Network Security Groups are Configured for Databricks Subnets", + "RationaleStatement": "Using NSGs with both explicit allow and deny rules provides clear documentation and control over permitted and prohibited traffic. While Azure NSGs implicitly deny all traffic not explicitly allowed, defining explicit deny rules for known malicious or unnecessary sources enhances clarity, simplifies troubleshooting, and supports compliance audits.", + "ImpactStatement": "* NSGs require periodic maintenance to ensure rule accuracy. * Misconfigured NSGs could inadvertently block required traffic.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Assign NSG to Databricks subnets under Networking > NSG Settings.", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to Virtual Networks > Subnets, and review NSG assignments. **Audit from Azure CLI** ``` az network nsg list --query [].{Name:name, Rules:securityRules} ``` **Audit from PowerShell** ``` Get-AzNetworkSecurityGroup -ResourceGroupName ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/azure-databricks-security-baseline:https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject#network-security-group-rules", + "DefaultValue": "By default, Databricks subnets do not have NSGs assigned." + } + ] + }, + { + "Id": "2.1.3", + "Description": "Ensure that Traffic is Encrypted Between Cluster Worker Nodes", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Traffic is Encrypted Between Cluster Worker Nodes", + "RationaleStatement": "* Protects sensitive data during transit between cluster nodes, mitigating risks of data interception or unauthorized access. * Aligns with organizational security policies and compliance requirements that mandate encryption of data in transit. * Enhances overall security posture by ensuring that all inter-node communications within the cluster are encrypted.", + "ImpactStatement": "* Enabling encryption may introduce a performance penalty due to the computational overhead associated with encrypting and decrypting traffic. This can result in longer query execution times, especially for data-intensive operations. * Implementing encryption requires creating and managing init scripts, which adds complexity to cluster configuration and maintenance. * The shared encryption secret is derived from the hash of the keystore stored in DBFS. If the keystore is updated or rotated, all running clusters must be restarted to prevent authentication failures between Spark workers and drivers.", + "RemediationProcedure": "Create a JKS keystore: 1. Generate a Java KeyStore (JKS) file that will be used for SSL/TLS encryption. 2. Upload the keystore file to a secure directory in DBFS (e.g. /dbfs//jetty_ssl_driver_keystore.jks). Develop an init script: 3. Create an init script that performs the following tasks: - Retrieves the JKS keystore file and password. - Derives a shared encryption secret from the keystore. - Configures Spark driver and executor settings to enable encryption. 4. Example init script: ``` #!/bin/bash set -euo pipefail keystore_dbfs_file=/dbfs//jetty_ssl_driver_keystore.jks max_attempts=30 while [ ! -f ${keystore_dbfs_file} ]; do if [ $max_attempts == 0 ]; then echo ERROR: Unable to find the file : $keystore_dbfs_file. Failing the script. exit 1 fi sleep 2s ((max_attempts--)) done sasl_secret=$(sha256sum $keystore_dbfs_file | cut -d' ' -f1) if [ -z ${sasl_secret} ]; then echo ERROR: Unable to derive the secret. Failing the script. exit 1 fi local_keystore_file=$DB_HOME/keys/jetty_ssl_driver_keystore.jks local_keystore_password=gb1gQqZ9ZIHS if [[ $DB_IS_DRIVER = TRUE ]]; then driver_conf=${DB_HOME}/driver/conf/spark-branch.conf echo Configuring driver conf at $driver_conf if [ ! -e $driver_conf ]; then echo spark.authenticate true >> $driver_conf echo spark.authenticate.secret $sasl_secret >> $driver_conf echo spark.authenticate.enableSaslEncryption true >> $driver_conf echo spark.network.crypto.enabled true >> $driver_conf echo spark.network.crypto.keyLength 256 >> $driver_conf echo spark.network.crypto.keyFactoryAlgorithm PBKDF2WithHmacSHA1 >> $driver_conf echo spark.io.encryption.enabled true >> $driver_conf echo spark.ssl.enabled true >> $driver_conf echo spark.ssl.keyPassword $local_keystore_password >> $driver_conf echo spark.ssl.keyStore $local_keystore_file >> $driver_conf echo spark.ssl.keyStorePassword $local_keystore_password >> $driver_conf echo spark.ssl.protocol TLSv1.3 >> $driver_conf fi fi executor_conf=${DB_HOME}/conf/spark.executor.extraJavaOptions echo Configuring executor conf at $executor_conf if [ ! -e $executor_conf ]; then echo -Dspark.authenticate=true >> $executor_conf echo -Dspark.authenticate.secret=$sasl_secret >> $executor_conf echo -Dspark.authenticate.enableSaslEncryption=true >> $executor_conf echo -Dspark.network.crypto.enabled=true >> $executor_conf echo -Dspark.network.crypto.keyLength=256 >> $executor_conf echo -Dspark.network.crypto.keyFactoryAlgorithm=PBKDF2WithHmacSHA1 >> $executor_conf echo -Dspark.io.encryption.enabled=true >> $executor_conf echo -Dspark.ssl.enabled=true >> $executor_conf echo -Dspark.ssl.keyPassword=$local_keystore_password >> $executor_conf echo -Dspark.ssl.keyStore=$local_keystore_file >> $executor_conf echo -Dspark.ssl.keyStorePassword=$local_keystore_password >> $executor_conf echo -Dspark.ssl.protocol=TLSv1.3 >> $executor_conf fi ``` 5. Save.", + "AuditProcedure": "**Audit from Azure Portal** Review cluster init scripts: 1. Navigate to your Azure Databricks workspace, go to the Clusters section, select a cluster, and check the Advanced Options for any init scripts that configure encryption settings. Verify spark configuration: 2. Ensure that the following Spark configurations are set: ``` spark.authenticate true spark.authenticate.enableSaslEncryption true spark.network.crypto.enabled true spark.network.crypto.keyLength 256 spark.network.crypto.keyFactoryAlgorithm PBKDF2WithHmacSHA1 spark.io.encryption.enabled true ``` These settings can be found in the cluster's Spark configuration properties. Check keystone management: 3. Verify that the Java KeyStore (JKS) file is securely stored in DBFS and that its integrity is maintained. 4. Ensure that the keystore password is securely managed and not hardcoded in scripts.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/security/keys/encrypt-otw", + "DefaultValue": "By default, traffic is not encrypted between cluster worker nodes." + } + ] + }, + { + "Id": "2.1.4", + "Description": "Ensure that Users and Groups are Synced from Microsoft Entra ID to Azure Databricks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Users and Groups are Synced from Microsoft Entra ID to Azure Databricks", + "RationaleStatement": "Syncing users and groups from Microsoft Entra ID centralizes access control, enforces the least privilege principle by automatically revoking unnecessary access, reduces administrative overhead by eliminating manual user management, and ensures auditability and compliance with industry regulations.", + "ImpactStatement": "SCIM provisioning requires role mapping to avoid misconfigured user privileges.", + "RemediationProcedure": "**Remediate from Azure Portal** Enable provisioning in Azure Portal: 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Enterprise applications`. 1. Click the name of the Azure Databricks SCIM application. 1. Under `Provisioning`, select `Automatic` and enter the SCIM endpoint and API token from Databricks. Enable provisioning in Databricks: 5. Navigate to `Admin Console` > `Identity and Access Management`. 6. Enable SCIM provisioning and generate an API token. Configure role assignments: 7. Ensure groups from Entra ID are mapped to appropriate Databricks roles. 8. Restrict administrative privileges to designated security groups. Regularly monitor sync logs: 9. Periodically review sync logs in Microsoft Entra ID and Databricks Admin Console. 10. Configure Azure Monitor alerts for provisioning failures. Disable manual user creation in Databricks: 11. Ensure that all user management is controlled via SCIM sync from Entra ID. 12. Disable personal access token usage for authentication. **Remediate from Azure CLI** Enable SCIM User and Group Provisioning in Azure Databricks: ``` az ad app update --id --set provisioning.provisioningMode=Automatic ```", + "AuditProcedure": "**Audit from Azure Portal** Verify SCIM provisioning is enabled: 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Enterprise applications`. 1. Click the name of the Azure Databricks SCIM application. 1. Under `Provisioning`, confirm that SCIM provisioning is enabled and running. Check user sync status in Azure Portal: 5. Under `Provisioning Logs`, verify the last successful sync and any failed entries. Check user sync status in Databricks: 6. Go to `Admin Console` > `Identity and Access Management`. 7. Confirm that Users and Groups match those assigned in Microsoft Entra ID. Ensure role-based access control (RBAC) mapping is correct: 8. Verify that users are assigned appropriate Databricks roles (e.g. Admin, User, Contributor). 9. Confirm that groups are mapped to workspace access roles.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/administration-guide/users-groups/scim/aad", + "DefaultValue": "By default, Azure Databricks does not sync users and groups from Microsoft Entra ID." + } + ] + }, + { + "Id": "2.1.5", + "Description": "Ensure that Unity Catalog is Configured for Azure Databricks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Unity Catalog is Configured for Azure Databricks", + "RationaleStatement": "* Enforces centralized access control policies and reduces data security risks. * Enables identity-based authentication via Microsoft Entra ID. * Improves compliance with industry regulations (e.g. GDPR, HIPAA, SOC 2) by providing audit logs and access visibility. * Prevents unauthorized data access through table-, row-, and column-level security (RLS & CLS).", + "ImpactStatement": "* Improperly configured permissions may lead to data exfiltration or unauthorized access. * Unity Catalog requires structured governance policies to be effective and prevent overly permissive access.", + "RemediationProcedure": "Use the remediation procedure written in this article: https://learn.microsoft.com/en-us/azure/databricks/data-governance/unity-catalog/get-started.", + "AuditProcedure": "Method 1: Verify unity catalog deployment: 1. As an Azure Databricks account admin, log into the account console. 1. Click Workspaces. 1. Find your workspace and check the Metastore column. If a metastore name is present, your workspace is attached to a Unity Catalog metastore and therefore enabled for Unity Catalog. Method 2: Run a SQL query to confirm Unity Catalog enablement Run the following SQL query in the SQL query editor or a notebook that is attached to a Unity Catalog-enabled compute resource. No admin role is required. ``` SELECT CURRENT_METASTORE(); ``` If the query returns a metastore ID like the following, then your workspace is attached to a Unity Catalog metastore and therefore enabled for Unity Catalog.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/data-governance/unity-catalog/:https://learn.microsoft.com/en-us/azure/databricks/admin/users-groups/:https://learn.microsoft.com/en-us/azure/databricks/data-governance/unity-catalog/enable-workspaces", + "DefaultValue": "New workspaces have Unity Catalog enabled by default. Existing workspaces may require manual enablement." + } + ] + }, + { + "Id": "2.1.6", + "Description": "Ensure that Usage is Restricted and Expiry is Enforced for Databricks Personal Access Tokens", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Usage is Restricted and Expiry is Enforced for Databricks Personal Access Tokens", + "RationaleStatement": "Restricting usage and enforcing expiry for personal access tokens reduces exposure to long-lived tokens, minimizes the risk of API abuse if compromised, and aligns with security best practices through controlled issuance and enforced expiry.", + "ImpactStatement": "If revoked improperly, applications relying on these tokens may fail, requiring a remediation plan for token rotation. Increased administrative effort is required to track and manage API tokens effectively.", + "RemediationProcedure": "**Remediate from Azure Portal** Disable personal access tokens: If your workspace does not require PATs, you can disable them entirely to prevent their use.", + "AuditProcedure": "Azure Databricks administrators can monitor and revoke personal access tokens within their workspace. Detailed instructions are available in the Monitor and Revoke Personal Access Tokens section of the Microsoft documentation: https://learn.microsoft.com/en-us/azure/databricks/admin/access-control/tokens. To evaluate the usage of personal access tokens in your Azure Databricks account, you can utilize the provided notebook that lists all PATs not rotated or updated in the last 90 days, allowing you to identify tokens that may require revocation. This process is detailed here: https://docs.azure.cn/en-us/databricks/security/auth/oauth-pat-usage. Implementing diagnostic logging provides a comprehensive reference of audit log services and events, enabling you to track activities related to personal access tokens. More information can be found in the diagnostic log reference section: https://docs.azure.cn/en-us/databricks/security/auth/oauth-pat-usage.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/administration-guide/access-control/tokens:https://learn.microsoft.com/en-us/azure/databricks/dev-tools/auth/", + "DefaultValue": "By default, personal access tokens are enabled and users can create the Personal access token and their expiry time." + } + ] + }, + { + "Id": "2.1.7", + "Description": "Ensure that Diagnostic Log Delivery is Configured for Azure Databricks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Diagnostic Log Delivery is Configured for Azure Databricks", + "RationaleStatement": "Diagnostic logging provides visibility into security and operational activities within Databricks workspaces while maintaining an audit trail for forensic investigations, and it supports compliance with regulatory standards that require logging and monitoring.", + "ImpactStatement": "Logs consume storage and may require additional monitoring tools, leading to increased operational overhead and costs. Incomplete log configurations may result in missing critical events, reducing monitoring effectiveness.", + "RemediationProcedure": "**Remediate from Azure Portal** Enable diagnostic logging for Azure Databricks: 1. Navigate to your Azure Databricks workspace. 1. In the left-hand menu, select `Monitoring` > `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Under `Category details`, select the log categories you wish to capture, such as AuditLogs, Clusters, Notebooks, and Jobs. 1. Choose a destination for the logs: - `Log Analytics workspace`: For advanced querying and monitoring. - `Storage account`: For long-term retention. - `Event Hub`: For integration with third-party systems. 1. Provide a `Name` for the diagnostic setting. 1. Click `Save`. Implement log retention policies: 1. Navigate to your Log Analytics workspace. 1. Under `General`, select `Usage and estimated costs`. 1. Click `Data Retention`. 1. Adjust the retention period slider to the desired number of days (up to 730 days). 1. Click `OK`. Monitor logs for anomalies: 1. Navigate to `Azure Monitor`. 1. Select `Alerts` > `+ New alert rule`. 1. Under `Scope`, specify the Databricks resource. 1. Define `Condition` based on log queries that identify anomalies (e.g. unauthorized access attempts). 1. Configure `Actions` to notify stakeholders or trigger automated responses. 1. Provide an Alert rule `name` and `description`. 1. Click `Create alert rule`. **Remediate from Azure CLI** Enable diagnostic logging for Azure Databricks: ``` az monitor diagnostic-settings create --name DatabricksLogging --resource --logs '[{category: AuditLogs, enabled: true}, {category: Clusters, enabled: true}, {category: Notebooks, enabled: true}, {category: Jobs, enabled: true}]' --workspace ``` Implement log retention policies: ``` az monitor log-analytics workspace update --resource-group --name --retention-time 365 ``` Monitor logs for anomalies: ``` az monitor activity-log alert create --name DatabricksAnomalyAlert --resource-group --scopes --condition contains 'UnauthorizedAccess' ```", + "AuditProcedure": "**Audit from Azure Portal** Check if diagnostic logging is enabled for the Databricks workspace: 1. Go to `Azure Databricks`. 1. Select a workspace. 1. In the left-hand menu, select `Monitoring` > `Diagnostic settings`. 1. Verify if a diagnostic setting is configured. If not, diagnostic logging is not enabled. Ensure that logging is enabled for the following categories:", + "AdditionalInformation": "* Ensure that the Azure Databricks workspace is on the Premium plan to utilize diagnostic logging features. * Regularly review and update alert rules to adapt to evolving security threats and operational requirements.", + "References": "https://learn.microsoft.com/en-us/azure/databricks/admin/account-settings/audit-log-delivery:https://learn.microsoft.com/en-us/troubleshoot/azure/azure-monitor/log-analytics/billing/configure-data-retention", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.8", + "Description": "Ensure Critical Data in Azure Databricks is Encrypted with Customer-managed Keys (CMK)", + "Checks": [ + "databricks_workspace_cmk_encryption_enabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Critical Data in Azure Databricks is Encrypted with Customer-managed Keys (CMK)", + "RationaleStatement": "By default in Azure, data at rest tends to be encrypted using Microsoft Managed Keys. If your organization wants to control and manage encryption keys for compliance and defense-in-depth, Customer Managed Keys can be established. While it is possible to automate the assessment of this recommendation, the assessment status for this recommendation remains 'Manual' due to ideally limited scope. The scope of application - which workloads CMK is applied to - should be carefully considered to account for organizational capacity and targeted to workloads with specific need for CMK.", + "ImpactStatement": "If the key expires due to setting the 'activation date' and 'expiration date', the key must be rotated manually. Using Customer Managed Keys may also incur additional man-hour requirements to create, store, manage, and protect the keys as needed.", + "RemediationProcedure": "NOTE: These remediations assume that an Azure KeyVault already exists in the subscription. Remediate from Azure CLI 1. Create a dedicated key: az keyvault key create --vault-name --name -protection 2. Assign permissions to Databricks: az keyvault set-policy --name --resource-group --spn --key-permissions get wrapKey unwrapKey 3. Enable encryption with CMK: az databricks workspace update --name --resourcegroup --key-source Microsoft.KeyVault --key-name --keyvault-uri Remediate from PowerShell $Key = Add-AzKeyVaultKey -VaultName -Name Destination Set-AzDatabricksWorkspace -ResourceGroupName WorkspaceName -EncryptionKeySource Microsoft.KeyVault -KeyVaultUri $Key.Id", + "AuditProcedure": "Audit: Audit from Azure Portal 1. Go to Azure Portal → Databricks Workspaces. 2. Select a Databricks Workspace and go to Encryption settings. 3. Check if customer-managed keys (CMK) are enabled under Managed Disk Encryption .4. If CMK is not enabled, the workspace is non-compliant. Audit from Azure CLI Run the following command to check encryption settings for Databricks workspace: az databricks workspace show --name --resourcegroup --query encryption Ensure that keySource is set to Microsoft.KeyVault. Audit from PowerShell Get-AzDatabricksWorkspace -ResourceGroupName -Name | Select-Object Encryption Verify that encryption is set to Customer-Managed Keys (CMK). Audit from Databricks CLI databricks workspace get-metadata --workspace-id Ensure that encryption settings reflect a CMK setup.", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation Ensure critical data is encrypted with customer-managed keys (CMK).", + "References": "https://docs.microsoft.com/en-us/azure/security/fundamentals/data-encryption-best-practices#protect-data-at-rest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-5-use-customer-managed-key-option-in-data-at-rest-encryption-when-required", + "DefaultValue": "By default, Encryption type is set to Microsoft Managed Keys." + } + ] + }, + { + "Id": "2.1.9", + "Description": "Ensure 'No Public IP' is Set to 'Enabled'", + "Checks": [ + "databricks_workspace_no_public_ip_enabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'No Public IP' is Set to 'Enabled'", + "RationaleStatement": "Enabling secure cluster connectivity limits exposure to the public internet, improving security and reducing the risk of external attacks.", + "ImpactStatement": "Enabling secure cluster connectivity requires careful network configuration. Before secure cluster connectivity can be enabled, Azure Databricks workspaces must be deployed in a customer-managed virtual network (VNet injection).", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Under `Network access`, next to `Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP)`, click the radio button next to `Enabled`. 5. Click `Save`. 6. Repeat steps 1-5 for each workspace requiring remediation. **Remediate from Azure CLI** For each workspace requiring remediation, run the following command to set enableNoPublicIp to true: ``` az databricks workspace update --resource-group --name --enable-no-public-ip true ``` **Remediate from PowerShell** For each workspace requiring remediation, run the following command to set EnableNoPublicIP to True: ``` Update-AzDatabricksWorkspace -ResourceGroupName -Name -EnableNoPublicIP ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Under `Network access`, ensure that `Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP)` is set to `Enabled`. 5. Repeat steps 1-4 for each workspace. **Audit from Azure CLI** Run the following command to list workspaces: ``` az databricks workspace list ``` For each workspace, run the following command to get the enableNoPublicIp setting: ``` az databricks workspace show --resource-group --name --query parameters.enableNoPublicIp.value ``` Ensure that `true` is returned. **Audit from PowerShell** Run the following command to list workspaces: ``` Get-AzDatabricksWorkspace ``` Run the following command to get the workspace in a resource group with a given name: ``` $workspace = Get-AzDatabricksWorkspace -ResourceGroupName -Name ``` Run the following command to get the EnableNoPublicIp setting: ``` $workspace.EnableNoPublicIP ``` Ensure that `True` is returned. **Audit from Azure Policy** - **Policy ID:** [51c1490f-3319-459c-bbbc-7f391bbed753] **- Name:** 'Azure Databricks Clusters should disable public IP'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity:https://learn.microsoft.com/en-us/cli/azure/databricks/workspace:https://learn.microsoft.com/en-us/powershell/module/az.databricks", + "DefaultValue": "No Public IP is set to Enabled by default." + } + ] + }, + { + "Id": "2.1.10", + "Description": "Ensure 'Allow Public Network Access' is set to 'Disabled'", + "Checks": [ + "databricks_workspace_public_network_access_disabled" + ], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Allow Public Network Access' is set to 'Disabled'", + "RationaleStatement": "Disabling public network access improves security by ensuring that Azure Databricks workspaces are not exposed on the public internet.", + "ImpactStatement": "Prior to disabling public network access, it is strongly recommended that virtual network integration is completed or private endpoints/links are set up. Disabling public network access restricts access to the service and will require the configuration of a virtual network and/or private endpoints for any services or users needing access within trusted networks.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings` click `Networking`. 4. Under `Network access`, next to `Allow Public Network Access`, click the radio button next to `Disabled`. 5. Click `Save`. 6. Repeat steps 1-5 for each workspace requiring remediation. **Remediate from Azure CLI** For each workspace requiring remediation, run the following command to set publicNetworkAccess to Disabled: ``` az databricks workspace update --resource-group --name --public-network-access Disabled ``` **Remediate from PowerShell** For each workspace requiring remediation, run the following command to set PublicNetworkAccess to Disabled: ``` Update-AzDatabricksWorkspace -ResourceGroupName -Name -PublicNetworkAccess Disabled ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings` click `Networking`. 4. Under `Network access`, ensure `Allow Public Network Access` is set to `Disabled`. 5. Repeat steps 1-4 for each workspace. **Audit from Azure CLI** Run the following command to list workspaces: ``` az databricks workspace list ``` For each workspace, run the following command to get the publicNetworkAccess setting: ``` az databricks workspace show --resource-group --name --query publicNetworkAccess ``` Ensure that `Disabled` is returned. **Audit from PowerShell** Run the following command to list workspaces: ``` Get-AzDatabricksWorkspace ``` Run the following command to get the PublicNetworkAccess setting: ``` $workspace = Get-AzDatabricksWorkspace -ResourceGroupName -Name $workspace.PublicNetworkAccess ``` Ensure that `Disabled` is returned. **Audit from Azure Policy** - **Policy ID:** [0e7849de-b939-4c50-ab48-fc6b0f5eeba2] **- Name:** 'Azure Databricks Workspaces should disable public network access'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation Ensure public network access is Disabled.", + "References": "https://learn.microsoft.com/en-us/cli/azure/databricks/workspace:https://learn.microsoft.com/en-us/powershell/module/az.databricks", + "DefaultValue": "Allow Public Network Access is set to Enabled by default." + } + ] + }, + { + "Id": "2.1.11", + "Description": "Ensure Private Endpoints are used to access Azure Databricks workspaces", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Private Endpoints are used to access Azure Databricks workspaces", + "RationaleStatement": "Using private endpoints for Azure Databricks workspaces ensures that all communication between clients, services, and data sources occurs over a secure, private IP space within an Azure Virtual Network (VNet). This approach eliminates exposure to the public internet, significantly reducing the attack surface and aligning with Zero Trust principles.", + "ImpactStatement": "If an Azure Virtual Network is not implemented correctly, this may result in the loss of critical network traffic. Private endpoints are charged per hour of use. Before a private endpoint can be configured, Azure Databricks workspaces must be deployed in a customer-managed virtual network, must have secure cluster connectivity enabled, and must be on the Premium pricing tier.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Click `Private endpoint connections`. 5. Click `+ Private endpoint`. 6. Under `Project details`, select a Subscription and a Resource group. 7. Under `Instance details`, provide a Name, Network Interface Name, and select a Region. 8. Click `Next : Resource >`. 9. Select a Target sub-resource. 10. Click `Next : Virtual Network >`. 11. Under `Networking`, select a Virtual network and a Subnet. 12. Optionally, configure Private IP configuration and Application security group. 13. Click `Next : DNS >`. 14. Optionally, configure Private DNS integration. 15. Click `Next : Tags >`. 16. Optionally, configure tags. 17. Click `Next : Review + create >`. 18. Click `Create`. 19. Repeat steps 1-18 for each workspace requiring remediation. **Remediate from Azure CLI** For each workspace requiring remediation, run the following command to create a private endpoint connection: ``` az network private-endpoint create --resource-group --name --location --vnet-name --subnet --private-connection-resource-id --connection-name --group-id ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Azure Databricks`. 2. Click the name of a workspace. 3. Under `Settings`, click `Networking`. 4. Click `Private endpoint connections`. 5. Ensure a private endpoint connection exists with a connection state of `Approved`. 6. Repeat steps 1-5 for each workspace. **Audit from Azure CLI** Run the following command to list workspaces: ``` az databricks workspace list ``` For each workspace, run the following command to get the privateEndpointConnections configuration: ``` az databricks workspace show --resource-group --name --query privateEndpointConnections ``` Ensure a private endpoint connection is returned with a privateLinkServiceConnectionState status of `Approved`. **Audit from PowerShell** Run the following command to list workspaces: ``` Get-AzDatabricksWorkspace ``` Run the following command to get the PrivateEndpointConnection configuration: ``` $workspace = Get-AzDatabricksWorkspace -ResourceGroupName -Name $workspace.PrivateEndpointConnection | Select-Object -Property Id,PrivateLinkServiceConnectionStateStatus ``` Ensure a private endpoint connection is returned with a PrivateLinkServiceConnectionStateStatus of `Approved`. **Audit from Azure Policy** - **Policy ID:** [258823f2-4595-4b52-b333-cc96192710d8] **- Name:** 'Azure Databricks Workspaces should use private link'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation Ensure Private Endpoints are used to access {service}.", + "References": "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link:https://learn.microsoft.com/en-us/cli/azure/databricks/workspace:https://learn.microsoft.com/en-us/powershell/module/az.databricks", + "DefaultValue": "Private endpoints are not configured for Azure Databricks workspaces by default." + } + ] + }, + { + "Id": "2.1.12", + "Description": "Ensure Azure Databricks groups are reviewed periodically", + "Checks": [], + "Attributes": [ + { + "Section": "2 Analytics Services", + "SubSection": "2.1 Azure Databricks", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Databricks groups are reviewed periodically", + "RationaleStatement": "To ensure accurate privileges for your Azure Databricks implementation, your organization should review all users and permission assignments on a regular interval.", + "ImpactStatement": "Administrative overhead of user management.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select `Azure Databricks`. 1. Select the Databricks implementation you wish to audit. 1. Select `Access control (IAM)`. 1. Scroll down and select `Add role assignment`. 1. Search for the role you wish to add. Then select `Next`. 1. Select the group members you wish to add. Then select `Next`. 1. Review the info you have chosen, then select `Review + assign`.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select `Azure Databricks`. 1. Select the Databricks implementation you wish to audit. 1. Select `Access control (IAM)`. 1. In the horizontal menu select `Role assignments`. 1. Audit the list for each role and its assignment to each user.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/azure-databricks-security-baseline:https://learn.microsoft.com/en-us/azure/databricks/security/auth/", + "DefaultValue": "By default Azure Databricks only has the Owner user and role assigned." + } + ] + }, + { + "Id": "3.1.1", + "Description": "Ensure only MFA Enabled Identities can Access Privileged Virtual Machine", + "Checks": [ + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "3 Compute Services", + "SubSection": "3.1 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure only MFA Enabled Identities can Access Privileged Virtual Machine", + "RationaleStatement": "Integrating multi-factor authentication (MFA) as part of the organizational policy can greatly reduce the risk of an identity gaining control of valid credentials that may be used for additional tactics such as initial access, lateral movement, and collecting information. MFA can also be used to restrict access to cloud resources and APIs. An Adversary may log into accessible cloud services within a compromised environment using Valid Accounts that are synchronized to move laterally and perform actions with the virtual machine's managed identity. The adversary may then perform management actions or access cloud-hosted resources as the logged-on managed identity.", + "ImpactStatement": "This recommendation requires the Entra ID P2 license to implement. Ensure that identities provisioned to a virtual machine utilize an RBAC/ABAC group and are allocated a role using Azure PIM, and that the role settings require MFA or use another third-party PAM solution for accessing virtual machines.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Log in to the Azure portal. 2. This can be remediated by enabling MFA for user, Removing user access or Reducing access of managed identities attached to virtual machines. - Case I : Enable MFA for users having access on virtual machines. 1. Go to `Microsoft Entra ID`. 1. For `Per-user MFA`: 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA`. 1. For each user requiring remediation, check the box next to their name. 1. Click `Enable MFA`. 1. Click `Enable`. 1. For `Conditional Access`: 1. Under `Manage`, click `Security`. 1. Under `Protect`, click `Conditional Access`. 1. Update the Conditional Access policy requiring MFA for all users, removing each user requiring remediation from the `Exclude` list. - Case II : Removing user access on a virtual machine. 1. Select the `Subscription`, then click on `Access control (IAM)`. 2. Select `Role assignments` and search for `Virtual Machine Administrator Login` or `Virtual Machine User Login` or any role that provides access to log into virtual machines. 3. Click on `Role Name`, Select `Assignments`, and remove identities with no MFA configured. - Case III : Reducing access of managed identities attached to virtual machines. 1. Select the `Subscription`, then click on `Access control (IAM)`. 2. Select `Role Assignments` from the top menu and apply filters on `Assignment type` as `Privileged administrator roles` and `Type` as `Virtual Machines`. 3. Click on `Role Name`, Select `Assignments`, and remove identities access make sure this follows the least privileges principal.", + "AuditProcedure": "**Audit from Azure Portal** 1. Log in to the Azure portal. 1. Select the `Subscription`, then click on `Access control (IAM)`. 1. Click `Role : All` and click `All` to display the drop-down menu. 1. Type `Virtual Machine Administrator Login` and select `Virtual Machine Administrator Login`. 1. Review the list of identities that have been assigned the `Virtual Machine Administrator Login` role. 1. Go to `Microsoft Entra ID`. 1. For `Per-user MFA`: 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA`. 1. Ensure that none of the identities assigned the `Virtual Machine Administrator Login` role from step 4 have `Status` set to `disabled`. 1. For `Conditional Access`: 1. Under `Manage`, click `Security`. 1. Under `Protect`, click `Conditional Access`. 1. Ensure that none of the identities assigned the `Virtual Machine Administrator Login` role from step 4 are exempt from a Conditional Access policy requiring MFA for all users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.1", + "Description": "Ensure that 'security defaults' is Enabled in Microsoft Entra ID", + "Checks": [ + "entra_security_defaults_enabled" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'security defaults' is Enabled in Microsoft Entra ID", + "RationaleStatement": "Security defaults provide secure default settings that we manage on behalf of organizations to keep customers safe until they are ready to manage their own identity security settings. For example, doing the following: - Requiring all users and admins to register for MFA. - Challenging users with MFA - when necessary, based on factors such as location, device, role, and task. - Disabling authentication from legacy authentication clients, which cant do MFA.", + "ImpactStatement": "This recommendation should be implemented initially and then may be overridden by other service/product specific CIS Benchmarks. Administrators should also be aware that certain configurations in Microsoft Entra ID may impact other Microsoft services such as Microsoft 365.", + "RemediationProcedure": "**Remediate from Azure Portal** To enable security defaults in your directory: 1. From Azure Home select the Portal Menu. 1. Browse to `Microsoft Entra ID` > `Properties`. 1. Select `Manage security defaults`. 1. Under `Security defaults`, select `Enabled (recommended)`. 1. Select `Save`.", + "AuditProcedure": "**Audit from Azure Portal** To ensure security defaults is enabled in your directory: 1. From Azure Home select the Portal Menu. 2. Browse to `Microsoft Entra ID` > `Properties`. 3. Select `Manage security defaults`. 4. Under `Security defaults`, verify that `Enabled (recommended)` is selected.", + "AdditionalInformation": "This recommendation differs from the [Microsoft 365 Benchmark](https://workbench.cisecurity.org/benchmarks/5741). This is because the potential impact associated with disabling Security Defaults is dependent upon the security settings implemented in the environment. It is recommended that organizations disabling Security Defaults implement appropriate security settings to replace the settings configured by Security Defaults.", + "References": "https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/concept-fundamentals-security-defaults:https://techcommunity.microsoft.com/t5/azure-active-directory-identity/introducing-security-defaults/ba-p/1061414:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-2-protect-identity-and-authentication-systems", + "DefaultValue": "If your tenant was created on or after October 22, 2019, security defaults may already be enabled in your tenant." + } + ] + }, + { + "Id": "5.1.2", + "Description": "Ensure that 'Require Multifactor Authentication to register or join devices with Microsoft Entra' is set to 'Yes'", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Require Multifactor Authentication to register or join devices with Microsoft Entra' is set to 'Yes'", + "RationaleStatement": "Multi-factor authentication is recommended when adding devices to Microsoft Entra ID. When set to `Yes`, users who are adding devices from the internet must first use the second method of authentication before their device is successfully added to the directory. This ensures that rogue devices are not added to the domain using a compromised user account.", + "ImpactStatement": "A slight impact of additional overhead, as Administrators will now have to approve every access to the domain.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Devices` 1. Under `Manage`, select `Device settings` 1. Under `Microsoft Entra join and registration settings`, set `Require Multifactor Authentication to register or join devices with Microsoft Entra` to `Yes` 1. Click `Save`", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Devices` 1. Under `Manage`, select `Device settings` 1. Under `Microsoft Entra join and registration settings`, ensure that `Require Multifactor Authentication to register or join devices with Microsoft Entra` is set to `Yes`", + "AdditionalInformation": "If Conditional Access is available, this recommendation should be bypassed in favor of the Conditional Access implementation of requiring Multifactor Authentication to register or join devices with Microsoft Entra. https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-mfa-device-register-join", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-mfa-device-register-join:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-6-use-strong-authentication-controls", + "DefaultValue": "By default, `Require Multifactor Authentication to register or join devices with Microsoft Entra` is set to `No`." + } + ] + }, + { + "Id": "5.1.3", + "Description": "Ensure that 'multifactor authentication' is 'enabled' For All Users", + "Checks": [ + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'multifactor authentication' is 'enabled' For All Users", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multifactor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multifactor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk.", + "ImpactStatement": "Users would require two forms of authentication before any access is granted. Additional administrative time will be required for managing dual forms of authentication when enabling multifactor authentication.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA` from the top menu. 1. Click the box next to a user with `Status` `disabled`. 1. Click `Enable MFA`. 1. Click `Enable`. 1. Repeat steps 1-6 for each user requiring remediation. **Other options within Azure Portal** - [https://docs.microsoft.com/en-us/azure/active-directory/authentication/tutorial-enable-azure-mfa](https://docs.microsoft.com/en-us/azure/active-directory/authentication/tutorial-enable-azure-mfa) - [https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-mfasettings](https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-mfasettings) - [https://docs.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-admin-mfa](https://docs.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-admin-mfa) - [https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-getstarted#enable-multi-factor-authentication-with-conditional-access](https://docs.microsoft.com/en-us/azure/active-directory/authentication/howto-mfa-getstarted#enable-multi-factor-authentication-with-conditional-access)", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Manage`, click `Users`. 1. Click `Per-user MFA` from the top menu. 1. Ensure that `Status` is `enabled` for all users. **Audit from REST API** Run the following Graph PowerShell command: ``` get-mguser -All | where {$_.StrongAuthenticationMethods.Count -eq 0} | Select-Object -Property UserPrincipalName ``` If the output contains any `UserPrincipalName`, then this recommendation is non-compliant.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/multi-factor-authentication/multi-factor-authentication:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication:https://azure.microsoft.com/en-us/blog/announcing-mandatory-multi-factor-authentication-for-azure-sign-in/:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-4-authenticate-server-and-services", + "DefaultValue": "Multifactor authentication is not enabled for all users by default. Starting in 2024, multifactor authentication is enabled for administrative accounts by default." + } + ] + }, + { + "Id": "5.1.4", + "Description": "Ensure that 'Allow users to remember multifactor authentication on devices they trust' is Disabled", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.1 Security Defaults (Per-User MFA)", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Allow users to remember multifactor authentication on devices they trust' is Disabled", + "RationaleStatement": "Remembering Multi-Factor Authentication (MFA) for devices and browsers allows users to have the option to bypass MFA for a set number of days after performing a successful sign-in using MFA. This can enhance usability by minimizing the number of times a user may need to perform two-step verification on the same device. However, if an account or device is compromised, remembering MFA for trusted devices may affect security. Hence, it is recommended that users not be allowed to bypass MFA.", + "ImpactStatement": "For every login attempt, the user will be required to perform multi-factor authentication.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, click `Users` 1. Click the `Per-user MFA` button on the top bar 1. Click on `Service settings` 1. Uncheck the box next to `Allow users to remember multi-factor authentication on devices they trust` 1. Click `Save`", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, click `Users` 1. Click the `Per-user MFA` button on the top bar 1. Click on `Service settings` 1. Ensure that `Allow users to remember multi-factor authentication on devices they trust` is not enabled", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-mfasettings#remember-multi-factor-authentication-for-devices-that-users-trust:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-identity-management#im-4-use-strong-authentication-controls-for-all-azure-active-directory-based-access:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-6-use-strong-authentication-controls", + "DefaultValue": "By default, `Allow users to remember multi-factor authentication on devices they trust` is disabled." + } + ] + }, + { + "Id": "5.3.1", + "Description": "Ensure that Azure Admin Accounts Are Not Used for Daily Operations", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Azure Admin Accounts Are Not Used for Daily Operations", + "RationaleStatement": "Using admin accounts for daily operations increases the risk of accidental misconfigurations and security breaches.", + "ImpactStatement": "Minor administrative overhead includes managing separate accounts, enforcing stricter access controls, and potential licensing costs for advanced security features.", + "RemediationProcedure": "If admin accounts are being used for daily operations, consider the following: - Monitor and alert on unusual activity. - Enforce the principle of least privilege. - Revoke any unnecessary administrative access. - Use Conditional Access to limit access to resources. - Ensure that administrators have separate admin and user accounts. - Use Microsoft Entra ID Protection helps organizations detect, investigate, and remediate identity-based risks. - Use Privileged Identity Management (PIM) in Microsoft Entra ID to limit standing administrator access to privileged roles, discover who has access, and review privileged access.", + "AuditProcedure": "**Audit from Azure Portal** Monitor: 1. Go to `Monitor`. 1. Click `Activity log`. 1. Review the activity log and ensure that admin accounts are not being used for daily operations. Microsoft Entra ID: 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Sign-in logs`. 1. Review the sign-in logs and ensure that admin accounts are not being accessed more frequently than necessary.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/privileged-access-workstations/critical-impact-accounts", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3.2", + "Description": "Ensure that Guest Users are Reviewed on a Regular Basis", + "Checks": [ + "entra_policy_guest_users_access_restrictions" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Guest Users are Reviewed on a Regular Basis", + "RationaleStatement": "Guest users are typically added outside your employee on-boarding/off-boarding process and could potentially be overlooked indefinitely. To prevent this, guest users should be reviewed on a regular basis. During this audit, guest users should also be determined to not have administrative privileges.", + "ImpactStatement": "Before removing guest users, determine their use and scope. Like removing any user, there may be unforeseen consequences to systems if an account is removed without careful consideration.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Users` 1. Click on `Add filter` 1. Select `User type` 1. Select `Guest` from the Value dropdown 1. Click `Apply` 1. Check the box next to all `Guest` users that are no longer required or are inactive 1. Click `Delete` 1. Click `OK` **Remediate from Azure CLI** Before deleting the user, set it to inactive using the ID from the Audit Procedure to determine if there are any dependent systems. ``` az ad user update --id --account-enabled {false} ``` After determining that there are no dependent systems, delete the user. ``` Remove-AzureADUser -ObjectId ``` **Remediate from Azure PowerShell** Before deleting the user, set it to inactive using the ID from the Audit Procedure to determine if there are any dependent systems. ``` Set-AzureADUser -ObjectId -AccountEnabled false ``` After determining that there are no dependent systems, delete the user. ``` PS C:\\>Remove-AzureADUser -ObjectId ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Entra ID` 1. Under `Manage`, select `Users` 1. Click on `Add filter` 1. Select `User type` 1. Select `Guest` from the Value dropdown 1. Click `Apply` 1. Audit the listed guest users **Audit from Azure CLI** ``` az ad user list --query [?userType=='Guest'] ``` Ensure all users listed are still required and not inactive. **Audit from Azure PowerShell** ``` Get-AzureADUser |Where-Object {$_.UserType -like Guest} |Select-Object DisplayName, UserPrincipalName, UserType -Unique ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [e9ac8f8e-ce22-4355-8f04-99b911d6be52](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fe9ac8f8e-ce22-4355-8f04-99b911d6be52) **- Name:** 'Guest accounts with read permissions on Azure resources should be removed' - **Policy ID:** [94e1c2ac-cbbe-4cac-a2b5-389c812dee87](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F94e1c2ac-cbbe-4cac-a2b5-389c812dee87) **- Name:** 'Guest accounts with write permissions on Azure resources should be removed' - **Policy ID:** [339353f6-2387-4a45-abe4-7f529d121046](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F339353f6-2387-4a45-abe4-7f529d121046) **- Name:** 'Guest accounts with owner permissions on Azure resources should be removed'", + "AdditionalInformation": "It is good practice to use a dynamic security group to manage guest users. To create the dynamic security group: 1. Navigate to the 'Microsoft Entra ID' blade in the Azure Portal 2. Select the 'Groups' item 3. Create new 4. Type of 'dynamic' 5. Use the following dynamic selection rule. (user.userType -eq Guest) 6. Once the group has been created, select access reviews option and create a new access review with a period of monthly and send to relevant administrators for review.", + "References": "https://learn.microsoft.com/en-us/entra/external-id/user-properties:https://learn.microsoft.com/en-us/entra/fundamentals/how-to-create-delete-users#delete-a-user:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-4-review-and-reconcile-user-access-regularly:https://www.microsoft.com/en-us/security/business/identity-access-management/azure-ad-pricing:https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-manage-inactive-user-accounts:https://learn.microsoft.com/en-us/entra/fundamentals/users-restore", + "DefaultValue": "By default no guest users are created." + } + ] + }, + { + "Id": "5.3.3", + "Description": "Ensure That Use of the 'User Access Administrator' Role is Restricted", + "Checks": [ + "iam_role_user_access_admin_restricted" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That Use of the 'User Access Administrator' Role is Restricted", + "RationaleStatement": "The User Access Administrator role provides extensive access control privileges. Unnecessary assignments heighten the risk of privilege escalation and unauthorized access. Removing the role immediately after use minimizes security exposure.", + "ImpactStatement": "Increased administrative effort to manage and remove role assignments appropriately.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Look for the following banner at the top of the page: `Action required: X users have elevated access in your tenant. You should take immediate action and remove all role assignments with elevated access.` 1. Click `View role assignments`. 1. Click `Remove`. **Remediate from Azure CLI** Run the following command: ``` az role assignment delete --role User Access Administrator --scope / ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Look for the following banner at the top of the page: `Action required: X users have elevated access in your tenant. You should take immediate action and remove all role assignments with elevated access.` If the banner is displayed, the `User Access Administrator` is assigned. **Audit from Azure CLI** Run the following command: ``` az role assignment list --role User Access Administrator --scope / ``` Ensure that the command does not return any `User Access Administrator` role assignment information.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles:https://learn.microsoft.com/en-us/azure/role-based-access-control/elevate-access-global-admin", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3.4", + "Description": "Ensure that All 'Privileged' Role Assignments are Periodically Reviewed", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that All 'Privileged' Role Assignments are Periodically Reviewed", + "RationaleStatement": "Privileged roles are crown jewel assets that can be used by malicious insiders, threat actors, and even through mistake to significantly damage an organization. These roles should be periodically reviewed to identify lingering permissions assignment and detect lateral movement through privilege escalation.", + "ImpactStatement": "Increased administrative effort to manage and remove role assignments appropriately.", + "RemediationProcedure": "Review privileged role assignments and remove any that are no longer necessary or appropriate. Use Azure PIM (Privileged Identity Management) to implement just-in-time access for privileged roles.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 2. Select `Subscriptions`. 3. Select a subscription. 4. Select `Access control (IAM)`. 5. Look for the number under the word `Privileged` accompanied by a link titled `View Assignments`. Click the `View assignments` link. 6. For each privileged role listed, evaluate whether the assignment is appropriate and current for each User, Group, or App assigned to each privileged role. NOTE: Determining 'appropriate' assignments requires a clear understanding of your organization's personnel, systems, policy, and security requirements.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.3.5", + "Description": "Ensure Disabled User Accounts do not Have Read, Write, or Owner Permissions", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure Disabled User Accounts do not Have Read, Write, or Owner Permissions", + "RationaleStatement": "Disabled accounts should not retain access to resources, as this poses a security risk. Removing role assignments mitigates potential unauthorized access and enforces the principle of least privilege.", + "ImpactStatement": "Ensure disabled accounts are not relied on for break glass or automated processes before removing roles to avoid service disruption.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Users`. 3. Click `Add filter`. 4. Click `Account enabled`. 5. Click the toggle switch to set the value to `No`. 6. Click `Apply`. 7. Click the `Display name` of a disabled user account with read, write, or owner roles assigned. 8. Click `Azure role assignments`. 9. Click the name of a read, write, or owner role. 10. Click `Assignments`. 11. Click `Remove` in the row for the disabled user account. 12. Click `Yes`. 13. Repeat steps 7-12 for disabled user accounts requiring remediation. **Remediate from PowerShell** ``` Remove-AzRoleAssignment -ObjectId $user.ObjectId -RoleDefinitionName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Users`. 3. Click `Add filter`. 4. Click `Account enabled`. 5. Click the toggle switch to set the value to `No`. 6. Click `Apply`. 7. Click the `Display name` of a disabled user account. 8. Click `Azure role assignments`. 9. Ensure that no read, write, or owner roles are assigned to the user account. 10. Repeat steps 7-9 for each disabled user account. **Audit from PowerShell** ``` Connect-AzureAD Get-AzureADUser $user = Get-AzureADUser -ObjectId $user.AccountEnabled ``` If AccountEnabled is False, run: ``` Get-AzRoleAssignment -ObjectId $user.ObjectId ``` **Audit from Azure Policy** - **Policy ID:** [0cfea604-3201-4e14-88fc-fae4c427a6c5] - Name: 'Blocked accounts with owner permissions on Azure resources should be removed' - **Policy ID:** [8d7e1fde-fe26-4b5f-8108-f8e432cbc2be] - Name: 'Blocked accounts with read and write permissions on Azure resources should be removed'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/powershell/module/az.resources/get-azaduser:https://learn.microsoft.com/en-us/powershell/module/az.resources/get-azroleassignment:https://learn.microsoft.com/en-us/powershell/module/az.resources/remove-azroleassignment", + "DefaultValue": "Disabled user accounts retain their prior role assignments." + } + ] + }, + { + "Id": "5.3.6", + "Description": "Ensure 'Tenant Creator' Role Assignments are Periodically Reviewed", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure 'Tenant Creator' Role Assignments are Periodically Reviewed", + "RationaleStatement": "Unnecessary assignments increase the risk of privilege escalation and unauthorized access. This recommendation should be applied alongside the recommendation 'Ensure that Restrict non-admin users from creating tenants is set to Yes'.", + "ImpactStatement": "Verify that the Tenant Creator role is no longer required by any assignments before removal to avoid disruption of critical functions.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Roles and administrators`. 3. In the search bar, type `Tenant Creator`. 4. Click the role. 5. Click the name of an assignment. 6. Check the box next to the `Tenant Creator` role. 7. Click `X Remove assignments`. 8. Click `Yes`. 9. Repeat steps 1-8 for each assignment requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 2. Under `Manage`, click `Roles and administrators`. 3. In the search bar, type `Tenant Creator`. 4. Click the role. 5. Review the assignments and ensure that they are appropriate.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/active-directory-b2c/tenant-management-check-tenant-creation-permission:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator", + "DefaultValue": "The Tenant Creator role is not assigned by default." + } + ] + }, + { + "Id": "5.3.7", + "Description": "Ensure All Non-privileged Role Assignments are Periodically Reviewed", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "SubSection": "5.3 Periodic Identity Reviews", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure All Non-privileged Role Assignments are Periodically Reviewed", + "RationaleStatement": "To ensure the principle of least privilege is followed, non-privileged role assignments should be reviewed periodically to confirm that users are granted only the minimum level of permissions they need to perform their tasks.", + "ImpactStatement": "Increased administrative effort to manage and remove role assignments appropriately.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access control (IAM)`. 4. Click `Role assignments`. 5. Click `Job function roles`. 6. Check the box next to any inappropriate assignments. 7. Click `Delete`. 8. Click `Yes`. 9. Repeat steps 1-8 for each subscription.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access control (IAM)`. 4. Click `Role assignments`. 5. Click `Job function roles`. 6. For each role, ensure the assignments are appropriate. 7. Repeat steps 1-6 for each subscription.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments", + "DefaultValue": "Users do not have non-privileged roles assigned to them by default." + } + ] + }, + { + "Id": "5.4", + "Description": "Ensure that No Custom Subscription Administrator Roles Exist", + "Checks": [ + "iam_subscription_roles_owner_custom_not_created" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that No Custom Subscription Administrator Roles Exist", + "RationaleStatement": "Custom roles in Azure with administrative access can obfuscate the permissions granted and introduce complexity and blind spots to the management of privileged identities. For less mature security programs without regular identity audits, the creation of Custom roles should be avoided entirely. For more mature security programs with regular identity audits, Custom Roles should be audited for use and assignment, used minimally, and the principle of least privilege should be observed when granting permissions", + "ImpactStatement": "Subscriptions will need to be handled by Administrators with permissions.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Select `Roles`. 1. Click `Type` and select `Custom role` from the drop-down menu. 1. Check the box next to each role which grants subscription administrator privileges. 1. Select `Delete`. 1. Select `Yes`. **Remediate from Azure CLI** List custom roles: ``` az role definition list --custom-role-only True ``` Check for entries with `assignableScope` of the `subscription`, and an action of `*`. To remove a violating role: ``` az role definition delete --name ``` Note that any role assignments must be removed before a custom role can be deleted. Ensure impact is assessed before deleting a custom role granting subscription administrator privileges.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Subscriptions`. 1. Select a subscription. 1. Select `Access control (IAM)`. 1. Select `Roles`. 1. Click `Type` and select `Custom role` from the drop-down menu. 1. Select `View` next to a role. 1. Select `JSON`. 1. Check for `assignableScopes` set to the subscription, and `actions` set to `*`. 1. Repeat steps 7-9 for each custom role. **Audit from Azure CLI** List custom roles: ``` az role definition list --custom-role-only True ``` Check for entries with `assignableScope` of the `subscription`, and an action of `*` **Audit from PowerShell** ``` Connect-AzAccount Get-AzRoleDefinition |Where-Object {($_.IsCustom -eq $true) -and ($_.Actions.contains('*'))} ``` Check the output for `AssignableScopes` value set to the subscription. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [a451c1ef-c6ca-483d-87ed-f49761e3ffb5](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fa451c1ef-c6ca-483d-87ed-f49761e3ffb5) **- Name:** 'Audit usage of custom RBAC roles'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/billing/billing-add-change-azure-subscription-administrator:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-3-manage-lifecycle-of-identities-and-entitlements:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle", + "DefaultValue": "By default, no custom owner roles are created." + } + ] + }, + { + "Id": "5.5", + "Description": "Ensure that a Custom Role is Assigned Permissions for Administering Resource Locks", + "Checks": [ + "iam_custom_role_has_permissions_to_administer_resource_locks" + ], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a Custom Role is Assigned Permissions for Administering Resource Locks", + "RationaleStatement": "Given that the resource lock functionality is outside of standard Role-Based Access Control (RBAC), it would be prudent to create a resource lock administrator role to prevent inadvertent unlocking of resources.", + "ImpactStatement": "By adding this role, specific permissions may be granted for managing only resource locks rather than needing to provide the broad Owner or User Access Administrator role, reducing the risk of the user being able to cause unintentional damage.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. In the Azure portal, navigate to a subscription or resource group. 1. Click `Access control (IAM)`. 1. Click `+ Add`. 1. Click `Add custom role`. 1. In the `Custom role name` field enter `Resource Lock Administrator`. 1. In the `Description` field enter `Can Administer Resource Locks`. 1. For `Baseline permissions` select `Start from scratch`. 1. Click `Next`. 1. Click `Add permissions`. 1. In the `Search for a permission` box, type `Microsoft.Authorization/locks`. 1. Click the result. 1. Check the box next to `Permission`. 1. Click `Add`. 1. Click `Review + create`. 1. Click `Create`. 1. Click `OK`. 1. Click `+ Add`. 1. Click `Add role assignment`. 1. In the `Search by role name, description, permission, or ID` box, type `Resource Lock Administrator`. 1. Select the role. 1. Click `Next`. 1. Click `+ Select members`. 1. Select appropriate members. 1. Click `Select`. 1. Click `Review + assign`. 1. Click `Review + assign` again. 1. Repeat steps 1-26 for each subscription or resource group requiring remediation. **Remediate from PowerShell:** Below is a PowerShell definition for a resource lock administrator role created at an Azure Management group level ``` Import-Module Az.Accounts Connect-AzAccount $role = Get-AzRoleDefinition User Access Administrator $role.Id = $null $role.Name = Resource Lock Administrator $role.Description = Can Administer Resource Locks $role.Actions.Clear() $role.Actions.Add(Microsoft.Authorization/locks/*) $role.AssignableScopes.Clear() * Scope at the Management group level Management group $role.AssignableScopes.Add(/providers/Microsoft.Management/managementGroups/MG-Name) New-AzRoleDefinition -Role $role Get-AzureRmRoleDefinition Resource Lock Administrator ```", + "AuditProcedure": "**Audit from Azure Portal** 1. In the Azure portal, navigate to a subscription or resource group. 1. Click `Access control (IAM)`. 1. Click `Roles`. 1. Click `Type : All`. 1. Click to view the drop-down menu. 1. Select `Custom role`. 1. Click `View` in the `Details` column of a custom role. 1. Review the role permissions. 1. Click `Assignments` and review the assignments. 1. Click the `X` to exit the custom role details page. 1. Repeat steps 7-10. Ensure that at least one custom role exists that assigns the `Microsoft.Authorization/locks` permission to appropriate members. 1. Repeat steps 1-11 for each subscription or resource group.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/role-based-access-control/custom-roles:https://docs.microsoft.com/en-us/azure/role-based-access-control/check-access:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-3-manage-lifecycle-of-identities-and-entitlements:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy", + "DefaultValue": "A role for administering resource locks does not exist by default." + } + ] + }, + { + "Id": "5.6", + "Description": "Ensure that 'Subscription leaving Microsoft Entra tenant' and 'Subscription entering Microsoft Entra tenant' is set to 'Permit no one'", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Subscription leaving Microsoft Entra tenant' and 'Subscription entering Microsoft Entra tenant' is set to 'Permit no one'", + "RationaleStatement": "Permissions to move subscriptions in and out of a Microsoft Entra tenant must only be given to appropriate administrative personnel. A subscription that is moved into a Microsoft Entra tenant may be within a folder to which other users have elevated permissions. This prevents loss of data or unapproved changes of the objects within by potential bad actors.", + "ImpactStatement": "Subscriptions will need to have these settings turned off to be moved.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From the Azure Portal Home select the portal menu 1. Select `Subscriptions` 1. In the `Advanced options` drop-down menu, select `Manage Policies` 1. Set `Subscription leaving Microsoft Entra tenant` and `Subscription entering Microsoft Entra tenant` to `Permit no one` 1. Click `Save changes`", + "AuditProcedure": "**Audit from Azure Portal** 1. From the Azure Portal Home select the portal menu 1. Select `Subscriptions` 1. In the `Advanced options` drop-down menu, select `Manage Policies` 1. Ensure `Subscription leaving Microsoft Entra tenant` and `Subscription entering Microsoft Entra tenant` are set to `Permit no one`", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/cost-management-billing/manage/manage-azure-subscription-policy:https://learn.microsoft.com/en-us/entra/fundamentals/how-subscriptions-associated-directory:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-2-protect-identity-and-authentication-systems", + "DefaultValue": "By default `Subscription leaving Microsoft Entra tenant` and `Subscription entering Microsoft Entra tenant` are set to `Allow everyone (default)`" + } + ] + }, + { + "Id": "5.7", + "Description": "Ensure there are between 2 and 3 Subscription Owners", + "Checks": [], + "Attributes": [ + { + "Section": "5 Identity Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure there are between 2 and 3 Subscription Owners", + "RationaleStatement": "If groups are used, ensure their membership is tightly controlled and regularly reviewed to avoid privilege sprawl. This includes user accounts, Entra ID groups, service principals, and managed identities.", + "ImpactStatement": "Implementation may require changes in administrative workflows or the redistribution of roles and responsibilities.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access Controls (IAM)`. 4. Click `Role assignments`. 5. Click `Role : All`. 6. Click `Owner`. 7. Check the box next to members from whom the owner role should be removed. 8. Click `Delete`. 9. Click `Yes`. 10. Repeat for each subscription requiring remediation. **Remediate from Azure CLI** ``` az role assignment delete --ids ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Subscriptions`. 2. Click the name of a subscription. 3. Click `Access Controls (IAM)`. 4. Click `Role assignments`. 5. Click `Role : All`. 6. Click the arrow next to `All`. 7. Click `Owner`. 8. Ensure a minimum of 2 and a maximum of 3 members are returned. 9. Repeat steps 1-8 for each subscription. **Audit from Azure CLI** ``` az role assignment list --role Owner --scope /subscriptions/ --query \"[].{PrincipalName:principalName, Type:principalType}\" ``` Ensure a minimum of 2 and a maximum of 3 members are returned. **Audit from PowerShell** ``` Get-AzRoleAssignment -RoleDefinitionName Owner -Scope /subscriptions/ ``` **Audit from Azure Policy** - **Policy ID:** [09024ccc-0c5f-475e-9457-b7c0d9ed487b] - Name: 'There should be more than one owner assigned to your subscription' - **Policy ID:** [4f11b553-d42e-4e3a-89be-32ca364cad4c] - Name: 'A maximum of 3 owners should be designated for your subscription'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/cli/azure/role/assignment:https://learn.microsoft.com/en-us/powershell/module/az.resources/get-azroleassignment:https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#owner", + "DefaultValue": "A subscription has 1 owner by default." + } + ] + }, + { + "Id": "6.1.1.1", + "Description": "Ensure that a 'Diagnostic Setting' Exists for Subscription Activity Logs", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that a 'Diagnostic Setting' Exists for Subscription Activity Logs", + "RationaleStatement": "A diagnostic setting controls how a diagnostic log is exported. By default, logs are retained only for 90 days. Diagnostic settings should be defined so that logs can be exported and stored for a longer duration to analyze security activities within an Azure subscription.", + "ImpactStatement": "Diagnostic settings incur costs based on the amount of data collected and the destination.", + "RemediationProcedure": "**Remediate from Azure Portal** To enable Diagnostic Settings on a Subscription: 1. Go to `Monitor` 2. Click on `Activity log` 3. Click on `Export Activity Logs` 4. Click `+ Add diagnostic setting` 5. Enter a `Diagnostic setting name` 6. Select `Categories` for the diagnostic setting 7. Select the appropriate `Destination details` (this may be Log Analytics, Storage Account, Event Hub, or Partner solution) 8. Click `Save` To enable Diagnostic Settings on a specific resource: 1. Go to `Monitoring` 1. Click `Diagnostic settings` 1. Select `Add diagnostic setting` 1. Enter a `Diagnostic setting name` 1. Select the appropriate log, metric, and destination (this may be Log Analytics, Storage Account, Event Hub, or Partner solution) 1. Click `Save` Repeat these step for all resources as needed. **Remediate from Azure CLI** To configure Diagnostic Settings on a Subscription: ``` az monitor diagnostic-settings subscription create --subscription --name --location <[--event-hub --event-hub-auth-rule ] [--storage-account ] [--workspace ] --logs (e.g. [{category:Security,enabled:true},{category:Administrative,enabled:true},{category:Alert,enabled:true},{category:Policy,enabled:true}]) ``` To configure Diagnostic Settings on a specific resource: ``` az monitor diagnostic-settings create --subscription --resource --name <[--event-hub --event-hub-rule ] [--storage-account ] [--workspace ] --logs --metrics ``` **Remediate from PowerShell** To configure Diagnostic Settings on a subscription: ``` $logCategories = @(); $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Administrative -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Security -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category ServiceHealth -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Alert -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Recommendation -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Policy -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Autoscale -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category ResourceHealth -Enabled $true New-AzSubscriptionDiagnosticSetting -SubscriptionId -Name <[-EventHubAuthorizationRule -EventHubName ] [-StorageAccountId ] [-WorkSpaceId ] [-MarketplacePartner ID ]> -Log $logCategories ``` To configure Diagnostic Settings on a specific resource: ``` $logCategories = @() $logCategories += New-AzDiagnosticSettingLogSettingsObject -Category -Enabled $true Repeat command and variable assignment for each Log category specific to the resource where this Diagnostic Setting will get configured. $metricCategories = @() $metricCategories += New-AzDiagnosticSettingMetricSettingsObject -Enabled $true [-Category ] [-RetentionPolicyDay ] [-RetentionPolicyEnabled $true] Repeat command and variable assignment for each Metric category or use the 'AllMetrics' category. New-AzDiagnosticSetting -ResourceId -Name -Log $logCategories -Metric $metricCategories [-EventHubAuthorizationRuleId -EventHubName ] [-StorageAccountId ] [-WorkspaceId ] [-MarketplacePartnerId ]>", + "AuditProcedure": "**Audit from Azure Portal** To identify Diagnostic Settings on a subscription: 1. Go to `Monitor` 2. Click `Activity Log` 3. Click `Export Activity Logs` 4. Select a `Subscription` 5. Ensure a `Diagnostic setting` exists for the selected Subscription To identify Diagnostic Settings on specific resources: 1. Go to `Monitoring` 2. Click `Diagnostic settings` 3. Ensure a `Diagnostic setting` exists for all appropriate resources. **Audit from Azure CLI** To identify Diagnostic Settings on a subscription: ``` az monitor diagnostic-settings subscription list --subscription ``` To identify Diagnostic Settings on a resource ``` az monitor diagnostic-settings list --resource ``` **Audit from PowerShell** To identify Diagnostic Settings on a Subscription: ``` Get-AzDiagnosticSetting -SubscriptionId ``` To identify Diagnostic Settings on a specific resource: ``` Get-AzDiagnosticSetting -ResourceId ```", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/monitoring-and-diagnostics/monitoring-overview-activity-logs#export-the-activity-log-with-a-log-profile:https://learn.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, diagnostic setting is not set." + } + ] + }, + { + "Id": "6.1.1.2", + "Description": "Ensure Diagnostic Setting Captures Appropriate Categories", + "Checks": [ + "monitor_diagnostic_setting_with_appropriate_categories" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Diagnostic Setting Captures Appropriate Categories", + "RationaleStatement": "A diagnostic setting controls how the diagnostic log is exported. Capturing the diagnostic setting categories for appropriate control/management plane activities allows proper alerting.", + "ImpactStatement": "Enabling additional categories may increase storage costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Monitor`. 1. Click `Activity log`. 1. Click on `Export Activity Logs`. 1. Select the `Subscription` from the drop down menu. 1. Click `Edit setting` next to a diagnostic setting. 1. Check the following categories: `Administrative, Alert, Policy, and Security`. 1. Choose the destination details according to your organization's needs. 1. Click `Save`. **Remediate from Azure CLI** ``` az monitor diagnostic-settings subscription create --subscription --name --location <[--event-hub --event-hub-auth-rule ] [--storage-account ] [--workspace ] --logs [{category:Security,enabled:true},{category:Administrative,enabled:true},{category:Alert,enabled:true},{category:Policy,enabled:true}] ``` **Remediate from PowerShell** ``` $logCategories = @(); $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Administrative -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Security -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Alert -Enabled $true $logCategories += New-AzDiagnosticSettingSubscriptionLogSettingsObject -Category Policy -Enabled $true New-AzSubscriptionDiagnosticSetting -SubscriptionId -Name <[-EventHubAuthorizationRule -EventHubName ] [-StorageAccountId ] [-WorkSpaceId ] [-MarketplacePartner ID ]> -Log $logCategories ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Monitor`. 1. Click `Activity log`. 1. Click on `Export Activity Logs`. 1. Select the appropriate `Subscription`. 1. Click `Edit setting` next to a diagnostic setting. 1. Ensure that the following categories are checked: `Administrative, Alert, Policy, and Security`. **Audit from Azure CLI** Ensure the categories `'Administrative', 'Alert', 'Policy', and 'Security'` set to: 'enabled: true' ``` az monitor diagnostic-settings subscription list --subscription ``` **Audit from PowerShell** Ensure the categories Administrative, Alert, Policy, and Security are set to Enabled:True ``` Get-AzSubscriptionDiagnosticSetting -Subscription ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [3b980d31-7904-4bb7-8575-5665739a8052](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F3b980d31-7904-4bb7-8575-5665739a8052) **- Name:** 'An activity log alert should exist for specific Security operations' - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations' - **Policy ID:** [c5447c04-a4d7-4ba8-a263-c9ee321a6858](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc5447c04-a4d7-4ba8-a263-c9ee321a6858) **- Name:** 'An activity log alert should exist for specific Policy operations'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings:https://docs.microsoft.com/en-us/azure/azure-monitor/samples/resource-manager-diagnostic-settings:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://learn.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest:https://learn.microsoft.com/en-us/powershell/module/az.monitor/new-azsubscriptiondiagnosticsetting?view=azps-9.2.0", + "DefaultValue": "When the diagnostic setting is created using Azure Portal, by default no categories are selected." + } + ] + }, + { + "Id": "6.1.1.3", + "Description": "Ensure the Storage Account Containing the Container with Activity Logs is Encrypted with Customer-managed Key (CMK)", + "Checks": [ + "monitor_storage_account_with_activity_logs_cmk_encrypted" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure the Storage Account Containing the Container with Activity Logs is Encrypted with Customer-managed Key (CMK)", + "RationaleStatement": "Configuring the storage account with the activity log export container to use CMKs provides additional confidentiality controls on log data, as a given user must have read permission on the corresponding storage account and must be granted decrypt permission by the CMK.", + "ImpactStatement": "**NOTE:** You must have your key vault setup to utilize this. All Audit Logs will be encrypted with a key you provide. You will need to set up customer managed keys separately, and you will select which key to use via the instructions here. You will be responsible for the lifecycle of the keys, and will need to manually replace them at your own determined intervals to keep the data secure.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Monitor`. 1. Select `Activity log`. 1. Select `Export Activity Logs`. 1. Select a `Subscription`. 1. Note the name of the `Storage Account` for the diagnostic setting. 1. Navigate to `Storage accounts`. 1. Click on the storage account. 1. Under `Security + networking`, click `Encryption`. 1. Next to `Encryption type`, select `Customer-managed keys`. 1. Complete the steps to configure a customer-managed key for encryption of the storage account. **Remediate from Azure CLI** ``` az storage account update --name --resource-group --encryption-key-source=Microsoft.Keyvault --encryption-key-vault --encryption-key-name --encryption-key-version ``` **Remediate from PowerShell** ``` Set-AzStorageAccount -ResourceGroupName -Name -KeyvaultEncryption -KeyVaultUri -KeyName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Monitor`. 1. Select `Activity log`. 1. Select `Export Activity Logs`. 1. Select a `Subscription`. 1. Note the name of the `Storage Account` for the diagnostic setting. 1. Navigate to `Storage accounts`. 1. Click on the storage account name noted in Step 5. 1. Under `Security + networking`, click `Encryption`. 1. Ensure `Customer-managed keys` is selected and a key is set. **Audit from Azure CLI** 1. Get storage account id configured with log profile: ``` az monitor diagnostic-settings subscription list --subscription --query 'value[*].storageAccountId' ``` 2. Ensure the storage account is encrypted with CMK: ``` az storage account list --query [?name==''] ``` In command output ensure `keySource` is set to `Microsoft.Keyvault` and `keyVaultProperties` is not set to `null` **Audit from PowerShell** ``` Get-AzStorageAccount -ResourceGroupName -Name |select-object -ExpandProperty encryption|format-list ``` Ensure the value of `KeyVaultProperties` is not `null` or empty, and ensure `KeySource` is not set to `Microsoft.Storage`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [fbb99e8e-e444-4da0-9ff1-75c92f5a85b2](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ffbb99e8e-e444-4da0-9ff1-75c92f5a85b2) **- Name:** 'Storage account containing the container with activity logs must be encrypted with BYOK'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-5-use-customer-managed-key-option-in-data-at-rest-encryption-when-required:https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/activity-log?tabs=cli#managing-legacy-log-profiles", + "DefaultValue": "By default, for a storage account `keySource` is set to `Microsoft.Storage` allowing encryption with vendor Managed key and not a Customer Managed Key." + } + ] + }, + { + "Id": "6.1.1.4", + "Description": "Ensure that Logging for Azure Key Vault is 'Enabled'", + "Checks": [ + "keyvault_logging_enabled" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Logging for Azure Key Vault is 'Enabled'", + "RationaleStatement": "Monitoring how and when key vaults are accessed, and by whom, enables an audit trail of interactions with confidential information, keys, and certificates managed by Azure Key Vault. Enabling logging for Key Vault saves information in a user provided destination of either an Azure storage account or Log Analytics workspace. The same destination can be used for collecting logs for multiple Key Vaults.", + "ImpactStatement": "Enabling logging incurs costs based on the volume of logs generated.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. Select a Key vault. 3. Under `Monitoring`, select `Diagnostic settings`. 4. Click `Edit setting` to update an existing diagnostic setting, or `Add diagnostic setting` to create a new one. 5. If creating a new diagnostic setting, provide a name. 6. Configure an appropriate destination. 7. Under `Category groups`, check `audit` and `allLogs`. 8. Click `Save`. **Remediate from Azure CLI** To update an existing `Diagnostic Settings` ``` az monitor diagnostic-settings update --name --resource ``` To create a new `Diagnostic Settings` ``` az monitor diagnostic-settings create --name --resource --logs [{category:audit,enabled:true},{category:allLogs,enabled:true}] --metrics [{category:AllMetrics,enabled:true}] <[--event-hub --event-hub-rule | --storage-account |--workspace | --marketplace-partner-id ]> ``` **Remediate from PowerShell** Create the `Log` settings object ``` $logSettings = @() $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -Category audit $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -Category allLogs ``` Create the `Metric` settings object ``` $metricSettings = @() $metricSettings += New-AzDiagnosticSettingMetricSettingsObject -Enabled $true -Category AllMetrics ``` Create the `Diagnostic Settings` for each `Key Vault` ``` New-AzDiagnosticSetting -Name -ResourceId -Log $logSettings -Metric $metricSettings [-StorageAccountId | -EventHubName -EventHubAuthorizationRuleId | -WorkSpaceId | -MarketPlacePartnerId ] ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 1. For each Key vault, under `Monitoring`, go to `Diagnostic settings`. 1. Click `Edit setting` next to a diagnostic setting. 1. Ensure that a destination is configured. 1. Under `Category groups`, ensure that `audit` and `allLogs` are checked. **Audit from Azure CLI** List all key vaults ``` az keyvault list ``` For each keyvault `id` ``` az monitor diagnostic-settings list --resource ``` Ensure that `storageAccountId` reflects your desired destination and that `categoryGroup` and `enabled` are set as follows in the sample outputs below. ``` logs: [ { categoryGroup: audit, enabled: true, }, { categoryGroup: allLogs, enabled: true, } ``` **Audit from PowerShell** List the key vault(s) in the subscription ``` Get-AzKeyVault ``` For each key vault, run the following: ``` Get-AzDiagnosticSetting -ResourceId ``` Ensure that `StorageAccountId`, `ServiceBusRuleId`, `MarketplacePartnerId`, or `WorkspaceId` is set as appropriate. Also, ensure that `enabled` is set to `true`, and that `categoryGroup` reflects both `audit` and `allLogs` category groups. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [cf820ca0-f99e-4f3e-84fb-66e913812d21](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fcf820ca0-f99e-4f3e-84fb-66e913812d21) **- Name:** 'Resource logs in Key Vault should be enabled'", + "AdditionalInformation": "**DEPRECATION WARNING** Retention rules for Key Vault logging is being migrated to Azure Storage Lifecycle Management. Retention rules should be set based on the needs of your organization and security or compliance frameworks. Please visit [https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/migrate-to-azure-storage-lifecycle-policy?tabs=portal](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/migrate-to-azure-storage-lifecycle-policy?tabs=portal) for detail on migrating your retention rules. Microsoft has provided the following deprecation timeline: March 31, 2023 – The Diagnostic Settings Storage Retention feature will no longer be available to configure new retention rules for log data. This includes using the portal, CLI PowerShell, and ARM and Bicep templates. If you have configured retention settings, you'll still be able to see and change them in the portal. March 31, 2024 – You will no longer be able to use the API (CLI, Powershell, or templates), or Azure portal to configure retention setting unless you're changing them to 0. Existing retention rules will still be respected. September 30, 2025 – All retention functionality for the Diagnostic Settings Storage Retention feature will be disabled across all environments.", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/general/howto-logging:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, Diagnostic AuditEvent logging is not enabled for Key Vault instances." + } + ] + }, + { + "Id": "6.1.1.5", + "Description": "Ensure that Network Security Group Flow Logs are Captured and Sent to Log Analytics", + "Checks": [ + "network_flow_log_captured_sent" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Network Security Group Flow Logs are Captured and Sent to Log Analytics", + "RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.", + "ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.", + "RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.", + "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation", + "DefaultValue": "By default Network Security Group logs are not sent to Log Analytics." + } + ] + }, + { + "Id": "6.1.1.6", + "Description": "Ensure that Virtual Network Flow Logs are Captured and Sent to Log Analytics", + "Checks": [ + "network_flow_log_captured_sent" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Virtual Network Flow Logs are Captured and Sent to Log Analytics", + "RationaleStatement": "Virtual network flow logs provide critical visibility into traffic patterns. Sending logs to a Log Analytics workspace enables centralized analysis, correlation, and alerting for faster threat detection and response.", + "ImpactStatement": "* Virtual network flow logs are charged per gigabyte of network flow logs collected and come with a free tier of 5 GB/month per subscription. * If traffic analytics is enabled with virtual network flow logs, traffic analytics pricing applies at per gigabyte processing rates. * The storage of logs is charged separately.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, click `Flow logs`. 1. Click `+ Create`. 1. Select a subscription. 1. Next to `Flow log type`, select `Virtual network`. 1. Click `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select a storage account, or create a new storage account. 1. Set the retention in days for the storage account. 1. Click `Next`. 1. Under `Analytics`, for `Flow logs version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Click `Next`. 1. Optionally, add `Tags`. 1. Click `Review + create`. 1. Click `Create`. 1. Repeat steps 1-20 for each subscription or virtual network requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Ensure that at least one virtual network flow log is listed and is configured to send logs to a `Log Analytics Workspace`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [2f080164-9f4d-497e-9db6-416dc9f7b48a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2f080164-9f4d-497e-9db6-416dc9f7b48a) **- Name:** 'Network Watcher flow logs should have traffic analytics enabled' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Audit flow logs configuration for every virtual network'", + "AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs will no longer be possible after June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.", + "References": "https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-overview:https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-cli", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.1.7", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Graph Activity Logs to an Appropriate Destination", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Graph Activity Logs to an Appropriate Destination", + "RationaleStatement": "Microsoft Graph activity logs provide visibility into HTTP requests made to the Microsoft Graph service, helping detect unauthorized access, suspicious activity, and security threats. Configuring diagnostic settings in Microsoft Entra ensures these logs are collected and sent to an appropriate destination for monitoring, analysis, and retention.", + "ImpactStatement": "A Microsoft Entra ID P1 or P2 tenant license is required to access the Microsoft Graph activity logs. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size and the applications in your tenant that interact with the Microsoft Graph APIs. See the following pricing calculations for respective services: - Log Analytics: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model. - Azure Storage: https://azure.microsoft.com/en-gb/pricing/details/storage/blobs/. - Event Hubs: https://azure.microsoft.com/en-gb/pricing/details/event-hubs/", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Provide a `Diagnostic setting name`. 1. Under `Logs > Categories`, check the box next to `MicrosoftGraphActivityLogs`. 1. Configure an appropriate destination for the logs. 1. Click `Save`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Next to each diagnostic setting, click `Edit setting`, and review the selected log categories and destination details. 1. Ensure that at least one diagnostic setting is configured to send `MicrosoftGraphActivityLogs` to an appropriate destination.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-configure-diagnostic-settings:https://learn.microsoft.com/en-us/graph/microsoft-graph-activity-logs-overview:https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model:https://azure.microsoft.com/en-gb/pricing/details/storage/blobs/:https://azure.microsoft.com/en-gb/pricing/details/event-hubs/", + "DefaultValue": "By default, Microsoft Entra diagnostic settings do not exist." + } + ] + }, + { + "Id": "6.1.1.8", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Entra Activity Logs to an Appropriate Destination", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that a Microsoft Entra Diagnostic Setting Exists to Send Microsoft Entra Activity Logs to an Appropriate Destination", + "RationaleStatement": "Microsoft Entra activity logs enables you to assess many aspects of your Microsoft Entra tenant. Configuring diagnostic settings in Microsoft Entra ensures these logs are collected and sent to an appropriate destination for monitoring, analysis, and retention.", + "ImpactStatement": "To export sign-in data, your organization needs an Azure AD P1 or P2 license. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size. See the following pricing calculations for respective services: - Log Analytics: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model. - Azure Storage: https://azure.microsoft.com/en-gb/pricing/details/storage/blobs/. - Event Hubs: https://azure.microsoft.com/en-gb/pricing/details/event-hubs/", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Provide a `Diagnostic setting name`. 1. Under `Logs > Categories`, check the box next to each of the following logs: - `AuditLogs` - `SignInLogs` - `NonInteractiveUserSignInLogs` - `ServicePrincipalSignInLogs` - `ManagedIdentitySignInLogs` - `ProvisioningLogs` - `ADFSSignInLogs` - `RiskyUsers` - `UserRiskEvents` - `NetworkAccessTrafficLogs` - `RiskyServicePrincipals` - `ServicePrincipalRiskEvents` - `EnrichedOffice365AuditLogs` - `MicrosoftGraphActivityLogs` - `RemoteNetworkHealthLogs` - `NetworkAccessAlerts` 1. Configure an appropriate destination for the logs. 1. Click `Save`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Entra ID`. 1. Under `Monitoring`, click `Diagnostic settings`. 1. Next to each diagnostic setting, click `Edit setting`, and review the selected log categories and destination details. 1. Ensure that at least one diagnostic setting is configured to send the following logs to an appropriate destination: - `AuditLogs` - `SignInLogs` - `NonInteractiveUserSignInLogs` - `ServicePrincipalSignInLogs` - `ManagedIdentitySignInLogs` - `ProvisioningLogs` - `ADFSSignInLogs` - `RiskyUsers` - `UserRiskEvents` - `NetworkAccessTrafficLogs` - `RiskyServicePrincipals` - `ServicePrincipalRiskEvents` - `EnrichedOffice365AuditLogs` - `MicrosoftGraphActivityLogs` - `RemoteNetworkHealthLogs` - `NetworkAccessAlerts`", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-configure-diagnostic-settings:https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-access-activity-logs?tabs=microsoft-entra-activity-logs%2Carchive-activity-logs-to-a-storage-account", + "DefaultValue": "By default, Microsoft Entra diagnostic settings do not exist." + } + ] + }, + { + "Id": "6.1.1.9", + "Description": "Ensure that Intune Logs are Captured and Sent to Log Analytics", + "Checks": [], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Intune Logs are Captured and Sent to Log Analytics", + "RationaleStatement": "Intune includes built-in logs that provide information about your environments. Sending logs to a Log Analytics workspace enables centralized analysis, correlation, and alerting for faster threat detection and response.", + "ImpactStatement": "A Microsoft Intune plan is required to access Intune: https://www.microsoft.com/en-gb/security/business/microsoft-intune-pricing. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size. For information on Log Analytics workspace costs, visit: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Intune`. 1. Click `Reports`. 1. Under `Azure monitor`, click `Diagnostic settings`. 1. Click `+ Add diagnostic setting`. 1. Provide a `Diagnostic setting name`. 1. Under `Logs > Categories`, check the box next to each of the following logs: - `AuditLogs` - `OperationalLogs` - `DeviceComplianceOrg` - `Devices` - `Windows365AuditLogs` 1. Under `Destination details`, check the box next to `Send to Log Analytics workspace`. 1. Select a `Subscription`. 1. Select a `Log Analytics workspace`. 1. Click `Save`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Intune`. 1. Click `Reports`. 1. Under `Azure monitor`, click `Diagnostic settings`. 1. Next to each diagnostic setting, click `Edit setting`, and review the selected log categories and destination details. 1. Ensure that at least one diagnostic setting is configured to send the following logs to a Log Analytics workspace: - `AuditLogs` - `OperationalLogs` - `DeviceComplianceOrg` - `Devices` - `Windows365AuditLogs`", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/mem/intune/fundamentals/review-logs-using-azure-monitor:https://www.microsoft.com/en-gb/security/business/microsoft-intune-pricing:https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs", + "DefaultValue": "By default, Intune diagnostic settings do not exist." + } + ] + }, + { + "Id": "6.1.2.1", + "Description": "Ensure that Activity Log Alert Exists for Create Policy Assignment", + "Checks": [ + "monitor_alert_create_policy_assignment" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create Policy Assignment", + "RationaleStatement": "Monitoring for create policy assignment events gives insight into changes done in Azure policy - assignments and can reduce the time it takes to detect unsolicited changes.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create policy assignment (Policy assignment)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Authorization/policyAssignments/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Authorization/policyAssignments/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Get the `Action Group` information and store it in a variable, then create a new `Action` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` variable. ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Authorization/policyAssignments/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Authorization/policyAssignments/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create policy assignment'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Authorization/policyAssignments/write` in the output. If it's missing, generate a finding. **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Authorization/policyAssignments/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` If the output is empty, an `alert rule` for `Create Policy Assignments` is not configured. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c5447c04-a4d7-4ba8-a263-c9ee321a6858](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc5447c04-a4d7-4ba8-a263-c9ee321a6858) **- Name:** 'An activity log alert should exist for specific Policy operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://docs.microsoft.com/en-in/rest/api/policy/policy-assignments:https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-log", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.2", + "Description": "Ensure that Activity Log Alert exists for Delete Policy Assignment", + "Checks": [ + "monitor_alert_delete_policy_assignment" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert exists for Delete Policy Assignment", + "RationaleStatement": "Monitoring for delete policy assignment events gives insight into changes done in azure policy - assignments and can reduce the time it takes to detect unsolicited changes.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete policy assignment (Policy assignment)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Authorization/policyAssignments/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the conditions object ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Authorization/policyAssignments/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Action` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` variable. ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Authorization/policyAssignments/delete`. ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Authorization/policyAssignments/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete policy assignment'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Authorization/policyAssignments/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Authorization/policyAssignments/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c5447c04-a4d7-4ba8-a263-c9ee321a6858](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc5447c04-a4d7-4ba8-a263-c9ee321a6858) **- Name:** 'An activity log alert should exist for specific Policy operations'", + "AdditionalInformation": "This log alert also applies for Azure Blueprints.", + "References": "https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://azure.microsoft.com/en-us/services/blueprints/", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.3", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Network Security Group", + "Checks": [ + "monitor_alert_create_update_nsg" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Network Security Group", + "RationaleStatement": "Monitoring for Create or Update Network Security Group events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create or Update Network Security Group (Network Security Group)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/write and level=verbose --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/networkSecurityGroups/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/networkSecurityGroups/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/networkSecurityGroups/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create or Update Network Security Group'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/networkSecurityGroups/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/networkSecurityGroups/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.4", + "Description": "Ensure that Activity Log Alert Exists for Delete Network Security Group", + "Checks": [ + "monitor_alert_delete_nsg" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete Network Security Group", + "RationaleStatement": "Monitoring for Delete Network Security Group events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete Network Security Group (Network Security Group)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/networkSecurityGroups/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/networkSecurityGroups/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/networkSecurityGroups/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/networkSecurityGroups/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete Network Security Group'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/networkSecurityGroups/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/networkSecurityGroups/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.5", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Security Solution", + "Checks": [ + "monitor_alert_create_update_security_solution" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Security Solution", + "RationaleStatement": "Monitoring for Create or Update Security Solution events gives insight into changes to the active security solutions and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create or Update Security Solutions (Security Solutions)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Security/securitySolutions/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Security/securitySolutions/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Security/securitySolutions/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Security/securitySolutions/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create or Update Security Solutions'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Security/securitySolutions/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Security/securitySolutions/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.6", + "Description": "Ensure that Activity Log Alert Exists for Delete Security Solution", + "Checks": [ + "monitor_alert_delete_security_solution" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete Security Solution", + "RationaleStatement": "Monitoring for Delete Security Solution events gives insight into changes to the active security solutions and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Alert rules incur minimal costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete Security Solutions (Security Solutions)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Security/securitySolutions/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Security/securitySolutions/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Security/securitySolutions/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Security/securitySolutions/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete Security Solutions'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Security/securitySolutions/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Security/securitySolutions/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.7", + "Description": "Ensure that Activity Log Alert Exists for Create or Update SQL Server Firewall Rule", + "Checks": [ + "monitor_alert_create_update_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update SQL Server Firewall Rule", + "RationaleStatement": "Monitoring for Create or Update SQL Server Firewall Rule events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create/Update server firewall rule (Server Firewall Rule)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Sql/servers/firewallRules/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Sql/servers/firewallRules/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Sql/servers/firewallRules/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create/Update server firewall rule'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Sql/servers/firewallRules/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Sql/servers/firewallRules/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.8", + "Description": "Ensure that Activity Log Alert Exists for Delete SQL Server Firewall Rule", + "Checks": [ + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete SQL Server Firewall Rule", + "RationaleStatement": "Monitoring for Delete SQL Server Firewall Rule events gives insight into SQL network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete server firewall rule (Server Firewall Rule)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Sql/servers/firewallRules/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Sql/servers/firewallRules/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Sql/servers/firewallRules/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Sql/servers/firewallRules/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete server firewall rule'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Sql/servers/firewallRules/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Sql/servers/firewallRules/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b954148f-4c11-4c38-8221-be76711e194a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb954148f-4c11-4c38-8221-be76711e194a) **- Name:** 'An activity log alert should exist for specific Administrative operations'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.9", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Public IP Address rule", + "Checks": [ + "monitor_alert_create_update_public_ip_address_rule" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Create or Update Public IP Address rule", + "RationaleStatement": "Monitoring for Create or Update Public IP Address events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Create or Update Public Ip Address (Public Ip Address)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/publicIPAddresses/write and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/publicIPAddresses/write -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/publicIPAddresses/write` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/publicIPAddresses/write`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Create or Update Public Ip Address'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/publicIPAddresses/write` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/publicIPAddresses/write}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1513498c-3091-461a-b321-e9b433218d28](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1513498c-3091-461a-b321-e9b433218d28) **- Name:** 'Enable logging by category group for Public IP addresses (microsoft.network/publicipaddresses) to Log Analytics'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.10", + "Description": "Ensure that Activity Log Alert Exists for Delete Public IP Address rule", + "Checks": [ + "monitor_alert_delete_public_ip_address_rule" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Activity Log Alert Exists for Delete Public IP Address rule", + "RationaleStatement": "Monitoring for Delete Public IP Address events gives insight into network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "There will be a substantial increase in log size if there are a large number of administrative actions on a server.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Select `Alerts`. 1. Select `Create`. 1. Select `Alert rule`. 1. Choose a subscription. 1. Select `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Delete Public Ip Address (Public Ip Address)`. 1. Click `Apply`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. **Remediate from Azure CLI** ``` az monitor activity-log alert create --resource-group --condition category=Administrative and operationName=Microsoft.Network/publicIPAddresses/delete and level= --scope /subscriptions/ --name --subscription --action-group ``` **Remediate from PowerShell** Create the `Conditions` object. ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Administrative -Field category $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Microsoft.Network/publicIPAddresses/delete -Field operationName $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Equal Verbose -Field level ``` Retrieve the `Action Group` information and store in a variable, then create the `Actions` object. ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object ``` $scope = /subscriptions/ ``` Create the `Activity Log Alert Rule` for `Microsoft.Network/publicIPAddresses/delete` ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the `Monitor` blade. 1. Click on `Alerts`. 1. In the Alerts window, click on `Alert rules`. 1. Ensure an alert rule exists where the Condition column contains `Operation name=Microsoft.Network/publicIPAddresses/delete`. 1. Click on the Alert `Name` associated with the previous step. 1. Ensure the `Condition` panel displays the text `Whenever the Activity Log has an event with Category='Administrative', Operation name='Delete Public Ip Address'` and does not filter on `Level`, `Status` or `Caller`. 1. Ensure the `Actions` panel displays an Action group is assigned to notify the appropriate personnel in your organization. **Audit from Azure CLI** ``` az monitor activity-log alert list --subscription --query [].{Name:name,Enabled:enabled,Condition:condition.allOf,Actions:actions} ``` Look for `Microsoft.Network/publicIPAddresses/delete` in the output **Audit from PowerShell** ``` Get-AzActivityLogAlert -SubscriptionId |where-object {$_.ConditionAllOf.Equal -match Microsoft.Network/publicIPAddresses/delete}|select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1513498c-3091-461a-b321-e9b433218d28](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1513498c-3091-461a-b321-e9b433218d28) **- Name:** 'Enable logging by category group for Public IP addresses (microsoft.network/publicipaddresses) to Log Analytics'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/updates/classic-alerting-monitoring-retirement:https://docs.microsoft.com/en-in/azure/azure-monitor/platform/alerts-activity-log:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/createorupdate:https://docs.microsoft.com/en-in/rest/api/monitor/activitylogalerts/listbysubscriptionid:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.2.11", + "Description": "Ensure that an Activity Log Alert Exists for Service Health", + "Checks": [ + "monitor_alert_service_health_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that an Activity Log Alert Exists for Service Health", + "RationaleStatement": "Monitoring for Service Health events provides insight into service issues, planned maintenance, security advisories, and other changes that may affect the Azure services and regions in use.", + "ImpactStatement": "There is no charge for creating activity log alert rules.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Monitor`. 1. Click `Alerts`. 1. Click `+ Create`. 1. Select `Alert rule` from the drop-down menu. 1. Choose a subscription. 1. Click `Apply`. 1. Select the `Condition` tab. 1. Click `See all signals`. 1. Select `Service health`. 1. Click `Apply`. 1. Open the drop-down menu next to `Event types`. 1. Check the box next to `Select all`. 1. Select the `Actions` tab. 1. Click `Select action groups` to select an existing action group, or `Create action group` to create a new action group. 1. Follow the prompts to choose or create an action group. 1. Select the `Details` tab. 1. Select a `Resource group`, provide an `Alert rule name` and an optional `Alert rule description`. 1. Click `Review + create`. 1. Click `Create`. 1. Repeat steps 1-19 for each subscription requiring remediation. **Remediate from Azure CLI** For each subscription requiring remediation, run the following command to create a `ServiceHealth` alert rule for a subscription: ``` az monitor activity-log alert create --subscription --resource-group --name --condition category=ServiceHealth and properties.incidentType=Incident --scope /subscriptions/ --action-group ``` **Remediate from PowerShell** Create the `Conditions` object: ``` $conditions = @() $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Field category -Equal ServiceHealth $conditions += New-AzActivityLogAlertAlertRuleAnyOfOrLeafConditionObject -Field properties.incidentType -Equal Incident ``` Retrieve the `Action Group` information and store in a variable: ``` $actionGroup = Get-AzActionGroup -ResourceGroupName -Name ``` Create the `Actions` object: ``` $actionObject = New-AzActivityLogAlertActionGroupObject -Id $actionGroup.Id ``` Create the `Scope` object: ``` $scope = /subscriptions/ ``` Create the activity log alert rule: ``` New-AzActivityLogAlert -Name -ResourceGroupName -Condition $conditions -Scope $scope -Location global -Action $actionObject -Subscription -Enabled $true ``` Repeat for each subscription requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Monitor`. 1. Click `Alerts`. 1. Click `Alert rules`. 1. Ensure an alert rule exists for a subscription with `Condition` set to `Service names=All, Event types=All` and `Target resource type` set to `Subscription`. 1. If an alert rule is found for step 4, click the name of the alert rule. 1. Ensure the `Actions` panel displays an action group configured to notify appropriate personnel. 1. Repeat steps 1-6 for each subscription. **Audit from Azure CLI** Run the following command to list activity log alerts: ``` az monitor activity-log alert list --subscription ``` For each activity log alert, run the following command: ``` az monitor activity-log alert show --subscription --resource-group --activity-log-alert-name ``` Ensure an alert exists for `ServiceHealth` with `scopes` set to a subscription ID. Repeat for each subscription. **Audit from PowerShell** Run the following command to locate `ServiceHealth` alert rules for a subscription: ``` Get-AzActivityLogAlert -SubscriptionId | where-object {$_.ConditionAllOf.Equal -match ServiceHealth} | select-object Location,Name,Enabled,ResourceGroupName,ConditionAllOf ``` Ensure that at least one `ServiceHealth` alert rule is returned. Repeat for each subscription.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/service-health/overview:https://learn.microsoft.com/en-us/azure/service-health/alerts-activity-log-service-notifications-portal:https://azure.microsoft.com/en-gb/pricing/details/monitor/#faq:https://learn.microsoft.com/en-us/cli/azure/monitor/activity-log/alert:https://learn.microsoft.com/en-us/powershell/module/az.monitor/get-azactivitylogalert:https://learn.microsoft.com/en-us/powershell/module/az.monitor/new-azactivitylogalert", + "DefaultValue": "By default, no monitoring alerts are created." + } + ] + }, + { + "Id": "6.1.3.1", + "Description": "Ensure Application Insights are Configured", + "Checks": [ + "appinsights_ensure_is_configured" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Application Insights are Configured", + "RationaleStatement": "Configuring Application Insights provides additional data not found elsewhere within Azure as part of a much larger logging and monitoring program within an organization's Information Security practice. The types and contents of these logs will act as both a potential cost saving measure (application performance) and a means to potentially confirm the source of a potential incident (trace logging). Metrics and Telemetry data provide organizations with a proactive approach to cost savings by monitoring an application's performance, while the trace logging data provides necessary details in a reactive incident response scenario by helping organizations identify the potential source of an incident within their application.", + "ImpactStatement": "Because Application Insights relies on a Log Analytics Workspace, an organization will incur additional expenses when using this service.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to `Application Insights`. 2. Under the `Basics` tab within the `PROJECT DETAILS` section, select the `Subscription`. 3. Select the `Resource group`. 4. Within the `INSTANCE DETAILS`, enter a `Name`. 5. Select a `Region`. 6. Next to `Resource Mode`, select `Workspace-based`. 7. Within the `WORKSPACE DETAILS`, select the `Subscription` for the log analytics workspace. 8. Select the appropriate `Log Analytics Workspace`. 9. Click `Next:Tags >`. 10. Enter the appropriate `Tags` as `Name`, `Value` pairs. 11. Click `Next:Review+Create`. 12. Click `Create`. **Remediate from Azure CLI** ``` az monitor app-insights component create --app --resource-group --location --kind web --retention-time --workspace --subscription ``` **Remediate from PowerShell** ``` New-AzApplicationInsights -Kind web -ResourceGroupName -Name -location -RetentionInDays -SubscriptionID -WorkspaceResourceId ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Application Insights`. 2. Ensure an `Application Insights` service is configured and exists. **Audit from Azure CLI** ``` az monitor app-insights component show --query [].{ID:appId, Name:name, Tenant:tenantId, Location:location, Provisioning_State:provisioningState} ``` Ensure the above command produces output, otherwise `Application Insights` has not been configured. **Audit from PowerShell** ``` Get-AzApplicationInsights|select location,name,appid,provisioningState,tenantid ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview", + "DefaultValue": "Application Insights are not enabled by default." + } + ] + }, + { + "Id": "6.1.4", + "Description": "Ensure that Azure Monitor Resource Logging is Enabled for All Services that Support it", + "Checks": [ + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Azure Monitor Resource Logging is Enabled for All Services that Support it", + "RationaleStatement": "A lack of monitoring reduces the visibility into the data plane, and therefore an organization's ability to detect reconnaissance, authorization attempts or other malicious activity. Unlike Activity Logs, Resource Logs are not enabled by default. Specifically, without monitoring it would be impossible to tell which entities had accessed a data store that was breached. In addition, alerts for failed attempts to access APIs for Web Services or Databases are only possible when logging is enabled.", + "ImpactStatement": "Costs for monitoring varies with Log Volume. Not every resource needs to have logging enabled. It is important to determine the security classification of the data being processed by the given resource and adjust the logging based on which events need to be tracked. This is typically determined by governance and compliance requirements.", + "RemediationProcedure": "Azure Subscriptions should log every access and operation for all resources. Logs should be sent to Storage and a Log Analytics Workspace or equivalent third-party system. Logs should be kept in readily-accessible storage for a minimum of one year, and then moved to inexpensive cold storage for a duration of time as necessary. If retention policies are set but storing logs in a Storage Account is disabled (for example, if only Event Hubs or Log Analytics options are selected), the retention policies have no effect. Enable all monitoring at first, and then be more aggressive moving data to cold storage if the volume of data becomes a cost concern. **Remediate from Azure Portal** The specific steps for configuring resources within the Azure console vary depending on resource, but typically the steps are: 1. Go to the resource 2. Click on Diagnostic settings 3. In the blade that appears, click Add diagnostic setting 4. Configure the diagnostic settings 5. Click on Save **Remediate from Azure CLI** For each `resource`, run the following making sure to use a `resource` appropriate JSON encoded `category` for the `--logs` option. ``` az monitor diagnostic-settings create --name --resource --logs [{category:,enabled:true,rentention-policy:{enabled:true,days:180}}] --metrics [{category:AllMetrics,enabled:true,retention-policy:{enabled:true,days:180}}] <[--event-hub --event-hub-rule | --storage-account |--workspace | --marketplace-partner-id ]> ``` **Remediate from PowerShell** Create the `log` settings object ``` $logSettings = @() $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -RetentionPolicyDay 180 -RetentionPolicyEnabled $true -Category $logSettings += New-AzDiagnosticSettingLogSettingsObject -Enabled $true -RetentionPolicyDay 180 -RetentionPolicyEnabled $true -Category ``` Create the `metric` settings object ``` $metricSettings = @() $metricSettings += New-AzDiagnosticSettingMetricSettingsObject -Enabled $true -RetentionPolicyDay 180 -RetentionPolicyEnabled $true -Category AllMetrics ``` Create the diagnostic setting for a specific resource ``` New-AzDiagnosticSetting -Name -ResourceId -Log $logSettings -Metric $metricSettings ```", + "AuditProcedure": "**Audit from Azure Portal** The specific steps for configuring resources within the Azure console vary depending on resource, but typically the steps are: 1. Go to the resource 2. Click on Diagnostic settings 3. In the blade that appears, click Add diagnostic setting 4. Configure the diagnostic settings 5. Click on Save **Audit from Azure CLI** List all `resources` for a `subscription` ``` az resource list --subscription ``` For each `resource` run the following ``` az monitor diagnostic-settings list --resource ``` An empty result means a `diagnostic settings` is not configured for that resource. An error message means a `diagnostic settings` is not supported for that resource. **Audit from PowerShell** Get a list of `resources` in a `subscription` context and store in a variable ``` $resources = Get-AzResource ``` Loop through each `resource` to determine if a diagnostic setting is configured or not. ``` foreach ($resource in $resources) {$diagnosticSetting = Get-AzDiagnosticSetting -ResourceId $resource.id -ErrorAction SilentlyContinue; if ([string]::IsNullOrEmpty($diagnosticSetting)) {$message = Diagnostic Settings not configured for resource: + $resource.Name;Write-Output $message}else{$diagnosticSetting}} ``` A result of `Diagnostic Settings not configured for resource: ` means a `diagnostic settings` is not configured for that resource. Otherwise, the output of the above command will show configured `Diagnostic Settings` for a resource. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [cf820ca0-f99e-4f3e-84fb-66e913812d21](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fcf820ca0-f99e-4f3e-84fb-66e913812d21) **- Name:** 'Resource logs in Key Vault should be enabled' - **Policy ID:** [91a78b24-f231-4a8a-8da9-02c35b2b6510](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F91a78b24-f231-4a8a-8da9-02c35b2b6510) **- Name:** 'App Service apps should have resource logs enabled' - **Policy ID:** [428256e6-1fac-4f48-a757-df34c2b3336d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F428256e6-1fac-4f48-a757-df34c2b3336d) **- Name:** 'Resource logs in Batch accounts should be enabled' - **Policy ID:** [057ef27e-665e-4328-8ea3-04b3122bd9fb](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F057ef27e-665e-4328-8ea3-04b3122bd9fb) **- Name:** 'Resource logs in Azure Data Lake Store should be enabled' - **Policy ID:** [c95c74d9-38fe-4f0d-af86-0c7d626a315c](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc95c74d9-38fe-4f0d-af86-0c7d626a315c) **- Name:** 'Resource logs in Data Lake Analytics should be enabled' - **Policy ID:** [83a214f7-d01a-484b-91a9-ed54470c9a6a](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F83a214f7-d01a-484b-91a9-ed54470c9a6a) **- Name:** 'Resource logs in Event Hub should be enabled' - **Policy ID:** [383856f8-de7f-44a2-81fc-e5135b5c2aa4](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F383856f8-de7f-44a2-81fc-e5135b5c2aa4) **- Name:** 'Resource logs in IoT Hub should be enabled' - **Policy ID:** [34f95f76-5386-4de7-b824-0d8478470c9d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F34f95f76-5386-4de7-b824-0d8478470c9d) **- Name:** 'Resource logs in Logic Apps should be enabled' - **Policy ID:** [b4330a05-a843-4bc8-bf9a-cacce50c67f4](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb4330a05-a843-4bc8-bf9a-cacce50c67f4) **- Name:** 'Resource logs in Search services should be enabled' - **Policy ID:** [f8d36e2f-389b-4ee4-898d-21aeb69a0f45](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ff8d36e2f-389b-4ee4-898d-21aeb69a0f45) **- Name:** 'Resource logs in Service Bus should be enabled' - **Policy ID:** [f9be5368-9bf5-4b84-9e0a-7850da98bb46](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ff9be5368-9bf5-4b84-9e0a-7850da98bb46) **- Name:** 'Resource logs in Azure Stream Analytics should be enabled'", + "AdditionalInformation": "For an up-to-date list of Azure resources which support Azure Monitor, refer to the Supported Log Categories reference.", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-5-centralize-security-log-management-and-analysis:https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/monitor-azure-resource:Supported Log Categories: https://docs.microsoft.com/en-us/azure/azure-monitor/essentials/resource-logs-categories:Logs and Audit - Fundamentals: https://docs.microsoft.com/en-us/azure/security/fundamentals/log-audit:Collecting Logs: https://docs.microsoft.com/en-us/azure/azure-monitor/platform/collect-activity-logs:Key Vault Logging: https://docs.microsoft.com/en-us/azure/key-vault/key-vault-logging:Monitor Diagnostic Settings: https://docs.microsoft.com/en-us/cli/azure/monitor/diagnostic-settings?view=azure-cli-latest:Overview of Diagnostic Logs: https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-logs-overview:Supported Services for Diagnostic Logs: https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-logs-schema:Diagnostic Logs for CDNs: https://docs.microsoft.com/en-us/azure/cdn/cdn-azure-diagnostic-logs", + "DefaultValue": "By default, Azure Monitor Resource Logs are 'Disabled' for all resources." + } + ] + }, + { + "Id": "6.1.5", + "Description": "Ensure Basic, Free, and Consumption SKUs are not used on Production artifacts requiring monitoring and SLA", + "Checks": [], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "SubSection": "6.1 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Basic, Free, and Consumption SKUs are not used on Production artifacts requiring monitoring and SLA", + "RationaleStatement": "Typically, production workloads need to be monitored and should have an SLA with Microsoft, using Basic SKUs for any deployed product will mean that that these capabilities do not exist. The following resource types should use standard SKUs as a minimum. - Public IP Addresses - Network Load Balancers - REDIS Cache - SQL PaaS Databases - VPN Gateways", + "ImpactStatement": "The impact of enforcing Standard SKU's is twofold 1) There will be a cost increase 2) The monitoring and service level agreements will be available and will support the production service. All resources should be either tagged or in separate Management Groups/Subscriptions", + "RemediationProcedure": "Each resource has its own process for upgrading from basic to standard SKUs that should be followed if required. - Public IP Address: https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-upgrade. - Basic Load Balancer: https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-basic-upgrade-guidance. - Azure Cache for Redis: https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-how-to-scale. - Azure SQL Database: https://learn.microsoft.com/en-us/azure/azure-sql/database/scale-resources. - VPN Gateway: https://learn.microsoft.com/en-us/azure/vpn-gateway/gateway-sku-resize.", + "AuditProcedure": "This needs to be audited by Azure Policy (one for each resource type) and denied for each artifact that is production. **Audit from Azure Portal** 1. Open `Azure Resource Graph Explorer` 1. Click `New query` 1. Paste the following into the query window: ``` Resources | where sku contains 'Basic' or sku contains 'consumption' | order by type ``` 4. Click `Run query` then evaluate the results in the results window. 5. Ensure that no production artifacts are returned. **Audit from Azure CLI** ``` az graph query -q Resources | where sku contains 'Basic' or sku contains 'consumption' | order by type ``` Alternatively, to filter on a specific resource group: ``` az graph query -q Resources | where resourceGroup == '' | where sku contains 'Basic' or sku contains 'consumption' | order by type ``` Ensure that no production artifacts are returned. **Audit from PowerShell** ``` Get-AzResource | ?{ $_.Sku -EQ Basic} ``` Ensure that no production artifacts are returned.", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/support/plans:https://azure.microsoft.com/en-us/support/plans/response/:https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-upgrade:https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-basic-upgrade-guidance:https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-how-to-scale:https://learn.microsoft.com/en-us/azure/azure-sql/database/scale-resources:https://learn.microsoft.com/en-us/azure/vpn-gateway/gateway-sku-resize", + "DefaultValue": "Policy should enforce standard SKUs for the following artifacts: - Public IP Addresses - Network Load Balancers - REDIS Cache - SQL PaaS Databases - VPN Gateways" + } + ] + }, + { + "Id": "6.2", + "Description": "Ensure that Resource Locks are set for Mission-Critical Azure Resources", + "Checks": [ + "iam_custom_role_has_permissions_to_administer_resource_locks" + ], + "Attributes": [ + { + "Section": "6 Management and Governance Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Resource Locks are set for Mission-Critical Azure Resources", + "RationaleStatement": "As an administrator, it may be necessary to lock a subscription, resource group, or resource to prevent other users in the organization from accidentally deleting or modifying critical resources. The lock level can be set to to `CanNotDelete` or `ReadOnly` to achieve this purpose. - `CanNotDelete` means authorized users can still read and modify a resource, but they cannot delete the resource. - `ReadOnly` means authorized users can read a resource, but they cannot delete or update the resource. Applying this lock is similar to restricting all authorized users to the permissions granted by the Reader role.", + "ImpactStatement": "There can be unintended outcomes of locking a resource. Applying a lock to a parent service will cause it to be inherited by all resources within. Conversely, applying a lock to a resource may not apply to connected storage, leaving it unlocked. Please see the documentation for further information.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the specific Azure Resource or Resource Group. 2. For each mission critical resource, click on `Locks`. 3. Click `Add`. 4. Give the lock a name and a description, then select the type, `Read-only` or `Delete` as appropriate. 5. Click OK. **Remediate from Azure CLI** To lock a resource, provide the name of the resource, its resource type, and its resource group name. ``` az lock create --name --lock-type --resource-group --resource-name --resource-type ``` **Remediate from PowerShell** ``` Get-AzResourceLock -ResourceName -ResourceType -ResourceGroupName -Locktype ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the specific Azure Resource or Resource Group. 2. Click on `Locks`. 3. Ensure the lock is defined with name and description, with type `Read-only` or `Delete` as appropriate. **Audit from Azure CLI** Review the list of all locks set currently: ``` az lock list --resource-group --resource-name --namespace --resource-type --parent ``` **Audit from PowerShell** Run the following command to list all resources. ``` Get-AzResource ``` For each resource, run the following command to check for Resource Locks. ``` Get-AzResourceLock -ResourceName -ResourceType -ResourceGroupName ``` Review the output of the `Properties` setting. Compliant settings will have the `CanNotDelete` or `ReadOnly` value.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-lock-resources:https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-manager-subscription-governance#azure-resource-locks:https://docs.microsoft.com/en-us/azure/governance/blueprints/concepts/resource-locking:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-asset-management#am-4-limit-access-to-asset-management", + "DefaultValue": "By default, no locks are set." + } + ] + }, + { + "Id": "7.1", + "Description": "Ensure that RDP Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_rdp_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that RDP Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with using RDP over the Internet is that attackers can use various brute force techniques to gain access to Azure Virtual Machines. Once the attackers gain access, they can use a virtual machine as a launch point for compromising other machines on an Azure Virtual Network or even attack networked devices outside of Azure.", + "ImpactStatement": "Restricting RDP access may require alternative methods for remote administration such as VPN or Azure Bastion.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network security groups`. 1. Under `Settings`, click `Inbound security rules`. 1. Check the box next to any inbound security rule matching: - Port: `3389` or range including 3389 - Protocol: `TCP` or `Any` - Source: `0.0.0.0/0`, `Internet`, or `Any` - Action: `Allow` 1. Click `Delete`. 1. Click `Yes`. **Remediate from Azure CLI** For each network security group rule requiring remediation, run the following command to delete a rule: ``` az network nsg rule delete --resource-group --nsg-name --name ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network security groups`. 1. Under `Settings`, click `Inbound security rules`. 1. Ensure that no inbound security rule exists that matches the following: - Port: `3389` or range including 3389 - Protocol: `TCP` or `Any` - Source: `0.0.0.0/0`, `Internet`, or `Any` - Action: `Allow` 1. Repeat steps 1-3 for each network security group. To audit from Azure Resource Graph: 1. Go to `Resource Graph Explorer`. 1. Click `New query`. 1. Paste the following into the query window: ``` resources | where type =~ microsoft.network/networksecuritygroups | project id, name, securityRule = properties.securityRules | mv-expand securityRule | extend access = securityRule.properties.access, direction = securityRule.properties.direction, protocol = securityRule.properties.protocol, destinationPort = case(isempty(securityRule.properties.destinationPortRange), securityRule.properties.destinationPortRanges, securityRule.properties.destinationPortRange), sourceAddress = case(isempty(securityRule.properties.sourceAddressPrefix), securityRule.properties.sourceAddressPrefixes, securityRule.properties.sourceAddressPrefix) | where access =~ Allow and direction =~ Inbound and protocol in~ (tcp, ) | mv-expand destinationPort | mv-expand sourceAddress | extend destinationPortMin = toint(split(destinationPort, -)[0]), destinationPortMax = toint(split(destinationPort, -)[-1]) | where (destinationPortMin <= 3389 and destinationPortMax >= 3389) or destinationPort == | where sourceAddress in~ (*, 0.0.0.0, internet, any) or sourceAddress endswith /0 ``` 1. Click `Run query`. 1. Ensure that no results are returned. **Audit from Azure CLI** List network security groups with non-default security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that no network security group has an inbound security rule that matches the following: ``` access : Allow destinationPortRange : 3389, *, or direction : Inbound protocol : TCP or * sourceAddressPrefix : 0.0.0.0/0, Internet, or * ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [22730e10-96f6-4aac-ad84-9383d35b5917](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F22730e10-96f6-4aac-ad84-9383d35b5917) **- Name:** 'Management ports should be closed on your virtual machines'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security/azure-security-network-security-best-practices#disable-rdpssh-access-to-azure-virtual-machines:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries:Express Route: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal", + "DefaultValue": "By default, RDP access from internet is not `enabled`." + } + ] + }, + { + "Id": "7.2", + "Description": "Ensure that SSH Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_ssh_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that SSH Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with using SSH over the Internet is that attackers can use various brute force techniques to gain access to Azure Virtual Machines. Once the attackers gain access, they can use a virtual machine as a launch point for compromising other machines on the Azure Virtual Network or even attack networked devices outside of Azure.", + "ImpactStatement": "Restricting SSH access may require alternative methods for remote administration such as VPN or Azure Bastion.", + "RemediationProcedure": "Where SSH is not explicitly required and narrowly configured for resources attached to the Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: [ExpressRoute](https://docs.microsoft.com/en-us/azure/expressroute/) [Site-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal) [Point-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal)", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `Networking` blade for the specific Virtual machine in Azure portal 2. Verify that the `INBOUND PORT RULES` **does not** have a rule for SSH such as - port = `22`, - protocol = `TCP` OR `ANY`, - Source = `Any` OR `Internet` **Audit from Azure CLI** List Network security groups with corresponding non-default Security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that none of the NSGs have security rule as below ``` access : Allow destinationPortRange : 22 or * or [port range containing 22] direction : Inbound protocol : TCP or * sourceAddressPrefix : * or 0.0.0.0 or /0 or /0 or internet or any ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [22730e10-96f6-4aac-ad84-9383d35b5917](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F22730e10-96f6-4aac-ad84-9383d35b5917) **- Name:** 'Management ports should be closed on your virtual machines'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security/azure-security-network-security-best-practices#disable-rdpssh-access-to-azure-virtual-machines:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries:Express Route: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal", + "DefaultValue": "By default, SSH access from internet is not `enabled`." + } + ] + }, + { + "Id": "7.3", + "Description": "Ensure that UDP Port Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_udp_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that UDP Port Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with broadly exposing UDP services over the Internet is that attackers can use DDoS amplification techniques to reflect spoofed UDP traffic from Azure Virtual Machines. The most common types of these attacks use exposed DNS, NTP, SSDP, SNMP, CLDAP and other UDP-based services as amplification sources for disrupting services of other machines on the Azure Virtual Network or even attack networked devices outside of Azure.", + "ImpactStatement": "Restricting UDP access may impact services that legitimately require UDP traffic.", + "RemediationProcedure": "Where UDP is not explicitly required and narrowly configured for resources attached to the Network Security Group, Internet-level access to your Azure resources should be restricted or eliminated. For internal access to relevant resources, configure an encrypted network tunnel such as: [ExpressRoute](https://docs.microsoft.com/en-us/azure/expressroute/) [Site-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal) [Point-to-site VPN](https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal)", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `Networking` blade for the specific Virtual machine in Azure portal 2. Verify that the `INBOUND PORT RULES` **does not** have a rule for UDP such as - protocol = `UDP`, - Source = `Any` OR `Internet` **Audit from Azure CLI** List Network security groups with corresponding non-default Security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that none of the NSGs have security rule as below ``` access : Allow destinationPortRange : * or [port range containing 53, 123, 161, 389, 1900, or other vulnerable UDP-based services] direction : Inbound protocol : UDP sourceAddressPrefix : * or 0.0.0.0 or /0 or /0 or internet or any ```", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security/fundamentals/network-best-practices#secure-your-critical-azure-service-resources-to-only-your-virtual-networks:https://docs.microsoft.com/en-us/azure/security/fundamentals/ddos-best-practices:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries:ExpressRoute: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal", + "DefaultValue": "By default, UDP access from internet is not `enabled`." + } + ] + }, + { + "Id": "7.4", + "Description": "Ensure that HTTP(S) Access from the Internet is Evaluated and Restricted", + "Checks": [ + "network_http_internet_access_restricted" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that HTTP(S) Access from the Internet is Evaluated and Restricted", + "RationaleStatement": "The potential security problem with using HTTP(S) over the Internet is that attackers can use various brute force techniques to gain access to Azure resources. Once the attackers gain access, they can use the resource as a launch point for compromising other resources within the Azure tenant.", + "ImpactStatement": "Restricting HTTP(S) access may require proper configuration of web application firewalls and load balancers.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual machines`. 2. For each VM, open the `Networking` blade. 3. Click on `Inbound port rules`. 4. Delete the rule with: * Port = 80/443 OR \\[port range containing 80/443\\] * Protocol = TCP OR Any * Source = Any (\\*) OR IP Addresses(0.0.0.0/0) OR Service Tag(Internet) * Action = Allow **Remediate from Azure CLI** 1. Run below command to list network security groups: ``` az network nsg list --subscription --output table ``` 2. For each network security group, run below command to list the rules associated with the specified port: ``` az network nsg rule list --resource-group --nsg-name --query [?destinationPortRange=='80 or 443'] ``` 3. Run the below command to delete the rule with: * Port = 80/443 OR \\[port range containing 80/443\\] * Protocol = TCP OR * * Source = Any (\\*) OR IP Addresses(0.0.0.0/0) OR Service Tag(Internet) * Action = Allow ``` az network nsg rule delete --resource-group --nsg-name --name ```", + "AuditProcedure": "**Audit from Azure Portal** 1. For each VM, open the Networking blade 2. Verify that the INBOUND PORT RULES does not have a rule for HTTP(S) such as - port = `80`/ `443`, - protocol = `TCP`, - Source = `Any` OR `Internet` **Audit from Azure CLI** List Network security groups with corresponding non-default Security rules: ``` az network nsg list --query [*].[name,securityRules] ``` Ensure that none of the NSGs have security rule as below ``` access : Allow destinationPortRange : 80/443 or * or [port range containing 80/443] direction : Inbound protocol : TCP sourceAddressPrefix : * or 0.0.0.0 or /0 or /0 or internet or any ```", + "AdditionalInformation": "", + "References": "Express Route: https://docs.microsoft.com/en-us/azure/expressroute/:Site-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-site-to-site-resource-manager-portal:Point-to-Site VPN: https://docs.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-point-to-site-resource-manager-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-1-establish-network-segmentation-boundaries", + "DefaultValue": "" + } + ] + }, + { + "Id": "7.5", + "Description": "Ensure that Network Security Group Flow Log Retention Days is Set to Greater than or equal to 90", + "Checks": [ + "network_flow_log_more_than_90_days" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Network Security Group Flow Log Retention Days is Set to Greater than or equal to 90", + "RationaleStatement": "Virtual network flow logs provide critical visibility into traffic patterns. Logs can be used to check for anomalies and give insight into suspected breaches.", + "ImpactStatement": "* Virtual network flow logs are charged per gigabyte of network flow logs collected and come with a free tier of 5 GB/month per subscription. * If traffic analytics is enabled with virtual network flow logs, traffic analytics pricing applies at per gigabyte processing rates. * The storage of logs is charged separately, and the cost will depend on the amount of logs and the retention period.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, set `Retention days` to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log requiring remediation. **Remediate from Azure CLI** Run the following command update the retention policy for a flow log in a network watcher, setting `retention` to `0`, `90`, or a number greater than 90: ``` az network watcher flow-log update --location --name --retention ``` Repeat for each virtual network flow log requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, ensure that `Retention days` is set to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log. **Audit from Azure CLI** Run the following command to list network watchers: ``` az network watcher list ``` Run the following command to list the name and retention policy of flow logs in a network watcher: ``` az network watcher flow-log list --location --query [*].[name,retentionPolicy] ``` For each flow log, ensure that `days` is set to `0`, `90`, or a number greater than 90. If `days` is set to `0`, the logs are retained indefinitely with no retention policy. Repeat for each network watcher.", + "AdditionalInformation": "As network security group flow logs are on the retirement path, Azure recommends migrating to virtual network flow logs.", + "References": "https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-portal:https://learn.microsoft.com/en-us/cli/azure/network/watcher/flow-log", + "DefaultValue": "When a virtual network flow log is created using the Azure CLI, retention days is set to 0 by default. When creating via the Azure Portal, retention days must be specified by the creator." + } + ] + }, + { + "Id": "7.6", + "Description": "Ensure that Network Watcher is 'Enabled' for Azure Regions That are in Use", + "Checks": [ + "network_watcher_enabled" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Network Watcher is 'Enabled' for Azure Regions That are in Use", + "RationaleStatement": "Network diagnostic and visualization tools available with Network Watcher help users understand, diagnose, and gain insights to the network in Azure.", + "ImpactStatement": "There are additional costs per transaction to run and store network data. For high-volume networks these charges will add up quickly.", + "RemediationProcedure": "Opting out of Network Watcher automatic enablement is a permanent change. Once you opt-out you cannot opt-in without contacting support. To manually enable Network Watcher in each region where you want to use Network Watcher capabilities, follow the steps below. **Remediate from Azure Portal** 1. Use the Search bar to search for and click on the `Network Watcher` service. 1. Click `Create`. 1. Select a `Region` from the drop-down menu. 1. Click `Add`. **Remediate from Azure CLI** ``` az network watcher configure --locations --enabled true --resource-group ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Use the Search bar to search for and click on the `Network Watcher` service. 1. From the Overview menu item, review each Network Watcher listed, and ensure that a network watcher is listed for each region in use by the subscription. **Audit from Azure CLI** ``` az network watcher list --query [].{Location:location,State:provisioningState} -o table ``` This will list all network watchers and their provisioning state. Ensure `provisioningState` is `Succeeded` for each network watcher. ``` az account list-locations --query [?metadata.regionType=='Physical'].{Name:name,DisplayName:regionalDisplayName} -o table ``` This will list all physical regions that exist in the subscription. Compare this list to the previous one to ensure that for each region in use, a network watcher exists with `provisioningState` set to `Succeeded`. **Audit from PowerShell** Get a list of Network Watchers ``` Get-AzNetworkWatcher ``` Make sure each watcher is set with the `ProvisioningState` setting set to `Succeeded` and all `Locations` that are in use by the subscription are using a watcher. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b6e2945c-0b7b-40f5-9233-7a5323b5cdc6](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb6e2945c-0b7b-40f5-9233-7a5323b5cdc6) **- Name:** 'Network Watcher should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-monitoring-overview:https://learn.microsoft.com/en-us/cli/azure/network/watcher?view=azure-cli-latest:https://learn.microsoft.com/en-us/cli/azure/network/watcher?view=azure-cli-latest#az-network-watcher-configure:https://learn.microsoft.com/en-us/azure/network-watcher/network-watcher-create:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation:https://azure.microsoft.com/en-ca/pricing/details/network-watcher/", + "DefaultValue": "Network Watcher is automatically enabled. When you create or update a virtual network in your subscription, Network Watcher will be enabled automatically in your Virtual Network's region. There is no impact to your resources or associated charge for automatically enabling Network Watcher." + } + ] + }, + { + "Id": "7.7", + "Description": "Ensure that Public IP Addresses are Evaluated on a Periodic Basis", + "Checks": [ + "network_public_ip_shodan" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Public IP Addresses are Evaluated on a Periodic Basis", + "RationaleStatement": "Public IP Addresses allocated to the tenant should be periodically reviewed for necessity. Public IP Addresses that are not intentionally assigned and controlled present a publicly facing vector for threat actors and significant risk to the tenant.", + "ImpactStatement": "Regular reviews require administrative effort.", + "RemediationProcedure": "Remediation will vary significantly depending on your organization's security requirements for the resources attached to each individual Public IP address.", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `All Resources` blade 2. Click on `Add Filter` 3. In the Add Filter window, select the following: Filter: `Type` Operator: `Equals` Value: `Public IP address` 4. Click the `Apply` button 5. For each Public IP address in the list, use Overview (or Properties) to review the `Associated to:` field and determine if the associated resource is still relevant to your tenant environment. If the associated resource is relevant, ensure that additional controls exist to mitigate risk (e.g. Firewalls, VPNs, Traffic Filtering, Virtual Gateway Appliances, Web Application Firewalls, etc.) on all subsequently attached resources. **Audit from Azure CLI** List all Public IP addresses: ``` az network public-ip list ``` For each Public IP address in the output, review the `name` property and determine if the associated resource is still relevant to your tenant environment. If the associated resource is relevant, ensure that additional controls exist to mitigate risk (e.g. Firewalls, VPNs, Traffic Filtering, Virtual Gateway Appliances, Web Application Firewalls, etc.) on all subsequently attached resources.", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/cli/azure/network/public-ip?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security", + "DefaultValue": "During Virtual Machine and Application creation, a setting may create and attach a public IP." + } + ] + }, + { + "Id": "7.8", + "Description": "Ensure that Virtual Network Flow Log Retention Days is Set to Greater than or Equal to 90", + "Checks": [ + "network_flow_log_more_than_90_days" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Virtual Network Flow Log Retention Days is Set to Greater than or Equal to 90", + "RationaleStatement": "Virtual network flow logs provide critical visibility into traffic patterns. Logs can be used to check for anomalies and give insight into suspected breaches.", + "ImpactStatement": "* Virtual network flow logs are charged per gigabyte of network flow logs collected and come with a free tier of 5 GB/month per subscription. * If traffic analytics is enabled with virtual network flow logs, traffic analytics pricing applies at per gigabyte processing rates. * The storage of logs is charged separately, and the cost will depend on the amount of logs and the retention period.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, set `Retention days` to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log requiring remediation. **Remediate from Azure CLI** Run the following command update the retention policy for a flow log in a network watcher, setting `retention` to `0`, `90`, or a number greater than 90: ``` az network watcher flow-log update --location --name --retention ``` Repeat for each virtual network flow log requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down menu, select `Flow log type`. 1. From the `Value` drop-down menu, check `Virtual network` only. 1. Click `Apply`. 1. Click the name of a virtual network flow log. 1. Under `Storage Account`, ensure that `Retention days` is set to `0`, `90`, or a number greater than 90. If `Retention days` is set to `0`, the logs are retained indefinitely with no retention policy. 1. Repeat steps 7 and 8 for each virtual network flow log. **Audit from Azure CLI** Run the following command to list network watchers: ``` az network watcher list ``` Run the following command to list the name and retention policy of flow logs in a network watcher: ``` az network watcher flow-log list --location --query [*].[name,retentionPolicy] ``` For each flow log, ensure that `days` is set to `0`, `90`, or a number greater than 90. If `days` is set to `0`, the logs are retained indefinitely with no retention policy. Repeat for each network watcher.", + "AdditionalInformation": "As network security group flow logs are on the retirement path, Azure recommends migrating to virtual network flow logs.", + "References": "https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-portal:https://learn.microsoft.com/en-us/cli/azure/network/watcher/flow-log", + "DefaultValue": "When a virtual network flow log is created using the Azure CLI, retention days is set to 0 by default. When creating via the Azure Portal, retention days must be specified by the creator." + } + ] + }, + { + "Id": "7.9", + "Description": "Ensure 'Authentication type' is Set to 'Azure Active Directory' only for Azure VPN Gateway Point-to-Site Configuration", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Authentication type' is Set to 'Azure Active Directory' only for Azure VPN Gateway Point-to-Site Configuration", + "RationaleStatement": "Microsoft Entra ID authentication provides strong security and centralized identity management, and reduces risks associated with static credentials and certificate management.", + "ImpactStatement": "Azure VPN Gateways incur hourly charges, with additional costs for point-to-site connections and data transfer. Pricing varies by SKU and usage. Refer to https://azure.microsoft.com/en-us/pricing/details/vpn-gateway/ for details.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual network gateways`. 2. Under `VPN gateway`, click `VPN gateways`. 3. Click the name of a VPN gateway. 4. Under `Settings`, click `Point-to-site configuration`. 5. Ensure `Authentication type` click to expand the drop-down menu. 6. Check the box next to `Azure Active Directory`, and uncheck the boxes next to `Azure certificate` and `RADIUS authentication`. 7. Provide a `Tenant`, `Audience`, and `Issuer` for the Azure Active Directory configuration. 8. Click `Save`. 9. Repeat steps 1-8 for each VPN gateway requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Virtual network gateways`. 2. Under `VPN gateway`, click `VPN gateways`. 3. Click the name of a VPN gateway. 4. Under `Settings`, click `Point-to-site configuration`. 5. Ensure `Authentication type` is set to `Azure Active Directory` only. 6. Repeat steps 1-5 for each VPN gateway. **Audit from Azure Policy** - **Policy ID:** 21a6bc25-125e-4d13-b82d-2e19b7208ab7 - **Name:** 'VPN gateways should use only Azure Active Directory (Azure AD) authentication for point-to-site users'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-about-vpngateways:https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-entra-gateway:https://learn.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-tenant", + "DefaultValue": "'Authentication type' is selected during creation of point-to-site configuration." + } + ] + }, + { + "Id": "7.10", + "Description": "Ensure Azure Web Application Firewall (WAF) is Enabled on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Azure Web Application Firewall (WAF) is Enabled on Azure Application Gateway", + "RationaleStatement": "Using Azure Web Application Firewall with Azure Application Gateway reduces exposure to external threats by mitigating attacks on public facing applications.", + "ImpactStatement": "The WAF V2 tier for Azure Application Gateways costs more than the Basic and Standard V2 tiers. Pricing includes a fixed hourly charge plus a charge per capacity-unit hour. Refer to https://azure.microsoft.com/en-gb/pricing/details/application-gateway/ for details.", + "RemediationProcedure": "**Note:** Basic tier application gateways cannot be upgraded to the WAF V2 tier. Create a new WAF V2 tier application gateway to replace a Basic tier application gateway. **Remediate from Azure Portal** To remediate a Standard V2 tier application gateway: 1. Go to `Application gateways`. 2. Click `Add filter`. 3. From the `Filter` drop-down menu, select `SKU size`. 4. Check the box next to `Standard_v2` only. 5. Click `Apply`. 6. Click the name of an application gateway. 7. Under `Settings`, click `Web application firewall`. 8. Under `Configure`, next to `Tier`, click `WAF V2`. 9. Select an existing or create a new WAF policy. 10. Click `Save`. 11. Repeat steps 1-10 for each Standard V2 tier application gateway requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. In the `Overview`, under `Essentials`, ensure `Tier` is set to `WAF V2`. 4. Repeat steps 1-3 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the firewall policy id: ``` az network application-gateway show --resource-group --name --query firewallPolicy.id ``` Ensure a firewall policy id is returned. **Audit from Azure Policy** - **Policy ID:** 564feb30-bf6a-4854-b4bb-0d2d2d1e6c66 - **Name:** 'Web Application Firewall (WAF) should be enabled for Application Gateway'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/application-gateway/features:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://azure.microsoft.com/en-us/pricing/details/application-gateway", + "DefaultValue": "Azure Web Application Firewall is enabled by default for the WAF V2 tier of Azure Application Gateway. It is not available in the Basic tier. Application gateways deployed using the Standard V2 tier can be upgraded to the WAF V2 tier to enable Azure Web Application Firewall." + } + ] + }, + { + "Id": "7.11", + "Description": "Ensure Subnets Are Associated with Network Security Groups", + "Checks": [ + "network_subnet_nsg_associated" + ], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Subnets Are Associated with Network Security Groups", + "RationaleStatement": "Unprotected subnets can expose resources to unauthorized access.", + "ImpactStatement": "Minor administrative effort is required to ensure subnets are associated with network security groups. There is no cost to create or use network security groups.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `Subnets`. 4. Click the name of a subnet. 5. Under `Security`, next to `Network security group`, click `None` to display the drop-down menu. 6. Select a network security group. 7. Click `Save`. 8. Repeat steps 1-7 for each virtual network and subnet requiring remediation. **Remediate from Azure CLI** For each subnet requiring remediation, run the following command to associate it with a network security group: ``` az network vnet subnet update --resource-group --vnet-name --name --network-security-group ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `Subnets`. 4. Click the name of a subnet. 5. Under `Security`, ensure `Network security group` is not set to `None`. 6. Repeat steps 1-5 for each virtual network and subnet. **Audit from Azure CLI** Run the following command to list virtual networks: ``` az network vnet list ``` For each virtual network, run the following command to list subnets: ``` az network vnet show --resource-group --name --query subnets ``` For each subnet, run the following command to get the network security group id: ``` az network vnet subnet show --resource-group --vnet-name --name --query networkSecurityGroup.id ``` Ensure a network security group id is returned. **Audit from Azure Policy** - **Policy ID:** e71308d3-144b-4262-b144-efdc3cc90517 - **Name:** 'Subnets should be associated with a Network Security Group'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview:https://learn.microsoft.com/en-us/cli/azure/network/vnet", + "DefaultValue": "By default, a subnet is not associated with a network security group." + } + ] + }, + { + "Id": "7.12", + "Description": "Ensure the SSL Policy's 'Min protocol version' is Set to 'TLSv1_2' or Higher on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure the SSL Policy's 'Min protocol version' is Set to 'TLSv1_2' or Higher on Azure Application Gateway", + "RationaleStatement": "TLS 1.0 and 1.1 are outdated and vulnerable to security risks. Since TLS 1.2 and TLS 1.3 provide enhanced security and improved performance, it is highly recommended to use TLS 1.2 or higher whenever possible.", + "ImpactStatement": "Using the latest TLS version may affect compatibility with clients and backend services.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Listeners`. 4. Under `SSL Policy`, next to the Selected SSL Policy name, click `change`. 5. Select an appropriate SSL policy with a `Min protocol version` of `TLSv1_2` or higher. 6. Click `Save`. 7. Repeat steps 1-6 for each application gateway requiring remediation. **Remediate from Azure CLI** Run the following command to list available SSL policy options: ``` az network application-gateway ssl-policy list-options ``` Run the following command to list available predefined SSL policies: ``` az network application-gateway ssl-policy predefined list ``` For each application gateway requiring remediation, run the following command to set a predefined SSL policy: ``` az network application-gateway ssl-policy set --resource-group --gateway-name --name --policy-type Predefined ``` Alternatively, run the following command to set a custom SSL policy: ``` az network application-gateway ssl-policy set --resource-group --gateway-name --policy-type Custom --min-protocol-version --cipher-suites ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Listeners`. 4. Under `SSL Policy`, ensure `Min protocol version` is set to `TLSv1_2` or higher. 5. Repeat steps 1-4 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the SSL policy: ``` az network application-gateway ssl-policy show --resource-group --gateway-name ``` For each SSL policy, run the following command to get the minProtocolVersion: ``` az network application-gateway ssl-policy predefined show --name --query minProtocolVersion ``` Ensure `TLSv1_2` or higher is returned.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-ssl-policy-overview:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway", + "DefaultValue": "Min protocol version is set to TLSv1_2 by default." + } + ] + }, + { + "Id": "7.13", + "Description": "Ensure 'HTTP2' is Set to 'Enabled' on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'HTTP2' is Set to 'Enabled' on Azure Application Gateway", + "RationaleStatement": "Enabling HTTP/2 supports use of modern encrypted connections.", + "ImpactStatement": "Clients and backend services that do not support HTTP/2 will fall back to HTTP/1.1.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Configuration`. 4. Under `HTTP2`, click `Enabled`. 5. Click `Save`. 6. Repeat steps 1-5 for each application gateway requiring remediation. **Remediate from Azure CLI** For each application gateway requiring remediation, run the following command to enable HTTP2: ``` az network application-gateway update --resource-group --name --http2 Enabled ``` **Remediate from PowerShell** Run the following command to get the application gateway in a resource group with a given name: ``` $gateway = Get-AzApplicationGateway -ResourceGroupName -Name ``` Run the following command to enable HTTP2: ``` $gateway.EnableHttp2 = $true ``` Run the following command to apply the update: ``` Set-AzApplicationGateway -ApplicationGateway $gateway ``` Repeat for each application gateway requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Configuration`. 4. Ensure `HTTP2` is set to `Enabled`. 5. Repeat steps 1-4 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the HTTP2 setting: ``` az network application-gateway show --resource-group --name --query enableHttp2 ``` Ensure `true` is returned. **Audit from PowerShell** Run the following command to list application gateways: ``` Get-AzApplicationGateway ``` Run the following command to get the application gateway in a resource group with a given name: ``` $gateway = Get-AzApplicationGateway -ResourceGroupName -Name ``` Run the following command to get the HTTP2 setting: ``` $gateway.EnableHttp2 ``` Ensure that `True` is returned. Repeat for each application gateway.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/application-gateway/features#websocket-and-http2-traffic:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://learn.microsoft.com/en-us/powershell/module/az.network/get-azapplicationgateway:https://learn.microsoft.com/en-us/powershell/module/az.network/set-azapplicationgateway", + "DefaultValue": "HTTP2 is enabled by default." + } + ] + }, + { + "Id": "7.14", + "Description": "Ensure Request Body Inspection is Enabled in Azure Web Application Firewall policy on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Request Body Inspection is Enabled in Azure Web Application Firewall policy on Azure Application Gateway", + "RationaleStatement": "Enabling request body inspection strengthens security by allowing the Web Application Firewall to detect common attacks, such as SQL injection and cross-site scripting.", + "ImpactStatement": "Minor performance impact on the Web Application Firewall. Additional effort may be required to monitor findings.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Policy settings`. 6. Check the box next to `Enforce request body inspection`. 7. Click `Save`. 8. Repeat steps 1-7 for each application gateway and firewall policy requiring remediation. **Remediate from Azure CLI** For each firewall policy requiring remediation, run the following command to enable request body inspection: ``` az network application-gateway waf-policy update --ids --policy-settings request-body-check=true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Policy settings`. 6. Ensure the box next to `Enforce request body inspection` is checked. 7. Repeat steps 1-6 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the firewall policy id: ``` az network application-gateway show --resource-group --name --query firewallPolicy.id ``` For each firewall policy, run the following command to get the request body inspection setting: ``` az network application-gateway waf-policy show --ids --query policySettings.requestBodyCheck ``` Ensure `true` is returned. **Audit from Azure Policy** - **Policy ID:** ca85ef9a-741d-461d-8b7a-18c2da82c666 - **Name:** 'Azure Web Application Firewall on Azure Application Gateway should have request body inspection enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-gb/azure/web-application-firewall/ag/application-gateway-waf-request-size-limits#request-body-inspection:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway/waf-policy", + "DefaultValue": "Request body inspection is enabled by default on Azure Application Gateways with Web Application Firewall." + } + ] + }, + { + "Id": "7.15", + "Description": "Ensure Bot Protection is Enabled in Azure Web Application Firewall Policy on Azure Application Gateway", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Bot Protection is Enabled in Azure Web Application Firewall Policy on Azure Application Gateway", + "RationaleStatement": "Internet traffic from bots can scrape, scan, and search for application vulnerabilities. Enabling bot protection stops requests from known malicious IP addresses and enhances the overall security of your application by reducing exposure to automated attacks.", + "ImpactStatement": "May require monitoring to identify false positives.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Managed rules`. 6. Click `Assign`. 7. Under `Bot Management ruleset`, click to display the drop-down menu. 8. Select a `Microsoft_BotManagerRuleSet`. 9. Click `Save`. 10. Click `X` to close the panel. 11. Repeat steps 1-10 for each application gateway and firewall policy requiring remediation. **Remediate from Azure CLI** For each firewall policy requiring remediation, run the following command to enable bot protection: ``` az network application-gateway waf-policy managed-rule rule-set add --resource-group --policy-name --type Microsoft_BotManagerRuleSet --version <0.1|1.0|1.1> ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Application gateways`. 2. Click the name of an application gateway. 3. Under `Settings`, click `Web application firewall`. 4. Under `Associated web application firewall policy`, click the policy name. 5. Under `Settings`, click `Managed rules`. 6. Ensure a `Rule Id` containing `Microsoft_BotManagerRuleSet` is listed. 7. Click the `>` to expand the row. 8. Ensure the `Status` for `Malicious Bots` is set to `Enabled`. 9. Repeat steps 1-8 for each application gateway. **Audit from Azure CLI** Run the following command to list application gateways: ``` az network application-gateway list ``` For each application gateway, run the following command to get the firewall policy id: ``` az network application-gateway show --resource-group --name --query firewallPolicy.id ``` For each firewall policy, run the following command to get the managed rule sets: ``` az network application-gateway waf-policy show --ids --query managedRules.managedRuleSets ``` Ensure a managed rule set with `ruleSetType` of `Microsoft_BotManagerRuleSet` is returned, and that no `ruleGroupOverrides` for `ruleGroupName` `KnownBadBots` with `state` `Disabled` are returned. **Audit from Azure Policy** - **Policy ID:** ebea0d86-7fbd-42e3-8a46-27e7568c2525 - **Name:** 'Bot Protection should be enabled for Azure Application Gateway WAF'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/bot-protection-overview:https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/bot-protection:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway/waf-policy:https://learn.microsoft.com/en-us/cli/azure/network/application-gateway/waf-policy/managed-rule/rule-set", + "DefaultValue": "Bot protection is disabled by default on Azure Application Gateways with Web Application Firewall." + } + ] + }, + { + "Id": "7.16", + "Description": "Ensure Azure Network Security Perimeter is Used to Secure Azure Platform-as-a-service Resources", + "Checks": [], + "Attributes": [ + { + "Section": "7 Networking Services", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Network Security Perimeter is Used to Secure Azure Platform-as-a-service Resources", + "RationaleStatement": "Network security perimeter denies public access to PaaS resources, reducing exposure and mitigating data exfiltration risks.", + "ImpactStatement": "Implementation requires administrative effort to configure and maintain network security perimeter profiles and resource assignments. Azure does not list any additional charges for using network security perimeters.", + "RemediationProcedure": "**Remediate from Azure Portal** Create and associate PaaS resources with a new network security perimeter: 1. Go to `Network Security Perimeters`. 2. Click `+ Create`. 3. Select a `Subscription` and `Resource group`, provide a `Name`, select a `Region`, and provide a `Profile name`. 4. Click `Next`. 5. Click `+ Add`. 6. Check the box next to a PaaS resource to associate it with the network security perimeter. 7. Click `Select`. 8. Click `Next`. 9. Configure appropriate `Inbound access rules` for your organization. 10. Click `Next`. 11. Configure appropriate `Outbound access rules` for your organization. 12. Click `Review + create`. 13. Click `Create`. **Remediate from Azure CLI** Use `az network perimeter profile list` or `az network perimeter profile create` to list existing or create a new network security perimeter profile. For each PaaS resource requiring association with a network security perimeter, run the following command: ``` az network perimeter association create --resource-group --perimeter-name --association-name --private-link-resource \"{id:}\" --profile \"{}\" ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Resource groups`. 2. Click the name of a resource group. 3. Take note of PaaS resources. 4. Go to `Network Security Perimeters`. 5. Click the name of a network security perimeter. 6. Under `Settings`, click `Associated resources`. 7. Take note of the associated resources. 8. Repeat steps 1-7 and ensure each PaaS resource is associated with a network security perimeter. **Audit from Azure CLI** Run the following command to list resource groups: ``` az group list ``` For each resource group, run the following command to list resources: ``` az resource list --resource-group ``` Take note of PaaS resources. For each resource group, run the following command to list network security perimeters: ``` az network perimeter list --resource-group ``` For each network security perimeter, run the following command to list resources: ``` az network perimeter association list --resource-group --perimeter-name ``` Ensure each PaaS resource is associated with a network security perimeter.", + "AdditionalInformation": "The current list of resources that can be associated with a network security perimeter are as follows: Azure Monitor, Azure AI Search, Cosmos DB, Event Hubs, Key Vault, SQL DB, Storage, Azure OpenAI Service. While network security perimeter is generally available, Cosmos DB, SQL DB, and Azure OpenAI Service are in public preview.", + "References": "https://learn.microsoft.com/en-us/azure/private-link/network-security-perimeter-concepts:https://learn.microsoft.com/en-us/azure/private-link/create-network-security-perimeter-portal:https://learn.microsoft.com/en-us/cli/azure/group:https://learn.microsoft.com/en-us/cli/azure/resource:https://learn.microsoft.com/en-us/cli/azure/network/perimeter", + "DefaultValue": "PaaS resources are not associated with a network security perimeter by default." + } + ] + }, + { + "Id": "8.1.1.1", + "Description": "Ensure Microsoft Defender CSPM is Set to 'On'", + "Checks": [ + "defender_ensure_defender_cspm_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Microsoft Defender CSPM is Set to 'On'", + "RationaleStatement": "Microsoft Defender CSPM provides detailed visibility into the security state of assets and workloads and offers hardening guidance to help improve security posture.", + "ImpactStatement": "Enabling Microsoft Defender CSPM incurs hourly charges for each billable compute, database, and storage resource. This can lead to significant costs in larger environments. Careful planning and cost analysis are recommended before enabling the service. Refer to https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/#pricing for pricing information.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment settings`. 3. Click the name of a subscription. 4. Select the `Defender plans` blade. 5. Under `Cloud Security Posture Management (CSPM)`, in the row for `Defender CSPM`, set the toggle switch for `Status` to `On`. 6. Click `Save`. **Remediate from Azure CLI** Run the following command to enable Defender CSPM: ``` az security pricing create --name CloudPosture --tier Standard --extensions name=ApiPosture isEnabled=true ``` **Remediate from PowerShell** Run the following command to enable Defender CSPM: ``` Set-AzSecurityPricing -Name CloudPosture -PricingTier Standard -Extension '[{\"name\":\"ApiPosture\",\"isEnabled\":\"True\"}]' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment settings`. 3. Click the name of a subscription. 4. Select the `Defender plans` blade. 5. Under `Cloud Security Posture Management (CSPM)`, in the row for `Defender CSPM`, ensure `Status` is set to `On`. **Audit from Azure CLI** Run the following command to get the CloudPosture plan pricing tier: ``` az security pricing show --name CloudPosture --query pricingTier ``` Ensure `Standard` is returned. **Audit from PowerShell** Run the following command to get the CloudPosture plan pricing tier: ``` Get-AzSecurityPricing -Name CloudPosture | Select-Object PricingTier ``` Ensure `Standard` is returned. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1f90fc71-a595-4066-8974-d4d0802e8ef0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1f90fc71-a595-4066-8974-d4d0802e8ef0) **- Name:** 'Microsoft Defender CSPM should be enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-cloud-security-posture-management:https://learn.microsoft.com/en-us/azure/defender-for-cloud/tutorial-enable-cspm-plan:https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/#pricing:https://learn.microsoft.com/en-us/cli/azure/security/pricing:https://learn.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/powershell/module/az.security/set-azsecuritypricing", + "DefaultValue": "Defender CSPM is disabled by default." + } + ] + }, + { + "Id": "8.1.2.1", + "Description": "Ensure Microsoft Defender for APIs is Set to 'On'", + "Checks": [ + "defender_ensure_defender_for_app_services_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Microsoft Defender for APIs is Set to 'On'", + "RationaleStatement": "Enabling Microsoft Defender for App Service allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for App Service incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Set `App Service` Status to `On` 6. Select `Save` **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n Appservices --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name AppServices -PricingTier Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Ensure Status is `On` for `App Service` **Audit from Azure CLI** Run the following command: ``` az security pricing show -n AppServices ``` Ensure `-PricingTier` is set to `Standard` **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'AppServices' |Select-Object Name,PricingTier ``` Ensure the `-PricingTier` is set to `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [2913021d-f2fd-4f3d-b958-22354e2bdbcb](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2913021d-f2fd-4f3d-b958-22354e2bdbcb) **- Name:** 'Azure Defender for App Service should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.3.1", + "Description": "Ensure that Defender for Servers is Set to 'On'", + "Checks": [ + "defender_ensure_defender_for_server_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Defender for Servers is Set to 'On'", + "RationaleStatement": "Enabling Defender for Servers allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Enabling Defender for Servers in Microsoft Defender for Cloud incurs an additional cost per resource. Refer to https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/ and https://azure.microsoft.com/en-us/pricing/calculator/ to estimate potential costs. - Plan 1: Subscription only - Plan 2: Subscription and workspace", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on a subscription name. 1. Click `Defender plans` in the left pane. 1. Under `Cloud Workload Protection (CWP)`, locate `Servers` in the Plan column, set Status to `On`. 1. Select `Save`. 1. Repeat steps 1-6 for each subscription requiring remediation. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n VirtualMachines --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name 'VirtualMachines' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on a subscription name. 1. Select `Defender plans` in the left pane. 1. Under `Cloud Workload Protection (CWP)`, locate `Servers` in the Plan column, ensure Status is set to `On`. 1. Repeat steps 1-5 for each subscription. **Audit from Azure CLI** Run the following command: ``` az security pricing show -n VirtualMachines --query pricingTier ``` If the tenant is licensed and enabled, the output will indicate `Standard`. **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'VirtualMachines' |Select-Object Name,PricingTier ``` If the tenant is licensed and enabled, the `-PricingTier` parameter will indicate `Standard`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [4da35fc9-c9e7-4960-aec9-797fe7d9051d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4da35fc9-c9e7-4960-aec9-797fe7d9051d) **- Name:** 'Azure Defender for servers should be enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-servers-overview:https://learn.microsoft.com/en-us/azure/defender-for-cloud/plan-defender-for-servers:https://learn.microsoft.com/en-us/rest/api/defenderforcloud/pricings/list:https://learn.microsoft.com/en-us/rest/api/defenderforcloud/pricings/update:https://learn.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/powershell/module/az.security/set-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-endpoint-security#es-1-use-endpoint-detection-and-response-edr", + "DefaultValue": "By default, the Defender for Servers plan is disabled." + } + ] + }, + { + "Id": "8.1.3.2", + "Description": "Ensure that 'Vulnerability assessment for machines' Component Status is set to 'On'", + "Checks": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Vulnerability assessment for machines' Component Status is set to 'On'", + "RationaleStatement": "Vulnerability assessment for machines scans for various security-related configurations and events such as system updates, OS vulnerabilities, and endpoint protection, then produces alerts on threat and vulnerability findings.", + "ImpactStatement": "Microsoft Defender for Servers plan 2 licensing is required, and configuration of Azure Arc introduces complexity beyond this recommendation.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Select a subscription 1. Click on `Settings & Monitoring` 1. Set the `Status` of `Vulnerability assessment for machines` to `On` 1. Click `Continue` Repeat the above for any additional subscriptions.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Select a subscription 1. Click on `Settings & monitoring` 1. Ensure that `Vulnerability assessment for machines` is set to `On` Repeat the above for any additional subscriptions.", + "AdditionalInformation": "While this feature is generally available as of publication, it is not yet available for Azure Government tenants.", + "References": "https://docs.microsoft.com/en-us/azure/defender-for-cloud/enable-data-collection?tabs=autoprovision-va:https://msdn.microsoft.com/en-us/library/mt704062.aspx:https://msdn.microsoft.com/en-us/library/mt704063.aspx:https://docs.microsoft.com/en-us/rest/api/securitycenter/autoprovisioningsettings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/autoprovisioningsettings/create:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-posture-vulnerability-management#pv-5-perform-vulnerability-assessments", + "DefaultValue": "By default, `Automatic provisioning of monitoring agent` is set to `Off`." + } + ] + }, + { + "Id": "8.1.3.3", + "Description": "Ensure that 'Endpoint protection' Component Status is set to 'On'", + "Checks": [ + "defender_assessments_vm_endpoint_protection_installed" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Endpoint protection' Component Status is set to 'On'", + "RationaleStatement": "Microsoft Defender for Endpoint integration brings comprehensive Endpoint Detection and Response (EDR) capabilities within Microsoft Defender for Cloud. This integration helps to spot abnormalities, as well as detect and respond to advanced attacks on endpoints monitored by Microsoft Defender for Cloud. MDE works only with Standard Tier subscriptions.", + "ImpactStatement": "Endpoint protection requires licensing and is included in these plans: - Defender for Servers plan 1 - Defender for Servers plan 2", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the subscription name. 1. Click `Settings & monitoring`. 1. Set the `Status` for `Endpoint protection` to `On`. 1. Click `Continue`. **Remediate from Azure CLI** Use the below command to enable Standard pricing tier for Storage Accounts ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions//providers/Microsoft.Security/settings/WDATP?api-version=2021-06-01 -d@input.json' ``` Where input.json contains the Request body json data as mentioned below. ``` { id: /subscriptions//providers/Microsoft.Security/settings/WDATP, kind: DataExportSettings, type: Microsoft.Security/settings, properties: { enabled: true } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the subscription name. 1. Click `Settings & monitoring`. 1. Ensure the `Status` for `Endpoint protection` is set to `On`. **Audit from Azure CLI** Ensure the output of the below command is `True` ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions//providers/Microsoft.Security/settings?api-version=2021-06-01' | jq '.|.value[] | select(.name==WDATP)'|jq '.properties.enabled' ``` **Audit from PowerShell** Run the following commands to login and audit this check ``` Connect-AzAccount Set-AzContext -Subscription Get-AzSecuritySetting | Select-Object name,enabled |where-object {$_.name -eq WDATP} ``` **PowerShell Output - Non-Compliant** ``` Name Enabled ---- ------- WDATP False ``` **PowerShell Output - Compliant** ``` Name Enabled ---- ------- WDATP True ```", + "AdditionalInformation": "**IMPORTANT:** When enabling integration between DfE & DfC it needs to be taken into account that this will have some side effects that may be undesirable. 1. For server 2019 & above if defender is installed (default for these server SKUs) this will trigger a deployment of the new unified agent and link to any of the extended configuration in the Defender portal. 1. If the new unified agent is required for server SKUs of Win 2016 or Linux and lower there is additional integration that needs to be switched on and agents need to be aligned. NOTE: Microsoft Defender for Endpoint (MDE) was formerly known as Windows Defender Advanced Threat Protection (WDATP). There are a number of places (e.g. Azure CLI) where the WDATP acronym is still used within Azure.", + "References": "https://docs.microsoft.com/en-in/azure/defender-for-cloud/integration-defender-for-endpoint?tabs=windows:https://docs.microsoft.com/en-us/rest/api/securitycenter/settings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/settings/update:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-endpoint-security#es-1-use-endpoint-detection-and-response-edr:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-endpoint-security#es-2-use-modern-anti-malware-software", + "DefaultValue": "By default, Endpoint protection is `off`." + } + ] + }, + { + "Id": "8.1.3.4", + "Description": "Ensure that 'Agentless scanning for machines' Component Status is Set to 'On'", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'Agentless scanning for machines' Component Status is Set to 'On'", + "RationaleStatement": "The Microsoft Defender for Cloud agentless machine scanner provides threat detection, vulnerability detection, and discovery of sensitive information.", + "ImpactStatement": "Agentless scanning for machines requires licensing and is included in these plans: - Defender CSPM - Defender for Servers plan 2", + "RemediationProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `Agentless scanning for machines` 1. Select `On` 1. Click `Continue` in the top left Repeat the above for any additional subscriptions.", + "AuditProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `Agentless scanning for machines` 1. Ensure that `On` is selected Repeat the above for any additional subscriptions.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-agentless-data-collection:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification:https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-agentless-scanning-vms", + "DefaultValue": "By default, Agentless scanning for machines is `off`." + } + ] + }, + { + "Id": "8.1.3.5", + "Description": "Ensure that 'File Integrity Monitoring' Component Status is Set to 'On'", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that 'File Integrity Monitoring' Component Status is Set to 'On'", + "RationaleStatement": "FIM provides a detection mechanism for compromised files. When FIM is enabled, critical system files are monitored for changes that might indicate a threat actor is attempting to modify system files for lateral compromise within a host operating system.", + "ImpactStatement": "File Integrity Monitoring requires licensing and is included in these plans: - Defender for Servers plan 2", + "RemediationProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `File Integrity Monitoring` 1. Select `On` 1. Click `Continue` in the top left Repeat the above for any additional subscriptions.", + "AuditProcedure": "**Audit from Azure Portal** 1. From the Azure Portal `Home` page, select `Microsoft Defender for Cloud` 1. Under `Management` select `Environment Settings` 1. Select a subscription 1. Under `Settings` > `Defender Plans`, click `Settings & monitoring` 1. Under the Component column, locate the row for `File Integrity Monitoring` 1. Ensure that `On` is selected Repeat the above for any additional subscriptions.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/file-integrity-monitoring-overview:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification:https://learn.microsoft.com/en-us/azure/defender-for-cloud/file-integrity-monitoring-enable-defender-endpoint", + "DefaultValue": "By default, File Integrity Monitoring is `Off`." + } + ] + }, + { + "Id": "8.1.4.1", + "Description": "Ensure That Microsoft Defender for Containers Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_containers_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Containers Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Containers enhances defense-in-depth by providing advanced threat detection, vulnerability assessment, and security monitoring for containerized environments, leveraging insights from the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Microsoft Defender for Containers incurs a charge per vCore. Refer to https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/ and https://azure.microsoft.com/en-us/pricing/calculator/ to estimate potential costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, click `Environment settings`. 1. Click the name of a subscription. 1. Under `Settings`, click `Defender plans`. 1. Under `Cloud Workload Protection (CWP)`, in the row for `Containers`, click `On` in the `Status` column. 1. If `Monitoring coverage` displays `Partial`, click `Settings` under `Partial`. 1. Set the status of each of the components to `On`. 1. Click `Continue`. 1. Click `Save`. 1. Repeat steps 1-9 for each subscription. **Remediate from Azure CLI** **Note:** Microsoft Defender for Container Registries ('ContainerRegistry') is deprecated and has been replaced by Microsoft Defender for Containers ('Containers'). Run the below command to enable the Microsoft Defender for Containers plan and its components: ``` az security pricing create -n 'Containers' --tier 'standard' --extensions name=ContainerRegistriesVulnerabilityAssessments isEnabled=True --extensions name=AgentlessDiscoveryForKubernetes isEnabled=True --extensions name=AgentlessVmScanning isEnabled=True --extensions name=ContainerSensor isEnabled=True ``` **Remediate from PowerShell** **Note:** Microsoft Defender for Container Registries ('ContainerRegistry') is deprecated and has been replaced by Microsoft Defender for Containers ('Containers'). Run the below command to enable the Microsoft Defender for Containers plan and its components: ``` Set-AzSecurityPricing -Name 'Containers' -PricingTier 'Standard' -Extension '[{name:ContainerRegistriesVulnerabilityAssessments,isEnabled:True},{name:AgentlessDiscoveryForKubernetes,isEnabled:True},{name:AgentlessVmScanning,isEnabled:True},{name:ContainerSensor,isEnabled:True}]' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, click `Environment settings`. 1. Click the name of a subscription. 1. Under `Settings`, click `Defender plans`. 1. Under `Cloud Workload Protection (CWP)`, in the row for `Containers`, ensure that the `Status` is set to `On` and `Monitoring coverage` displays `Full`. 1. Repeat steps 1-5 for each subscription. **Audit from Azure CLI** For Microsoft Defender for Container Registries (deprecated), run the following command: ``` az security pricing show --name ContainerRegistry --query pricingTier ``` Ensure that the command returns `Standard`. For Microsoft Defender for Containers, run the following command: ``` az security pricing show --name Containers --query [pricingTier,extensions[*].[name,isEnabled]] ``` Ensure that the command returns `Standard`, and that each of the extensions (ContainerRegistriesVulnerabilityAssessments, AgentlessDiscoveryForKubernetes, AgentlessVmScanning, ContainerSensor) returns `True`. Repeat for each subscription. **Audit from PowerShell** For Microsoft Defender for Container Registries (deprecated), run the following command: ``` Get-AzSecurityPricing -Name 'ContainerRegistry' | Select-Object Name,PricingTier ``` Ensure the command returns `PricingTier` `Standard`. For Microsoft Defender for Containers, run the following command: ``` Get-AzSecurityPricing -Name 'Containers' ``` Ensure that `PricingTier` is set to `Standard`, and that each of the extensions (ContainerRegistriesVulnerabilityAssessments, AgentlessDiscoveryForKubernetes, AgentlessVmScanning, ContainerSensor) has `isEnabled` set to `True`. Repeat for each subscription. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [1c988dd6-ade4-430f-a608-2a3e5b0a6d38](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F1c988dd6-ade4-430f-a608-2a3e5b0a6d38) **- Name:** 'Microsoft Defender for Containers should be enabled'", + "AdditionalInformation": "The Azure Policy 'Microsoft Defender for Containers should be enabled' checks only that the `pricingTier` for `Containers` is set to `Standard`. It does not check the status of the plan's components.", + "References": "https://learn.microsoft.com/en-us/cli/azure/security/pricing:https://learn.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/powershell/module/az.security/set-azsecuritypricing:https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-introduction:https://learn.microsoft.com/en-us/azure/defender-for-cloud/tutorial-enable-containers-azure:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "The Microsoft Defender for Containers plan is disabled by default." + } + ] + }, + { + "Id": "8.1.5.1", + "Description": "Ensure That Microsoft Defender for Storage Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_storage_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Storage Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Storage allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for Storage incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Set `Status` to `On` for `Storage`. 6. Select `Save`. **Remediate from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing create -n StorageAccounts --tier 'standard' ``` **Remediate from PowerShell** ``` Set-AzSecurityPricing -Name 'StorageAccounts' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Ensure `Status` is set to `On` for `Storage`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n StorageAccounts ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'StorageAccounts' | Select-Object Name,PricingTier ``` Ensure output for `Name PricingTier` is `StorageAccounts Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [640d2586-54d2-465f-877f-9ffc1d2109f4](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F640d2586-54d2-465f-877f-9ffc1d2109f4) **- Name:** 'Microsoft Defender for Storage should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.5.2", + "Description": "Ensure Advanced Threat Protection Alerts for Storage Accounts Are Monitored", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Advanced Threat Protection Alerts for Storage Accounts Are Monitored", + "RationaleStatement": "Enabling Microsoft Defender for Storage without a monitoring process limits its value. Continuous monitoring and alert triage ensure that detected threats are acted upon quickly, reducing risk exposure.", + "ImpactStatement": "Requires integration effort with SIEM or alerting tools and a defined incident response process. The amount of data logged and, thus, the cost incurred can vary significantly depending on the tenant size and the applications in your tenant that interact with the Microsoft Graph APIs. See pricing: Log Analytics (https://learn.microsoft.com/en-us/azure/azure-monitor/logs/cost-logs#pricing-model), Azure Storage (https://azure.microsoft.com/en-us/pricing/details/storage/blobs/), Event Hubs (https://azure.microsoft.com/en-us/pricing/details/event-hubs/).", + "RemediationProcedure": "Connect Microsoft Defender for Cloud to a SIEM such as Microsoft Sentinel or another log analytics solution. **Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment Settings`. 3. Expand the Tenant Root Group(s) to reveal subscriptions. For each subscription listed: 1. Click the subscription name to open the Defender Plans settings 2. In the settings on the left, click `Continuous Export` 3. Select either `Event Hub`, `Log Analytics Workspace`, or both depending on your environment. 4. Set `Export enabled` to `On` 5. Under `Exported data types`, ensure that at least `Security Alerts (Medium and High)` is checked. 6. Under `Export target`, set the target Event Hub or Log Analytics Workspace which is tied to a SIEM that is configured to monitor and alert for security alerts. Ensure security alerts are included in the security operations workflow and incident response plan.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, click `Environment Settings`. 3. Expand the Tenant Root Group(s) to reveal subscriptions. For each subscription listed: 1. Click the subscription name to open the Defender Plans settings 2. In the settings on the left, click `Continuous Export` Ensure that `Export enabled` is set to `On` and delivering at least `Security Alerts (Medium and High)` to an Event Hub or Log Analytics Workspace which is tied to a SIEM that is configured to monitor and alert for security alerts.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/azure/defender-for-cloud/alerts-overview:https://learn.microsoft.com/azure/sentinel/connect-defender-for-cloud:https://learn.microsoft.com/en-us/azure/defender-for-cloud/continuous-export", + "DefaultValue": "By default, continuous export is off." + } + ] + }, + { + "Id": "8.1.6.1", + "Description": "Ensure That Microsoft Defender for App Services Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_app_services_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for App Services Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for App Service allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for App Service incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Set `App Service` Status to `On` 6. Select `Save` **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n Appservices --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name AppServices -PricingTier Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud` 2. Under `Management`, select `Environment Settings` 3. Click on the subscription name 4. Select `Defender plans` 5. Ensure Status is `On` for `App Service` **Audit from Azure CLI** Run the following command: ``` az security pricing show -n AppServices ``` Ensure `-PricingTier` is set to `Standard` **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'AppServices' |Select-Object Name,PricingTier ``` Ensure the `-PricingTier` is set to `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [2913021d-f2fd-4f3d-b958-22354e2bdbcb](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2913021d-f2fd-4f3d-b958-22354e2bdbcb) **- Name:** 'Azure Defender for App Service should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.7.1", + "Description": "Ensure That Microsoft Defender for Azure Cosmos DB Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_cosmosdb_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Azure Cosmos DB Is Set To 'On'", + "RationaleStatement": "In scanning Azure Cosmos DB requests within a subscription, requests are compared to a heuristic list of potential security threats. These threats could be a result of a security breach within your services, thus scanning for them could prevent a potential security threat from being introduced.", + "ImpactStatement": "Enabling Microsoft Defender for Azure Cosmos DB requires enabling Microsoft Defender for your subscription. Both will incur additional charges.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. On the `Database` row click on `Select types >`. 6. Set the toggle switch next to `Azure Cosmos DB` to `On`. 7. Click `Continue`. 8. Click `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n 'CosmosDbs' --tier 'standard' ``` **Remediate from PowerShell** Use the below command to enable Standard pricing tier for Azure Cosmos DB ``` Set-AzSecurityPricing -Name 'CosmosDbs' -PricingTier 'Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. On the `Database` row click on `Select types >`. 6. Ensure the toggle switch next to `Azure Cosmos DB` is set to `On`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n CosmosDbs --query pricingTier ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'CosmosDbs' | Select-Object Name,PricingTier ``` Ensure output of `-PricingTier` is `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [adbe85b5-83e6-4350-ab58-bf3a4f736e5e](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fadbe85b5-83e6-4350-ab58-bf3a4f736e5e) **- Name:** 'Microsoft Defender for Azure Cosmos DB should be enabled'", + "AdditionalInformation": "", + "References": "https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/:https://docs.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security:https://docs.microsoft.com/en-us/azure/defender-for-cloud/alerts-overview:https://docs.microsoft.com/en-us/security/benchmark/azure/baselines/cosmos-db-security-baseline:https://docs.microsoft.com/en-us/azure/defender-for-cloud/quickstart-enable-database-protections:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender for Azure Cosmos DB is not enabled." + } + ] + }, + { + "Id": "8.1.7.2", + "Description": "Ensure That Microsoft Defender for Open-Source Relational Databases Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_os_relational_databases_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Open-Source Relational Databases Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Open-source relational databases allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for Open-source relational databases incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Set the toggle switch next to `Open-source relational databases` to `On`. 7. Select `Continue`. 8. Select `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n 'OpenSourceRelationalDatabases' --tier 'standard' ``` **Remediate from PowerShell** Use the below command to enable Standard pricing tier for Open-source relational databases ``` set-azsecuritypricing -name OpenSourceRelationalDatabases -pricingtier Standard ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the subscription name. 1. Select the `Defender plans` blade. 1. Click `Select types >` in the row for `Databases`. 1. Ensure the toggle switch next to `Open-source relational databases` is set to `On`. **Audit from Azure CLI** Run the following command: ``` az security pricing show -n OpenSourceRelationalDatabases --query pricingTier ``` **Audit from PowerShell** ``` Get-AzSecurityPricing | Where-Object {$_.Name -eq 'OpenSourceRelationalDatabases'} | Select-Object Name, PricingTier ``` Ensure output for `Name PricingTier` is `OpenSourceRelationalDatabases Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [0a9fbe0d-c5c4-4da8-87d8-f4fd77338835](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F0a9fbe0d-c5c4-4da8-87d8-f4fd77338835) **- Name:** 'Azure Defender for open-source relational databases should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-2-monitor-anomalies-and-threats-targeting-sensitive-data:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.7.3", + "Description": "Ensure That Microsoft Defender for (Managed Instance) Azure SQL Databases Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_azure_sql_databases_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for (Managed Instance) Azure SQL Databases Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Azure SQL Databases allows for greater defense-in-depth, includes functionality for discovering and classifying sensitive data, surfacing and mitigating potential database vulnerabilities, and detecting anomalous activities that could indicate a threat to your database.", + "ImpactStatement": "Turning on Microsoft Defender for Azure SQL Databases incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Set the toggle switch next to `Azure SQL Databases` to `On`. 7. Select `Continue`. 8. Select `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n SqlServers --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name 'SqlServers' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Ensure the toggle switch next to `Azure SQL Databases` is set to `On`. **Audit from Azure CLI** Run the following command: ``` az security pricing show -n SqlServers ``` Ensure `-PricingTier` is set to `Standard` **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'SqlServers' | Select-Object Name,PricingTier ``` Ensure the `-PricingTier` is set to `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [7fe3b40f-802b-4cdd-8bd4-fd799c948cc2](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F7fe3b40f-802b-4cdd-8bd4-fd799c948cc2) **- Name:** 'Azure Defender for Azure SQL Database servers should be enabled' - **Policy ID:** [abfb7388-5bf4-4ad7-ba99-2cd2f41cebb9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fabfb7388-5bf4-4ad7-ba99-2cd2f41cebb9) **- Name:** 'Azure Defender for SQL should be enabled for unprotected SQL Managed Instances'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-2-monitor-anomalies-and-threats-targeting-sensitive-data:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.7.4", + "Description": "Ensure That Microsoft Defender for SQL Servers on Machines Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_sql_servers_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for SQL Servers on Machines Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for SQL servers on machines allows for greater defense-in-depth, functionality for discovering and classifying sensitive data, surfacing and mitigating potential database vulnerabilities, and detecting anomalous activities that could indicate a threat to your database.", + "ImpactStatement": "Turning on Microsoft Defender for SQL servers on machines incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Set the toggle switch next to `SQL servers on machines` to `On`. 7. Select `Continue`. 8. Select `Save`. **Remediate from Azure CLI** Run the following command: ``` az security pricing create -n SqlServerVirtualMachines --tier 'standard' ``` **Remediate from PowerShell** Run the following command: ``` Set-AzSecurityPricing -Name 'SqlServerVirtualMachines' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Click `Select types >` in the row for `Databases`. 6. Ensure the toggle switch next to `SQL servers on machines` is set to `On`. **Audit from Azure CLI** Ensure Defender for SQL is licensed with the following command: ``` az security pricing show -n SqlServerVirtualMachines ``` Ensure the 'PricingTier' is set to 'Standard' **Audit from PowerShell** Run the following command: ``` Get-AzSecurityPricing -Name 'SqlServerVirtualMachines' | Select-Object Name,PricingTier ``` Ensure the 'PricingTier' is set to 'Standard' **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [6581d072-105e-4418-827f-bd446d56421b](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F6581d072-105e-4418-827f-bd446d56421b) **- Name:** 'Azure Defender for SQL servers on machines should be enabled' - **Policy ID:** [abfb4388-5bf4-4ad7-ba82-2cd2f41ceae9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fabfb4388-5bf4-4ad7-ba82-2cd2f41ceae9) **- Name:** 'Azure Defender for SQL should be enabled for unprotected Azure SQL servers'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/defender-for-sql-usage:https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-2-monitor-anomalies-and-threats-targeting-sensitive-data:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.8.1", + "Description": "Ensure That Microsoft Defender for Key Vault Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_keyvault_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Key Vault Is Set To 'On'", + "RationaleStatement": "Enabling Microsoft Defender for Key Vault allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", + "ImpactStatement": "Turning on Microsoft Defender for Key Vault incurs an additional cost per resource.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Select `On` under `Status` for `Key Vault`. 6. Select `Save`. **Remediate from Azure CLI** Enable Standard pricing tier for Key Vault: ``` az security pricing create -n 'KeyVaults' --tier 'Standard' ``` **Remediate from PowerShell** Enable Standard pricing tier for Key Vault: ``` Set-AzSecurityPricing -Name 'KeyVaults' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Ensure `Status` is set to `On` for `Key Vault`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n 'KeyVaults' --query 'pricingTier' ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'KeyVaults' | Select-Object Name,PricingTier ``` Ensure output for `PricingTier` is `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [0e6763cc-5078-4e64-889d-ff4d9a839047](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F0e6763cc-5078-4e64-889d-ff4d9a839047) **- Name:** 'Azure Defender for Key Vault should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/update:https://docs.microsoft.com/en-us/powershell/module/az.security/get-azsecuritypricing:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender plan is off." + } + ] + }, + { + "Id": "8.1.9.1", + "Description": "Ensure That Microsoft Defender for Resource Manager Is Set To 'On'", + "Checks": [ + "defender_ensure_defender_for_arm_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure That Microsoft Defender for Resource Manager Is Set To 'On'", + "RationaleStatement": "Scanning resource requests lets you be alerted every time there is suspicious activity in order to prevent a security threat from being introduced.", + "ImpactStatement": "Enabling Microsoft Defender for Resource Manager requires enabling Microsoft Defender for your subscription. Both will incur additional charges.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Select `On` under `Status` for `Resource Manager`. 6. Select `Save. **Remediate from Azure CLI** Use the below command to enable Standard pricing tier for Defender for Resource Manager ``` az security pricing create -n 'Arm' --tier 'Standard' ``` **Remediate from PowerShell** Use the below command to enable Standard pricing tier for Defender for Resource Manager ``` Set-AzSecurityPricing -Name 'Arm' -PricingTier 'Standard' ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender for Cloud`. 2. Under `Management`, select `Environment Settings`. 3. Click on the subscription name. 4. Select the `Defender plans` blade. 5. Ensure `Status` is set to `On` for `Resource Manager`. **Audit from Azure CLI** Ensure the output of the below command is Standard ``` az security pricing show -n 'Arm' --query 'pricingTier' ``` **Audit from PowerShell** ``` Get-AzSecurityPricing -Name 'Arm' | Select-Object Name,PricingTier ``` Ensure the output of `PricingTier` is `Standard` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c3d20c29-b36d-48fe-808b-99a87530ad99](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc3d20c29-b36d-48fe-808b-99a87530ad99) **- Name:** 'Azure Defender for Resource Manager should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security:https://docs.microsoft.com/en-us/azure/defender-for-cloud/defender-for-resource-manager-introduction:https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/:https://docs.microsoft.com/en-us/azure/defender-for-cloud/alerts-overview:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities", + "DefaultValue": "By default, Microsoft Defender for Resource Manager is not enabled." + } + ] + }, + { + "Id": "8.1.10", + "Description": "Ensure that Microsoft Defender for Cloud is Configured to Check VM Operating Systems for Updates", + "Checks": [ + "defender_ensure_system_updates_are_applied" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Microsoft Defender for Cloud is Configured to Check VM Operating Systems for Updates", + "RationaleStatement": "Windows and Linux virtual machines should be kept updated to: - Address a specific bug or flaw - Improve an OS or applications general stability - Fix a security vulnerability Microsoft Defender for Cloud retrieves a list of available security and critical updates from Windows Update or Windows Server Update Services (WSUS), depending on which service is configured on a Windows VM. The security center also checks for the latest updates in Linux systems. If a VM is missing a system update, the security center will recommend system updates be applied.", + "ImpactStatement": "Running Microsoft Defender for Cloud incurs additional charges for each resource monitored. Please see attached reference for exact charges per hour.", + "RemediationProcedure": "Follow Microsoft Azure documentation to apply security patches from the security center. Alternatively, you can employ your own patch assessment and management tool to periodically assess, report, and install the required security patches for your OS.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Then the `Recommendations` blade 1. Ensure that there are no recommendations for `System updates should be installed on your machines (powered by Update Center)` Alternatively, you can employ your own patch assessment and management tool to periodically assess, report and install the required security patches for your OS. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [f85bf3e0-d513-442e-89c3-1784ad63382b](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ff85bf3e0-d513-442e-89c3-1784ad63382b) **- Name:** 'System updates should be installed on your machines (powered by Update Center)' - **Policy ID:** [bd876905-5b84-4f73-ab2d-2e7a7c4568d9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fbd876905-5b84-4f73-ab2d-2e7a7c4568d9) **- Name:** 'Machines should be configured to periodically check for missing system updates'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-posture-vulnerability-management#pv-6-rapidly-and-automatically-remediate-vulnerabilities:https://azure.microsoft.com/en-us/pricing/details/defender-for-cloud/:https://docs.microsoft.com/en-us/azure/defender-for-cloud/deploy-vulnerability-assessment-vm", + "DefaultValue": "By default, patches are not automatically deployed." + } + ] + }, + { + "Id": "8.1.11", + "Description": "Ensure that non-deprecated Microsoft Cloud Security Benchmark policies are not set to 'Disabled'", + "Checks": [ + "policy_ensure_asc_enforcement_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that non-deprecated Microsoft Cloud Security Benchmark policies are not set to 'Disabled'", + "RationaleStatement": "A security policy defines the desired configuration of resources in your environment and helps ensure compliance with company or regulatory security requirements. The MCSB Policy Initiative a set of security recommendations based on best practices and is associated with every subscription by default. When a policy Effect is set to `Audit`, policies in the MCSB ensure that Defender for Cloud evaluates relevant resources for supported recommendations. To ensure that policies within the MCSB are not being missed when the Policy Initiative is evaluated, none of the policies should have an Effect of `Disabled`.", + "ImpactStatement": "Policies within the MCSB default to an effect of `Audit` and will evaluate—but not enforce—policy recommendations. Ensuring these policies are set to `Audit` simply ensures that the evaluation occurs to allow administrators to understand where an improvement may be possible. Administrators will need to determine if the recommendations are relevant and desirable for their environment, then manually take action to resolve the status if desired.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Management Group or Subscription. 1. Click on `Security policies` in the left column. 1. Click on `Microsoft cloud security benchmark` 1. Click `Add Filter` and select `Effect` 1. Check the `Disabled` box to search for all disabled policies 1. Click `Apply` 1. Click the blue ellipsis `...` to the right of a policy name. 1. Click `Manage effect and parameters`. 1. Under `Policy effect`, select the radio button next to `Audit`. 1. Click `Save`. 1. Click `Refresh`. 1. Repeat steps 10-14 until all disabled policies are updated. 1. Repeat steps 1-15 for each Management Group or Subscription requiring remediation.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Management Group or Subscription. 1. Click on `Security policies` in the left column. 1. Click on `Microsoft cloud security benchmark`. 1. Click `Add filter` and select `Effect`. 1. Check the `Disabled` box to search for all disabled policies. 1. Click `Apply`. 1. Ensure that no policies are displayed, signifying that there are no disabled policies. 1. Repeat steps 1-10 for each Management Group or Subscription.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-in/azure/defender-for-cloud/security-policy-concept:https://docs.microsoft.com/en-us/azure/security-center/security-center-policies:https://learn.microsoft.com/en-us/azure/defender-for-cloud/implement-security-recommendations:https://learn.microsoft.com/en-us/rest/api/policy/policy-assignments/get:https://learn.microsoft.com/en-us/rest/api/policy/policy-assignments/create:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-7-define-and-implement-logging-threat-detection-and-incident-response-strategy", + "DefaultValue": "By default, the MCSB policy initiative is assigned on all subscriptions, and **most** policies will have an effect of `Audit`. Some policies will have a default effect of `Disabled`." + } + ] + }, + { + "Id": "8.1.12", + "Description": "Ensure That 'All users with the following roles' is Set to 'Owner'", + "Checks": [ + "defender_ensure_notify_emails_to_owners" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That 'All users with the following roles' is Set to 'Owner'", + "RationaleStatement": "Enabling security alert emails to subscription owners ensures that they receive security alert emails from Microsoft. This ensures that they are aware of any potential security issues and can mitigate the risk in a timely fashion.", + "ImpactStatement": "Owners will receive email notifications for security alerts.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Click on the appropriate Management Group, Subscription, or Workspace 1. Click on `Email notifications` 1. In the drop down of the `All users with the following roles` field select `Owner` 1. Click `Save` **Remediate from Azure CLI** Use the below command to set `Send email also to subscription owners` to `On`. ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts/default1?api-version=2017-08-01-preview -d@input.json' ``` Where `input.json` contains the data below, replacing `validEmailAddress` with a single email address or multiple comma-separated email addresses: ``` { id: /subscriptions//providers/Microsoft.Security/securityContacts/default1, name: default1, type: Microsoft.Security/securityContacts, properties: { email: , alertNotifications: On, alertsToAdmins: On, notificationsByRole: Owner } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu 1. Select `Microsoft Defender for Cloud` 1. Under `Management`, select `Environment Settings` 1. Click on the appropriate Management Group, Subscription, or Workspace 1. Click on `Email notifications` 1. Ensure that `All users with the following roles` is set to `Owner` **Audit from Azure CLI** Ensure the command below returns state of `On` and that `Owner` appears in roles. ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2020-01-01-preview'| jq '.[] | select(.name==default).properties.notificationsByRole' ```", + "AdditionalInformation": "Excluding any entries in the input.json properties block disables the specific setting by default.", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details:https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/security-contacts:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification", + "DefaultValue": "By default, `Owner` is selected" + } + ] + }, + { + "Id": "8.1.13", + "Description": "Ensure 'Additional email addresses' is Configured with a Security Contact Email", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Additional email addresses' is Configured with a Security Contact Email", + "RationaleStatement": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.", + "ImpactStatement": "Security contacts will receive email notifications.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the appropriate Management Group, Subscription, or Workspace. 1. Click on `Email notifications`. 1. Enter a valid security contact email address (or multiple addresses separated by commas) in the `Additional email addresses` field. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to set `Security contact emails` to `On`. ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts/default?api-version=2020-01-01-preview -d@input.json' ``` Where `input.json` contains the data below, replacing `validEmailAddress` with a single email address or multiple comma-separated email addresses: ``` { id: /subscriptions//providers/Microsoft.Security/securityContacts/default, name: default, type: Microsoft.Security/securityContacts, properties: { email: , alertNotifications: On, alertsToAdmins: On } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment Settings`. 1. Click on the appropriate Management Group, Subscription, or Workspace. 1. Click on `Email notifications`. 1. Ensure that a valid security contact email address is listed in the `Additional email addresses` field. **Audit from Azure CLI** Ensure the output of the below command is not empty and is set with appropriate email ids: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2020-01-01-preview' | jq '.|.[] | select(.name==default)'|jq '.properties.emails' ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [4f4f78b8-e367-4b10-a341-d9a4ad5cf1c7](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4f4f78b8-e367-4b10-a341-d9a4ad5cf1c7) **- Name:** 'Subscriptions should have a contact email address for security issues'", + "AdditionalInformation": "Excluding any entries in the input.json properties block disables the specific setting by default.", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details:https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list:https://docs.microsoft.com/en-us/rest/api/securitycenter/security-contacts:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification", + "DefaultValue": "By default, there are no additional email addresses entered." + } + ] + }, + { + "Id": "8.1.14", + "Description": "Ensure that 'Notify about alerts with the following severity (or higher)' is Enabled", + "Checks": [ + "defender_ensure_notify_alerts_severity_is_high" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Notify about alerts with the following severity (or higher)' is Enabled", + "RationaleStatement": "Enabling security alert emails ensures that security alert emails are sent by Microsoft. This ensures that the right people are aware of any potential security issues and can mitigate the risk.", + "ImpactStatement": "Enabling security alert emails can cause alert fatigue, increasing the risk of missing important alerts. Select an appropriate severity level to manage notifications. Azure aims to reduce alert fatigue by limiting the daily email volume per severity level. Learn more: https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications#email-frequency.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under `Notification types`, check box next to `Notify about alerts with the following severity (or higher)` and select an appropriate severity level from the drop-down menu. 1. Click `Save`. 1. Repeat steps 1-7 for each Subscription requiring remediation. **Remediate from Azure CLI** Use the below command to enable `Send email notification for high severity alerts`: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X PUT -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/<$0>/providers/Microsoft.Security/securityContacts/default1?api-version=2017-08-01-preview -d@input.json' ``` Where `input.json` contains the data below, replacing `validEmailAddress` with a single email address or multiple comma-separated email addresses: ``` { id: /subscriptions//providers/Microsoft.Security/securityContacts/default, name: default, type: Microsoft.Security/securityContacts, properties: { email: , alertNotifications: On, alertsToAdmins: On } } ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under `Notification types`, ensure that the box next to `Notify about alerts with the following severity (or higher)` is checked, and an appropriate severity level is selected. 1. Repeat steps 1-6 for each Subscription. **Audit from Azure CLI** Including a Subscription ID at the `$0` in `/subscriptions/$0/providers`, ensure the below command returns `state: On`, and that `minimalSeverity` is set to an appropriate severity level: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2020-01-01-preview' | jq '.|.[] | select(.name==default)'|jq '.properties.alertNotifications' ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [6e2593d9-add6-4083-9c9b-4b7d2188c899](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F6e2593d9-add6-4083-9c9b-4b7d2188c899) **- Name:** 'Email notification for high severity alerts should be enabled'", + "AdditionalInformation": "Excluding any entries in the `input.json` properties block disables the specific setting by default. This recommendation has been updated to reflect recent changes to Microsoft REST APIs for getting and updating security contact information.", + "References": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details:https://docs.microsoft.com/en-us/rest/api/securitycenter/security-contacts:https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-incident-response#ir-2-preparation---setup-incident-notification", + "DefaultValue": "By default, subscription owners receive email notifications for high-severity alerts." + } + ] + }, + { + "Id": "8.1.15", + "Description": "Ensure that 'Notify about attack paths with the following risk level (or higher)' is Enabled", + "Checks": [ + "defender_attack_path_notifications_properly_configured" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Notify about attack paths with the following risk level (or higher)' is Enabled", + "RationaleStatement": "Enabling attack path emails ensures that attack path emails are sent by Microsoft. This ensures that the right people are aware of any potential security issues and can mitigate the risk.", + "ImpactStatement": "Enabling attack path emails can cause alert fatigue, increasing the risk of missing important alerts. Select an appropriate risk level to manage notifications. Azure aims to reduce alert fatigue by limiting the daily email volume per risk level. Learn more: https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications#email-frequency.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under Notification types, check the box next to `Notify about attack paths with the following risk level (or higher)`, and select an appropriate risk level from the drop-down menu. 1. Repeat steps 1-6 for each Subscription.", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home select the Portal Menu. 1. Select `Microsoft Defender for Cloud`. 1. Under `Management`, select `Environment settings`. 1. Click on the appropriate Subscription. 1. Click on `Email notifications`. 1. Under Notification types, ensure that the box next to `Notify about attack paths with the following risk level (or higher)` is checked, and an appropriate risk level is selected. 1. Repeat steps 1-6 for each Subscription. **Audit from Azure CLI** Including a Subscription ID at the `$0` in `/subscriptions/$0/providers`, ensure the below command returns `sourceType: AttackPath`, and that `minimalRiskLevel` is set to an appropriate risk level: ``` az account get-access-token --query {subscription:subscription,accessToken:accessToken} --out tsv | xargs -L1 bash -c 'curl -X GET -H Authorization: Bearer $1 -H Content-Type: application/json https://management.azure.com/subscriptions/$0/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview' | jq '.|.[]' ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications:https://learn.microsoft.com/en-us/azure/defender-for-cloud/how-to-manage-attack-path:https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-attack-path", + "DefaultValue": "" + } + ], + "ConfigRequirements": [ + { + "Check": "defender_attack_path_notifications_properly_configured", + "ConfigKey": "defender_attack_path_minimal_risk_level", + "Operator": "in", + "Value": [ + "Low", + "Medium", + "High" + ] + } + ] + }, + { + "Id": "8.1.16", + "Description": "Ensure that Microsoft Defender External Attack Surface Monitoring (EASM) is Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.1 Microsoft Defender for Cloud", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Microsoft Defender External Attack Surface Monitoring (EASM) is Enabled", + "RationaleStatement": "This tool can monitor the externally exposed resources of an organization, provide valuable insights, and export these findings in a variety of formats (including CSV) for use in vulnerability management operations and red/purple team exercises.", + "ImpactStatement": "Microsoft Defender EASM workspaces are currently available as Azure Resources with a 30-day free trial period but can quickly accrue significant charges. The costs are calculated daily as (Number of billable inventory items) x (item cost per day; approximately: $0.017). Estimated cost is not provided within the tool, and users are strongly advised to contact their Microsoft sales representative for pricing and set a calendar reminder for the end of the trial period. For an EASM workspace having an Inventory of 5k-10k billable items (IP addresses, hostnames, SSL certificates, etc) a typical cost might be approximately $85-170 per day or $2500-5000 USD/month at the time of publication. If the workspace is deleted by the last day of a free trial period, no charges are billed.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Microsoft Defender EASM`. 1. Click `+ Create`. 1. Under `Project details`, select a subscription. 1. Select or create a resource group. 1. Under `Instance details`, enter a name for the workspace. 1. Select a region. 1. Click `Review + create`. 1. Click `Create`. 1. Once the deployment has completed, go to `Microsoft Defender EASM`. 1. Click the workspace name. 1. Configure the workspace appropriately for your environment and organization.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Microsoft Defender EASM`. 1. Ensure that at least one Microsoft Defender EASM workspace is listed. 1. Click the name of a workspace. 1. Ensure the workspace is configured appropriately for your environment and organization. 1. Repeat steps 3-4 for each workspace.", + "AdditionalInformation": "Microsoft added its Defender for External Attack Surface management (EASM) offering to Azure following its 2022 acquisition of EASM SaaS tool company RiskIQ.", + "References": "https://learn.microsoft.com/en-us/azure/external-attack-surface-management/:https://learn.microsoft.com/en-us/azure/external-attack-surface-management/deploying-the-defender-easm-azure-resource:https://www.microsoft.com/en-us/security/blog/2022/08/02/microsoft-announces-new-solutions-for-threat-intelligence-and-attack-surface-management/", + "DefaultValue": "Microsoft Defender EASM is an optional, paid Azure Resource that must be created and configured inside a Subscription and Resource Group." + } + ] + }, + { + "Id": "8.2.1", + "Description": "Ensure That Microsoft Defender for IoT Hub Is Set To 'On'", + "Checks": [ + "defender_ensure_iot_hub_defender_is_on" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.2 Microsoft Defender for IoT", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure That Microsoft Defender for IoT Hub Is Set To 'On'", + "RationaleStatement": "IoT devices are very rarely patched and can be potential attack vectors for enterprise networks. Updating their network configuration to use a central security hub allows for detection of these breaches.", + "ImpactStatement": "Enabling Microsoft Defender for IoT will incur additional charges dependent on the level of usage.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `IoT Hub`. 2. Select an `IoT Hub` to validate. 3. Select `Overview` in `Defender for IoT`. 4. Click on `Secure your IoT solution`, and complete the onboarding.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `IoT Hub`. 2. Select an `IoT Hub` to validate. 3. Select `Overview` in `Defender for IoT`. 4. The Threat prevention and Threat detection screen will appear, if `Defender for IoT` is Enabled.", + "AdditionalInformation": "There are additional configurations for Microsoft Defender for IoT that allow for types of deployments called hybrid or local. Both run on your physical infrastructure. These are complicated setups and are primarily outside of the scope of a purely Azure benchmark. Please see the references to consider these options for your organization.", + "References": "https://azure.microsoft.com/en-us/services/iot-defender/#overview:https://docs.microsoft.com/en-us/azure/defender-for-iot/:https://azure.microsoft.com/en-us/pricing/details/iot-defender/:https://docs.microsoft.com/en-us/security/benchmark/azure/baselines/defender-for-iot-security-baseline:https://docs.microsoft.com/en-us/cli/azure/iot?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-1-enable-threat-detection-capabilities:https://learn.microsoft.com/en-us/azure/defender-for-iot/device-builders/quickstart-onboard-iot-hub", + "DefaultValue": "By default, Microsoft Defender for IoT is not enabled." + } + ] + }, + { + "Id": "8.3.1", + "Description": "Ensure that the Expiration Date is Set for all Keys in Key Vaults using RBAC", + "Checks": [ + "keyvault_rbac_key_expiration_set" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is Set for all Keys in Key Vaults using RBAC", + "RationaleStatement": "Azure Key Vault enables users to store and use cryptographic keys within the Microsoft Azure environment. The `exp` (expiration date) attribute identifies the expiration date on or after which the key MUST NOT be used for encryption of new data, wrapping of new keys, and signing. By default, keys never expire. It is thus recommended that keys be rotated in the key vault and set an explicit expiration date for all keys to help enforce the key rotation. This ensures that the keys cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Keys cannot be used beyond their assigned expiration dates respectively. Keys need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that an appropriate `Expiration date` is set for any keys that are `Enabled`. **Remediate from Azure CLI** Update the `Expiration date` for the key using the below command: ``` az keyvault key set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` **Note:** To view the expiration date on all keys in a Key Vault using Microsoft API, the List Key permission is required. To update the expiration date for the keys: 1. Go to the Key vault, click on Access Control (IAM). 2. Click on Add role assignment and assign the role of Key Vault Crypto Officer to the appropriate user. **Remediate from PowerShell** ``` Set-AzKeyVaultKeyAttribute -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that an appropriate `Expiration date` is set for any keys that are `Enabled`. **Audit from Azure CLI** Get a list of all the key vaults in your Azure environment by running the following command: ``` az keyvault list ``` Then for each key vault listed ensure that the output of the below command contains Key ID (kid), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault key list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Azure Key vaults: ``` Get-AzKeyVault ``` For each Key vault run the following command to determine which vaults are configured to use RBAC. ``` Get-AzKeyVault -VaultName ``` For each Key vault with the `EnableRbacAuthorizatoin` setting set to `True`, run the following command. ``` Get-AzKeyVaultKey -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0) **- Name:** 'Key Vault keys should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultkeyattribute?view=azps-0.10.0", + "DefaultValue": "By default, keys do not expire." + } + ] + }, + { + "Id": "8.3.2", + "Description": "Ensure that the Expiration Date is set for All Keys in Key Vaults using access policies (legacy)", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is set for All Keys in Key Vaults using access policies (legacy)", + "RationaleStatement": "Azure Key Vault enables users to store and use cryptographic keys within the Microsoft Azure environment. The `exp` (expiration date) attribute identifies the expiration date on or after which the key MUST NOT be used for a cryptographic operation. By default, keys never expire. It is thus recommended that keys be rotated in the key vault and set an explicit expiration date for all keys. This ensures that the keys cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Keys cannot be used beyond their assigned expiration dates respectively. Keys need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that the status of the key is `Enabled`. 4. For each enabled key, ensure that an appropriate `Expiration date` is set. **Remediate from Azure CLI** Update the `Expiration date` for the key using the below command: ``` az keyvault key set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` **Note:** To view the expiration date on all keys in a Key Vault using Microsoft API, the List Key permission is required. To update the expiration date for the keys: 1. Go to Key vault, click on `Access policies`. 2. Click on `Create` and add an access policy with the `Update` permission (in the Key Permissions - Key Management Operations section). **Remediate from PowerShell** ``` Set-AzKeyVaultKeyAttribute -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Keys`. 3. In the main pane, ensure that the status of the key is `Enabled`. 4. For each enabled key, ensure that an appropriate `Expiration date` is set. **Audit from Azure CLI** Get a list of all the key vaults in your Azure environment by running the following command: ``` az keyvault list ``` For each key vault, ensure that the output of the below command contains Key ID (kid), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault key list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Azure Key vaults: ``` Get-AzKeyVault ``` For each Key vault, run the following command to determine which vaults are configured to not use RBAC: ``` Get-AzKeyVault -VaultName ``` For each Key vault with the `EnableRbacAuthorizatoin` setting set to `False` or empty, run the following command. ``` Get-AzKeyVaultKey -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0) **- Name:** 'Key Vault keys should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultkeyattribute?view=azps-0.10.0", + "DefaultValue": "By default, keys do not expire." + } + ] + }, + { + "Id": "8.3.3", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using RBAC", + "Checks": [ + "keyvault_rbac_secret_expiration_set" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using RBAC", + "RationaleStatement": "The Azure Key Vault enables users to store and keep secrets within the Microsoft Azure environment. Secrets in the Azure Key Vault are octet sequences with a maximum size of 25k bytes each. The `exp` (expiration date) attribute identifies the expiration date on or after which the secret MUST NOT be used. By default, secrets never expire. It is thus recommended to rotate secrets in the key vault and set an explicit expiration date for all secrets. This ensures that the secrets cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Secrets cannot be used beyond their assigned expiry date respectively. Secrets need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. For each enabled secret, ensure that an appropriate `Expiration date` is set. **Remediate from Azure CLI** Update the Expiration date for the secret using the below command: ``` az keyvault secret set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` Note: To view the expiration date on all secrets in a Key Vault using Microsoft API, the `List Secret` permission is required. To update the expiration date for the secrets: 1. Go to the Key vault, click on `Access Control (IAM)`. 2. Click on `Add role assignment` and assign the role of `Key Vault Secrets Officer` to the appropriate user. **Remediate from PowerShell** ``` Set-AzKeyVaultSecretAttribute -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. For each enabled secret, ensure that an appropriate `Expiration date` is set. **Audit from Azure CLI** Ensure that the output of the below command contains ID (id), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault secret list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Key vaults: ``` Get-AzKeyVault ``` For each Key vault, run the following command to determine which vaults are configured to use RBAC: ``` Get-AzKeyVault -VaultName ``` For each Key vault with the `EnableRbacAuthorization` setting set to `True`, run the following command: ``` Get-AzKeyVaultSecret -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [98728c90-32c7-4049-8429-847dc0f4fe37](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F98728c90-32c7-4049-8429-847dc0f4fe37) **- Name:** 'Key Vault secrets should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-secrets:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultsecretattribute?view=azps-0.10.0", + "DefaultValue": "By default, secrets do not expire." + } + ] + }, + { + "Id": "8.3.4", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using access policies (legacy)", + "Checks": [ + "keyvault_non_rbac_secret_expiration_set" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Expiration Date is set for All Secrets in Key Vaults using access policies (legacy)", + "RationaleStatement": "The Azure Key Vault enables users to store and keep secrets within the Microsoft Azure environment. Secrets in the Azure Key Vault are octet sequences with a maximum size of 25k bytes each. The `exp` (expiration date) attribute identifies the expiration date on or after which the secret MUST NOT be used. By default, secrets never expire. It is thus recommended to rotate secrets in the key vault and set an explicit expiration date for all secrets. This ensures that the secrets cannot be used beyond their assigned lifetimes.", + "ImpactStatement": "Secrets cannot be used beyond their assigned expiry date respectively. Secrets need to be rotated periodically wherever they are used.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. Set an appropriate `Expiration date` on all secrets. **Remediate from Azure CLI** Update the `Expiration date` for the secret using the below command: ``` az keyvault secret set-attributes --name --vault-name --expires Y-m-d'T'H:M:S'Z' ``` Note: To view the expiration date on all secrets in a Key Vault using Microsoft API, the `List` Secret permission is required. To update the expiration date for the secrets: 1. Go to Key vault, click on `Access policies`. 2. Click on `Create` and add an access policy with the `Update` permission (in the Secret Permissions - Secret Management Operations section). **Remediate from PowerShell** For each Key vault with the `EnableRbacAuthorization` setting set to `False` or empty, run the following command. ``` Set-AzKeyVaultSecret -VaultName -Name -Expires ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. For each Key vault, click on `Secrets`. 3. In the main pane, ensure that the status of the secret is `Enabled`. 4. Set an appropriate `Expiration date` on all secrets. **Audit from Azure CLI** Get a list of all the key vaults in your Azure environment by running the following command: ``` az keyvault list ``` For each key vault, ensure that the output of the below command contains ID (id), enabled status as `true` and Expiration date (expires) is not empty or null: ``` az keyvault secret list --vault-name --query '[*].{kid:kid,enabled:attributes.enabled,expires:attributes.expires}' ``` **Audit from PowerShell** Retrieve a list of Key vaults: ``` Get-AzKeyVault ``` For each Key vault run the following command to determine which vaults are configured to use RBAC: ``` Get-AzKeyVault -VaultName ``` For each Key Vault with the `EnableRbacAuthorization` setting set to `False` or empty, run the following command. ``` Get-AzKeyVaultSecret -VaultName ``` Make sure the `Expires` setting is configured with a value as appropriate wherever the `Enabled` setting is set to `True`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [98728c90-32c7-4049-8429-847dc0f4fe37](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F98728c90-32c7-4049-8429-847dc0f4fe37) **- Name:** 'Key Vault secrets should have an expiration date'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis:https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-secrets:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultsecret?view=azps-7.4.0", + "DefaultValue": "By default, secrets do not expire." + } + ] + }, + { + "Id": "8.3.5", + "Description": "Ensure 'Purge protection' is Set to 'Enabled'", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Purge protection' is Set to 'Enabled'", + "RationaleStatement": "Users may accidentally run delete/purge commands on a key vault, or an attacker or malicious user may do so deliberately in order to cause disruption. Deleting or purging a key vault leads to immediate data loss, as keys encrypting data and secrets/certificates allowing access/services will become inaccessible. Enabling purge protection ensures that even if a key vault is deleted, the key vault and its objects remain recoverable during the configurable retention period. If no action is taken, the key vault and its objects will be purged once the retention period elapses.", + "ImpactStatement": "Once purge protection is enabled for a key vault, it cannot be disabled.", + "RemediationProcedure": "**Note:** Once enabled, purge protection cannot be disabled. **Remediate from Azure Portal** 1. Go to `Key Vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Properties`. 4. Select the radio button next to `Enable purge protection (enforce a mandatory retention period for deleted vaults and vault objects)`. 5. Click `Save`. 6. Repeat steps 1-5 for each key vault requiring remediation. **Remediate from Azure CLI** For each key vault requiring remediation, run the following command to enable purge protection: ``` az resource update --resource-group --name --resource-type \"Microsoft.KeyVault/vaults\" --set properties.enablePurgeProtection=true ``` **Remediate from PowerShell** For each key vault requiring remediation, run the following command to enable purge protection: ``` Update-AzKeyVault -ResourceGroupName -VaultName -EnablePurgeProtection ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key Vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Properties`. 4. Next to `Purge protection`, ensure that `Enable purge protection (enforce a mandatory retention period for deleted vaults and vault objects)` is selected. 5. Repeat steps 1-4 for each key vault. **Audit from Azure CLI** Run the following command to list key vaults: ``` az resource list --query \"[?type=='Microsoft.KeyVault/vaults']\" ``` For each key vault, run the following command to get the purge protection setting: ``` az resource show --resource-group --name --resource-type \"Microsoft.KeyVault/vaults\" --query properties.enablePurgeProtection ``` Ensure that `true` is returned. **Audit from PowerShell** Run the following command to list key vaults: ``` Get-AzKeyVault ``` For each key vault, run the following command to get the key vault details: ``` Get-AzKeyVault -ResourceGroupName -VaultName ``` Ensure `Purge Protection Enabled?` is set to `True`. **Audit from Azure Policy** - **Policy ID:** 0b60c0b2-2dc2-4e1c-b5c9-abbed971de53 - **Name:** 'Key vaults should have deletion protection enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/key-vault/general/key-vault-recovery:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-8-define-and-implement-backup-and-recovery-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository", + "DefaultValue": "Purge protection is disabled by default." + } + ] + }, + { + "Id": "8.3.6", + "Description": "Ensure that Role Based Access Control for Azure Key Vault is Enabled", + "Checks": [ + "keyvault_rbac_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure that Role Based Access Control for Azure Key Vault is Enabled", + "RationaleStatement": "The new RBAC permissions model for Key Vaults enables a much finer grained access control for key vault secrets, keys, certificates, etc., than the vault access policy. This in turn will permit the use of privileged identity management over these roles, thus securing the key vaults with JIT Access management.", + "ImpactStatement": "Implementation needs to be properly designed from the ground up, as this is a fundamental change to the way key vaults are accessed/managed. Changing permissions to key vaults will result in loss of service as permissions are re-applied. For the least amount of downtime, map your current groups and users to their corresponding permission needs.", + "RemediationProcedure": "**Remediate from Azure Portal** Key Vaults can be configured to use `Azure role-based access control` on creation. For existing Key Vaults: 1. From Azure Home open the Portal Menu in the top left corner 2. Select `Key Vaults` 3. Select a Key Vault to audit 4. Select `Access configuration` 5. Set the Permission model radio button to `Azure role-based access control`, taking note of the warning message 6. Click `Save` 7. Select `Access Control (IAM)` 8. Select the `Role Assignments` tab 9. Reapply permissions as needed to groups or users **Remediate from Azure CLI** To enable RBAC Authorization for each Key Vault, run the following Azure CLI command: ``` az keyvault update --resource-group --name --enable-rbac-authorization true ``` **Remediate from PowerShell** To enable RBAC authorization on each Key Vault, run the following PowerShell command: ``` Update-AzKeyVault -ResourceGroupName -VaultName -EnableRbacAuthorization $True ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home open the Portal Menu in the top left corner 2. Select Key Vaults 3. Select a Key Vault to audit 4. Select Access configuration 5. Ensure the Permission Model radio button is set to `Azure role-based access control` **Audit from Azure CLI** Run the following command for each Key Vault in each Resource Group: ``` az keyvault show --resource-group --name ``` Ensure the `enableRbacAuthorization` setting is set to `true` within the output of the above command. **Audit from PowerShell** Run the following PowerShell command: ``` Get-AzKeyVault -Vaultname -ResourceGroupName ``` Ensure the `Enabled For RBAC Authorization` setting is set to `True` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5) **- Name:** 'Azure Key Vault should use RBAC permission model'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-gb/azure/key-vault/general/rbac-migration#vault-access-policy-to-azure-rbac-migration-steps:https://docs.microsoft.com/en-gb/azure/role-based-access-control/role-assignments-portal?tabs=current:https://docs.microsoft.com/en-gb/azure/role-based-access-control/overview:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository", + "DefaultValue": "The default value for Access control in Key Vaults is Vault Policy." + } + ] + }, + { + "Id": "8.3.7", + "Description": "Ensure Public Network Access is Disabled", + "Checks": [ + "keyvault_private_endpoints" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Public Network Access is Disabled", + "RationaleStatement": "Disabling public network access improves security by ensuring that a service is not exposed on the public internet. Removing a point of interconnection from the internet edge to your key vault can strengthen the network security boundary of your system and reduce the risk of exposing the control plane or vault objects to untrusted clients. Although Azure resources are never truly isolated from the public internet, disabling the public endpoint removes a line of sight from the public internet and increases the effort required for an attack.", + "ImpactStatement": "NOTE: Prior to disabling public network access, it is strongly recommended that, for each key vault, either: virtual network integration is completed OR private endpoints/links are set up as described in 'Ensure Private Endpoints are used to access Azure Key Vault.' Disabling public network access restricts access to the service. This enhances security but will require the configuration of a virtual network and/or private endpoints for any services or users needing access within trusted networks.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Networking`. 4. Under `Firewalls and virtual networks`, next to `Allow access from:`, click the radio button next to `Disable public access`. 5. Click `Apply`. 6. Repeat steps 1-5 for each key vault requiring remediation. **Remediate from Azure CLI** For each key vault requiring remediation, run the following command to disable public network access: ``` az keyvault update --resource-group --name --public-network-access Disabled ``` **Remediate from PowerShell** For each key vault requiring remediation, run the following command to disable public network access: ``` Update-AzKeyVault -ResourceGroupName -VaultName -PublicNetworkAccess \"Disabled\" ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Settings`, click `Networking`. 4. Under `Firewalls and virtual networks`, ensure that `Allow access from:` is set to `Disable public access`. 5. Repeat steps 1-4 for each key vault. **Audit from Azure CLI** Run the following command to list key vaults: ``` az keyvault list ``` For each key vault, run the following command to get the public network access setting: ``` az keyvault show --resource-group --name --query properties.publicNetworkAccess ``` Ensure that `Disabled` is returned. **Audit from PowerShell** Run the following command to list key vaults: ``` Get-AzKeyVault ``` Run the following command to get the key vault in a resource group with a given name: ``` $vault = Get-AzKeyVault -ResourceGroupName -Name ``` Run the following command to get the public network access setting for the key vault: ``` $vault.PublicNetworkAccess ``` Ensure that `Disabled` is returned. Repeat for each key vault. **Audit from Azure Policy** - **Policy ID:** 405c5871-3e91-4644-8a63-58e19d68ff5b - **Name:** 'Azure Key Vault should disable public network access'", + "AdditionalInformation": "This Common Reference Recommendation is referenced in the following Service Recommendations: - Storage Services > Storage Accounts > Networking > **Ensure that 'Public Network Access' is 'Disabled' for storage accounts**", + "References": "https://learn.microsoft.com/en-us/azure/key-vault/general/network-security:https://learn.microsoft.com/en-us/azure/key-vault/general/private-link-service", + "DefaultValue": "Public network access is enabled by default." + } + ] + }, + { + "Id": "8.3.8", + "Description": "Ensure Private Endpoints are Used to Access Azure Key Vault", + "Checks": [ + "keyvault_private_endpoints" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Private Endpoints are Used to Access Azure Key Vault", + "RationaleStatement": "Private endpoints will keep network requests to Azure Key Vault limited to the endpoints attached to the resources that are whitelisted to communicate with each other. Assigning the Key Vault to a network without an endpoint will allow other resources on that network to view all traffic from the Key Vault to its destination. In spite of the complexity in configuration, this is recommended for high security secrets.", + "ImpactStatement": "Incorrect or poorly-timed changing of network configuration could result in service interruption. There are also additional costs tiers for running a private endpoint per petabyte or more of networking traffic.", + "RemediationProcedure": "**Please see the additional information about the requirements needed before starting this remediation procedure.** **Remediate from Azure Portal** 1. From Azure Home open the Portal Menu in the top left. 2. Select Key Vaults. 3. Select a Key Vault to audit. 4. Select `Networking` in the left column. 5. Select `Private endpoint connections` from the top row. 6. Select `+ Create`. 7. Select the subscription the Key Vault is within, and other desired configuration. 8. Select `Next`. 9. For resource type select `Microsoft.KeyVault/vaults`. 10. Select the Key Vault to associate the Private Endpoint with. 11. Select `Next`. 12. In the `Virtual Networking` field, select the network to assign the Endpoint. 13. Select other configuration options as desired, including an existing or new application security group. 14. Select `Next`. 15. Select the private DNS the Private Endpoints will use. 16. Select `Next`. 17. Optionally add `Tags`. 18. Select `Next : Review + Create`. 19. Review the information and select `Create`. Follow the Audit Procedure to determine if it has successfully applied. 20. Repeat steps 3-19 for each Key Vault. **Remediate from Azure CLI** 1. To create an endpoint, run the following command: ``` az network private-endpoint create --resource-group --subnet --name --private-connection-resource-id /subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults/ --group-ids vault --connection-name --location --manual-request ``` 2. To manually approve the endpoint request, run the following command: ``` az keyvault private-endpoint-connection approve --resource-group --vault-name –name ``` 3. Determine the Private Endpoint's IP address to connect the Key Vault to the Private DNS you have previously created: 4. Look for the property networkInterfaces then id; the value must be placed in the variable within step 7. ``` az network private-endpoint show -g -n ``` 5. Look for the property networkInterfaces then id; the value must be placed on in step 7. ``` az network nic show --ids ``` 6. Create a Private DNS record within the DNS Zone you created for the Private Endpoint: ``` az network private-dns record-set a add-record -g -z privatelink.vaultcore.azure.net -n -a ``` 7. nslookup the private endpoint to determine if the DNS record is correct: ``` nslookup .vault.azure.net nslookup .privatelink.vaultcore.azure.n ```", + "AuditProcedure": "**Audit from Azure Portal** 1. From Azure Home open the Portal Menu in the top left. 2. Select Key Vaults. 3. Select a Key Vault to audit. 4. Select `Networking` in the left column. 5. Select `Private endpoint connections` from the top row. 6. View if there is an endpoint attached. **Audit from Azure CLI** Run the following command within a subscription for each Key Vault you wish to audit. ``` az keyvault show --name ``` Ensure that `privateEndpointConnections` is not `null`. **Audit from PowerShell** Run the following command within a subscription for each Key Vault you wish to audit. ``` Get-AzPrivateEndpointConnection -PrivateLinkResourceId '/subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults//' ``` Ensure that the response contains details of a private endpoint. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [a6abeaec-4d90-4a02-805f-6b26c4d3fbe9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fa6abeaec-4d90-4a02-805f-6b26c4d3fbe9) **- Name:** 'Azure Key Vaults should use private link'", + "AdditionalInformation": "This recommendation assumes that you have created a Resource Group containing a Virtual Network that the services are already associated with and configured private DNS. A Bastion on the virtual network is also required, and the service to which you are connecting must already have a Private Endpoint. For information concerning the installation of these services, please see the attached documentation. Microsoft's own documentation lists the requirements as: A Key Vault. An Azure virtual network. A subnet in the virtual network. Owner or contributor permissions for both the Key Vault and the virtual network.", + "References": "https://docs.microsoft.com/en-us/azure/private-link/private-endpoint-overview:https://docs.microsoft.com/en-us/azure/storage/common/storage-private-endpoints:https://azure.microsoft.com/en-us/pricing/details/private-link/:https://docs.microsoft.com/en-us/azure/key-vault/general/private-link-service?tabs=portal:https://docs.microsoft.com/en-us/azure/virtual-network/quick-create-portal:https://docs.microsoft.com/en-us/azure/private-link/tutorial-private-endpoint-storage-portal:https://docs.microsoft.com/en-us/azure/bastion/bastion-overview:https://docs.microsoft.com/azure/dns/private-dns-getstarted-cli#create-an-additional-dns-record:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-8-ensure-security-of-key-and-certificate-repository", + "DefaultValue": "By default, Private Endpoints are not enabled for any services within Azure." + } + ] + }, + { + "Id": "8.3.9", + "Description": "Ensure Automatic Key Rotation is Enabled within Azure Key Vault", + "Checks": [ + "keyvault_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Automatic Key Rotation is Enabled within Azure Key Vault", + "RationaleStatement": "Automatic key rotation reduces risk by ensuring that keys are rotated without manual intervention. Azure and NIST recommend that keys be rotated every two years or less. Refer to 'Table 1: Suggested cryptoperiods for key types' on page 46 of the following document for more information: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf.", + "ImpactStatement": "There is an additional cost for each scheduled key rotation.", + "RemediationProcedure": "**Note:** Azure CLI and PowerShell use the ISO8601 duration format for time spans. The format is `P(Y,M,D)`. The leading P is required and is referred to as `period`. The `(Y,M,D)` are for the duration of Year, Month, and Day, respectively. A time frame of 2 years, 2 months, 2 days would be `P2Y2M2D`. For Azure CLI and PowerShell, it is easiest to supply the policy flags in a `.json file`, for example: ``` { lifetimeActions: [ { trigger: { timeAfterCreate: P(Y,M,D), timeBeforeExpiry : null }, action: { type: Rotate } }, { trigger: { timeBeforeExpiry : P(Y,M,D) }, action: { type: Notify } } ], attributes: { expiryTime: P(Y,M,D) } } ``` **Remediate from Azure Portal** 1. Go to `Key Vaults`. 1. Select a Key Vault. 1. Under `Objects`, select `Keys`. 1. Select a key. 1. From the top row, select `Rotation policy`. 1. Select an appropriate `Expiry time`. 1. Set `Enable auto rotation` to `Enabled`. 1. Set an appropriate `Rotation option` and `Rotation time`. 1. Optionally, set a `Notification time`. 1. Click `Save`. 1. Repeat steps 1-10 for each Key Vault and Key. **Remediate from Azure CLI** Run the following command for each key to enable automatic rotation: ``` az keyvault key rotation-policy update --vault-name --name --value ``` **Remediate from PowerShell** Run the following command for each key to enable automatic rotation: ``` Set-AzKeyVaultKeyRotationPolicy -VaultName -Name -PolicyPath ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key Vaults`. 1. Select a Key Vault. 1. Under `Objects`, select `Keys`. 1. Select a key. 1. From the top row, select `Rotation policy`. 1. Ensure `Enable auto rotation` is set to `Enabled`. 1. Ensure the `Rotation time` is set to an appropriate value. 1. Repeat steps 1-7 for each Key Vault and Key. **Audit from Azure CLI** Run the following command: ``` az keyvault key rotation-policy show --vault-name --name ``` Ensure that the response contains a `lifetimeAction` of `Rotate` and that `timeAfterCreate` is set to an appropriate value. **Audit from PowerShell** Run the following command: ``` Get-AzKeyVaultKeyRotationPolicy -VaultName -Name ``` Ensure that the response contains a `LifetimeAction` of `Rotate` and that `TimeAfterCreate` is set to an appropriate value. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [d8cf8476-a2ec-4916-896e-992351803c44](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fd8cf8476-a2ec-4916-896e-992351803c44) **- Name:** 'Keys should have a rotation policy ensuring that their rotation is scheduled within the specified number of days after creation.'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/key-vault/keys/how-to-configure-key-rotation:https://docs.microsoft.com/en-us/azure/storage/common/customer-managed-keys-overview#update-the-key-version:https://docs.microsoft.com/en-us/azure/virtual-machines/windows/disks-enable-customer-managed-keys-powershell#set-up-an-azure-key-vault-and-diskencryptionset-optionally-with-automatic-key-rotation:https://azure.microsoft.com/en-us/updates/public-preview-automatic-key-rotation-of-customermanaged-keys-for-encrypting-azure-managed-disks/:https://docs.microsoft.com/en-us/cli/azure/keyvault/key/rotation-policy?view=azure-cli-latest#az-keyvault-key-rotation-policy-update:https://docs.microsoft.com/en-us/powershell/module/az.keyvault/set-azkeyvaultkeyrotationpolicy?view=azps-8.1.0:https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/scalar-data-types/timespan:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-6-use-a-secure-key-management-process:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf", + "DefaultValue": "By default, automatic key rotation is not enabled." + } + ] + }, + { + "Id": "8.3.10", + "Description": "Ensure that Azure Key Vault Managed HSM is Used when Required", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that Azure Key Vault Managed HSM is Used when Required", + "RationaleStatement": "Managed HSM is a fully managed, highly available, single-tenant service that ensures FIPS 140-2 Level 3 compliance. It provides centralized key management, isolated access control, and private endpoints for secure access. Integrated with Azure services, it supports migration from Key Vault, ensures data residency, and offers monitoring and auditing for enhanced security.", + "ImpactStatement": "Managed HSM incurs a cost of $0.40 to $5 per month for each actively used HSM-protected key, depending on the key type and quantity. Each key version is billed separately. Additionally, there is an hourly usage fee of $3.20 per Managed HSM pool.", + "RemediationProcedure": "**Remediate from Azure CLI** Run the following command to set `oid` to be the `OID` of the signed-in user: ``` $oid = az ad signed-in-user show --query id -o tsv ``` Alternatively, prepare a space-separated list of OIDs to be provided as the `administrators` of the HSM. Run the following command to create a Managed HSM: ``` az keyvault create --resource-group --hsm-name --retention-days --administrators $oid ``` The command can take several minutes to complete. After the HSM has been created, it must be activated before it can be used. Activation requires providing a minimum of three and a maximum of ten RSA key pairs, as well as the minimum number of keys required to decrypt the security domain (called a quorum). OpenSSL can be used to generate the self-signed certificates, for example: ``` openssl req -newkey rsa:2048 -nodes -keyout cert_1.key -x509 -days 365 -out cert_1.cer ``` Run the following command to download the security domain and activate the Managed HSM: ``` az keyvault security-domain download --hsm-name --sd-wrapping-keys --sd-quorum --security-domain-file .json ``` Store the security domain file and the RSA key pairs securely. They will be required for disaster recovery or for creating another Managed HSM that shares the same security domain so that the two can share keys. The Managed HSM will now be in an active state and ready for use.", + "AuditProcedure": "**Audit from Azure CLI** Run the following command to list key vaults: ``` az keyvault list --query [*].[name,type] ``` Ensure that at least one key vault with type `Microsoft.KeyVault/managedHSMs` exists.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/security/fundamentals/key-management-choose:https://learn.microsoft.com/en-us/azure/key-vault/managed-hsm/overview:https://azure.microsoft.com/en-gb/pricing/details/key-vault/:https://learn.microsoft.com/en-us/azure/key-vault/managed-hsm/quick-create-cli:https://learn.microsoft.com/en-us/cli/azure/keyvault", + "DefaultValue": "" + } + ] + }, + { + "Id": "8.3.11", + "Description": "Ensure Certificate 'Validity Period (in months)' is Less Than or Equal to '12'", + "Checks": [], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.3 Key Vault", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Certificate 'Validity Period (in months)' is Less Than or Equal to '12'", + "RationaleStatement": "Limiting certificate validity reduces the risk of misuse if compromised and helps ensure timely renewal, improving security and reliability.", + "ImpactStatement": "Minor administrative effort required to ensure certificate renewal and lifecycle management.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Objects`, click `Certificates`. 4. Click the name of a certificate. 5. Click `Issuance Policy`. 6. Set `Validity Period (in months)` to an integer between 1 and 12, inclusive. 7. Click `Save`. 8. Repeat steps 1-7 for each key vault and certificate requiring remediation. **Remediate from PowerShell** For each certificate requiring remediation, run the following command to set ValidityInMonths to an integer between 1 and 12, inclusive: ``` Set-AzKeyVaultCertificatePolicy -VaultName $vault.VaultName -Name -ValidityInMonths ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Key vaults`. 2. Click the name of a key vault. 3. Under `Objects`, click `Certificates`. 4. Click the name of a certificate. 5. Click `Issuance Policy`. 6. Ensure that `Validity Period (in months)` is set to 12 or less. 7. Repeat steps 1-6 for each key vault and certificate. **Audit from Azure CLI** Run the following command to list key vaults: ``` az keyvault list ``` For each key vault, run the following command to list certificates: ``` az keyvault certificate list --vault-name ``` For each certificate, run the following command to get the certificate policy's validityInMonths setting: ``` az keyvault certificate show --id --query policy.x509CertificateProperties.validityInMonths ``` Ensure that 12 or less is returned. **Audit from PowerShell** Run the following command to list key vaults: ``` Get-AzKeyVault ``` Run the following command to get the key vault with a given name: ``` $vault = Get-AzKeyVault -Name ``` Run the following command to list certificates in the key vault: ``` Get-AzKeyVaultCertificate -VaultName $vault.VaultName ``` Run the following command to get the policy of a certificate with a given name: ``` $certificate = Get-AzKeyVaultCertificatePolicy -VaultName $vault.VaultName -Name ``` Run the following command to get the certificate policy's ValidityInMonths setting: ``` $certificate.ValidityInMonths ``` Ensure that 12 or less is returned. Repeat for each key vault and certificate. **Audit from Azure Policy** - **Policy ID:** 0a075868-4c26-42ef-914c-5bc007359560 - **Name:** 'Certificates should have the specified maximum validity period'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/key-vault/certificates/about-certificates:https://learn.microsoft.com/en-us/cli/azure/keyvault:https://learn.microsoft.com/en-us/powershell/module/az.keyvault", + "DefaultValue": "Validity Period (in months) is set to 12 by default." + } + ] + }, + { + "Id": "8.4.1", + "Description": "Ensure an Azure Bastion Host Exists", + "Checks": [ + "network_bastion_host_exists" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "SubSection": "8.4 Azure Bastion", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure an Azure Bastion Host Exists", + "RationaleStatement": "The Azure Bastion service allows organizations a more secure means of accessing Azure Virtual Machines over the Internet without assigning public IP addresses to those Virtual Machines. The Azure Bastion service provides Remote Desktop Protocol (RDP) and Secure Shell (SSH) access to Virtual Machines using TLS within a web browser, thus preventing organizations from opening up 3389/TCP and 22/TCP to the Internet on Azure Virtual Machines. Additional benefits of the Bastion service includes Multi-Factor Authentication, Conditional Access Policies, and any other hardening measures configured within Azure Active Directory using a central point of access.", + "ImpactStatement": "The Azure Bastion service incurs additional costs and requires a specific virtual network configuration. The `Standard` tier offers additional configuration options compared to the `Basic` tier and may incur additional costs for those added features.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Click on `Bastions` 2. Select the `Subscription` 3. Select the `Resource group` 4. Type a `Name` for the new Bastion host 5. Select a `Region` 6. Choose `Standard` next to `Tier` 7. Use the slider to set the `Instance count` 8. Select the `Virtual network` or `Create new` 9. Select the `Subnet` named `AzureBastionSubnet`. Create a `Subnet` named `AzureBastionSubnet` using a `/26` CIDR range if it doesn't already exist. 10. Select the appropriate `Public IP address` option. 11. If `Create new` is selected for the `Public IP address` option, provide a `Public IP address name`. 12. If `Use existing` is selected for `Public IP address` option, select an IP address from `Choose public IP address` 13. Click `Next: Tags >` 14. Configure the appropriate `Tags` 15. Click `Next: Advanced >` 16. Select the appropriate `Advanced` options 17. Click `Next: Review + create >` 18. Click `Create` **Remediate from Azure CLI** ``` az network bastion create --location --name --public-ip-address --resource-group --vnet-name --scale-units --sku Standard [--disable-copy-paste true|false] [--enable-ip-connect true|false] [--enable-tunneling true|false] ``` **Remediate from PowerShell** Create the appropriate `Virtual network` settings and `Public IP Address` settings. ``` $subnetName = AzureBastionSubnet $subnet = New-AzVirtualNetworkSubnetConfig -Name $subnetName -AddressPrefix $virtualNet = New-AzVirtualNetwork -Name -ResourceGroupName -Location -AddressPrefix -Subnet $subnet $publicip = New-AzPublicIpAddress -ResourceGroupName -Name -Location -AllocationMethod Dynamic -Sku Standard ``` Create the `Azure Bastion` service using the information within the created variables from above. ``` New-AzBastion -ResourceGroupName -Name -PublicIpAddress $publicip -VirtualNetwork $virtualNet -Sku Standard -ScaleUnit ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Click on `Bastions` 2. Ensure there is at least one `Bastion` host listed under the `Name` column **Audit from Azure CLI** **Note:** The Azure CLI `network bastion` module is in `Preview` as of this writing ``` az network bastion list --subscription ``` Ensure the output of the above command is not empty. **Audit from PowerShell** Retrieve the `Bastion` host(s) information for a specific `Resource Group` ``` Get-AzBastion -ResourceGroupName ``` Ensure the output of the above command is not empty.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/bastion/bastion-overview#sku:https://learn.microsoft.com/en-us/powershell/module/az.network/get-azbastion?view=azps-9.2.0:https://learn.microsoft.com/en-us/cli/azure/network/bastion?view=azure-cli-latest", + "DefaultValue": "By default, the Azure Bastion service is not configured." + } + ] + }, + { + "Id": "8.5", + "Description": "Ensure Azure DDoS Network Protection is Enabled on Virtual Networks", + "Checks": [ + "network_vnet_ddos_protection_enabled" + ], + "Attributes": [ + { + "Section": "8 Security Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Azure DDoS Network Protection is Enabled on Virtual Networks", + "RationaleStatement": "Virtual networks and resources are protected against attacks, helping to ensure reliability and availability for critical workloads.", + "ImpactStatement": "Azure DDoS Network Protection incurs a significant fixed monthly charge, with additional charges if more than 100 public IP resources are protected. Careful consideration and analysis should be applied before enabling DDoS protection. Refer to https://azure.microsoft.com/en-us/pricing/details/ddos-protection for detailed pricing information.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `DDoS protection`. 4. Next to `DDoS Network Protection`, click `Enable`. 5. Provide a DDoS protection plan resource ID, or select a DDoS protection plan from the drop-down menu. 6. Click `Save`. 7. Repeat steps 1-6 for each virtual network requiring remediation. **Remediate from Azure CLI** For each virtual network requiring remediation, run the following command to enable DDoS protection: ``` az network vnet update --resource-group --name --ddos-protection true --ddos-protection-plan ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Virtual networks`. 2. Click the name of a virtual network. 3. Under `Settings`, click `DDoS protection`. 4. Ensure `DDoS Network Protection` is set to `Enable`. 5. Repeat steps 1-4 for each virtual network. **Audit from Azure CLI** Run the following command to list virtual networks: ``` az network vnet list ``` For each virtual network, run the following command to get the DDoS protection setting: ``` az network vnet show --resource-group --name --query enableDdosProtection ``` Ensure `true` is returned. **Audit from Azure Policy** - **Policy ID:** a7aca53f-2ed4-4466-a25e-0b45ade68efd - **Name:** 'Azure DDoS Protection should be enabled'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview:https://learn.microsoft.com/en-us/azure/ddos-protection/manage-ddos-protection:https://azure.microsoft.com/en-us/pricing/details/ddos-protection:https://learn.microsoft.com/en-us/cli/azure/network/vnet", + "DefaultValue": "DDoS protection is disabled by default." + } + ] + }, + { + "Id": "9.1.1", + "Description": "Ensure Soft Delete for Azure File Shares is Enabled", + "Checks": [ + "storage_ensure_file_shares_soft_delete_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.1 Azure Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Soft Delete for Azure File Shares is Enabled", + "RationaleStatement": "Important data could be accidentally deleted or removed by a malicious actor. With soft delete enabled, the data is retained for the defined retention period before permanent deletion, allowing for recovery of the data.", + "ImpactStatement": "When a file share is soft-deleted, the used portion of the storage is charged for the indicated soft-deleted period. All other meters are not charged unless the share is restored.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account with file shares, under `Data storage`, click `File shares`. 1. Under `File share settings`, click the value next to `Soft delete`. 1. Under `Soft delete for all file shares`, click the toggle to set it to `Enabled`. 1. Under `Retention policies`, set an appropriate number of days to retain soft deleted data between 1 and 365, inclusive. 1. Click `Save`. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable soft delete for file shares and set an appropriate number of days for deleted data to be retained, between 1 and 365, inclusive: ``` az storage account file-service-properties update --account-name --enable-delete-retention true --delete-retention-days ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to enable soft delete for file shares and set an appropriate number of days for deleted data to be retained, between 1 and 365, inclusive: ``` Update-AzStorageFileServiceProperty -ResourceGroupName -AccountName -EnableShareDeleteRetentionPolicy $true -ShareRetentionDays ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account with file shares, under `Data storage`, click on `File shares`. 1. Under `File share settings`, ensure the value for `Soft delete` shows a number of days between 1 and 365, inclusive. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has file shares: ``` az storage share list --account-name ``` For each storage account with file shares, run the following command: ``` az storage account file-service-properties show --resource-group --account-name ``` Ensure that under `shareDeleteRetentionPolicy`, `enabled` is set to `true`, and `days` is set to an appropriate value between 1 and 365, inclusive. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount -ResourceGroupName ``` With a storage account context set, run the following command to determine if a storage account has file shares: ``` Get-AzStorageShare ``` For each storage account with file shares, run the following command: ``` Get-AzStorageFileServiceProperty -ResourceGroupName -AccountName ``` Ensure that `ShareDeleteRetentionPolicy.Enabled` is set to `True` and `ShareDeleteRetentionPolicy.Days` is set to an appropriate value between 1 and 365, inclusive.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/files/storage-files-enable-soft-delete:https://learn.microsoft.com/en-us/cli/azure/storage/account/file-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/azure/storage/files/storage-files-prevent-file-share-deletion", + "DefaultValue": "Soft delete is enabled by default at the storage account file share setting level." + } + ] + }, + { + "Id": "9.1.2", + "Description": "Ensure 'SMB protocol version' is Set to 'SMB 3.1.1' or Higher for SMB file shares", + "Checks": [ + "storage_smb_protocol_version_is_latest" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.1 Azure Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'SMB protocol version' is Set to 'SMB 3.1.1' or Higher for SMB file shares", + "RationaleStatement": "Using the latest supported SMB protocol version enhances the security of SMB file shares by preventing the exploitation of known vulnerabilities in outdated SMB versions.", + "ImpactStatement": "Using the latest SMB protocol version may impact client compatibility.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. If `Profile` is set to `Maximum compatibility`, click the drop-down menu and select `Maximum security` or `Custom`. 1. If selecting `Custom`, under `SMB protocol versions`, uncheck the boxes next to `SMB 2.1` and `SMB 3.0`. 1. Click `Save`. 1. Repeat steps 1-7 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to set the SMB protocol version: ``` az storage account file-service-properties update --resource-group --account-name --versions SMB3.1.1 ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to set the SMB protocol version: ``` Update-AzStorageFileServiceProperty -ResourceGroupName -StorageAccountName -SmbProtocolVersion SMB3.1.1 ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. Under `SMB protocol versions`, ensure that `SMB3.1.1` is the only checked protocol version. 1. Repeat steps 1-5 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account file-service-properties show --resource-group --account-name ``` Ensure that under `protocolSettings` > `smb`, `versions` is set to `SMB3.1.1;` only. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the file service properties for a storage account in a resource group with a given name: ``` $storageaccountfileservice = Get-AzStorageFileServiceProperty -ResourceGroupName -AccountName ``` Run the following command to get the SMB protocol version setting: ``` $storageaccountfileservice.ProtocolSettings.Smb.Versions ``` Ensure that the command returns `SMB3.1.1` only. Repeat for each storage account.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-files#recommendations-for-smb-file-shares:https://learn.microsoft.com/en-us/azure/storage/files/files-smb-protocol#smb-security-settings:https://learn.microsoft.com/en-us/cli/azure/storage/account/file-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstoragefileserviceproperty", + "DefaultValue": "By default, all SMB versions are allowed." + } + ] + }, + { + "Id": "9.1.3", + "Description": "Ensure 'SMB channel encryption' is Set to 'AES-256-GCM' or Higher for SMB file shares", + "Checks": [ + "storage_smb_channel_encryption_with_secure_algorithm" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.1 Azure Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'SMB channel encryption' is Set to 'AES-256-GCM' or Higher for SMB file shares", + "RationaleStatement": "AES-256-GCM encryption enhances the security of data transmitted over SMB channels by safeguarding it from unauthorized interception and tampering.", + "ImpactStatement": "Using the AES-256-GCM SMB channel encryption may impact client compatibility.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. If `Profile` is set to `Maximum compatibility`, click the drop-down menu and select `Maximum security` or `Custom`. 1. If selecting `Custom`, under `SMB channel encryption`, uncheck the boxes next to `AES-128-CCM` and `AES-128-GCM`. 1. Click `Save`. 1. Repeat steps 1-7 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to set the SMB channel encryption: ``` az storage account file-service-properties update --resource-group --account-name --channel-encryption AES-256-GCM ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to set the SMB channel encryption: ``` Update-AzStorageFileServiceProperty -ResourceGroupName -StorageAccountName -SmbChannelEncryption AES-256-GCM ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Data storage`, click `File shares`. 1. Under `File share settings`, click the link next to `Security`. 1. Under `SMB channel encryption`, ensure that `AES-256-GCM`, or higher, is the only checked SMB channel encryption setting. 1. Repeat steps 1-5 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account file-service-properties show --resource-group --account-name ``` Ensure that under `protocolSettings` > `smb`, `channelEncryption` is set to `AES-256-GCM;`, or higher, only. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the file service properties for a storage account in a resource group with a given name: ``` $storageaccountfileservice = Get-AzStorageFileServiceProperty -ResourceGroupName -AccountName ``` Run the following command to get the SMB channel encryption setting: ``` $storageaccountfileservice.ProtocolSettings.Smb.ChannelEncryption ``` Ensure that the command returns `AES-256-GCM`, or higher, only. Repeat for each storage account.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-files#recommendations-for-smb-file-shares:https://learn.microsoft.com/en-us/azure/storage/files/files-smb-protocol?tabs=azure-portal#smb-security-settings:https://learn.microsoft.com/en-us/cli/azure/storage/account/file-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragefileserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstoragefileserviceproperty", + "DefaultValue": "By default, the following SMB channel encryption algorithms are allowed: - AES-128-CCM - AES-128-GCM - AES-256-GCM" + } + ], + "ConfigRequirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] + }, + { + "Id": "9.2.1", + "Description": "Ensure That Soft Delete for Blobs on Azure Blob Storage Storage Accounts is Enabled", + "Checks": [ + "storage_ensure_soft_delete_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.2 Azure Blob Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That Soft Delete for Blobs on Azure Blob Storage Storage Accounts is Enabled", + "RationaleStatement": "Blobs can be deleted incorrectly. An attacker or malicious user may do this deliberately in order to cause disruption. Deleting an Azure storage blob results in immediate data loss. Enabling this configuration for Azure storage accounts ensures that even if blobs are deleted from the storage account, the blobs are recoverable for a specific period of time, which is defined in the Retention policies, ranging from 7 to 365 days.", + "ImpactStatement": "All soft-deleted data is billed at the same rate as active data. Additional costs may be incurred for deleted blobs until the soft delete period ends and the data is permanently removed.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Check the box next to `Enable soft delete for blobs`. 1. Set the retention period to a sufficient length for your organization. 1. Click `Save`. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable soft delete for blobs: ``` az storage blob service-properties delete-policy update --days-retained --account-name --enable true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Ensure that `Enable soft delete for blobs` is checked. 1. Ensure that the retention period is a sufficient length for your organization. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has containers: ``` az storage container list --account-name ``` For each storage account with containers, ensure that the output of the below command contains `enabled: true` and `days` is not `null`: ``` az storage blob service-properties delete-policy show --account-name ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/soft-delete-blob-overview", + "DefaultValue": "Soft delete for blob storage is **enabled** by default on storage accounts created via the Azure Portal, and **disabled** by default on storage accounts created via Azure CLI or PowerShell." + } + ] + }, + { + "Id": "9.2.2", + "Description": "Ensure that Soft Delete for Containers on Azure Blob Storage Storage Accounts is Enabled", + "Checks": [ + "storage_ensure_soft_delete_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.2 Azure Blob Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that Soft Delete for Containers on Azure Blob Storage Storage Accounts is Enabled", + "RationaleStatement": "Blobs can be deleted incorrectly. An attacker or malicious user may do this deliberately in order to cause disruption. Deleting an Azure storage blob results in immediate data loss. Enabling this configuration for Azure storage accounts ensures that even if blobs are deleted from the storage account, the blobs are recoverable for a specific period of time, which is defined in the Retention policies, ranging from 7 to 365 days.", + "ImpactStatement": "All soft-deleted data is billed at the same rate as active data. Additional costs may be incurred for deleted blobs until the soft delete period ends and the data is permanently removed.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Check the box next to `Enable soft delete for blobs`. 1. Set the retention period to a sufficient length for your organization. 1. Click `Save`. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable soft delete for blobs: ``` az storage blob service-properties delete-policy update --days-retained --account-name --enable true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each Storage Account with blob storage, under `Data management`, go to `Data protection`. 1. Ensure that `Enable soft delete for blobs` is checked. 1. Ensure that the retention period is a sufficient length for your organization. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has containers: ``` az storage container list --account-name ``` For each storage account with containers, ensure that the output of the below command contains `enabled: true` and `days` is not `null`: ``` az storage blob service-properties delete-policy show --account-name ```", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/soft-delete-blob-overview", + "DefaultValue": "Soft delete for blob storage is **enabled** by default on storage accounts created via the Azure Portal, and **disabled** by default on storage accounts created via Azure CLI or PowerShell." + } + ] + }, + { + "Id": "9.2.3", + "Description": "Ensure 'Versioning' is Set to 'Enabled' on Azure Blob Storage Storage Accounts", + "Checks": [ + "storage_blob_versioning_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.2 Azure Blob Storage", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Versioning' is Set to 'Enabled' on Azure Blob Storage Storage Accounts", + "RationaleStatement": "Blob versioning safeguards data integrity and enables recovery by retaining previous versions of stored objects, facilitating quick restoration from accidental deletion, modification, or malicious activity.", + "ImpactStatement": "Enabling blob versioning for a storage account creates a new version with each write operation to a blob, which can increase storage costs. To control these costs, a lifecycle management policy can be applied to automatically delete older versions.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account with blob storage. 1. In the `Overview` page, on the `Properties` tab, under `Blob service`, click `Disabled` next to `Versioning`. 1. Under `Tracking`, check the box next to `Enable versioning for blobs`. 1. Select the radio button next to `Keep all versions` or `Delete versions after (in days)`. 1. If selecting to delete versions, enter a number of in the box after which to delete blob versions. 1. Click `Save`. 1. Repeat steps 1-7 for each storage account with blob storage. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable blob versioning: ``` az storage account blob-service-properties update --account-name --enable-versioning true ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to enable blob versioning: ``` Update-AzStorageBlobServiceProperty -ResourceGroupName -StorageAccountName -IsVersioningEnabled $true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account with blob storage. 1. In the `Overview` page, on the `Properties` tab, under `Blob service`, ensure `Versioning` is set to `Enabled`. 1. Repeat steps 1-3 for each storage account with blob storage. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` Run the following command to determine if a storage account has containers: ``` az storage container list --account-name ``` For each storage account with containers, ensure that the output of the below command contains `isVersioningEnabled: true`: ``` az storage account blob-service-properties show --account-name ``` **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to create an Azure Storage context for a storage account: ``` $context = New-AzStorageContext -StorageAccountName ``` Run the following command to list containers for the storage account: ``` Get-AzStorageContainer -Context $context ``` If the storage account has containers, run the following command to get the blob service properties of the storage account: ``` $account = Get-AzStorageBlobServiceProperty -ResourceGroupName -AccountName ``` Run the following command to get the blob versioning setting for the storage account: ``` $account.IsVersioningEnabled ``` Ensure that the command returns `True`. Repeat for each storage account. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c36a325b-ae04-4863-ad4f-19c6678f8e08](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc36a325b-ae04-4863-ad4f-19c6678f8e08) **- Name:** 'Configure your Storage account to enable blob versioning'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/cli/azure/storage/account:https://learn.microsoft.com/en-us/cli/azure/storage/account/blob-service-properties:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstorageaccount:https://learn.microsoft.com/en-us/powershell/module/az.storage/new-azstoragecontext:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstoragecontainer:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstorageblobserviceproperty:https://learn.microsoft.com/en-us/powershell/module/az.storage/update-azstorageblobserviceproperty:https://learn.microsoft.com/en-us/azure/storage/blobs/versioning-overview:https://learn.microsoft.com/en-us/azure/storage/blobs/lifecycle-management-overview", + "DefaultValue": "Blob versioning is disabled by default on storage accounts." + } + ] + }, + { + "Id": "9.3.1.1", + "Description": "Ensure That 'Enable key rotation reminders' is Enabled for Each Storage Account", + "Checks": [ + "storage_infrastructure_encryption_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That 'Enable key rotation reminders' is Enabled for Each Storage Account", + "RationaleStatement": "Reminders such as those generated by this recommendation will help maintain a regular and healthy cadence for activities which improve the overall efficacy of a security program. Cryptographic key rotation periods will vary depending on your organization's security requirements and the type of data which is being stored in the Storage Account. For example, PCI DSS mandates that cryptographic keys be replaced or rotated 'regularly,' and advises that keys for static data stores be rotated every 'few months.' For the purposes of this recommendation, 90 days will be prescribed for the reminder. Review and adjustment of the 90 day period is recommended, and may even be necessary. Your organization's security requirements should dictate the appropriate setting.", + "ImpactStatement": "This recommendation only creates a periodic reminder to regenerate access keys. Regenerating access keys can affect services in Azure as well as the organization's applications that are dependent on the storage account. All clients that use the access key to access the storage account must be updated to use the new key.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts` 1. For each Storage Account that is not compliant, under `Security + networking`, go to `Access keys` 1. Click `Set rotation reminder` 1. Check `Enable key rotation reminders` 1. In the `Send reminders` field select `Custom`, then set the `Remind me every` field to `90` and the period drop down to `Days` 1. Click `Save` **Remediate from Powershell** ``` $rgName = $accountName = $account = Get-AzStorageAccount -ResourceGroupName $rgName -Name $accountName if ($account.KeyCreationTime.Key1 -eq $null -or $account.KeyCreationTime.Key2 -eq $null){ Write-output (You must regenerate both keys at least once before setting expiration policy) } else { $account = Set-AzStorageAccount -ResourceGroupName $rgName -Name $accountName -KeyExpirationPeriodInDay 90 } $account.KeyPolicy.KeyExpirationPeriodInDays ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts` 2. For each Storage Account, under `Security + networking`, go to `Access keys` 3. If the button `Edit rotation reminder` is displayed, the Storage Account is compliant. Click `Edit rotation reminder` and review the `Remind me every` field for a desirable periodic setting that fits your security program's needs. If the button `Set rotation reminder` is displayed, the Storage Account is not compliant. **Audit from Powershell** ``` $rgName = $accountName = $account = Get-AzStorageAccount -ResourceGroupName $rgName -Name $accountName Write-Output $accountName -> Write-Output Expiration Reminder set to: $($account.KeyPolicy.KeyExpirationPeriodInDays) Days Write-Output Key1 Last Rotated: $($account.KeyCreationTime.Key1.ToShortDateString()) Write-Output Key2 Last Rotated: $($account.KeyCreationTime.Key2.ToShortDateString()) ``` Key rotation is recommended if the creation date for any key is empty. If the reminder is set, the period in days will be returned. The recommended period is 90 days. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [044985bb-afe1-42cd-8a36-9d5d42424537](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F044985bb-afe1-42cd-8a36-9d5d42424537) **- Name:** 'Storage account keys should not be expired'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-create-storage-account#regenerate-storage-access-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-3-manage-application-identities-securely-and-automatically:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-8-restrict-the-exposure-of-credentials-and-secrets:https://www.pcidssguide.com/pci-dss-key-rotation-requirements/:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf", + "DefaultValue": "By default, Key rotation reminders are not configured." + } + ] + }, + { + "Id": "9.3.1.2", + "Description": "Ensure That Storage Account Access keys are Periodically Regenerated", + "Checks": [ + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure That Storage Account Access keys are Periodically Regenerated", + "RationaleStatement": "When a storage account is created, Azure generates two 512-bit storage access keys which are used for authentication when the storage account is accessed. Rotating these keys periodically ensures that any inadvertent access or exposure does not result from the compromise of these keys. Cryptographic key rotation periods will vary depending on your organization's security requirements and the type of data which is being stored in the Storage Account. For example, PCI DSS mandates that cryptographic keys be replaced or rotated 'regularly,' and advises that keys for static data stores be rotated every 'few months.' For the purposes of this recommendation, 90 days will be prescribed for the reminder. Review and adjustment of the 90 day period is recommended, and may even be necessary. Your organization's security requirements should dictate the appropriate setting.", + "ImpactStatement": "Regenerating access keys can affect services in Azure as well as the organization's applications that are dependent on the storage account. All clients who use the access key to access the storage account must be updated to use the new key.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 2. For each Storage Account with outdated keys, under `Security + networking`, go to `Access keys`. 3. Click `Rotate key` next to the outdated key, then click `Yes` to the prompt confirming that you want to regenerate the access key. After Azure regenerates the Access Key, you can confirm that `Access keys` reflects a `Last rotated` date of `(0 days ago)`.", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 2. For each Storage Account, under `Security + networking`, go to `Access keys`. 3. Review the date and days in the `Last rotated` field for **each** key. If the `Last rotated` field indicates a number or days greater than 90 [or greater than your organization's period of validity], the key should be rotated. **Audit from Azure CLI** 1. Get a list of storage accounts ``` az storage account list --subscription ``` Make a note of `id`, `name` and `resourceGroup`. 2. For every storage account make sure that key is regenerated in the past 90 days. ``` az monitor activity-log list --namespace Microsoft.Storage --offset 90d --query [?contains(authorization.action, 'regenerateKey')] --resource-id ``` The output should contain ``` authorization/scope: AND authorization/action: Microsoft.Storage/storageAccounts/regeneratekey/action AND status/localizedValue: Succeeded status/Value: Succeeded ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [044985bb-afe1-42cd-8a36-9d5d42424537](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F044985bb-afe1-42cd-8a36-9d5d42424537) **- Name:** 'Storage account keys should not be expired'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-create-storage-account#regenerate-storage-access-keys:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-2-protect-identity-and-authentication-systems:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-6-define-and-implement-identity-and-privileged-access-strategy:https://www.pcidssguide.com/pci-dss-key-rotation-requirements/:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf", + "DefaultValue": "By default, access keys are not regenerated periodically." + } + ] + }, + { + "Id": "9.3.1.3", + "Description": "Ensure 'Allow storage account key access' for Azure Storage Accounts is 'Disabled'", + "Checks": [ + "storage_account_key_access_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Allow storage account key access' for Azure Storage Accounts is 'Disabled'", + "RationaleStatement": "Microsoft Entra ID provides superior security and ease of use compared to Shared Key and is recommended by Microsoft. To require clients to use Microsoft Entra ID for authorizing requests, you can disallow requests to the storage account that are authorized with Shared Key.", + "ImpactStatement": "When you disallow Shared Key authorization for a storage account, any requests to the account that are authorized with Shared Key, including shared access signatures (SAS), will be denied. Client applications that currently access the storage account using the Shared Key will no longer function.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Settings`, click `Configuration`. 1. Under `Allow storage account key access`, click the radio button next to `Disabled`. 1. Click `Save`. 1. Repeat steps 1-5 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to disallow shared key authorization: ``` az storage account update --resource-group --name --allow-shared-key-access false ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to disallow shared key authorization: ``` Set-AzStorageAccount -ResourceGroupName -Name -AllowSharedKeyAccess $false ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Settings`, click `Configuration`. 1. Under `Allow storage account key access`, ensure that the radio button next to `Disabled` is selected. 1. Repeat steps 1-4 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account show --resource-group --name ``` Ensure that `allowSharedKeyAccess` is set to `false`. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the storage account in a resource group with a given name: ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName -Name ``` Run the following command to get the shared key access setting for the storage account: ``` $storageAccount.allowSharedKeyAccess ``` Ensure that the command returns `False`. Repeat for each storage account. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54) **- Name:** 'Storage accounts should prevent shared key access'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent:https://learn.microsoft.com/en-us/cli/azure/storage/account:https://learn.microsoft.com/en-us/powershell/module/az.storage/get-azstorageaccount:https://learn.microsoft.com/en-us/powershell/module/az.storage/set-azstorageaccount", + "DefaultValue": "The AllowSharedKeyAccess property of a storage account is not set by default and does not return a value until you explicitly set it. The storage account permits requests that are authorized with the Shared Key when the property value is **null** or when it is **true**." + } + ] + }, + { + "Id": "9.3.2.1", + "Description": "Ensure Private Endpoints are Used to Access Storage Accounts", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Private Endpoints are Used to Access Storage Accounts", + "RationaleStatement": "Securing traffic between services through encryption protects the data from easy interception and reading.", + "ImpactStatement": "If an Azure Virtual Network is not implemented correctly, this may result in the loss of critical network traffic. Private endpoints are charged per hour of use. Refer to https://azure.microsoft.com/en-us/pricing/details/private-link/ and https://azure.microsoft.com/en-us/pricing/calculator/ to estimate potential costs.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Open the `Storage Accounts` blade 1. For each listed Storage Account, perform the following: 1. Under the `Security + networking` heading, click on `Networking` 1. Click on the `Private endpoint connections` tab at the top of the networking window 1. Click the `+ Private endpoint` button 1. In the `1 - Basics` tab/step: - `Enter a name` that will be easily recognizable as associated with the Storage Account (*Note*: The Network Interface Name will be automatically completed, but you can customize it if needed.) - Ensure that the `Region` matches the region of the Storage Account - Click `Next` 1. In the `2 - Resource` tab/step: - Select the `target sub-resource` based on what type of storage resource is being made available - Click `Next` 1. In the `3 - Virtual Network` tab/step: - Select the `Virtual network` that your Storage Account will be connecting to - Select the `Subnet` that your Storage Account will be connecting to - (Optional) Select other network settings as appropriate for your environment - Click `Next` 1. In the `4 - DNS` tab/step: - (Optional) Select other DNS settings as appropriate for your environment - Click `Next` 1. In the `5 - Tags` tab/step: - (Optional) Set any tags that are relevant to your organization - Click `Next` 1. In the `6 - Review + create` tab/step: - A validation attempt will be made and after a few moments it should indicate `Validation Passed` - if it does not pass, double-check your settings before beginning more in depth troubleshooting. - If validation has passed, click `Create` then wait for a few minutes for the scripted deployment to complete. Repeat the above procedure for each Private Endpoint required within every Storage Account. **Remediate from PowerShell** ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName '' -Name '' $privateEndpointConnection = @{ Name = 'connectionName' PrivateLinkServiceId = $storageAccount.Id GroupID = blob|blob_secondary|file|file_secondary|table|table_secondary|queue|queue_secondary|web|web_secondary|dfs|dfs_secondary } $privateLinkServiceConnection = New-AzPrivateLinkServiceConnection @privateEndpointConnection $virtualNetDetails = Get-AzVirtualNetwork -ResourceGroupName '' -Name '' $privateEndpoint = @{ ResourceGroupName = '' Name = '' Location = '' Subnet = $virtualNetDetails.Subnets[0] PrivateLinkServiceConnection = $privateLinkServiceConnection } New-AzPrivateEndpoint @privateEndpoint ``` **Remediate from Azure CLI** ``` az network private-endpoint create --resource-group --name --vnet-name --subnet --private-connection-resource-id --connection-name --group-id ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Open the `Storage Accounts` blade. 1. For each listed Storage Account, perform the following check: 1. Under the `Security + networking` heading, click on `Networking`. 1. Click on the `Private endpoint connections` tab at the top of the networking window. 1. Ensure that for each VNet that the Storage Account must be accessed from, a unique Private Endpoint is deployed and the `Connection state` for each Private Endpoint is `Approved`. Repeat the procedure for each Storage Account. **Audit from PowerShell** ``` $storageAccount = Get-AzStorageAccount -ResourceGroup '' -Name '' Get-AzPrivateEndpoint -ResourceGroup ''|Where-Object {$_.PrivateLinkServiceConnectionsText -match $storageAccount.id} ``` If the results of the second command returns information, the Storage Account is using a Private Endpoint and complies with this Benchmark, otherwise if the results of the second command are empty, the Storage Account generates a finding. **Audit from Azure CLI** ``` az storage account show --name '' --query privateEndpointConnections[0].id ``` If the above command returns data, the Storage Account complies with this Benchmark, otherwise if the results are empty, the Storage Account generates a finding. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [6edd7eda-6dd8-40f7-810d-67160c639cd9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F6edd7eda-6dd8-40f7-810d-67160c639cd9) **- Name:** 'Storage accounts should use private link'", + "AdditionalInformation": "A NAT gateway is the recommended solution for outbound internet access. This recommendation is based on the Common Reference Recommendation `Ensure Private Endpoints are used to access {service}`, from the `Common Reference Recommendations > Networking > Private Endpoints` section.", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-private-endpoints:https://docs.microsoft.com/en-us/azure/virtual-network/virtual-networks-overview:https://docs.microsoft.com/en-us/azure/private-link/create-private-endpoint-portal:https://docs.microsoft.com/en-us/azure/private-link/create-private-endpoint-cli?tabs=dynamic-ip:https://docs.microsoft.com/en-us/azure/private-link/create-private-endpoint-powershell?tabs=dynamic-ip:https://docs.microsoft.com/en-us/azure/private-link/tutorial-private-endpoint-storage-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls", + "DefaultValue": "By default, Private Endpoints are not created for Storage Accounts." + } + ] + }, + { + "Id": "9.3.2.2", + "Description": "Ensure that 'Public Network Access' is 'Disabled' for Storage Accounts", + "Checks": [ + "storage_account_public_network_access_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Public Network Access' is 'Disabled' for Storage Accounts", + "RationaleStatement": "The default network configuration for a storage account permits a user with appropriate permissions to configure public network access to containers and blobs in a storage account. Keep in mind that public access to a container is always turned off by default and must be explicitly configured to permit anonymous requests. It grants read-only access to these resources without sharing the account key, and without requiring a shared access signature. It is recommended not to provide public network access to storage accounts until, and unless, it is strongly desired. A shared access signature token or Azure AD RBAC should be used for providing controlled and timed access to blob containers.", + "ImpactStatement": "Access will have to be managed using shared access signatures or via Azure AD RBAC. For classic storage accounts (to be retired on August 31, 2024), each container in the account must be configured to block anonymous access. Either configure all containers or to configure at the storage account level, migrate to the Azure Resource Manager deployment model.", + "RemediationProcedure": "**Remediate from Azure Portal** First, follow Microsoft documentation and create shared access signature tokens for your blob containers. Then, 1. Go to `Storage Accounts`. 1. For each storage account, under the `Security + networking` section, click `Networking`. 1. Set `Public network access` to `Disabled`. 1. Click `Save`. **Remediate from Azure CLI** Set 'Public Network Access' to `Disabled` on the storage account ``` az storage account update --name --resource-group --public-network-access Disabled ``` **Remediate from PowerShell** For each Storage Account, run the following to set the `PublicNetworkAccess` setting to `Disabled` ``` Set-AzStorageAccount -ResourceGroupName -Name -PublicNetworkAccess Disabled ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 2. For each storage account, under the `Security + networking` section, click `Networking`. 3. Ensure the `Public network access` setting is set to `Disabled`. **Audit from Azure CLI** Ensure `publicNetworkAccess` is `Disabled` ``` az storage account show --name --resource-group --query {publicNetworkAccess:publicNetworkAccess} ``` **Audit from PowerShell** For each Storage Account, ensure `PublicNetworkAccess` is `Disabled` ``` Get-AzStorageAccount -Name -ResourceGroupName |select PublicNetworkAccess ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [b2982f36-99f2-4db5-8eff-283140c09693](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fb2982f36-99f2-4db5-8eff-283140c09693) **- Name:** 'Storage accounts should disable public network access'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation `Ensure public network access is Disabled`, from the `Common Reference Recommendations > Networking > Virtual Networks (VNets)` section.", + "References": "https://docs.microsoft.com/en-us/azure/storage/blobs/storage-manage-access-to-resources:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls:https://docs.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access:https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security?tabs=azure-portal", + "DefaultValue": "By default, `Public Network Access` is set to `Enabled from all networks` for the Storage Account." + } + ] + }, + { + "Id": "9.3.2.3", + "Description": "Ensure Default Network Access Rule for Storage Accounts is Set to Deny", + "Checks": [ + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure Default Network Access Rule for Storage Accounts is Set to Deny", + "RationaleStatement": "Storage accounts should be configured to deny access to traffic from all networks (including internet traffic). Access can be granted to traffic from specific Azure Virtual networks, allowing a secure network boundary for specific applications to be built. Access can also be granted to public internet IP address ranges to enable connections from specific internet or on-premises clients. When network rules are configured, only applications from allowed networks can access a storage account. When calling from an allowed network, applications continue to require proper authorization (a valid access key or SAS token) to access the storage account.", + "ImpactStatement": "All allowed networks will need to be whitelisted on each specific network, creating administrative overhead. This may result in loss of network connectivity, so do not turn on for critical resources during business hours.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Security + networking`, click `Networking`. 1. Click the `Firewalls and virtual networks` heading. 1. Set `Public network access` to `Enabled from selected virtual networks and IP addresses`. 1. Add rules to allow traffic from specific networks and IP addresses. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to update `default-action` to `Deny`. ``` az storage account update --name --resource-group --default-action Deny ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to Storage Accounts. 2. For each storage account, under `Security + networking`, click `Networking`. 4. Click the `Firewalls and virtual networks` heading. 3. Ensure that `Public network access` is not set to `Enabled from all networks`. **Audit from Azure CLI** Ensure `defaultAction` is not set to ` Allow`. ``` az storage account list --query '[*].networkRuleSet' ``` **Audit from PowerShell** ``` Connect-AzAccount Set-AzContext -Subscription Get-AzStorageAccountNetworkRuleset -ResourceGroupName -Name |Select-Object DefaultAction ``` PowerShell Result - Non-Compliant ``` DefaultAction : Allow ``` PowerShell Result - Compliant ``` DefaultAction : Deny ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [34c877ad-507e-4c82-993e-3452a6e0ad3c](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F34c877ad-507e-4c82-993e-3452a6e0ad3c) **- Name:** 'Storage accounts should restrict network access' - **Policy ID:** [2a1a9cdf-e04d-429a-8416-3bfb72a1b26f](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F2a1a9cdf-e04d-429a-8416-3bfb72a1b26f) **- Name:** 'Storage accounts should restrict network access using virtual network rules'", + "AdditionalInformation": "This recommendation is based on the Common Reference Recommendation `Ensure Network Access Rules are set to Deny-by-default`, from the `Common Reference Recommendations > Networking > Virtual Networks (VNets)` section.", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-network-security:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-governance-strategy#gs-2-define-and-implement-enterprise-segmentationseparation-of-duties-strategy:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls", + "DefaultValue": "By default, Storage Accounts will accept connections from clients on any network." + } + ] + }, + { + "Id": "9.3.3.1", + "Description": "Ensure that 'Default to Microsoft Entra authorization in the Azure portal' is Set to 'Enabled'", + "Checks": [ + "storage_default_to_entra_authorization_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Default to Microsoft Entra authorization in the Azure portal' is Set to 'Enabled'", + "RationaleStatement": "Microsoft Entra ID provides superior security and ease of use over Shared Key.", + "ImpactStatement": "Users will need appropriate RBAC permissions to access storage data.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Settings`, click `Configuration`. 1. Under `Default to Microsoft Entra authorization in the Azure portal`, click the radio button next to `Enabled`. 1. Click `Save`. 1. Repeat steps 1-5 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable `defaultToOAuthAuthentication`: ``` az storage account update --resource-group --name --set defaultToOAuthAuthentication=true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click the name of a storage account. 1. Under `Settings`, click `Configuration`. 1. Ensure that `Default to Microsoft Entra authorization in the Azure portal` is set to `Enabled`. 1. Repeat steps 1-4 for each storage account. **Audit from Azure CLI** Run the following command to get the `name` and `defaultToOAuthAuthentication` setting for each storage account: ``` az storage account list --query [*].[name,defaultToOAuthAuthentication] ``` Ensure that `true` is returned for each storage account.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-data-operations-portal#default-to-microsoft-entra-authorization-in-the-azure-portal:https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest", + "DefaultValue": "By default, `defaultToOAuthAuthentication` is disabled." + } + ] + }, + { + "Id": "9.3.4", + "Description": "Ensure that 'Secure transfer required' is Set to 'Enabled'", + "Checks": [ + "storage_secure_transfer_required_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Secure transfer required' is Set to 'Enabled'", + "RationaleStatement": "The secure transfer option enhances the security of a storage account by only allowing requests to the storage account by a secure connection. For example, when calling REST APIs to access storage accounts, the connection must use HTTPS. Any requests using HTTP will be rejected when 'secure transfer required' is enabled. When using the Azure files service, connection without encryption will fail, including scenarios using SMB 2.1, SMB 3.0 without encryption, and some flavors of the Linux SMB client. Because Azure storage doesnt support HTTPS for custom domain names, this option is not applied when using a custom domain name.", + "ImpactStatement": "Applications using HTTP will need to be updated to use HTTPS.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Set `Secure transfer required` to `Enabled`. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to enable `Secure transfer required` for a `Storage Account` ``` az storage account update --name --resource-group --https-only true ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Ensure that `Secure transfer required` is set to `Enabled`. **Audit from Azure CLI** Use the below command to ensure the `Secure transfer required` is enabled for all the `Storage Accounts` by ensuring the output contains `true` for each of the `Storage Accounts`. ``` az storage account list --query [*].[name,enableHttpsTrafficOnly] ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [404c3081-a854-4457-ae30-26a93ef643f9](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F404c3081-a854-4457-ae30-26a93ef643f9) **- Name:** 'Secure transfer to storage accounts should be enabled'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/blobs/security-recommendations#encryption-in-transit:https://docs.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest#az_storage_account_list:https://docs.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest#az_storage_account_update:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-3-encrypt-sensitive-data-in-transit", + "DefaultValue": "By default, `Secure transfer required` is set to `Disabled`." + } + ] + }, + { + "Id": "9.3.5", + "Description": "Ensure 'Allow trusted Microsoft services to access this resource' is Enabled for Storage Account Access", + "Checks": [ + "storage_ensure_azure_services_are_trusted_to_access_is_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Allow trusted Microsoft services to access this resource' is Enabled for Storage Account Access", + "RationaleStatement": "Turning on firewall rules for a storage account will block access to incoming requests for data, including from other Azure services. We can re-enable this functionality by allowing access to `trusted Azure services` through networking exceptions.", + "ImpactStatement": "This creates authentication credentials for services that need access to storage resources so that services will no longer need to communicate via network request. There may be a temporary loss of communication as you set each Storage Account. It is recommended to not do this on mission-critical resources during business hours.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Security + networking`, click `Networking`. 1. Click on the `Firewalls and virtual networks` heading. 1. Under `Exceptions`, check the box next to `Allow Azure services on the trusted services list to access this storage account`. 1. Click `Save`. **Remediate from Azure CLI** Use the below command to update `bypass` to `Azure services`. ``` az storage account update --name --resource-group --bypass AzureServices ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Security + networking`, click `Networking`. 1. Click on the `Firewalls and virtual networks` heading. 1. Under `Exceptions`, ensure that `Allow Azure services on the trusted services list to access this storage account` is checked. **Audit from Azure CLI** Ensure `bypass` contains `AzureServices` ``` az storage account list --query '[*].networkRuleSet' ``` **Audit from PowerShell** ``` Connect-AzAccount Set-AzContext -Subscription Get-AzStorageAccountNetworkRuleset -ResourceGroupName -Name |Select-Object Bypass ``` If the response from the above command is `None`, the storage account configuration is out of compliance with this check. If the response is `AzureServices`, the storage account configuration is in compliance with this check. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [c9d007d0-c057-4772-b18c-01e546713bcd](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc9d007d0-c057-4772-b18c-01e546713bcd) **- Name:** 'Storage accounts should allow access from trusted Microsoft services'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/storage-network-security:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-network-security#ns-2-secure-cloud-native-services-with-network-controls", + "DefaultValue": "By default, Storage Accounts will accept connections from clients on any network." + } + ] + }, + { + "Id": "9.3.6", + "Description": "Ensure the 'Minimum TLS version' for Storage Accounts is Set to 'Version 1.2'", + "Checks": [ + "storage_ensure_minimum_tls_version_12" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure the 'Minimum TLS version' for Storage Accounts is Set to 'Version 1.2'", + "RationaleStatement": "TLS 1.0 has known vulnerabilities and has been replaced by later versions of the TLS protocol. Continued use of this legacy protocol affects the security of data in transit.", + "ImpactStatement": "When set to TLS 1.2 all requests must leverage this version of the protocol. Applications leveraging legacy versions of the protocol will fail.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Set the `Minimum TLS version` to `Version 1.2`. 1. Click `Save`. **Remediate from Azure CLI** ``` az storage account update \\ --name \\ --resource-group \\ --min-tls-version TLS1_2 ``` **Remediate from PowerShell** To set the minimum TLS version, run the following command: ``` Set-AzStorageAccount -AccountName ` -ResourceGroupName ` -MinimumTlsVersion TLS1_2 ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Ensure that the `Minimum TLS version` is set to `Version 1.2`. **Audit from Azure CLI** Get a list of all storage accounts and their resource groups ``` az storage account list | jq '.[] | {name, resourceGroup}' ``` Then query the minimumTLSVersion field ``` az storage account show \\ --name \\ --resource-group \\ --query minimumTlsVersion \\ --output tsv ``` **Audit from PowerShell** To get the minimum TLS version, run the following command: ``` (Get-AzStorageAccount -Name -ResourceGroupName ).MinimumTlsVersion ``` **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [fe83a0eb-a853-422d-aac2-1bffd182c5d0](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Ffe83a0eb-a853-422d-aac2-1bffd182c5d0) **- Name:** 'Storage accounts should have the specified minimum TLS version'", + "AdditionalInformation": "", + "References": "https://docs.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-minimum-version?tabs=portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-data-protection#dp-3-encrypt-sensitive-data-in-transit", + "DefaultValue": "If a storage account is created through the portal, the MinimumTlsVersion property for that storage account will be set to TLS 1.2. If a storage account is created through PowerShell or CLI, the MinimumTlsVersion property for that storage account will not be set, and defaults to TLS 1.0." + } + ] + }, + { + "Id": "9.3.7", + "Description": "Ensure 'Cross Tenant Replication' is Not Enabled", + "Checks": [ + "storage_cross_tenant_replication_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure 'Cross Tenant Replication' is Not Enabled", + "RationaleStatement": "Disabling Cross Tenant Replication minimizes the risk of unauthorized data access and ensures that data governance policies are strictly adhered to. This control is especially critical for organizations with stringent data security and privacy requirements, as it prevents the accidental sharing of sensitive information.", + "ImpactStatement": "Disabling Cross Tenant Replication may affect data availability and sharing across different Azure tenants. Ensure that this change aligns with your organizational data sharing and availability requirements.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Data management`, click `Object replication`. 1. Click `Advanced settings`. 1. Uncheck `Allow cross-tenant replication`. 1. Click `OK`. **Remediate from Azure CLI** Replace the information within <> with appropriate values: ``` az storage account update --name --resource-group --allow-cross-tenant-replication false ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Data management`, click `Object replication`. 1. Click `Advanced settings`. 1. Ensure `Allow cross-tenant replication` is not checked. **Audit from Azure CLI** ``` az storage account list --query [*].[name,allowCrossTenantReplication] ``` The value of `false` should be returned for each storage account listed. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [92a89a79-6c52-4a7e-a03f-61306fc49312](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F92a89a79-6c52-4a7e-a03f-61306fc49312) **- Name:** 'Storage accounts should prevent cross tenant object replication'", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/object-replication-prevent-cross-tenant-policies?tabs=portal", + "DefaultValue": "For new storage accounts created after Dec 15, 2023 cross tenant replication is not enabled." + } + ] + }, + { + "Id": "9.3.8", + "Description": "Ensure that 'Allow Blob Anonymous Access' is Set to 'Disabled'", + "Checks": [ + "storage_blob_public_access_level_is_disabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that 'Allow Blob Anonymous Access' is Set to 'Disabled'", + "RationaleStatement": "If Allow Blob Anonymous Access is enabled, blobs can be accessed by adding the blob name to the URL to see the contents. An attacker can enumerate a blob using methods, such as brute force, and access them. Exfiltration of data by brute force enumeration of items from a storage account may occur if this setting is set to 'Enabled'.", + "ImpactStatement": "Additional consideration may be required for exceptional circumstances where elements of a storage account require public accessibility. In these circumstances, it is highly recommended that all data stored in the public facing storage account be reviewed for sensitive or potentially compromising data, and that sensitive or compromising data is never stored in these storage accounts.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Set `Allow Blob Anonymous Access` to `Disabled`. 1. Click `Save`. **Remediate from Powershell** For every storage account in scope, run the following: ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName -Name $storageAccount.AllowBlobPublicAccess = $false Set-AzStorageAccount -InputObject $storageAccount ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage Accounts`. 1. For each storage account, under `Settings`, click `Configuration`. 1. Ensure `Allow Blob Anonymous Access` is set to `Disabled`. **Audit from Azure CLI** For every storage account in scope: ``` az storage account show --name --query allowBlobPublicAccess ``` Ensure that every storage account in scope returns `false` for the allowBlobPublicAccess setting. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [4fa4b6c0-31ca-4c0d-b10d-24b96f62a751](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4fa4b6c0-31ca-4c0d-b10d-24b96f62a751) **- Name:** 'Storage account public access should be disallowed'", + "AdditionalInformation": "Azure Storage accounts that use the classic deployment model will be retired on August 31, 2024.", + "References": "https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent?tabs=portal:https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent?source=recommendations&tabs=portal:Classic Storage Accounts: https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent-classic?tabs=portal", + "DefaultValue": "Disabled" + } + ] + }, + { + "Id": "9.3.9", + "Description": "Ensure Azure Resource Manager Delete Locks are Applied to Azure Storage Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Resource Manager Delete Locks are Applied to Azure Storage Accounts", + "RationaleStatement": "Applying a _Delete_ lock on storage accounts protects the availability of data by preventing the accidental or unauthorized deletion of the entire storage account. It is a fundamental protective control that can prevent data loss", + "ImpactStatement": "- Prevents the deletion of the Storage account Resource entirely. - Prevents the deletion of the parent Resource Group containing the locked Storage account resource. - Does not prevent other control plane operations, including modification of configurations, network settings, containers, and access. - Does not prevent deletion of containers or other objects within the storage account.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. Under the `Settings` section, select `Locks`. 1. Select `Add`. 1. Provide a Name, and choose `Delete` for the type of lock. 1. Add a note about the lock if desired. **Remediate from Azure CLI** Replace the information within <> with appropriate values: ``` az lock create --name \\ --resource-group \\ --resource \\ --lock-type CanNotDelete \\ --resource-type Microsoft.Storage/storageAccounts ``` **Remediate from PowerShell** Replace the information within <> with appropriate values: ``` New-AzResourceLock -LockLevel CanNotDelete ` -LockName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ` -ResourceGroupName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. For each storage account, under `Settings`, click `Locks`. 1. Ensure that a `Delete` lock exists on the storage account. **Audit from Azure CLI** ``` az lock list --resource-group \\ --resource-name \\ --resource-type Microsoft.Storage/storageAccounts ``` **Audit from PowerShell** ``` Get-AzResourceLock -ResourceGroupName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ``` **Audit from Azure Policy** There is currently no built-in Microsoft policy to audit resource locks on storage accounts. Custom and community policy definitions can check for the existence of a “Microsoft.Authorization/locks” resource with an AuditIfNotExists effect.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/lock-account-resource:https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/lock-resources", + "DefaultValue": "By default, no locks are applied to Azure resources, including storage accounts. Locks must be manually configured after resource creation." + } + ] + }, + { + "Id": "9.3.10", + "Description": "Ensure Azure Resource Manager ReadOnly Locks are Considered for Azure Storage Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure Azure Resource Manager ReadOnly Locks are Considered for Azure Storage Accounts", + "RationaleStatement": "Applying a `ReadOnly` lock on storage accounts protects the confidentiality and availability of data by preventing the accidental or unauthorized deletion of the entire storage account and modification of the account, container properties, or access permissions. It can offer enhanced protection for blob and queue workloads with tradeoffs in usability and compatibility for clients using account shared access keys.", + "ImpactStatement": "- Prevents the deletion of the Storage account Resource entirely. - Prevents the deletion of the parent Resource Group containing the locked Storage account resource. - Prevents clients from obtaining the storage account shared access keys using a `listKeys` operation. - Requires Entra credentials to access blob and queue data in the Portal. - Data in Azure Files or the Table service may be inaccessible to clients using the account shared access keys. - Prevents modification of account properties, network settings, containers, and RBAC assignments. - Does not prevent access using existing account shared access keys issued to clients. - Does not prevent deletion of containers or other objects within the storage account.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. Under the `Settings` section, select `Locks`. 1. Select `Add`. 1. Provide a Name, and choose `ReadOnly` for the type of lock. 1. Add a note about the lock if desired. **Remediate from Azure CLI** Replace the information within <> with appropriate values: ``` az lock create --name \\ --resource-group \\ --resource \\ --lock-type ReadOnly \\ --resource-type Microsoft.Storage/storageAccounts ``` **Remediate from PowerShell** Replace the information within <> with appropriate values: ``` New-AzResourceLock -LockLevel ReadOnly ` -LockName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ` -ResourceGroupName ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Navigate to the storage account in the Azure portal. 1. For each storage account, under `Settings`, click `Locks`. 1. Ensure that a `ReadOnly` lock exists on the storage account. **Audit from Azure CLI** ``` az lock list --resource-group \\ --resource-name \\ --resource-type Microsoft.Storage/storageAccounts ``` **Audit from PowerShell** ``` Get-AzResourceLock -ResourceGroupName ` -ResourceName ` -ResourceType Microsoft.Storage/storageAccounts ``` **Audit from Azure Policy** There is currently no built-in Microsoft policy to audit resource locks on storage accounts. Custom and community policy definitions can check for the existence of a “Microsoft.Authorization/locks” resource with an AuditIfNotExists effect.", + "AdditionalInformation": "", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/lock-account-resource:https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/lock-resources:https://github.com/Azure/azure-rest-api-specs/tree/main/specification/storage", + "DefaultValue": "By default, no locks are applied to Azure resources, including storage accounts. Locks must be manually configured after resource creation." + } + ] + }, + { + "Id": "9.3.11", + "Description": "Ensure Redundancy is Set to 'geo-redundant storage (GRS)' on Critical Azure Storage Accounts", + "Checks": [ + "storage_geo_redundant_enabled" + ], + "Attributes": [ + { + "Section": "9 Storage Services", + "SubSection": "9.3 Storage Accounts", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Ensure Redundancy is Set to 'geo-redundant storage (GRS)' on Critical Azure Storage Accounts", + "RationaleStatement": "Enabling GRS protects critical data from regional failures by maintaining a copy in a geographically separate location. This significantly reduces the risk of data loss, supports business continuity, and meets high availability requirements for disaster recovery.", + "ImpactStatement": "Enabling geo-redundant storage on Azure storage accounts increases costs due to cross-region data replication.", + "RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Data management`, click `Redundancy`. 1. From the `Redundancy` drop-down menu, select `Geo-redundant storage (GRS)`. 1. Click `Save`. 1. Repeat steps 1-5 for each storage account requiring remediation. **Remediate from Azure CLI** For each storage account requiring remediation, run the following command to enable geo-redundant storage: ``` az storage account update --resource-group --name --sku Standard_GRS ``` **Remediate from PowerShell** For each storage account requiring remediation, run the following command to enable geo-redundant storage: ``` Set-AzStorageAccount -ResourceGroupName -Name -SkuName Standard_GRS ```", + "AuditProcedure": "**Audit from Azure Portal** 1. Go to `Storage accounts`. 1. Click on a storage account. 1. Under `Data management`, click `Redundancy`. 1. Ensure that `Redundancy` is set to `Geo-redundant storage (GRS)`. 1. Repeat steps 1-4 for each storage account. **Audit from Azure CLI** Run the following command to list storage accounts: ``` az storage account list ``` For each storage account, run the following command: ``` az storage account show --resource-group --name ``` Under `sku`, ensure that `name` is set to `Standard_GRS`. **Audit from PowerShell** Run the following command to list storage accounts: ``` Get-AzStorageAccount ``` Run the following command to get the storage account in a resource group with a given name: ``` $storageAccount = Get-AzStorageAccount -ResourceGroupName -Name ``` Run the following command to get the redundancy setting for the storage account: ``` $storageAccount.SKU.Name ``` Ensure that the command returns `Standard_GRS`. Repeat for each storage account. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [bf045164-79ba-4215-8f95-f8048dc1780b](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fbf045164-79ba-4215-8f95-f8048dc1780b) **- Name:** 'Geo-redundant storage should be enabled for Storage Accounts'", + "AdditionalInformation": "When choosing the best redundancy option, weigh the trade-offs between lower costs and higher availability. Key factors to consider include: - The method of data replication within the primary region. - The replication of data from a primary to a geographically distant secondary region for protection against regional disasters (geo-replication). - The necessity for read access to replicated data in the secondary region during an outage in the primary region (geo-replication with read access).", + "References": "https://learn.microsoft.com/en-us/azure/storage/common/storage-redundancy:https://learn.microsoft.com/en-us/azure/storage/common/redundancy-migration:https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest#az-storage-account-update:https://learn.microsoft.com/en-us/powershell/module/az.storage/set-azstorageaccount?view=azps-12.4.0:https://learn.microsoft.com/en-us/azure/storage/common/storage-disaster-recovery-guidance", + "DefaultValue": "When creating a storage account in the Azure Portal, the default redundancy setting is geo-redundant storage (GRS). Using the Azure CLI, the default is read-access geo-redundant storage (RA-GRS). In PowerShell, a redundancy level must be explicitly specified during account creation." + } + ] + } + ] +} diff --git a/prowler/compliance/azure/csa_ccm_4.0_azure.json b/prowler/compliance/azure/csa_ccm_4.0_azure.json deleted file mode 100644 index b4505ac089..0000000000 --- a/prowler/compliance/azure/csa_ccm_4.0_azure.json +++ /dev/null @@ -1,7548 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "Azure", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_auth_is_set_up", - "app_ftp_deployment_disabled", - "app_function_access_keys_configured", - "app_function_ftps_deployment_disabled", - "app_register_with_identity" - ] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_periodic_recurring_scans_enabled", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_scan_reports_configured", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_server_is_on", - "vm_backup_enabled" - ] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled", - "storage_geo_redundant_enabled", - "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "iam_custom_role_has_permissions_to_administer_resource_locks", - "monitor_alert_create_policy_assignment", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_policy_assignment", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_delete_sqlserver_fr", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "databricks_workspace_cmk_encryption_enabled", - "mysql_flexible_server_ssl_connection_enabled", - "postgresql_flexible_server_enforce_ssl_enabled", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "storage_infrastructure_encryption_is_enabled", - "storage_secure_transfer_required_is_enabled", - "storage_smb_channel_encryption_with_secure_algorithm", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "keyvault_key_rotation_enabled", - "mysql_flexible_server_minimum_tls_version_12", - "postgresql_flexible_server_enforce_ssl_enabled", - "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "storage_smb_protocol_version_is_latest" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "keyvault_access_only_through_private_endpoints", - "keyvault_private_endpoints", - "keyvault_rbac_enabled", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_rbac_enabled", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_non_rbac_secret_expiration_set", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set", - "storage_key_rotation_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "keyvault_recoverable" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_file_shares_soft_delete_is_enabled", - "storage_ensure_soft_delete_is_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on" - ] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "aisearch_service_not_publicly_accessible", - "aks_clusters_public_access_disabled", - "containerregistry_not_publicly_accessible", - "cosmosdb_account_firewall_use_selected_networks", - "sqlserver_unrestricted_inbound_access", - "storage_blob_public_access_level_is_disabled", - "storage_default_network_access_rule_is_denied", - "storage_ensure_private_endpoints_in_storage_accounts", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_http_is_redirected_to_https", - "app_ensure_using_http20", - "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "storage_secure_transfer_required_is_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "postgresql_flexible_server_log_retention_days_greater_3", - "sqlserver_auditing_retention_90_days", - "storage_ensure_file_shares_soft_delete_is_enabled", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "containerregistry_not_publicly_accessible", - "cosmosdb_account_firewall_use_selected_networks", - "defender_ensure_defender_for_storage_is_on", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "sqlserver_unrestricted_inbound_access", - "storage_account_key_access_disabled", - "storage_blob_public_access_level_is_disabled", - "storage_cross_tenant_replication_disabled", - "storage_default_network_access_rule_is_denied", - "storage_default_to_entra_authorization_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "vm_ensure_attached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_arm_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "entra_policy_default_users_cannot_create_security_groups", - "entra_policy_ensure_default_user_cannot_create_apps", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "app_function_identity_without_admin_privileges", - "entra_policy_ensure_default_user_cannot_create_apps", - "entra_policy_ensure_default_user_cannot_create_tenants", - "entra_policy_restricts_user_consent_for_apps", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users", - "keyvault_non_rbac_secret_expiration_set", - "keyvault_rbac_secret_expiration_set", - "storage_key_rotation_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users", - "entra_policy_guest_invite_only_for_admin_roles", - "entra_policy_guest_users_access_restrictions", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_user_with_vm_access_has_mfa", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "vm_jit_access_enabled" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "entra_trusted_named_locations_exists", - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "postgresql_flexible_server_entra_id_authentication_enabled", - "sqlserver_azuread_administrator_enabled" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "entra_user_with_vm_access_has_mfa" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "aks_cluster_rbac_enabled", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_function_not_publicly_accessible", - "entra_policy_user_consent_for_verified_apps", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_azure_services_are_trusted_to_access_is_enabled", - "storage_secure_transfer_required_is_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "aks_clusters_created_with_private_nodes", - "aks_clusters_public_access_disabled", - "aks_network_policy_enabled", - "network_bastion_host_exists", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted", - "network_watcher_enabled" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_assessments_vm_endpoint_protection_installed", - "defender_ensure_system_updates_are_applied", - "vm_ensure_using_managed_disks", - "vm_linux_enforce_ssh_authentication", - "vm_trusted_launch_enabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "app_function_not_publicly_accessible", - "app_function_vnet_integration_enabled", - "containerregistry_uses_private_link", - "cosmosdb_account_use_private_endpoints", - "databricks_workspace_vnet_injection_enabled", - "network_http_internet_access_restricted", - "storage_ensure_private_endpoints_in_storage_accounts" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "mysql_flexible_server_ssl_connection_enabled", - "postgresql_flexible_server_enforce_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_arm_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_iot_hub_defender_is_on", - "defender_ensure_wdatp_is_enabled", - "network_flow_log_captured_sent", - "network_watcher_enabled", - "sqlserver_microsoft_defender_enabled", - "vm_jit_access_enabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "storage_ensure_encryption_with_customer_managed_keys", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_create_update_security_solution", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_storage_account_with_activity_logs_is_private" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_policy_assignment", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_delete_sqlserver_fr", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "appinsights_ensure_is_configured", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "app_http_logs_enabled", - "appinsights_ensure_is_configured", - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "mysql_flexible_server_audit_log_connection_activated", - "mysql_flexible_server_audit_log_enabled", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days", - "postgresql_flexible_server_log_checkpoints_on", - "postgresql_flexible_server_log_connections_on", - "postgresql_flexible_server_log_disconnections_on", - "postgresql_flexible_server_log_retention_days_greater_3", - "sqlserver_auditing_enabled", - "sqlserver_auditing_retention_90_days" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_rbac_key_expiration_set" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high" - ] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_additional_email_configured_with_a_security_contact", - "defender_ensure_notify_emails_to_owners" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_assessments_vm_endpoint_protection_installed", - "defender_auto_provisioning_log_analytics_agent_vms_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_java_version_is_latest", - "app_ensure_php_version_is_latest", - "app_ensure_python_version_is_latest", - "app_function_latest_runtime_version", - "defender_ensure_system_updates_are_applied" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_cosmosdb_is_on", - "defender_ensure_defender_for_os_relational_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_defender_for_sql_servers_is_on", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_emails_notifications_admins_enabled", - "sqlserver_va_periodic_recurring_scans_enabled", - "sqlserver_va_scan_reports_configured", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled", - "sqlserver_microsoft_defender_enabled", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "storage_infrastructure_encryption_is_enabled", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_mcas_is_enabled" - ] - } - ] -} diff --git a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json index c845f75f60..655f649abe 100644 --- a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json +++ b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json @@ -328,6 +328,7 @@ "Checks": [ "entra_non_privileged_user_has_mfa", "entra_privileged_user_has_mfa", + "entra_user_with_recent_sign_in", "entra_user_with_vm_access_has_mfa", "iam_custom_role_has_permissions_to_administer_resource_locks", "iam_role_user_access_admin_restricted", diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 332d081590..62ffe2fdad 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -182,6 +182,7 @@ } ], "Checks": [ + "entra_user_with_recent_sign_in", "storage_key_rotation_90_days", "keyvault_key_rotation_enabled", "keyvault_rbac_key_expiration_set", @@ -430,6 +431,7 @@ } ], "Checks": [ + "recovery_vault_backup_policy_retention_adequate", "vm_backup_enabled", "vm_sufficient_daily_backup_retention_period", "storage_blob_versioning_is_enabled", @@ -438,7 +440,10 @@ "storage_geo_redundant_enabled", "keyvault_recoverable", "sqlserver_auditing_retention_90_days", - "postgresql_flexible_server_log_retention_days_greater_3" + "postgresql_flexible_server_log_retention_days_greater_3", + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled" ] }, { @@ -762,6 +767,17 @@ "mysql_flexible_server_minimum_tls_version_12", "mysql_flexible_server_ssl_connection_enabled", "postgresql_flexible_server_enforce_ssl_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -814,6 +830,17 @@ "mysql_flexible_server_ssl_connection_enabled", "postgresql_flexible_server_enforce_ssl_enabled", "databricks_workspace_cmk_encryption_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] } ] diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 0051795890..23392533de 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -271,6 +271,7 @@ } ], "Checks": [ + "aks_cluster_local_accounts_disabled", "defender_ensure_defender_for_containers_is_on", "defender_ensure_defender_for_cosmosdb_is_on", "defender_ensure_defender_for_databases_is_on", @@ -284,6 +285,7 @@ "defender_ensure_notify_alerts_severity_is_high", "entra_policy_guest_users_access_restrictions", "entra_policy_restricts_user_consent_for_apps", + "entra_user_with_recent_sign_in", "entra_users_cannot_create_microsoft_365_groups", "iam_custom_role_has_permissions_to_administer_resource_locks", "monitor_alert_create_update_security_solution", @@ -332,7 +334,8 @@ "entra_policy_guest_invite_only_for_admin_roles", "entra_policy_guest_users_access_restrictions", "entra_policy_restricts_user_consent_for_apps", - "entra_policy_user_consent_for_verified_apps" + "entra_policy_user_consent_for_verified_apps", + "entra_user_with_recent_sign_in" ] }, { @@ -1102,6 +1105,7 @@ } ], "Checks": [ + "entra_authentication_methods_policy_strong_auth_enforced", "entra_conditional_access_policy_require_mfa_for_management_app", "entra_non_privileged_user_has_mfa entra_privileged_user_has_mfa", "entra_user_with_vm_access_has_mfa", @@ -1266,7 +1270,12 @@ "Check_Summary": "Backup copies of information, software and systems should be maintained and regularly tested in accordance with the agreed topic-specific policy on backup." } ], - "Checks": [] + "Checks": [ + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "recovery_vault_backup_policy_retention_adequate" + ] }, { "Id": "A.8.14", @@ -1293,7 +1302,12 @@ "storage_ensure_private_endpoints_in_storage_accounts", "storage_secure_transfer_required_is_enabled", "vm_ensure_using_managed_disks", - "vm_trusted_launch_enabled" + "vm_trusted_launch_enabled", + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_geo_redundant_backup_enabled", + "mysql_flexible_server_high_availability_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_high_availability_enabled" ] }, { @@ -1337,6 +1351,8 @@ } ], "Checks": [ + "aks_cluster_azure_monitor_enabled", + "defender_ensure_defender_cspm_is_on", "monitor_alert_create_policy_assignment", "monitor_alert_create_update_nsg", "monitor_alert_create_update_public_ip_address_rule", @@ -1407,6 +1423,9 @@ "aks_network_policy_enabled", "containerregistry_not_publicly_accessible", "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_no_public_ip_enabled", + "databricks_workspace_public_network_access_disabled", "network_bastion_host_exists", "network_flow_log_captured_sent", "network_flow_log_more_than_90_days", @@ -1414,7 +1433,9 @@ "network_public_ip_shodan", "network_rdp_internet_access_restricted", "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", "network_udp_internet_access_restricted", + "network_vnet_ddos_protection_enabled", "network_watcher_enabled" ] }, @@ -1468,6 +1489,7 @@ "network_public_ip_shodan", "network_rdp_internet_access_restricted", "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", "network_udp_internet_access_restricted", "network_watcher_enabled" ] @@ -1500,6 +1522,8 @@ ], "Checks": [ "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "entra_app_registration_credential_not_expired", "monitor_storage_account_with_activity_logs_cmk_encrypted", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled", diff --git a/prowler/compliance/azure/mitre_attack_azure.json b/prowler/compliance/azure/mitre_attack_azure.json index 92ddd72713..8b338da534 100644 --- a/prowler/compliance/azure/mitre_attack_azure.json +++ b/prowler/compliance/azure/mitre_attack_azure.json @@ -212,6 +212,7 @@ "Description": "Adversaries may obtain and abuse credentials of existing accounts as a means of gaining Initial Access, Persistence, Privilege Escalation, or Defense Evasion. Compromised credentials may be used to bypass access controls placed on various resources on systems within the network and may even be used for persistent access to remote systems and externally available services, such as VPNs, Outlook Web Access, network devices, and remote desktop.[1] Compromised credentials may also grant an adversary increased privilege to specific systems or access to restricted areas of the network. Adversaries may choose not to use malware or tools in conjunction with the legitimate access those credentials provide to make it harder to detect their presence.", "TechniqueURL": "https://attack.mitre.org/techniques/T1078/", "Checks": [ + "entra_app_registration_credential_not_expired", "entra_conditional_access_policy_require_mfa_for_management_api", "entra_global_admin_in_less_than_five_users", "entra_non_privileged_user_has_mfa", diff --git a/prowler/compliance/azure/nis2_azure.json b/prowler/compliance/azure/nis2_azure.json index 5c48c22f6b..0ae814fad7 100644 --- a/prowler/compliance/azure/nis2_azure.json +++ b/prowler/compliance/azure/nis2_azure.json @@ -1133,6 +1133,17 @@ "defender_ensure_defender_for_dns_is_on", "sqlserver_tde_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ], "Attributes": [ { "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", @@ -1164,6 +1175,17 @@ "network_udp_internet_access_restricted", "network_watcher_enabled" ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ], "Attributes": [ { "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", @@ -1887,6 +1909,17 @@ "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled" ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ], "Attributes": [ { "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", diff --git a/prowler/compliance/azure/prowler_threatscore_azure.json b/prowler/compliance/azure/prowler_threatscore_azure.json index aa801887b2..a030bb845b 100644 --- a/prowler/compliance/azure/prowler_threatscore_azure.json +++ b/prowler/compliance/azure/prowler_threatscore_azure.json @@ -459,7 +459,7 @@ "Id": "2.2.6", "Description": "Ensure that 'Public Network Access' is 'Disabled' for storage accounts", "Checks": [ - "storage_blob_public_access_level_is_disabled" + "storage_account_public_network_access_disabled" ], "Attributes": [ { @@ -709,17 +709,17 @@ }, { "Id": "3.1.8", - "Description": "Ensure that Network Security Group Flow logs are captured and sent to Log Analytics", + "Description": "Ensure that Network Watcher flow logs are captured and sent to Log Analytics", "Checks": [ "network_flow_log_captured_sent" ], "Attributes": [ { - "Title": "Network Security Group Flow logs are captured and sent to Log Analytics", + "Title": "Network Watcher flow logs are captured and sent to Log Analytics", "Section": "3. Logging and Monitoring", "SubSection": "3.1 Logging", - "AttributeDescription": "Ensure that network flow logs are collected and sent to a central Log Analytics workspace for monitoring and analysis.", - "AdditionalInformation": "Capturing network flow logs provides visibility into traffic patterns across your network, helping detect anomalies, potential lateral movement, and security threats. These logs integrate with Azure Monitor and Azure Sentinel, enabling advanced analytics and visualization for improved network security and incident response.", + "AttributeDescription": "Ensure that Network Watcher flow logs for supported targets, such as virtual networks and network security groups, are collected and sent to a central Log Analytics workspace for monitoring and analysis.", + "AdditionalInformation": "Capturing Network Watcher flow logs provides visibility into traffic patterns across your network, helping detect anomalies, potential lateral movement, and security threats. These logs integrate with Azure Monitor and Azure Sentinel, enabling advanced analytics and visualization for improved network security and incident response. For new deployments, prefer virtual network flow logs because NSG flow logs are on the retirement path.", "LevelOfRisk": 4, "Weight": 100 } @@ -763,17 +763,17 @@ }, { "Id": "3.2.1", - "Description": "Ensure that Network Security Group Flow Log retention period is 'greater than 90 days'", + "Description": "Ensure that Network Watcher flow log retention period is '0 or at least 90 days'", "Checks": [ "network_flow_log_more_than_90_days" ], "Attributes": [ { - "Title": "Network Security Group Flow Log retention period is 'greater than 90 days'", + "Title": "Network Watcher flow log retention period is '0 or at least 90 days'", "Section": "3. Logging and Monitoring", "SubSection": "3.2 Retention", - "AttributeDescription": "Enable Network Security Group (NSG) Flow Logs and configure the retention period to at least 90 days to capture and store IP traffic data for security monitoring and analysis.", - "AdditionalInformation": "NSG Flow Logs provide visibility into network traffic, helping detect anomalies, unauthorized access, and potential security breaches. Retaining logs for at least 90 days ensures that historical data is available for incident investigation, compliance, and forensic analysis, strengthening overall network security monitoring.", + "AttributeDescription": "Enable Network Watcher flow logs for supported targets, such as virtual networks and network security groups, and configure the retention period to 0 for unlimited retention or at least 90 days to capture and store IP traffic data for security monitoring and analysis.", + "AdditionalInformation": "Network Watcher flow logs provide visibility into network traffic, helping detect anomalies, unauthorized access, and potential security breaches. Retaining logs for 0 days (unlimited) or at least 90 days ensures that historical data is available for incident investigation, compliance, and forensic analysis, strengthening overall network security monitoring. For new deployments, prefer virtual network flow logs because NSG flow logs are on the retirement path.", "LevelOfRisk": 3, "Weight": 10 } diff --git a/prowler/compliance/azure/secnumcloud_3.2_azure.json b/prowler/compliance/azure/secnumcloud_3.2_azure.json index ef8c94113e..3f2e8c7603 100644 --- a/prowler/compliance/azure/secnumcloud_3.2_azure.json +++ b/prowler/compliance/azure/secnumcloud_3.2_azure.json @@ -339,6 +339,7 @@ } ], "Checks": [ + "entra_authentication_methods_policy_strong_auth_enforced", "entra_non_privileged_user_has_mfa", "entra_privileged_user_has_mfa", "entra_security_defaults_enabled" @@ -439,6 +440,25 @@ "postgresql_flexible_server_enforce_ssl_enabled", "mysql_flexible_server_ssl_connection_enabled", "mysql_flexible_server_minimum_tls_version_12" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } ] }, { @@ -493,7 +513,8 @@ "keyvault_non_rbac_secret_expiration_set", "keyvault_logging_enabled", "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints" + "keyvault_access_only_through_private_endpoints", + "entra_app_registration_credential_not_expired" ] }, { diff --git a/prowler/compliance/azure/soc2_azure.json b/prowler/compliance/azure/soc2_azure.json index d8936f5485..c3b4db6e39 100644 --- a/prowler/compliance/azure/soc2_azure.json +++ b/prowler/compliance/azure/soc2_azure.json @@ -241,7 +241,8 @@ "app_function_not_publicly_accessible", "containerregistry_not_publicly_accessible", "network_public_ip_shodan", - "storage_blob_public_access_level_is_disabled" + "storage_blob_public_access_level_is_disabled", + "entra_app_registration_credential_not_expired" ] }, { @@ -265,6 +266,17 @@ "sqlserver_tde_encryption_enabled", "sqlserver_unrestricted_inbound_access", "storage_secure_transfer_required_is_enabled" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { @@ -307,7 +319,19 @@ "app_minimum_tls_version_12", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12" + "storage_ensure_minimum_tls_version_12", + "network_subnet_nsg_associated" + ], + "ConfigRequirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } ] }, { diff --git a/prowler/compliance/cis_controls_8.1.json b/prowler/compliance/cis_controls_8.1.json new file mode 100644 index 0000000000..c03d9e806b --- /dev/null +++ b/prowler/compliance/cis_controls_8.1.json @@ -0,0 +1,4554 @@ +{ + "framework": "CIS-Controls", + "name": "CIS Controls v8.1", + "version": "8.1", + "description": "The CIS Critical Security Controls (CIS Controls) v8.1 are a prioritized set of Safeguards to mitigate the most prevalent cyber-attacks against systems and networks. They are organized into 18 top-level Controls and mapped to three Implementation Groups (IG1, IG2, IG3). This is a cross-provider mapping of Prowler checks to the CIS Controls Safeguards that can be assessed automatically against cloud and platform configurations.", + "icon": "cisecurity", + "attributes_metadata": [ + { + "key": "Section", + "label": "CIS Control", + "type": "str", + "required": true, + "enum": [ + "1. Inventory and Control of Enterprise Assets", + "2. Inventory and Control of Software Assets", + "3. Data Protection", + "4. Secure Configuration of Enterprise Assets and Software", + "5. Account Management", + "6. Access Control Management", + "7. Continuous Vulnerability Management", + "8. Audit Log Management", + "9. Email and Web Browser Protections", + "10. Malware Defenses", + "11. Data Recovery", + "12. Network Infrastructure Management", + "13. Network Monitoring and Defense", + "14. Security Awareness and Skills Training", + "15. Service Provider Management", + "16. Application Software Security", + "17. Incident Response Management", + "18. Penetration Testing" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "Function", + "label": "Security Function", + "type": "str", + "required": false, + "enum": [ + "Identify", + "Protect", + "Detect", + "Respond", + "Recover", + "Govern" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "AssetType", + "label": "Asset Type", + "type": "str", + "required": false, + "enum": [ + "Data", + "Devices", + "Documentation", + "Network", + "Software", + "Users" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "ImplementationGroups", + "label": "Implementation Groups", + "type": "list_str", + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + } + ], + "outputs": { + "table_config": { + "group_by": "Section" + }, + "pdf_config": { + "language": "en", + "primary_color": "#cc0000", + "secondary_color": "#7a1f1f", + "bg_color": "#FAF0F0", + "group_by_field": "Section", + "sections": [ + "1. Inventory and Control of Enterprise Assets", + "2. Inventory and Control of Software Assets", + "3. Data Protection", + "4. Secure Configuration of Enterprise Assets and Software", + "5. Account Management", + "6. Access Control Management", + "7. Continuous Vulnerability Management", + "8. Audit Log Management", + "9. Email and Web Browser Protections", + "10. Malware Defenses", + "11. Data Recovery", + "12. Network Infrastructure Management", + "13. Network Monitoring and Defense", + "14. Security Awareness and Skills Training", + "15. Service Provider Management", + "16. Application Software Security", + "17. Incident Response Management", + "18. Penetration Testing" + ], + "section_short_names": { + "1. Inventory and Control of Enterprise Assets": "CIS 1", + "2. Inventory and Control of Software Assets": "CIS 2", + "3. Data Protection": "CIS 3", + "4. Secure Configuration of Enterprise Assets and Software": "CIS 4", + "5. Account Management": "CIS 5", + "6. Access Control Management": "CIS 6", + "7. Continuous Vulnerability Management": "CIS 7", + "8. Audit Log Management": "CIS 8", + "9. Email and Web Browser Protections": "CIS 9", + "10. Malware Defenses": "CIS 10", + "11. Data Recovery": "CIS 11", + "12. Network Infrastructure Management": "CIS 12", + "13. Network Monitoring and Defense": "CIS 13", + "14. Security Awareness and Skills Training": "CIS 14", + "15. Service Provider Management": "CIS 15", + "16. Application Software Security": "CIS 16", + "17. Incident Response Management": "CIS 17", + "18. Penetration Testing": "CIS 18" + }, + "charts": [ + { + "id": "section_compliance", + "type": "horizontal_bar", + "group_by": "Section", + "title": "Compliance Score by CIS Control", + "y_label": "CIS Control", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "1.1", + "name": "Establish and Maintain Detailed Enterprise Asset Inventory", + "description": "Establish and maintain an accurate, detailed, and up-to-date inventory of all enterprise assets with the potential to store or process data, to include: end-user devices (including portable and mobile), network devices, non-computing/IoT devices, and servers. Ensure the inventory records the network address (if static), hardware address, machine name, enterprise asset owner, department for each asset, and whether the asset has been approved to connect to the network. For mobile end-user devices, MDM type tools can support this process, where appropriate. This inventory includes assets connected to the infrastructure physically, virtually, remotely, and those within cloud environments. Additionally, it includes assets that are regularly connected to the enterprise's network infrastructure, even if they are not under control of the enterprise. Review and update the inventory of all enterprise assets bi-annually, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Identify", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "resourceexplorer2_indexes_found" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "1.2", + "name": "Address Unauthorized Assets", + "description": "Ensure that a process exists to address unauthorized assets on a weekly basis. The enterprise may choose to remove the asset from the network, deny the asset from connecting remotely to the network, or quarantine the asset.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Respond", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "1.3", + "name": "Utilize an Active Discovery Tool", + "description": "Utilize an active discovery tool to identify assets connected to the enterprise's network. Configure the active discovery tool to execute daily, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "1.4", + "name": "Use Dynamic Host Configuration Protocol (DHCP) Logging to Update Enterprise Asset Inventory", + "description": "Use DHCP logging on all DHCP servers or Internet Protocol (IP) address management tools to update the enterprise's asset inventory. Review and use logs to update the enterprise's asset inventory weekly, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Identify", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "1.5", + "name": "Use a Passive Asset Discovery Tool", + "description": "Use a passive discovery tool to identify assets connected to the enterprise's network. Review and use scans to update the enterprise's asset inventory at least weekly, or more frequently.", + "attributes": { + "Section": "1. Inventory and Control of Enterprise Assets", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.1", + "name": "Establish and Maintain a Software Inventory", + "description": "Establish and maintain a detailed inventory of all licensed software installed on enterprise assets. The software inventory must document the title, publisher, initial install/use date, and business purpose for each entry; where appropriate, include the Uniform Resource Locator (URL), app store(s), version(s), deployment mechanism, decommission date, and number of licenses. Review and update the software inventory bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.2", + "name": "Ensure Authorized Software is Currently Supported", + "description": "Ensure that only currently supported software is designated as authorized in the software inventory for enterprise assets. If software is unsupported, yet necessary for the fulfillment of the enterprise's mission, document an exception detailing mitigating controls and residual risk acceptance. For any unsupported software without an exception documentation, designate as unauthorized. Review the software list to verify software support at least monthly, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "awslambda_function_using_supported_runtimes", + "ec2_instance_with_outdated_ami", + "eks_cluster_uses_a_supported_version", + "kafka_cluster_uses_latest_version", + "rds_instance_deprecated_engine_version" + ] + } + }, + { + "id": "2.3", + "name": "Address Unauthorized Software", + "description": "Ensure that unauthorized software is either removed from use on enterprise assets or receives a documented exception. Review monthly, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Respond", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.4", + "name": "Utilize Automated Software Inventory Tools", + "description": "Utilize software inventory tools, when possible, throughout the enterprise to automate the discovery and documentation of installed software.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Detect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.5", + "name": "Allowlist Authorized Software", + "description": "Use technical controls, such as application allowlisting, to ensure that only authorized software can execute or be accessed. Reassess bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "chat_apps_installation_disabled", + "marketplace_apps_access_restricted", + "security_app_access_restricted" + ], + "m365": [ + "entra_admin_consent_workflow_enabled", + "entra_policy_restricts_user_consent_for_apps", + "entra_thirdparty_integrated_apps_not_allowed" + ] + } + }, + { + "id": "2.6", + "name": "Allowlist Authorized Libraries", + "description": "Use technical controls to ensure that only authorized software libraries, such as specific .dll, .ocx, and .so files, are allowed to load into a system process. Block unauthorized libraries from loading into a system process. Reassess bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "2.7", + "name": "Allowlist Authorized Scripts", + "description": "Use technical controls, such as digital signatures and version control, to ensure that only authorized scripts, such as specific .ps1, and .py files are allowed to execute. Block unauthorized scripts from executing. Reassess bi-annually, or more frequently.", + "attributes": { + "Section": "2. Inventory and Control of Software Assets", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.1", + "name": "Establish and Maintain a Data Management Process", + "description": "Establish and maintain a documented data management process. In the process, address data sensitivity, data owner, handling of data, data retention limits, and disposal requirements, based on sensitivity and retention standards for the enterprise. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Govern", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.2", + "name": "Establish and Maintain a Data Inventory", + "description": "Establish and maintain a data inventory based on the enterprise's data management process. Inventory sensitive data, at a minimum. Review and update inventory annually, at a minimum, with a priority on sensitive data.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Identify", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "macie_automated_sensitive_data_discovery_enabled", + "macie_is_enabled" + ] + } + }, + { + "id": "3.3", + "name": "Configure Data Access Control Lists", + "description": "Configure data access control lists based on a user's need to know. Apply data access control lists, also known as access permissions, to local and remote file systems, databases, and applications.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "actiontrail_oss_bucket_not_publicly_accessible", + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist" + ], + "aws": [ + "awslambda_function_not_publicly_accessible", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudwatch_log_group_not_publicly_accessible", + "codebuild_project_not_publicly_accessible", + "dynamodb_table_cross_account_access", + "ec2_ami_public", + "ecr_repositories_not_publicly_accessible", + "efs_access_point_enforce_root_directory", + "efs_access_point_enforce_user_identity", + "efs_mount_target_not_publicly_accessible", + "efs_not_publicly_accessible", + "eventbridge_bus_cross_account_access", + "eventbridge_bus_exposed", + "glacier_vaults_policy_public_access", + "glue_data_catalogs_not_publicly_accessible", + "kms_key_not_publicly_accessible", + "s3_access_point_public_access_block", + "s3_account_level_public_access_blocks", + "s3_bucket_acl_prohibited", + "s3_bucket_cross_account_access", + "s3_bucket_level_public_access_block", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", + "s3_multi_region_access_point_public_access_block", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_not_publicly_accessible", + "ses_identity_not_publicly_accessible", + "sns_topics_not_publicly_accessible", + "sqs_queues_not_publicly_accessible", + "ssm_documents_set_as_public" + ], + "azure": [ + "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_use_aad_and_rbac", + "keyvault_rbac_enabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_key_access_disabled", + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_access", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "compute_image_not_publicly_shared", + "kms_key_not_publicly_accessible", + "secretmanager_secret_not_publicly_accessible" + ], + "github": [ + "organization_default_repository_permission_strict" + ], + "googleworkspace": [ + "calendar_external_sharing_primary_calendar", + "calendar_external_sharing_secondary_calendar", + "chat_external_file_sharing_disabled", + "chat_external_messaging_restricted", + "chat_external_spaces_restricted", + "chat_internal_file_sharing_disabled", + "drive_access_checker_recipients_only", + "drive_internal_users_distribute_content", + "drive_publishing_files_disabled", + "drive_shared_drive_disable_download_print_copy", + "drive_shared_drive_managers_cannot_override", + "drive_shared_drive_members_only_access", + "drive_sharing_allowlisted_domains", + "gmail_auto_forwarding_disabled", + "gmail_mail_delegation_disabled", + "groups_external_access_restricted", + "groups_view_conversations_restricted" + ], + "kubernetes": [ + "core_no_secrets_envs", + "rbac_minimize_secret_access" + ], + "m365": [ + "admincenter_external_calendar_sharing_disabled", + "admincenter_groups_not_public_visibility", + "admincenter_organization_customer_lockbox_enabled", + "sharepoint_external_sharing_managed", + "sharepoint_external_sharing_restricted", + "sharepoint_guest_sharing_restricted", + "teams_external_domains_restricted", + "teams_external_file_sharing_restricted", + "teams_external_users_cannot_start_conversations", + "teams_unmanaged_communication_disabled" + ], + "mongodbatlas": [ + "clusters_authentication_enabled" + ], + "openstack": [ + "image_not_publicly_visible", + "image_not_shared_with_multiple_projects", + "objectstorage_container_acl_not_globally_shared", + "objectstorage_container_listing_disabled", + "objectstorage_container_public_read_acl_disabled", + "objectstorage_container_write_acl_restricted" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "objectstorage_bucket_not_publicly_accessible" + ], + "vercel": [ + "project_deployment_protection_enabled", + "project_password_protection_enabled", + "project_production_deployment_protection_enabled", + "team_member_role_least_privilege" + ] + } + }, + { + "id": "3.4", + "name": "Enforce Data Retention", + "description": "Retain data according to the enterprise's documented data management process. Data retention must include both minimum and maximum timelines.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ecr_repositories_lifecycle_policy_enabled", + "kinesis_stream_data_retention_period", + "s3_bucket_lifecycle_enabled" + ], + "gcp": [ + "cloudstorage_bucket_lifecycle_management_enabled", + "cloudstorage_bucket_sufficient_retention_period" + ], + "stackit": [ + "objectstorage_bucket_object_lock_enabled", + "objectstorage_bucket_retention_policy" + ] + } + }, + { + "id": "3.5", + "name": "Securely Dispose of Data", + "description": "Securely dispose of data as outlined in the enterprise's documented data management process. Ensure the disposal process and method are commensurate with the data sensitivity.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.6", + "name": "Encrypt Data on End-User Devices", + "description": "Encrypt data on end-user devices containing sensitive data. Example implementations can include: Windows BitLocker®, Apple FileVault®, Linux® dm-crypt.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.7", + "name": "Establish and Maintain a Data Classification Scheme", + "description": "Establish and maintain an overall data classification scheme for the enterprise. Enterprises may use labels, such as \"Sensitive,\" \"Confidential,\" and \"Public,\" and classify their data according to those labels. Review and update the classification scheme annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Identify", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.8", + "name": "Document Data Flows", + "description": "Document data flows. Data flow documentation includes service provider data flows and should be based on the enterprise's data management process. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Identify", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.9", + "name": "Encrypt Data on Removable Media", + "description": "Encrypt data on removable media.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.10", + "name": "Encrypt Sensitive Data in Transit", + "description": "Encrypt sensitive data in transit. Example implementations can include: Transport Layer Security (TLS) and Open Secure Shell (OpenSSH).", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "aws": [ + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "dms_endpoint_redis_in_transit_encryption_enabled", + "dms_endpoint_ssl_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "elb_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elb_ssl_listeners_use_acm_certificate", + "elbv2_insecure_ssl_ciphers", + "elbv2_nlb_tls_termination_enabled", + "elbv2_ssl_listeners", + "glue_database_connections_ssl_enabled", + "kafka_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "opensearch_service_domains_https_communications_enforced", + "opensearch_service_domains_node_to_node_encryption_enabled", + "rds_instance_transport_encrypted", + "redshift_cluster_in_transit_encryption_enabled", + "s3_bucket_secure_transport_policy", + "sagemaker_training_jobs_intercontainer_encryption_enabled", + "sns_subscription_not_using_http_endpoints", + "transfer_server_in_transit_encryption_enabled" + ], + "azure": [ + "app_ensure_http_is_redirected_to_https", + "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm" + ], + "cloudflare": [ + "zone_automatic_https_rewrites_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_min_tls_version_secure", + "zone_ssl_strict", + "zone_tls_1_3_enabled", + "zone_universal_ssl_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections" + ], + "kubernetes": [ + "apiserver_etcd_cafile_set", + "apiserver_etcd_tls_config", + "apiserver_kubelet_cert_auth", + "apiserver_kubelet_tls_auth", + "apiserver_strong_ciphers_only", + "apiserver_tls_config", + "etcd_no_auto_tls", + "etcd_no_peer_auto_tls", + "etcd_peer_tls_config", + "etcd_tls_encryption", + "kubelet_strong_ciphers_only", + "kubelet_tls_cert_and_key" + ], + "mongodbatlas": [ + "clusters_tls_enabled" + ], + "oraclecloud": [ + "compute_instance_in_transit_encryption_enabled" + ], + "vercel": [ + "domain_ssl_certificate_valid" + ] + } + }, + { + "id": "3.11", + "name": "Encrypt Sensitive Data at Rest", + "description": "Encrypt sensitive data at rest on servers, applications, and databases. Storage-layer encryption, also known as server-side encryption, meets the minimum requirement of this Safeguard. Additional encryption methods may include application-layer encryption, also known as client-side encryption, where access to the data storage device(s) does not permit access to the plain-text data.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom" + ], + "aws": [ + "apigateway_restapi_cache_encrypted", + "athena_workgroup_encryption", + "awslambda_function_env_vars_not_encrypted_with_cmk", + "backup_recovery_point_encrypted", + "backup_vaults_encrypted", + "bedrock_model_invocation_logs_encryption_enabled", + "bedrock_prompt_encrypted_with_cmk", + "cloudtrail_kms_encryption_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "codebuild_project_s3_logs_encrypted", + "codebuild_report_group_export_encrypted", + "documentdb_cluster_storage_encrypted", + "dynamodb_accelerator_cluster_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "ec2_ebs_default_encryption", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_volume_encryption", + "efs_encryption_at_rest_enabled", + "eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "elasticache_redis_cluster_rest_encryption_enabled", + "firehose_stream_encrypted_at_rest", + "glue_data_catalogs_connection_passwords_encryption_enabled", + "glue_data_catalogs_metadata_encryption_enabled", + "glue_development_endpoints_s3_encryption_enabled", + "glue_etl_jobs_amazon_s3_encryption_enabled", + "glue_etl_jobs_cloudwatch_logs_encryption_enabled", + "glue_ml_transform_encrypted_at_rest", + "kafka_cluster_encryption_at_rest_uses_cmk", + "kinesis_stream_encrypted_at_rest", + "neptune_cluster_snapshot_encrypted", + "neptune_cluster_storage_encrypted", + "opensearch_service_domains_encryption_at_rest_enabled", + "rds_cluster_storage_encrypted", + "rds_instance_storage_encrypted", + "rds_snapshots_encrypted", + "redshift_cluster_encrypted_at_rest", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "sagemaker_notebook_instance_encryption_enabled", + "sagemaker_training_jobs_volume_and_output_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled", + "sqs_queues_server_side_encryption_enabled", + "stepfunctions_statemachine_encrypted_with_cmk", + "storagegateway_fileshare_encryption_enabled", + "workspaces_volume_encryption_enabled" + ], + "azure": [ + "databricks_workspace_cmk_encryption_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "cloudsql_instance_cmek_encryption_enabled", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled" + ], + "kubernetes": [ + "apiserver_encryption_provider_config_set" + ], + "linode": [ + "compute_instance_disk_encryption_enabled" + ], + "mongodbatlas": [ + "clusters_encryption_at_rest_enabled" + ], + "openstack": [ + "blockstorage_volume_encryption_enabled" + ], + "oraclecloud": [ + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk", + "filestorage_file_system_encrypted_with_cmk", + "objectstorage_bucket_encrypted_with_cmk" + ], + "vercel": [ + "project_environment_no_secrets_in_plain_type" + ] + } + }, + { + "id": "3.12", + "name": "Segment Data Processing and Storage Based on Sensitivity", + "description": "Segment data processing and storage based on the sensitivity of the data. Do not process sensitive data on enterprise assets intended for lower sensitivity data.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "3.13", + "name": "Deploy a Data Loss Prevention Solution", + "description": "Implement an automated tool, such as a host-based Data Loss Prevention (DLP) tool to identify all sensitive data stored, processed, or transmitted through enterprise assets, including those located onsite or at a remote service provider, and update the enterprise's data inventory.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "macie_automated_sensitive_data_discovery_enabled", + "macie_is_enabled" + ], + "googleworkspace": [ + "security_dlp_drive_rules_configured" + ], + "openstack": [ + "blockstorage_snapshot_metadata_sensitive_data", + "blockstorage_volume_metadata_sensitive_data", + "compute_instance_metadata_sensitive_data", + "objectstorage_container_metadata_sensitive_data" + ] + } + }, + { + "id": "3.14", + "name": "Log Sensitive Data Access", + "description": "Log sensitive data access, including modification and disposal.", + "attributes": { + "Section": "3. Data Protection", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "oss_bucket_logging_enabled", + "rds_instance_sql_audit_enabled" + ], + "aws": [ + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "opensearch_service_domains_audit_logging_enabled" + ], + "azure": [ + "keyvault_logging_enabled", + "mysql_flexible_server_audit_log_enabled", + "sqlserver_auditing_enabled" + ], + "gcp": [ + "cloudstorage_audit_logs_enabled" + ], + "m365": [ + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ], + "mongodbatlas": [ + "projects_auditing_enabled" + ], + "oraclecloud": [ + "objectstorage_bucket_logging_enabled" + ] + } + }, + { + "id": "4.1", + "name": "Establish and Maintain a Secure Configuration Process", + "description": "Establish and maintain a documented secure configuration process for enterprise assets (end-user devices, including portable and mobile, non-computing/IoT devices, and servers) and software (operating systems and applications). Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "policy_ensure_asc_enforcement_enabled" + ], + "kubernetes": [ + "apiserver_request_timeout_set", + "controllermanager_garbage_collection", + "kubelet_conf_file_ownership", + "kubelet_conf_file_permissions", + "kubelet_config_yaml_ownership", + "kubelet_config_yaml_permissions", + "kubelet_event_record_qps", + "kubelet_service_file_ownership_root", + "kubelet_service_file_permissions", + "kubelet_streaming_connection_timeout" + ], + "openstack": [ + "compute_instance_config_drive_enabled", + "compute_instance_locked_status_enabled", + "compute_instance_trusted_image_certificates", + "image_secure_boot_enabled", + "image_signature_verification_enabled" + ] + } + }, + { + "id": "4.2", + "name": "Establish and Maintain a Secure Configuration Process for Network Infrastructure", + "description": "Establish and maintain a documented secure configuration process for network devices. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.3", + "name": "Configure Automatic Session Locking on Enterprise Assets", + "description": "Configure automatic session locking on enterprise assets after a defined period of inactivity. For general purpose operating systems, the period must not exceed 15 minutes. For mobile end-user devices, the period must not exceed 2 minutes.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "appstream_fleet_maximum_session_duration", + "appstream_fleet_session_disconnect_timeout", + "appstream_fleet_session_idle_disconnect_timeout" + ], + "googleworkspace": [ + "security_session_duration_limited" + ], + "m365": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", + "entra_intune_enrollment_sign_in_frequency_every_time" + ], + "okta": [ + "application_admin_console_session_idle_timeout_15min", + "signon_global_session_cookies_not_persistent", + "signon_global_session_idle_timeout_15min", + "signon_global_session_lifetime_18h" + ] + } + }, + { + "id": "4.4", + "name": "Implement and Manage a Firewall on Servers", + "description": "Implement and manage a firewall on servers, where supported. Example implementations include a virtual firewall, operating system firewall, or a third-party firewall agent.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet" + ], + "aws": [ + "ec2_instance_port_cassandra_exposed_to_internet", + "ec2_instance_port_cifs_exposed_to_internet", + "ec2_instance_port_elasticsearch_kibana_exposed_to_internet", + "ec2_instance_port_ftp_exposed_to_internet", + "ec2_instance_port_kafka_exposed_to_internet", + "ec2_instance_port_kerberos_exposed_to_internet", + "ec2_instance_port_ldap_exposed_to_internet", + "ec2_instance_port_memcached_exposed_to_internet", + "ec2_instance_port_mongodb_exposed_to_internet", + "ec2_instance_port_mysql_exposed_to_internet", + "ec2_instance_port_oracle_exposed_to_internet", + "ec2_instance_port_postgresql_exposed_to_internet", + "ec2_instance_port_rdp_exposed_to_internet", + "ec2_instance_port_redis_exposed_to_internet", + "ec2_instance_port_sqlserver_exposed_to_internet", + "ec2_instance_port_ssh_exposed_to_internet", + "ec2_instance_port_telnet_exposed_to_internet", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23", + "ec2_securitygroup_allow_wide_open_public_ipv4", + "ec2_securitygroup_default_restrict_traffic", + "ec2_securitygroup_with_many_ingress_egress_rules" + ], + "azure": [ + "network_subnet_nsg_associated", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "kubernetes": [ + "kubelet_manage_iptables" + ], + "linode": [ + "networking_firewall_assigned_to_devices", + "networking_firewall_default_inbound_policy_drop", + "networking_firewall_default_outbound_policy_drop", + "networking_firewall_inbound_rules_configured", + "networking_firewall_outbound_rules_configured", + "networking_firewall_status_enabled" + ], + "mongodbatlas": [ + "organizations_api_access_list_required", + "projects_network_access_list_exposed_to_internet" + ], + "nhn": [ + "compute_instance_security_groups" + ], + "openstack": [ + "compute_instance_security_groups_attached", + "networking_port_security_disabled", + "networking_security_group_allows_all_ingress_from_internet", + "networking_security_group_allows_rdp_from_internet", + "networking_security_group_allows_ssh_from_internet" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "stackit": [ + "iaas_security_group_all_traffic_unrestricted", + "iaas_security_group_database_unrestricted", + "iaas_security_group_rdp_unrestricted", + "iaas_security_group_ssh_unrestricted" + ], + "vercel": [ + "security_custom_rules_configured", + "security_ip_blocking_rules_configured", + "security_waf_enabled" + ] + } + }, + { + "id": "4.5", + "name": "Implement and Manage a Firewall on End-User Devices", + "description": "Implement and manage a host-based firewall or port-filtering tool on end-user devices, with a default-deny rule that drops all traffic except those services and ports that are explicitly allowed.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.6", + "name": "Securely Manage Enterprise Assets and Software", + "description": "Securely manage enterprise assets and software. Example implementations include managing configuration through version-controlled Infrastructure-as-Code (IaC) and accessing administrative interfaces over secure network protocols, such as Secure Shell (SSH) and Hypertext Transfer Protocol Secure (HTTPS). Do not use insecure management protocols, such as Telnet (Teletype Network) and HTTP, unless operationally essential.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "aws": [ + "autoscaling_group_launch_configuration_requires_imdsv2", + "ec2_instance_account_imdsv2_enabled", + "ec2_instance_imdsv2_enabled", + "ec2_instance_managed_by_ssm", + "ec2_launch_template_imdsv2_required" + ], + "azure": [ + "app_client_certificates_on", + "app_ensure_http_is_redirected_to_https", + "storage_secure_transfer_required_is_enabled", + "vm_linux_enforce_ssh_authentication" + ], + "cloudflare": [ + "zone_automatic_https_rewrites_enabled", + "zone_https_redirect_enabled", + "zone_min_tls_version_secure", + "zone_ssl_strict" + ], + "gcp": [ + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_project_os_login_enabled" + ], + "kubernetes": [ + "controllermanager_bind_address", + "scheduler_bind_address" + ], + "mongodbatlas": [ + "clusters_tls_enabled" + ], + "openstack": [ + "compute_instance_key_based_authentication" + ], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled" + ] + } + }, + { + "id": "4.7", + "name": "Manage Default Accounts on Enterprise Assets and Software", + "description": "Manage default accounts on enterprise assets and software, such as root, administrator, and other pre-configured vendor accounts. Example implementations can include: disabling default accounts or making them unusable.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_no_root_access_key" + ], + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_credentials_management_enabled", + "rds_cluster_default_admin", + "rds_instance_default_admin", + "redshift_cluster_non_default_username" + ], + "azure": [ + "aks_cluster_local_accounts_disabled", + "containerregistry_admin_user_disabled", + "cosmosdb_account_use_aad_and_rbac", + "storage_account_key_access_disabled" + ], + "gcp": [ + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account" + ], + "kubernetes": [ + "apiserver_anonymous_requests", + "kubelet_disable_anonymous_auth" + ], + "m365": [ + "exchange_shared_mailbox_sign_in_disabled" + ], + "nhn": [ + "compute_instance_login_user" + ], + "oraclecloud": [ + "identity_tenancy_admin_users_no_api_keys" + ], + "scaleway": [ + "iam_api_keys_no_root_owned" + ] + } + }, + { + "id": "4.8", + "name": "Uninstall or Disable Unnecessary Services on Enterprise Assets and Software", + "description": "Uninstall or disable unnecessary services on enterprise assets and software, such as an unused file sharing service, web application module, or service function.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_dashboard_disabled" + ], + "azure": [ + "app_ftp_deployment_disabled", + "app_function_ftps_deployment_disabled" + ], + "gcp": [ + "compute_instance_ip_forwarding_is_enabled", + "compute_instance_serial_ports_in_use" + ], + "googleworkspace": [ + "chat_incoming_webhooks_disabled", + "drive_desktop_access_disabled", + "gmail_per_user_outbound_gateway_disabled", + "gmail_pop_imap_access_disabled", + "security_less_secure_apps_disabled", + "sites_service_disabled" + ], + "kubernetes": [ + "apiserver_disable_profiling", + "controllermanager_disable_profiling", + "kubelet_disable_read_only_port", + "scheduler_profiling" + ], + "m365": [ + "exchange_transport_config_smtp_auth_disabled", + "teams_email_sending_to_channel_disabled" + ], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled" + ], + "vercel": [ + "project_auto_expose_system_env_disabled", + "project_directory_listing_disabled" + ] + } + }, + { + "id": "4.9", + "name": "Configure Trusted DNS Servers on Enterprise Assets", + "description": "Configure trusted DNS servers on network infrastructure. Example implementations include configuring network devices to use enterprise-controlled DNS servers and/or reputable externally accessible DNS servers.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "cloudflare": [ + "zone_dnssec_enabled" + ] + } + }, + { + "id": "4.10", + "name": "Enforce Automatic Device Lockout on Portable End-User Devices", + "description": "Enforce automatic device lockout following a predetermined threshold of local failed authentication attempts on portable end-user devices, where supported. For laptops, do not allow more than 20 failed authentication attempts; for tablets and smartphones, no more than 10 failed authentication attempts. Example implementations include Microsoft® InTune Device Lock and Apple® Configuration Profile maxFailedAttempts.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.11", + "name": "Enforce Remote Wipe Capability on Portable End-User Devices", + "description": "Remotely wipe enterprise data from enterprise-owned portable end-user devices when deemed appropriate such as lost or stolen devices, or when an individual no longer supports the enterprise.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "4.12", + "name": "Separate Enterprise Workspaces on Mobile End-User Devices", + "description": "Ensure separate enterprise workspaces are used on mobile end-user devices, where supported. Example implementations include using an Apple® Configuration Profile or Android™ Work Profile to separate enterprise applications and data from personal applications and data.", + "attributes": { + "Section": "4. Secure Configuration of Enterprise Assets and Software", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "5.1", + "name": "Establish and Maintain an Inventory of Accounts", + "description": "Establish and maintain an inventory of all accounts managed in the enterprise. The inventory must at a minimum include user, administrator, and service accounts. The inventory, at a minimum, should contain the person's name, username, start/stop dates, and department. Validate that all active accounts are authorized, on a recurring schedule at a minimum quarterly, or more frequently.", + "attributes": { + "Section": "5. Account Management", + "Function": "Identify", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "5.2", + "name": "Use Unique Passwords", + "description": "Use unique passwords for all enterprise assets. Best practice implementation includes, at a minimum, an 8-character password for accounts using Multi-Factor Authentication (MFA) and a 14-character password for accounts not using MFA.", + "attributes": { + "Section": "5. Account Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_password_policy_lowercase", + "ram_password_policy_minimum_length", + "ram_password_policy_number", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_symbol", + "ram_password_policy_uppercase" + ], + "aws": [ + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol", + "cognito_user_pool_password_policy_uppercase", + "iam_password_policy_lowercase", + "iam_password_policy_minimum_length_14", + "iam_password_policy_number", + "iam_password_policy_reuse_24", + "iam_password_policy_symbol", + "iam_password_policy_uppercase" + ], + "googleworkspace": [ + "security_password_policy_strong" + ], + "okta": [ + "authenticator_password_common_password_check", + "authenticator_password_complexity_lowercase", + "authenticator_password_complexity_number", + "authenticator_password_complexity_symbol", + "authenticator_password_complexity_uppercase", + "authenticator_password_history_5", + "authenticator_password_minimum_length_15" + ], + "oraclecloud": [ + "identity_password_policy_minimum_length_14", + "identity_password_policy_prevents_reuse" + ] + } + }, + { + "id": "5.3", + "name": "Disable Dormant Accounts", + "description": "Delete or disable any dormant accounts after a period of 45 days of inactivity, where supported.", + "attributes": { + "Section": "5. Account Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_user_console_access_unused" + ], + "aws": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "azure": [ + "entra_user_with_recent_sign_in" + ], + "gcp": [ + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ], + "okta": [ + "user_inactivity_automation_35d_enabled" + ], + "vercel": [ + "authentication_no_stale_tokens" + ] + } + }, + { + "id": "5.4", + "name": "Restrict Administrator Privileges to Dedicated Administrator Accounts", + "description": "Restrict administrator privileges to dedicated administrator accounts on enterprise assets. Conduct general computing activities, such as internet browsing, email, and productivity suite use, from the user's primary, non-privileged account.", + "attributes": { + "Section": "5. Account Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_policy_no_administrative_privileges" + ], + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_group_administrator_access_policy", + "iam_inline_policy_no_administrative_privileges", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy" + ], + "azure": [ + "app_function_identity_without_admin_privileges", + "entra_global_admin_in_less_than_five_users", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created" + ], + "gcp": [ + "iam_sa_no_administrative_privileges" + ], + "googleworkspace": [ + "directory_super_admin_count", + "directory_super_admin_only_admin_roles" + ], + "kubernetes": [ + "rbac_cluster_admin_usage" + ], + "m365": [ + "admincenter_users_admins_reduced_license_footprint", + "admincenter_users_between_two_and_four_global_admins", + "entra_admin_portals_access_restriction", + "entra_admin_users_cloud_only" + ], + "okta": [ + "apitoken_not_super_admin" + ], + "oraclecloud": [ + "identity_iam_admins_cannot_update_tenancy_admins", + "identity_service_level_admins_exist", + "identity_tenancy_admin_permissions_limited", + "identity_tenancy_admin_users_no_api_keys" + ], + "scaleway": [ + "iam_api_keys_no_root_owned" + ], + "vercel": [ + "team_member_role_least_privilege" + ] + } + }, + { + "id": "5.5", + "name": "Establish and Maintain an Inventory of Service Accounts", + "description": "Establish and maintain an inventory of service accounts. The inventory, at a minimum, must contain department owner, review date, and purpose. Perform service account reviews to validate that all active accounts are authorized, on a recurring schedule at a minimum quarterly, or more frequently.", + "attributes": { + "Section": "5. Account Management", + "Function": "Identify", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "5.6", + "name": "Centralize Account Management", + "description": "Centralize account management through a directory or identity service.", + "attributes": { + "Section": "5. Account Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "iam_check_saml_providers_sts" + ], + "azure": [ + "cosmosdb_account_use_aad_and_rbac", + "postgresql_flexible_server_entra_id_authentication_enabled", + "sqlserver_azuread_administrator_enabled", + "storage_default_to_entra_authorization_enabled" + ], + "vercel": [ + "team_directory_sync_enabled", + "team_saml_sso_enabled" + ] + } + }, + { + "id": "6.1", + "name": "Establish an Access Granting Process", + "description": "Establish and follow a documented process, preferably automated, for granting access to enterprise assets upon new hire or role change of a user.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "vercel": [ + "team_directory_sync_enabled" + ] + } + }, + { + "id": "6.2", + "name": "Establish an Access Revoking Process", + "description": "Establish and follow a process, preferably automated, for revoking access to enterprise assets, through disabling accounts immediately upon termination, rights revocation, or role change of a user. Disabling accounts, instead of deleting accounts, may be necessary to preserve audit trails.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "vercel": [ + "team_directory_sync_enabled" + ] + } + }, + { + "id": "6.3", + "name": "Require MFA for Externally-Exposed Applications", + "description": "Require all externally-exposed enterprise or third-party applications to enforce MFA, where supported. Enforcing MFA through a directory service or SSO provider is a satisfactory implementation of this Safeguard.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "aws": [ + "cognito_user_pool_mfa_enabled", + "iam_user_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access" + ], + "azure": [ + "entra_authentication_methods_policy_strong_auth_enforced", + "entra_non_privileged_user_has_mfa", + "entra_security_defaults_enabled" + ], + "github": [ + "organization_members_mfa_required" + ], + "googleworkspace": [ + "security_2sv_enforced", + "security_advanced_protection_configured" + ], + "linode": [ + "administration_user_2fa_enabled" + ], + "m365": [ + "entra_conditional_access_policy_mfa_enforced_for_guest_users", + "entra_users_mfa_capable", + "entra_users_mfa_enabled" + ], + "mongodbatlas": [ + "organizations_mfa_required" + ], + "okta": [ + "application_dashboard_mfa_required", + "application_dashboard_phishing_resistant_authentication" + ], + "oraclecloud": [ + "identity_user_mfa_enabled_console_access" + ] + } + }, + { + "id": "6.4", + "name": "Require MFA for Remote Network Access", + "description": "Require MFA for remote network access.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "directoryservice_radius_server_security_protocol", + "directoryservice_supported_mfa_radius_enabled" + ], + "azure": [ + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled", + "entra_non_privileged_user_has_mfa" + ], + "gcp": [ + "compute_project_os_login_2fa_enabled" + ], + "github": [ + "organization_members_mfa_required" + ], + "linode": [ + "administration_user_2fa_enabled" + ], + "m365": [ + "entra_users_mfa_enabled", + "entra_legacy_authentication_blocked" + ], + "mongodbatlas": [ + "organizations_mfa_required" + ] + } + }, + { + "id": "6.5", + "name": "Require MFA for Administrative Access", + "description": "Require MFA for all administrative access accounts, where supported, on all enterprise assets, whether managed on-site or through a service provider.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "aws": [ + "iam_administrator_access_with_mfa", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled" + ], + "azure": [ + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "github": [ + "organization_members_mfa_required" + ], + "googleworkspace": [ + "security_2sv_hardware_keys_admins" + ], + "linode": [ + "administration_user_2fa_enabled" + ], + "m365": [ + "entra_admin_users_mfa_enabled", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered" + ], + "mongodbatlas": [ + "organizations_mfa_required" + ], + "okta": [ + "application_admin_console_mfa_required", + "application_admin_console_phishing_resistant_authentication" + ], + "vercel": [ + "team_saml_sso_enforced", + "team_saml_sso_enabled" + ] + } + }, + { + "id": "6.6", + "name": "Establish and Maintain an Inventory of Authentication and Authorization Systems", + "description": "Establish and maintain an inventory of the enterprise's authentication and authorization systems, including those hosted on-site or at a remote service provider. Review and update the inventory, at a minimum, annually, or more frequently.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "6.7", + "name": "Centralize Access Control", + "description": "Centralize access control for all enterprise assets through a directory service or SSO provider, where supported.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "dms_endpoint_neptune_iam_authorization_enabled", + "iam_check_saml_providers_sts", + "neptune_cluster_iam_authentication_enabled", + "opensearch_service_domains_use_cognito_authentication_for_kibana", + "rds_cluster_iam_authentication_enabled", + "rds_instance_iam_authentication_enabled", + "sagemaker_domain_sso_configured" + ], + "azure": [ + "aks_cluster_local_accounts_disabled", + "cosmosdb_account_use_aad_and_rbac", + "postgresql_flexible_server_entra_id_authentication_enabled", + "sqlserver_azuread_administrator_enabled" + ], + "m365": [ + "entra_all_apps_conditional_access_coverage", + "entra_conditional_access_policy_all_apps_all_users", + "entra_password_hash_sync_enabled" + ], + "vercel": [ + "team_directory_sync_enabled", + "team_saml_sso_enabled", + "team_saml_sso_enforced" + ] + } + }, + { + "id": "6.8", + "name": "Define and Maintain Role-Based Access Control", + "description": "Define and maintain role-based access control, through determining and documenting the access rights necessary for each role within the enterprise to successfully carry out its assigned duties. Perform access control reviews of enterprise assets to validate that all privileges are authorized, on a recurring schedule at a minimum annually, or more frequently.", + "attributes": { + "Section": "6. Access Control Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_rbac_enabled", + "ram_policy_attached_only_to_group_or_roles", + "ram_policy_no_administrative_privileges" + ], + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "bedrock_agent_role_least_privilege", + "bedrock_api_key_no_administrative_privileges", + "ec2_instance_profile_attached", + "iam_inline_policy_allows_privilege_escalation", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_no_custom_policy_permissive_role_assumption", + "iam_policy_allows_privilege_escalation", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_policy_no_wildcard_marketplace_subscribe", + "iam_role_cross_account_readonlyaccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "iam_user_with_temporary_credentials" + ], + "azure": [ + "aks_cluster_rbac_enabled", + "cosmosdb_account_use_aad_and_rbac", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "keyvault_rbac_enabled" + ], + "gcp": [ + "iam_account_access_approval_enabled", + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges" + ], + "github": [ + "organization_default_repository_permission_strict", + "organization_repository_creation_limited", + "organization_repository_deletion_limited" + ], + "kubernetes": [ + "apiserver_auth_mode_include_node", + "apiserver_auth_mode_include_rbac", + "apiserver_auth_mode_not_always_allow", + "controllermanager_service_account_credentials", + "kubelet_authorization_mode", + "rbac_minimize_csr_approval_access", + "rbac_minimize_node_proxy_subresource_access", + "rbac_minimize_pod_creation_access", + "rbac_minimize_pv_creation_access", + "rbac_minimize_service_account_token_creation", + "rbac_minimize_webhook_config_access", + "rbac_minimize_wildcard_use_roles" + ], + "m365": [ + "entra_admin_portals_access_restriction", + "entra_app_registration_no_unused_privileged_permissions", + "entra_policy_guest_users_access_restrictions", + "entra_service_principal_privileged_role_no_owners" + ], + "oraclecloud": [ + "identity_no_resources_in_root_compartment", + "identity_non_root_compartment_exists", + "identity_service_level_admins_exist", + "identity_storage_service_level_admins_scoped", + "identity_tenancy_admin_permissions_limited" + ], + "vercel": [ + "team_member_role_least_privilege" + ] + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "7.1", + "name": "Establish and Maintain a Vulnerability Management Process", + "description": "Establish and maintain a documented vulnerability management process for enterprise assets. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "7.2", + "name": "Establish and Maintain a Remediation Process", + "description": "Establish and maintain a risk-based remediation strategy documented in a remediation process, with monthly, or more frequent, reviews.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "7.3", + "name": "Perform Automated Operating System Patch Management", + "description": "Perform operating system updates on enterprise assets through automated patch management on a monthly, or more frequent, basis.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied" + ], + "aws": [ + "ssm_managed_compliant_patching" + ], + "azure": [ + "aks_cluster_auto_upgrade_enabled", + "defender_ensure_system_updates_are_applied" + ] + } + }, + { + "id": "7.4", + "name": "Perform Automated Application Patch Management", + "description": "Perform application updates on enterprise assets through automated patch management on a monthly, or more frequent, basis.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "dms_instance_minor_version_upgrade_enabled", + "elasticache_redis_cluster_auto_minor_version_upgrades", + "elasticbeanstalk_environment_managed_updates_enabled", + "memorydb_cluster_auto_minor_version_upgrades", + "mq_broker_auto_minor_version_upgrades", + "rds_cluster_minor_version_upgrade_enabled", + "rds_instance_minor_version_upgrade_enabled", + "redshift_cluster_automatic_upgrades" + ], + "azure": [ + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version" + ] + } + }, + { + "id": "7.5", + "name": "Perform Automated Vulnerability Scans of Internal Enterprise Assets", + "description": "Perform automated vulnerability scans of internal enterprise assets on a quarterly, or more frequent, basis. Conduct both authenticated and unauthenticated scans.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled" + ], + "aws": [ + "ecr_registry_scan_images_on_push_enabled", + "ecr_repositories_scan_images_on_push_enabled", + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "inspector2_is_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "sqlserver_va_emails_notifications_admins_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "github": [ + "repository_dependency_scanning_enabled" + ] + } + }, + { + "id": "7.6", + "name": "Perform Automated Vulnerability Scans of Externally-Exposed Enterprise Assets", + "description": "Perform automated vulnerability scans of externally-exposed enterprise assets. Perform scans on a monthly, or more frequent, basis.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "inspector2_is_enabled" + ], + "azure": [ + "network_public_ip_shodan" + ] + } + }, + { + "id": "7.7", + "name": "Remediate Detected Vulnerabilities", + "description": "Remediate detected vulnerabilities in software through processes and tooling on a monthly, or more frequent, basis, based on the remediation process.", + "attributes": { + "Section": "7. Continuous Vulnerability Management", + "Function": "Respond", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "inspector2_active_findings_exist" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities" + ] + } + }, + { + "id": "8.1", + "name": "Establish and Maintain an Audit Log Management Process", + "description": "Establish and maintain a documented audit log management process that defines the enterprise's logging requirements. At a minimum, address the collection, review, and retention of audit logs for enterprise assets. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "8.2", + "name": "Collect Audit Logs", + "description": "Collect audit logs. Ensure that logging, per the enterprise's audit log management process, has been enabled across enterprise assets.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "cs_kubernetes_log_service_enabled", + "oss_bucket_logging_enabled", + "rds_instance_sql_audit_enabled", + "vpc_flow_logs_enabled" + ], + "aws": [ + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "appsync_field_level_logging_enabled", + "athena_workgroup_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", + "bedrock_model_invocation_logging_enabled", + "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "codebuild_project_logging_enabled", + "config_recorder_all_regions_enabled", + "datasync_task_logging_enabled", + "directoryservice_directory_log_forwarding_enabled", + "dms_replication_task_source_logging_enabled", + "dms_replication_task_target_logging_enabled", + "documentdb_cluster_cloudwatch_log_export", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "ecs_task_definitions_logging_enabled", + "eks_control_plane_logging_all_types_enabled", + "elasticbeanstalk_environment_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "mq_broker_logging_enabled", + "neptune_cluster_integration_cloudwatch_logs", + "networkfirewall_logging_enabled", + "opensearch_service_domains_cloudwatch_logging_enabled", + "rds_cluster_integration_cloudwatch_logs", + "rds_instance_integration_cloudwatch_logs", + "redshift_cluster_audit_logging", + "s3_bucket_server_access_logging_enabled", + "stepfunctions_statemachine_logging_enabled", + "vpc_flow_logs_enabled", + "waf_global_webacl_logging_enabled", + "wafv2_webacl_logging_enabled" + ], + "azure": [ + "app_function_application_insights_enabled", + "app_http_logs_enabled", + "appinsights_ensure_is_configured", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_diagnostic_settings_exists", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "sqlserver_auditing_enabled" + ], + "gcp": [ + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "googleworkspace": [ + "gmail_comprehensive_mail_storage_enabled" + ], + "kubernetes": [ + "apiserver_audit_log_path_set" + ], + "m365": [ + "exchange_mailbox_audit_bypass_disabled", + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ], + "mongodbatlas": [ + "projects_auditing_enabled" + ], + "okta": [ + "systemlog_streaming_enabled" + ], + "oraclecloud": [ + "network_vcn_subnet_flow_logs_enabled", + "objectstorage_bucket_logging_enabled" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "8.3", + "name": "Ensure Adequate Audit Log Storage", + "description": "Ensure that logging destinations maintain adequate storage to comply with the enterprise's audit log management process.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "kubernetes": [ + "apiserver_audit_log_maxbackup_set", + "apiserver_audit_log_maxsize_set" + ] + } + }, + { + "id": "8.4", + "name": "Standardize Time Synchronization", + "description": "Standardize time synchronization. Configure at least two synchronized time sources across enterprise assets, where supported.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "8.5", + "name": "Collect Detailed Audit Logs", + "description": "Configure detailed audit logging for enterprise assets containing sensitive data. Include event source, date, username, timestamp, source addresses, destination addresses, and other useful elements that could assist in a forensic investigation.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled", + "rds_instance_postgresql_log_duration_enabled" + ], + "aws": [ + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "opensearch_service_domains_audit_logging_enabled" + ], + "azure": [ + "monitor_diagnostic_setting_with_appropriate_categories", + "mysql_flexible_server_audit_log_connection_activated", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on" + ], + "gcp": [ + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag" + ], + "m365": [ + "exchange_user_mailbox_auditing_enabled" + ], + "mongodbatlas": [ + "projects_auditing_enabled" + ] + } + }, + { + "id": "8.6", + "name": "Collect DNS Query Audit Logs", + "description": "Collect DNS query audit logs on enterprise assets, where appropriate and supported.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "route53_public_hosted_zones_cloudwatch_logging_enabled" + ], + "gcp": [ + "compute_network_dns_logging_enabled" + ] + } + }, + { + "id": "8.7", + "name": "Collect URL Request Audit Logs", + "description": "Collect URL request audit logs on enterprise assets, where appropriate and supported.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "app_http_logs_enabled" + ], + "gcp": [ + "compute_loadbalancer_logging_enabled" + ] + } + }, + { + "id": "8.8", + "name": "Collect Command-Line Audit Logs", + "description": "Collect command-line audit logs. Example implementations include collecting audit logs from PowerShell®, BASH™, and remote administrative terminals.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "8.9", + "name": "Centralize Audit Logs", + "description": "Centralize, to the extent possible, audit log collection and retention across enterprise assets in accordance with the documented audit log management process. Example implementations primarily include leveraging a SIEM tool to centralize multiple log sources.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "cloudtrail_cloudwatch_logging_enabled", + "config_delegated_admin_and_org_aggregator_all_regions" + ], + "azure": [ + "defender_auto_provisioning_log_analytics_agent_vms_on", + "network_flow_log_captured_sent" + ], + "gcp": [ + "logging_sink_created" + ], + "m365": [ + "purview_audit_log_search_enabled" + ], + "okta": [ + "systemlog_streaming_enabled" + ] + } + }, + { + "id": "8.10", + "name": "Retain Audit Logs", + "description": "Retain audit logs across enterprise assets for a minimum of 90 days.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "rds_instance_sql_audit_retention", + "sls_logstore_retention_period" + ], + "aws": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "azure": [ + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "kubernetes": [ + "apiserver_audit_log_maxage_set" + ], + "m365": [ + "exchange_user_mailbox_auditing_enabled" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days" + ] + } + }, + { + "id": "8.11", + "name": "Conduct Audit Log Reviews", + "description": "Conduct reviews of audit logs to detect anomalies or abnormal events that could indicate a potential threat. Conduct reviews on a weekly, or more frequent, basis.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled" + ], + "aws": [ + "cloudtrail_insights_exist", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "googleworkspace": [ + "rules_admin_privilege_granted_alert_configured", + "rules_gmail_employee_spoofing_alert_configured", + "rules_government_backed_attacks_alert_configured", + "rules_leaked_password_alert_configured", + "rules_password_changed_alert_configured", + "rules_suspicious_activity_suspension_alert_configured", + "rules_suspicious_login_alert_configured", + "rules_suspicious_programmatic_login_alert_configured" + ], + "oraclecloud": [ + "events_rule_cloudguard_problems", + "events_rule_iam_group_changes", + "events_rule_iam_policy_changes", + "events_rule_identity_provider_changes", + "events_rule_idp_group_mapping_changes", + "events_rule_local_user_authentication", + "events_rule_network_gateway_changes", + "events_rule_network_security_group_changes", + "events_rule_route_table_changes", + "events_rule_security_list_changes", + "events_rule_user_changes", + "events_rule_vcn_changes" + ] + } + }, + { + "id": "8.12", + "name": "Collect Service Provider Logs", + "description": "Collect service provider logs, where supported. Example implementations include collecting authentication and authorization events, data creation and disposal events, and user management events.", + "attributes": { + "Section": "8. Audit Log Management", + "Function": "Detect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "actiontrail_multi_region_enabled" + ], + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events" + ], + "azure": [ + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "iam_audit_logs_enabled" + ], + "m365": [ + "purview_audit_log_search_enabled" + ], + "okta": [ + "systemlog_streaming_enabled" + ] + } + }, + { + "id": "9.1", + "name": "Ensure Use of Only Fully Supported Browsers and Email Clients", + "description": "Ensure only fully supported browsers and email clients are allowed to execute in the enterprise, only using the latest version of browsers and email clients provided through the vendor.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "9.2", + "name": "Use DNS Filtering Services", + "description": "Use DNS filtering services on all end-user devices, including remote and on-premises assets, to block access to known malicious domains.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "9.3", + "name": "Maintain and Enforce Network-Based URL Filters", + "description": "Enforce and update network-based URL filters to limit an enterprise asset from connecting to potentially malicious or unapproved websites. Example implementations include category-based filtering, reputation-based filtering, or through the use of block lists. Enforce filters for all enterprise assets.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "gmail_shortener_scanning_enabled", + "gmail_untrusted_link_warnings_enabled" + ], + "m365": [ + "defender_safelinks_policy_enabled" + ] + } + }, + { + "id": "9.4", + "name": "Restrict Unnecessary or Unauthorized Browser and Email Client Extensions", + "description": "Restrict, either through uninstalling or disabling, any unauthorized or unnecessary browser or email client plugins, extensions, and add-on applications.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "m365": [ + "exchange_mailbox_policy_additional_storage_restricted", + "exchange_roles_assignment_policy_addins_disabled" + ] + } + }, + { + "id": "9.5", + "name": "Implement DMARC", + "description": "To lower the chance of spoofed or modified emails from valid domains, implement DMARC policy and verification, starting with implementing the Sender Policy Framework (SPF) and the DomainKeys Identified Mail (DKIM) standards.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "ses_identity_dkim_enabled" + ], + "cloudflare": [ + "zone_record_dkim_exists", + "zone_record_dmarc_exists", + "zone_record_spf_exists" + ], + "googleworkspace": [ + "gmail_groups_spoofing_protection_enabled", + "gmail_inbound_domain_spoofing_protection_enabled", + "gmail_unauthenticated_email_protection_enabled" + ], + "m365": [ + "defender_antiphishing_policy_configured", + "defender_domain_dkim_enabled" + ] + } + }, + { + "id": "9.6", + "name": "Block Unnecessary File Types", + "description": "Block unnecessary file types attempting to enter the enterprise's email gateway.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "gmail_anomalous_attachment_protection_enabled", + "gmail_script_attachment_protection_enabled" + ], + "m365": [ + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied" + ] + } + }, + { + "id": "9.7", + "name": "Deploy and Maintain Email Server Anti-Malware Protections", + "description": "Deploy and maintain email server anti-malware protections, such as attachment scanning and/or sandboxing.", + "attributes": { + "Section": "9. Email and Web Browser Protections", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "googleworkspace": [ + "gmail_encrypted_attachment_protection_enabled", + "gmail_enhanced_pre_delivery_scanning_enabled", + "gmail_external_image_scanning_enabled" + ], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_safe_attachments_policy_enabled" + ] + } + }, + { + "id": "10.1", + "name": "Deploy and Maintain Anti-Malware Software", + "description": "Deploy and maintain anti-malware software on all enterprise assets.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "ecs_instance_endpoint_protection_installed", + "securitycenter_all_assets_agent_installed" + ], + "aws": [ + "guardduty_ec2_malware_protection_enabled" + ], + "azure": [ + "defender_assessments_vm_endpoint_protection_installed", + "defender_ensure_defender_for_server_is_on" + ], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_safe_attachments_policy_enabled", + "defender_zap_for_teams_enabled" + ] + } + }, + { + "id": "10.2", + "name": "Configure Automatic Anti-Malware Signature Updates", + "description": "Configure automatic updates for anti-malware signature files on all enterprise assets.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "10.3", + "name": "Disable Autorun and Autoplay for Removable Media", + "description": "Disable autorun and autoplay auto-execute functionality for removable media.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "10.4", + "name": "Configure Automatic Anti-Malware Scanning of Removable Media", + "description": "Configure anti-malware software to automatically scan removable media.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "10.5", + "name": "Enable Anti-Exploitation Features", + "description": "Enable anti-exploitation features on enterprise assets and software, where possible, such as Microsoft® Data Execution Prevention (DEP), Windows® Defender Exploit Guard (WDEG), or Apple® System Integrity Protection (SIP) and Gatekeeper™.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "vm_trusted_launch_enabled" + ], + "openstack": [ + "image_secure_boot_enabled" + ], + "oraclecloud": [ + "compute_instance_secure_boot_enabled" + ] + } + }, + { + "id": "10.6", + "name": "Centrally Manage Anti-Malware Software", + "description": "Centrally manage anti-malware software.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_all_assets_agent_installed" + ], + "aws": [ + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions" + ], + "azure": [ + "aks_cluster_defender_enabled", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "10.7", + "name": "Use Behavior-Based Anti-Malware Software", + "description": "Use behavior-based anti-malware software.", + "attributes": { + "Section": "10. Malware Defenses", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "guardduty_eks_runtime_monitoring_enabled", + "guardduty_is_enabled", + "guardduty_lambda_protection_enabled", + "guardduty_rds_protection_enabled", + "guardduty_s3_protection_enabled" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled" + ], + "m365": [ + "defender_atp_safe_attachments_and_docs_configured", + "defender_safe_attachments_policy_enabled", + "defender_zap_for_teams_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "11.1", + "name": "Establish and Maintain a Data Recovery Process", + "description": "Establish and maintain a documented data recovery process that includes detailed backup procedures. In the process, address the scope of data recovery activities, recovery prioritization, and the security of backup data. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "11.2", + "name": "Perform Automated Backups", + "description": "Perform automated backups of in-scope enterprise assets. Run backups weekly, or more frequently, based on the sensitivity of the data.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Recover", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_vaults_exist", + "dlm_ebs_snapshot_lifecycle_policy_exists", + "documentdb_cluster_backup_enabled", + "drs_job_exist", + "dynamodb_table_protected_by_backup_plan", + "dynamodb_tables_pitr_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "lightsail_instance_automated_snapshots", + "neptune_cluster_backup_enabled", + "rds_cluster_backtrack_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "redshift_cluster_automated_snapshot", + "s3_bucket_object_versioning" + ], + "azure": [ + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "recovery_vault_has_protected_items", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_versioning_enabled" + ], + "linode": [ + "compute_instance_backups_enabled" + ], + "mongodbatlas": [ + "clusters_backup_enabled" + ], + "openstack": [ + "blockstorage_volume_backup_exists", + "objectstorage_container_versioning_enabled" + ], + "oraclecloud": [ + "objectstorage_bucket_versioning_enabled" + ] + }, + "config_requirements": [ + { + "Check": "drs_job_exist", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "11.3", + "name": "Protect Recovery Data", + "description": "Protect recovery data with equivalent controls to the original data. Reference encryption or data separation, based on requirements.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "backup_recovery_point_encrypted", + "backup_vaults_encrypted", + "documentdb_cluster_public_snapshot", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_ebs_snapshots_encrypted", + "neptune_cluster_public_snapshot", + "neptune_cluster_snapshot_encrypted", + "rds_snapshots_encrypted", + "rds_snapshots_public_access", + "s3_bucket_no_mfa_delete", + "s3_bucket_object_lock" + ], + "azure": [ + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_ensure_soft_delete_is_enabled" + ], + "stackit": [ + "objectstorage_bucket_object_lock_enabled" + ] + } + }, + { + "id": "11.4", + "name": "Establish and Maintain an Isolated Instance of Recovery Data", + "description": "Establish and maintain an isolated instance of recovery data. Example implementations include, version controlling backup destinations through offline, cloud, or off-site systems or services.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Recover", + "AssetType": "Data", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "s3_bucket_cross_region_replication" + ], + "azure": [ + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "storage_geo_redundant_enabled" + ] + } + }, + { + "id": "11.5", + "name": "Test Data Recovery", + "description": "Test backup recovery quarterly, or more frequently, for a sampling of in-scope enterprise assets.", + "attributes": { + "Section": "11. Data Recovery", + "Function": "Recover", + "AssetType": "Data", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.1", + "name": "Ensure Network Infrastructure is Up-to-Date", + "description": "Ensure network infrastructure is kept up-to-date. Example implementations include running the latest stable release of software and/or using currently supported network as a service (Naas) offerings. Review software versions monthly, or more frequently, to verify software support.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.2", + "name": "Establish and Maintain a Secure Network Architecture", + "description": "Design and maintain a secure network architecture. A secure network architecture must address segmentation, least privilege, and availability, at a minimum. Example implementations may include documentation, policy, and design components.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_private_cluster_enabled", + "ecs_instance_no_legacy_network" + ], + "aws": [ + "appstream_fleet_default_internet_access_disabled", + "autoscaling_group_launch_configuration_no_public_ip", + "awslambda_function_inside_vpc", + "dms_instance_no_public_access", + "ec2_instance_public_ip", + "ec2_launch_template_no_public_ip", + "ec2_transitgateway_auto_accept_vpc_attachments", + "ecs_service_no_assign_public_ip", + "ecs_task_set_no_assign_public_ip", + "eks_cluster_not_publicly_accessible", + "eks_cluster_private_nodes_enabled", + "elasticache_cluster_uses_public_subnet", + "elb_internet_facing", + "elbv2_internet_facing", + "emr_cluster_account_public_block_enabled", + "emr_cluster_master_nodes_no_public_ip", + "emr_cluster_publicly_accesible", + "kafka_cluster_is_public", + "lightsail_database_public", + "lightsail_instance_public", + "mq_broker_not_publicly_accessible", + "neptune_cluster_uses_public_subnet", + "opensearch_service_domains_not_publicly_accessible", + "rds_instance_inside_vpc", + "rds_instance_no_public_access", + "redshift_cluster_public_access", + "sagemaker_models_vpc_settings_configured", + "sagemaker_notebook_instance_vpc_settings_configured", + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "sagemaker_training_jobs_vpc_settings_configured", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_peering_routing_tables_with_least_privilege", + "vpc_subnet_no_public_ip_by_default", + "vpc_subnet_separate_private_public" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_public_network_access_disabled", + "cosmosdb_account_use_private_endpoints", + "databricks_workspace_public_network_access_disabled", + "keyvault_access_only_through_private_endpoints", + "keyvault_private_endpoints", + "storage_account_public_network_access_disabled", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "gcp": [ + "cloudfunction_function_inside_vpc", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_public_ip", + "cloudstorage_uses_vpc_service_controls", + "compute_instance_public_ip", + "compute_instance_single_network_interface", + "compute_network_default_in_use", + "compute_network_not_legacy" + ], + "mongodbatlas": [ + "projects_network_access_list_exposed_to_internet" + ], + "nhn": [ + "compute_instance_public_ip", + "network_vpc_subnet_has_external_router" + ], + "openstack": [ + "compute_instance_isolated_private_network" + ], + "oraclecloud": [ + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted", + "objectstorage_bucket_not_publicly_accessible" + ] + } + }, + { + "id": "12.3", + "name": "Securely Manage Network Infrastructure", + "description": "Securely manage network infrastructure. Example implementations include version-controlled Infrastructure-as-Code (IaC), and the use of secure network protocols, such as SSH and HTTPS.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "gcp": [ + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ] + } + }, + { + "id": "12.4", + "name": "Establish and Maintain Architecture Diagram(s)", + "description": "Establish and maintain architecture diagram(s) and/or other network system documentation. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.5", + "name": "Centralize Network Authentication, Authorization, and Auditing (AAA)", + "description": "Centralize network AAA.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "12.6", + "name": "Use of Secure Network Management and Communication Protocols", + "description": "Adopt secure network management protocols (e.g., 802.1X) and secure communication protocols (e.g., Wi-Fi Protected Access 2 (WPA2) Enterprise or more secure alternatives).", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "storage_smb_protocol_version_is_latest" + ], + "cloudflare": [ + "zone_automatic_https_rewrites_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_min_tls_version_secure", + "zone_ssl_strict", + "zone_tls_1_3_enabled", + "zone_universal_ssl_enabled" + ], + "kubernetes": [ + "apiserver_client_ca_file_set", + "controllermanager_root_ca_file_set", + "controllermanager_rotate_kubelet_server_cert", + "etcd_client_cert_auth", + "etcd_peer_client_cert_auth", + "etcd_unique_ca", + "kubelet_client_ca_file_set", + "kubelet_rotate_certificates" + ] + } + }, + { + "id": "12.7", + "name": "Ensure Remote Devices Utilize a VPN and are Connecting to an Enterprise's AAA Infrastructure", + "description": "Require users to authenticate to enterprise-managed VPN and authentication services prior to accessing enterprise resources on end-user devices.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "ec2_client_vpn_endpoint_connection_logging_enabled" + ] + } + }, + { + "id": "12.8", + "name": "Establish and Maintain Dedicated Computing Resources for All Administrative Work", + "description": "Establish and maintain dedicated computing resources, either physically or logically separated, for all administrative tasks or tasks requiring administrative access. The computing resources should be segmented from the enterprise's primary network and not be allowed internet access.", + "attributes": { + "Section": "12. Network Infrastructure Management", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "azure": [ + "network_bastion_host_exists", + "vm_jit_access_enabled" + ] + } + }, + { + "id": "13.1", + "name": "Centralize Security Event Alerting", + "description": "Centralize security event alerting across enterprise assets for log correlation and analysis. Best practice implementation requires the use of a SIEM, which includes vendor-defined event correlation alerts. A log analytics platform configured with security-relevant correlation alerts also satisfies this Safeguard.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled" + ], + "aws": [ + "cloudwatch_alarm_actions_alarm_state_configured", + "cloudwatch_alarm_actions_enabled", + "securityhub_delegated_admin_enabled_all_regions", + "securityhub_enabled" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_attack_path_notifications_properly_configured", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "monitor_alert_create_update_security_solution" + ], + "googleworkspace": [ + "rules_admin_privilege_granted_alert_configured", + "rules_gmail_employee_spoofing_alert_configured", + "rules_government_backed_attacks_alert_configured", + "rules_leaked_password_alert_configured", + "rules_password_changed_alert_configured", + "rules_suspicious_activity_suspension_alert_configured", + "rules_suspicious_login_alert_configured", + "rules_suspicious_programmatic_login_alert_configured" + ], + "oraclecloud": [ + "events_notification_topic_and_subscription_exists", + "events_rule_cloudguard_problems", + "events_rule_iam_group_changes", + "events_rule_iam_policy_changes", + "events_rule_identity_provider_changes", + "events_rule_idp_group_mapping_changes", + "events_rule_local_user_authentication", + "events_rule_network_gateway_changes", + "events_rule_network_security_group_changes", + "events_rule_route_table_changes", + "events_rule_security_list_changes", + "events_rule_user_changes", + "events_rule_vcn_changes" + ] + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "13.2", + "name": "Deploy a Host-Based Intrusion Detection Solution", + "description": "Deploy a host-based intrusion detection solution on enterprise assets, where appropriate and/or supported.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "securitycenter_all_assets_agent_installed", + "ecs_instance_endpoint_protection_installed" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled" + ] + } + }, + { + "id": "13.3", + "name": "Deploy a Network Intrusion Detection Solution", + "description": "Deploy a network intrusion detection solution on enterprise assets, where appropriate. Example implementations include the use of a Network Intrusion Detection System (NIDS) or equivalent cloud service provider (CSP) service.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "guardduty_eks_audit_log_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "guardduty_is_enabled" + ], + "azure": [ + "defender_ensure_defender_for_dns_is_on" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "13.4", + "name": "Perform Traffic Filtering Between Network Segments", + "description": "Perform traffic filtering between network segments, where appropriate.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "cs_kubernetes_network_policy_enabled" + ], + "aws": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389", + "networkfirewall_in_all_vpc", + "vpc_peering_routing_tables_with_least_privilege" + ], + "azure": [ + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", + "network_udp_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "linode": [ + "networking_firewall_default_inbound_policy_drop", + "networking_firewall_default_outbound_policy_drop", + "networking_firewall_inbound_rules_configured", + "networking_firewall_outbound_rules_configured" + ], + "mongodbatlas": [ + "projects_network_access_list_exposed_to_internet" + ], + "openstack": [ + "networking_security_group_allows_all_ingress_from_internet", + "networking_security_group_allows_rdp_from_internet", + "networking_security_group_allows_ssh_from_internet" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port" + ], + "stackit": [ + "iaas_security_group_all_traffic_unrestricted", + "iaas_security_group_database_unrestricted", + "iaas_security_group_rdp_unrestricted", + "iaas_security_group_ssh_unrestricted" + ] + } + }, + { + "id": "13.5", + "name": "Manage Access Control for Remote Assets", + "description": "Manage access control for assets remotely connecting to enterprise resources. Determine amount of access to enterprise resources based on: up-to-date anti-malware software installed, configuration compliance with the enterprise's secure configuration process, and ensuring the operating system and applications are up-to-date.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "m365": [ + "entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required", + "entra_conditional_access_policy_mdm_compliant_device_required", + "entra_managed_device_required_for_authentication", + "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default", + "sharepoint_onedrive_sync_restricted_unmanaged_devices" + ] + } + }, + { + "id": "13.6", + "name": "Collect Network Traffic Flow Logs", + "description": "Collect network traffic flow logs and/or network traffic to review and alert upon from network devices.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "alibabacloud": [ + "vpc_flow_logs_enabled" + ], + "aws": [ + "vpc_flow_logs_enabled" + ], + "azure": [ + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_watcher_enabled" + ], + "gcp": [ + "compute_subnet_flow_logs_enabled" + ], + "oraclecloud": [ + "network_vcn_subnet_flow_logs_enabled" + ] + } + }, + { + "id": "13.7", + "name": "Deploy a Host-Based Intrusion Prevention Solution", + "description": "Deploy a host-based intrusion prevention solution on enterprise assets, where appropriate and/or supported. Example implementations include use of an Endpoint Detection and Response (EDR) client or host-based IPS agent.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Devices", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "azure": [ + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on" + ] + } + }, + { + "id": "13.8", + "name": "Deploy a Network Intrusion Prevention Solution", + "description": "Deploy a network intrusion prevention solution, where appropriate. Example implementations include the use of a Network Intrusion Prevention System (NIPS) or equivalent CSP service.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "networkfirewall_in_all_vpc", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets", + "networkfirewall_policy_rule_group_associated", + "shield_advanced_protection_in_associated_elastic_ips", + "shield_advanced_protection_in_classic_load_balancers", + "shield_advanced_protection_in_cloudfront_distributions", + "shield_advanced_protection_in_global_accelerators", + "shield_advanced_protection_in_internet_facing_load_balancers", + "shield_advanced_protection_in_route53_hosted_zones" + ], + "azure": [ + "network_vnet_ddos_protection_enabled" + ], + "cloudflare": [ + "zone_bot_fight_mode_enabled", + "zone_firewall_blocking_rules_configured", + "zone_rate_limiting_enabled", + "zone_waf_enabled", + "zone_waf_owasp_ruleset_enabled" + ], + "vercel": [ + "security_managed_rulesets_enabled", + "security_rate_limiting_configured", + "security_waf_enabled" + ] + } + }, + { + "id": "13.9", + "name": "Deploy Port-Level Access Control", + "description": "Deploy port-level access control. Port-level access control utilizes 802.1x, or similar network access control protocols, such as certificates, and may incorporate user and/or device authentication.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "13.10", + "name": "Perform Application Layer Filtering", + "description": "Perform application layer filtering. Example implementations include a filtering proxy, application layer firewall, or gateway.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "apigateway_restapi_waf_acl_attached", + "cloudfront_distributions_using_waf", + "cognito_user_pool_waf_acl_attached", + "elbv2_waf_acl_attached", + "waf_global_rule_with_conditions", + "waf_global_rulegroup_not_empty", + "waf_global_webacl_with_rules", + "waf_regional_rule_with_conditions", + "waf_regional_rulegroup_not_empty", + "waf_regional_webacl_with_rules", + "wafv2_webacl_with_rules" + ], + "cloudflare": [ + "dns_record_proxied", + "zone_bot_fight_mode_enabled", + "zone_browser_integrity_check_enabled", + "zone_firewall_blocking_rules_configured", + "zone_rate_limiting_enabled", + "zone_waf_enabled", + "zone_waf_owasp_ruleset_enabled" + ], + "vercel": [ + "security_custom_rules_configured", + "security_ip_blocking_rules_configured", + "security_managed_rulesets_enabled", + "security_rate_limiting_configured", + "security_waf_enabled" + ] + } + }, + { + "id": "13.11", + "name": "Tune Security Event Alerting Thresholds", + "description": "Tune security event alerting thresholds monthly, or more frequently.", + "attributes": { + "Section": "13. Network Monitoring and Defense", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.1", + "name": "Establish and Maintain a Security Awareness Program", + "description": "Establish and maintain a security awareness program. The purpose of a security awareness program is to educate the enterprise's workforce on how to interact with enterprise assets and data in a secure manner. Conduct training at hire and, at a minimum, annually. Review and update content annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.2", + "name": "Train Workforce Members to Recognize Social Engineering Attacks", + "description": "Train workforce members to recognize social engineering attacks, such as phishing, business email compromise (BEC), pretexting, and tailgating.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.3", + "name": "Train Workforce Members on Authentication Best Practices", + "description": "Train workforce members on authentication best practices. Example topics include MFA, password composition, and credential management.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.4", + "name": "Train Workforce on Data Handling Best Practices", + "description": "Train workforce members on how to identify and properly store, transfer, archive, and destroy sensitive data. This also includes training workforce members on clear screen and desk best practices, such as locking their screen when they step away from their enterprise asset, erasing physical and virtual whiteboards at the end of meetings, and storing data and assets securely.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.5", + "name": "Train Workforce Members on Causes of Unintentional Data Exposure", + "description": "Train workforce members to be aware of causes for unintentional data exposure. Example topics include mis-delivery of sensitive data, losing a portable end-user device, or publishing data to unintended audiences.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.6", + "name": "Train Workforce Members on Recognizing and Reporting Security Incidents", + "description": "Train workforce members to be able to recognize a potential incident and be able to report such an incident.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.7", + "name": "Train Workforce on How to Identify and Report if Their Enterprise Assets are Missing Security Updates", + "description": "Train workforce to understand how to verify and report out-of-date software patches or any failures in automated processes and tools. Part of this training should include notifying IT personnel of any failures in automated processes and tools.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.8", + "name": "Train Workforce on the Dangers of Connecting to and Transmitting Enterprise Data Over Insecure Networks", + "description": "Train workforce members on the dangers of connecting to, and transmitting data over, insecure networks for enterprise activities. If the enterprise has remote workers, training must include guidance to ensure that all users securely configure their home network infrastructure.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "14.9", + "name": "Conduct Role-Specific Security Awareness and Skills Training", + "description": "Conduct role-specific security awareness and skills training. Example implementations include secure system administration courses for IT professionals, OWASP® Top 10 vulnerability awareness and prevention training for web application developers, and advanced social engineering awareness training for high-profile roles.", + "attributes": { + "Section": "14. Security Awareness and Skills Training", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.1", + "name": "Establish and Maintain an Inventory of Service Providers", + "description": "Establish and maintain an inventory of service providers. The inventory is to list all known service providers, include classification(s), and designate an enterprise contact for each service provider. Review and update the inventory annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Identify", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.2", + "name": "Establish and Maintain a Service Provider Management Policy", + "description": "Establish and maintain a service provider management policy. Ensure the policy addresses the classification, inventory, assessment, monitoring, and decommissioning of service providers. Review and update the policy annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.3", + "name": "Classify Service Providers", + "description": "Classify service providers. Classification consideration may include one or more characteristics, such as data sensitivity, data volume, availability requirements, applicable regulations, inherent risk, and mitigated risk. Update and review classifications annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.4", + "name": "Ensure Service Provider Contracts Include Security Requirements", + "description": "Ensure service provider contracts include security requirements. Example requirements may include minimum security program requirements, security incident and/or data breach notification and response, data encryption requirements, and data disposal commitments. These security requirements must be consistent with the enterprise's service provider management policy. Review service provider contracts annually to ensure contracts are not missing security requirements.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.5", + "name": "Assess Service Providers", + "description": "Assess service providers consistent with the enterprise's service provider management policy. Assessment scope may vary based on classification(s), and may include review of standardized assessment reports, such as Service Organization Control 2 (SOC 2) and Payment Card Industry (PCI) Attestation of Compliance (AoC), customized questionnaires, or other appropriately rigorous processes. Reassess service providers annually, at a minimum, or with new and renewed contracts.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Users", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.6", + "name": "Monitor Service Providers", + "description": "Monitor service providers consistent with the enterprise's service provider management policy. Monitoring may include periodic reassessment of service provider compliance, monitoring service provider release notes, and dark web monitoring.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Govern", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "15.7", + "name": "Securely Decommission Service Providers", + "description": "Securely decommission service providers. Example considerations include user and service account deactivation, termination of data flows, and secure disposal of enterprise data within service provider systems.", + "attributes": { + "Section": "15. Service Provider Management", + "Function": "Protect", + "AssetType": "Data", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.1", + "name": "Establish and Maintain a Secure Application Development Process", + "description": "Establish and maintain a secure application development process. In the process, address such items as: secure application design standards, secure coding practices, developer training, vulnerability management, security of third-party code, and application security testing procedures. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "github": [ + "repository_default_branch_deletion_disabled", + "repository_default_branch_disallows_force_push", + "repository_default_branch_dismisses_stale_reviews", + "repository_default_branch_protection_applies_to_admins", + "repository_default_branch_protection_enabled", + "repository_default_branch_requires_codeowners_review", + "repository_default_branch_requires_conversation_resolution", + "repository_default_branch_requires_linear_history", + "repository_default_branch_requires_multiple_approvals", + "repository_default_branch_requires_signed_commits", + "repository_default_branch_status_checks_required", + "repository_has_codeowners_file" + ] + } + }, + { + "id": "16.2", + "name": "Establish and Maintain a Process to Accept and Address Software Vulnerabilities", + "description": "Establish and maintain a process to accept and address reports of software vulnerabilities, including providing a means for external entities to report. The process is to include such items as: a vulnerability handling policy that identifies reporting process, responsible party for handling vulnerability reports, and a process for intake, assignment, remediation, and remediation testing. As part of the process, use a vulnerability tracking system that includes severity ratings and metrics for measuring timing for identification, analysis, and remediation of vulnerabilities. Review and update documentation annually, or when significant enterprise changes occur that could impact this Safeguard. Third-party application developers need to consider this an externally-facing policy that helps to set expectations for outside stakeholders.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "github": [ + "repository_public_has_securitymd_file" + ] + } + }, + { + "id": "16.3", + "name": "Perform Root Cause Analysis on Security Vulnerabilities", + "description": "Perform root cause analysis on security vulnerabilities. When reviewing vulnerabilities, root cause analysis is the task of evaluating underlying issues that create vulnerabilities in code, and allows development teams to move beyond just fixing individual vulnerabilities as they arise.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Detect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.4", + "name": "Establish and Manage an Inventory of Third-Party Software Components", + "description": "Establish and manage an updated inventory of third-party components used in development, often referred to as a \"bill of materials,\" as well as components slated for future use. This inventory is to include any risks that each third-party component could pose. Evaluate the list at least monthly to identify any changes or updates to these components, and validate that the component is still supported.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Identify", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "github": [ + "repository_dependency_scanning_enabled" + ] + } + }, + { + "id": "16.5", + "name": "Use Up-to-Date and Trusted Third-Party Software Components", + "description": "Use up-to-date and trusted third-party software components. When possible, choose established and proven frameworks and libraries that provide adequate security. Acquire these components from trusted sources or evaluate the software for vulnerabilities before use.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "codeartifact_packages_external_public_publishing_disabled" + ], + "github": [ + "repository_dependency_scanning_enabled" + ] + } + }, + { + "id": "16.6", + "name": "Establish and Maintain a Severity Rating System and Process for Application Vulnerabilities", + "description": "Establish and maintain a severity rating system and process for application vulnerabilities that facilitates prioritizing the order in which discovered vulnerabilities are fixed. This process includes setting a minimum level of security acceptability for releasing code or applications. Severity ratings bring a systematic way of triaging vulnerabilities that improves risk management and helps ensure the most severe bugs are fixed first. Review and update the system and process annually.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.7", + "name": "Use Standard Hardening Configuration Templates for Application Infrastructure", + "description": "Use standard, industry-recommended hardening configuration templates for application infrastructure components. This includes underlying servers, databases, and web servers, and applies to cloud containers, Platform as a Service (PaaS) components, and SaaS components. Do not allow in-house developed software to weaken configuration hardening.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "azure": [ + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_minimum_tls_version_12" + ], + "gcp": [ + "cloudsql_instance_mysql_local_infile_flag", + "cloudsql_instance_mysql_skip_show_database_flag", + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", + "cloudsql_instance_sqlserver_external_scripts_enabled_flag", + "cloudsql_instance_sqlserver_remote_access_flag", + "compute_instance_shielded_vm_enabled", + "gke_cluster_no_default_service_account" + ], + "kubernetes": [ + "apiserver_always_pull_images_plugin", + "apiserver_deny_service_external_ips", + "apiserver_event_rate_limit", + "apiserver_namespace_lifecycle_plugin", + "apiserver_no_always_admit_plugin", + "apiserver_node_restriction_plugin", + "apiserver_security_context_deny_plugin", + "core_image_tag_fixed", + "core_minimize_admission_hostport_containers", + "core_minimize_admission_windows_hostprocess_containers", + "core_minimize_allowPrivilegeEscalation_containers", + "core_minimize_containers_added_capabilities", + "core_minimize_containers_capabilities_assigned", + "core_minimize_hostIPC_containers", + "core_minimize_hostNetwork_containers", + "core_minimize_hostPID_containers", + "core_minimize_net_raw_capability_admission", + "core_minimize_privileged_containers", + "core_minimize_root_containers_admission", + "core_seccomp_profile_docker_default" + ], + "vercel": [ + "project_auto_expose_system_env_disabled", + "project_directory_listing_disabled", + "project_git_fork_protection_enabled" + ] + } + }, + { + "id": "16.8", + "name": "Separate Production and Non-Production Systems", + "description": "Maintain separate environments for production and non-production systems.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "vercel": [ + "deployment_production_uses_stable_target", + "project_deployment_protection_enabled", + "project_environment_no_overly_broad_target", + "project_environment_production_vars_not_in_preview" + ] + } + }, + { + "id": "16.9", + "name": "Train Developers in Application Security Concepts and Secure Coding", + "description": "Ensure that all software development personnel receive training in writing secure code for their specific development environment and responsibilities. Training can include general security principles and application security standard practices. Conduct training at least annually and design in a way to promote security within the development team, and build a culture of security among the developers.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.10", + "name": "Apply Secure Design Principles in Application Architectures", + "description": "Apply secure design principles in application architectures. Secure design principles include the concept of least privilege and enforcing mediation to validate every operation that the user makes, promoting the concept of \"never trust user input.\" Examples include ensuring that explicit error checking is performed and documented for all input, including for size, data type, and acceptable ranges or formats. Secure design also means minimizing the application infrastructure attack surface, such as turning off unprotected ports and services, removing unnecessary programs and files, and renaming or removing default accounts.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "apigateway_restapi_authorizers_enabled", + "apigateway_restapi_public_with_authorizer", + "apigatewayv2_api_authorizers_enabled", + "appsync_graphql_api_no_api_key_authentication", + "cognito_user_pool_client_prevent_user_existence_errors", + "ecs_task_definitions_containers_readonly_access", + "ecs_task_definitions_host_namespace_not_shared", + "ecs_task_definitions_host_networking_mode_users", + "ecs_task_definitions_no_privileged_containers", + "elb_desync_mitigation_mode", + "elbv2_alb_drop_invalid_header_fields_enabled", + "elbv2_desync_mitigation_mode", + "sagemaker_notebook_instance_root_access_disabled" + ] + } + }, + { + "id": "16.11", + "name": "Leverage Vetted Modules or Services for Application Security Components", + "description": "Leverage vetted modules or services for application security components, such as identity management, encryption, auditing, and logging. Using platform features in critical security functions will reduce developers' workload and minimize the likelihood of design or implementation errors. Modern operating systems provide effective mechanisms for identification, authentication, and authorization and make those mechanisms available to applications. Use only standardized, currently accepted, and extensively reviewed encryption algorithms. Operating systems also provide mechanisms to create and maintain secure audit logs.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_secret_rotated_periodically" + ], + "azure": [ + "app_ensure_auth_is_set_up", + "app_function_access_keys_configured", + "app_function_identity_is_configured", + "app_register_with_identity" + ], + "vercel": [ + "team_directory_sync_enabled", + "team_saml_sso_enabled" + ] + } + }, + { + "id": "16.12", + "name": "Implement Code-Level Security Checks", + "description": "Apply static and dynamic analysis tools within the application life cycle to verify that secure coding practices are being followed.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": { + "aws": [ + "awslambda_function_no_secrets_in_code", + "inspector2_is_enabled" + ], + "github": [ + "githubactions_workflow_security_scan", + "repository_secret_scanning_enabled" + ] + } + }, + { + "id": "16.13", + "name": "Conduct Application Penetration Testing", + "description": "Conduct application penetration testing. For critical applications, authenticated penetration testing is better suited to finding business logic vulnerabilities than code scanning and automated security testing. Penetration testing relies on the skill of the tester to manually manipulate an application as an authenticated and unauthenticated user.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Detect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "16.14", + "name": "Conduct Threat Modeling", + "description": "Conduct threat modeling. Threat modeling is the process of identifying and addressing application security design flaws within a design, before code is created. It is conducted through specially trained individuals who evaluate the application design and gauge security risks for each entry point and access level. The goal is to map out the application, architecture, and infrastructure in a structured way to understand its weaknesses.", + "attributes": { + "Section": "16. Application Software Security", + "Function": "Protect", + "AssetType": "Software", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.1", + "name": "Designate Personnel to Manage Incident Handling", + "description": "Designate one key person, and at least one backup, who will manage the enterprise's incident handling process. Management personnel are responsible for the coordination and documentation of incident response and recovery efforts and can consist of employees internal to the enterprise, service providers, or a hybrid approach. If using a service provider, designate at least one person internal to the enterprise to oversee any third-party work. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Respond", + "AssetType": "Users", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.2", + "name": "Establish and Maintain Contact Information for Reporting Security Incidents", + "description": "Establish and maintain contact information for parties that need to be informed of security incidents. Contacts may include internal staff, service providers, law enforcement, cyber insurance providers, relevant government agencies, Information Sharing and Analysis Center (ISAC) partners, or other stakeholders. Verify contacts annually to ensure that information is up-to-date.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "account_maintain_current_contact_details", + "account_maintain_different_contact_details_to_security_billing_and_operations", + "account_security_contact_information_is_registered" + ], + "gcp": [ + "iam_organization_essential_contacts_configured" + ], + "mongodbatlas": [ + "organizations_security_contact_defined" + ] + } + }, + { + "id": "17.3", + "name": "Establish and Maintain an Enterprise Process for Reporting Incidents", + "description": "Establish and maintain an documented enterprise process for the workforce to report security incidents. The process includes reporting timeframe, personnel to report to, mechanism for reporting, and the minimum information to be reported. Ensure the process is publicly available to all of the workforce. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG1", + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.4", + "name": "Establish and Maintain an Incident Response Process", + "description": "Establish and maintain a documented incident response process that addresses roles and responsibilities, compliance requirements, and a communication plan. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": { + "aws": [ + "ssmincidents_enabled_with_plans" + ] + } + }, + { + "id": "17.5", + "name": "Assign Key Roles and Responsibilities", + "description": "Assign key roles and responsibilities for incident response, including staff from legal, IT, information security, facilities, public relations, human resources, incident responders, analysts, and relevant third parties. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Respond", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.6", + "name": "Define Mechanisms for Communicating During Incident Response", + "description": "Determine which primary and secondary mechanisms will be used to communicate and report during a security incident. Mechanisms can include phone calls, emails, secure chat, or notification letters. Keep in mind that certain mechanisms, such as emails, can be affected during a security incident. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Respond", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.7", + "name": "Conduct Routine Incident Response Exercises", + "description": "Plan and conduct routine incident response exercises and scenarios for key personnel involved in the incident response process to prepare for responding to real-world incidents. Exercises need to test communication channels, decision making, and workflows. Conduct testing on an annual basis, at a minimum.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Recover", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.8", + "name": "Conduct Post-Incident Reviews", + "description": "Conduct post-incident reviews. Post-incident reviews help prevent incident recurrence through identifying lessons learned and follow-up action.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Recover", + "AssetType": "Users", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "17.9", + "name": "Establish and Maintain Security Incident Thresholds", + "description": "Establish and maintain security incident thresholds, including, at a minimum, differentiating between an incident and an event. Examples can include: abnormal activity, security vulnerability, security weakness, data breach, privacy incident, etc. Review annually, or when significant enterprise changes occur that could impact this Safeguard.", + "attributes": { + "Section": "17. Incident Response Management", + "Function": "Recover", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.1", + "name": "Establish and Maintain a Penetration Testing Program", + "description": "Establish and maintain a penetration testing program appropriate to the size, complexity, industry, and maturity of the enterprise. Penetration testing program characteristics include scope, such as network, web application, Application Programming Interface (API), hosted services, and physical premise controls; frequency; limitations, such as acceptable hours, and excluded attack types; point of contact information; remediation, such as how findings will be routed internally; and retrospective requirements.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Govern", + "AssetType": "Documentation", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.2", + "name": "Perform Periodic External Penetration Tests", + "description": "Perform periodic external penetration tests based on program requirements, no less than annually. External penetration testing must include enterprise and environmental reconnaissance to detect exploitable information. Penetration testing requires specialized skills and experience and must be conducted through a qualified party. The testing may be clear box or opaque box.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.3", + "name": "Remediate Penetration Test Findings", + "description": "Remediate penetration test findings based on the enterprise's documented vulnerability remediation process. This should include determining a timeline and level of effort based on the impact and prioritization of each identified finding.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG2", + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.4", + "name": "Validate Security Measures", + "description": "Validate security measures after each penetration test. If deemed necessary, modify rulesets and capabilities to detect the techniques used during testing.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Protect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + }, + { + "id": "18.5", + "name": "Perform Periodic Internal Penetration Tests", + "description": "Perform periodic internal penetration tests based on program requirements, no less than annually. The testing may be clear box or opaque box.", + "attributes": { + "Section": "18. Penetration Testing", + "Function": "Detect", + "AssetType": "Network", + "ImplementationGroups": [ + "IG3" + ] + }, + "checks": {} + } + ] +} diff --git a/prowler/compliance/csa_ccm_4.0.json b/prowler/compliance/csa_ccm_4.0.json new file mode 100644 index 0000000000..1cb7428e51 --- /dev/null +++ b/prowler/compliance/csa_ccm_4.0.json @@ -0,0 +1,9180 @@ +{ + "framework": "CSA-CCM", + "name": "CSA Cloud Controls Matrix (CCM) v4.0.13", + "version": "4.0", + "description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", + "icon": "csa", + "attributes_metadata": [ + { + "key": "Section", + "label": "Section", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "CCMLite", + "label": "CCM Lite", + "type": "str", + "enum": [ + "Yes", + "No" + ], + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "IaaS", + "label": "IaaS", + "type": "str", + "enum": [ + "Shared", + "CSP-Owned", + "Customer-Owned" + ], + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "PaaS", + "label": "PaaS", + "type": "str", + "enum": [ + "Shared", + "CSP-Owned", + "Customer-Owned" + ], + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "SaaS", + "label": "SaaS", + "type": "str", + "enum": [ + "Shared", + "CSP-Owned", + "Customer-Owned" + ], + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "ScopeApplicability", + "label": "Scope Applicability", + "type": "list_dict", + "required": false, + "output_formats": { + "csv": true, + "ocsf": false + } + } + ], + "outputs": { + "table_config": { + "group_by": "Section" + }, + "pdf_config": { + "language": "en", + "primary_color": "#336699", + "secondary_color": "#4D80B3", + "bg_color": "#F2F8FF", + "group_by_field": "Section", + "sections": [ + "Application & Interface Security", + "Audit & Assurance", + "Business Continuity Management and Operational Resilience", + "Change Control and Configuration Management", + "Cryptography, Encryption & Key Management", + "Data Security and Privacy Lifecycle Management", + "Datacenter Security", + "Governance, Risk and Compliance", + "Identity & Access Management", + "Infrastructure & Virtualization Security", + "Interoperability & Portability", + "Logging and Monitoring", + "Security Incident Management, E-Discovery, & Cloud Forensics", + "Threat & Vulnerability Management", + "Universal Endpoint Management" + ], + "section_short_names": { + "Application & Interface Security": "App & Interface Security", + "Business Continuity Management and Operational Resilience": "Business Continuity", + "Change Control and Configuration Management": "Change Control & Config", + "Cryptography, Encryption & Key Management": "Cryptography & Encryption", + "Data Security and Privacy Lifecycle Management": "Data Security & Privacy", + "Security Incident Management, E-Discovery, & Cloud Forensics": "Incident Mgmt & Forensics", + "Infrastructure & Virtualization Security": "Infrastructure & Virtualization" + }, + "charts": [ + { + "id": "section_compliance", + "type": "horizontal_bar", + "group_by": "Section", + "title": "Compliance Score by Domain", + "y_label": "Domain", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "A&A-02", + "description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", + "name": "Independent Assessments", + "attributes": { + "Section": "Audit & Assurance", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC4.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "AAC-02" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.5.2", + "5.2.6" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "AS1.1", + "AS2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.18.2.1", + "27002: 18.2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.35", + "27001: A.5.36" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CA-2", + "CA-2(1)", + "CA-2(2)", + "CA-7", + "CA-7(1)" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.IM-01" + ] + } + ] + }, + "checks": { + "aws": [ + "securityhub_enabled" + ], + "azure": [ + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_server_is_on" + ], + "gcp": [ + "iam_audit_logs_enabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "A&A-04", + "description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", + "name": "Requirements Compliance", + "attributes": { + "Section": "Audit & Assurance", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC3.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "GRM-01", + "GRM-03" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "7.1.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "AS1.1", + "AS2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 9.3.2", + "27001: A.18.2.2", + "27002: 18.2.2", + "27001: A.18.2.3", + "27002: 18.2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 9.3.2", + "27001: A.5.31", + "27001: A.5.32", + "27001: A.5.33", + "27001: A.5.34", + "27001: A.5.36" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CA-1" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.GV-3", + "DE.DP-2" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.IM-01" + ] + } + ] + }, + "checks": { + "aws": [ + "securityhub_enabled", + "config_recorder_all_regions_enabled" + ], + "azure": [ + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_mcas_is_enabled", + "monitor_diagnostic_settings_exists", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [ + "iam_audit_logs_enabled", + "iam_cloud_asset_inventory_enabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "AIS-04", + "description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", + "name": "Secure Application Design and Development", + "attributes": { + "Section": "Application & Interface Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.8", + "CC8.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "AIS-01", + "AIS-03" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "16.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.3.4", + "5.3.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SD1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.14.1.1", + "27002: 14.1.1", + "27017: 14.1.1", + "27001: A.14.1.2", + "27002: 14.1.2", + "27017: 14.1.2", + "27001: A.14.2.1", + "27002: 14.2.1", + "27017: 14.2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.8", + "27001: A.8.25", + "27001: A.8.26", + "27001: A.8.28" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PL-2", + "PL-8", + "PL-8(1)", + "SA-3", + "SA-3(1)", + "SA-4", + "SA-4(2)", + "SA-4(3)", + "SA-4(8)", + "SA-4(9)", + "SA-5", + "SA-8", + "SA-8(1)-(7)", + "SA-8(9)-(13)", + "SA-8(15)-(20)", + "SA-8(22)", + "SA-8(24)-(28)", + "SA-8(30)-(33)", + "SA-17", + "SA-17(1)-(9)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-6", + "PR.DS-7", + "PR.IP-2" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-08", + "PR.IR-01", + "PR.PS-01", + "PR.PS-02", + "PR.PS-06" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.3" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.2.1", + "6.2.3", + "6.5.2" + ] + } + ] + }, + "checks": { + "aws": [ + "codebuild_project_source_repo_url_no_sensitive_credentials", + "codebuild_project_no_secrets_in_variables" + ], + "azure": [ + "app_ensure_auth_is_set_up", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_ftps_deployment_disabled", + "app_register_with_identity" + ] + } + }, + { + "id": "AIS-05", + "description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", + "name": "Automated Application Security Testing", + "attributes": { + "Section": "Application & Interface Security", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.8", + "CC8.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "AIS-01", + "AIS-03" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "16.12", + "16.13" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SD2.3", + "SD2.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.14.2.8", + "27001: A.14.2.9", + "27001: A.12.1.2", + "27002: 12.1.2", + "27001: A.14.1.1", + "27002: 14.1.1", + "27001: A.14.2.2", + "27002: 14.2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.25", + "27001: A.8.29", + "27001: A.8.32", + "27002: 8.25 (e)", + "27002: 8.32 (d)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SA-11", + "SA-11(1)-(9)", + "SI-6", + "SI-6(2)", + "SI-6(3)", + "SI-10", + "SI-10(1)-(6)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.IP-2", + "PR.PT-3", + "PR.IP-12", + "DE.CM-8" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-08", + "ID.RA-01", + "PR.PS-01", + "PR.PS-02" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "A.3.2.2", + "A.3.2.2.1", + "6.6" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.2.4", + "6.4.1", + "6.4.2", + "6.5.1" + ] + } + ] + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ecr_registry_scan_images_on_push_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_containers_is_on", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled" + ] + } + }, + { + "id": "AIS-07", + "description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", + "name": "Application Vulnerability Remediation", + "attributes": { + "Section": "Application & Interface Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.1", + "CC7.4", + "CC8.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "TVM-02" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "16.2", + "16.6" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.16.1.5", + "27002: 16.1.5", + "27017: 16.1.5", + "27001: A.12.6.1", + "27002: 12.6.1", + "27017: 12.6.1", + "27018: 12.6.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.26", + "27001: A.8.8", + "27002: 5.26 (j)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SI-2", + "SI-2(2)-(6)", + "SA-11", + "SA-11(2)", + "SA-15", + "SA-15(1)-(3)", + "SA-15(5)-(8)", + "SA-15(10)-(12)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.IP-2", + "PR.IP-12", + "DE.CM-8", + "RS.AN-5", + "RS.MI-3", + "PR.DS-6" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-08", + "ID.RA-01", + "ID.RA-06", + "ID.RA-08", + "PR.PS-02", + "PR.PS-06" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.2", + "6.5", + "6.5.1-10" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.3.1", + "11.3.1", + "11.3.1.1" + ] + } + ] + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_containers_is_on", + "sqlserver_va_scan_reports_configured", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled" + ] + } + }, + { + "id": "BCR-08", + "description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", + "name": "Backup", + "attributes": { + "Section": "Business Continuity Management and Operational Resilience", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "A1.2", + "A1.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "BCR-11" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "11.1", + "11.2", + "11.3", + "11.4", + "11.5" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.8", + "5.2.9" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SY2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.3", + "27017: 12.3", + "27018: 12.3.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.13", + "27001: A.5.23", + "27001: A.5.30", + "27002: 8.13", + "27002: 5.23 2nd (i)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CP-4", + "CP-4(4)", + "CP-6", + "CP-6(1)-(3)", + "CP-9", + "CP-9(1)", + "CP-9(2)", + "CP-10", + "CP-10(2)", + "CP-10(4)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.IP-4", + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-01", + "PR.DS-11", + "RC.RP-03" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "9.5.1", + "12.10.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "12.10.1", + "10.3.3" + ] + } + ] + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_vaults_exist", + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "dynamodb_tables_pitr_enabled", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "ec2_ebs_volume_protected_by_backup_plan", + "efs_have_backup_enabled", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "rds_cluster_protected_by_backup_plan", + "redshift_cluster_automated_snapshot", + "s3_bucket_object_versioning", + "documentdb_cluster_backup_enabled", + "neptune_cluster_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "fsx_file_system_copy_tags_to_backups_enabled", + "lightsail_instance_automated_snapshots", + "dlm_ebs_snapshot_lifecycle_policy_exists" + ], + "azure": [ + "storage_ensure_encryption_with_customer_managed_keys", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_versioning_enabled", + "cloudstorage_bucket_soft_delete_enabled" + ], + "oraclecloud": [ + "objectstorage_bucket_versioning_enabled" + ] + } + }, + { + "id": "BCR-09", + "description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", + "name": "Disaster Response Plan", + "attributes": { + "Section": "Business Continuity Management and Operational Resilience", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "A1.2", + "CC3.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.8", + "5.2.9", + "1.6.1", + "1.6.2", + "1.6.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "BC1.4", + "BC2.1", + "BC2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.29", + "27001: A.5.30", + "27002: 5.29", + "27002: 5.30" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CP-2(1)", + "CP-2(2)", + "CP-2(3)", + "CP-2(5)", + "CP-2(6)", + "CP-2(7)", + "CP-2(8)", + "PE-13", + "PE-13(1)", + "PE-13(2)", + "PE-13(4)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.IP-9", + "PR.IP-10", + "RC.IM-1", + "RC.IM-2" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.IM-04" + ] + } + ] + }, + "checks": { + "aws": [ + "drs_job_exist", + "ssmincidents_enabled_with_plans" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "vm_backup_enabled" + ] + }, + "config_requirements": [ + { + "Check": "drs_job_exist", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "BCR-11", + "description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", + "name": "Equipment Redundancy", + "attributes": { + "Section": "Business Continuity Management and Operational Resilience", + "CCMLite": "No", + "IaaS": "CSP-Owned", + "PaaS": "CSP-Owned", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "A1.2", + "CC3.2" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "BCR-06" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.8" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "BC1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.20", + "27001: A.7.11", + "27001: A.8.14", + "27002: 5.20 (t)", + "27002: 8.14 (c)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CP-2", + "CP-2(2)", + "CP-4(3)", + "CP-6", + "CP-6(1)", + "CP-7", + "CP-8", + "CP-8(1)-(3)", + "CP-9", + "CP-9(6)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.BE-4", + "ID.BE-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "GV.OC-04", + "GV.OC-05", + "PR.IR-03" + ] + } + ] + }, + "checks": { + "aws": [ + "rds_instance_multi_az", + "rds_cluster_multi_az", + "elbv2_is_in_multiple_az", + "elb_is_in_multiple_az", + "autoscaling_group_multiple_az", + "autoscaling_group_multiple_instance_types", + "ec2_ebs_volume_protected_by_backup_plan", + "elasticache_redis_cluster_multi_az_enabled", + "elasticache_redis_cluster_automatic_failover_enabled", + "opensearch_service_domains_fault_tolerant_data_nodes", + "opensearch_service_domains_fault_tolerant_master_nodes", + "dynamodb_accelerator_cluster_multi_az", + "documentdb_cluster_multi_az_enabled", + "neptune_cluster_multi_az", + "efs_multi_az_enabled", + "vpc_vpn_connection_tunnels_up", + "directconnect_connection_redundancy", + "directconnect_virtual_interface_redundancy", + "networkfirewall_multi_az", + "vpc_endpoint_multi_az_enabled", + "awslambda_function_vpc_multi_az", + "redshift_cluster_multi_az_enabled" + ], + "azure": [ + "storage_blob_versioning_is_enabled", + "storage_geo_redundant_enabled", + "vm_scaleset_associated_with_load_balancer", + "vm_scaleset_not_empty", + "cosmosdb_account_automatic_failover_enabled", + "cosmosdb_account_backup_policy_continuous" + ], + "gcp": [ + "compute_instance_automatic_restart_enabled", + "compute_instance_group_autohealing_enabled", + "compute_instance_group_load_balancer_attached", + "compute_instance_group_multiple_zones", + "compute_instance_on_host_maintenance_migrate" + ] + } + }, + { + "id": "CCC-04", + "description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", + "name": "Unauthorized Change Protection", + "attributes": { + "Section": "Change Control and Configuration Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC8.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "CCC-04" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.1", + "1.3.4", + "5.3.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SY2.4", + "SM2.6" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.1.4", + "27002: 12.1.4", + "27001: A.12.4.2", + "27002: 12.4.2", + "27001: A.14.2.2", + "27017: 14.2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.3", + "27001: A.8.4", + "27001: A.8.15", + "27001: A.8.31", + "27001: A.8.32" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CA-7", + "CA-7(4)", + "CM-3", + "CM-3(1)", + "CM-3(5)", + "CM-3(7)", + "CM-3(8)", + "CM-5", + "CM-5(1)", + "CM-5(4)", + "CM-5(5)", + "CM-6", + "CM-6(1)", + "CM-6(2)", + "CM-7", + "CM-7(1)", + "CM-7(4)", + "CM-7(5)", + "CM-7(9)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.AM-1", + "ID.AM-2", + "ID.AM-4", + "PR.MA-1", + "PR.MA-2", + "PR.AC-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-01", + "ID.AM-02", + "ID.AM-04", + "ID.AM-08", + "PR.PS-02", + "PR.PS-03", + "PR.PS-05", + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.4.5.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.5.1", + "6.5.2" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_log_file_validation_enabled", + "s3_bucket_object_lock", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "servicecatalog_portfolio_shared_within_organization_only" + ], + "azure": [ + "iam_custom_role_has_permissions_to_administer_resource_locks", + "monitor_alert_create_policy_assignment", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "policy_ensure_asc_enforcement_enabled", + "storage_ensure_soft_delete_is_enabled" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled" + ], + "oraclecloud": [ + "events_rule_iam_group_changes", + "events_rule_iam_policy_changes", + "events_rule_user_changes", + "events_rule_vcn_changes" + ] + } + }, + { + "id": "CCC-07", + "description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", + "name": "Detection of Baseline Deviation", + "attributes": { + "Section": "Change Control and Configuration Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC8.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "GRM-01" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.5.1", + "1.5.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SY2.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.14.2.2", + "27001: A.14.2.4", + "27001: A.12.4.1", + "27002: 12.4.1 (g)", + "27001: A.5.1.1", + "27017: 5.1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.9", + "27001: A.8.15", + "27002: 8.9" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CM-6", + "CM-6(2)", + "SI-2", + "SI-2(2)-(6)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.MA-1", + "PR.IP-1", + "DE.DP-4", + "PR.IP-3" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-01", + "DE.CM-09", + "DE.AE-06" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.4.5.3", + "6.4.5.4", + "11.5", + "11.5.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "11.5.2", + "11.6.1" + ] + } + ] + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "guardduty_is_enabled", + "securityhub_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk" + ], + "azure": [ + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_settings_exists", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "sls_security_group_changes_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_cloud_firewall_changes_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled" + ], + "oraclecloud": [ + "cloudguard_enabled", + "events_rule_cloudguard_problems", + "events_rule_network_gateway_changes", + "events_rule_network_security_group_changes", + "events_rule_route_table_changes", + "events_rule_security_list_changes", + "events_rule_vcn_changes" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "CEK-03", + "description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", + "name": "Data Encryption", + "attributes": { + "Section": "Cryptography, Encryption & Key Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.7" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "EKM-03", + "EKM-04" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.6", + "3.1", + "3.11", + "11.3", + "16.11" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1", + "5.1.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.18.1.1", + "27001: A.18.1.2", + "27001: A.18.1.3", + "27001: A.18.1.4", + "27001: A.18.1.5", + "27001: A.10.1", + "27002: 10.1", + "27001: A.13.2.1", + "27002: 13.2.1", + "27001: A.18", + "27002: 18", + "27001: A.14.1.2", + "27002: 14.1.2", + "27001: A.14.1.3", + "27002 14.1.3 c)", + "27001 - A.10.1.1", + "27017 - 10.1.1", + "27001 - A.10.1.2", + "27017 - 10.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.14", + "27001: A.8.24", + "27002: 8.24 Other Information (a)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-19", + "AC-19(5)", + "SC-8", + "SC-8(1)", + "SC-8(3)", + "SC-8(4)", + "SC-12", + "SC-12(2)", + "SC-12(3)", + "SC-28", + "SC-28(1)-(3)", + "SI-4", + "SI-4(10)", + "SI-7", + "SI-7(6)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-1", + "PR.DS-2" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-01", + "PR.DS-02" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "Requirement 3", + "2.2.3", + "2.3", + "3.4", + "3.5.3", + "4.1", + "8.2.1", + "PCI Glossary - Strong Cryptography" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "2.2.7", + "3.5.1", + "4.2.1", + "4.2.1.2", + "4.2.2" + ] + } + ] + }, + "checks": { + "aws": [ + "ec2_ebs_volume_encryption", + "ec2_ebs_default_encryption", + "ec2_ebs_snapshots_encrypted", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "s3_bucket_secure_transport_policy", + "rds_instance_storage_encrypted", + "rds_cluster_storage_encrypted", + "rds_instance_transport_encrypted", + "rds_snapshots_encrypted", + "efs_encryption_at_rest_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "dynamodb_accelerator_cluster_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "kinesis_stream_encrypted_at_rest", + "firehose_stream_encrypted_at_rest", + "sns_topics_kms_encryption_at_rest_enabled", + "sqs_queues_server_side_encryption_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "opensearch_service_domains_encryption_at_rest_enabled", + "opensearch_service_domains_node_to_node_encryption_enabled", + "opensearch_service_domains_https_communications_enforced", + "redshift_cluster_encrypted_at_rest", + "redshift_cluster_in_transit_encryption_enabled", + "documentdb_cluster_storage_encrypted", + "neptune_cluster_storage_encrypted", + "neptune_cluster_snapshot_encrypted", + "elasticache_redis_cluster_rest_encryption_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "kafka_cluster_in_transit_encryption_enabled", + "kafka_cluster_encryption_at_rest_uses_cmk", + "kafka_connector_in_transit_encryption_enabled", + "dms_endpoint_ssl_enabled", + "dms_endpoint_redis_in_transit_encryption_enabled", + "elb_ssl_listeners", + "elbv2_ssl_listeners", + "elbv2_insecure_ssl_ciphers", + "elbv2_nlb_tls_termination_enabled", + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + "cloudfront_distributions_custom_ssl_certificate", + "transfer_server_in_transit_encryption_enabled", + "transfer_server_pqc_ssh_kex_enabled", + "sagemaker_notebook_instance_encryption_enabled", + "sagemaker_training_jobs_volume_and_output_encryption_enabled", + "workspaces_volume_encryption_enabled", + "storagegateway_fileshare_encryption_enabled", + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "athena_workgroup_encryption", + "glue_data_catalogs_connection_passwords_encryption_enabled", + "glue_data_catalogs_metadata_encryption_enabled", + "glue_etl_jobs_amazon_s3_encryption_enabled", + "glue_etl_jobs_cloudwatch_logs_encryption_enabled", + "glue_etl_jobs_job_bookmark_encryption_enabled", + "glue_development_endpoints_s3_encryption_enabled", + "glue_development_endpoints_cloudwatch_logs_encryption_enabled", + "glue_development_endpoints_job_bookmark_encryption_enabled", + "glue_ml_transform_encrypted_at_rest", + "bedrock_model_invocation_logs_encryption_enabled", + "codebuild_project_s3_logs_encrypted", + "codebuild_report_group_export_encrypted" + ], + "azure": [ + "app_minimum_tls_version_12", + "databricks_workspace_cmk_encryption_enabled", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "compute_instance_encryption_with_csek_enabled", + "compute_instance_confidential_computing_enabled", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "cloudsql_instance_ssl_connections", + "dataproc_encrypted_with_cmks_disabled" + ], + "alibabacloud": [ + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "rds_instance_tde_enabled", + "rds_instance_ssl_enabled", + "oss_bucket_secure_transport_enabled" + ], + "oraclecloud": [ + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk", + "compute_instance_in_transit_encryption_enabled", + "filestorage_file_system_encrypted_with_cmk", + "objectstorage_bucket_encrypted_with_cmk" + ] + }, + "config_requirements": [ + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "Provider": "azure", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] + }, + { + "id": "CEK-04", + "description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", + "name": "Encryption Algorithm", + "attributes": { + "Section": "Cryptography, Encryption & Key Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.7" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "EKM-04" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "16.11" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1", + "5.1.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 6.1.2", + "27001: 6.1.3", + "27001: A.8.2", + "27002: 8.2", + "27001: A.8.3", + "27001: A.10.1.1", + "27002: 10.1.1 (b)", + "27001: A.10.1.2", + "27002: 10.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 6.1.2", + "27001: 6.1.3", + "27001: A.8.24", + "27001: A.5.12", + "27001: A.5.13", + "27002: 8.24 General (b)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SC-12", + "SC-12(2)", + "SC-12(3)", + "SC-28", + "SC-28(1)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-1", + "PR.DS-2", + "ID.AM-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-01", + "PR.DS-02", + "ID.AM-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "A2", + "Requirement 3", + "2.3", + "2.2.3", + "3.4", + "3.5.3", + "4.1", + "8.2.1", + "PCI Glossary - Strong Cryptography" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "2.2.7", + "3.5.1", + "4.2.1", + "4.2.1.2", + "4.2.2" + ] + } + ] + }, + "checks": { + "aws": [ + "acm_certificates_with_secure_key_algorithms", + "elb_insecure_ssl_ciphers", + "elbv2_insecure_ssl_ciphers", + "transfer_server_pqc_ssh_kex_enabled", + "cloudfront_distributions_using_deprecated_ssl_protocols" + ], + "azure": [ + "app_minimum_tls_version_12", + "keyvault_key_rotation_enabled", + "mysql_flexible_server_minimum_tls_version_12", + "postgresql_flexible_server_enforce_ssl_enabled", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "storage_smb_protocol_version_is_latest" + ], + "gcp": [ + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ] + }, + "config_requirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "Provider": "aws", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + }, + { + "Check": "sqlserver_recommended_minimal_tls_version", + "Provider": "azure", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ] + }, + { + "id": "CEK-08", + "description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", + "name": "CSC Key Management Capability", + "attributes": { + "Section": "Cryptography, Encryption & Key Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.2", + "SC2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.10.1", + "27017: 10.1", + "27001: A.10.1.1", + "27017: 10.1.1", + "27001: A.10.1.2", + "27017: 10.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.23", + "27001: A.8.24" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CP-9", + "CP-9(8)", + "SA-9", + "SA-9(6)", + "SC-12", + "SC-12(6)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.SC-3", + "ID.AM-6", + "PR.AC-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "GV.SC-05" + ] + } + ] + }, + "checks": { + "aws": [ + "kms_cmk_are_used", + "s3_bucket_kms_encryption", + "dynamodb_tables_kms_cmk_encryption_enabled", + "kms_key_not_publicly_accessible", + "kms_cmk_not_multi_region" + ], + "azure": [ + "databricks_workspace_cmk_encryption_enabled", + "keyvault_access_only_through_private_endpoints", + "keyvault_private_endpoints", + "keyvault_rbac_enabled", + "storage_ensure_encryption_with_customer_managed_keys" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled", + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled" + ], + "alibabacloud": [ + "rds_instance_tde_key_custom" + ], + "oraclecloud": [ + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk", + "filestorage_file_system_encrypted_with_cmk", + "objectstorage_bucket_encrypted_with_cmk" + ] + } + }, + { + "id": "CEK-10", + "description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", + "name": "Key Generation", + "attributes": { + "Section": "Cryptography, Encryption & Key Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "EKM-04" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "16.11" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.2", + "TS2.3", + "SY1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.10.1.1", + "27002: 10.1.1 (e)", + "27017: 10.1.1", + "27001: A.10.1.2", + "27002: 10.1.2", + "27002: 10.1.2 (a)", + "27017: 10.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.24", + "27002: 8.24 (d), Key management (a)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SC-12", + "SC-12(2)", + "SC-12(3)", + "SC-13" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "2.2.3", + "3.6.1", + "PCI Glossary - Cryptographic Key Generation" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.6.1", + "3.6.1.1", + "3.7.1" + ] + } + ] + }, + "checks": { + "aws": [ + "kms_cmk_are_used" + ], + "azure": [ + "keyvault_rbac_enabled", + "storage_ensure_encryption_with_customer_managed_keys" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "dataproc_encrypted_with_cmks_disabled" + ], + "alibabacloud": [ + "rds_instance_tde_key_custom" + ] + } + }, + { + "id": "CEK-12", + "description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", + "name": "Key Rotation", + "attributes": { + "Section": "Cryptography, Encryption & Key Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.10.1.1", + "27017: 10.1.1", + "27001: A.10.1.2", + "27002: 10.1.2 e)", + "27017: 10.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.31", + "27001: A.8.24", + "27002: 5.31 Cryptography", + "27002: 8.24 Key management (e,m)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SC-12", + "SC-12(2)", + "SC-12(3)", + "SC-13" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "ID.GV-3" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-05", + "GV.OC-03" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.7.4", + "3.7.5" + ] + } + ] + }, + "checks": { + "aws": [ + "kms_cmk_rotation_enabled", + "iam_rotate_access_key_90_days", + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_secret_rotated_periodically" + ], + "azure": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "storage_key_rotation_90_days" + ], + "gcp": [ + "kms_key_rotation_enabled", + "iam_sa_user_managed_key_rotate_90_days", + "apikeys_key_rotated_in_90_days" + ], + "alibabacloud": [ + "ram_rotate_access_key_90_days" + ], + "oraclecloud": [ + "kms_key_rotation_enabled", + "identity_user_api_keys_rotated_90_days", + "identity_user_auth_tokens_rotated_90_days", + "identity_user_customer_secret_keys_rotated_90_days", + "identity_user_db_passwords_rotated_90_days" + ] + } + }, + { + "id": "CEK-14", + "description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", + "name": "Key Destruction", + "attributes": { + "Section": "Cryptography, Encryption & Key Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.10.1.1", + "27017: 10.1.1", + "27017: 10.1.2", + "27001: A.10.1.2", + "27002: 10.1.2 (j)", + "27001: A.18.1.3", + "27002: 18.1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.31", + "27001: A.8.24", + "27002: 5.31 Cryptography", + "27002: 8.24 Key management (j,m)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SC-12", + "SC-12(2)", + "SC-12(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.IP-6", + "ID.GV-3", + "PR.DS-3" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-05", + "ID.AM-08", + "GV.OC-03" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "3.6.4", + "3.6.5" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.7.4", + "3.7.5" + ] + } + ] + }, + "checks": { + "aws": [ + "kms_cmk_not_deleted_unintentionally" + ], + "azure": [ + "keyvault_recoverable" + ], + "gcp": [ + "iam_role_kms_enforce_separation_of_duties" + ] + } + }, + { + "id": "DCS-06", + "description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", + "name": "Assets Cataloguing and Tracking", + "attributes": { + "Section": "Datacenter Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "DCS - 01" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "1.1", + "2.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.3.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SM2.6" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.8.1.1", + "27002: 8.1.1", + "27017: 8.1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.9" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CM-8", + "CM-8(1)", + "CM-8(2)", + "CM-8(4)", + "CM-8(7)", + "CM-8(8)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.AM-1", + "ID.AM-2", + "ID.AM-4", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-01", + "ID.AM-02", + "ID.AM-04" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "2.4", + "9.7.1", + "9.9.1", + "9.9.1.a", + "9.9.1.b", + "9.9.1.c", + "12.3.3", + "12.3.4" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.6.1.1", + "6.3.2", + "9.4.2", + "9.4.3", + "12.5.1" + ] + } + ] + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "resourceexplorer2_indexes_found" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "monitor_diagnostic_settings_exists", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ], + "alibabacloud": [ + "securitycenter_all_assets_agent_installed" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DSP-02", + "description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", + "name": "Secure Disposal", + "attributes": { + "Section": "Data Security and Privacy Lifecycle Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.2", + "CC6.3", + "CC6.4", + "CC6.5", + "CC6.7", + "P4.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "DSI-07" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.5" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1", + "5.3.3", + "7.1.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.1", + "IM1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.8.3.2", + "27002: 8.3.2", + "27001: A.11.2.7", + "27002: 11.2.7" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.7.10", + "27001: A.7.14", + "27001: A.8.10", + "27002: 7.10 (Secure reuse or disposal)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PM-22", + "SI-12", + "SI-12(3)", + "SI-18", + "SI-18(1)", + "SI-18(4)", + "SI-18(5)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.IP-6" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "GV.SC-10", + "PR.PS-02", + "PR.PS-03", + "ID.AM-08" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "3.1", + "9.8", + "9.8.1", + "9.8.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.2.1", + "3.7.5", + "9.4.7" + ] + } + ] + }, + "checks": { + "aws": [ + "s3_bucket_lifecycle_enabled", + "dlm_ebs_snapshot_lifecycle_policy_exists", + "ecr_repositories_lifecycle_policy_enabled" + ], + "azure": [ + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_ensure_soft_delete_is_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "cloudstorage_bucket_lifecycle_management_enabled" + ] + } + }, + { + "id": "DSP-03", + "description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", + "name": "Data Inventory", + "attributes": { + "Section": "Data Security and Privacy Lifecycle Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.3.1", + "1.3.2", + "1.3.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.1", + "IM2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.8.1.1", + "27002: 8.1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.9", + "27001: A.8.12" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CM-12", + "CM-12(1)", + "PM-5", + "PM-5(1)", + "SI-12", + "SI-12(1)", + "SI-19", + "SI-19(1)", + "SI-19(2)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.AM-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-07" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.2.1", + "9.4.5" + ] + } + ] + }, + "checks": { + "aws": [ + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "config_recorder_all_regions_enabled" + ], + "azure": [ + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_mcas_is_enabled", + "monitor_diagnostic_settings_exists", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled" + ], + "alibabacloud": [ + "securitycenter_all_assets_agent_installed" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DSP-04", + "description": "Classify data according to its type and sensitivity level.", + "name": "Data Classification", + "attributes": { + "Section": "Data Security and Privacy Lifecycle Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "C1.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "DSI-01" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.7" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.3.1", + "1.3.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.8.2.1", + "27002: 8.2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.12" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-16", + "AC-16(9)", + "PM-22", + "PM-23", + "PT-2", + "PT-2(1)", + "SI-18", + "SI-18(2)", + "SI-19", + "SI-19(6)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.AM-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-05", + "ID.AM-07" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "9.6.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "9.4.2", + "9.4.3" + ] + } + ] + }, + "checks": { + "aws": [ + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled" + ], + "azure": [ + "defender_ensure_defender_for_storage_is_on" + ] + } + }, + { + "id": "DSP-07", + "description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", + "name": "Data Protection by Design and Default", + "attributes": { + "Section": "Data Security and Privacy Lifecycle Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "PI1.2", + "PI1.3" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "16.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.3.1", + "5.3.2", + "5.3.3", + "5.3.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SD2.2", + "IM1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.14.1.1", + "27002:14.1.1", + "27001: A.14.2.5", + "27002:14.2.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.27", + "27001: A.8.28", + "27001: A.8.29", + "27002: 5.8 (Information security requirements a-i)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PM-17", + "PM-24", + "PM-25", + "PT-2", + "PT-2(2)", + "SA-3", + "SA-4", + "SA-5", + "SA-8", + "SA-8(9)", + "SA-8(13)", + "SA-8(18)", + "SA-8(20)", + "SA-8(22)", + "SA-8(23)", + "SA-8(33)", + "SA-15", + "SA-15(12)", + "SC-3", + "SC-3(3)", + "SC-7", + "SC-7(24)", + "SC-8", + "SC-8(1)-(4)", + "SC-28", + "SC-28(1)", + "SI-12", + "SI-12(1)-(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.IP-2", + "PR.PT-3", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-08", + "PR.PS-06" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "ec2_ebs_default_encryption", + "s3_account_level_public_access_blocks", + "s3_bucket_level_public_access_block", + "ec2_ebs_snapshot_account_block_public_access", + "rds_instance_no_public_access", + "rds_snapshots_public_access" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "sqlserver_unrestricted_inbound_access", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudstorage_bucket_public_access", + "compute_image_not_publicly_shared", + "compute_instance_public_ip", + "kms_key_not_publicly_accessible" + ], + "alibabacloud": [ + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist" + ], + "oraclecloud": [ + "objectstorage_bucket_not_publicly_accessible", + "database_autonomous_database_access_restricted", + "analytics_instance_access_restricted", + "integration_instance_access_restricted" + ] + } + }, + { + "id": "DSP-10", + "description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", + "name": "Sensitive Data Transfer", + "attributes": { + "Section": "Data Security and Privacy Lifecycle Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.7" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "GRM-02", + "EKM-03" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.1", + "3.12", + "3.13" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.2", + "9.5.1", + "9.5.2", + "9.5.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.4", + "IM2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.13.2.1", + "27002: 13.2.1", + "27001: A.8.3.3", + "27002: 8.3.3", + "27001: A.13.2.3", + "27002: 13.2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.14", + "27001: A.7.10" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-4", + "AC-4(23)-(25)", + "CA-3", + "CA-3(6)", + "CA-6", + "CA-6(1)", + "CA-6(2)", + "SC-4", + "SC-4(2)", + "SC-7", + "SC-7(10)", + "SC-7(24)", + "SC-8", + "SC-8(1)-(5)", + "SC-16", + "SC-16(1)-(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-2", + "PR.DS-5", + "PR.PT-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-02", + "PR.IR-01", + "ID.AM-03", + "GV.OC-03", + "ID.AM-07" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "4.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "4.1.1", + "4.2.1", + "4.2.2" + ] + } + ] + }, + "checks": { + "aws": [ + "s3_bucket_secure_transport_policy", + "opensearch_service_domains_https_communications_enforced", + "redshift_cluster_in_transit_encryption_enabled", + "rds_instance_transport_encrypted", + "transfer_server_in_transit_encryption_enabled", + "kafka_cluster_mutual_tls_authentication_enabled" + ], + "azure": [ + "app_ensure_http_is_redirected_to_https", + "app_ensure_using_http20", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections" + ], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ], + "oraclecloud": [ + "compute_instance_in_transit_encryption_enabled" + ] + }, + "config_requirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "Provider": "azure", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + } + ] + }, + { + "id": "DSP-16", + "description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", + "name": "Data Retention and Deletion", + "attributes": { + "Section": "Data Security and Privacy Lifecycle Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "C1.1", + "C1.2", + "CC3.1", + "P4.2" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "GRM-02", + "BCR-11" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.4", + "3.5" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1", + "5.3.1", + "7.1.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.1", + "IM2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.18.1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.33", + "27001: A.8.10", + "27002: 5.33 (b)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SI-12", + "SI-12(1)-(3)", + "SI-18", + "SI-18(1)", + "SI-18(4)", + "SI-18(5)", + "SI-19", + "SI-19(2)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-3", + "PR.IP-6", + "ID.GV-3" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-08", + "GV.OC-03", + "GV.SC-10", + "PR.DS-11" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "3.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "s3_bucket_lifecycle_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "kinesis_stream_data_retention_period", + "ecr_repositories_lifecycle_policy_enabled" + ], + "azure": [ + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_ensure_soft_delete_is_enabled" + ], + "gcp": [ + "cloudstorage_bucket_lifecycle_management_enabled", + "cloudstorage_bucket_sufficient_retention_period" + ], + "alibabacloud": [ + "sls_logstore_retention_period", + "rds_instance_sql_audit_retention" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days" + ] + } + }, + { + "id": "DSP-17", + "description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", + "name": "Sensitive Data Protection", + "attributes": { + "Section": "Data Security and Privacy Lifecycle Management", + "CCMLite": "Yes", + "IaaS": "CSP-Owned", + "PaaS": "CSP-Owned", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC2.1", + "CC6.1", + "CC6.3", + "CC6.7", + "CC8.1", + "C1.1", + "P2.0", + "P3.0", + "P4.0", + "P5.0", + "P6.0" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.1", + "3.1", + "3.14" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.3.3", + "9.1.1", + "9.2.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.1", + "IM2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.18.1.3", + "27002: 18.1.3", + "27001:A.18.1.4", + "27002:18.1.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.11", + "27001: A.8.12" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PL-2", + "PM-22", + "PM-24", + "PT-7", + "PT-7(1)", + "PT-7(2)", + "PT-8", + "SC-8", + "SC-8(1)-(5)", + "SC-28", + "SC-28(1)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-1", + "PR.DS-2", + "PR.DS-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-01", + "PR.DS-02", + "PR.DS-10" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "3.0 (including all subsections)", + "4.0 (including all subsections)" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.1.1", + "4.1.1" + ] + } + ] + }, + "checks": { + "aws": [ + "s3_account_level_public_access_blocks", + "s3_bucket_public_access", + "s3_bucket_policy_public_write_access", + "ec2_ebs_public_snapshot", + "rds_snapshots_public_access", + "rds_instance_no_public_access", + "ec2_ebs_volume_encryption", + "s3_bucket_default_encryption", + "rds_instance_storage_encrypted", + "secretsmanager_not_publicly_accessible", + "macie_is_enabled" + ], + "azure": [ + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "defender_ensure_defender_for_storage_is_on", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_key_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_cross_tenant_replication_disabled", + "storage_default_network_access_rule_is_denied", + "storage_default_to_entra_authorization_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "vm_ensure_attached_disks_encrypted_with_cmk" + ], + "gcp": [ + "cloudstorage_bucket_public_access", + "bigquery_dataset_public_access", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "compute_instance_public_ip", + "compute_image_not_publicly_shared", + "kms_key_not_publicly_accessible" + ], + "alibabacloud": [ + "oss_bucket_not_publicly_accessible", + "rds_instance_no_public_access_whitelist", + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "rds_instance_tde_enabled" + ], + "oraclecloud": [ + "objectstorage_bucket_not_publicly_accessible", + "objectstorage_bucket_encrypted_with_cmk", + "database_autonomous_database_access_restricted", + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk" + ] + } + }, + { + "id": "GRC-05", + "description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", + "name": "Information Security Program", + "attributes": { + "Section": "Governance, Risk and Compliance", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "GRM-04" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "14.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SG2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 4.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 4.3" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PM-1", + "PM-3", + "PM-14", + "PL-2", + "PM-18", + "PM-31" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "12.4.1", + "A.3.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "12.4.1", + "A3.1.1" + ] + } + ] + }, + "checks": { + "aws": [ + "securityhub_enabled", + "guardduty_is_enabled" + ], + "azure": [ + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled" + ], + "gcp": [ + "iam_account_access_approval_enabled", + "iam_audit_logs_enabled", + "iam_organization_essential_contacts_configured" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "IAM-02", + "description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", + "name": "Strong Password Policy and Procedures", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-02", + "IAM-12", + "GRM-06", + "GRM-09" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "5.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.1.1", + "1.5.1", + "4.1.2", + "4.1.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.1", + "SA1.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 5.1", + "27001: 5.2", + "27001: 7.3", + "27001: 7.4", + "27001: 7.5", + "27001: 9.1", + "27001: 9.3", + "27001: A.5", + "27002: 5", + "27001: A.9.4.3", + "27002: 9.4.3", + "27017: 9.4.3", + "27018: 9.4.3", + "27001: A.9.2.4", + "27002: 9.2.4", + "27017: 9.2.4", + "27001: A.7.2.2", + "27002: 7.2.2", + "27001: A.9.2.6", + "27002: 9.2.6", + "27001: A.9.2.3", + "27002: 9.2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 5.1", + "27001: 5.2", + "27001: 7.3", + "27001: 7.4", + "27001: 7.5", + "27001: 9.1", + "27001: 9.3", + "27001: A.5.1", + "27001: A.5.4", + "27001: A.5.17", + "27001: A.6.3", + "27001: A.8.5", + "27001: A.5.37" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-2", + "AC-2(3)", + "AC-2(11)", + "AC-3", + "AC-3(3)", + "AC-12", + "AC-12(1)", + "IA-2", + "IA-2(10)", + "IA-5", + "IA-5(1)", + "IA-5(18)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.GV-1", + "PR.AC-1", + "PR.AC-7" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "GV.PO-01", + "GV.PO-02", + "ID.IM-03", + "PR.AA-03" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "8.4", + "12.1", + "12.1.1", + "12.11" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "8.1.1", + "8.3.8" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_password_policy_minimum_length_14", + "iam_password_policy_lowercase", + "iam_password_policy_uppercase", + "iam_password_policy_number", + "iam_password_policy_symbol", + "iam_password_policy_reuse_24", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_uppercase", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol" + ], + "azure": [ + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled" + ], + "alibabacloud": [ + "ram_password_policy_minimum_length", + "ram_password_policy_lowercase", + "ram_password_policy_uppercase", + "ram_password_policy_number", + "ram_password_policy_symbol", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_max_password_age", + "ram_password_policy_max_login_attempts" + ], + "oraclecloud": [ + "identity_password_policy_minimum_length_14", + "identity_password_policy_expires_within_365_days", + "identity_password_policy_prevents_reuse" + ] + } + }, + { + "id": "IAM-03", + "description": "Manage, store, and review the information of system identities, and level of access.", + "name": "Identity Inventory", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-04", + "IAM-08", + "IAM-10" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "5.1", + "5.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.1.3", + "4.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 9.2 (c)", + "27001: A.8.1.1", + "27002: 8.1.1", + "27001: A.9.4.1", + "27002: 9.4.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 9.2 (c)", + "27001: A.5.15", + "27001: A.5.16", + "27001: A.5.18", + "27001: A.7.4", + "27001: A.8.15", + "27001: A.8.2", + "27001: A.8.3" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-10", + "AU-10(1)", + "AU-10(2)", + "AU-16", + "AU-16(1)", + "IA-4", + "IA-4(8)", + "IA-4(9)", + "IA-5", + "IA-5(5)", + "IA-8", + "IA-8(4)", + "PM-5(1)", + "SA-8", + "SA-8(22)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.AC-6", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-02", + "PR.AA-04", + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "2.4.a" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "7.2.5", + "7.2.5.1" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused", + "iam_user_two_active_access_key" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users" + ], + "gcp": [ + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ], + "alibabacloud": [ + "ram_user_console_access_unused" + ], + "oraclecloud": [ + "identity_user_api_keys_rotated_90_days", + "identity_user_auth_tokens_rotated_90_days", + "identity_user_customer_secret_keys_rotated_90_days", + "identity_user_valid_email_address" + ] + } + }, + { + "id": "IAM-04", + "description": "Employ the separation of duties principle when implementing information system access.", + "name": "Separation of Duties", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC1.3", + "CC5.1", + "CC6.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-05" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "6.8" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.2.2", + "4.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.6.1.2", + "27002: 6.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.15", + "27001: A.5.18", + "27001: A.5.3", + "27001: A.8.2" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-2", + "AC-2(3)", + "AC-2(11)", + "AC-6", + "AC-6(1)-(10)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.4", + "6.4.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.5.3", + "6.5.4", + "7.2.1", + "7.2.2" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_policy_attached_only_to_group_or_roles", + "iam_securityaudit_role_created", + "iam_support_role_created" + ], + "azure": [ + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created" + ], + "gcp": [ + "iam_role_sa_enforce_separation_of_duties", + "iam_role_kms_enforce_separation_of_duties", + "iam_no_service_roles_at_project_level" + ], + "alibabacloud": [ + "ram_policy_attached_only_to_group_or_roles" + ], + "oraclecloud": [ + "identity_service_level_admins_exist", + "identity_iam_admins_cannot_update_tenancy_admins", + "identity_tenancy_admin_permissions_limited" + ] + } + }, + { + "id": "IAM-05", + "description": "Employ the least privilege principle when implementing information system access.", + "name": "Least Privilege", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-02", + "IAM-06", + "IVS-11" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "6.8" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.1.1", + "27002: 9.1.1", + "27001: A.9.1.2", + "27002: 9.1.2", + "27001: A.9.2.3", + "27002: 9.2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.15", + "27001: A.8.2", + "27002: 5.15 (Other information 2nd (a))" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-6", + "AC-6(4)", + "IA-12", + "IA-12(2)", + "IA-12(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "7.1", + "7.1.1", + "7.1.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "7.2.1", + "7.2.2", + "7.2.5", + "7.2.6" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_allows_privilege_escalation", + "iam_no_custom_policy_permissive_role_assumption", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy", + "iam_group_administrator_access_policy", + "iam_administrator_access_with_mfa" + ], + "azure": [ + "app_function_identity_without_admin_privileges", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_restricts_user_consent_for_apps", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "iam_subscription_roles_owner_custom_not_created" + ], + "gcp": [ + "apikeys_api_restrictions_configured", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account", + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges" + ], + "oraclecloud": [ + "identity_tenancy_admin_permissions_limited", + "identity_service_level_admins_exist", + "identity_no_resources_in_root_compartment", + "identity_non_root_compartment_exists" + ] + } + }, + { + "id": "IAM-07", + "description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", + "name": "User Access Changes and Revocation", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC5.3", + "CC6.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-11" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "5.3", + "6.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.15", + "27001: A.5.18" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-2", + "AC-2(1)", + "AC-2(2)", + "AC-2(6)", + "AC-2(8)", + "AC-3", + "AC-3(8)", + "AC-6", + "AC-6(7)", + "AU-10", + "AU-10(4)", + "AU-16", + "AU-16(1)", + "CM-7", + "CM-7(1)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.AC-4", + "PR.IP-11" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "GV.RR-04", + "GV.SC-10", + "PR.AA-01", + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "8.1.2", + "8.1.3" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "8.2.5", + "8.2.6" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused", + "iam_user_no_setup_initial_access_key" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users" + ], + "gcp": [ + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ], + "alibabacloud": [ + "ram_user_console_access_unused" + ], + "oraclecloud": [ + "identity_user_api_keys_rotated_90_days", + "identity_user_auth_tokens_rotated_90_days", + "identity_user_customer_secret_keys_rotated_90_days" + ] + } + }, + { + "id": "IAM-08", + "description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", + "name": "User Access Review", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.2", + "CC6.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-10" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "5.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.2.5", + "27001: A.9.2.6", + "27001: A.9.4.1", + "27017: 9.4.1", + "27001: A.6.1.2", + "27001: A 9.2.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.3", + "27001: A.5.18", + "27001: A.8.3" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-6", + "AC-6(4)", + "AC-6(8)", + "IA-8", + "IA-8(4)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "12.5.5" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "7.2.5.1", + "7.2.5", + "7.2.4" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused", + "iam_rotate_access_key_90_days", + "secretsmanager_secret_unused" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_secret_expiration_set", + "storage_key_rotation_90_days" + ], + "gcp": [ + "iam_sa_user_managed_key_unused", + "iam_service_account_unused", + "iam_sa_user_managed_key_rotate_90_days" + ], + "alibabacloud": [ + "ram_user_console_access_unused", + "ram_rotate_access_key_90_days" + ], + "oraclecloud": [ + "identity_user_api_keys_rotated_90_days", + "identity_user_auth_tokens_rotated_90_days", + "identity_user_customer_secret_keys_rotated_90_days", + "identity_user_db_passwords_rotated_90_days" + ] + } + }, + { + "id": "IAM-09", + "description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", + "name": "Segregation of Privileged Access Roles", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC5.1", + "CC6.1", + "CC6.3" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "5.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.2.3", + "27002: 9.2.3", + "27017: 9.2.3", + "27018: 9.2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.2", + "27001: A.8.18", + "27002: 8.2 (j)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-6", + "AC-3(7)", + "AC-6(4)", + "AC-6(8)", + "IA-5", + "IA-5(6)", + "IA-8", + "IA-8(4)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "2.3", + "3.5.2", + "7.1.2", + "7.1.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.6.1", + "3.7.6", + "6.5.3", + "6.5.4", + "7.2.1", + "7.2.2", + "10.3.1" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_policy_attached_only_to_group_or_roles", + "iam_role_administratoraccess_policy", + "iam_avoid_root_usage", + "iam_no_root_access_key" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "iam_subscription_roles_owner_custom_not_created" + ], + "gcp": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges" + ], + "alibabacloud": [ + "ram_policy_attached_only_to_group_or_roles", + "ram_no_root_access_key" + ], + "oraclecloud": [ + "identity_tenancy_admin_permissions_limited", + "identity_iam_admins_cannot_update_tenancy_admins", + "identity_tenancy_admin_users_no_api_keys" + ] + } + }, + { + "id": "IAM-10", + "description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", + "name": "Management of Privileged Access Roles", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.2", + "CC6.3" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "5.1", + "6.5" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.2.3", + "27002: 9.2.3", + "27017: 9.2.3", + "27018: 9.2.3", + "27001: A.9.4.4", + "27002: 9.4.4", + "27017: 9.4.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.2", + "27001: A.8.18", + "27002: 8.2 (i)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-2", + "AC-2(7)", + "AC-3", + "AC-3(4)", + "AC-3(11)", + "AC-3(13)", + "AC-3(14)", + "AC-6", + "AC-6(4)", + "AC-6(5)", + "AC-6(8)", + "AC-12", + "AC-12(3)", + "AC-17", + "AC-17(4)", + "IA-8", + "IA-8(4)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "7.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "7.2.1", + "7.2.2" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_role_cross_account_readonlyaccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "iam_inline_policy_allows_privilege_escalation", + "iam_policy_allows_privilege_escalation" + ], + "azure": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_global_admin_in_less_than_five_users", + "entra_user_with_vm_access_has_mfa", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "vm_jit_access_enabled" + ], + "gcp": [ + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges" + ], + "alibabacloud": [ + "ram_no_root_access_key", + "ram_policy_no_administrative_privileges" + ], + "oraclecloud": [ + "identity_tenancy_admin_permissions_limited", + "identity_tenancy_admin_users_no_api_keys", + "identity_iam_admins_cannot_update_tenancy_admins" + ] + } + }, + { + "id": "IAM-12", + "description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", + "name": "Safeguard Logs Integrity", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.3" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.2.1", + "5.2.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.4.1", + "27002: 12.4.1", + "27017: 12.4.1", + "27018: 12.4.1", + "27001: A.12.4.2", + "27002: 12.4.2", + "27017: 12.4.2", + "27018: 12.4.2", + "27001: A.12.4.3", + "27002: 12.4.3", + "27017: 12.4.3", + "27018: 12.4.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.15", + "27001: A.8.18", + "27002: 8.15 Protection of Logs" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-2", + "AC-2(11)", + "AC-2(12)", + "IA-8", + "IA-8(4)", + "SA-8", + "SA-8(22)", + "SC-34", + "SC-34(1)", + "SC-34(2)", + "SC-36", + "SI-4", + "SI-4(5)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.5" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.3.1", + "10.3.2", + "10.3.3", + "10.3.4" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_log_file_validation_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_bucket_requires_mfa_delete" + ], + "azure": [ + "entra_trusted_named_locations_exists", + "keyvault_logging_enabled", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_is_private" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_logging_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "actiontrail_oss_bucket_not_publicly_accessible" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days" + ] + } + }, + { + "id": "IAM-13", + "description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", + "name": "Uniquely Identifiable Users", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.1.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.2.1", + "27002: 9.2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.16" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-3", + "AC-3(14)", + "AC-24", + "AC-24(2)", + "AU-10", + "AU-10(1)", + "IA-2", + "IA-2(1)", + "IA-2(2)", + "IA-2(12)", + "IA-4", + "IA-4(1)", + "SA-8", + "SA-8(22)", + "SC-23", + "SC-23(3)", + "SC-40(4)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.AC-6" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-02" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "8.1", + "8.2", + "8.6" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "8.2.1", + "8.2.2", + "8.2.4" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_user_mfa_enabled_console_access", + "iam_check_saml_providers_sts" + ], + "azure": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled", + "postgresql_flexible_server_entra_id_authentication_enabled", + "sqlserver_azuread_administrator_enabled" + ], + "gcp": [ + "compute_project_os_login_enabled" + ], + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "oraclecloud": [ + "identity_user_mfa_enabled_console_access", + "identity_user_valid_email_address" + ] + } + }, + { + "id": "IAM-14", + "description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", + "name": "Strong Authentication", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.2" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-02", + "IAM-05" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "6.3", + "6.5", + "12.5", + "12.7" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.1.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3", + "SA1.4", + "SA1.8" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.1.2", + "27002: 9.1.2", + "27017: 9.1.2", + "27001: A.9.2.4", + "27002: 9.2.4", + "27017: 9.2.4", + "27001: A.9.4.2", + "27002: 9.4.2", + "27017: 9.4.2", + "27018: 9.4.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.15", + "27001: A.5.17", + "27001: A.8.5", + "27001: A.8.24", + "27002: 8.5", + "27002: 8.24 other information (d)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-6", + "AC-6(5)", + "AC-7", + "AC-7(4)", + "AU-10", + "AU-10(2)", + "IA-2", + "IA-2(1)", + "IA-2(2)", + "IA-2(8)", + "IA-2(12)", + "IA-3", + "IA-3(1)", + "IA-5", + "IA-5(2)", + "IA-5(7)", + "IA-5(9)", + "IA-5(10)", + "IA-5(12)", + "IA-5(14)-(16)", + "IA-8", + "IA-8(1)", + "IA-8(6)", + "SC-23", + "SC-23(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.AC-6", + "PR.AC-7" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-02", + "PR.AA-03" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "8.1.2", + "8.1.3", + "8.1.6", + "8.2", + "8.3", + "8.3.2", + "12.3.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "7.2.1", + "8.3.1", + "8.3.2", + "8.4.1", + "8.4.2", + "8.4.3" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled", + "cognito_user_pool_mfa_enabled" + ], + "azure": [ + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled", + "entra_user_with_vm_access_has_mfa" + ], + "gcp": [ + "compute_project_os_login_2fa_enabled", + "compute_project_os_login_enabled" + ], + "alibabacloud": [ + "ram_user_mfa_enabled_console_access" + ], + "oraclecloud": [ + "identity_user_mfa_enabled_console_access" + ] + } + }, + { + "id": "IAM-15", + "description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", + "name": "Passwords Management", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.1.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.2.4", + "27002: 9.2.4", + "27017: 9.2.4", + "27018: 9.2.4", + "27001: A.9.3.1", + "27002: 9.3.1", + "27017: 9.3.1", + "27018: 9.3.1", + "27001: A.9.4.3", + "27002: 9.4.3", + "27017: 9.4.3", + "27018: 9.4.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.17" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "IA-4", + "IA-4(8)", + "IA-5", + "IA-5(1)", + "IA-5(8)", + "IA-5(18)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "8.2", + "8.2.1-6" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "2.2.2", + "2.3.1", + "8.3.5", + "8.3.6", + "8.3.7", + "8.3.8", + "8.3.9", + "8.3.10", + "8.3.10.1", + "8.6.2" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_password_policy_minimum_length_14", + "iam_password_policy_reuse_24", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_temporary_password_expiration" + ], + "azure": [ + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled" + ], + "alibabacloud": [ + "ram_password_policy_minimum_length", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_max_password_age" + ], + "oraclecloud": [ + "identity_password_policy_minimum_length_14", + "identity_password_policy_expires_within_365_days", + "identity_password_policy_prevents_reuse" + ] + } + }, + { + "id": "IAM-16", + "description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", + "name": "Authorization Mechanisms", + "attributes": { + "Section": "Identity & Access Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.2", + "CC6.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IAM-02" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "5.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SA1.3", + "SA1.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.9.2.5", + "27002: 9.2.5", + "27017: 9.2.5", + "27018: 9.2.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.18" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-3", + "AC-3(5)", + "AC-4", + "AC-4(17)", + "AC-4(21)", + "AC-4(22)", + "AC-6", + "AC-6(8)", + "AC-6(9)", + "AC-12", + "AC-12(1)", + "AC-20", + "AC-20(1)", + "AU-10", + "AU-10(1)", + "AU-10(2)", + "IA-2", + "IA-2(1)", + "IA-2(2)", + "IA-2(12)", + "IA-3", + "IA-3(1)", + "IA-5(1)", + "IA-5(2)", + "IA-5(5)", + "IA-5(8)", + "IA-5(10)", + "IA-5(12)", + "IA-8", + "IA-8(1)", + "IA-8(2)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.AC-4", + "PR.AC-6", + "PR.AC-7", + "PR.PT-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-01", + "PR.AA-02", + "PR.AA-03", + "PR.AA-04", + "PR.AA-05", + "PR.PS-04" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "5.3", + "7.1.4" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "7.2.4", + "7.2.3", + "7.2.5.1" + ] + } + ] + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "apigateway_restapi_authorizers_enabled", + "apigatewayv2_api_authorizers_enabled", + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "cognito_user_pool_waf_acl_attached" + ], + "azure": [ + "aks_cluster_rbac_enabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_not_publicly_accessible", + "entra_policy_user_consent_for_verified_apps", + "iam_subscription_roles_owner_custom_not_created" + ], + "gcp": [ + "apikeys_api_restrictions_configured", + "cloudstorage_bucket_uniform_bucket_level_access", + "compute_instance_default_service_account_in_use_with_full_api_access", + "iam_sa_no_administrative_privileges" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges", + "cs_kubernetes_rbac_enabled" + ], + "oraclecloud": [ + "identity_tenancy_admin_permissions_limited", + "identity_service_level_admins_exist", + "database_autonomous_database_access_restricted", + "analytics_instance_access_restricted", + "integration_instance_access_restricted" + ] + } + }, + { + "id": "IPY-03", + "description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", + "name": "Secure Interoperability and Portability Management", + "attributes": { + "Section": "Interoperability & Portability", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.7" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IPY-04" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1", + "5.1.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SY1.1", + "SY1.2", + "NC1.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.18.1", + "27001: A.15.1.1", + "27002: 15.1.1", + "27017: 15.1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.19", + "27001: A.5.23", + "27001: A.5.31", + "27001: A.5.32", + "27001: A.5.33", + "27001: A.5.34" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PT-2", + "PT-2(2)", + "SA-4", + "SC-16", + "SC-16(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-2" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-02" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "1.2.1", + "1.2.5", + "1.2.6", + "2.2.4", + "2.2.5", + "2.2.7", + "4.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "s3_bucket_secure_transport_policy" + ], + "azure": [ + "storage_ensure_azure_services_are_trusted_to_access_is_enabled", + "storage_secure_transfer_required_is_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections" + ], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled" + ], + "oraclecloud": [ + "compute_instance_in_transit_encryption_enabled" + ] + } + }, + { + "id": "IVS-02", + "description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", + "name": "Capacity and Resource Planning", + "attributes": { + "Section": "Infrastructure & Virtualization Security", + "CCMLite": "No", + "IaaS": "CSP-Owned", + "PaaS": "CSP-Owned", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "A1.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-04" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SY2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 5.3", + "27001: 6.1", + "27001: 9.1", + "27001: A.12.1.3", + "27002: 12.1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 5.3 (b)", + "27001: 6.1", + "27001: 9.1", + "27001: A.8.6", + "27001: A.8.14" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CP-2", + "CP-2(2)", + "SC-5", + "SC-5(2)", + "SC-4", + "SI-4" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-4", + "ID.BE-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.IR-04", + "GV.OC-04" + ] + } + ] + }, + "checks": { + "aws": [ + "autoscaling_group_multiple_az", + "autoscaling_group_multiple_instance_types" + ], + "azure": [ + "vm_scaleset_associated_with_load_balancer", + "vm_scaleset_not_empty" + ], + "gcp": [ + "compute_instance_group_autohealing_enabled", + "compute_instance_group_load_balancer_attached", + "compute_instance_group_multiple_zones" + ] + } + }, + { + "id": "IVS-03", + "description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", + "name": "Network Security", + "attributes": { + "Section": "Infrastructure & Virtualization Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.7" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-06" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.8", + "3.1", + "12.2", + "13.6", + "13.9" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.2", + "5.2.7" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "NC1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 7.5", + "27001: 9.1", + "27001: A.13.1.1", + "27002: 13.1.1", + "27001: A.13.1.2", + "27002: 13.1.2", + "27001: A.13.1.3", + "27002: 13.1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 7.5", + "27001: 9.1", + "27001: A.5.15", + "27001: A.5.37", + "27001: A.8.5", + "27001: A.8.9", + "27001: A.8.16", + "27001: A.8.20", + "27001: A.8.21", + "27001: A.8.22", + "27001: A.8.24", + "27002: A.5.15 2nd c)", + "27002: 8.20", + "27002: 8.21", + "27002: 8.22", + "27002: 8.24" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SC-1", + "SC-4", + "SC-7", + "SC-7(4)", + "SC-7(5)", + "SC-7(8)", + "SC-7(9)", + "SC-7(11)", + "SC-8", + "SC-8(1)", + "SC-11", + "SC-12", + "SC-16", + "SC-23", + "SC-29", + "SC-29(1)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-5", + "PR.AC-7", + "PR.PT-4", + "DE.CM-1", + "DE.CM-7", + "PR.DS-2" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.IR-01", + "PR.AA-03", + "PR.AA-05", + "DE.CM-01", + "PR.DS-02", + "ID.AM-03" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "1.1.6", + "1.2", + "1.2.3", + "2.2", + "4.1.1", + "10.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "1.2.5", + "1.2.6", + "1.2.7", + "1.4.2", + "2.2.4", + "2.2.5", + "2.2.7", + "4.2.1", + "10.1.1" + ] + } + ] + }, + "checks": { + "aws": [ + "vpc_flow_logs_enabled", + "ec2_securitygroup_default_restrict_traffic", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_networkacl_allow_ingress_any_port", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389", + "ec2_securitygroup_allow_wide_open_public_ipv4", + "vpc_peering_routing_tables_with_least_privilege", + "vpc_subnet_no_public_ip_by_default" + ], + "azure": [ + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "aks_network_policy_enabled", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_instance_ip_forwarding_is_enabled", + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy", + "compute_subnet_flow_logs_enabled" + ], + "alibabacloud": [ + "vpc_flow_logs_enabled", + "ecs_securitygroup_restrict_ssh_internet", + "ecs_securitygroup_restrict_rdp_internet" + ], + "oraclecloud": [ + "network_vcn_subnet_flow_logs_enabled", + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port" + ] + } + }, + { + "id": "IVS-04", + "description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", + "name": "OS Hardening and Base Controls", + "attributes": { + "Section": "Infrastructure & Virtualization Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "CSP-Owned", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.8", + "CC7.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-07", + "IVS-11" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "4.1", + "4.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.1.3", + "5.2.5" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SY1.1", + "SY1.3", + "SY1.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 7.5", + "27001: 9.1", + "27001: A.14.2.2", + "27002: 14.2.2", + "27001: A.14.2.3", + "27001 A.14.2.4", + "27018: 12.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 7.5", + "27001: 9.1", + "27001: A.5.37", + "27001: A.8.5", + "27001: A.8.9", + "27001: A.8.16", + "27001: A.8.20", + "27001: A.8.22", + "27001: A.8.24", + "27002: 8.20", + "27002: 8.22", + "27002: 8.24" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CM-6", + "CM-6(1)", + "SC-29", + "SC-29(1)", + "SC-2", + "SC-7", + "SC-7(12)", + "SC-30", + "SC-34", + "SC-35", + "SC-39", + "SC-44" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.IP-1", + "PR.PT-3" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-01" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "2.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "2.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "ec2_instance_imdsv2_enabled", + "ec2_instance_account_imdsv2_enabled", + "ec2_launch_template_imdsv2_required", + "ec2_instance_managed_by_ssm", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_assessments_vm_endpoint_protection_installed", + "defender_ensure_system_updates_are_applied", + "vm_ensure_using_managed_disks", + "vm_linux_enforce_ssh_authentication", + "vm_trusted_launch_enabled" + ], + "gcp": [ + "compute_instance_shielded_vm_enabled", + "compute_project_os_login_enabled", + "compute_instance_serial_ports_in_use", + "compute_instance_block_project_wide_ssh_keys_disabled" + ], + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied", + "ecs_instance_endpoint_protection_installed" + ], + "oraclecloud": [ + "compute_instance_legacy_metadata_endpoint_disabled", + "compute_instance_secure_boot_enabled" + ] + } + }, + { + "id": "IVS-06", + "description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", + "name": "Segmentation and Segregation", + "attributes": { + "Section": "Infrastructure & Virtualization Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-09" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.2.1", + "5.3.4", + "5.2.7" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SC2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 9.1", + "27001: A.13.1.3", + "27002: 13.1.3", + "27017: 13.1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 9.1", + "27001: A.5.15", + "27001: A.5.20", + "27001: A.8.3", + "27001: A.8.9", + "27001: A.8.16", + "27001: A.8.22", + "27002: 5.15 (b)", + "27002: 8.3 (b)", + "27002: 8.16 (b)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SC-3", + "SC-7", + "SC-7(20)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4", + "PR.AC-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05", + "PR.IR-01", + "PR.PS-01", + "PR.PS-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "2.6", + "8.3.1", + "10.8", + "11.3", + "A3.2.1", + "A3.3.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "A1.1.1", + "A1.1.2", + "A1.1.3" + ] + } + ] + }, + "checks": { + "aws": [ + "ec2_securitygroup_default_restrict_traffic", + "vpc_subnet_separate_private_public", + "vpc_peering_routing_tables_with_least_privilege", + "ec2_instance_public_ip", + "awslambda_function_inside_vpc", + "sagemaker_notebook_instance_vpc_settings_configured", + "sagemaker_models_vpc_settings_configured", + "sagemaker_training_jobs_vpc_settings_configured" + ], + "azure": [ + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "databricks_workspace_vnet_injection_enabled", + "network_http_internet_access_restricted", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "gcp": [ + "cloudsql_instance_private_ip_assignment", + "cloudstorage_uses_vpc_service_controls", + "compute_instance_public_ip", + "compute_network_default_in_use", + "compute_network_not_legacy" + ], + "alibabacloud": [ + "cs_kubernetes_network_policy_enabled", + "cs_kubernetes_private_cluster_enabled", + "ecs_instance_no_legacy_network" + ], + "oraclecloud": [ + "network_default_security_list_restricts_traffic", + "identity_non_root_compartment_exists", + "identity_no_resources_in_root_compartment" + ] + } + }, + { + "id": "IVS-07", + "description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", + "name": "Migration to Cloud Environments", + "attributes": { + "Section": "Infrastructure & Virtualization Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.7" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-10" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.4", + "IM1.4", + "NC1.4", + "SC2.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.13.1.1", + "27002: 13.1.1", + "27017: 13.1.1", + "27018: 13.1.1", + "27001: A.13.1.2", + "27002: 13.1.2", + "27017: 13.1.2", + "27018: 13.1.2", + "27001: A.13.1.3", + "27002: 13.1.3", + "27017: 13.1.3", + "27018: 13.1.3", + "27001: A.13.2.1", + "27002: 13.2.1", + "27017: 13.2.1", + "27018: 13.2.1", + "27001: A.13.2.2", + "27002: 13.2.2", + "27017: 13.2.2", + "27018: 13.2.2", + "27001: A.13.2.3", + "27002: 13.2.3", + "27017: 13.2.3", + "27018: 13.2.3", + "27001: A.13.2.4", + "27002: 13.2.4", + "27017: 13.2.4", + "27018: 13.2.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.14", + "27001: A.8.20", + "27001: A.8.24", + "27002: 8.20 (e)", + "27002: 8.24 Guidance (b,f), other information (a)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-17", + "AC-20", + "SC-7", + "SC-7(28)", + "SC-8", + "SC-8(1)", + "SC-12", + "SC-23", + "SC-29", + "SI-7", + "SI-7(1)-(3)", + "SI-7(5)-(10)", + "SI-7(12)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-2", + "PR.PT-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-02" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "4.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "dms_endpoint_ssl_enabled" + ], + "azure": [ + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections" + ], + "alibabacloud": [ + "rds_instance_ssl_enabled" + ], + "oraclecloud": [ + "compute_instance_in_transit_encryption_enabled" + ] + } + }, + { + "id": "IVS-09", + "description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", + "name": "Network Defense", + "attributes": { + "Section": "Infrastructure & Virtualization Security", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.6", + "CC6.8", + "CC7.1", + "CC7.2", + "CC7.5" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-13" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "13.3", + "13.8" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.3", + "5.2.4", + "5.2.5", + "5.2.7", + "5.3.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "NC1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 6.1", + "27001: 6.2", + "27001: A.14.1.2", + "27002: 14.1.2", + "27017: 14.1.2", + "27001: A.11.1.4", + "27002: 11.1.4", + "27017: 11.1.4", + "27018: 16.1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 6.1", + "27001: 6.2", + "27001: A.5.24", + "27001: A.5.26", + "27001: A.8.8", + "27001: A.8.16", + "27001: A.8.20", + "27001: A.8.21", + "27001: A.8.22", + "27001: A.8.26", + "27002: 8.8 (i)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PL-8", + "PL-8(1)", + "SC-5", + "SC-5(1)", + "SC-5(3)", + "SC-7", + "SC-7(13)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "DE.AE-1", + "DE.DP-1", + "DE.CM-1", + "DE.CM-7", + "PR.AC-5", + "RS.MI-2", + "PR.DS-2", + "RS.RP-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-03", + "DE.CM-01", + "PR.IR-01", + "RS.MA-01", + "RS.MI-01", + "RS.MI-02" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.6", + "1.1", + "1.2", + "1.3", + "1.5", + "12.10.5" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "1.1.1", + "1.3.1", + "1.3.2", + "1.3.3", + "1.4.1", + "1.4.2", + "1.4.3", + "1.4.4", + "1.4.5", + "1.5.1", + "12.10.1" + ] + } + ] + }, + "checks": { + "aws": [ + "networkfirewall_in_all_vpc", + "networkfirewall_logging_enabled", + "networkfirewall_policy_rule_group_associated", + "wafv2_webacl_with_rules", + "wafv2_webacl_logging_enabled", + "elbv2_waf_acl_attached", + "cloudfront_distributions_using_waf", + "guardduty_is_enabled", + "shield_advanced_protection_in_cloudfront_distributions", + "shield_advanced_protection_in_internet_facing_load_balancers" + ], + "azure": [ + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_iot_hub_defender_is_on", + "defender_ensure_wdatp_is_enabled", + "network_flow_log_captured_sent", + "network_watcher_enabled", + "sqlserver_microsoft_defender_enabled", + "vm_jit_access_enabled" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_loadbalancer_logging_enabled", + "compute_public_address_shodan", + "dns_dnssec_disabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "sls_cloud_firewall_changes_alert_enabled" + ], + "oraclecloud": [ + "cloudguard_enabled", + "events_rule_cloudguard_problems" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "LOG-02", + "description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", + "name": "Audit Logs Protection", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-01" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "8.1", + "8.9", + "8.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "3.1.3", + "5.1.2", + "5.2.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.18.1.3", + "27002: 18.1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.28", + "27001: A.5.33", + "27001: A.8.15" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-4", + "AU-11" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4", + "PR.IP-4", + "PR.IP-6", + "PR.PT-1", + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05", + "PR.DS-01", + "PR.DS-02", + "ID.AM-08", + "PR.DS-11", + "PR.PS-04" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.5", + "10.7" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.3.1", + "10.3.2", + "10.3.3", + "10.3.4", + "10.5.1" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_log_file_validation_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_bucket_requires_mfa_delete", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_not_publicly_accessible", + "s3_bucket_object_lock" + ], + "azure": [ + "keyvault_logging_enabled", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_ensure_soft_delete_is_enabled" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_logging_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "actiontrail_oss_bucket_not_publicly_accessible", + "sls_logstore_retention_period" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days" + ] + } + }, + { + "id": "LOG-03", + "description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", + "name": "Security Monitoring and Alerting", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.8", + "CC7.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "SEF-03", + "SEF-05" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "8.5" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.4", + "5.2.7", + "1.6.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.2", + "TM1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.4.1", + "27002: 12.4.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.28", + "27001: A.8.15" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-5", + "AU-5(2)", + "AU-13" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "DE.AE-1", + "DE.AE-2", + "DE.AE-3", + "DE.AE-5", + "DE.CM-1", + "DE.CM-2", + "DE.CM-3", + "DE.CM-4", + "DE.CM-5", + "DE.CM-6", + "DE.CM-7", + "DE.DP-1", + "DE.DP-4", + "DE.AE-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-04", + "DE.AE-02", + "DE.AE-03", + "DE.AE-04", + "DE.AE-06", + "DE.AE-07", + "DE.AE-08", + "DE.CM-01", + "DE.CM-02", + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.2.1", + "10.2.2", + "10.4.1.1", + "10.4.2.1", + "10.4.3" + ] + } + ] + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "securityhub_enabled", + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa" + ], + "azure": [ + "defender_attack_path_notifications_properly_configured", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_wdatp_is_enabled", + "monitor_alert_create_update_security_solution", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_notification_enabled_high_risk", + "sls_unauthorized_api_calls_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled" + ], + "oraclecloud": [ + "cloudguard_enabled", + "events_rule_cloudguard_problems", + "events_notification_topic_and_subscription_exists", + "events_rule_local_user_authentication" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "LOG-04", + "description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", + "name": "Audit Logs Access and Accountability", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "IVS-01" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.14" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "3.1.1", + "4.1.2", + "4.1.3", + "4.2.1", + "5.2.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.4.2", + "27001: A.12.4.1", + "27002: 12.4.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.33", + "27001: A.8.15" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-9", + "AU-9(4)", + "AU-9(6)", + "AU-10" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-1", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05", + "PR.PS-04" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.1", + "10.2.1", + "10.2.3", + "10.5.1", + "10.5.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.2.1.3", + "10.3.1" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudwatch_log_group_not_publicly_accessible" + ], + "azure": [ + "monitor_storage_account_with_activity_logs_is_private" + ], + "gcp": [ + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "iam_audit_logs_enabled", + "kms_key_not_publicly_accessible" + ], + "alibabacloud": [ + "actiontrail_oss_bucket_not_publicly_accessible" + ] + } + }, + { + "id": "LOG-05", + "description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", + "name": "Audit Logs Monitoring and Response", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.2" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "8.8", + "8.11" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.6.1", + "1.6.2", + "5.2.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.4.3", + "27002: 12.4.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.15", + "27001: A.8.16" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-6", + "AU-6(1)", + "AU-6(5)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "DE.AE-3", + "PR.PT-1", + "RS.AN-1", + "RS.CO-1.", + "DE.AE-1", + "DE.AE-5", + "DE.DP-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.AM-03", + "PR.PS-04", + "DE.AE-02", + "DE.AE-03", + "DE.AE-06", + "DE.AE-07", + "DE.AE-08", + "DE.CM-01", + "DE.CM-02", + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.6", + "10.6.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.4.1.1", + "10.4.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "guardduty_no_high_severity_findings" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "alibabacloud": [ + "sls_unauthorized_api_calls_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_cloud_firewall_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled" + ], + "oraclecloud": [ + "events_rule_iam_group_changes", + "events_rule_iam_policy_changes", + "events_rule_identity_provider_changes", + "events_rule_idp_group_mapping_changes", + "events_rule_local_user_authentication", + "events_rule_network_gateway_changes", + "events_rule_network_security_group_changes", + "events_rule_route_table_changes", + "events_rule_security_list_changes", + "events_rule_user_changes", + "events_rule_vcn_changes", + "events_rule_cloudguard_problems" + ] + } + }, + { + "id": "LOG-07", + "description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", + "name": "Logging Scope", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.2" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "8.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 7.5.3", + "27001: A.12.4.1", + "27002: 12.4.1", + "27017: 12.4.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 7.5.3", + "27001: A.8.15" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-1", + "AU-14", + "AU-16" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.SC-3", + "ID.SC-4", + "PR.PT-1", + "ID.GV-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.3" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.2.1", + "10.2.2" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "vpc_flow_logs_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled" + ], + "azure": [ + "app_function_application_insights_enabled", + "appinsights_ensure_is_configured", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days" + ], + "gcp": [ + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "vpc_flow_logs_enabled" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days", + "network_vcn_subnet_flow_logs_enabled", + "objectstorage_bucket_logging_enabled" + ] + } + }, + { + "id": "LOG-08", + "description": "Generate audit records containing relevant security information.", + "name": "Log Records", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.2" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "8.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.4.1", + "27002: 12.4.1", + "27017: 12.4.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.15" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-3", + "AU-3(1)", + "AU-3(3)", + "AU-6", + "AU-6(8)", + "AU-12", + "AU-12(1)", + "AU-12(2)", + "AU-12(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.PT-1", + "DE.AE-3", + "DE.CM-1", + "DE.CM-2", + "DE.CM-3", + "DE.CM-6", + "DE.CM-7" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-04", + "DE.CM-01", + "DE.CM-02", + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.3" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.2.2" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "vpc_flow_logs_enabled", + "s3_bucket_server_access_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "cloudfront_distributions_logging_enabled", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "wafv2_webacl_logging_enabled", + "redshift_cluster_audit_logging", + "rds_cluster_integration_cloudwatch_logs", + "rds_instance_integration_cloudwatch_logs", + "opensearch_service_domains_audit_logging_enabled", + "eks_control_plane_logging_all_types_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "networkfirewall_logging_enabled", + "mq_broker_logging_enabled", + "documentdb_cluster_cloudwatch_log_export", + "neptune_cluster_integration_cloudwatch_logs", + "codebuild_project_logging_enabled", + "glue_etl_jobs_logging_enabled", + "stepfunctions_statemachine_logging_enabled", + "datasync_task_logging_enabled", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "elasticbeanstalk_environment_cloudwatch_logging_enabled" + ], + "azure": [ + "app_function_application_insights_enabled", + "app_http_logs_enabled", + "appinsights_ensure_is_configured", + "keyvault_logging_enabled", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days" + ], + "gcp": [ + "iam_audit_logs_enabled", + "compute_subnet_flow_logs_enabled", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "logging_sink_created", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudsql_instance_postgres_enable_pgaudit_flag" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "vpc_flow_logs_enabled", + "oss_bucket_logging_enabled", + "rds_instance_sql_audit_enabled", + "cs_kubernetes_log_service_enabled", + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled", + "rds_instance_postgresql_log_duration_enabled" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days", + "network_vcn_subnet_flow_logs_enabled", + "objectstorage_bucket_logging_enabled" + ] + } + }, + { + "id": "LOG-09", + "description": "The information system protects audit records from unauthorized access, modification, and deletion.", + "name": "Log Protection", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "GRM-04", + "IVS-01" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.4", + "4.2.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.4.2", + "27002: 12.4.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.15" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-9", + "AU-9(2)", + "AU-9(3)", + "AU-9(4)", + "AU-12(3)", + "AU-12(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.AC-4", + "PR.IP-4", + "PR.IP-6", + "PR.PT-1", + "PR.DS-1", + "PR.DS-6" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AA-05", + "PR.DS-01", + "PR.DS-02", + "PR.DS-11" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.5", + "10.5.1", + "10.5.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.3.1", + "10.3.2", + "10.3.3", + "10.3.4" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_log_file_validation_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_not_publicly_accessible" + ], + "azure": [ + "keyvault_logging_enabled", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "storage_ensure_encryption_with_customer_managed_keys" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_uniform_bucket_level_access" + ], + "alibabacloud": [ + "actiontrail_oss_bucket_not_publicly_accessible" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days" + ] + } + }, + { + "id": "LOG-10", + "description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", + "name": "Encryption Monitoring and Reporting", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC7.2" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "EKM-02", + "EKM-03" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "4.2.1", + "5.1.1", + "5.1.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.10.1", + "27002: 10.1", + "27001: A.10.1.2", + "27017: 10.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.24" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-1", + "AU-9", + "AU-9(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.GV-1", + "PR.PT-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-04", + "DE.CM-09" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.1.1", + "10.2.1", + "10.4.1" + ] + } + ] + }, + "checks": { + "aws": [ + "kms_cmk_rotation_enabled", + "acm_certificates_expiration_check" + ], + "azure": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_rbac_key_expiration_set" + ], + "gcp": [ + "kms_key_rotation_enabled" + ], + "alibabacloud": [ + "sls_customer_created_cmk_changes_alert_enabled" + ], + "oraclecloud": [ + "kms_key_rotation_enabled" + ] + } + }, + { + "id": "LOG-11", + "description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", + "name": "Transaction/Activity Logging", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC7.2" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "EKM-02" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.1" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.10.1.2", + "27017: 10.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.24" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-9", + "AU-9(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.PT-1", + "DE.AE-3" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-04", + "DE.CM-09" + ] + } + ] + }, + "checks": { + "aws": [ + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_multi_region_enabled_logging_management_events" + ], + "azure": [ + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "cloudstorage_audit_logs_enabled", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled" + ], + "oraclecloud": [ + "audit_log_retention_period_365_days" + ] + } + }, + { + "id": "LOG-13", + "description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", + "name": "Failures and Anomalies Reporting", + "attributes": { + "Section": "Logging and Monitoring", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC2.3", + "CC7.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "SEF-03" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.6.1", + "5.2.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.16.1.1", + "27002: 16.1.1", + "27001: A.16.1.2", + "27017: 16.1.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.24", + "27001: A.6.8", + "27002: 6.8 (g)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AU-5", + "AU-5(2)", + "AU-6", + "AU-6(3)", + "AU-6(4)", + "AU-6(5)", + "AU-16" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "DE.DP-3", + "DE.DP-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-04", + "DE.AE-06" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "10.6" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "10.4.3", + "10.7.1", + "10.7.2", + "10.7.3" + ] + } + ] + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_wdatp_is_enabled", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_notification_enabled_high_risk" + ], + "oraclecloud": [ + "cloudguard_enabled", + "events_rule_cloudguard_problems", + "events_notification_topic_and_subscription_exists" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "SEF-03", + "description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", + "name": "Incident Response Plans", + "attributes": { + "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.2", + "CC7.3", + "CC7.4" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "BCR-02" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "17.2", + "17.4" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.6.2", + "1.6.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 5.2", + "27001: 7.3", + "27001: 7.4", + "27001: 7.5", + "27001: A.16.1.5", + "27002: 16.1.5", + "27017: 16.1.5", + "27017: CLD.12.1.5", + "27018: 16.1.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 5.2", + "27001: 7.3", + "27001: 7.4", + "27001: 7.5", + "27001: A.5.26", + "27002: 5.26 (e,f)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "IR-1", + "IR-2", + "IR-2(1)-(3)", + "IR-3", + "IR-3(1)-(3)", + "IR-4", + "IR-4(1)-(15)", + "IR-5", + "IR-5(1)", + "IR-6", + "IR-6(1)-(3)", + "IR-7", + "IR-7(1)", + "IR-7(2)", + "IR-8", + "IR-8(1)", + "IR-9", + "IR-9(1)-(4)", + "PM-12" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "RS.CO-1", + "RS.CO-4", + "ID.AM-6", + "ID.GV-2", + "ID.SC-5", + "PR.IP-9", + "PR.IP10" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.AT-01", + "PR.AT-02", + "RS.MA-01", + "GV.SC-08", + "ID.IM-02", + "ID.IM-04", + "RC.RP-01" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "12.1", + "12.10.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "12.10.1", + "12.10.5" + ] + } + ] + }, + "checks": { + "aws": [ + "ssmincidents_enabled_with_plans" + ], + "azure": [ + "defender_attack_path_notifications_properly_configured", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_notify_alerts_severity_is_high" + ] + } + }, + { + "id": "SEF-06", + "description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", + "name": "Event Triage Processes", + "attributes": { + "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "SEF-02" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.6.2" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.16.1.4", + "27002: 16.1.4", + "27017: 16.1.4", + "27018: 16.1.4", + "27001: A.16.1.5", + "27002: 16.1.5", + "27017: 16.1.5", + "27018: 16.1.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.25" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CA-7", + "CA-7(3)", + "CA-7(4)", + "CA-7(5)", + "CA-7(6)", + "IR-4", + "IR-4(1)", + "IR-4(3)", + "IR-4(4)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "DE.AE-1", + "DE.AE-2", + "DE.AE-4", + "RS.RP-1", + "RS.AN-2" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "RS.MA-02", + "RS.MA-03", + "RS.AN-03", + "DE.AE-02", + "DE.AE-04", + "DE.AE-06", + "DE.AE-07", + "DE.AE-08", + "RS.MI-02", + "RC.RP-02" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "12.5.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "12.10.1" + ] + } + ] + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "securityhub_enabled" + ], + "azure": [ + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled" + ], + "gcp": [ + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "SEF-08", + "description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", + "name": "Points of Contact Maintenance", + "attributes": { + "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC2.3" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "SEF-01" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "17.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.6.2", + "1.6.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "SM2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 4.2", + "27001: A.6.1.3", + "27002: 6.1.3", + "27017: 6.1.3", + "27018: 6.1.3", + "27001: A.16.1.1", + "27002: 16.1.1", + "27001: A.18.1.1", + "27002: 18.1.1", + "27017: 18.1.1", + "27018: 18.1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.5", + "27001: A.5.24", + "27002: 5.24 Incident management procedure (d)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "IR-4", + "IR-4(8)", + "IR-6", + "IR-6(3)", + "IR-7", + "IR-7(2)", + "PM-21", + "PM-23", + "PM-26" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.GV-2", + "RS.CO-3", + "RS.CO-4" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "GV.RR-02", + "RS.CO-02", + "RS.CO-03" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "12.10.1" + ] + } + ] + }, + "checks": { + "aws": [ + "account_maintain_current_contact_details", + "account_security_contact_information_is_registered", + "account_maintain_different_contact_details_to_security_billing_and_operations" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners" + ], + "gcp": [ + "iam_organization_essential_contacts_configured" + ] + } + }, + { + "id": "TVM-02", + "description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", + "name": "Malware Protection Policy and Procedures", + "attributes": { + "Section": "Threat & Vulnerability Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC5.3", + "CC6.8" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "TVM-01", + "GRM-06", + "GRM-09" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "9.7", + "10.1" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "1.1.1", + "1.5.1", + "5.2.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS1.2", + "TS1.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 5.1", + "27001: 5.2", + "27001: 7.3", + "27001: 7.4", + "27001: 7.5", + "27001: 9.1", + "27001: 9.3", + "27001: A.5", + "27002: 5", + "27001: A.12.2.1", + "27001: A.6.2.1", + "27002: 6.2.1 (h)", + "27001: A.6.2.2", + "27002: 6.2.2 (j)", + "27001: A.7.2.2", + "27002: 7.2.2 (d)", + "27001: A.10.1.1", + "27002: 10.1.1 (g)", + "27001: A.13.2.1", + "27002: 13.2.1 (b)", + "27001: A.15.1.2", + "27017: 15.1.2", + "27001: A.12.2.1", + "27002: 12.2.1 (a),(d)", + "27017: CLD.9.5.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 5.1", + "27001: 5.2", + "27001: 7.3", + "27001: 7.4", + "27001: 7.5", + "27001: 9.1", + "27001: 9.3", + "27001: A.5.1", + "27001: A.5.4", + "27001: A.5.7", + "27001: A.5.37", + "27001: A.8.7", + "27002: 5.7 (b)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "RA-3", + "RA-3(3)", + "RA-5", + "RA-5(3)", + "RA-5(5)", + "SI-3", + "SI-3(4)", + "SI-3(10)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.GV-1", + "DE.CM-4", + "DE.CM-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "GV.PO-01", + "GV.PO-02", + "ID.IM-03", + "DE.CM-01", + "DE.CM-09" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "5.4", + "12.1", + "12.1.1", + "12.3.1", + "12.5.1", + "12.11" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "12.1.1", + "12.1.2", + "5.1.1", + "5.3.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "guardduty_ec2_malware_protection_enabled" + ], + "azure": [ + "defender_assessments_vm_endpoint_protection_installed", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled" + ], + "alibabacloud": [ + "ecs_instance_endpoint_protection_installed" + ] + } + }, + { + "id": "TVM-03", + "description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", + "name": "Vulnerability Remediation Schedule", + "attributes": { + "Section": "Threat & Vulnerability Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC5.3", + "CC7.1", + "CC7.4" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "TVM-02" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "7.2", + "7.7", + "17.9" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.5" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.1", + "TM2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 6.1.3", + "27001: A.12.2.1", + "27001: A.12.6.1", + "27002: 12.6.1(c)(d)(j)", + "27018: 12.6.1(k)(i)" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 6.1.3", + "27001: A.8.7", + "27001: A.8.8", + "27001: A.8.32", + "27002: 8.7", + "27002: 8.8", + "27002: 8.32" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "PM-31", + "RA-3", + "RA-3(1)", + "RA-5", + "RA-5(2)-(4)", + "RA-5(6)", + "SI-3", + "SI-3(10)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "RS.AN-5", + "PR.IP-12" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.RA-01", + "ID.RA-06", + "ID.RA-08", + "PR.PS-02", + "PR.PS-03" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.1", + "6.1.a", + "6.1.b" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.1.1", + "6.3.1", + "6.3.2", + "6.3.3", + "12.10.1" + ] + } + ] + }, + "checks": { + "aws": [ + "ssm_managed_compliant_patching", + "rds_instance_minor_version_upgrade_enabled", + "rds_cluster_minor_version_upgrade_enabled", + "redshift_cluster_automatic_upgrades", + "elasticbeanstalk_environment_managed_updates_enabled", + "dms_instance_minor_version_upgrade_enabled", + "elasticache_redis_cluster_auto_minor_version_upgrades", + "memorydb_cluster_auto_minor_version_upgrades", + "mq_broker_auto_minor_version_upgrades", + "opensearch_service_domains_updated_to_the_latest_service_software_version", + "kafka_cluster_uses_latest_version" + ], + "azure": [ + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "defender_ensure_system_updates_are_applied" + ], + "alibabacloud": [ + "ecs_instance_latest_os_patches_applied" + ] + } + }, + { + "id": "TVM-04", + "description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", + "name": "Detection Updates", + "attributes": { + "Section": "Threat & Vulnerability Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.2" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "No mapping" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "10.2" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.3" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TS1.3", + "TS1.4", + "TM1.3", + "TM1.4", + "IM1.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 6.1.3", + "27001: A.5.1.1", + "27002: 5.1.1 (h)", + "27001: A.12.6.1", + "27002: 12.6.1 (b),(c)" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 6.1.3", + "27001: A.5.1", + "27001: A.8.8", + "27001: A.8.15", + "27001: A.8.16", + "27002: 5.1", + "27002: 5.37", + "27002: 8.8", + "27002: 8.15 (d)", + "27002: 8.16 (d,e)", + "27002: 8.31 2nd (a)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "CM-7", + "CM-7(4)", + "RA-3", + "RA-3(3)", + "RA-5(2)", + "SA-10", + "SA-10(5)", + "SA-11", + "SA-11(2)", + "SI-2", + "SI-2(4)", + "SI-3", + "SI-3(4)", + "SI-4", + "SI-4(9)", + "SI-4(24)", + "SI-8", + "SI-8(2)", + "SI-8(3)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "DE.DP-5", + "PR.IP-12" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.PS-02", + "ID.RA-02" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "5.2", + "5.2a", + "5.2b", + "5.2c" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "5.3.1" + ] + } + ] + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "inspector2_is_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_wdatp_is_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_vulnerability_scan_enabled" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "TVM-05", + "description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", + "name": "External Library Vulnerabilities", + "attributes": { + "Section": "Threat & Vulnerability Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "CSP-Owned", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC3.2" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "No mapping" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "2.6" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.1", + "SD2.3" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: 6.1.3", + "27001: A.12.6.2", + "27002: 12.6.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: 6.1.3", + "27001: A 5.6", + "27001: A.8.19", + "27001: A.8.8", + "27001: A.8.28", + "27001: A.8.31", + "27002: 5.6 (c)", + "27001: 8.19", + "27001: 8.8", + "27001: 8.28", + "27001: 8.31" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "RA-5", + "RA-5(3)", + "SA-11", + "SA-11(2)", + "SA-11(5)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "DE.DP-5", + "PR.IP-12" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.RA-01", + "ID.RA-03", + "PR.PS-02" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.1", + "6.2", + "6.3.2" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.3.1", + "6.3.2", + "6.3.3" + ] + } + ] + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ecr_registry_scan_images_on_push_enabled" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_containers_is_on", + "sqlserver_va_emails_notifications_admins_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled" + ] + } + }, + { + "id": "TVM-07", + "description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", + "name": "Vulnerability Identification", + "attributes": { + "Section": "Threat & Vulnerability Management", + "CCMLite": "Yes", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC7.1" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "TVM-02" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "7.1", + "7.5", + "7.6" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.5", + "5.2.6" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "TM1.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.6", + "27001: A.12.6.1", + "27002: 12.6.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.8", + "27002: 8.8" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "RA-5", + "RA-5(4)", + "RA-5(5)", + "SA-11", + "SA-11(5)", + "SA-15(5)", + "SC-7", + "SC-7(10)", + "SI-3(8)", + "SI-3(10)", + "SI-7", + "SI-7(9)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "ID.RA-1", + "DE.CM-8", + "PR.IP-12" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "ID.RA-01" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "6.1", + "11.2", + "11.2.1" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "6.3.1", + "6.3.2", + "6.3.3", + "11.3.2", + "11.3.2.1" + ] + } + ] + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "guardduty_is_enabled", + "ecr_repositories_scan_vulnerabilities_in_latest_image" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled", + "sqlserver_microsoft_defender_enabled", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "compute_public_address_shodan", + "gcr_container_scanning_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_advanced_or_enterprise_edition" + ], + "oraclecloud": [ + "cloudguard_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "UEM-08", + "description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", + "name": "Storage Encryption", + "attributes": { + "Section": "Universal Endpoint Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.1", + "CC6.7" + ] + }, + { + "ReferenceId": "CCM v3.0.1", + "Identifiers": [ + "MOS-11" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.6" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.1.2", + "3.1.4" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "PA1.2", + "PA1.3", + "PA1.5", + "PA2.2", + "PM1.4" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.11.2.7", + "27002: 11.2.7", + "27001: A.18.1.1", + "27017: 18.1.1", + "27001: A.12.3.1", + "27017: 12.3.1", + "27018: A.11.4", + "27018: A.11.5" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.8.1", + "27002: 8.1 (h)" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "AC-19(5)", + "SC-28", + "SC-28(1)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-1" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-01" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "3.4", + "3.6" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "3.5.1", + "3.6" + ] + } + ] + }, + "checks": { + "aws": [ + "ec2_ebs_volume_encryption", + "ec2_ebs_default_encryption", + "workspaces_volume_encryption_enabled" + ], + "azure": [ + "databricks_workspace_cmk_encryption_enabled", + "storage_infrastructure_encryption_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "compute_instance_confidential_computing_enabled", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled" + ], + "alibabacloud": [ + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted" + ], + "oraclecloud": [ + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk", + "filestorage_file_system_encrypted_with_cmk" + ] + } + }, + { + "id": "UEM-11", + "description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", + "name": "Data Loss Prevention", + "attributes": { + "Section": "Universal Endpoint Management", + "CCMLite": "No", + "IaaS": "Shared", + "PaaS": "Shared", + "SaaS": "Shared", + "ScopeApplicability": [ + { + "ReferenceId": "AICPA TSC 2017", + "Identifiers": [ + "CC6.7" + ] + }, + { + "ReferenceId": "CIS v8.0", + "Identifiers": [ + "3.13" + ] + }, + { + "ReferenceId": "ENX ISA v6.0", + "Identifiers": [ + "5.2.7" + ] + }, + { + "ReferenceId": "ISF SOGP 2022", + "Identifiers": [ + "IM1.5", + "PA2.2" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", + "Identifiers": [ + "27001: A.12.3", + "27002: 12.3", + "27001: A.8.3.1", + "27002: 8.3.1", + "27001: A.12.2", + "27002: 12.2", + "27001: A.18.1.3", + "27002: 18.1.3", + "27001: A.6.1.1", + "27017: 6.1.1", + "27018: 12.3.1", + "27018: 10.1" + ] + }, + { + "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", + "Identifiers": [ + "27001: A.5.12", + "27001: A.8.3" + ] + }, + { + "ReferenceId": "NIST 800-53 rev 5", + "Identifiers": [ + "SC-7", + "SC-7(10)" + ] + }, + { + "ReferenceId": "NIST CSF v1.1", + "Identifiers": [ + "PR.DS-5" + ] + }, + { + "ReferenceId": "NIST CSF v2.0", + "Identifiers": [ + "PR.DS-02", + "PR.DS-10", + "PR.PS-01", + "ID.AM-08", + "DE.CM-09" + ] + }, + { + "ReferenceId": "PCI DSS v3.2.1", + "Identifiers": [ + "A3.2.6" + ] + }, + { + "ReferenceId": "PCI DSS v4.0", + "Identifiers": [ + "A3.2.6" + ] + } + ] + }, + "checks": { + "aws": [ + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled" + ], + "azure": [ + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_mcas_is_enabled" + ] + } + } + ] +} diff --git a/prowler/compliance/dora_2022_2554.json b/prowler/compliance/dora_2022_2554.json new file mode 100644 index 0000000000..3b828059da --- /dev/null +++ b/prowler/compliance/dora_2022_2554.json @@ -0,0 +1,1421 @@ +{ + "framework": "DORA", + "name": "Digital Operational Resilience Act (Regulation (EU) 2022/2554)", + "version": "2022/2554", + "description": "The Digital Operational Resilience Act (DORA) is a European Union regulation (Regulation (EU) 2022/2554) that sets a uniform framework for the digital operational resilience of the EU financial sector. Mandatory since 17 January 2025, it applies to financial entities (banks, insurers, investment firms, payment institutions, etc.) and to ICT third-party service providers. DORA is structured around five pillars: ICT risk management, ICT-related incident reporting, digital operational resilience testing, ICT third-party risk management, and information sharing. This Prowler mapping covers the technical controls auditable from cloud configuration; the organisational, contractual and supervisory obligations defined in DORA must be addressed outside of Prowler.", + "icon": "dora", + "attributes_metadata": [ + { + "key": "Pillar", + "label": "Pillar", + "type": "str", + "required": true, + "enum": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "Article", + "label": "Article", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "ArticleTitle", + "label": "Article Title", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + } + ], + "outputs": { + "table_config": { + "group_by": "Pillar" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003399", + "secondary_color": "#0055A5", + "bg_color": "#F0F4FA", + "group_by_field": "Pillar", + "sections": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "section_short_names": { + "ICT Risk Management": "ICT Risk Mgmt", + "ICT-Related Incident Reporting": "Incident Reporting", + "Digital Operational Resilience Testing": "Resilience Testing", + "ICT Third-Party Risk Management": "Third-Party Risk", + "Information Sharing": "Info Sharing" + }, + "charts": [ + { + "id": "pillar_compliance", + "type": "horizontal_bar", + "group_by": "Pillar", + "title": "Compliance Score by DORA Pillar", + "y_label": "Pillar", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. Senior management is accountable for ICT risk and shall enforce strong identity, authentication and least-privilege policies for privileged identities, including the root account.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 5", + "ArticleTitle": "Governance and organisation" + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_root_credentials_management_enabled", + "iam_password_policy_minimum_length_14", + "iam_password_policy_lowercase", + "iam_password_policy_uppercase", + "iam_password_policy_number", + "iam_password_policy_symbol", + "iam_password_policy_reuse_24", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_securityaudit_role_created", + "iam_support_role_created", + "organizations_account_part_of_organizations", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled", + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_users_cannot_create_microsoft_365_groups", + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks" + ], + "gcp": [ + "compute_project_os_login_enabled", + "compute_project_os_login_2fa_enabled", + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_organization_essential_contacts_configured", + "iam_account_access_approval_enabled", + "iam_service_account_unused", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account" + ], + "alibabacloud": [ + "ram_no_root_access_key", + "ram_user_mfa_enabled_console_access", + "ram_user_console_access_unused", + "ram_rotate_access_key_90_days", + "ram_password_policy_minimum_length", + "ram_password_policy_lowercase", + "ram_password_policy_uppercase", + "ram_password_policy_number", + "ram_password_policy_symbol", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_max_password_age", + "ram_password_policy_max_login_attempts", + "ram_policy_attached_only_to_group_or_roles", + "ram_policy_no_administrative_privileges" + ] + } + }, + { + "id": "DORA-Art6", + "name": "ICT risk management framework", + "description": "Financial entities shall have an ICT risk management framework that is sound, comprehensive and well-documented, enabling them to address ICT risk quickly, efficiently and comprehensively and to ensure a high level of digital operational resilience. This includes continuous configuration recording, security findings aggregation and an enterprise-wide visibility plane.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 6", + "ArticleTitle": "ICT risk management framework" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "config_recorder_using_aws_service_role", + "securityhub_enabled", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "organizations_delegated_administrators", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled", + "logging_sink_created", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "iam_organization_essential_contacts_configured" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_all_assets_agent_installed", + "securitycenter_vulnerability_scan_enabled", + "actiontrail_multi_region_enabled" + ] + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art7", + "name": "ICT systems, protocols and tools", + "description": "Financial entities shall use and maintain updated ICT systems, protocols and tools that are appropriate to the magnitude of operations supporting ICT functions, technologically resilient, and adequately equipped to securely process data. Cryptographic primitives, certificate hygiene and network segmentation are core to this requirement.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 7", + "ArticleTitle": "ICT systems, protocols and tools" + }, + "checks": { + "aws": [ + "acm_certificates_with_secure_key_algorithms", + "acm_certificates_transparency_logs_enabled", + "acm_certificates_expiration_check", + "ec2_ebs_default_encryption", + "kms_cmk_rotation_enabled", + "s3_bucket_secure_transport_policy", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "vpc_subnet_separate_private_public", + "vpc_subnet_no_public_ip_by_default", + "elb_insecure_ssl_ciphers", + "elbv2_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elbv2_ssl_listeners", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "cloudfront_distributions_https_enabled", + "rds_instance_transport_encrypted" + ], + "azure": [ + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest", + "app_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "app_ensure_using_http20", + "sqlserver_recommended_minimal_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "keyvault_key_rotation_enabled", + "storage_key_rotation_90_days", + "aks_network_policy_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections", + "compute_instance_encryption_with_csek_enabled", + "compute_instance_confidential_computing_enabled", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "dataproc_encrypted_with_cmks_disabled", + "kms_key_rotation_enabled", + "kms_key_rotation_max_90_days", + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "compute_network_not_legacy", + "compute_network_default_in_use", + "compute_instance_single_network_interface" + ], + "cloudflare": [ + "zone_universal_ssl_enabled", + "zone_ssl_strict", + "zone_min_tls_version_secure", + "zone_tls_1_3_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_automatic_https_rewrites_enabled", + "zone_development_mode_disabled", + "zone_record_caa_exists", + "zone_dnssec_enabled" + ], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom", + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "ecs_instance_no_legacy_network" + ] + }, + "config_requirements": [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "Provider": "aws", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": [ + "RSA-1024", + "P-192" + ] + }, + { + "Check": "sqlserver_recommended_minimal_tls_version", + "Provider": "azure", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "Provider": "azure", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] + } + ] + }, + { + "id": "DORA-Art8", + "name": "Identification", + "description": "Financial entities shall identify, classify and adequately document all ICT supported business functions, roles and responsibilities, the information assets and ICT assets supporting them, and their interdependencies. They shall on a continuous basis identify all sources of ICT risk, in particular the risk exposure to and from other financial entities.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 8", + "ArticleTitle": "Identification" + }, + "checks": { + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "ec2_securitygroup_not_used", + "ec2_elastic_ip_unassigned", + "ec2_networkacl_unused", + "secretsmanager_secret_unused" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "network_watcher_enabled", + "network_public_ip_shodan", + "vm_scaleset_not_empty" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_service_account_unused", + "iam_sa_user_managed_key_unused", + "apikeys_key_exists", + "compute_instance_suspended_without_persistent_disks", + "compute_public_address_shodan" + ], + "cloudflare": [ + "dns_record_no_wildcard", + "dns_record_no_internal_ip", + "dns_record_cname_target_valid" + ], + "alibabacloud": [ + "securitycenter_all_assets_agent_installed", + "ram_user_console_access_unused" + ] + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art9", + "name": "Protection and prevention", + "description": "Financial entities shall continuously monitor and control the security and functioning of ICT systems and tools and minimise the impact of ICT risk by deploying appropriate ICT security tools, policies and procedures. Encryption at rest and in transit, blocking of public exposure, network access controls, secret management and instance hardening are central to this article.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 9", + "ArticleTitle": "Protection and prevention" + }, + "checks": { + "aws": [ + "kms_key_not_publicly_accessible", + "ec2_ebs_volume_encryption", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_level_public_access_block", + "s3_bucket_public_access", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_write_acl", + "s3_bucket_public_list_acl", + "s3_bucket_acl_prohibited", + "s3_access_point_public_access_block", + "ec2_securitygroup_default_restrict_traffic", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "rds_instance_storage_encrypted", + "rds_cluster_storage_encrypted", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "secretsmanager_not_publicly_accessible", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_automatic_rotation_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "ec2_instance_imdsv2_enabled", + "ec2_instance_account_imdsv2_enabled", + "efs_encryption_at_rest_enabled", + "awslambda_function_not_publicly_accessible" + ], + "azure": [ + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "cosmosdb_account_firewall_use_selected_networks", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "keyvault_rbac_enabled", + "app_function_not_publicly_accessible", + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "aks_clusters_created_with_private_nodes", + "sqlserver_unrestricted_inbound_access", + "postgresql_flexible_server_allow_access_services_disabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk", + "vm_ensure_using_managed_disks", + "vm_trusted_launch_enabled", + "vm_linux_enforce_ssh_authentication", + "vm_jit_access_enabled", + "sqlserver_tde_encryption_enabled", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled", + "network_ssh_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_http_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_bastion_host_exists" + ], + "gcp": [ + "kms_key_not_publicly_accessible", + "bigquery_dataset_public_access", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "cloudstorage_uses_vpc_service_controls", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_ssl_connections", + "cloudsql_instance_cmek_encryption_enabled", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_instance_public_ip", + "compute_instance_shielded_vm_enabled", + "compute_instance_confidential_computing_enabled", + "compute_instance_serial_ports_in_use", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_instance_ip_forwarding_is_enabled", + "compute_instance_encryption_with_csek_enabled", + "compute_image_not_publicly_shared", + "dataproc_encrypted_with_cmks_disabled", + "kms_key_rotation_enabled", + "gke_cluster_no_default_service_account", + "cloudfunction_function_inside_vpc", + "apikeys_api_restrictions_configured", + "apikeys_api_restricted_with_gemini_api", + "cloudsql_instance_mysql_local_infile_flag", + "cloudsql_instance_mysql_skip_show_database_flag", + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", + "cloudsql_instance_sqlserver_external_scripts_enabled_flag", + "cloudsql_instance_sqlserver_remote_access_flag", + "cloudsql_instance_sqlserver_trace_flag", + "cloudsql_instance_sqlserver_user_connections_flag", + "cloudsql_instance_sqlserver_user_options_flag" + ], + "cloudflare": [ + "zone_universal_ssl_enabled", + "zone_ssl_strict", + "zone_min_tls_version_secure", + "zone_tls_1_3_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_automatic_https_rewrites_enabled", + "zone_record_caa_exists", + "zone_dnssec_enabled", + "zone_waf_enabled", + "zone_waf_owasp_ruleset_enabled", + "zone_firewall_blocking_rules_configured", + "zone_browser_integrity_check_enabled", + "zone_bot_fight_mode_enabled", + "zone_rate_limiting_enabled", + "zone_challenge_passage_configured", + "zone_ip_geolocation_enabled", + "dns_record_proxied", + "dns_record_no_internal_ip", + "dns_record_no_wildcard", + "dns_record_cname_target_valid", + "zone_record_spf_exists", + "zone_record_dkim_exists", + "zone_record_dmarc_exists" + ], + "alibabacloud": [ + "oss_bucket_not_publicly_accessible", + "oss_bucket_secure_transport_enabled", + "actiontrail_oss_bucket_not_publicly_accessible", + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "ecs_securitygroup_restrict_ssh_internet", + "ecs_securitygroup_restrict_rdp_internet", + "ecs_instance_endpoint_protection_installed", + "rds_instance_no_public_access_whitelist", + "rds_instance_ssl_enabled", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom", + "cs_kubernetes_network_policy_enabled", + "cs_kubernetes_eni_multiple_ip_enabled", + "cs_kubernetes_private_cluster_enabled", + "cs_kubernetes_rbac_enabled", + "cs_kubernetes_dashboard_disabled" + ] + } + }, + { + "id": "DORA-Art10", + "name": "Detection", + "description": "Financial entities shall have in place mechanisms to promptly detect anomalous activities, including ICT network performance issues and ICT-related incidents, and to identify potential single points of failure. Threat detection across compute, identity, storage and the API control plane is required for timely detection.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 10", + "ArticleTitle": "Detection" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "guardduty_ec2_malware_protection_enabled", + "guardduty_lambda_protection_enabled", + "guardduty_rds_protection_enabled", + "guardduty_s3_protection_enabled", + "guardduty_eks_audit_log_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "securityhub_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_insights_exist", + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "ec2_elastic_ip_shodan" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "defender_container_images_scan_enabled", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "compute_public_address_shodan", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "logging_sink_created" + ], + "cloudflare": [ + "zone_bot_fight_mode_enabled", + "zone_browser_integrity_check_enabled", + "zone_rate_limiting_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_notification_enabled_high_risk", + "securitycenter_all_assets_agent_installed", + "ecs_instance_endpoint_protection_installed", + "cs_kubernetes_cloudmonitor_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art11", + "name": "Response and recovery", + "description": "Financial entities shall put in place a comprehensive ICT business continuity policy, including ICT response and recovery plans, that ensures the continuity of ICT-supported critical or important functions. Operational alarming, automated event routing and tested recovery actions are essential.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 11", + "ArticleTitle": "Response and recovery" + }, + "checks": { + "aws": [ + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured", + "eventbridge_global_endpoint_event_replication_enabled", + "sns_subscription_not_using_http_endpoints", + "backup_plans_exist", + "backup_vaults_exist", + "rds_instance_critical_event_subscription", + "rds_cluster_critical_event_subscription" + ], + "azure": [ + "monitor_alert_service_health_exists", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_attack_path_notifications_properly_configured", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "compute_instance_automatic_restart_enabled", + "compute_instance_group_autohealing_enabled", + "compute_instance_on_host_maintenance_migrate", + "cloudsql_instance_high_availability_enabled", + "cloudsql_instance_automated_backups", + "compute_instance_deletion_protection_enabled", + "compute_instance_group_load_balancer_attached", + "compute_instance_preemptible_vm_disabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "cs_kubernetes_cluster_check_recent", + "cs_kubernetes_cluster_check_weekly" + ] + } + }, + { + "id": "DORA-Art12", + "name": "Backup policies and procedures, restoration and recovery procedures and methods", + "description": "Financial entities shall develop and document backup policies and procedures specifying the scope of data subject to backup and the minimum frequency of the backup, as well as restoration and recovery procedures and methods. Backups must be encrypted, retained, and resources must be designed for recoverability across availability zones and regions.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 12", + "ArticleTitle": "Backup policies and procedures, restoration and recovery procedures and methods" + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_vaults_exist", + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "rds_instance_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "rds_instance_multi_az", + "rds_cluster_multi_az", + "rds_cluster_backtrack_enabled", + "rds_instance_deletion_protection", + "rds_cluster_deletion_protection", + "rds_snapshots_encrypted", + "s3_bucket_object_versioning", + "s3_bucket_object_lock", + "s3_bucket_cross_region_replication", + "s3_bucket_no_mfa_delete", + "dynamodb_tables_pitr_enabled", + "dynamodb_table_deletion_protection_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "autoscaling_group_multiple_az", + "elb_is_in_multiple_az", + "elbv2_is_in_multiple_az", + "cloudfront_distributions_multiple_origin_failover_configured", + "dynamodb_table_protected_by_backup_plan" + ], + "azure": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period", + "vm_ensure_using_managed_disks", + "storage_ensure_soft_delete_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled", + "storage_geo_redundant_enabled", + "keyvault_recoverable" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudsql_instance_high_availability_enabled", + "cloudstorage_bucket_versioning_enabled", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_lifecycle_management_enabled", + "cloudstorage_bucket_sufficient_retention_period", + "compute_instance_group_multiple_zones", + "compute_instance_disk_auto_delete_disabled", + "compute_instance_deletion_protection_enabled", + "compute_snapshot_not_outdated", + "compute_instance_suspended_without_persistent_disks" + ] + } + }, + { + "id": "DORA-Art13", + "name": "Learning and evolving", + "description": "Financial entities shall have in place capabilities and staff to gather information on vulnerabilities and cyber threats, perform post ICT-related incident reviews, and continuously feed lessons learnt back into the ICT risk assessment process. Findings aggregation and continuous insights drive this cycle.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 13", + "ArticleTitle": "Learning and evolving" + }, + "checks": { + "aws": [ + "securityhub_enabled", + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_insights_exist" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_assessments_vm_endpoint_protection_installed", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_system_updates_are_applied", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "iam_cloud_asset_inventory_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_all_assets_agent_installed", + "ecs_instance_latest_os_patches_applied" + ] + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art14", + "name": "Communication", + "description": "As part of the ICT risk management framework, financial entities shall have in place crisis communication plans enabling a responsible disclosure of ICT-related incidents or major vulnerabilities to clients, counterparts and the public. Reliable, encrypted and access-controlled notification channels are required.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 14", + "ArticleTitle": "Communication" + }, + "checks": { + "aws": [ + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "sns_subscription_not_using_http_endpoints", + "eventbridge_bus_exposed", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "iam_organization_essential_contacts_configured", + "logging_sink_created", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk" + ] + } + }, + { + "id": "DORA-Art17", + "name": "ICT-related incident management process", + "description": "Financial entities shall define, establish and implement an ICT-related incident management process to detect, manage and notify ICT-related incidents. Comprehensive trail logging, log integrity protection, retention and centralisation of ICT events are foundational requirements.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 17", + "ArticleTitle": "ICT-related incident management process" + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_bucket_requires_mfa_delete", + "cloudtrail_bedrock_logging_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_no_secrets_in_logs", + "cloudwatch_log_group_not_publicly_accessible", + "vpc_flow_logs_enabled", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "cloudfront_distributions_logging_enabled", + "s3_bucket_server_access_logging_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_is_private", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days", + "mysql_flexible_server_audit_log_enabled", + "mysql_flexible_server_audit_log_connection_activated", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3", + "app_http_logs_enabled", + "app_function_application_insights_enabled", + "appinsights_ensure_is_configured" + ], + "gcp": [ + "iam_audit_logs_enabled", + "cloudstorage_audit_logs_enabled", + "logging_sink_created", + "cloudstorage_bucket_logging_enabled", + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_sufficient_retention_period", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled", + "compute_loadbalancer_logging_enabled", + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "actiontrail_oss_bucket_not_publicly_accessible", + "oss_bucket_logging_enabled", + "sls_logstore_retention_period", + "vpc_flow_logs_enabled", + "cs_kubernetes_log_service_enabled", + "rds_instance_sql_audit_enabled", + "rds_instance_sql_audit_retention", + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled", + "rds_instance_postgresql_log_duration_enabled" + ] + } + }, + { + "id": "DORA-Art18", + "name": "Classification of ICT-related incidents and cyber threats", + "description": "Financial entities shall classify ICT-related incidents and shall determine their impact based on criteria such as the number of clients affected, duration, geographical spread, data losses, and criticality of the services affected. Severity-aware threat detection across the estate underpins this classification.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 18", + "ArticleTitle": "Classification of ICT-related incidents and cyber threats" + }, + "checks": { + "aws": [ + "guardduty_no_high_severity_findings", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions", + "securityhub_enabled", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation" + ], + "azure": [ + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_public_address_shodan", + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "securitycenter_vulnerability_scan_enabled" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_delegated_admin_enabled_all_regions", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art19", + "name": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats", + "description": "Financial entities shall report major ICT-related incidents to the relevant competent authority and may, on a voluntary basis, notify significant cyber threats. Detective metric filters, change-tracking alarms and reliable notification topics are needed to surface and route reportable events.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 19", + "ArticleTitle": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats" + }, + "checks": { + "aws": [ + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "sns_subscription_not_using_http_endpoints" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_delete_nsg", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_service_health_exists", + "defender_additional_email_configured_with_a_security_contact" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "iam_organization_essential_contacts_configured", + "logging_sink_created" + ], + "alibabacloud": [ + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled", + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled" + ] + } + }, + { + "id": "DORA-Art24", + "name": "General requirements for the performance of digital operational resilience testing", + "description": "Financial entities shall establish, maintain and review a sound and comprehensive digital operational resilience testing programme, as an integral part of the ICT risk management framework. Continuous vulnerability discovery, configuration assessment and instance manageability are foundational.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 24", + "ArticleTitle": "General requirements for the performance of digital operational resilience testing" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "securityhub_enabled", + "ec2_instance_managed_by_ssm", + "ec2_instance_with_outdated_ami", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_ensure_system_updates_are_applied", + "defender_container_images_scan_enabled", + "defender_assessments_vm_endpoint_protection_installed", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "vm_ensure_using_approved_images", + "vm_desired_sku_size" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_instance_shielded_vm_enabled", + "compute_snapshot_not_outdated", + "compute_public_address_shodan" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_advanced_or_enterprise_edition", + "ecs_instance_latest_os_patches_applied", + "cs_kubernetes_cluster_check_recent", + "cs_kubernetes_cluster_check_weekly" + ] + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art25", + "name": "Testing of ICT tools and systems", + "description": "Financial entities shall ensure that tests are undertaken on ICT tools and systems, on critical ICT systems supporting all critical or important functions, at least yearly. Vulnerability assessments, deprecated component detection and certificate hygiene must be tracked.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 25", + "ArticleTitle": "Testing of ICT tools and systems" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "config_recorder_all_regions_enabled", + "ec2_instance_with_outdated_ami", + "ec2_instance_managed_by_ssm", + "ec2_instance_paravirtual_type", + "rds_instance_deprecated_engine_version", + "acm_certificates_expiration_check", + "rds_instance_certificate_expiration", + "iam_no_expired_server_certificates_stored", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_va_emails_notifications_admins_enabled", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_secret_expiration_set", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "storage_smb_protocol_version_is_latest" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_snapshot_not_outdated", + "compute_network_not_legacy", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "apikeys_key_rotated_in_90_days", + "iam_sa_user_managed_key_rotate_90_days" + ], + "cloudflare": [ + "zone_min_tls_version_secure" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "ecs_instance_latest_os_patches_applied", + "ecs_instance_no_legacy_network" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art28", + "name": "General principles (ICT third-party risk)", + "description": "Financial entities shall manage ICT third-party risk as an integral component of ICT risk within their ICT risk management framework. Cross-account access, trust boundaries, organization-level controls and dependency visibility are critical to monitor third-party exposure on AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 28", + "ArticleTitle": "General principles (ICT third-party risk)" + }, + "checks": { + "aws": [ + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_cross_account_readonlyaccess_policy", + "iam_no_custom_policy_permissive_role_assumption", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "s3_bucket_cross_account_access", + "dynamodb_table_cross_account_access", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_cross_account_sharing_disabled", + "organizations_delegated_administrators", + "organizations_account_part_of_organizations", + "organizations_scp_check_deny_regions", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_peering_routing_tables_with_least_privilege", + "awslambda_function_using_cross_account_layers" + ], + "azure": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "storage_cross_tenant_replication_disabled", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "aks_clusters_created_with_private_nodes" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudstorage_bucket_public_access", + "kms_key_not_publicly_accessible", + "cloudstorage_uses_vpc_service_controls", + "cloudfunction_function_inside_vpc", + "iam_sa_no_user_managed_keys", + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "apikeys_api_restrictions_configured", + "apikeys_api_restricted_with_gemini_api", + "compute_image_not_publicly_shared", + "iam_cloud_asset_inventory_enabled" + ], + "cloudflare": [ + "zone_record_caa_exists", + "dns_record_cname_target_valid" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges", + "oss_bucket_not_publicly_accessible", + "actiontrail_oss_bucket_not_publicly_accessible" + ] + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art30", + "name": "Key contractual provisions", + "description": "Contractual arrangements with ICT third-party service providers shall be set out in writing and include, at minimum, agreed service levels and clear allocation of rights and obligations. Privilege boundaries, least-privilege policies and absence of administrative wildcards are the technical guardrails that enforce these contractual constraints inside AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 30", + "ArticleTitle": "Key contractual provisions" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_allows_privilege_escalation", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy", + "iam_group_administrator_access_policy", + "iam_administrator_access_with_mfa", + "iam_policy_attached_only_to_group_or_roles", + "accessanalyzer_enabled" + ], + "azure": [ + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "entra_global_admin_in_less_than_five_users", + "app_function_identity_without_admin_privileges", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps" + ], + "gcp": [ + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_user_managed_keys", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account", + "iam_account_access_approval_enabled", + "apikeys_api_restrictions_configured" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges", + "ram_policy_attached_only_to_group_or_roles", + "ram_no_root_access_key" + ] + }, + "config_requirements": [ + { + "Check": "accessanalyzer_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + }, + { + "id": "DORA-Art45", + "name": "Information-sharing arrangements on cyber threat information and intelligence", + "description": "Financial entities may exchange amongst themselves cyber threat information and intelligence, including indicators of compromise, tactics, techniques and procedures, cyber security alerts and configuration tools. Centralised threat detection, sensitive data discovery and trail-based intelligence enable participation in such information-sharing arrangements.", + "attributes": { + "Pillar": "Information Sharing", + "Article": "Article 45", + "ArticleTitle": "Information-sharing arrangements on cyber threat information and intelligence" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_centrally_managed", + "securityhub_enabled", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "accessanalyzer_enabled_without_findings" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on", + "defender_attack_path_notifications_properly_configured", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_public_address_shodan", + "logging_sink_created" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_notification_enabled_high_risk", + "actiontrail_multi_region_enabled", + "sls_logstore_retention_period" + ] + }, + "config_requirements": [ + { + "Check": "guardduty_is_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + }, + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false + } + ] + } + ] +} diff --git a/prowler/compliance/gcp/ccc_gcp.json b/prowler/compliance/gcp/ccc_gcp.json index e3060646c5..520b6b534a 100644 --- a/prowler/compliance/gcp/ccc_gcp.json +++ b/prowler/compliance/gcp/ccc_gcp.json @@ -1,10 +1,1638 @@ { "Framework": "CCC", - "Version": "", + "Version": "v2025.10", "Provider": "GCP", "Name": "Common Cloud Controls Catalog (CCC)", "Description": "Common Cloud Controls Catalog (CCC) for GCP", "Requirements": [ + { + "Id": "CCC.Core.CN01.AR01", + "Description": "When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Most cloud services enable TLS 1.3 by default. Where it is not already set, ensure that your services are configured or updated accordingly.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "cloudsql_instance_ssl_connections" + ] + }, + { + "Id": "CCC.Core.CN01.AR02", + "Description": "When a port is exposed for SSH network traffic, all traffic MUST include a SSH handshake AND be encrypted using SSHv2 or higher.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Any time port 22 is exposed, ensure that it has a properly implemented SSH server with SSHv2 enabled and configured with strong ciphers.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_project_os_login_enabled", + "compute_project_os_login_2fa_enabled" + ] + }, + { + "Id": "CCC.Core.CN01.AR03", + "Description": "When the service receives unencrypted traffic, then it MUST either block the request or automatically redirect it to the secure equivalent.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Review firewall, load balancer, and application configurations to ensure insecure protocols such as HTTP, FTP, and Telnet are not exposed. Where possible, implement automatic redirection to secure protocols such as HTTPS, SFTP, SSH, and regularly scan for protocol drift.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [ + "cloudsql_instance_ssl_connections" + ] + }, + { + "Id": "CCC.Core.CN01.AR07", + "Description": "When a port is exposed, the service MUST ensure that the protocol and service officially assigned to that port number by the IANA Service Name and Transport Protocol Port Number Registry, and no other, is run on that port.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Reference the IANA Service Name and Transport Protocol Port Number Registry for more information about correct protocol-to-port assignments. Avoid running non-standard services on well-known ports.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN01.AR08", + "Description": "When a service transmits data using TLS, mutual TLS (mTLS) MUST be implemented to require both client and server certificate authentication for all connections.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "SubSection": "", + "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Configure mTLS for all endpoints that process or transmit sensitive data. Ensure both client and server certificates are validated and managed securely. Regularly review certificate authorities and automate certificate rotation where possible.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "IVS-03", + "IVS-07" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN13.AR01", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST only use valid, unexpired certificates issued by a trusted certificate authority.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN13.AR02", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 180 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-amber" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN13.AR03", + "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 90 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH18" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN06.AR01", + "Description": "When the service is running, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate the service's deployment location is included in this list.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-19" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN06.AR02", + "Description": "When a child resource is deployed, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate that child resources can only be deployed to locations included in this list.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-19" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN08.AR01", + "Description": "When data is created or modified, the data MUST have a complete and recoverable duplicate that is stored in a physically separate data center.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "SubSection": "", + "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement automated data replication processes to ensure that data is consistently duplicated in another region or availability zone. Regularly test data recovery from the replicated location to ensure integrity and availability.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "BCR-08", + "BCR-10", + "BCR-11" + ] + } + ] + } + ], + "Checks": [ + "cloudsql_instance_automated_backups" + ] + }, + { + "Id": "CCC.Core.CN08.AR02", + "Description": "When data is replicated into a second location, the service MUST be able to accurately represent the replication locations, replication status, and data synchronization status.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "SubSection": "", + "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "BCR-08", + "BCR-10", + "BCR-11" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN09.AR01", + "Description": "When the service is operational, its logs and any child resource logs MUST NOT be accessible from the resource they record access to.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "CCC.Core.CN09.AR02", + "Description": "When the service is operational, disabling the logs for the service or its child resources MUST NOT be possible without also disabling the corresponding resource.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "No normal business operations should disable logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging mechanisms are tightly integrated with service operations, so that logging cannot be disabled without stopping the service itself.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "logging_sink_created", + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "CCC.Core.CN09.AR03", + "Description": "When the service is operational, any attempt to redirect logs for the service or its child resources MUST NOT be possible without halting operation of the corresponding resource and publishing corresponding events to monitored channels.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "No normal business operations should result in the redirection of logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging configurations are immutable during service operation so that any changes require stopping the service and publishing corresponding events to monitored channels.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07", + "CCC.Core.TH09", + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-02", + "LOG-04", + "LOG-09" + ] + } + ] + } + ], + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ] + }, + { + "Id": "CCC.Core.CN10.AR01", + "Description": "When data is replicated, the service MUST ensure that replication only occurs to destinations that are explicitly included within the defined trust perimeter.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN10 Restrict Data Replication to Trust Perimeter", + "SubSection": "", + "SubSectionObjective": "Ensure that data is only replicated on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-10", + "DSP-19" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN02.AR01", + "Description": "When data is stored, it MUST be encrypted using the latest industry-standard encryption methods.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN02 Encrypt Data for Storage", + "SubSection": "", + "SubSectionObjective": "Ensure that all data stored is encrypted at rest using strong encryption algorithms.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-03", + "CEK-04", + "UEM-08", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption" + ] + }, + { + "Id": "CCC.Core.CN11.AR01", + "Description": "When encryption keys are used, the service MUST verify that all encryption keys use the latest industry-standard cryptographic algorithms.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN11.AR02", + "Description": "When encryption keys are used, the service MUST rotate active keys within 180 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_key_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR03", + "Description": "When encrypting data, the service MUST verify that customer-managed encryption keys (CMEKs) are used.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "dataproc_encrypted_with_cmks_disabled", + "compute_instance_encryption_with_csek_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR04", + "Description": "When encryption keys are accessed, the service MUST verify that access to encryption keys is restricted to authorized personnel and services, following the principle of least privilege.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_key_not_publicly_accessible", + "iam_role_kms_enforce_separation_of_duties" + ] + }, + { + "Id": "CCC.Core.CN11.AR05", + "Description": "When encryption keys are used, the service MUST rotate active keys within 365 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-clear", + "tlp-green" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_key_rotation_enabled" + ] + }, + { + "Id": "CCC.Core.CN11.AR06", + "Description": "When encryption keys are used, the service MUST rotate active keys within 90 days of issuance.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN11 Protect Encryption Keys", + "SubSection": "", + "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "CEK-08", + "CEK-10", + "CEK-12" + ] + } + ] + } + ], + "Checks": [ + "kms_key_rotation_max_90_days" + ] + }, + { + "Id": "CCC.Core.CN14.AR01", + "Description": "When backups are created for disaster recovery purposes, the storage mechanism MUST NOT allow modification or deletion within 30 days of creation.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Use immutable storage solutions where possible. Implement backup retention policies that enforce a minimum retention period of 30 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_sufficient_retention_period" + ], + "ConfigRequirements": [ + { + "Check": "cloudstorage_bucket_sufficient_retention_period", + "ConfigKey": "storage_min_retention_days", + "Operator": "gte", + "Value": 30 + } + ] + }, + { + "Id": "CCC.Core.CN14.AR02", + "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 30 days.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber" + ], + "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 30 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "cloudsql_instance_automated_backups" + ] + }, + { + "Id": "CCC.Core.CN14.AR03", + "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 14 days.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.Core.CN14 Maintain Recent Backups", + "SubSection": "", + "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 14 days.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [] + } + ], + "Checks": [ + "cloudsql_instance_automated_backups" + ] + }, + { + "Id": "CCC.Core.CN03.AR01", + "Description": "When an entity attempts to modify the service through a user interface, the authentication process MUST require multiple identifying factors for authentication.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "compute_project_os_login_2fa_enabled" + ] + }, + { + "Id": "CCC.Core.CN03.AR02", + "Description": "When an entity attempts to modify the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days" + ] + }, + { + "Id": "CCC.Core.CN03.AR03", + "Description": "When an entity attempts to view information on the service through a user interface, the authentication process MUST require multiple identifying factors from the user.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "compute_project_os_login_2fa_enabled" + ] + }, + { + "Id": "CCC.Core.CN03.AR04", + "Description": "When an entity attempts to view information on the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "SubSection": "", + "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-14" + ] + } + ] + } + ], + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days" + ] + }, + { + "Id": "CCC.Core.CN05.AR01", + "Description": "When an attempt is made to modify data on the service or a child resource, the service MUST block requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_public_access", + "compute_instance_public_ip", + "cloudsql_instance_public_ip", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account", + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges" + ] + }, + { + "Id": "CCC.Core.CN05.AR02", + "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_account_access_approval_enabled" + ] + }, + { + "Id": "CCC.Core.CN05.AR03", + "Description": "When administrative access or configuration change is attempted on the service or a child resource in a multi-tenant environment, the service MUST refuse requests across tenant boundaries unless the origin is explicitly included in a pre-approved allowlist.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_uses_vpc_service_controls" + ] + }, + { + "Id": "CCC.Core.CN05.AR04", + "Description": "When data is requested from outside the trust perimeter, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_public_access", + "cloudstorage_uses_vpc_service_controls", + "cloudsql_instance_public_ip", + "cloudsql_instance_public_access", + "compute_instance_public_ip", + "kms_key_not_publicly_accessible", + "bigquery_dataset_public_access" + ] + }, + { + "Id": "CCC.Core.CN05.AR05", + "Description": "When any request is made from outside the trust perimeter, the service MUST NOT provide any response that may indicate the service exists.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "compute_instance_public_ip", + "cloudstorage_bucket_public_access", + "cloudsql_instance_public_ip", + "kms_key_not_publicly_accessible", + "compute_image_not_publicly_shared", + "bigquery_dataset_public_access" + ] + }, + { + "Id": "CCC.Core.CN05.AR06", + "Description": "When any request is made to the service or a child resource, the service MUST refuse requests from unauthorized entities.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", + "SubSection": "", + "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-01", + "DSP-07", + "DSP-08", + "DSP-10", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "iam_service_account_unused", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties" + ] + }, + { + "Id": "CCC.Core.CN04.AR01", + "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ] + }, + { + "Id": "CCC.Core.CN04.AR02", + "Description": "When any attempt is made to modify data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "logging_sink_created" + ] + }, + { + "Id": "CCC.Core.CN04.AR03", + "Description": "When any attempt is made to read data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN04 Log All Access and Changes", + "SubSection": "", + "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-08" + ] + } + ] + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "logging_sink_created" + ] + }, + { + "Id": "CCC.Core.CN07.AR01", + "Description": "When enumeration activities are detected, the service MUST publish an event to a monitored channel which includes the client identity, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement event publication mechanisms and alerts for patterns indicative of enumeration activities, such as repeated access attempts, requests, or liveness probes. Configure alerts to notify security teams of any activities that merit further investigation.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH15" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-05", + "SEF-05" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Core.CN07.AR02", + "Description": "When enumeration activities are detected, the service MUST log the client identity, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements.", + "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement logging mechanisms to capture details of enumeration activities, including client identity, timestamps, and activity nature. Retain logs according to organizational policies, and occasionally review them for patterns that may indicate reconnaissance activities.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH15" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "LOG-05", + "SEF-05" + ] + } + ] + } + ], + "Checks": [] + }, { "Id": "CCC.AuditLog.CN01.AR01", "Description": "When the signature validation process is performed, then it MUST detect any modification of data.", @@ -18,13 +1646,13 @@ "Applicability": [ "tlp-red" ], - "Recommendation": "Ensure hash of data is included in digital signature. ", + "Recommendation": "Ensure hash of data is included in digital signature.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -44,12 +1672,7 @@ ] } ], - "Checks": [ - "iam_audit_logs_enabled", - "cloudstorage_bucket_log_retention_policy_lock", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" - ] + "Checks": [] }, { "Id": "CCC.AuditLog.CN01.AR02", @@ -64,13 +1687,13 @@ "Applicability": [ "tlp-red" ], - "Recommendation": "Ensure verification process includes a chained hash function. ", + "Recommendation": "Ensure verification process includes a chained hash function.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -90,11 +1713,7 @@ ] } ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" - ] + "Checks": [] }, { "Id": "CCC.AuditLog.CN02.AR01", @@ -115,7 +1734,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06" + "CCC.Core.TH06" ] } ], @@ -139,15 +1758,7 @@ ], "Checks": [ "iam_audit_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + "logging_sink_created" ] }, { @@ -164,12 +1775,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Ensure alerting is correctly configured ", + "Recommendation": "Ensure alerting is correctly configured", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -191,17 +1802,7 @@ } ], "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "cloudstorage_bucket_log_retention_policy_lock", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "iam_audit_logs_enabled" + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" ] }, { @@ -218,12 +1819,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Ensure alerting is correctly configured ", + "Recommendation": "Ensure alerting is correctly configured", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -245,9 +1846,7 @@ } ], "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "cloudstorage_bucket_log_retention_policy_lock", - "logging_sink_created" + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled" ] }, { @@ -264,13 +1863,13 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure the audit log bucket to enable server access logging. Ensure the target logging bucket is configured for appropriate security, including restricted access and immutability. ", + "Recommendation": "Configure the audit log bucket to enable server access logging. Ensure the target logging bucket is configured for appropriate security, including restricted access and immutability.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01", - "CCC.TH09" + "CCC.Core.TH01", + "CCC.Core.TH09" ] } ], @@ -292,10 +1891,8 @@ } ], "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_bucket_level_access", - "logging_sink_created" + "cloudstorage_bucket_logging_enabled", + "cloudstorage_audit_logs_enabled" ] }, { @@ -312,12 +1909,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure audit log exporting. ", + "Recommendation": "Configure audit log exporting.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -340,11 +1937,8 @@ } ], "Checks": [ - "logging_sink_created", "iam_audit_logs_enabled", - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_uniform_bucket_level_access", - "cloudstorage_bucket_public_access" + "logging_sink_created" ] }, { @@ -362,13 +1956,13 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure the audit log bucket's lifecycle rules or object retention settings to enforce the required data retention period. ", + "Recommendation": "Configure the audit log bucket's lifecycle rules or object retention settings to enforce the required data retention period.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -390,8 +1984,7 @@ } ], "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "logging_sink_created" + "cloudstorage_bucket_log_retention_policy_lock" ] }, { @@ -409,13 +2002,13 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Enable MFA Delete (or equivalent multi-factor authentication for delete operations) on the audit log bucket. ", + "Recommendation": "Enable MFA Delete (or equivalent multi-factor authentication for delete operations) on the audit log bucket.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06", - "CCC.TH07" + "CCC.Core.TH06", + "CCC.Core.TH07" ] } ], @@ -436,11 +2029,7 @@ ] } ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "iam_audit_logs_enabled", - "logging_sink_created" - ] + "Checks": [] }, { "Id": "CCC.AuditLog.CN08.AR01", @@ -456,12 +2045,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure object lock policy. ", + "Recommendation": "Configure object lock policy.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -500,12 +2089,12 @@ "tlp-red", "tlp-amber" ], - "Recommendation": "Review field level access controls on audit data. ", + "Recommendation": "Review field level access controls on audit data.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH07" ] } ], @@ -547,59 +2136,12 @@ "tlp-amber", "tlp-green" ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_bucket_level_access", - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.AuditLog.CN10.AR02", - "Description": "When the URL of a audit log storage bucket's object is accessed publicly then, it should be denied by bucket policy.", - "Attributes": [ - { - "FamilyName": "Confidentiality", - "FamilyDescription": "Controls designed to protected the confidentiality of Audit Log data.", - "Section": "CCC.AuditLog.CN10 Ensure Audit Bucket is Not Publicly Accessible", - "SubSection": "", - "SubSectionObjective": "Ensure that audit log storage buckets are not publicly accessible to prevent unauthorized exposure of sensitive log data.", - "Applicability": [ - "tlp-red", - "tlp-amber", - "tlp-green" - ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -626,25 +2168,26 @@ ] }, { - "Id": "CCC.Build.CN01.AR01", - "Description": "Attempt to initiate a build using an unauthorized build agent and verify that the build is rejected.", + "Id": "CCC.AuditLog.CN10.AR02", + "Description": "When the URL of a audit log storage bucket's object is accessed publicly then, it should be denied by bucket policy.", "Attributes": [ { - "FamilyName": "Access Control", - "FamilyDescription": "TODO: Describe this control family", - "Section": "", - "SubSection": "CCC.Build.C01 Restrict Allowed Build Agents", - "SubSectionObjective": "Ensure that builds are executed only on authorized build agents to maintain control over the build environment and prevent unauthorized code execution.", + "FamilyName": "Confidentiality", + "FamilyDescription": "Controls designed to protected the confidentiality of Audit Log data.", + "Section": "CCC.AuditLog.CN10 Ensure Audit Bucket is Not Publicly Accessible", + "SubSection": "", + "SubSectionObjective": "Ensure that audit log storage buckets are not publicly accessible to prevent unauthorized exposure of sensitive log data.", "Applicability": [ "tlp-red", - "tlp-amber" + "tlp-amber", + "tlp-green" ], - "Recommendation": "", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -652,14 +2195,296 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-4" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ "AC-3", - "AC-6" + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access" + ] + }, + { + "Id": "CCC.Logging.CN01.AR01", + "Description": "When a new cloud account is created, provider-level audit and network flow logging MUST be enabled by default and directed to the central sink.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", + "SubSection": "", + "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH07" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-2", + "AU-3" + ] + } + ] + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "compute_subnet_flow_logs_enabled", + "logging_sink_created" + ] + }, + { + "Id": "CCC.Logging.CN01.AR02", + "Description": "When a new cloud compute resource is deployed, it MUST be configured to forward all relevant logs (e.g., OS, application, service logs) to the central log sink.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", + "SubSection": "", + "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH07" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-2", + "AU-3" + ] + } + ] + } + ], + "Checks": [ + "logging_sink_created", + "compute_subnet_flow_logs_enabled", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled" + ] + }, + { + "Id": "CCC.Logging.CN02.AR01", + "Description": "When a new log bucket or stream is created, its retention policy MUST be configured in accordance with organisation's data retention policy.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "GV.PO-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-11" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_sufficient_retention_period" + ] + }, + { + "Id": "CCC.Logging.CN02.AR02", + "Description": "When a query is performed to retrieve log events older than the number of days defined in the organisation's data retention policy, it MUST return an empty result.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", + "SubSection": "", + "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "GV.PO-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-11" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_sufficient_retention_period" + ] + }, + { + "Id": "CCC.Logging.CN03.AR01", + "Description": "When an attempt is made to modify or delete data before the object lock period expires, then the action MUST be denied.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data.", + "Section": "CCC.Logging.CN03 Enable Object Lock On Log Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure log immutability by enabling Write Once, Read Many (WORM) protection using object lock on log storage buckets. This prevents logs from being modified or deleted during the defined retention period, supporting compliance and forensic integrity.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "Configure object lock policy.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH07" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-9", + "AU-11" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "CCC.Logging.CN04.AR01", + "Description": "When restricted fields are accessed by unauthorized users, then those fields MUST remain masked.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify logs.", + "Section": "CCC.Logging.CN04 Restrict Field And Log Type Access", + "SubSection": "", + "SubSectionObjective": "Configure access to logs to follow the principle of least privilege in particular where technically possible limit the log fields users have access to to prevent accidental exposure to sensitive information such as PII.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "Review field level access controls on log data.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PS-04" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6", + "AU-9", + "AC-3", + "PT-2", + "PT-3", + "PT-3" ] } ] @@ -668,25 +2493,26 @@ "Checks": [] }, { - "Id": "CCC.Build.CN02.AR01", - "Description": "Attempt to trigger a build from an unauthorized external service or repository and verify that the build does not start.", + "Id": "CCC.Logging.CN05.AR01", + "Description": "When a log storage bucket is created, the bucket's access control settings MUST explicitly deny public read and write access.", "Attributes": [ { - "FamilyName": "Access Control", - "FamilyDescription": "TODO: Describe this control family", - "Section": "", - "SubSection": "CCC.Build.C02 Restrict Allowed External Services for Build Triggers", - "SubSectionObjective": "Ensure that builds can only be triggered by authorized external services or repositories to prevent unauthorized code execution or tampering.", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify logs.", + "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", + "SubSection": "", + "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", "Applicability": [ "tlp-red", - "tlp-amber" + "tlp-amber", + "tlp-green" ], - "Recommendation": "", + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -694,14 +2520,108 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-4" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ "AC-3", - "AC-6" + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access" + ] + }, + { + "Id": "CCC.Logging.CN05.AR02", + "Description": "When the URL of a log storage bucket's object is accessed publicly, the action MUST be denied by bucket policy.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify logs.", + "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", + "SubSection": "", + "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green" + ], + "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access" + ] + }, + { + "Id": "CCC.Logging.CN06.AR01", + "Description": "When a single principal executes an anomalously high number of log queries, an alert MUST be generated.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain logging-related events.", + "Section": "CCC.Logging.CN06 Detect and Alert on Potential Log Exfiltration", + "SubSection": "", + "SubSectionObjective": "Identify and alert on anomalous data access patterns that may indicate an attempt to exfiltrate log data.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Logging.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "CA-7", + "AU-6" ] } ] @@ -710,26 +2630,1273 @@ "Checks": [] }, { - "Id": "CCC.Build.CN03.AR01", - "Description": "Attempt to access the build environment from an external network and verify that access is denied.", + "Id": "CCC.Logging.CN07.AR01", + "Description": "When an audit log event is recorded that corresponds to a modification of the logging service configuration such as disabling a log trail, deleting a log sink, or altering a log forwarding rule, an alert MUST be generated.", "Attributes": [ { - "FamilyName": "Network Security", - "FamilyDescription": "TODO: Describe this control family", - "Section": "", - "SubSection": "CCC.Build.C03 Deny External Network Access for Build Environments", - "SubSectionObjective": "Ensure that build environments do not have external network access to prevent unauthorized external access and data exfiltration.", + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain logging-related events.", + "Section": "CCC.Logging.CN07 Detect and Alert on Log Service Tampering", + "SubSection": "", + "SubSectionObjective": "Alert when any component of the critical logging infrastructure is disabled, modified, or deleted, indicating a defense evasion attempt.", "Applicability": [ - "tlp-red", - "tlp-amber" + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH02", - "CCC.TH05" + "CCC.Core.TH16" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "CA-7", + "AU-6" + ] + } + ] + } + ], + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ] + }, + { + "Id": "CCC.Monitor.CN01.AR01", + "Description": "When an External Monitoring system exceeds the anticipated rate of monitoring checks then Rate Limiting MUST be applied and an Audit Alert MUST be generated.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", + "Section": "CCC.Monitor.CN01 Rate Limiting on External Monitoring", + "SubSection": "", + "SubSectionObjective": "Prevent DoS attacks using External Monitoring tools.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IR-01", + "DE.CM-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-5", + "SC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Monitor.CN02.AR01", + "Description": "When an Custom or User-Defined Metric starts to flood a collector, then a rate limit MUST be applied to reduce the network impact of traffic and an alert must triggered.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", + "Section": "CCC.Monitor.CN02 Rate Limiting on Metric Generation", + "SubSection": "", + "SubSectionObjective": "Prevent Malicious Actor or misconfiguration from flooding services with metric data.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-5(2)", + "CA-7", + "SI-4" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Monitor.CN03.AR01", + "Description": "When external systems have approved access to internal systems not normally available for public access then they MUST be secured to prevent unauthorised access jumping through to the internal systems and only allow access to specific internal services.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN03 Access External Monitoring", + "SubSection": "", + "SubSectionObjective": "Control access to Synthetic monitoring solutions using API keys or Certificate based authentication to ensure they don't become an attack path, preventing monitoring systems from forging network requests to gain access to internal systems.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-06", + "PR.IR-01", + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3" + ] + } + ] + } + ], + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days" + ] + }, + { + "Id": "CCC.Monitor.CN04.AR01", + "Description": "When monitoring dashboards display degraded services which may become potential targets then the dashboard MUST be protected from unauthorised access.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN04 Restrict access to Monitoring Dashboards", + "SubSection": "", + "SubSectionObjective": "Control access to Monitoring Dashboards and reports to ensure they don't highlight an attack path.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-09", + "DE.AE-03" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "AC-3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Monitor.CN05.AR01", + "Description": "When monitoring services have generated an alert, the service MUST ensure only authorised responders silence or acknowledge the alert.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN05 Restrict access to silence or acknowledge an alert", + "SubSection": "", + "SubSectionObjective": "Ensure only a subset of users can silence or acknowledge alerts to prevent attackers hiding their activity.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IR-01", + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Monitor.CN06.AR01", + "Description": "When systems push metrics or traces they MUST be authenticated for that particular type of metric or trace", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", + "Section": "CCC.Monitor.CN06 Metrics pushed for authorised services only", + "SubSection": "", + "SubSectionObjective": "Use IAM to control which types of metrics or traces can be pushed by different system to avoid a compromised system pushing fabricated metrics about a different service", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Monitor.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-5" + ] + } + ] + } + ], + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR01", + "Description": "When a request is made to read a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR02", + "Description": "When a request is made to read an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR03", + "Description": "When a request is made to write to a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "CCC.ObjStor.CN01.AR04", + "Description": "When a request is made to write to an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", + "SubSection": "", + "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption, or sensitive data decryption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-03", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "kms_key_not_publicly_accessible" + ] + }, + { + "Id": "CCC.ObjStor.CN03.AR01", + "Description": "When an object storage bucket deletion is attempted, the bucket MUST be fully recoverable for a set time-frame after deletion is requested.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", + "SubSection": "", + "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "CCC.ObjStor.CN03.AR02", + "Description": "When an attempt is made to modify the retention policy for an object storage bucket, the service MUST prevent the policy from being modified.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", + "SubSection": "", + "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "CCC.ObjStor.CN04.AR01", + "Description": "When an object is uploaded to the object storage system, the object MUST automatically receive a default retention policy that prevents premature deletion or modification.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN04 Objects have an Effective Retention Policy by Default", + "SubSection": "", + "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + }, + { + "ReferenceId": "CCC.ObjStor", + "Identifiers": [ + "CCC.ObjStor.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_sufficient_retention_period", + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "CCC.ObjStor.CN04.AR02", + "Description": "When an attempt is made to delete or modify an object that is subject to an active retention policy, the service MUST prevent the action from being completed.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN04 Objects have an Effective Retention Policy by Default", + "SubSection": "", + "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + }, + { + "ReferenceId": "CCC.ObjStor", + "Identifiers": [ + "CCC.ObjStor.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR01", + "Description": "When an object is uploaded to the object storage bucket, the object MUST be stored with a unique identifier.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR02", + "Description": "When an object is modified, the service MUST assign a new unique identifier to the modified object to differentiate it from the previous version.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR03", + "Description": "When an object is modified, the service MUST allow for recovery of previous versions of the object.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN05.AR04", + "Description": "When an object is deleted, the service MUST retain other versions of the object to allow for recovery of previous versions.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN05 Versioning is Enabled for All Objects in the Bucket", + "SubSection": "", + "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "DSP-17" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "CCC.ObjStor.CN07.AR01", + "Description": "The object storage service MUST support a configuration option that requires MFA to be successfully completed before any object deletion can be attempted, regardless of the request interface.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.ObjStor.CN07.AR02", + "Description": "When MFA deletion protection is enabled on a bucket or object namespace, the service MUST deny any deletion request from an identity that has not satisfied the MFA requirement at the time of the request.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.ObjStor.CN07.AR03", + "Description": "When an attempt is made to delete an object, the service's audit logs MUST clearly record each deletion attempt, including whether MFA was required and whether validation was met.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + "Section": "CCC.ObjStor.CN07 Multi-Factor Authentication Is Required for Object Deletion", + "SubSection": "", + "SubSectionObjective": "Ensure that deletion of objects stored in the object storage system is protected by multi-factor authentication (MFA), reducing the risk of accidental, unauthorized, or compromised-credential–based data destruction.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01", + "CCC.Core.TH06", + "CCC.Core.TH17" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "DSP-16", + "IAM-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.ObjStor.CN02.AR01", + "Description": "When a permission set is allowed for an object in a bucket, the service MUST allow the same permission set to access all objects in the same bucket.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources.", + "Section": "CCC.ObjStor.CN02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", + "SubSection": "", + "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_uniform_bucket_level_access" + ] + }, + { + "Id": "CCC.ObjStor.CN02.AR02", + "Description": "When a permission set is denied for an object in a bucket, the service MUST deny the same permission set to access all objects in the same bucket.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources.", + "Section": "CCC.ObjStor.CN02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", + "SubSection": "", + "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + } + ] + } + ], + "Checks": [ + "cloudstorage_bucket_uniform_bucket_level_access" + ] + }, + { + "Id": "CCC.LB.CN01.AR01", + "Description": "When a single client sends more than 2000 requests within any 5-minute sliding window, the load balancer MUST throttle all subsequent requests from that client for at least 60 seconds.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", + "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", + "SubSection": "", + "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Implement per-IP token-bucket limits with and verify via synthetic traffic tests.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH01", + "CCC.LB.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-1", + "PR.AC-7", + "PR.PT-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-6", + "SC-5", + "AC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.LB.CN01.AR02", + "Description": "When throttling is invoked, the load balancer MUST record the event in the access log within 5 minutes for alerting and trend analysis.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", + "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", + "SubSection": "", + "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Enable access logging and configure metric filters on HTTP 429 counts to trigger alerts.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH01", + "CCC.LB.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-1", + "PR.AC-7", + "PR.PT-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-6", + "SC-5", + "AC-7" + ] + } + ] + } + ], + "Checks": [ + "compute_loadbalancer_logging_enabled" + ] + }, + { + "Id": "CCC.LB.CN06.AR01", + "Description": "When more than 10 percent of targets change from healthy to unhealthy within five minutes, an alert MUST be issued.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity.", + "Section": "CCC.LB.CN06 Secure Health-Check Telemetry", + "SubSection": "", + "SubSectionObjective": "Monitor health-check endpoints for tampering and alert on abnormal status changes.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Instrument metrics for health check results and target removal events. Configure monitoring alarms to alert on abnormal spikes in unhealthy targets.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.AE-2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4" + ] + } + ] + } + ], + "Checks": [ + "compute_loadbalancer_logging_enabled" + ] + }, + { + "Id": "CCC.LB.CN04.AR01", + "Description": "When routing weights change, the request MUST originate from an explicitly defined and trusted identity and MUST be logged.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", + "Section": "CCC.LB.CN04 Enforce Distribution Policies", + "SubSection": "", + "SubSectionObjective": "Ensure traffic-splitting weights and algorithms are modified only by trusted identities.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Define a list of trusted principals allowed to modify routing configurations. Enforce via conditional access policies, and log changes using audit logging.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3" + ] + } + ] + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "compute_loadbalancer_logging_enabled" + ] + }, + { + "Id": "CCC.LB.CN05.AR01", + "Description": "When stickiness is enabled, session cookies MUST expire within 30 minutes of inactivity.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", + "Section": "CCC.LB.CN05 Validate Session Affinity", + "SubSection": "", + "SubSectionObjective": "Configure session persistence to minimise fixation and hijacking risks.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Audit CCC.LB.CP15 parameters via configuration scans.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-7" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-23" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.LB.CN09.AR01", + "Description": "When an API call originates outside the approved CIDR set, the request MUST be denied.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can change or query load-balancer resources.", + "Section": "CCC.LB.CN09 Restrict Management API Access", + "SubSection": "", + "SubSectionObjective": "Limit load-balancer API calls to authorised identities and trusted networks.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Combine VPC endpoints with IAM condition-key filters for protected APIs.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH08" ] } ], @@ -743,102 +3910,7 @@ { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-7", - "SC-5" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_public_ip", - "cloudstorage_bucket_public_access", - "cloudsql_instance_public_ip", - "cloudsql_instance_public_access" - ] - }, - { - "Id": "CCC.CntrReg.CN01.AR01", - "Description": "Attempt to push an artifact with known vulnerabilities to the registry and observe if it is flagged or rejected by the vulnerability scanning process.", - "Attributes": [ - { - "FamilyName": "Risk Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CntrReg.CN01 Implement Vulnerability Scanning for Artifacts", - "SubSection": "", - "SubSectionObjective": "Ensure that container images and artifacts stored in the container registry are scanned for vulnerabilities to identify and remediate security issues before deployment.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.CntrReg.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "ID.RA-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "RA-5", - "SI-5" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "CCC.DataWar.CN01.AR01", - "Description": "Attempt to access underlying database tables directly without using managed views and verify that access is denied.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN01 Enforce Use of Managed Views for Data Access", - "SubSection": "", - "SubSectionObjective": "Ensure that data access is provided through managed views, restricting users from accessing underlying tables directly and enforcing consistent security policies.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "AC-6" + "SC-7" ] } ] @@ -847,25 +3919,26 @@ "Checks": [] }, { - "Id": "CCC.DataWar.CN02.AR01", - "Description": "Attempt to query sensitive columns without the necessary permissions and verify that access is denied or data is masked.", + "Id": "CCC.LB.CN02.AR01", + "Description": "When concurrent connections reach 80 percent of capacity, the autoscaling group MUST add at least one instance within five minutes.", "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN02 Enforce Column-Level Security Policies", + "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer.", + "Section": "CCC.LB.CN02 Auto-Scale Load Balancer Capacity", "SubSection": "", - "SubSectionObjective": "Ensure that access to sensitive data columns is restricted based on user roles, preventing unauthorized access to sensitive information.", + "SubSectionObjective": "Expand load-balancer capacity to maintain availability during traffic spikes.", "Applicability": [ - "tlp-red", - "tlp-amber" + "tlp-green", + "tlp-amber", + "tlp-red" ], - "Recommendation": "", + "Recommendation": "Enable autoscaling policies.", "SectionThreatMappings": [ { - "ReferenceId": "CCC", + "ReferenceId": "LB", "Identifiers": [ - "CCC.TH01" + "CCC.LB.TH09" ] } ], @@ -873,14 +3946,13 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-4" + "ID.BE-5" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-3", - "AC-6" + "CP-10" ] } ] @@ -889,25 +3961,26 @@ "Checks": [] }, { - "Id": "CCC.DataWar.CN03.AR01", - "Description": "Attempt to query data rows that the user should not have access to and verify that access is denied or data is not returned.", + "Id": "CCC.LB.CN07.AR01", + "Description": "When responses pass through the load balancer, the \"Server\" header MUST be replaced with \"lb\".", "Attributes": [ { "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.DataWar.CN03 Enforce Row-Level Security Policies", + "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer.", + "Section": "CCC.LB.CN07 Scrub Sensitive Headers", "SubSection": "", - "SubSectionObjective": "Ensure that access to data rows is restricted based on user roles or attributes, preventing unauthorized access to specific subsets of data.", + "SubSectionObjective": "Remove headers that disclose internal details or software versions from HTTP responses.", "Applicability": [ - "tlp-red", - "tlp-amber" + "tlp-green", + "tlp-amber", + "tlp-red" ], - "Recommendation": "", + "Recommendation": "Configure header-transformation rules.", "SectionThreatMappings": [ { - "ReferenceId": "CCC", + "ReferenceId": "LB", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH15" ] } ], @@ -915,50 +3988,286 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-4" + "PR.DS-2" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-3", - "AC-6" + "SC-13" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.LB.CN08.AR01", + "Description": "When a certificate is within 30 days of expiry, automated renewal MUST complete and deploy a new certificate within 24 hours.", + "Attributes": [ + { + "FamilyName": "Encryption", + "FamilyDescription": "Controls that ensure trustworthy TLS certificates and ciphers.", + "Section": "CCC.LB.CN08 Automate Certificate Renewal", + "SubSection": "", + "SubSectionObjective": "Maintain valid TLS certificates by automating renewal and deployment before expiry.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "Use certificate-manager auto-renewal workflows.", + "SectionThreatMappings": [ + { + "ReferenceId": "LB", + "Identifiers": [ + "CCC.LB.TH07" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.DS-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-17" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.VPC.CN01.AR01", + "Description": "When a subscription is created, the subscription MUST NOT contain default network resources.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.VPC.CN01 Restrict Default Network Creation", + "SubSection": "", + "SubSectionObjective": "Restrict the automatic creation of default virtual networks and related resources during subscription initialization to avoid insecure default configurations and enforce custom network policies.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.VPC.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-5" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "TVM-02" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.12.3.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7" + ] + } + ] + } + ], + "Checks": [ + "compute_network_default_in_use" + ] + }, + { + "Id": "CCC.VPC.CN02.AR01", + "Description": "When a resource is created in a public subnet, that resource MUST NOT be assigned an external IP address by default.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.VPC.CN02 Limit Resource Creation in Public Subnet", + "SubSection": "", + "SubSectionObjective": "Restrict the creation of resources in the public subnet with direct access to the internet to minimize attack surfaces.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-4" ] } ] } ], "Checks": [ - "iam_no_service_roles_at_project_level", - "iam_sa_no_administrative_privileges", - "iam_role_sa_enforce_separation_of_duties", - "iam_role_kms_enforce_separation_of_duties", - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "cloudsql_instance_postgres_enable_pgaudit_flag", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_min_messages_flag", - "cloudsql_instance_postgres_log_min_duration_statement_flag", - "cloudsql_instance_postgres_log_error_verbosity_flag", - "cloudsql_instance_postgres_log_min_error_statement_flag", - "cloudsql_instance_public_ip", - "cloudsql_instance_private_ip_assignment", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", "compute_instance_public_ip" ] }, + { + "Id": "CCC.VPC.CN03.AR01", + "Description": "When a VPC peering connection is requested, the service MUST prevent connections from VPCs that are not explicitly allowed.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.VPC.CN03 Restrict VPC Peering to Authorized Accounts", + "SubSection": "", + "SubSectionObjective": "Ensure VPC peering connections are only established with explicitly authorized destinations to limit network exposure and enforce boundary controls.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.VPC.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IVS-01" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.3" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-4" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.VPC.CN04.AR01", + "Description": "When any network traffic goes to or from an interface in the VPC, the service MUST capture and log all relevant information.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.VPC.CN04 Enforce VPC Flow Logs on VPCs", + "SubSection": "", + "SubSectionObjective": "Ensure VPCs are configured with flow logs enabled to capture traffic information.", + "Applicability": [ + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.VPC.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.PT-1" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.12.4.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AU-2" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IVS-06" + ] + } + ] + } + ], + "Checks": [ + "compute_subnet_flow_logs_enabled" + ] + }, { "Id": "CCC.KeyMgmt.CN01.AR01", "Description": "When a key version is scheduled for deletion or disabled, an alert MUST be generated within five minutes.", "Attributes": [ { - "FamilyName": "Logging and Metrics Publication", + "FamilyName": "Logging and Monitoring", "FamilyDescription": "Controls that collect, alert, and retain key-management events.", "Section": "CCC.KeyMgmt.CN01 Alert on Key-version Changes", "SubSection": "", @@ -1117,429 +4426,29 @@ ] } ], - "Checks": [ - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "CCC.LB.CN01.AR01", - "Description": "When a single client sends more than 2000 requests within any 5-minute sliding window, the load balancer MUST throttle all subsequent requests from that client for at least 60 seconds.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", - "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", - "SubSection": "", - "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement per-IP token-bucket limits with and verify via synthetic traffic tests. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH01", - "CCC.LB.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-1", - "PR.AC-7", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6", - "SC-5", - "AC-7" - ] - } - ] - } - ], - "Checks": [ - "compute_loadbalancer_logging_enabled", - "compute_subnet_flow_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "CCC.LB.CN01.AR02", - "Description": "When throttling is invoked, the load balancer MUST record the event in the access log within 5 minutes for alerting and trend analysis.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", - "Section": "CCC.LB.CN01 Enforce and Detect Rate Limiting", - "SubSection": "", - "SubSectionObjective": "Detect and throttle malicious or excessive requests to prevent downstream resource exhaustion and brute-force activity.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Enable access logging and configure metric filters on HTTP 429 counts to trigger alerts. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH01", - "CCC.LB.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-1", - "PR.AC-7", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6", - "SC-5", - "AC-7" - ] - } - ] - } - ], - "Checks": [ - "compute_loadbalancer_logging_enabled", - "logging_sink_created" - ] - }, - { - "Id": "CCC.LB.CN06.AR01", - "Description": "When more than 10 percent of targets change from healthy to unhealthy within five minutes, an alert MUST be issued.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that detect anomalous traffic and record load-balancer activity. ", - "Section": "CCC.LB.CN06 Secure Health-Check Telemetry", - "SubSection": "", - "SubSectionObjective": "Monitor health-check endpoints for tampering and alert on abnormal status changes.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Instrument metrics for health check results and target removal events. Configure monitoring alarms to alert on abnormal spikes in unhealthy targets. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4" - ] - } - ] - } - ], - "Checks": [ - "compute_loadbalancer_logging_enabled", - "logging_sink_created", - "compute_subnet_flow_logs_enabled" - ] - }, - { - "Id": "CCC.LB.CN04.AR01", - "Description": "When routing weights change, the request MUST originate from an explicitly defined and trusted identity and MUST be logged.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", - "Section": "CCC.LB.CN04 Enforce Distribution Policies", - "SubSection": "", - "SubSectionObjective": "Ensure traffic-splitting weights and algorithms are modified only by trusted identities.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Define a list of trusted principals allowed to modify routing configurations. Enforce via conditional access policies, and log changes using audit logging. ", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH03" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "compute_loadbalancer_logging_enabled", - "logging_sink_created", - "iam_no_service_roles_at_project_level", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "CCC.LB.CN05.AR01", - "Description": "When stickiness is enabled, session cookies MUST expire within 30 minutes of inactivity.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", - "Section": "CCC.LB.CN05 Validate Session Affinity", - "SubSection": "", - "SubSectionObjective": "Configure session persistence to minimise fixation and hijacking risks.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Audit CCC.LB.F15 parameters via configuration scans.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-23" - ] - } - ] - } - ], - "Checks": [ - "iam_no_service_roles_at_project_level", - "iam_sa_no_administrative_privileges", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC.LB.CN09.AR01", - "Description": "When an API call originates outside the approved CIDR set, the request MUST be denied.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can change or query load-balancer resources. ", - "Section": "CCC.LB.CN09 Restrict Management API Access", - "SubSection": "", - "SubSectionObjective": "Limit load-balancer API calls to authorised identities and trusted networks.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Combine VPC endpoints with IAM condition-key filters for protected APIs.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH08" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_no_service_roles_at_project_level", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "CCC.LB.CN02.AR01", - "Description": "When concurrent connections reach 80 percent of capacity, the autoscaling group MUST add at least one instance within five minutes.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer. ", - "Section": "CCC.LB.CN02 Auto-Scale Load Balancer Capacity", - "SubSection": "", - "SubSectionObjective": "Expand load-balancer capacity to maintain availability during traffic spikes.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Enable autoscaling policies.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "CP-10" - ] - } - ] - } - ], "Checks": [] }, { - "Id": "CCC.LB.CN07.AR01", - "Description": "When responses pass through the load balancer, the \"Server\" header MUST be replaced with \"lb\".", + "Id": "CCC.SecMgmt.CN01.AR01", + "Description": "Attempt to use an outdated version of a secret after its rotation period has passed and verify that access is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "Controls that preserve availability and confidentiality of traffic processed by the load balancer. ", - "Section": "CCC.LB.CN07 Scrub Sensitive Headers", + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SecMgmt.CN01 Enforce Automatic Secret Rotation", "SubSection": "", - "SubSectionObjective": "Remove headers that disclose internal details or software versions from HTTP responses.", + "SubSectionObjective": "Ensure that secrets are automatically rotated on a defined schedule to reduce the risk of secret compromise and unauthorized access.", "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], - "Recommendation": "Configure header-transformation rules.", + "Recommendation": "", "SectionThreatMappings": [ { - "ReferenceId": "LB", + "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-13" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.LB.CN08.AR01", - "Description": "When a certificate is within 30 days of expiry, automated renewal MUST complete and deploy a new certificate within 24 hours.", - "Attributes": [ - { - "FamilyName": "Encryption", - "FamilyDescription": "Controls that ensure trustworthy TLS certificates and ciphers.", - "Section": "CCC.LB.CN08 Automate Certificate Renewal", - "SubSection": "", - "SubSectionObjective": "Maintain valid TLS certificates by automating renewal and deployment before expiry.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Use certificate-manager auto-renewal workflows.", - "SectionThreatMappings": [ - { - "ReferenceId": "LB", - "Identifiers": [ - "CCC.LB.TH07" + "CCC.Core.TH01", + "CCC.Core.TH14" ] } ], @@ -1553,7 +4462,8 @@ { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-17" + "SC-12", + "SC-28" ] } ] @@ -1562,214 +4472,26 @@ "Checks": [] }, { - "Id": "CCC.Logging.CN01.AR01", - "Description": "When a new cloud account is created, provider-level audit and network flow logging MUST be enabled by default and directed to the central sink.", + "Id": "CCC.SecMgmt.CN02.AR01", + "Description": "Attempt to retrieve a secret from an unauthorized region and verify that access is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SecMgmt.CN02 Enforce Secret Replication Policies", "SubSection": "", - "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH07" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "compute_subnet_flow_logs_enabled", - "logging_sink_created" - ] - }, - { - "Id": "CCC.Logging.CN01.AR02", - "Description": "When a new cloud compute resource is deployed, it MUST be configured to forward all relevant logs (e.g., OS, application, service logs) to the central log sink.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN01 Centralized and Comprehensive Log Aggregation", - "SubSection": "", - "SubSectionObjective": "Ensure all operational and security logs from across the cloud environment, including applications, operating systems, network traffic, and cloud service activity, are captured automatically and streamed to a central, secure log management service.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH07" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3" - ] - } - ] - } - ], - "Checks": [ - "logging_sink_created", - "cloudstorage_bucket_log_retention_policy_lock", - "iam_audit_logs_enabled", - "compute_subnet_flow_logs_enabled", - "compute_network_dns_logging_enabled", - "compute_loadbalancer_logging_enabled" - ] - }, - { - "Id": "CCC.Logging.CN02.AR01", - "Description": "When a new log bucket or stream is created, its retention policy MUST be configured in accordance with organisation's data retention policy.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", - "SubSection": "", - "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "GV.PO-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-11" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.Logging.CN02.AR02", - "Description": "When a query is performed to retrieve log events older than the number of days defined in the organisation's data retention policy, it MUST return an empty result.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN02 Enforce Data Retention Policy for Logs", - "SubSection": "", - "SubSectionObjective": "Ensure that the retention period configured for logs aligns with the organization's data retention policy.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "GV.PO-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-11" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.AuditLog.CN08.AR01", - "Description": "When an attempt is made to modify or delete data before the object lock period expires, then the action MUST be denied.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "Controls related to the confidentiality, integrity and availability of log data. ", - "Section": "CCC.Logging.CN03 Enable Object Lock On Log Bucket", - "SubSection": "", - "SubSectionObjective": "Ensure log immutability by enabling Write Once, Read Many (WORM) protection using object lock on log storage buckets. This prevents logs from being modified or deleted during the defined retention period, supporting compliance and forensic integrity.", + "SubSectionObjective": "Ensure that secrets are replicated only to authorized locations as per organizational data residency and compliance requirements.", "Applicability": [ "tlp-red", "tlp-amber" ], - "Recommendation": "Configure object lock policy. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07" + "CCC.Core.TH03", + "CCC.Core.TH04" ] } ], @@ -1777,98 +4499,7 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-9", - "AU-11" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.AuditLog.CN04.AR01", - "Description": "When restricted fields are accessed by unauthorized users, then those fields MUST remain masked.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", - "Section": "CCC.Logging.CN04 Restrict Field And Log Type Access", - "SubSection": "", - "SubSectionObjective": "Configure access to logs to follow the principle of least privilege in particular where technically possible limit the log fields users have access to to prevent accidental exposure to sensitive information such as PII.", - "Applicability": [ - "tlp-red", - "tlp-amber" - ], - "Recommendation": "Review field level access controls on log data. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Logging.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-6", - "AU-9", - "AC-3", - "PT-2", - "PT-3", - "PT-3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.Logging.CN05.AR01", - "Description": "When a log storage bucket is created, the bucket's access control settings MUST explicitly deny public read and write access.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", - "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", - "SubSection": "", - "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", - "Applicability": [ - "tlp-red", - "tlp-amber", - "tlp-green" - ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AA-05" + "PR.DS-5" ] }, { @@ -1881,946 +4512,28 @@ ] } ], - "Checks": [ - "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_bucket_level_access", - "cloudstorage_bucket_log_retention_policy_lock", - "logging_sink_created" - ] + "Checks": [] }, { - "Id": "CCC.Logging.CN05.AR02", - "Description": "When the URL of a log storage bucket's object is accessed publicly, the action MUST be denied by bucket policy.", + "Id": "CCC.DataWar.CN01.AR01", + "Description": "Attempt to access underlying database tables directly without using managed views and verify that access is denied.", "Attributes": [ { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls that restrict who can access and modify logs. ", - "Section": "CCC.Logging.CN05 Ensure Log Bucket is Not Publicly Accessible", + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN01 Enforce Use of Managed Views for Data Access", "SubSection": "", - "SubSectionObjective": "Ensure that log storage buckets are not publicly accessible to prevent unauthorized access to sensitive log data. In addition, logs should be replicated to another cloud region to enhance availability, durability, and support disaster recovery requirements.", + "SubSectionObjective": "Ensure that data access is provided through managed views, restricting users from accessing underlying tables directly and enforcing consistent security policies.", "Applicability": [ "tlp-red", - "tlp-amber", - "tlp-green" - ], - "Recommendation": "Configure bucket policies and access control lists (ACLs) to restrict public access. Regularly review bucket permissions to ensure no public access has been inadvertently granted. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3", - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_bucket_level_access" - ] - }, - { - "Id": "CCC.Logging.CN06.AR01", - "Description": "When a single principal executes an anomalously high number of log queries, an alert MUST be generated.", - "Attributes": [ - { - "FamilyName": "Logging and Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain logging-related events. ", - "Section": "CCC.Logging.CN06 Detect and Alert on Potential Log Exfiltration", - "SubSection": "", - "SubSectionObjective": "Identify and alert on anomalous data access patterns that may indicate an attempt to exfiltrate log data.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-amber" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.Logging.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-03", - "DE.CM-09" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4", - "CA-7", - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_sink_created" - ] - }, - { - "Id": "CCC.Logging.CN07.AR01", - "Description": "When an audit log event is recorded that corresponds to a modification of the logging service configuration such as disabling a log trail, deleting a log sink, or altering a log forwarding rule, an alert MUST be generated.", - "Attributes": [ - { - "FamilyName": "Logging and Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain logging-related events. ", - "Section": "CCC.Logging.CN07 Detect and Alert on Log Service Tampering", - "SubSection": "", - "SubSectionObjective": "Alert when any component of the critical logging infrastructure is disabled, modified, or deleted, indicating a defense evasion attempt.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-03", - "DE.CM-09" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4", - "CA-7", - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR01", - "Description": "When a request is made to read a protected bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR02", - "Description": "When a request is made to read a protected object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.ObjStor.CN01.AR03", - "Description": "When a request is made to write to a bucket, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "CCC.ObjStor.CN01.AR04", - "Description": "When a request is made to write to an object, the service MUST prevent any request using KMS keys not listed as trusted by the organization.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN01 Prevent Unencrypted Requests", - "SubSection": "CCC.ObjStor.C01 Prevent Requests to Buckets or Objects with Untrusted KMS Keys", - "SubSectionObjective": "Prevent any requests to object storage buckets or objects using untrusted KMS keys to protect against unauthorized data encryption that can impact data availability and integrity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01", - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-04", - "DCS-06" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled", - "dataproc_encrypted_with_cmks_disabled", - "compute_instance_encryption_with_csek_enabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption" - ] - }, - { - "Id": "CCC.ObjStor.CN03.AR01", - "Description": "When an object storage bucket deletion is attempted, the bucket MUST be fully recoverable for a set time-frame after deletion is requested.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "CCC.ObjStor.C03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", - "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.ObjStor.CN03.AR02", - "Description": "When an attempt is made to modify the retention policy for an object storage bucket, the service MUST prevent the policy from being modified.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "CCC.ObjStor.C03 Prevent Bucket Deletion Through Irrevocable Bucket Retention Policy", - "SubSectionObjective": "Ensure that object storage bucket is not deleted after creation, and that the preventative measure cannot be unset.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.ObjStor.CN04.AR01", - "Description": "When an object is uploaded to the object storage system, the object MUST automatically receive a default retention policy that prevents premature deletion or modification.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN04 Log All Access and Changes", - "SubSection": "CCC.ObjStor.C04 Objects have an Effective Retention Policy by Default", - "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects and ensuring compliance with data retention regulations.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.ObjStor.CN04.AR02", - "Description": "When an attempt is made to delete or modify an object that is subject to an active retention policy, the service MUST prevent the action from being completed.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN04 Log All Access and Changes", - "SubSection": "CCC.ObjStor.C04 Objects have an Effective Retention Policy by Default", - "SubSectionObjective": "Ensure that all objects stored in the object storage system have a retention policy applied by default, preventing premature deletion or modification of objects and ensuring compliance with data retention regulations.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.ObjStor.CN05.AR01", - "Description": "When an object is uploaded to the object storage bucket, the object MUST be stored with a unique identifier.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.ObjStor.CN05.AR02", - "Description": "When an object is modified, the service MUST assign a new unique identifier to the modified object to differentiate it from the previous version.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.ObjStor.CN05.AR03", - "Description": "When an object is modified, the service MUST allow for recovery of previous versions of the object.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.ObjStor.CN05.AR04", - "Description": "When an object is deleted, the service MUST retain other versions of the object to allow for recovery of previous versions.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN05 Prevent Access from Untrusted Entities", - "SubSection": "CCC.ObjStor.C05 Versioning is Enabled for All Objects in the Bucket", - "SubSectionObjective": "Ensure that versioning is enabled for all objects stored in the object storage bucket to enable recovery of previous versions of objects in case of loss or corruption.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.1.4" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-28", - "CP-10" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-16" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.ObjStor.CN06.AR01", - "Description": "When an object storage bucket is accessed, the service MUST store access logs in a separate data store.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN06 Prevent Deployment in Restricted Regions", - "SubSection": "CCC.ObjStor.C06 Access Logs are Stored in a Separate Data Store", - "SubSectionObjective": "Ensure that access logs for object storage buckets are stored in a separate data store to protect against unauthorized access, tampering, or deletion of logs (Logbuckets are exempt from this requirement, but must be tlp-red).", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH07", - "CCC.TH09" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-6" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-07", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2022 A.8.15.0" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-9", - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "logging_sink_created" - ] - }, - { - "Id": "CCC.ObjStor.CN02.AR01", - "Description": "When a permission set is allowed for an object in a bucket, the service MUST allow the same permission set to access all objects in the same bucket.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN02 Ensure Data Encryption at Rest for All Stored Data", - "SubSection": "CCC.ObjStor.C02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", - "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -2831,54 +4544,38 @@ "PR.AC-4" ] }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.1" - ] - }, { "ReferenceId": "NIST_800_53", "Identifiers": [ "AC-3", "AC-6" ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-09" - ] } ] } ], - "Checks": [ - "cloudstorage_bucket_uniform_bucket_level_access" - ] + "Checks": [] }, { - "Id": "CCC.ObjStor.CN02.AR02", - "Description": "When a permission set is denied for an object in a bucket, the service MUST deny the same permission set to access all objects in the same bucket.", + "Id": "CCC.DataWar.CN02.AR01", + "Description": "Attempt to query sensitive columns without the necessary permissions and verify that access is denied or data is masked.", "Attributes": [ { - "FamilyName": "Identity and Access Management", + "FamilyName": "Data", "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.CN02 Ensure Data Encryption at Rest for All Stored Data", - "SubSection": "CCC.ObjStor.C02 Enforce Uniform Bucket-level Access to Prevent Inconsistent Permissions", - "SubSectionObjective": "Ensure that uniform bucket-level access is enforced across all object storage buckets. This prevents the use of ad-hoc or inconsistent object-level permissions, ensuring centralized, consistent, and secure access management in accordance with the principle of least privilege.", + "Section": "CCC.DataWar.CN02 Enforce Column-Level Security Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that access to sensitive data columns is restricted based on user roles, preventing unauthorized access to sensitive information.", "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -2890,9 +4587,45 @@ ] }, { - "ReferenceId": "ISO_27001", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "2013 A.9.4.1" + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.DataWar.CN03.AR01", + "Description": "Attempt to query data rows that the user should not have access to and verify that access is denied or data is not returned.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.DataWar.CN03 Enforce Row-Level Security Policies", + "SubSection": "", + "SubSectionObjective": "Ensure that access to data rows is restricted based on user roles or attributes, preventing unauthorized access to specific subsets of data.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" ] }, { @@ -2901,502 +4634,12 @@ "AC-3", "AC-6" ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DCS-09" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_uniform_bucket_level_access" - ] - }, - { - "Id": "CCC.Monitor.CN01.AR01", - "Description": "When an External Monitoring system exceeds the anticipated rate of monitoring checks then Rate Limiting MUST be applied and an Audit Alert MUST be generated.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", - "Section": "CCC.Monitor.CN01 Rate Limiting on External Monitoring", - "SubSection": "", - "SubSectionObjective": "Prevent DoS attacks using External Monitoring tools.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH03" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.IR-01", - "DE.CM-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-5", - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created", - "cloudstorage_bucket_log_retention_policy_lock", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC.Monitor.CN02.AR01", - "Description": "When an Custom or User-Defined Metric starts to flood a collector, then a rate limit MUST be applied to reduce the network impact of traffic and an alert must triggered.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "Controls that collect, alert, and retain events from other monitoring services.", - "Section": "CCC.Monitor.CN02 Rate Limiting on Metric Generation", - "SubSection": "", - "SubSectionObjective": "Prevent Malicious Actor or misconfiguration from flooding services with metric data.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-01" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-5(2)", - "CA-7", - "SI-4" - ] - } - ] - } - ], - "Checks": [ - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "iam_audit_logs_enabled", - "compute_loadbalancer_logging_enabled", - "compute_network_dns_logging_enabled", - "compute_subnet_flow_logs_enabled" - ] - }, - { - "Id": "CCC.Monitor.CN03.AR01", - "Description": "When external systems have approved access to internal systems not normally available for public access then they MUST be secured to prevent unauthorised access jumping through to the internal systems and only allow access to specific internal services.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN03 Access External Monitoring", - "SubSection": "", - "SubSectionObjective": "Control access to Synthetic monitoring solutions using API keys or Certificate based authentication to ensure they don't become an attack path, preventing monitoring systems from forging network requests to gain access to internal systems.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-06", - "PR.IR-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "apikeys_key_exists", - "apikeys_key_rotated_in_90_days" - ] - }, - { - "Id": "CCC.Monitor.CN04.AR01", - "Description": "When monitoring dashboards display degraded services which may become potential targets then the dashboard MUST be protected from unauthorised access.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN04 Restrict access to Monitoring Dashboards", - "SubSection": "", - "SubSectionObjective": "Control access to Monitoring Dashboards and reports to ensure they don't highlight an attack path.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.CM-09", - "DE.AE-03" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SI-4", - "AC-3" - ] } ] } ], "Checks": [] }, - { - "Id": "CCC.Monitor.CN05.AR01", - "Description": "When monitoring services have generated an alert, the service MUST ensure only authorised responders silence or acknowledge the alert.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN05 Restrict access to silence or acknowledge an alert", - "SubSection": "", - "SubSectionObjective": "Ensure only a subset of users can silence or acknowledge alerts to prevent attackers hiding their activity.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH10" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.IR-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_sink_created", - "iam_organization_essential_contacts_configured" - ] - }, - { - "Id": "CCC.Monitor.CN06.AR01", - "Description": "When systems push metrics or traces they MUST be authenticated for that particular type of metric or trace", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "Controls designed to prevent unauthorised access to monitoring features.", - "Section": "CCC.Monitor.CN06 Metrics pushed for authorised services only", - "SubSection": "", - "SubSectionObjective": "Use IAM to control which types of metrics or traces can be pushed by different system to avoid a compromised system pushing fabricated metrics about a different service", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.Monitor.TH05" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-5" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account", - "iam_no_service_roles_at_project_level", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "CCC.VPC.CN01.AR01", - "Description": "When a subscription is created, the subscription MUST NOT contain default network resources.", - "Attributes": [ - { - "FamilyName": "Network Security", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.VPC.CN01 Restrict Default Network Creation", - "SubSection": "", - "SubSectionObjective": "Restrict the automatic creation of default virtual networks and related resources during subscription initialization to avoid insecure default configurations and enforce custom network policies.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.VPC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-5" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.12.3.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-7" - ] - } - ] - } - ], - "Checks": [ - "compute_network_default_in_use" - ] - }, - { - "Id": "CCC.VPC.CN03.AR01", - "Description": "When a VPC peering connection is requested, the service MUST prevent connections from VPCs that are not explicitly allowed.", - "Attributes": [ - { - "FamilyName": "Network Security", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.VPC.CN03 Restrict VPC Peering to Authorized Accounts", - "SubSection": "", - "SubSectionObjective": "Ensure VPC peering connections are only established with explicitly authorized destinations to limit network exposure and enforce boundary controls.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.VPC.TH03" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-4" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC.VPC.CN04.AR01", - "Description": "When any network traffic goes to or from an interface in the VPC, the service MUST capture and log all relevant information.", - "Attributes": [ - { - "FamilyName": "Network Security", - "FamilyDescription": "TODO: Describe this control family", - "Section": "CCC.VPC.CN04 Enforce VPC Flow Logs on VPCs", - "SubSection": "", - "SubSectionObjective": "Ensure VPCs are configured with flow logs enabled to capture traffic information.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.VPC.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PT-1" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.12.4.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IVS-06" - ] - } - ] - } - ], - "Checks": [ - "compute_subnet_flow_logs_enabled" - ] - }, { "Id": "CCC.Vector.CN01.AR01", "Description": "When a vector embedding is submitted for indexing, the system MUST validate that it matches expected schema, dimension, and format profiles.", @@ -3420,7 +4663,7 @@ "Identifiers": [ "CCC.Vector.TH02", "CCC.Vector.TH05", - "CCC.TH12" + "CCC.Core.TH12" ] } ], @@ -3459,7 +4702,7 @@ "Identifiers": [ "CCC.Vector.TH02", "CCC.Vector.TH04", - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -3474,11 +4717,7 @@ } ], "Checks": [ - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "iam_no_service_roles_at_project_level", - "kms_key_not_publicly_accessible" + "iam_sa_no_administrative_privileges" ] }, { @@ -3501,7 +4740,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH03", - "CCC.TH01" + "CCC.Core.TH01" ] } ], @@ -3540,7 +4779,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH02", - "CCC.TH12" + "CCC.Core.TH12" ] } ], @@ -3576,8 +4815,8 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH04", - "CCC.TH09", - "CCC.TH04" + "CCC.Core.TH09", + "CCC.Core.TH04" ] } ], @@ -3591,18 +4830,7 @@ ] } ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled" - ] + "Checks": [] }, { "Id": "CCC.Vector.CN06.AR01", @@ -3626,7 +4854,7 @@ "ReferenceId": "CCC", "Identifiers": [ "CCC.Vector.TH05", - "CCC.TH06" + "CCC.Core.TH06" ] } ], @@ -3671,422 +4899,25 @@ "Checks": [] }, { - "Id": "CCC.Core.CN01.AR01", - "Description": "When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + "Id": "CCC.RDMS.CN01.AR02", + "Description": "When an attempt is made to authenticate to the database using known default credentials, the authentication attempt must fail and no access should be granted.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN01 Password Management", "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Most cloud services enable TLS 1.3 by default. Where it is not already set, ensure that your services are configured or updated accordingly. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "CCC.Core.CN01.AR02", - "Description": "When a port is exposed for SSH network traffic, all traffic MUST include a SSH handshake AND be encrypted using SSHv2 or higher.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Any time port 22 is exposed, ensure that it has a properly implemented SSH server with SSHv2 enabled and configured with strong ciphers. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_ssh_access_from_the_internet_allowed" - ] - }, - { - "Id": "CCC.Core.CN01.AR03", - "Description": "When the service receives unencrypted traffic, then it MUST either block the request or automatically redirect it to the secure equivalent.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Review firewall, load balancer, and application configurations to ensure insecure protocols such as HTTP, FTP, and Telnet are not exposed. Where possible, implement automatic redirection to secure protocols such as HTTPS, SFTP, SSH, and regularly scan for protocol drift. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_public_ip", - "cloudstorage_bucket_public_access" - ] - }, - { - "Id": "CCC.Core.CN01.AR07", - "Description": "When a port is exposed, the service MUST ensure that the protocol and service officially assigned to that port number by the IANA Service Name and Transport Protocol Port Number Registry, and no other, is run on that port.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Reference the IANA Service Name and Transport Protocol Port Number Registry for more information about correct protocol-to-port assignments. Avoid running non-standard services on well-known ports. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed" - ] - }, - { - "Id": "CCC.Core.CN01.AR08", - "Description": "When a service transmits data using TLS, mutual TLS (mTLS) MUST be implemented to require both client and server certificate authentication for all connections.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN01 Encrypt Data for Transmission", - "SubSection": "", - "SubSectionObjective": "Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Configure mTLS for all endpoints that process or transmit sensitive data. Ensure both client and server certificates are validated and managed securely. Regularly review certificate authorities and automate certificate rotation where possible. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH02" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-03", - "CEK-04", - "IVS-03", - "IVS-07" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.1" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-8", - "SC-13" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "CCC.Core.CN13.AR01", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST only use valid, unexpired certificates issued by a trusted certificate authority.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [] - }, - { - "Id": "CCC.Core.CN13.AR02", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 180 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", + "SubSectionObjective": "Ensure default vendor-supplied DB administrator credentials are replaced with strong, unique passwords and that these credentials are properly managed using a secure password or secrets management solution.", "Applicability": [ + "tlp-red", "tlp-amber" ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [] - }, - { - "Id": "CCC.Core.CN13.AR03", - "Description": "When a port is exposed that uses certificate-based encryption, the service MUST rotate active certificates within 90 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN13 Minimize Lifetime of Encryption and Authentication Certificates", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption and authentication certificates have a limited lifetime to reduce the risk of compromise and ensure the use of up-to-date security practices.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "Track certificate expiration dates and automate certificate renewal where possible. Use certificate management tools to ensure only certificates from trusted authorities are deployed. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH18" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "apikeys_key_rotated_in_90_days", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "CCC.Core.CN06.AR01", - "Description": "When the service is running, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", - "SubSection": "", - "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate the service's deployment location is included in this list. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH03" + "CCC.RDMS.TH01" ] } ], @@ -4094,19 +4925,89 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.AA-01" ] }, { - "ReferenceId": "CCM", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "DSP-19" + "AC-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.RDMS.CN02.AR01", + "Description": "When repeated failed login attempts are made in a short timeframe, the account must be locked out or rate-limited to prevent further login attempts.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN02 Account Lockout and Rate-Limiting", + "SubSection": "", + "SubSectionObjective": "Ensure the database enforces lockouts or rate-limiting after a specified number of failed authentication attempts. This prevents brute force or password-guessing attacks from succeeding.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-1" ] }, { - "ReferenceId": "ISO_27001", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "2013 A.11.1.1" + "AC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.RDMS.CN04.AR01", + "Description": "When there is an attempt to perform a backup or restore, then the attempt must fail with an access denied message if credentials or roles that are not explicitly authorized for backup/restore functions.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN04 Access Control for Backup and Restore Operations", + "SubSection": "", + "SubSectionObjective": "Restrict who can initiate, manage, and validate database backup or restore operations through strict role-based or least-privilege access. Prevents accidental or malicious restorations, protecting data integrity and availability.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" ] }, { @@ -4118,30 +5019,30 @@ ] } ], - "Checks": [] + "Checks": [ + "iam_no_service_roles_at_project_level" + ] }, { - "Id": "CCC.Core.CN06.AR02", - "Description": "When a child resource is deployed, its region and availability zone MUST be included in a list of explicitly trusted or approved locations within the trust perimeter.", + "Id": "CCC.RDMS.CN05.AR01", + "Description": "When an attempt is made to share a snapshot with an unauthorized account, the sharing request must be denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN06 Restrict Deployments to Trust Perimeter", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN05 Restrict Snapshot Sharing to Authorized Accounts", "SubSection": "", - "SubSectionObjective": "Ensure that the service and its child resources are only deployed on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "SubSectionObjective": "Ensure database snapshots can only be shared with explicitly authorized accounts, thereby minimizing the risk of data exposure or exfiltration.", "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], - "Recommendation": "Maintain an up-to-date list of trusted and approved regions based on organizational policies. Validate that child resources can only be deployed to locations included in this list. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH03" + "CCC.RDMS.TH05" ] } ], @@ -4149,24 +5050,99 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-19" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.11.1.1" + "PR.DS-10" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ + "AC-4" + ] + } + ] + } + ], + "Checks": [ + "compute_image_not_publicly_shared" + ] + }, + { + "Id": "CCC.RDMS.CN03.AR01", + "Description": "When backups are disabled, paused, or fail to run as scheduled, an alert must be triggered and logged.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.RDMS.CN03 Enforce and Monitor Automated Backups", + "SubSection": "", + "SubSectionObjective": "Ensure database backups are automatically scheduled, actively monitored, and promptly reported if any disruptions occur. This helps maintain data integrity, facilitates disaster recovery, and supports business continuity when a system failure or breach occurs.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.RDMS.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "CP-9" + ] + } + ] + } + ], + "Checks": [ + "cloudsql_instance_automated_backups" + ] + }, + { + "Id": "CCC.Build.CN01.AR01", + "Description": "Attempt to initiate a build using an unauthorized build agent and verify that the build is rejected.", + "Attributes": [ + { + "FamilyName": "Access Control", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN01 Restrict Allowed Build Agents", + "SubSection": "", + "SubSectionObjective": "Ensure that builds are executed only on authorized build agents to maintain control over the build environment and prevent unauthorized code execution.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-3", "AC-6" ] } @@ -4176,80 +5152,25 @@ "Checks": [] }, { - "Id": "CCC.Core.CN08.AR01", - "Description": "When data is created or modified, the data MUST have a complete and recoverable duplicate that is stored in a physically separate data center.", + "Id": "CCC.Build.CN02.AR01", + "Description": "Attempt to trigger a build from an unauthorized external service or repository and verify that the build does not start.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", + "FamilyName": "Access Control", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN02 Restrict Allowed External Services for Build Triggers", "SubSection": "", - "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", + "SubSectionObjective": "Ensure that builds can only be triggered by authorized external services or repositories to prevent unauthorized code execution or tampering.", "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement automated data replication processes to ensure that data is consistently duplicated in another region or availability zone. Regularly test data recovery from the replicated location to ensure integrity and availability. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.PT-5" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "BCR-08", - "BCR-10", - "BCR-11" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "CP-2", - "CP-10" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_automated_backups", - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.Core.CN08.AR02", - "Description": "When data is replicated into a second location, the service MUST be able to accurately represent the replication locations, replication status, and data synchronization status.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN08 Replicate Data to Multiple Locations", - "SubSection": "", - "SubSectionObjective": "Ensure that data is replicated across multiple physical locations to protect against data loss due to hardware failures, natural disasters, or other catastrophic events.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH06" + "CCC.Core.TH01" ] } ], @@ -4257,22 +5178,14 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.PT-5" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "BCR-08", - "BCR-10", - "BCR-11" + "PR.AC-4" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "CP-2", - "CP-10" + "AC-3", + "AC-6" ] } ] @@ -4281,15 +5194,144 @@ "Checks": [] }, { - "Id": "CCC.Core.CN09.AR01", - "Description": "When the service is operational, its logs and any child resource logs MUST NOT be accessible from the resource they record access to.", + "Id": "CCC.Build.CN03.AR01", + "Description": "Attempt to access the build environment from an external network and verify that access is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Build.CN03 Deny External Network Access for Build Environments", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Ensure that build environments do not have external network access to prevent unauthorized external access and data exfiltration.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH02", + "CCC.Core.TH05" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-5" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7", + "SC-5" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.CntrReg.CN01.AR01", + "Description": "Attempt to push an artifact with known vulnerabilities to the registry and observe if it is flagged or rejected by the vulnerability scanning process.", + "Attributes": [ + { + "FamilyName": "Risk Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.CntrReg.CN01 Implement Vulnerability Scanning for Artifacts", + "SubSection": "", + "SubSectionObjective": "Ensure that container images and artifacts stored in the container registry are scanned for vulnerabilities to identify and remediate security issues before deployment.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.CntrReg.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "ID.RA-1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "RA-5", + "SI-5" + ] + } + ] + } + ], + "Checks": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ] + }, + { + "Id": "CCC.CntrReg.CN02.AR01", + "Description": "Confirm that artifacts older than the specified retention period are automatically deleted from the registry.", + "Attributes": [ + { + "FamilyName": "Data Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.CntrReg.CN02 Implement Cleanup Policies for Artifacts", + "SubSection": "", + "SubSectionObjective": "Ensure that unused or outdated artifacts are cleaned up according to defined policies to manage storage effectively and reduce security risks associated with outdated versions.", + "Applicability": [ + "tlp-red", + "tlp-amber" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.Core.TH14" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-12" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN01.AR01", + "Description": "When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating credentials or generating temporary session tokens.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN01 Restrict IAM User Credentials Creation", + "SubSection": "", + "SubSectionObjective": "Prevent non-administrative principals from creating new long-lived credentials like access keys or generating temporary session tokens. This blocks a common privilege escalation and persistence vector.", "Applicability": [ "tlp-clear", "tlp-green", @@ -4301,9 +5343,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH03" ] } ], @@ -4311,56 +5351,48 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" ] } ] } ], "Checks": [ - "logging_sink_created", - "cloudstorage_bucket_log_retention_policy_lock", - "iam_audit_logs_enabled" + "iam_sa_no_user_managed_keys", + "iam_no_service_roles_at_project_level" ] }, { - "Id": "CCC.Core.CN09.AR02", - "Description": "When the service is operational, disabling the logs for the service or its child resources MUST NOT be possible without also disabling the corresponding resource.", + "Id": "CCC.IAM.CN01.AR02", + "Description": "When a non-administrative principal attempts to create new credentials or a temporary session token, the service MUST deny the action.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN01 Restrict IAM User Credentials Creation", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", + "SubSectionObjective": "Prevent non-administrative principals from creating new long-lived credentials like access keys or generating temporary session tokens. This blocks a common privilege escalation and persistence vector.", "Applicability": [ "tlp-clear", "tlp-green", "tlp-amber", "tlp-red" ], - "Recommendation": "No normal business operations should disable logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging mechanisms are tightly integrated with service operations, so that logging cannot be disabled without stopping the service itself. ", + "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" + "CCC.IAM.TH03" ] } ], @@ -4368,21 +5400,603 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-6" + "PR.AA-05" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AU-9" + "AC-2", + "AC-3", + "AC-5", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_sa_no_user_managed_keys", + "iam_no_service_roles_at_project_level" + ] + }, + { + "Id": "CCC.IAM.CN02.AR01", + "Description": "When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating, updating, or attaching policies.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN02 Restrict IAM Policies Modification", + "SubSection": "", + "SubSectionObjective": "Ensure that only designated administrative accounts have the ability to create, modify, or attach policies that define permissions for other identities.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" ] }, { - "ReferenceId": "CCM", + "ReferenceId": "NIST_800_53", "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" + "AC-2", + "AC-3", + "AC-5", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges" + ] + }, + { + "Id": "CCC.IAM.CN02.AR02", + "Description": "When a non-administrative principal attempts to create, update, or attach policies, the service MUST deny the action.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN02 Restrict IAM Policies Modification", + "SubSection": "", + "SubSectionObjective": "Ensure that only designated administrative accounts have the ability to create, modify, or attach policies that define permissions for other identities.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-5", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges" + ] + }, + { + "Id": "CCC.IAM.CN03.AR01", + "Description": "When a policy is created or updated that grants a principal permission to assume a role or impersonate a service identity, the principal MUST NOT contain a wildcard or be public/anonymous.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN03 Restrict Role Assumption / Delegation", + "SubSection": "", + "SubSectionObjective": "Limit which principals can assume a role or impersonate a service identity to only those required. This prevents unintended cross-account or public access by securing the \"who can act as this identity\" boundary.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_role_sa_enforce_separation_of_duties", + "iam_no_service_roles_at_project_level" + ] + }, + { + "Id": "CCC.IAM.CN03.AR02", + "Description": "When an external or unauthenticated principal tries to assume a role or impersonate a service identity, the service MUST deny the action.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN03 Restrict Role Assumption / Delegation", + "SubSection": "", + "SubSectionObjective": "Limit which principals can assume a role or impersonate a service identity to only those required. This prevents unintended cross-account or public access by securing the \"who can act as this identity\" boundary.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3", + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_role_sa_enforce_separation_of_duties", + "iam_no_service_roles_at_project_level" + ] + }, + { + "Id": "CCC.IAM.CN04.AR01", + "Description": "When an IAM policy is created or updated, it MUST NOT contain allow statements with wildcard permissions, unless the statement is restricted by a condition.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN04 Restrict Wildcard Usage in IAM Policies", + "SubSection": "", + "SubSectionObjective": "Limit the use of wildcard permissions in IAM policies to prevent overly broad access from being granted by default.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01", + "CCC.IAM.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-6" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3", + "AC-6" + ] + } + ] + } + ], + "Checks": [ + "iam_sa_no_administrative_privileges" + ] + }, + { + "Id": "CCC.IAM.CN05.AR01", + "Description": "When a new cloud account is provisioned, a password policy MUST be configured for IAM users following the minimum PCI DSS v4.0.1 configurations.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "Controls that restrict who can access and modify IAM resources.", + "Section": "CCC.IAM.CN05 Strong Password Policies for IAM Users", + "SubSection": "", + "SubSectionObjective": "Ensure that the password policies for IAM users have strong configurations.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "When a new cloud account is provisioned, a password policy must be configured for all IAM users to align with the minimum requirements defined in PCI DSS v4.0.1. This includes, at a minimum: strength: 0 # Not yet specified - reference-id: A password length of at least 12 characters. strength: 0 # Not yet specified - reference-id: A mix of upper- and lower-case letters, numbers, and special characters. strength: 0 # Not yet specified - reference-id: Prevention of the use of previously used passwords (password history). strength: 0 # Not yet specified - reference-id: Password expiration at a defined interval (e.g., every 90 days). strength: 0 # Not yet specified - reference-id: Account lockout after a defined number of failed login attempts.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-05" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IA-5" + ] + }, + { + "ReferenceId": "PCI-DSS", + "Identifiers": [ + "8.3.9", + "8.6.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.IAM.CN06.AR01", + "Description": "When a static credential such as an access key has existed for 90 days or more, it MUST be rotated.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN06 Maximum Age for Long-Term Static Credentials", + "SubSection": "", + "SubSectionObjective": "Ensure that long-lived static credentials like access keys are programmatically rotated within a defined time period to limit the window of opportunity if compromised.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "When a static credential such as an access key has existed for 90 days or more, it must be automatically rotated to reduce the risk of compromise due to long-term exposure. Organizations should implement automated checks to identify aging credentials and enforce rotation policies. Additionally, access key usage should be regularly monitored, and credentials that are no longer in use should be deactivated or deleted promptly. Where possible, prefer temporary, short-lived credentials over long-lived static ones to further minimize risk.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH09", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "iam_sa_user_managed_key_rotate_90_days" + ] + }, + { + "Id": "CCC.IAM.CN07.AR01", + "Description": "When a user account is disabled or deleted in the organization's IdP, the corresponding cloud identity and its access policies MUST be disabled or deleted within 24 hours.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN07 Automate Identity De-provisioning", + "SubSection": "", + "SubSectionObjective": "Ensure that when an identity is terminated in the central Identity Provider (IdP), ts corresponding access to cloud resources is revoked automatically.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH10", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "iam_service_account_unused", + "iam_sa_user_managed_key_unused" + ] + }, + { + "Id": "CCC.IAM.CN08.AR01", + "Description": "When an IAM user has credentials, such as passwords or access keys, that have not been used for 90 days or more, the unused credentials MUST be removed or deactivated.", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN08 Maximum Age for Unused Credentials", + "SubSection": "", + "SubSectionObjective": "Ensure that unused IAM credentals are removed to reduce exposure in the event of potential compromise.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "IAM user credentials (such as passwords or access keys) that have not been used for 90 days or more must be automatically removed or deactivated.", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH11", + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ], + "ConfigRequirements": [ + { + "Check": "iam_sa_user_managed_key_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 90 + }, + { + "Check": "iam_service_account_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 90 + } + ] + }, + { + "Id": "CCC.IAM.CN09.AR01", + "Description": "When a human user accesses the cloud environment, they MUST authenticate through the organization's federated IdP via a standard protocol (e.g., SAML, OIDC).", + "Attributes": [ + { + "FamilyName": "Identity Provisioning and Lifecycle", + "FamilyDescription": "Controls related to the provisioning and lifecycle of IAM identities.", + "Section": "CCC.IAM.CN09 Enforce Federated Single Sign-On (SSO) for Human Users", + "SubSection": "", + "SubSectionObjective": "Ensure that all human users must authenticate through a central, federated Identity Provider (IdP) to access the cloud environment. This eliminates cloud-native user accounts with long-lived passwords, centralizes authentication controls, and simplifies lifecycle management.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01", + "CCC.IAM.TH09" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AA-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "IA-2" + ] + } + ] + } + ], + "Checks": [ + "compute_project_os_login_enabled" + ] + }, + { + "Id": "CCC.IAM.CN10.AR01", + "Description": "When suspicious API requests are detected, real time alerts MUST be generated to notify security personnel.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN10 Alert On Anomalous Behaviour", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when anomalous API requests are made by a single identity, such as API requests commonly associated with privilege escalation tactics, originating from an external or malicious IP address or performed by a previously dormant identity, which may indicate that credentals may be compromised, as well as for password brute-force attempts and account lockouts.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "SI-5", + "AC-2" + ] + } + ] + } + ], + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled" + ] + }, + { + "Id": "CCC.IAM.CN10.AR02", + "Description": "When suspicious API requests are detected, the associated events MUST be logged, including the source details, time, and nature of the activity.", + "Attributes": [ + { + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN10 Alert On Anomalous Behaviour", + "SubSection": "", + "SubSectionObjective": "Ensure that logs and associated alerts are generated when anomalous API requests are made by a single identity, such as API requests commonly associated with privilege escalation tactics, originating from an external or malicious IP address or performed by a previously dormant identity, which may indicate that credentals may be compromised, as well as for password brute-force attempts and account lockouts.", + "Applicability": [ + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.IAM.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "DE.CM-03", + "DE.CM-06", + "DE.CM-09" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-4", + "SI-5", + "AC-2" ] } ] @@ -4390,78 +6004,21 @@ ], "Checks": [ "iam_audit_logs_enabled", - "logging_sink_created", - "cloudstorage_bucket_log_retention_policy_lock", - "compute_subnet_flow_logs_enabled", - "compute_loadbalancer_logging_enabled", - "compute_network_dns_logging_enabled" + "logging_sink_created" ] }, { - "Id": "CCC.Core.CN09.AR03", - "Description": "When the service is operational, any attempt to redirect logs for the service or its child resources MUST NOT be possible without halting operation of the corresponding resource and publishing corresponding events to monitored channels.", + "Id": "CCC.IAM.CN11.AR01", + "Description": "When a cloud account or organization is provisioned, the native automated access and usage analysis services MUST be enabled to continuously monitor for external or public access to resources, and unused access.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN09 Ensure Integrity of Access Logs", + "FamilyName": "Logging and Monitoring", + "FamilyDescription": "Controls that collect, alert, and retain IAM-related events.", + "Section": "CCC.IAM.CN11 Enable Continuous IAM Access and Usage Analysis", "SubSection": "", - "SubSectionObjective": "Ensure that access logs are always recorded to an external location that cannot be manipulated from the context of the service(s) it contains logs for.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "No normal business operations should result in the redirection of logs, as this could indicate an attempt to cover up unauthorized access. Ensure that logging configurations are immutable during service operation so that any changes require stopping the service and publishing corresponding events to monitored channels. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH07", - "CCC.TH09", - "CCC.TH04" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-9" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-02", - "LOG-04", - "LOG-09" - ] - } - ] - } - ], - "Checks": [ - "logging_sink_created", - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.Core.CN10.AR01", - "Description": "When data is replicated, the service MUST ensure that replication only occurs to destinations that are explicitly included within the defined trust perimeter.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN10 Restrict Data Replication to Trust Perimeter", - "SubSection": "", - "SubSectionObjective": "Ensure that data is only replicated on infrastructure in locations that are explicitly included within a defined trust perimeter.", + "SubSectionObjective": "Enable and configure the cloud provider's native access and usage analysis services to continuously monitor for external access paths and internal unused access.", "Applicability": [ + "tlp-clear", "tlp-green", "tlp-amber", "tlp-red" @@ -4471,7 +6028,978 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH04" + "CCC.IAM.TH02", + "CCC.IAM.TH10", + "CCC.IAM.TH11" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "ID.RA-01", + "ID.IM-01" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "CA-7", + "RA-5" + ] + } + ] + } + ], + "Checks": [ + "iam_account_access_approval_enabled", + "iam_cloud_asset_inventory_enabled" + ] + }, + { + "Id": "CCC.GenAI.CN01.AR01", + "Description": "Untrusted input such as user queries, RAG data or tool output MUST be validated before it is passed to a GenAI model.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN01 Model Input Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate input before it is passed to a GenAI model in order to filter or sanitise adversarial queries and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Input Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0021", + "AML.M0015" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN01.AR02", + "Description": "If malicious patterns such as prompt injection or sensitive data are detected during input validation, the input MUST be blocked or sanitised.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN01 Model Input Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate input before it is passed to a GenAI model in order to filter or sanitise adversarial queries and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Input Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0021", + "AML.M0015" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN02.AR01", + "Description": "GenAI model output MUST be validated for format conformance, malicious patterns, sensitive data and inapropriate content before being passed to users, application or plugins.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN02 Model Output Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate GenAI model output before passing it to users, applications or plugins in order to filter or sanitise insecure or unreliable output and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03", + "CCC.GenAI.TH04", + "CCC.GenAI.TH05", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Output Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0002" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN02.AR02", + "Description": "In the event of policy violations, the AI-generated content MUST be redacted, encoded or rejected.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN02 Model Output Filtering and Sanitisation", + "SubSection": "", + "SubSectionObjective": "Inspect and validate GenAI model output before passing it to users, applications or plugins in order to filter or sanitise insecure or unreliable output and prevent sensitive data leakage.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH03", + "CCC.GenAI.TH04", + "CCC.GenAI.TH05", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-003", + "AIR-PREV-017", + "AIR-PREV-002", + "AIR-DET-001" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Output Validation and Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0020", + "AML.M0002" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN03.AR01", + "Description": "When data is designated for model training or RAG ingestion, then its source MUST be explicitly approved and its provenance documented.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN03 Data Provenance and Source Vetting", + "SubSection": "", + "SubSectionObjective": "Ensure that all data for training, fine-tuning or RAG comes from trusted, approved sources and is authorised for the intended purposes in order to prevent the initial introduction of malicious content or leaked sensitive data.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-006" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Management" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0025" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN03.AR02", + "Description": "Data from unvetted sources MUST NOT be used in production systems.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN03 Data Provenance and Source Vetting", + "SubSection": "", + "SubSectionObjective": "Ensure that all data for training, fine-tuning or RAG comes from trusted, approved sources and is authorised for the intended purposes in order to prevent the initial introduction of malicious content or leaked sensitive data.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-006" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Management" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0025" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN04.AR01", + "Description": "When data is ingested for training, fine-tuning or conversion to vector embeddings, it MUST be validated for sensitive information or malicious content.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN04 Sanitisation of Ingested Data", + "SubSection": "", + "SubSectionObjective": "Validate and sanitise all data ingested by GenAI systems from extenal sources or internal knowledge bases, whether for training, conversion to vector embeddings, or real-time retireval, in order to remove or redact poisoned or sensitive data before further processing.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-002" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0007" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN04.AR02", + "Description": "If sensitive data or malicious content is detected, it must be rejected, redacted or flagged for manual review.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN04 Sanitisation of Ingested Data", + "SubSection": "", + "SubSectionObjective": "Validate and sanitise all data ingested by GenAI systems from extenal sources or internal knowledge bases, whether for training, conversion to vector embeddings, or real-time retireval, in order to remove or redact poisoned or sensitive data before further processing.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH02", + "CCC.GenAI.TH03" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-002" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Training Data Sanitization" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0007" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN05.AR01", + "Description": "When a RAG-enabled system generates a response containing information retrieved from its knowledge base, then the response MUST include a verifiable citation that links back to the specific source document.", + "Attributes": [ + { + "FamilyName": "Data", + "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters.", + "Section": "CCC.GenAI.CN05 Citations and Source Traceability", + "SubSection": "", + "SubSectionObjective": "Require the GenAI system to provide citations or direct links back to the source documents used to generate a response, in to enhance the transparency, trustworthiness, and verifiability of AI-generated content.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH09", + "CCC.GenAI.TH04" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-DET-013" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN06.AR01", + "Description": "When an LLM invokes an external tool (e.g., an API, a plugin), then the tool MUST operate with the least privileges required for performing its intended functionality.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration.", + "Section": "CCC.GenAI.CN06 Least Privilege for Plugins", + "SubSection": "", + "SubSectionObjective": "Restricts the permissions of any external tools the GenAI system can call to limit the potential damage if an agent is coerced to perform unintended actions or vulnerabilities in the tools are exploited.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH07", + "CCC.GenAI.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Agent Permissions" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN07.AR01", + "Description": "When an application makes an API call to a foundational model in a production environment, then it MUST specify an explicit version identifier.", + "Attributes": [ + { + "FamilyName": "Configuration Management", + "FamilyDescription": "The Configuration Management control family involves establishing, maintaining and monitoring the configuration of the service and related applications and infrastructure to ensure consistency, secure defaults and compliance.", + "Section": "CCC.GenAI.CN07 Model Version Pinning", + "SubSection": "", + "SubSectionObjective": "Mandate that applications are locked (\"pinned\") to a specific, tested version of a foundational model to prevent unexpected behaviour changes introduced by provider-side updates.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-010" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN08.AR01", + "Description": "When a new AI model is considered for production deployment, it MUST undergo a formal red teaming and quality assurance review.", + "Attributes": [ + { + "FamilyName": "Model Assurance and Evaluation", + "FamilyDescription": "The Model Assurance and Evaluation control family encompasses the proactiveand continuous processes of testing and validating the AI model's behavior to ensure it aligns with safety, ethical, and quality standards.", + "Section": "CCC.GenAI.CN08 Quality Control and Red Teaming", + "SubSection": "", + "SubSectionObjective": "Establish a formal program for quality evaluation and adversarial testing (red teaming) to ensure GenAI system meet all business, quality, security and compliance requirements before getting deployed into production environments.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH02", + "CCC.GenAI.TH04", + "CCC.GenAI.TH08", + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-005" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Adversarial Training and Testing", + "Red Teaming", + "Product Governance" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0008" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.GenAI.CN08.AR02", + "Description": "If model quality review or red teaming identifies an issue that exceeds the organization's risk tolerance, the model MUST NOT be deployed until the issue is remediated.", + "Attributes": [ + { + "FamilyName": "Model Assurance and Evaluation", + "FamilyDescription": "The Model Assurance and Evaluation control family encompasses the proactiveand continuous processes of testing and validating the AI model's behavior to ensure it aligns with safety, ethical, and quality standards.", + "Section": "CCC.GenAI.CN08 Quality Control and Red Teaming", + "SubSection": "", + "SubSectionObjective": "Establish a formal program for quality evaluation and adversarial testing (red teaming) to ensure GenAI system meet all business, quality, security and compliance requirements before getting deployed into production environments.", + "Applicability": [ + "tlp-clear", + "tlp-green", + "tlp-amber", + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.GenAI.TH01", + "CCC.GenAI.TH02", + "CCC.GenAI.TH04", + "CCC.GenAI.TH08", + "CCC.GenAI.TH10" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "FINOS-AIGF", + "Identifiers": [ + "AIR-PREV-005" + ] + }, + { + "ReferenceId": "SAIF", + "Identifiers": [ + "Adversarial Training and Testing", + "Red Teaming", + "Product Governance" + ] + }, + { + "ReferenceId": "MITRE-ATLAS", + "Identifiers": [ + "AML.M0008" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN01.AR01", + "Description": "Verify that only authorized users can access MLDE resources, and that access modes are properly defined and enforced.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN01 Define Access Mode for ML Development Environments", + "SubSection": "", + "SubSectionObjective": "Ensure that access to Machine Learning Development Environment (MLDE) resources is strictly defined and controlled. Only authorized users with appropriate permissions can access these environments, mitigating the risk of unauthorized access, data leakage, or service disruption.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.1", + "2013 A.9.2.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-2", + "AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-01", + "IAM-02" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN03.AR01", + "Description": "Verify that root access is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN03 Disable Root Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from obtaining root access on MLDE instances to reduce the risk of unauthorized system modifications and potential security breaches.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08", + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN03.AR02", + "Description": "For MLDE instances without sensitive data, ensure that root access is only enabled when necessary and properly authorized.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN03 Disable Root Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from obtaining root access on MLDE instances to reduce the risk of unauthorized system modifications and potential security breaches.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08", + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN04.AR01", + "Description": "Verify that terminal access is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN04 Disable Terminal Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from accessing the terminal on MLDE instances to limit the risk of unauthorized commands and potential system compromise.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN04.AR02", + "Description": "For MLDE instances without sensitive data, ensure that terminal access is only enabled when necessary and properly authorized.", + "Attributes": [ + { + "FamilyName": "Identity and Access Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN04 Disable Terminal Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent users from accessing the terminal on MLDE instances to limit the risk of unauthorized commands and potential system compromise.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-08" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.2.3" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN02.AR01", + "Description": "Confirm that file download functionality is disabled on MLDE instances containing sensitive data.", + "Attributes": [ + { + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN02 Disable File Downloads on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent unauthorized file downloads from MLDE instances to protect sensitive data from being exfiltrated.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.Core.TH02" ] } ], @@ -4485,14 +7013,21 @@ { "ReferenceId": "CCM", "Identifiers": [ - "DSP-10", - "DSP-19" + "DSI-05", + "DSI-07" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.2.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "AC-4" + "SC-7", + "SC-8" ] } ] @@ -4501,26 +7036,28 @@ "Checks": [] }, { - "Id": "CCC.Core.CN02.AR01", - "Description": "When data is stored, it MUST be encrypted using the latest industry-standard encryption methods.", + "Id": "CCC.MLDE.CN02.AR02", + "Description": "For MLDE instances without sensitive data, ensure that file downloads are monitored and logged.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN02 Encrypt Data for Storage", + "FamilyName": "Data Protection", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN02 Disable File Downloads on MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that all data stored is encrypted at rest using strong encryption algorithms.", + "SubSectionObjective": "Prevent unauthorized file downloads from MLDE instances to protect sensitive data from being exfiltrated.", "Applicability": [ + "tlp-red", + "tlp-amber", "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-clear" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.MLDE.TH02", + "CCC.Core.TH02" ] } ], @@ -4528,110 +7065,46 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.DS-5" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-03", - "CEK-04", - "UEM-08", - "DSP-17" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-13", - "SC-28" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_encryption_with_csek_enabled", - "dataproc_encrypted_with_cmks_disabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "CCC.Core.CN11.AR01", - "Description": "When encryption keys are used, the service MUST verify that all encryption keys use the latest industry-standard cryptographic algorithms.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "DSI-05", + "DSI-07" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.13.2.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "SC-7", + "SC-8" ] } ] } ], - "Checks": [ - "kms_key_rotation_enabled", - "dataproc_encrypted_with_cmks_disabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "kms_key_not_publicly_accessible" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR02", - "Description": "When encryption keys are used, the service MUST rotate active keys within 180 days of issuance.", + "Id": "CCC.MLDE.CN05.AR01", + "Description": "Verify that only approved VM and container images can be selected when creating MLDE instances.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Configuration Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN05 Restrict Environment Options on MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Limit the virtual machine and container image options available when creating new MLDE instances to approved and secure configurations.", "Applicability": [ + "tlp-red", "tlp-amber" ], "Recommendation": "", @@ -4639,7 +7112,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.MLDE.TH04" ] } ], @@ -4647,53 +7120,43 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.IP-1" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "TVM-02" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.12.5.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "CM-2" ] } ] } ], - "Checks": [ - "kms_key_rotation_enabled", - "kms_key_not_publicly_accessible", - "dataproc_encrypted_with_cmks_disabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR03", - "Description": "When encrypting data, the service MUST verify that customer-managed encryption keys (CMEKs) are used.", + "Id": "CCC.MLDE.CN05.AR02", + "Description": "Attempt to create an MLDE instance with an unapproved image and confirm that it is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "", - "SubSection": "CCC.Core.CN11 Protect Encryption Keys", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "FamilyName": "Configuration Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN05 Restrict Environment Options on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual machine and container image options available when creating new MLDE instances to approved and secure configurations.", "Applicability": [ - "tlp-amber", "tlp-red" ], "Recommendation": "", @@ -4701,7 +7164,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.MLDE.TH04" ] } ], @@ -4709,51 +7172,371 @@ { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.DS-1" + "PR.IP-1" ] }, { "ReferenceId": "CCM", "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" + "TVM-02" ] }, { "ReferenceId": "ISO_27001", "Identifiers": [ - "2013 A.10.1.2" + "2013 A.12.5.1" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "SC-12", - "SC-17" + "CM-2" ] } ] } ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "dataproc_encrypted_with_cmks_disabled", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR04", - "Description": "When encryption keys are accessed, the service MUST verify that access to encryption keys is restricted to authorized personnel and services, following the principle of least privilege.", + "Id": "CCC.MLDE.CN06.AR01", + "Description": "Verify that automatic scheduled upgrades are enabled on user-managed MLDE instances containing sensitive data.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Vulnerability Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN06 Require Automatic Scheduled Upgrades on User-Managed MLDE Instances", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Ensure that MLDE instances are kept up-to-date with the latest security patches by enforcing automatic scheduled upgrades.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH04", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-12" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "TVM-01", + "TVM-02" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.12.6.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN06.AR02", + "Description": "Ensure that the upgrade schedule is appropriately configured and does not interfere with critical operations.", + "Attributes": [ + { + "FamilyName": "Vulnerability Management", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN06 Require Automatic Scheduled Upgrades on User-Managed MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Ensure that MLDE instances are kept up-to-date with the latest security patches by enforcing automatic scheduled upgrades.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH04", + "CCC.Core.TH06" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.IP-12" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "TVM-01", + "TVM-02" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.12.6.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SI-2" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN07.AR01", + "Description": "Verify that MLDE instances containing sensitive data cannot be accessed via public IP addresses.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN07 Restrict Public IP Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent public IP access to MLDE instances to reduce exposure to the internet and enhance security.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN07.AR02", + "Description": "For MLDE instances without sensitive data requiring public access, ensure that appropriate security controls are in place and access is approved.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN07 Restrict Public IP Access on MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Prevent public IP access to MLDE instances to reduce exposure to the internet and enhance security.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH02", + "CCC.VPC.TH02" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-3" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "SEF-05" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.13.1.1" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "SC-7" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN08.AR01", + "Description": "Verify that MLDE instances containing sensitive data can only be deployed in approved virtual networks with appropriate security controls.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN08 Restrict Virtual Networks for MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual networks that can be used when creating new MLDE instances to ensure they are deployed within approved and secure network environments.", + "Applicability": [ + "tlp-red" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.MLDE.CN08.AR02", + "Description": "Ensure that MLDE instances without sensitive data are deployed in networks that meet organizational security standards.", + "Attributes": [ + { + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.MLDE.CN08 Restrict Virtual Networks for MLDE Instances", + "SubSection": "", + "SubSectionObjective": "Limit the virtual networks that can be used when creating new MLDE instances to ensure they are deployed within approved and secure network environments.", + "Applicability": [ + "tlp-red", + "tlp-amber", + "tlp-green", + "tlp-clear" + ], + "Recommendation": "", + "SectionThreatMappings": [ + { + "ReferenceId": "CCC", + "Identifiers": [ + "CCC.MLDE.TH01", + "CCC.Core.TH01" + ] + } + ], + "SectionGuidelineMappings": [ + { + "ReferenceId": "NIST-CSF", + "Identifiers": [ + "PR.AC-4" + ] + }, + { + "ReferenceId": "CCM", + "Identifiers": [ + "IAM-12" + ] + }, + { + "ReferenceId": "ISO_27001", + "Identifiers": [ + "2013 A.9.1.2" + ] + }, + { + "ReferenceId": "NIST_800_53", + "Identifiers": [ + "AC-6" + ] + } + ] + } + ], + "Checks": [] + }, + { + "Id": "CCC.Message.CN01.AR01", + "Description": "Attempt to publish a message without using a customer-managed encryption key and verify that the message is rejected or not stored.", + "Attributes": [ + { + "FamilyName": "Encryption", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.Message.CN01 Use Customer-Managed Encryption Keys (CMEK) for Messages", + "SubSection": "", + "SubSectionObjective": "Ensure that messages are encrypted using customer-managed encryption keys (CMEK) to provide enhanced control over encryption processes and keys, meeting compliance and security requirements.", "Applicability": [ "tlp-clear", "tlp-green", @@ -4765,7 +7548,7 @@ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH16" + "CCC.Core.TH01" ] } ], @@ -4776,310 +7559,53 @@ "PR.DS-1" ] }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, { "ReferenceId": "NIST_800_53", "Identifiers": [ "SC-12", - "SC-17" + "SC-13" ] } ] } ], - "Checks": [ - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "dataproc_encrypted_with_cmks_disabled" - ] + "Checks": [] }, { - "Id": "CCC.Core.CN11.AR05", - "Description": "When encryption keys are used, the service MUST rotate active keys within 365 days of issuance.", + "Id": "CCC.SvlsComp.CN01.AR01", + "Description": "Attempt to access the serverless function over the public internet and verify that access is denied.", "Attributes": [ { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", + "FamilyName": "Network Security", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SvlsComp.CN01 Enforce Use of Private Endpoints for Serverless Function", "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", + "SubSectionObjective": "Ensure that the serverless function is accessible only through a private endpoint, allowing it to communicate securely within a virtual private network and preventing unauthorized external access.", "Applicability": [ - "tlp-clear", - "tlp-green" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12", - "SC-17" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "kms_key_not_publicly_accessible", - "dataproc_encrypted_with_cmks_disabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption" - ] - }, - { - "Id": "CCC.Core.CN11.AR06", - "Description": "When encryption keys are used, the service MUST rotate active keys within 90 days of issuance.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN11 Protect Encryption Keys", - "SubSection": "", - "SubSectionObjective": "Ensure that encryption keys are managed securely by enforcing the use of approved algorithms, regular key rotation, and customer-managed encryption keys (CMEKs).", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH16" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "CEK-08", - "CEK-10", - "CEK-12" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.10.1.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "SC-12", - "SC-17" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "kms_key_not_publicly_accessible", - "dataproc_encrypted_with_cmks_disabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption" - ] - }, - { - "Id": "CCC.Core.CN14.AR01", - "Description": "When backups are created for disaster recovery purposes, the storage mechanism MUST NOT allow modification or deletion within 30 days of creation.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Use immutable storage solutions where possible. Implement backup retention policies that enforce a minimum retention period of 30 days. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudsql_instance_automated_backups" - ] - }, - { - "Id": "CCC.Core.CN14.AR02", - "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 30 days.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-clear", - "tlp-green", + "tlp-red", "tlp-amber" ], - "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 30 days. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "cloudsql_instance_automated_backups" - ] - }, - { - "Id": "CCC.Core.CN14.AR02", - "Description": "When backups are created for disaster recovery purposes, the most recent backup MUST have a creation date within the past 14 days.", - "Attributes": [ - { - "FamilyName": "Data", - "FamilyDescription": "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle. These controls govern how data is transmitted, stored, replicated, and protected from unauthorized access, tampering, or exposure beyond defined trust perimeters. ", - "Section": "CCC.Core.CN14 Maintain Recent Backups", - "SubSection": "", - "SubSectionObjective": "Ensure that all backups used for disaster recovery are recent and subject to a retention policy that limits deletion.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "Implement automated backup processes to ensure that backups are created regularly. Monitor backup schedules and verify that the most recent backup creation date is within the last 14 days. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH06" - ] - } - ], - "SectionGuidelineMappings": [] - } - ], - "Checks": [ - "cloudsql_instance_automated_backups", - "cloudstorage_bucket_log_retention_policy_lock" - ] - }, - { - "Id": "CCC.Core.CN03.AR01", - "Description": "When an entity attempts to modify the service through a user interface, the authentication process MUST require multiple identifying factors for authentication.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH01" ] } ], "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" + "PR.AC-5" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "IA-2" + "SC-7", + "SC-8" ] } ] @@ -5088,990 +7614,45 @@ "Checks": [] }, { - "Id": "CCC.Core.CN03.AR02", - "Description": "When an entity attempts to modify the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", + "Id": "CCC.SvlsComp.CN02.AR01", + "Description": "Send requests to invoke the function up to the allowed threshold and confirm they are successful; then send additional requests exceeding the threshold from the same entity and verify that they are denied.", "Attributes": [ { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", + "FamilyName": "Availability", + "FamilyDescription": "TODO: Describe this control family", + "Section": "CCC.SvlsComp.CN02 Implement Function Invocation Rate Limits", "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", + "SubSectionObjective": "Ensure that function invocation is limited to a specified threshold from any single entity, preventing resource exhaustion and denial of service attacks.", "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" + "tlp-red", + "tlp-amber" ], "Recommendation": "", "SectionThreatMappings": [ { "ReferenceId": "CCC", "Identifiers": [ - "CCC.TH01" + "CCC.Core.TH12" ] } ], "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, { "ReferenceId": "NIST-CSF", "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" + "PR.DS-4" ] }, { "ReferenceId": "NIST_800_53", "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "apikeys_key_exists", - "apikeys_key_rotated_in_90_days", - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_public_ip", - "cloudsql_instance_public_ip", - "cloudstorage_bucket_public_access" - ] - }, - { - "Id": "CCC.Core.CN03.AR03", - "Description": "When an entity attempts to view information on the service through a user interface, the authentication process MUST require multiple identifying factors from the user.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" + "SC-5" ] } ] } ], "Checks": [] - }, - { - "Id": "CCC.Core.CN03.AR04", - "Description": "When an entity attempts to view information on the service through an API endpoint, the authentication process MUST require a credential such as an API key or token AND originate from within the trust perimeter.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN03 Implement Multi-factor Authentication (MFA) for Access", - "SubSection": "", - "SubSectionObjective": "Ensure that all sensitive activities require two or more identity factors during authentication to prevent unauthorized access.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-14" - ] - }, - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-7" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "IAM-03", - "IAM-08" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.9.4.2" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "IA-2" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "cloudsql_instance_public_ip", - "cloudsql_instance_public_access", - "cloudstorage_bucket_public_access", - "apikeys_api_restrictions_configured", - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "compute_project_os_login_enabled" - ] - }, - { - "Id": "CCC.Core.CN05.AR01", - "Description": "When an attempt is made to modify data on the service or a child resource, the service MUST block requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "cloudstorage_bucket_public_access", - "compute_instance_public_ip", - "cloudsql_instance_public_ip", - "compute_instance_ip_forwarding_is_enabled", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account", - "iam_no_service_roles_at_project_level", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "iam_audit_logs_enabled", - "iam_account_access_approval_enabled" - ] - }, - { - "Id": "CCC.Core.CN05.AR02", - "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_no_service_roles_at_project_level", - "iam_sa_no_administrative_privileges", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "compute_project_os_login_enabled", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "compute_instance_public_ip", - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "cloudsql_instance_public_ip", - "cloudstorage_bucket_public_access", - "apikeys_api_restrictions_configured", - "apikeys_key_exists", - "apikeys_key_rotated_in_90_days", - "iam_cloud_asset_inventory_enabled" - ] - }, - { - "Id": "CCC.Core.CN05.AR03", - "Description": "When administrative access or configuration change is attempted on the service or a child resource in a multi-tenant environment, the service MUST refuse requests across tenant boundaries unless the origin is explicitly included in a pre-approved allowlist.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "iam_no_service_roles_at_project_level", - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_public_ip", - "cloudsql_instance_public_ip", - "cloudsql_instance_public_access", - "cloudstorage_bucket_public_access", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account" - ] - }, - { - "Id": "CCC.Core.CN05.AR04", - "Description": "When data is requested from outside the trust perimeter, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "", - "SubSection": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "iam_no_service_roles_at_project_level", - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "gke_cluster_no_default_service_account", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "compute_instance_block_project_wide_ssh_keys_disabled", - "compute_instance_public_ip", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_firewall_rdp_access_from_the_internet_allowed", - "cloudsql_instance_public_ip", - "cloudsql_instance_public_access", - "cloudstorage_bucket_public_access", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled", - "apikeys_api_restrictions_configured", - "apikeys_key_exists", - "apikeys_key_rotated_in_90_days" - ] - }, - { - "Id": "CCC.Core.CN05.AR05", - "Description": "When any request is made from outside the trust perimeter, the service MUST NOT provide any response that may indicate the service exists.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_public_ip", - "cloudsql_instance_public_access", - "cloudstorage_bucket_public_access", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account", - "iam_no_service_roles_at_project_level", - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "apikeys_api_restrictions_configured", - "kms_key_not_publicly_accessible", - "compute_instance_ip_forwarding_is_enabled" - ] - }, - { - "Id": "CCC.Core.CN05.AR06", - "Description": "When any request is made to the service or a child resource, the service MUST refuse requests from unauthorized entities.", - "Attributes": [ - { - "FamilyName": "Identity and Access Management", - "FamilyDescription": "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources. These controls establish strong authentication, enforce multi-factor verification, and restrict access to approved sources to prevent unauthorized use or data exfiltration. ", - "Section": "CCC.Core.CN05 Prevent Access from Untrusted Entities", - "SubSection": "", - "SubSectionObjective": "Ensure that secure access controls enforce the principle of least privilege to restrict access to authorized entities from explicitly trusted sources only.", - "Applicability": [ - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "PR.AC-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "DSP-01", - "DSP-07", - "DSP-08", - "DSP-10", - "DSP-17" - ] - }, - { - "ReferenceId": "ISO_27001", - "Identifiers": [ - "2013 A.13.1.3" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AC-3" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_public_ip", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account", - "cloudstorage_bucket_public_access", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "cloudsql_instance_private_ip_assignment", - "apikeys_api_restrictions_configured", - "apikeys_key_exists", - "apikeys_key_rotated_in_90_days", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled", - "iam_no_service_roles_at_project_level", - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "CCC.Core.CN04.AR01", - "Description": "When administrative access or configuration change is attempted on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "iam_audit_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC.Core.CN04.AR02", - "Description": "When any attempt is made to modify data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC.Core.CN04.AR03", - "Description": "When any attempt is made to read data on the service or a child resource, the service MUST log the client identity, time, and result of the attempt.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN04 Log All Access and Changes", - "SubSection": "", - "SubSectionObjective": "Ensure that all access attempts are logged to maintain a detailed audit trail for security and compliance purposes.", - "Applicability": [ - "tlp-red" - ], - "Recommendation": "", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH01" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-3" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-08" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-2", - "AU-3", - "AU-12" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC.Core.CN07.AR01", - "Description": "When enumeration activities are detected, the service MUST publish an event to a monitored channel which includes the client identity, time, and nature of the activity.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", - "SubSection": "", - "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", - "Applicability": [ - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement event publication mechanisms and alerts for patterns indicative of enumeration activities, such as repeated access attempts, requests, or liveness probes. Configure alerts to notify security teams of any activities that merit further investigation. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-05", - "SEF-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_sink_created" - ] - }, - { - "Id": "CCC.Core.CN07.AR02", - "Description": "When enumeration activities are detected, the service MUST log the client identity, time, and nature of the activity.", - "Attributes": [ - { - "FamilyName": "Logging & Monitoring", - "FamilyDescription": "The Logging & Monitoring control family ensures that access, changes, and security-relevant events are captured, monitored, and alerted on in order to provide visibility, support incident response, and meet compliance requirements. ", - "Section": "CCC.Core.CN07 Alert on Unusual Enumeration Activity", - "SubSection": "", - "SubSectionObjective": "Ensure that logs and associated alerts are generated when unusual enumeration activity is detected that may indicate reconnaissance activities.", - "Applicability": [ - "tlp-clear", - "tlp-green", - "tlp-amber", - "tlp-red" - ], - "Recommendation": "Implement logging mechanisms to capture details of enumeration activities, including client identity, timestamps, and activity nature. Retain logs according to organizational policies, and occasionally review them for patterns that may indicate reconnaissance activities. ", - "SectionThreatMappings": [ - { - "ReferenceId": "CCC", - "Identifiers": [ - "CCC.TH15" - ] - } - ], - "SectionGuidelineMappings": [ - { - "ReferenceId": "NIST-CSF", - "Identifiers": [ - "DE.AE-1" - ] - }, - { - "ReferenceId": "CCM", - "Identifiers": [ - "LOG-05", - "SEF-05" - ] - }, - { - "ReferenceId": "NIST_800_53", - "Identifiers": [ - "AU-6" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled" - ] } ] } diff --git a/prowler/compliance/gcp/cis_2.0_gcp.json b/prowler/compliance/gcp/cis_2.0_gcp.json index d391bcc271..dd9eb62a96 100644 --- a/prowler/compliance/gcp/cis_2.0_gcp.json +++ b/prowler/compliance/gcp/cis_2.0_gcp.json @@ -150,7 +150,7 @@ "Id": "1.10", "Description": "Google Cloud Key Management Service stores cryptographic keys in a hierarchical structure designed for useful and elegant access control management. The format for the rotation schedule depends on the client library that is used. For the gcloud command-line tool, the next rotation time must be in `ISO` or `RFC3339` format, and the rotation period must be in the form `INTEGERUNIT`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { diff --git a/prowler/compliance/gcp/cis_3.0_gcp.json b/prowler/compliance/gcp/cis_3.0_gcp.json index b8d796c35a..958a2a9bc3 100644 --- a/prowler/compliance/gcp/cis_3.0_gcp.json +++ b/prowler/compliance/gcp/cis_3.0_gcp.json @@ -201,7 +201,7 @@ "Id": "1.10", "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { diff --git a/prowler/compliance/gcp/cis_4.0_gcp.json b/prowler/compliance/gcp/cis_4.0_gcp.json index 7664744e05..d7a3c5d7a2 100644 --- a/prowler/compliance/gcp/cis_4.0_gcp.json +++ b/prowler/compliance/gcp/cis_4.0_gcp.json @@ -201,7 +201,7 @@ "Id": "1.10", "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { @@ -914,7 +914,7 @@ ] }, { - "Id": "3.1", + "Id": "3.10", "Description": "Use Identity Aware Proxy (IAP) to Ensure Only Traffic From Google IP Addresses are 'Allowed'", "Checks": [], "Attributes": [ @@ -1132,7 +1132,7 @@ ] }, { - "Id": "4.1", + "Id": "4.10", "Description": "Ensure That App Engine Applications Enforce HTTPS Connections", "Checks": [], "Attributes": [ diff --git a/prowler/compliance/gcp/cis_5.0_gcp.json b/prowler/compliance/gcp/cis_5.0_gcp.json new file mode 100644 index 0000000000..edc962ff0c --- /dev/null +++ b/prowler/compliance/gcp/cis_5.0_gcp.json @@ -0,0 +1,2097 @@ +{ + "Framework": "CIS", + "Name": "CIS Google Cloud Platform Foundation Benchmark v5.0.0", + "Version": "5.0", + "Provider": "GCP", + "Description": "The CIS Google Cloud Platform Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of GCP with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Ensure Super Admin Email Address Is Not Tied To A Single User", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended that the super admin role is assigned to a dedicated email address (e.g., gcp-superadmin@company.com) rather than to individual user email addresses (e.g., john.doe@company.com). The dedicated super admin email should be a separate user account used exclusively for super admin level administrative tasks. When the GCP organization resource is created, existing super administrator accounts from Google Workspace or Cloud Identity automatically gain access to manage the organization. The super admin role has unrestricted access to all GCP organization resources and settings. Using individual user email addresses for super admin access creates security and operational risks.", + "RationaleStatement": "Using a dedicated email address for super admin accounts rather than individual user emails significantly reduces the risk of phishing attacks, as individual user accounts receive daily business emails and are high-value phishing targets, while a dedicated super admin email address not used for daily communications has minimal exposure to credential compromise. Dedicated super admin accounts can enforce stronger authentication requirements such as hardware security keys and shorter session timeouts without impacting day-to-day productivity. When employees leave the organization or change roles, super admin access remains continuous through the dedicated account rather than being tied to departing personnel. Separating super admin access from daily user activities prevents accidental misuse of elevated privileges and provides clear audit separation, supporting segregation of duties.", + "ImpactStatement": "Creating a new dedicated super admin account requires coordination with Google Workspace or Cloud Identity administrators. Existing super admin accounts tied to individual users should be replaced with the dedicated account, and individual user accounts should be removed from the super admin role once migration is complete.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\n1. Navigate to the Google Admin console at https://admin.google.com\n2. Go to `Account` --> `Admin roles` in the left navigation menu\n3. Click on `Super Admin` role\n4. Review the list of users assigned the Super Admin role\n5. Verify that the super admin role is assigned to a dedicated email address (e.g., gcp-superadmin@company.com) and that individual user email addresses are NOT assigned the Super Admin role directly.", + "RemediationProcedure": "**From Google Cloud Admin Console**\n\n1. Create a dedicated super admin user account: in the Google Admin console go to `Directory` -> `Users`, click `Add new user` and configure a dedicated address (e.g., gcp-superadmin@company.com). Enforce 2-Step Verification with a hardware security key.\n2. Assign the Super Admin role to the dedicated account: go to `Account` --> `Admin roles`, click `Super Admin` role, `Assigned Admins` --> `Assign users`, select the dedicated account and click `Assign role`.\n3. Remove individual user accounts from the Super Admin role: for each individual user email address, select the user, click `Unassign role` and confirm the removal. Verify that only the dedicated super admin account remains assigned.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource", + "DefaultValue": "When you first sign up for Google Workspace or Cloud Identity, the person who completes the signup process automatically becomes the first super administrator, typically resulting in an individual user's email address being assigned the super admin role by default." + } + ] + }, + { + "Id": "1.1.2", + "Description": "Ensure Super Admin Account Is Not Used For Google Cloud Administration", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended that super admin accounts are not granted any IAM roles in Google Cloud Platform and are used exclusively for identity management in Google Workspace or Cloud Identity. Google Cloud administration should be performed using separate regular user accounts that are granted appropriate GCP IAM roles such as Organization Administrator. The Super Admin role and GCP resource management are two separate privilege domains that should not be combined in a single account.", + "RationaleStatement": "Separating super admin accounts from GCP administration is critical because Super Admin privileges are irrevocable and can override almost any setting in both Workspace and GCP. If a super admin account is granted GCP permissions and compromised through phishing, an attacker gains full control over both the email domain and the entire cloud infrastructure. Super admin accounts not granted GCP IAM permissions cannot be used to directly manipulate cloud resources even if compromised, limiting blast radius to identity management functions only. This separation enforces least privilege and reduces the attack surface by ensuring the most powerful account type is isolated from daily cloud operations.", + "ImpactStatement": "Organizations must create separate regular user accounts in Workspace/Cloud Identity for GCP administration. These accounts will be granted appropriate GCP IAM roles for cloud resource management. Existing super admin accounts that have been granted GCP IAM roles must have those permissions removed.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\n1. In the Google Admin console (https://admin.google.com), go to `Account` -> `Admin roles`, click `Super Admin` role and note all user accounts assigned the Super Admin role.\n2. In the Google Cloud Console (https://console.cloud.google.com), go to `IAM & Admin` -> `IAM` at the organization level and review all IAM policy bindings.\n3. Verify that none of the super admin accounts appear in the IAM permissions list and that they have NO IAM roles granted at organization, folder, or project levels.", + "RemediationProcedure": "**From Google Cloud Admin Console**\n\n1. Create a separate regular user account for GCP administration in `Directory` -> `Users` (do NOT assign it the Super Admin role).\n2. Grant GCP IAM roles to that cloud admin account in the Cloud Console under `IAM & Admin` -> `IAM` (e.g., Organization Administrator).\n3. Delete all GCP IAM bindings for super admin users: in `IAM & Admin` -> `IAM` at the organization level, edit each super admin principal and remove all assigned roles. Repeat for all folders and projects.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource", + "DefaultValue": "By default, super administrator accounts from Google Workspace or Cloud Identity gain access to manage the GCP organization resource when it is created." + } + ] + }, + { + "Id": "1.1.3", + "Description": "Ensure Folders Are Structured By Environment And Sensitivity", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that GCP Resource Manager folders are structured primarily by environment (for example, production, non-production, sandbox) and sensitivity (for example, security, logging, shared services, regulated workloads), rather than mirroring the corporate org chart. Folders should group projects that share similar security requirements and controls so that appropriate Organization Policies, IAM policies, and other guardrails can be applied consistently at the folder level.", + "RationaleStatement": "A clear folder structure based on environment and sensitivity makes it easier to apply consistent guardrails and centralized security controls to projects that have similar risk profiles and compliance needs. Folders enable hierarchical policy inheritance where stricter controls can be applied to production folders while development folders can have more relaxed policies. Poorly defined or ad-hoc folder structures complicate policy management, increase the chance of misapplied controls, and can lead to mixing workloads with different data sensitivities. Without proper folder segregation, a compromised development project can be used to pivot into production resources and compliance auditing becomes significantly more complex.", + "ImpactStatement": "Restructuring folders by environment and sensitivity can require moving projects, changing inherited Organization Policies and IAM policies, and updating automation that assumes existing folder paths. This may introduce short-term operational overhead, including policy revalidation, testing of workloads under new guardrails, and coordination with application and platform teams.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\n1. In the Cloud Console (https://console.cloud.google.com), select your organization and go to `IAM & Admin` -> `Manage resources` to view the organization hierarchy.\n2. Review and document the folder hierarchy (organization -> folders -> sub-folders -> projects).\n3. Evaluate whether top-level and key folders are clearly aligned to environment (production, non-production, sandbox) and sensitivity/function (security, logging, shared services, regulated).\n4. For each environment/sensitivity folder, sample projects and verify their primary workloads match the folder's stated purpose. Note any folders organized mainly by department/owner or that mix production and non-production workloads.", + "RemediationProcedure": "**From Google Cloud Admin Console**\n\n1. Work with security, platform, and application teams to agree on a small set of top-level folders (e.g., Security/Management, Shared Services/Infrastructure, Prod, Non-Prod). Define dedicated folders for highly regulated workloads.\n2. Create the folder structure under `IAM & Admin` -> `Manage resources` using `CREATE FOLDER`.\n3. Map each project to its target folder based on environment and sensitivity.\n4. Move projects to the environment/sensitivity-based folders (start with low-risk sandbox/non-production projects, test workloads, then proceed with production).\n5. Remove old folders that no longer reflect the target structure and update architecture docs and onboarding runbooks.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource" + } + ] + }, + { + "Id": "1.1.4", + "Description": "Ensure Organization Policies Are Configured For Centralized Constraints", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that one or more baseline Organization Policies are configured at the organization or folder level in accordance with enterprise security requirements. Organization Policies act as preventive guardrails that set centralized constraints on how resources can be configured across all projects within the organization hierarchy. These policies can enforce security invariants such as preventing public IP addresses on compute instances, requiring OS Login for SSH access, restricting resource locations to approved regions, enforcing uniform bucket-level access on Cloud Storage, or limiting IAM policy member domains to prevent external sharing.", + "RationaleStatement": "Organization Policies do not grant permissions but instead set organization-wide limits on resource configurations and service usage, regardless of local project-level IAM policies. Without baseline guardrail Organization Policies, each project can configure resources inconsistently, disable security features, use unapproved regions, allow public access to resources, or share data with external domains. Configuring standard Organization Policies at the organization or folder level enforces preventive, centralized control over high-risk configurations and supports consistent security posture at scale. Organization Policies are inherited down the resource hierarchy (organization -> folder -> project), making them an effective mechanism for enforcing security controls across large numbers of projects.", + "ImpactStatement": "Enforcing baseline Organization Policies can initially block some existing resource configurations, such as use of unapproved regions, creation of resources with public IPs, or external domain sharing. Teams may need to adjust deployment pipelines, Terraform configurations, and exception processes. Organizations should test policies in non-production folders before applying them broadly.", + "AuditProcedure": "**From Google Cloud Admin Console**\n\nPre-requisite: Document your organization's baseline guardrail requirements (e.g., constraints/gcp.resourceLocations, constraints/compute.requireOsLogin, constraints/compute.vmExternalIpAccess, constraints/storage.uniformBucketLevelAccess, constraints/iam.allowedPolicyMemberDomains).\n\n1. In the Cloud Console (https://console.cloud.google.com), select your organization and go to `IAM & Admin` -> `Organization Policies`. Review the policies set at the organization level and verify baseline security constraints are configured (enforced, exceptions, configured values).\n2. Verify policies are not overridden or disabled at folder or project levels (look for policies marked as `Customized`).\n3. Compare your documented baseline requirements against the configured Organization Policies and identify gaps.", + "RemediationProcedure": "IMPORTANT: Before enforcing Organization Policies, use dry run mode to preview which resources would be blocked and review violations in Cloud Asset Inventory before actual enforcement.\n\n**From Google Cloud Admin Console**\n\n1. In `IAM & Admin` -> `Organization Policies`, review existing policies and identify gaps based on your security requirements.\n2. For each baseline constraint, search for the constraint, click `Manage policy`, select `Override parent's policy` and configure it (boolean: `Enforcement On`; list: choose Deny all/Allow all or Custom values). Click `Set policy` and verify it shows as `Enforced`.\n3. Configure folder-level policies if different constraints are needed per environment, ensuring folder-level policies do not weaken organization-level security controls unless explicitly approved.", + "AdditionalInformation": "", + "References": "", + "SubSection": "1.1 Organization Resource" + } + ] + }, + { + "Id": "1.2", + "Description": "Ensure that Corporate Login Credentials are Used", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use corporate login credentials instead of consumer accounts, such as Gmail accounts.", + "RationaleStatement": "It is recommended fully-managed corporate Google accounts be used for increased visibility, auditing, and controlling access to Cloud Platform resources. Email accounts based outside of the user's organization, such as consumer accounts, should not be used for business purposes.", + "ImpactStatement": "There will be increased overhead as maintaining accounts will now be required. For smaller organizations, this will not be an issue, but will balloon with size.", + "RemediationProcedure": "Remove all consumer Google accounts from IAM policies. Follow the documentation and setup corporate login accounts. **Prevention:** To ensure that no email addresses outside the organization can be granted IAM permissions to its Google Cloud projects, folders or organization, turn on the Organization Policy for `Domain Restricted Sharing`. Learn more at: [https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains)", + "AuditProcedure": "For each Google Cloud Platform project, list the accounts that have been granted access to that project: **From Google Cloud CLI** gcloud projects get-iam-policy PROJECT_ID Also list the accounts added on each folder: gcloud resource-manager folders get-iam-policy FOLDER_ID And list your organization's IAM policy: gcloud organizations get-iam-policy ORGANIZATION_ID No email accounts outside the organization domain should be granted permissions in the IAM policies. This excludes Google-owned service accounts.", + "AdditionalInformation": "", + "References": "https://support.google.com/work/android/answer/6371476:https://cloud.google.com/sdk/gcloud/reference/projects/get-iam-policy:https://cloud.google.com/sdk/gcloud/reference/resource-manager/folders/get-iam-policy:https://cloud.google.com/sdk/gcloud/reference/organizations/get-iam-policy:https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints", + "DefaultValue": "By default, no email addresses outside the organization's domain have access to its Google Cloud deployments, but any user email account can be added to the IAM policy for Google Cloud Platform projects, folders, or organizations.", + "SubSection": null + } + ] + }, + { + "Id": "1.3", + "Description": "Ensure that Multi-Factor Authentication is 'Enabled' for All Non-Service Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Setup multi-factor authentication for Google Cloud Platform accounts.", + "RationaleStatement": "Multi-factor authentication requires more than one mechanism to authenticate a user. This secures user logins from attackers exploiting stolen or weak credentials.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** For each Google Cloud Platform project: 1. Identify non-service accounts. 1. Setup multi-factor authentication for each account.", + "AuditProcedure": "**From Google Cloud Console** For each Google Cloud Platform project, folder, or organization: 1. Identify non-service accounts. 1. Manually verify that multi-factor authentication for each account is set.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/solutions/securing-gcp-account-u2f:https://support.google.com/accounts/answer/185839", + "DefaultValue": "By default, multi-factor authentication is not set.", + "SubSection": null + } + ] + }, + { + "Id": "1.4", + "Description": "Ensure that Security Key Enforcement is Enabled for All Admin Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Setup Security Key Enforcement for Google Cloud Platform admin accounts.", + "RationaleStatement": "Google Cloud Platform users with Organization Administrator roles have the highest level of privilege in the organization. These accounts should be protected with the strongest form of two-factor authentication: Security Key Enforcement. Ensure that admins use Security Keys to log in instead of weaker second factors like SMS or one-time passwords (OTP). Security Keys are actual physical keys used to access Google Organization Administrator Accounts. They send an encrypted signature rather than a code, ensuring that logins cannot be phished.", + "ImpactStatement": "If an organization administrator loses access to their security key, the user could lose access to their account. For this reason, it is important to set up backup security keys.", + "RemediationProcedure": "1. Identify users with the Organization Administrator role. 2. Setup Security Key Enforcement for each account. Learn more at: [https://cloud.google.com/security-key/](https://cloud.google.com/security-key/)", + "AuditProcedure": "1. Identify users with Organization Administrator privileges: gcloud organizations get-iam-policy ORGANIZATION_ID Look for members granted the role roles/resourcemanager.organizationAdmin. 2. Manually verify that Security Key Enforcement has been enabled for each account.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/security-key/:https://gsuite.google.com/learn-more/key_for_working_smarter_faster_and_more_securely.html", + "DefaultValue": "By default, Security Key Enforcement is not enabled for Organization Administrators.", + "SubSection": null + } + ] + }, + { + "Id": "1.5", + "Description": "Ensure That There Are Only GCP-Managed Service Account Keys for Each Service Account", + "Checks": [ + "iam_sa_no_user_managed_keys" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "User-managed service accounts should not have user-managed keys.", + "RationaleStatement": "Anyone who has access to the keys will be able to access resources through the service account. GCP-managed keys are used by Cloud Platform services such as App Engine and Compute Engine. These keys cannot be downloaded. Google will keep the keys and automatically rotate them on an approximately weekly basis. User-managed keys are created, downloadable, and managed by users. They expire 10 years from creation. For user-managed keys, the user has to take ownership of key management activities which include: - Key storage - Key distribution - Key revocation - Key rotation - Protecting the keys from unauthorized users - Key recovery Even with key owner precautions, keys can be easily leaked by common development malpractices like checking keys into the source code or leaving them in the Downloads directory, or accidentally leaving them on support blogs/channels. It is recommended to prevent user-managed service account keys.", + "ImpactStatement": "Deleting user-managed service account keys may break communication with the applications using the corresponding keys.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console using `https://console.cloud.google.com/iam-admin/iam` 2. In the left navigation pane, click `Service accounts`. All service accounts and their corresponding keys are listed. 3. Click the service account. 4. Click the `edit` and delete the keys. **From Google Cloud CLI** To delete a user managed Service Account Key, gcloud iam service-accounts keys delete --iam-account= **Prevention:** You can disable service account key creation through the `Disable service account key creation` Organization policy by visiting [https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountKeyCreation](https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountKeyCreation). Learn more at: [https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts) In addition, if you do not need to have service accounts in your project, you can also prevent the creation of service accounts through the `Disable service account creation` Organization policy: [https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountCreation](https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountCreation).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console using `https://console.cloud.google.com/iam-admin/iam` 2. In the left navigation pane, click `Service accounts`. All service accounts and their corresponding keys are listed. 3. Click the service accounts and check if keys exist. **From Google Cloud CLI** List All the service accounts: gcloud iam service-accounts list Identify user-managed service accounts which have an account `EMAIL` ending with `iam.gserviceaccount.com` For each user-managed service account, list the keys managed by the user: gcloud iam service-accounts keys list --iam-account= --managed-by=user No keys should be listed.", + "AdditionalInformation": "A user-managed key cannot be created on GCP-Managed Service Accounts.", + "References": "https://cloud.google.com/iam/docs/understanding-service-accounts#managing_service_account_keys:https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts", + "DefaultValue": "By default, there are no user-managed keys created for user-managed service accounts.", + "SubSection": null + } + ] + }, + { + "Id": "1.6", + "Description": "Ensure That Service Account Has No Admin Privileges", + "Checks": [ + "iam_sa_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A service account is a special Google account that belongs to an application or a VM, instead of to an individual end-user. The application uses the service account to call the service's Google API so that users aren't directly involved. It's recommended not to use admin access for ServiceAccount.", + "RationaleStatement": "Service accounts represent service-level security of the Resources (application or a VM) which can be determined by the roles assigned to it. Enrolling ServiceAccount with Admin rights gives full access to an assigned application or a VM. A ServiceAccount Access holder can perform critical actions like delete, update change settings, etc. without user intervention. For this reason, it's recommended that service accounts not have Admin rights.", + "ImpactStatement": "Removing `*Admin` or `*admin` or `Editor` or `Owner` role assignments from service accounts may break functionality that uses impacted service accounts. Required role(s) should be assigned to impacted service accounts in order to restore broken functionalities.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. Under the `IAM` Tab look for `VIEW BY PRINCIPALS` 3. Filter `PRINCIPALS` using `type : Service account` 4. Look for the Service Account with the Principal nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com` 5. Identify `User-Managed user created` service account with roles containing `*Admin` or `*admin` or role matching `Editor` or role matching `Owner` under `Role` Column. 6. Click on `Edit (Pencil Icon)` for the Service Account, it will open all the roles which are assigned to the Service Account. 7. Click the `Delete bin` icon to remove the role from the Principal (service account in this case) **From Google Cloud CLI** gcloud projects get-iam-policy PROJECT_ID --format json > iam.json 1. Using a text editor, Remove `Role` which contains `roles/*Admin` or `roles/*admin` or matched `roles/editor` or matches 'roles/owner`. Add a role to the bindings array that defines the group members and the role for those members. For example, to grant the role roles/appengine.appViewer to the `ServiceAccount` which is roles/editor, you would change the example shown below as follows: { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appViewer }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY= } 2. Update the project's IAM policy: gcloud projects set-iam-policy PROJECT_ID iam.json ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. Under the `IAM` Tab look for `VIEW BY PRINCIPALS` 3. Filter `PRINCIPALS` using `type : Service account` 4. Look for the Service Account with the nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com` 5. Ensure that there are no such Service Accounts with roles containing `*Admin` or `*admin` or role matching `Editor` or role matching `Owner` under `Role` column. **From Google Cloud CLI** 1. Get the policy that you want to modify, and write it to a JSON file: gcloud projects get-iam-policy PROJECT_ID --format json > iam.json 2. The contents of the JSON file will look similar to the following. Note that `role` of members group associated with each `serviceaccount` does not contain `*Admin` or `*admin` or does not match `roles/editor` or does not match `roles/owner`. This recommendation is only applicable to `User-Managed user-created` service accounts. These accounts have the nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com`. Note that some Google-managed, Google-created service accounts have the same naming format, and should be excluded (e.g., `appsdev-apps-dev-script-auth@system.gserviceaccount.com` which needs the Owner role). **Sample Json output:** { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appAdmin }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY=, version: 1 }", + "AdditionalInformation": "Default (user-managed but not user-created) service accounts have the `Editor (roles/editor)` role assigned to them to support GCP services they offer. Such Service accounts are: `PROJECT_NUMBER-compute@developer.gserviceaccount.com`, `PROJECT_ID@appspot.gserviceaccount.com`.", + "References": "https://cloud.google.com/sdk/gcloud/reference/iam/service-accounts/:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/understanding-service-accounts", + "DefaultValue": "User Managed (and not user-created) default service accounts have the `Editor (roles/editor)` role assigned to them to support GCP services they offer. By default, there are no roles assigned to `User Managed User created` service accounts.", + "SubSection": null + } + ] + }, + { + "Id": "1.7", + "Description": "Ensure That IAM Users Are Not Assigned the Service Account User or Service Account Token Creator Roles at Project Level", + "Checks": [ + "iam_no_service_roles_at_project_level" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to assign the `Service Account User (iam.serviceAccountUser)` and `Service Account Token Creator (iam.serviceAccountTokenCreator)` roles to a user for a specific service account rather than assigning the role to a user at project level.", + "RationaleStatement": "A service account is a special Google account that belongs to an application or a virtual machine (VM), instead of to an individual end-user. Application/VM-Instance uses the service account to call the service's Google API so that users aren't directly involved. In addition to being an identity, a service account is a resource that has IAM policies attached to it. These policies determine who can use the service account. Users with IAM roles to update the App Engine and Compute Engine instances (such as App Engine Deployer or Compute Instance Admin) can effectively run code as the service accounts used to run these instances, and indirectly gain access to all the resources for which the service accounts have access. Similarly, SSH access to a Compute Engine instance may also provide the ability to execute code as that instance/Service account. Based on business needs, there could be multiple user-managed service accounts configured for a project. Granting the `iam.serviceAccountUser` or `iam.serviceAccountTokenCreator` roles to a user for a project gives the user access to all service accounts in the project, including service accounts that may be created in the future. This can result in elevation of privileges by using service accounts and corresponding `Compute Engine instances`. In order to implement `least privileges` best practices, IAM users should not be assigned the `Service Account User` or `Service Account Token Creator` roles at the project level. Instead, these roles should be assigned to a user for a specific service account, giving that user access to the service account. The `Service Account User` allows a user to bind a service account to a long-running job service, whereas the `Service Account Token Creator` role allows a user to directly impersonate (or assert) the identity of a service account.", + "ImpactStatement": "After revoking `Service Account User` or `Service Account Token Creator` roles at the project level from all impacted user account(s), these roles should be assigned to a user(s) for specific service account(s) according to business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console by visiting: [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam). 2. Click on the filter table text bar. Type `Role: Service Account User` 3. Click the `Delete Bin` icon in front of the role `Service Account User` for every user listed as a result of a filter. 4. Click on the filter table text bar. Type `Role: Service Account Token Creator` 5. Click the `Delete Bin` icon in front of the role `Service Account Token Creator` for every user listed as a result of a filter. **From Google Cloud CLI** 1. Using a text editor, remove the bindings with the `roles/iam.serviceAccountUser` or `roles/iam.serviceAccountTokenCreator`. For example, you can use the iam.json file shown below as follows: { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appViewer }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY= } 2. Update the project's IAM policy: gcloud projects set-iam-policy PROJECT_ID iam.json ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console by visiting [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam) 2. Click on the filter table text bar, Type `Role: Service Account User`. 3. Ensure no user is listed as a result of the filter. 4. Click on the filter table text bar, Type `Role: Service Account Token Creator`. 3. Ensure no user is listed as a result of the filter. **From Google Cloud CLI** To ensure IAM users are not assigned Service Account User role at the project level: gcloud projects get-iam-policy PROJECT_ID --format json | jq '.bindings[].role' | grep roles/iam.serviceAccountUser gcloud projects get-iam-policy PROJECT_ID --format json | jq '.bindings[].role' | grep roles/iam.serviceAccountTokenCreator These commands should not return any output.", + "AdditionalInformation": "To assign the role `roles/iam.serviceAccountUser` or `roles/iam.serviceAccountTokenCreator` to a user role on a service account instead of a project: 1. Go to [https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts](https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts) 2. Select ` Target Project` 3. Select `target service account`. Click `Permissions` on the top bar. It will open permission pane on right side of the page 4. Add desired members with `Service Account User` or `Service Account Token Creator` role.", + "References": "https://cloud.google.com/iam/docs/service-accounts:https://cloud.google.com/iam/docs/granting-roles-to-service-accounts:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/granting-changing-revoking-access:https://console.cloud.google.com/iam-admin/iam", + "DefaultValue": "By default, users do not have the Service Account User or Service Account Token Creator role assigned at project level.", + "SubSection": null + } + ] + }, + { + "Id": "1.8", + "Description": "Ensure User-Managed/External Keys for Service Accounts Are Rotated Every 90 Days or Fewer", + "Checks": [ + "iam_sa_user_managed_key_rotate_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Service Account keys consist of a key ID (Private_key_Id) and Private key, which are used to sign programmatic requests users make to Google cloud services accessible to that particular service account. It is recommended that all Service Account keys are regularly rotated.", + "RationaleStatement": "Rotating Service Account keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. Service Account keys should be rotated to ensure that data cannot be accessed with an old key that might have been lost, cracked, or stolen. Each service account is associated with a key pair managed by Google Cloud Platform (GCP). It is used for service-to-service authentication within GCP. Google rotates the keys daily. GCP provides the option to create one or more user-managed (also called external key pairs) key pairs for use from outside GCP (for example, for use with Application Default Credentials). When a new key pair is created, the user is required to download the private key (which is not retained by Google). With external keys, users are responsible for keeping the private key secure and other management operations such as key rotation. External keys can be managed by the IAM API, gcloud command-line tool, or the Service Accounts page in the Google Cloud Platform Console. GCP facilitates up to 10 external service account keys per service account to facilitate key rotation.", + "ImpactStatement": "Rotating service account keys will break communication for dependent applications. Dependent applications need to be configured manually with the new key `ID` displayed in the `Service account keys` section and the `private key` downloaded by the user.", + "RemediationProcedure": "**From Google Cloud Console** **Delete any external (user-managed) Service Account Key older than 90 days:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the Section `Service Account Keys`, for every external (user-managed) service account key where `creation date` is greater than or equal to the past 90 days, click `Delete Bin Icon` to `Delete Service Account key` **Create a new external (user-managed) Service Account Key for a Service Account:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. Click `Create Credentials` and Select `Service Account Key`. 3. Choose the service account in the drop-down list for which an External (user-managed) Service Account key needs to be created. 4. Select the desired key type format among `JSON` or `P12`. 5. Click `Create`. It will download the `private key`. Keep it safe. 6. Click `Close` if prompted. 7. The site will redirect to the `APIs & ServicesCredentials` page. Make a note of the new `ID` displayed in the `Service account keys` section.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `Service Account Keys`, for every External (user-managed) service account key listed ensure the `creation date` is within the past 90 days. **From Google Cloud CLI** 1. List all Service accounts from a project. gcloud iam service-accounts list 2. For every service account list service account keys. gcloud iam service-accounts keys list --iam-account [Service_Account_Email_Id] --format=json 3. Ensure every service account key for a service account has a `validAfterTime` value within the past 90 days.", + "AdditionalInformation": "For user-managed Service Account key(s), key management is entirely the user's responsibility.", + "References": "https://cloud.google.com/iam/docs/understanding-service-accounts#managing_service_account_keys:https://cloud.google.com/sdk/gcloud/reference/iam/service-accounts/keys/list:https://cloud.google.com/iam/docs/service-accounts", + "DefaultValue": "GCP does not provide an automation option for External (user-managed) Service key rotation.", + "SubSection": null + } + ] + }, + { + "Id": "1.9", + "Description": "Ensure That Separation of Duties Is Enforced While Assigning Service Account Related Roles to Users", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the principle of 'Separation of Duties' is enforced while assigning service-account related roles to users.", + "RationaleStatement": "The built-in/predefined IAM role `Service Account admin` allows the user/identity to create, delete, and manage service account(s). The built-in/predefined IAM role `Service Account User` allows the user/identity (with adequate privileges on Compute and App Engine) to assign service account(s) to Apps/Compute Instances. Separation of duties is the concept of ensuring that one individual does not have all necessary permissions to be able to complete a malicious action. In Cloud IAM - service accounts, this could be an action such as using a service account to access resources that user should not normally have access to. Separation of duties is a business control typically used in larger organizations, meant to help avoid security or privacy incidents and errors. It is considered best practice. No user should have `Service Account Admin` and `Service Account User` roles assigned at the same time.", + "ImpactStatement": "The removed role should be assigned to a different user based on business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam`. 2. For any member having both `Service Account Admin` and `Service account User` roles granted/assigned, click the `Delete Bin` icon to remove either role from the member. Removal of a role should be done based on the business requirements.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam`. 2. Ensure no member has the roles `Service Account Admin` and `Service account User` assigned together. **From Google Cloud CLI** 1. List all users and role assignments: gcloud projects get-iam-policy [Project_ID] --format json | jq -r '[ ([Service_Account_Admin_and_User] | (., map(length*-))), ( [ .bindings[] | select(.role == roles/iam.serviceAccountAdmin or .role == roles/iam.serviceAccountUser).members[] ] | group_by(.) | map({User: ., Count: length}) | .[] | select(.Count == 2).User | unique ) ] | .[] | @tsv' 2. All common users listed under `Service_Account_Admin_and_User` are assigned both the `roles/iam.serviceAccountAdmin` and `roles/iam.serviceAccountUser` roles.", + "AdditionalInformation": "Users granted with Owner (roles/owner) and Editor (roles/editor) have privileges equivalent to `Service Account Admin` and `Service Account User`. To avoid the misuse, Owner and Editor roles should be granted to very limited users and Use of these primitive privileges should be minimal. These requirements are addressed in separate recommendations.", + "References": "https://cloud.google.com/iam/docs/service-accounts:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/granting-roles-to-service-accounts", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "1.10", + "Description": "Ensure That Cloud KMS Cryptokeys Are Not Anonymously or Publicly Accessible", + "Checks": [ + "kms_key_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the IAM policy on Cloud KMS `cryptokeys` should restrict anonymous and/or public access.", + "RationaleStatement": "Granting permissions to `allUsers` or `allAuthenticatedUsers` allows anyone to access the dataset. Such access might not be desirable if sensitive data is stored at the location. In this case, ensure that anonymous and/or public access to a Cloud KMS `cryptokey` is not allowed.", + "ImpactStatement": "Removing the binding for `allUsers` and `allAuthenticatedUsers` members denies accessing `cryptokeys` to anonymous or public users.", + "RemediationProcedure": "**From Google Cloud CLI** 1. List all Cloud KMS `Cryptokeys`. gcloud kms keys list --keyring=[key_ring_name] --location=global --format=json | jq '.[].name' 2. Remove IAM policy binding for a KMS key to remove access to `allUsers` and `allAuthenticatedUsers` using the below command. gcloud kms keys remove-iam-policy-binding [key_name] --keyring=[key_ring_name] --location=global --member='allAuthenticatedUsers' --role='[role]' gcloud kms keys remove-iam-policy-binding [key_name] --keyring=[key_ring_name] --location=global --member='allUsers' --role='[role]' ", + "AuditProcedure": "**From Google Cloud CLI** 1. List all Cloud KMS `Cryptokeys`. gcloud kms keys list --keyring=[key_ring_name] --location=global --format=json | jq '.[].name' 2. Ensure the below command's output does not contain `allUsers` or `allAuthenticatedUsers`. gcloud kms keys get-iam-policy [key_name] --keyring=[key_ring_name] --location=global --format=json | jq '.bindings[].members[]' ", + "AdditionalInformation": "[key_ring_name] : Is the resource ID of the key ring, which is the fully-qualified Key ring name. This value is case-sensitive and in the form: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING You can retrieve the key ring resource ID using the Cloud Console: 1. Open the `Cryptographic Keys` page in the Cloud Console. 2. For the key ring whose resource ID you are retrieving, click the `More icon (3 vertical dots)`. 3. Click `Copy Resource ID`. The resource ID for the key ring is copied to your clipboard. [key_name] : Is the resource ID of the key, which is the fully-qualified CryptoKey name. This value is case-sensitive and in the form: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY You can retrieve the key resource ID using the Cloud Console: 1. Open the `Cryptographic Keys` page in the Cloud Console. 2. Click the name of the key ring that contains the key. 3. For the key whose resource ID you are retrieving, click the `More icon (3 vertical dots)`. 4. Click `Copy Resource ID`. The resource ID for the key is copied to your clipboard. [role] : The role to remove the member from.", + "References": "https://cloud.google.com/sdk/gcloud/reference/kms/keys/remove-iam-policy-binding:https://cloud.google.com/sdk/gcloud/reference/kms/keys/set-iam-policy:https://cloud.google.com/sdk/gcloud/reference/kms/keys/get-iam-policy:https://cloud.google.com/kms/docs/object-hierarchy#key_resource_id", + "DefaultValue": "By default Cloud KMS does not allow access to `allUsers` or `allAuthenticatedUsers`.", + "SubSection": null + } + ] + }, + { + "Id": "1.11", + "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", + "Checks": [ + "kms_key_rotation_max_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Google Cloud Key Management Service stores cryptographic keys in a hierarchical structure designed for useful and elegant access control management. The format for the rotation schedule depends on the client library that is used. For the gcloud command-line tool, the next rotation time must be in `ISO` or `RFC3339` format, and the rotation period must be in the form `INTEGER[UNIT]`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).", + "RationaleStatement": "Set a key rotation period and starting time. A key can be created with a specified `rotation period`, which is the time between when new key versions are generated automatically. A key can also be created with a specified next rotation time. A key is a named object representing a `cryptographic key` used for a specific purpose. The key material, the actual bits used for `encryption`, can change over time as new key versions are created. A key is used to protect some `corpus of data`. A collection of files could be encrypted with the same key and people with `decrypt` permissions on that key would be able to decrypt those files. Therefore, it's necessary to make sure the `rotation period` is set to a specific time.", + "ImpactStatement": "After a successful key rotation, the older key version is required in order to decrypt the data encrypted by that previous key version.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Cryptographic Keys` by visiting: [https://console.cloud.google.com/security/kms](https://console.cloud.google.com/security/kms). 2. Click on the specific key ring 3. From the list of keys, choose the specific key and Click on `Right side pop up the blade (3 dots)`. 4. Click on `Edit rotation period`. 5. On the pop-up window, `Select a new rotation period` in days which should be less than 90 and then choose `Starting on` date (date from which the rotation period begins). **From Google Cloud CLI** 1. Update and schedule rotation by `ROTATION_PERIOD` and `NEXT_ROTATION_TIME` for each key: gcloud kms keys update new --keyring=KEY_RING --location=LOCATION --next-rotation-time=NEXT_ROTATION_TIME --rotation-period=ROTATION_PERIOD ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Cryptographic Keys` by visiting: [https://console.cloud.google.com/security/kms](https://console.cloud.google.com/security/kms). 2. Click on each key ring, then ensure each key in the keyring has `Next Rotation` set for less than 90 days from the current date. **From Google Cloud CLI** 1. Ensure rotation is scheduled by `ROTATION_PERIOD` and `NEXT_ROTATION_TIME` for each key : gcloud kms keys list --keyring= --location= --format=json'(rotationPeriod)' Ensure outcome values for `rotationPeriod` and `nextRotationTime` satisfy the below criteria: `rotationPeriod is <= 129600m` `rotationPeriod is <= 7776000s` `rotationPeriod is <= 2160h` `rotationPeriod is <= 90d` `nextRotationTime is <= 90days` from current DATE", + "AdditionalInformation": "- Key rotation does NOT re-encrypt already encrypted data with the newly generated key version. If you suspect unauthorized use of a key, you should re-encrypt the data protected by that key and then disable or schedule destruction of the prior key version. - It is not recommended to rely solely on irregular rotation, but rather to use irregular rotation if needed in conjunction with a regular rotation schedule.", + "References": "https://cloud.google.com/kms/docs/key-rotation#frequency_of_key_rotation:https://cloud.google.com/kms/docs/re-encrypt-data", + "DefaultValue": "By default, KMS encryption keys are rotated every 90 days.", + "SubSection": null + } + ] + }, + { + "Id": "1.12", + "Description": "Ensure That Separation of Duties Is Enforced While Assigning KMS Related Roles to Users", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the principle of 'Separation of Duties' is enforced while assigning KMS related roles to users.", + "RationaleStatement": "The built-in/predefined IAM role `Cloud KMS Admin` allows the user/identity to create, delete, and manage service account(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Encrypter/Decrypter` allows the user/identity (with adequate privileges on concerned resources) to encrypt and decrypt data at rest using an encryption key(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Encrypter` allows the user/identity (with adequate privileges on concerned resources) to encrypt data at rest using an encryption key(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Decrypter` allows the user/identity (with adequate privileges on concerned resources) to decrypt data at rest using an encryption key(s). Separation of duties is the concept of ensuring that one individual does not have all necessary permissions to be able to complete a malicious action. In Cloud KMS, this could be an action such as using a key to access and decrypt data a user should not normally have access to. Separation of duties is a business control typically used in larger organizations, meant to help avoid security or privacy incidents and errors. It is considered best practice. No user(s) should have `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` roles assigned at the same time.", + "ImpactStatement": "Removed roles should be assigned to another user based on business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. For any member having `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` roles granted/assigned, click the `Delete Bin` icon to remove the role from the member. Note: Removing a role should be done based on the business requirement.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` by visiting: [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam) 2. Ensure no member has the roles `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` assigned. **From Google Cloud CLI** 1. List all users and role assignments: gcloud projects get-iam-policy PROJECT_ID 2. Ensure that there are no common users found in the member section for roles `cloudkms.admin` and any one of `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter`", + "AdditionalInformation": "Users granted with Owner (roles/owner) and Editor (roles/editor) have privileges equivalent to `Cloud KMS Admin` and `Cloud KMS CryptoKey Encrypter/Decrypter`. To avoid misuse, Owner and Editor roles should be granted to a very limited group of users. Use of these primitive privileges should be minimal.", + "References": "https://cloud.google.com/kms/docs/separation-of-duties", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "1.13", + "Description": "Ensure API Keys Only Exist for Active Services", + "Checks": [ + "apikeys_key_exists" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. Unused keys with their permissions in tact may still exist within a project. Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to use standard authentication flow instead.", + "RationaleStatement": "To avoid the security risk in using API keys, it is recommended to use standard authentication flow instead. Security risks involved in using API-Keys appear below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key", + "ImpactStatement": "Deleting an API key will break dependent applications (if any).", + "RemediationProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using https://console.cloud.google.com/apis/credentials. 1. In the section `API Keys`, to delete API Keys: Click the `Delete Bin Icon` in front of every `API Key Name`. **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter 1. Run the following command, providing the ID of the key or fully qualified identifier for the key for : gcloud services api-keys delete ", + "AuditProcedure": "**From Console:** 1. From within the Project you wish to audit Go to `APIs & ServicesCredentials`. 1. In the section `API Keys`, no API key should be listed. **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter 1. There should be no keys listed at the project level.", + "AdditionalInformation": "Google recommends using the standard authentication flow instead of using API keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. If a business requires API keys to be used, then the API keys should be secured properly.", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/list:https://cloud.google.com/docs/authentication:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/delete", + "DefaultValue": "By default, API keys are not created for a project.", + "SubSection": null + } + ] + }, + { + "Id": "1.14", + "Description": "Ensure API Keys Are Restricted To Use by Only Specified Hosts and Apps", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. In this case, unrestricted keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to restrict API key usage to trusted hosts, HTTP referrers and apps. It is recommended to use the more secure standard authentication flow instead.", + "RationaleStatement": "Security risks involved in using API-Keys appear below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key In light of these potential risks, Google recommends using the standard authentication flow instead of API keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. In order to reduce attack vectors, API-Keys can be restricted only to trusted hosts, HTTP referrers and applications.", + "ImpactStatement": "Setting `Application Restrictions` may break existing application functioning, if not done carefully.", + "RemediationProcedure": "**From Google Cloud Console** ***Leaving Keys in Place*** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. In the `Key restrictions` section, set the application restrictions to any of `HTTP referrers, IP addresses, Android apps, iOS apps`. 4. Click `Save`. 1. Repeat steps 2,3,4 for every unrestricted API key. **Note:** Do not set `HTTP referrers` to wild-cards (* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s) Do not set `IP addresses` and referrer to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` ***Removing Keys*** Another option is to remove the keys entirely. 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, select the checkbox next to each key you wish to remove 3. Select `Delete` and confirm.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 1. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 1. For every API Key, ensure the section `Key restrictions` parameter `Application restrictions` is not set to `None`. Or, 1. Ensure `Application restrictions` is set to `HTTP referrers` and the referrer is not set to wild-cards `(* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s)` Or, 1. Ensure `Application restrictions` is set to `IP addresses` and referrer is not set to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter=-restrictions:* --format=table[box](displayName:label='Key With No Restrictions') ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/list:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "DefaultValue": "By default, `Application Restrictions` are set to `None`.", + "SubSection": null + } + ] + }, + { + "Id": "1.15", + "Description": "Ensure API Keys Are Restricted to Only APIs That Application Needs Access", + "Checks": [ + "apikeys_api_restrictions_configured" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. API keys are always at risk because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to restrict API keys to use (call) only APIs required by an application.", + "RationaleStatement": "Security risks involved in using API-Keys are below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key In light of these potential risks, Google recommends using the standard authentication flow instead of API-Keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. In order to reduce attack surfaces by providing `least privileges`, API-Keys can be restricted to use (call) only APIs required by an application.", + "ImpactStatement": "Setting `API restrictions` may break existing application functioning, if not done carefully.", + "RemediationProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. In the `Key restrictions` section go to `API restrictions`. 4. Click the `Select API` drop-down to choose an API. 5. Click `Save`. 6. Repeat steps 2,3,4,5 for every unrestricted API key **Note:** Do not set `API restrictions` to `Google Cloud APIs`, as this option allows access to all services offered by Google cloud. **From Google Cloud CLI** 1. List all API keys. gcloud services api-keys list 2. Note the `UID` of the key to add restrictions to. 3. Run the update command with the appropriate API target service or flags file with API target services and methods to add the required restrictions. Command with appropriate API target service: gcloud services api-keys update --api-target=service= Command with flags file: gcloud services api-keys update --flags-file=.yaml Content of flags file: - --api-target: service: foo.service.com - --api-target: service: bar.service.com methods: - foomethod - barmethod Note: Flags can be found by running: gcloud services api-keys update --help Note: Services can be found by running: gcloud services list or in this documentation https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "AuditProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. For every API Key, ensure the section `Key restrictions` parameter `API restrictions` is not set to `None`. Or, Ensure `API restrictions` is not set to `Google Cloud APIs` **Note:** `Google Cloud APIs` represents the API collection of all cloud services/APIs offered by Google cloud. **From Google Cloud CLI** 1. List all API Keys. gcloud services api-keys list Each key should have a line that says `restrictions:` followed by varying parameters and NOT have a line saying `- service: cloudapis.googleapis.com` as shown here restrictions: apiTargets: - service: cloudapis.googleapis.com ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/apis/docs/overview", + "DefaultValue": "By default, `API restrictions` are set to `None`.", + "SubSection": null + } + ] + }, + { + "Id": "1.16", + "Description": "Ensure API Keys Are Rotated Every 90 Days", + "Checks": [ + "apikeys_key_rotated_in_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. If they are in use it is recommended to rotate API keys every 90 days.", + "RationaleStatement": "Security risks involved in using API-Keys are listed below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key Because of these potential risks, Google recommends using the standard authentication flow instead of API Keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. Once a key is stolen, it has no expiration, meaning it may be used indefinitely unless the project owner revokes or regenerates the key. Rotating API keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. API keys should be rotated to ensure that data cannot be accessed with an old key that might have been lost, cracked, or stolen.", + "ImpactStatement": "`Regenerating Key` may break existing client connectivity as the client will try to connect with older API keys they have stored on devices.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. Click `REGENERATE KEY` to rotate API key. 4. Click `Save`. 5. Repeat steps 2,3,4 for every API key that has not been rotated in the last 90 days. **Note:** Do not set `HTTP referrers` to wild-cards (* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s) Do not set `IP addresses` and referrer to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` **From Google Cloud CLI** There is not currently a way to regenerate and API key using gcloud commands. To 'regenerate' a key you will need to create a new one, duplicate the restrictions from the key being rotated, and delete the old key. 1. List existing keys. gcloud services api-keys list 2. Note the `UID` and restrictions of the key to regenerate. 3. Run this command to create a new API key. is the display name of the new key. ` gcloud services api-keys create --display-name= ` Note the `UID` of the newly created key 4. Run the update command to add required restrictions. Note - the restriction may vary for each key. Refer to this documentation for the appropriate flags. https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update gcloud services api-keys update 5. Delete the old key. gcloud services api-keys delete ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, for every key ensure the `creation date` is less than 90 days. **From Google Cloud CLI** To list keys, use the command gcloud services api-keys list Ensure the date in `createTime` is within 90 days.", + "AdditionalInformation": "There is no option to automatically regenerate (rotate) API keys periodically.", + "References": "https://developers.google.com/maps/api-security-best-practices#regenerate-apikey:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "1.17", + "Description": "Ensure Essential Contacts is Configured for Organization", + "Checks": [ + "iam_organization_essential_contacts_configured" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that Essential Contacts is configured to designate email addresses for Google Cloud services to notify of important technical or security information.", + "RationaleStatement": "Many Google Cloud services, such as Cloud Billing, send out notifications to share important information with Google Cloud users. By default, these notifications are sent to members with certain Identity and Access Management (IAM) roles. With Essential Contacts, you can customize who receives notifications by providing your own list of contacts.", + "ImpactStatement": "There is no charge for Essential Contacts except for the 'Technical Incidents' category that is only available to premium support customers.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Essential Contacts` by visiting https://console.cloud.google.com/iam-admin/essential-contacts 2. Make sure the organization appears in the resource selector at the top of the page. The resource selector tells you what project, folder, or organization you are currently managing contacts for. 3. Click `+Add contact` 4. In the `Email` and `Confirm Email` fields, enter the email address of the contact. 5. From the `Notification categories` drop-down menu, select the notification categories that you want the contact to receive communications for. 6. Click `Save` **From Google Cloud CLI** 1. To add an organization Essential Contacts run a command: gcloud essential-contacts create --email= --notification-categories= --organization= ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Essential Contacts` by visiting https://console.cloud.google.com/iam-admin/essential-contacts 2. Make sure the organization appears in the resource selector at the top of the page. The resource selector tells you what project, folder, or organization you are currently managing contacts for. 3. Ensure that appropriate email addresses are configured for each of the following notification categories: - `Legal` - `Security` - `Suspension` - `Technical` Alternatively, appropriate email addresses can be configured for the `All` notification category to receive all possible important notifications. **From Google Cloud CLI** 1. To list all configured organization Essential Contacts run a command: gcloud essential-contacts list --organization= 2. Ensure at least one appropriate email address is configured for each of the following notification categories: - `LEGAL` - `SECURITY` - `SUSPENSION` - `TECHNICAL` Alternatively, appropriate email addresses can be configured for the `ALL` notification category to receive all possible important notifications.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/resource-manager/docs/managing-notification-contacts", + "DefaultValue": "By default, there are no Essential Contacts configured. In the absence of an Essential Contact, the following IAM roles are used to identify users to notify for the following categories: - **Legal**: `roles/billing.admin` - **Security**: `roles/resourcemanager.organizationAdmin` - **Suspension**: `roles/owner` - **Technical**: `roles/owner` - **Technical Incidents**: `roles/owner`", + "SubSection": null + } + ] + }, + { + "Id": "1.18", + "Description": "Ensure Secrets are Not Stored in Cloud Functions Environment Variables by Using Secret Manager", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Google Cloud Functions allow you to host serverless code that is executed when an event is triggered, without the requiring the management a host operating system. These functions can also store environment variables to be used by the code that may contain authentication or other information that needs to remain confidential.", + "RationaleStatement": "It is recommended to use the Secret Manager, because environment variables are stored unencrypted, and accessible for all users who have access to the code.", + "ImpactStatement": "There should be no impact on the Cloud Function. There are minor costs after 10,000 requests a month to the Secret Manager API as well for a high use of other functions. Modifying the Cloud Function to use the Secret Manager may prevent it running to completion.", + "RemediationProcedure": "Enable Secret Manager API for your Project **From Google Cloud Console** 1. Within the project you wish to enable, select the Navigation hamburger menu in the top left. Hover over 'APIs & Services' to under the heading 'Serverless', then select 'Enabled APIs & Services' in the menu that opens up. 2. Click the button '+ Enable APIS and Services' 3. In the Search bar, search for 'Secret Manager API' and select it. 4. Click the blue box that says 'Enable'. **From Google Cloud CLI** 1. Within the project you wish to enable the API in, run the following command. gcloud services enable Secret Manager API Reviewing Environment Variables That Should Be Migrated to Secret Manager **From Google Cloud Console** 1. Log in to the Google Cloud Web Portal (https://console.cloud.google.com/) 1. Go to Cloud Functions 1. Click on a function name from the list 1. Click on Edit and review the Runtime environment for variables that should be secrets. Leave this list open for the next step. **From Google Cloud CLI** 1. To view a list of your cloud functions run gcloud functions list 2. For each cloud function run the following command. gcloud functions describe 3. Review the settings of the buildEnvironmentVariables and environmentVariables. Keep this information for the next step. Migrating Environment Variables to Secrets within the Secret Manager **From Google Cloud Console** 1. Go to the Secret Manager page in the Cloud Console. 1. On the Secret Manager page, click Create Secret. 1. On the Create secret page, under Name, enter the name of the Environment Variable you are replacing. This will then be the Secret Variable you will reference in your code. 1. You will also need to add a version. This is the actual value of the variable that will be referenced from the code. To add a secret version when creating the initial secret, in the Secret value field, enter the value from the Environment Variable you are replacing. 1. Leave the Regions section unchanged. 1. Click the Create secret button. 1. Repeat for all Environment Variables **From Google Cloud CLI** 1. Run the following command with the Environment Variable name you are replacing in the ``. It is most secure to point this command to a file with the Environment Variable value located in it, as if you entered it via command line it would show up in your shell’s command history. gcloud secrets create --data-file=/path/to/file.txt Granting your Runtime's Service Account Access to Secrets **From Google Cloud Console** 1. Within the project containing your runtime login with account that has the 'roles/secretmanager.secretAccessor' permission. 2. Select the Navigation hamburger menu in the top left. Hover over 'Security' to under the then select 'Secret Manager' in the menu that opens up. 3. Click the name of a secret listed in this screen. 4. If it is not already open, click Show Info Panel in this screen to open the panel. 5.In the info panel, click Add principal. 6.In the New principals field, enter the service account your function uses for its identity. (If you need help locating or updating your runtime's service account, please see the 'docs/securing/function-identity#runtime_service_account' reference.) 7. In the Select a role dropdown, choose Secret Manager and then Secret Manager Secret Accessor. **From Google Cloud CLI** As of the time of writing, using Google CLI to list Runtime variables is only in beta. Because this is likely to change we are not including it here. Modifying the Code to use the Secrets in Secret Manager **From Google Cloud Console** This depends heavily on which language your runtime is in. For the sake of the brevity of this recommendation, please see the '/docs/creating-and-accessing-secrets#access' reference for language specific instructions. **From Google Cloud CLI** This depends heavily on which language your runtime is in. For the sake of the brevity of this recommendation, please see the' /docs/creating-and-accessing-secrets#access' reference for language specific instructions. Deleting the Insecure Environment Variables **Be certain to do this step last.** Removing variables from code actively referencing them will prevent it from completing successfully. **From Google Cloud Console** 1. Select the Navigation hamburger menu in the top left. Hover over 'Security' then select 'Secret Manager' in the menu that opens up. 1. Click the name of a function. Click Edit. 1. Click Runtime, build and connections settings to expand the advanced configuration options. 1. Click 'Security’. Hover over the secret you want to remove, then click 'Delete'. 1. Click Next. Click Deploy. The latest version of the runtime will now reference the secrets in Secret Manager. **From Google Cloud CLI** gcloud functions deploy --remove-env-vars If you need to find the env vars to remove, they are from the step where ‘gcloud functions describe ``’ was run.", + "AuditProcedure": "Determine if Confidential Information is Stored in your Functions in Cleartext **From Google Cloud Console** 1. Within the project you wish to audit, select the Navigation hamburger menu in the top left. Scroll down to under the heading 'Serverless', then select 'Cloud Functions' 1. Click on a function name from the list 1. Open the Variables tab and you will see both buildEnvironmentVariables and environmentVariables 1. Review the variables whether they are secrets 1. Repeat step 3-5 until all functions are reviewed **From Google Cloud CLI** 1. To view a list of your cloud functions run gcloud functions list 2. For each cloud function in the list run the following command. gcloud functions describe 3. Review the settings of the buildEnvironmentVariables and environmentVariables. Determine if this is data that should not be publicly accessible. Determine if Secret Manager API is 'Enabled' for your Project **From Google Cloud Console** 1. Within the project you wish to audit, select the Navigation hamburger menu in the top left. Hover over 'APIs & Services' to under the heading 'Serverless', then select 'Enabled APIs & Services' in the menu that opens up. 1. Click the button '+ Enable APIS and Services' 1. In the Search bar, search for 'Secret Manager API' and select it. 1. If it is enabled, the blue box that normally says 'Enable' will instead say 'Manage'. **From Google Cloud CLI** 1. Within the project you wish to audit, run the following command. gcloud services list 2. If 'Secret Manager API' is in the list, it is enabled.", + "AdditionalInformation": "There are slight additional costs to using the Secret Manager API. Review the documentation to determine your organizations' needs.", + "References": "https://cloud.google.com/functions/docs/configuring/env-var#managing_secrets:https://cloud.google.com/secret-manager/docs/overview", + "DefaultValue": "By default Secret Manager is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "2.1", + "Description": "Ensure That Cloud Audit Logging Is Configured Properly", + "Checks": [ + "iam_audit_logs_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that Cloud Audit Logging is configured to track all admin activities and read, write access to user data.", + "RationaleStatement": "Cloud Audit Logging maintains two audit logs for each project, folder, and organization: Admin Activity and Data Access. 1. Admin Activity logs contain log entries for API calls or other administrative actions that modify the configuration or metadata of resources. Admin Activity audit logs are enabled for all services and cannot be configured. 2. Data Access audit logs record API calls that create, modify, or read user-provided data. These are disabled by default and should be enabled. There are three kinds of Data Access audit log information: - Admin read: Records operations that read metadata or configuration information. Admin Activity audit logs record writes of metadata and configuration information that cannot be disabled. - Data read: Records operations that read user-provided data. - Data write: Records operations that write user-provided data. It is recommended to have an effective default audit config configured in such a way that: 1. logtype is set to DATA_READ (to log user activity tracking) and DATA_WRITES (to log changes/tampering to user data). 2. audit config is enabled for all the services supported by the Data Access audit logs feature. 3. Logs should be captured for all users, i.e., there are no exempted users in any of the audit config sections. This will ensure overriding the audit config will not contradict the requirement.", + "ImpactStatement": "There is no charge for Admin Activity audit logs. Enabling the Data Access audit logs might result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Audit Logs` by visiting [https://console.cloud.google.com/iam-admin/audit](https://console.cloud.google.com/iam-admin/audit). 2. Follow the steps at [https://cloud.google.com/logging/docs/audit/configure-data-access](https://cloud.google.com/logging/docs/audit/configure-data-access) to enable audit logs for all Google Cloud services. Ensure that no exemptions are allowed. **From Google Cloud CLI** 1. To read the project's IAM policy and store it in a file run a command: gcloud projects get-iam-policy PROJECT_ID > /tmp/project_policy.yaml Alternatively, the policy can be set at the organization or folder level. If setting the policy at the organization level, it is not necessary to also set it for each folder or project. gcloud organizations get-iam-policy ORGANIZATION_ID > /tmp/org_policy.yaml gcloud resource-manager folders get-iam-policy FOLDER_ID > /tmp/folder_policy.yaml 2. Edit policy in /tmp/policy.yaml, adding or changing only the audit logs configuration to: **Note: Admin Activity Logs are enabled by default, and cannot be disabled. So they are not listed in these configuration changes.** auditConfigs: - auditLogConfigs: - logType: DATA_WRITE - logType: DATA_READ service: allServices **Note:** `exemptedMembers:` is not set as audit logging should be enabled for all the users 3. To write new IAM policy run command: gcloud organizations set-iam-policy ORGANIZATION_ID /tmp/org_policy.yaml gcloud resource-manager folders set-iam-policy FOLDER_ID /tmp/folder_policy.yaml gcloud projects set-iam-policy PROJECT_ID /tmp/project_policy.yaml If the preceding command reports a conflict with another change, then repeat these steps, starting with the first step.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Audit Logs` by visiting [https://console.cloud.google.com/iam-admin/audit](https://console.cloud.google.com/iam-admin/audit). 2. Ensure that Admin Read, Data Write, and Data Read are enabled for all Google Cloud services and that no exemptions are allowed. **From Google Cloud CLI** 1. List the Identity and Access Management (IAM) policies for the project, folder, or organization: gcloud organizations get-iam-policy ORGANIZATION_ID gcloud resource-manager folders get-iam-policy FOLDER_ID gcloud projects get-iam-policy PROJECT_ID 2. Policy should have a default auditConfigs section which has the logtype set to DATA_WRITES and DATA_READ for all services. Note that projects inherit settings from folders, which in turn inherit settings from the organization. When called, projects get-iam-policy, the result shows only the policies set in the project, not the policies inherited from the parent folder or organization. Nevertheless, if the parent folder has Cloud Audit Logging enabled, the project does as well. Sample output for default audit configs may look like this: auditConfigs: - auditLogConfigs: - logType: ADMIN_READ - logType: DATA_WRITE - logType: DATA_READ service: allServices 3. Any of the auditConfigs sections should not have parameter exemptedMembers: set, which will ensure that Logging is enabled for all users and no user is exempted.", + "AdditionalInformation": "- Log type `DATA_READ` is equally important to that of `DATA_WRITE` to track detailed user activities. - BigQuery Data Access logs are handled differently from other data access logs. BigQuery logs are enabled by default and cannot be disabled. They do not count against logs allotment and cannot result in extra logs charges.", + "References": "https://cloud.google.com/logging/docs/audit/:https://cloud.google.com/logging/docs/audit/configure-data-access", + "DefaultValue": "Admin Activity logs are always enabled. They cannot be disabled. Data Access audit logs are disabled by default because they can be quite large.", + "SubSection": null + } + ] + }, + { + "Id": "2.2", + "Description": "Ensure Google Workspace/Cloud Identity Data Sharing with Google Cloud is Enabled for Admin Audit Logging", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Google Workspace and Cloud Identity maintain audit logs for administrative actions, such as user creation, password changes, and modifications to security settings. By default, these logs are only available within the Google Admin Console. Enabling the \"Sharing of data with Google Cloud Services\" setting allows these logs to flow into the Google Cloud (GCP) Organization's Cloud Logging. This is critical for centralized security monitoring, long-term log retention, and the creation of automated alerts for high-risk identity events like privilege escalation.", + "RationaleStatement": "Without centralized logging of identity provider events, security teams cannot easily correlate Workspace/Identity administrative changes with GCP resource changes. If a Super Admin account is compromised, the audit trail must be preserved in a secure, centralized location to prevent an attacker from deleting evidence within the Workspace console.", + "ImpactStatement": "", + "AuditProcedure": "**From Google Admin Console**\n\n1. Log in to the Google Admin Console as a Super Admin.\n2. From the Home page, go to `Account` > `Account settings`.\n3. Click on the `Legal and compliance` section.\n4. Locate the setting titled `Sharing options - Google Cloud Platform Sharing Options` and verify that the status is set to `Enabled`.", + "RemediationProcedure": "**From Google Admin Console**\n\n1. Log in to the Google Admin Console as a Super Admin.\n2. From the Home page, go to `Account` > `Account settings`.\n3. Click on the `Legal and compliance` section.\n4. Locate the setting titled `Sharing options - Google Cloud Platform Sharing Options`, click on `Enabled` and click `Save`.\n5. (Optional but Recommended) Navigate to the GCP Console > `Logging` > `Logs Explorer` and verify that the logs are arriving from the admin portal.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, Google Workspace/Cloud Identity admin audit logs are only available within the Google Admin Console and are not shared with Google Cloud." + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure That Sinks Are Configured for All Log Entries", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to create a sink that will export copies of all the log entries. This can help aggregate logs from multiple projects and export them to a Security Information and Event Management (SIEM).", + "RationaleStatement": "Log entries are held in Cloud Logging. To aggregate logs, export them to a SIEM. To keep them longer, it is recommended to set up a log sink. Exporting involves writing a filter that selects the log entries to export, and choosing a destination in Cloud Storage, BigQuery, or Cloud Pub/Sub. The filter and destination are held in an object called a sink. To ensure all log entries are exported to sinks, ensure that there is no filter configured for a sink. Sinks can be created in projects, organizations, folders, and billing accounts.", + "ImpactStatement": "There are no costs or limitations in Cloud Logging for exporting logs, but the export destinations charge for storing or transmitting the log data.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Logs Router` by visiting [https://console.cloud.google.com/logs/router](https://console.cloud.google.com/logs/router). 2. Click on the arrow symbol with `CREATE SINK` text. 3. Fill out the fields for `Sink details`. 4. Choose Cloud Logging bucket in the Select sink destination drop down menu. 5. Choose a log bucket in the next drop down menu. 6. If an inclusion filter is not provided for this sink, all ingested logs will be routed to the destination provided above. This may result in higher than expected resource usage. 7. Click `Create Sink`. For more information, see [https://cloud.google.com/logging/docs/export/configure_export_v2#dest-create](https://cloud.google.com/logging/docs/export/configure_export_v2#dest-create). **From Google Cloud CLI** To create a sink to export all log entries in a Google Cloud Storage bucket: gcloud logging sinks create storage.googleapis.com/DESTINATION_BUCKET_NAME Sinks can be created for a folder or organization, which will include all projects. gcloud logging sinks create storage.googleapis.com/DESTINATION_BUCKET_NAME --include-children --folder=FOLDER_ID | --organization=ORGANIZATION_ID **Note:** 1. A sink created by the command-line above will export logs in storage buckets. However, sinks can be configured to export logs into BigQuery, or Cloud Pub/Sub, or `Custom Destination`. 2. While creating a sink, the sink option `--log-filter` is not used to ensure the sink exports all log entries. 3. A sink can be created at a folder or organization level that collects the logs of all the projects underneath bypassing the option `--include-children` in the gcloud command.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Logs Router` by visiting [https://console.cloud.google.com/logs/router](https://console.cloud.google.com/logs/router). 2. For every sink, click the 3-dot button for Menu options and select `View sink details`. 3. Ensure there is at least one sink with an `empty` Inclusion filter. 4. Additionally, ensure that the resource configured as `Destination` exists. **From Google Cloud CLI** 1. Ensure that a sink with an `empty filter` exists. List the sinks for the project, folder or organization. If sinks are configured at a folder or organization level, they do not need to be configured for each project: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID The output should list at least one sink with an `empty filter`. 2. Additionally, ensure that the resource configured as `Destination` exists. See [https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list](https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list) for more information.", + "AdditionalInformation": "For Command-Line Audit and Remediation, the sink destination of type `Cloud Storage Bucket` is considered. However, the destination could be configured to `Cloud Storage Bucket` or `BigQuery` or `Cloud PubSub` or `Custom Destination`. Command Line Interface commands would change accordingly.", + "References": "https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/logging/quotas:https://cloud.google.com/logging/docs/routing/overview:https://cloud.google.com/logging/docs/export/using_exported_logs:https://cloud.google.com/logging/docs/export/configure_export_v2:https://cloud.google.com/logging/docs/export/aggregated_exports:https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list", + "DefaultValue": "By default, there are no sinks configured.", + "SubSection": null + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure That Retention Policies on Cloud Storage Buckets Used for Exporting Logs Are Configured Using Bucket Lock", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Enabling retention policies on log buckets will protect logs stored in cloud storage buckets from being overwritten or accidentally deleted. It is recommended to set up retention policies and configure Bucket Lock on all storage buckets that are used as log sinks.", + "RationaleStatement": "Logs can be exported by creating one or more sinks that include a log filter and a destination. As Cloud Logging receives new log entries, they are compared against each sink. If a log entry matches a sink's filter, then a copy of the log entry is written to the destination. Sinks can be configured to export logs in storage buckets. It is recommended to configure a data retention policy for these cloud storage buckets and to lock the data retention policy; thus permanently preventing the policy from being reduced or removed. This way, if the system is ever compromised by an attacker or a malicious insider who wants to cover their tracks, the activity logs are definitely preserved for forensics and security investigations.", + "ImpactStatement": "Locking a bucket is an irreversible action. Once you lock a bucket, you cannot remove the retention policy from the bucket or decrease the retention period for the policy. You will then have to wait for the retention period for all items within the bucket before you can delete them, and then the bucket.", + "RemediationProcedure": "**From Google Cloud Console** 1. If sinks are **not** configured, first follow the instructions in the recommendation: `Ensure that sinks are configured for all Log entries`. 2. For each storage bucket configured as a sink, go to the Cloud Storage browser at `https://console.cloud.google.com/storage/browser/`. 3. Select the Bucket Lock tab near the top of the page. 4. In the Retention policy entry, click the Add Duration link. The `Set a retention policy` dialog box appears. 5. Enter the desired length of time for the retention period and click `Save policy`. 6. Set the `Lock status` for this retention policy to `Locked`. **From Google Cloud CLI** 1. To list all sinks destined to storage buckets: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID 2. For each storage bucket listed above, set a retention policy and lock it: gsutil retention set [TIME_DURATION] gs://[BUCKET_NAME] gsutil retention lock gs://[BUCKET_NAME] For more information, visit [https://cloud.google.com/storage/docs/using-bucket-lock#set-policy](https://cloud.google.com/storage/docs/using-bucket-lock#set-policy).", + "AuditProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. In the Column display options menu, make sure `Retention policy` is checked. 3. In the list of buckets, the retention period of each bucket is found in the `Retention policy` column. If the retention policy is locked, an image of a lock appears directly to the left of the retention period. **From Google Cloud CLI** 1. To list all sinks destined to storage buckets: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID 2. For every storage bucket listed above, verify that retention policies and Bucket Lock are enabled: gsutil retention get gs://BUCKET_NAME For more information, see [https://cloud.google.com/storage/docs/using-bucket-lock#view-policy](https://cloud.google.com/storage/docs/using-bucket-lock#view-policy).", + "AdditionalInformation": "Caution: Locking a retention policy is an irreversible action. Once locked, you must delete the entire bucket in order to remove the bucket's retention policy. However, before you can delete the bucket, you must be able to delete all the objects in the bucket, which itself is only possible if all the objects have reached the retention period set by the retention policy.", + "References": "https://cloud.google.com/storage/docs/bucket-lock:https://cloud.google.com/storage/docs/using-bucket-lock:https://cloud.google.com/storage/docs/bucket-lock", + "DefaultValue": "By default, storage buckets used as log sinks do not have retention policies and Bucket Lock configured.", + "SubSection": null + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure Log Metric Filter and Alerts Exist for Project Ownership Assignments/Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "In order to prevent unnecessary project ownership assignments to users/service-accounts and further misuses of projects and resources, all `roles/Owner` assignments should be monitored. Members (users/Service-Accounts) with a role assignment to primitive role `roles/Owner` are project owners. The project owner has all the privileges on the project the role belongs to. These are summarized below: - All viewer permissions on all GCP Services within the project - Permissions for actions that modify the state of all GCP services within the project - Manage roles and permissions for a project and all resources within the project - Set up billing for a project Granting the owner role to a member (user/Service-Account) will allow that member to modify the Identity and Access Management (IAM) policy. Therefore, grant the owner role only if the member has a legitimate purpose to manage the IAM policy. This is because the project IAM policy contains sensitive access control data. Having a minimal set of users allowed to manage IAM policy will simplify any auditing that may be necessary.", + "RationaleStatement": "Project ownership has the highest level of privileges on a project. To avoid misuse of project resources, the project ownership assignment/change actions mentioned above should be monitored and alerted to concerned recipients. - Sending project ownership invites - Acceptance/Rejection of project ownership invite by user - Adding `roleOwner` to a user/service-account - Removing a user/Service account from `roleOwner`", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: (protoPayload.serviceName=cloudresourcemanager.googleapis.com) AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=REMOVE AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=ADD AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) 4. Click `Submit Filter`. The logs display based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and the `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 6. Click `Create Metric`. **Create the display prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the desired metric and select `Create alert from Metric`. A new page opens. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create a prescribed Log Metric: - Use the command: gcloud beta logging metrics create - Reference for Command Usage: https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create Create prescribed Alert Policy - Use the command: gcloud alpha monitoring policies create - Reference for Command Usage: https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Log-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with filter text: (protoPayload.serviceName=cloudresourcemanager.googleapis.com) AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=REMOVE AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=ADD AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) **Ensure that the prescribed Alerting Policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for your organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: (protoPayload.serviceName=cloudresourcemanager.googleapis.com) AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=REMOVE AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=ADD AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "1. Project ownership assignments for a user cannot be done using the gcloud utility as assigning project ownership to a user requires sending, and the user accepting, an invitation. 2. Project Ownership assignment to a service account does not send any invites. SetIAMPolicy to `role/owner`is directly performed on service accounts.", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Audit Configuration Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Google Cloud Platform (GCP) services write audit log entries to the Admin Activity and Data Access logs to help answer the questions of, who did what, where, and when? within GCP projects. Cloud audit logging records information includes the identity of the API caller, the time of the API call, the source IP address of the API caller, the request parameters, and the response elements returned by GCP services. Cloud audit logging provides a history of GCP API calls for an account, including API calls made via the console, SDKs, command-line tools, and other GCP services.", + "RationaleStatement": "Admin activity and data access logs produced by cloud audit logging enable security analysis, resource change tracking, and compliance auditing. Configuring the metric filter and alerts for audit configuration changes ensures the recommended state of audit configuration is maintained so that all activities in the project are audit-able at any point in time.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This will ensure that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create a prescribed Alert Policy:** 1. Identify the new metric the user just created, under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page opens. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create a prescribed Log Metric: - Use the command: gcloud beta logging metrics create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create ](https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create) Create prescribed Alert Policy - Use the command: gcloud alpha monitoring policies create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create](https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create)", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than zero(0) seconds`, means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud beta logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/logging/docs/audit/configure-data-access#getiampolicy-setiampolicy", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.7", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Custom Role Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for changes to Identity and Access Management (IAM) role creation, deletion and updating activities.", + "RationaleStatement": "Google Cloud IAM provides predefined roles that give granular access to specific Google Cloud Platform resources and prevent unwanted access to other resources. However, to cater to organization-specific needs, Cloud IAM also provides the ability to create custom roles. Project owners and administrators with the Organization Role Administrator role or the IAM Role Administrator role can create custom roles. Monitoring role creation, deletion and updating activities will help in identifying any over-privileged role at early stages.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Console:** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 1. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 1. Clear any text and add: resource.type=iam_role AND (protoPayload.methodName = google.iam.admin.v1.CreateRole OR protoPayload.methodName=google.iam.admin.v1.DeleteRole OR protoPayload.methodName=google.iam.admin.v1.UpdateRole OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) 1. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 1. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 1. Click `Create Metric`. **Create a prescribed Alert Policy:** 1. Identify the new metric that was just created under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 1. Configure the desired notification channels in the section `Notifications`. 1. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed Alert Policy: - Use the command: gcloud alpha monitoring policies create ", + "AuditProcedure": "**From Console:** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with filter text: resource.type=iam_role AND (protoPayload.methodName=google.iam.admin.v1.CreateRole OR protoPayload.methodName=google.iam.admin.v1.DeleteRole OR protoPayload.methodName=google.iam.admin.v1.UpdateRole OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** Ensure that the prescribed log metric is present: 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=iam_role AND (protoPayload.methodName = google.iam.admin.v1.CreateRole OR protoPayload.methodName=google.iam.admin.v1.DeleteRole OR protoPayload.methodName=google.iam.admin.v1.UpdateRole OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/iam/docs/understanding-custom-roles", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.8", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Firewall Rule Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) Network Firewall rule changes.", + "RationaleStatement": "Monitoring for Create or Update Firewall rule events gives insight to network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 6. Click `Create Metric`. **Create the prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with this filter text: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/vpc/docs/firewalls", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.9", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Route Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) network route changes.", + "RationaleStatement": "Google Cloud Platform (GCP) routes define the paths network traffic takes from a VM instance to another destination. The other destination can be inside the organization VPC network (such as another VM) or outside of it. Every route consists of a destination and a next hop. Traffic whose destination IP is within the destination range is sent to the next hop for delivery. Monitoring changes to route tables will help ensure that all VPC traffic flows through an expected path.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed Log Metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter` 3. Clear any text and add: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed the alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed Log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) **Ensure the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting: [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alert thresholds make sense for the user's organization. 5. Ensure that the appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/access-control/iam:https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create:https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.10", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) network changes.", + "RationaleStatement": "It is possible to have more than one VPC within a project. In addition, it is also possible to create a peer connection between two VPCs enabling network traffic to route between VPCs. Monitoring changes to a VPC will help ensure VPC traffic flow is not getting impacted.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gce_network AND (protoPayload.methodName:compute.networks.insert OR protoPayload.methodName:compute.networks.patch OR protoPayload.methodName:compute.networks.delete OR protoPayload.methodName:compute.networks.removePeering OR protoPayload.methodName:compute.networks.addPeering) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of 0 for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with filter text: resource.type=gce_network AND (protoPayload.methodName:compute.networks.insert OR protoPayload.methodName:compute.networks.patch OR protoPayload.methodName:compute.networks.delete OR protoPayload.methodName:compute.networks.removePeering OR protoPayload.methodName:compute.networks.addPeering) **Ensure the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than 0 seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure the log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: resource.type=gce_network AND protoPayload.methodName=beta.compute.networks.insert OR protoPayload.methodName=beta.compute.networks.patch OR protoPayload.methodName=v1.compute.networks.delete OR protoPayload.methodName=v1.compute.networks.removePeering OR protoPayload.methodName=v1.compute.networks.addPeering 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/vpc/docs/overview", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.11", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Cloud Storage IAM Permission Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Cloud Storage Bucket IAM changes.", + "RationaleStatement": "Monitoring changes to cloud storage bucket permissions may reduce the time needed to detect and correct permissions on sensitive cloud storage buckets and objects inside the bucket.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud beta logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. For each project that contains cloud storage buckets, go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with the filter text: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than 0 seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/overview:https://cloud.google.com/storage/docs/access-control/iam-roles", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.12", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for SQL Instance Configuration Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for SQL instance configuration changes.", + "RationaleStatement": "Monitoring changes to SQL instance configuration changes may reduce the time needed to detect and correct misconfigurations done on the SQL server. Below are a few of the configurable options which may the impact security posture of an SQL instance: - Enable auto backups and high availability: Misconfiguration may adversely impact business continuity, disaster recovery, and high availability - Authorize networks: Misconfiguration may increase exposure to untrusted networks", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed Log Metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: protoPayload.methodName=cloudsql.instances.update 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the user's project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed log metric: - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create](https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create)", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. For each project that contains Cloud SQL instances, go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: protoPayload.methodName=cloudsql.instances.update **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to protoPayload.methodName=cloudsql.instances.update 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/overview:https://cloud.google.com/sql/docs/:https://cloud.google.com/sql/docs/mysql/:https://cloud.google.com/sql/docs/postgres/", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "2.13", + "Description": "Ensure That Cloud DNS Logging Is Enabled for All VPC Networks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud DNS logging records the queries from the name servers within your VPC to Stackdriver. Logged queries can come from Compute Engine VMs, GKE containers, or other GCP resources provisioned within the VPC.", + "RationaleStatement": "Security monitoring and forensics cannot depend solely on IP addresses from VPC flow logs, especially when considering the dynamic IP usage of cloud resources, HTTP virtual host routing, and other technology that can obscure the DNS name used by a client from the IP address. Monitoring of Cloud DNS logs provides visibility to DNS names requested by the clients within the VPC. These logs can be monitored for anomalous domain names, evaluated against threat intelligence, and Note: For full capture of DNS, firewall must block egress UDP/53 (DNS) and TCP/443 (DNS over HTTPS) to prevent client from using external DNS name server for resolution.", + "ImpactStatement": "Enabling of Cloud DNS logging might result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud CLI** **Add New DNS Policy With Logging Enabled** For each VPC network that needs a DNS policy with logging enabled: gcloud dns policies create enable-dns-logging --enable-logging --description=Enable DNS Logging --networks=VPC_NETWORK_NAME The VPC_NETWORK_NAME can be one or more networks in comma-separated list **Enable Logging for Existing DNS Policy** For each VPC network that has an existing DNS policy that needs logging enabled: gcloud dns policies update POLICY_NAME --enable-logging --networks=VPC_NETWORK_NAME The VPC_NETWORK_NAME can be one or more networks in comma-separated list", + "AuditProcedure": "**From Google Cloud CLI** 1. List all VPCs networks in a project: gcloud compute networks list --format=table[box,title='All VPC Networks'](name:label='VPC Network Name') 2. List all DNS policies, logging enablement, and associated VPC networks: gcloud dns policies list --flatten=networks[] --format=table[box,title='All DNS Policies By VPC Network'](name:label='Policy Name',enableLogging:label='Logging Enabled':align=center,networks.networkUrl.basename():label='VPC Network Name') Each VPC Network should be associated with a DNS policy with logging enabled.", + "AdditionalInformation": "Additional Info - Only queries that reach a name server are logged. Cloud DNS resolvers cache responses, queries answered from caches, or direct queries to an external DNS resolver outside the VPC are not logged.", + "References": "https://cloud.google.com/dns/docs/monitoring", + "DefaultValue": "Cloud DNS logging is disabled by default on each network.", + "SubSection": null + } + ] + }, + { + "Id": "2.14", + "Description": "Ensure Cloud Asset Inventory Is Enabled", + "Checks": [ + "iam_cloud_asset_inventory_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "GCP Cloud Asset Inventory is services that provides a historical view of GCP resources and IAM policies through a time-series database. The information recorded includes metadata on Google Cloud resources, metadata on policies set on Google Cloud projects or resources, and runtime information gathered within a Google Cloud resource. Cloud Asset Inventory Service (CAIS) API enablement is not required for operation of the service, but rather enables the mechanism for searching/exporting CAIS asset data directly.", + "RationaleStatement": "The GCP resources and IAM policies captured by GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing. It is recommended GCP Cloud Asset Inventory be enabled for all GCP projects.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** Enable the Cloud Asset API: 1. Go to `API & Services/Library` by visiting [https://console.cloud.google.com/apis/library](https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Click the `ENABLE` button. **From Google Cloud CLI** Enable the Cloud Asset API: 1. Enable the Cloud Asset API through the services interface: gcloud services enable cloudasset.googleapis.com ", + "AuditProcedure": "**From Google Cloud Console** Ensure that the Cloud Asset API is enabled: 1. Go to `API & Services/Library` by visiting [https://console.cloud.google.com/apis/library](https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Ensure that `API Enabled` is displayed. **From Google Cloud CLI** Ensure that the Cloud Asset API is enabled: 1. Query enabled services: gcloud services list --enabled --filter=name:cloudasset.googleapis.com If the API is listed, then it is enabled. If the response is `Listed 0 items` the API is not enabled.", + "AdditionalInformation": "Additional info - Cloud Asset Inventory only keeps a five-week history of Google Cloud asset metadata. If a longer history is desired, automation to export the history to Cloud Storage or BigQuery should be evaluated. Users need not enable CAI API if they don't have any plans to export.", + "References": "https://cloud.google.com/asset-inventory/docs", + "DefaultValue": "The Cloud Asset Inventory API is disabled by default in each project.", + "SubSection": null + } + ] + }, + { + "Id": "2.15", + "Description": "Ensure 'Access Transparency' is 'Enabled'", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "GCP Access Transparency provides audit logs for all actions that Google personnel take in your Google Cloud resources.", + "RationaleStatement": "Controlling access to your information is one of the foundations of information security. Given that Google Employees do have access to your organizations' projects for support reasons, you should have logging in place to view who, when, and why your information is being accessed.", + "ImpactStatement": "To use Access Transparency your organization will need to have at one of the following support level: Premium, Enterprise, Platinum, or Gold. There will be subscription costs associated with support, as well as increased storage costs for storing the logs. You will also not be able to turn Access Transparency off yourself, and you will need to submit a service request to Google Cloud Support.", + "RemediationProcedure": "**From Google Cloud Console** **Add privileges to enable Access Transparency** 1. From the Google Cloud Home, within the project you wish to check, click on the Navigation hamburger menu in the top left. Hover over the 'IAM and Admin'. Select `IAM` in the top of the column that opens. 2. Click the blue button the says `+add` at the top of the screen. 3. In the `principals` field, select a user or group by typing in their associated email address. 4. Click on the `role` field to expand it. In the filter field enter `Access Transparency Admin` and select it. 5. Click `save`. **Verify that the Google Cloud project is associated with a billing account** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Select `Billing`. 2. If you see `This project is not associated with a billing account` you will need to enter billing information or switch to a project with a billing account. **Enable Access Transparency** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Hover over the IAM & Admin Menu. Select `settings` in the middle of the column that opens. 2. Click the blue button labeled Enable `Access Transparency for Organization`", + "AuditProcedure": "**From Google Cloud Console** **Determine if Access Transparency is Enabled** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Hover over the IAM & Admin Menu. Select `settings` in the middle of the column that opens. 2. The status will be under the heading `Access Transparency`. Status should be `Enabled`", + "AdditionalInformation": "To enable Access Transparency for your Google Cloud organization, your Google Cloud organization must have one of the following customer support levels: Premium, Enterprise, Platinum, or Gold.", + "References": "https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/overview:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/enable:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/reading-logs:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/reading-logs#justification_reason_codes:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/supported-services", + "DefaultValue": "By default Access Transparency is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "2.16", + "Description": "Ensure 'Access Approval' is 'Enabled'", + "Checks": [ + "iam_account_access_approval_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "GCP Access Approval enables you to require your organizations' explicit approval whenever Google support try to access your projects. You can then select users within your organization who can approve these requests through giving them a security role in IAM. All access requests display which Google Employee requested them in an email or Pub/Sub message that you can choose to Approve. This adds an additional control and logging of who in your organization approved/denied these requests.", + "RationaleStatement": "Controlling access to your information is one of the foundations of information security. Google Employees do have access to your organizations' projects for support reasons. With Access Approval, organizations can then be certain that their information is accessed by only approved Google Personnel.", + "ImpactStatement": "To use Access Approval your organization will need have enabled Access Transparency and have at one of the following support level: Enhanced or Premium. There will be subscription costs associated with these support levels, as well as increased storage costs for storing the logs. You will also not be able to turn the Access Transparency which Access Approval depends on, off yourself. To do so you will need to submit a service request to Google Cloud Support. There will also be additional overhead in managing user permissions. There may also be a potential delay in support times as Google Personnel will have to wait for their access to be approved.", + "RemediationProcedure": "**From Google Cloud Console** 1. From the Google Cloud Home, within the project you wish to enable, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. The status will be displayed here. On this screen, there is an option to click `Enroll`. If it is greyed out and you see an error bar at the top of the screen that says `Access Transparency is not enabled` please view the corresponding reference within this section to enable it. 3. In the second screen click `Enroll`. **Grant an IAM Group or User the role with permissions to Add Users to be Access Approval message Recipients** 1. From the Google Cloud Home, within the project you wish to enable, click on the Navigation hamburger menu in the top left. Hover over the `IAM and Admin`. Select `IAM` in the middle of the column that opens. 2. Click the blue button the says `+ ADD` at the top of the screen. 3. In the `principals` field, select a user or group by typing in their associated email address. 4. Click on the role field to expand it. In the filter field enter `Access Approval Approver` and select it. 5. Click `save`. **Add a Group or User as an Approver for Access Approval Requests** 1. As a user with the `Access Approval Approver` permission, within the project where you wish to add an email address to which request will be sent, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. Click `Manage Settings` 3. Under `Set up approval notifications`, enter the email address associated with a Google Cloud User or Group you wish to send Access Approval requests to. All future access approvals will be sent as emails to this address. **From Google Cloud CLI** 1. To update all services in an entire project, run the following command from an account that has permissions as an 'Approver for Access Approval Requests' gcloud access-approval settings update --project= --enrolled_services=all --notification_emails='@' ", + "AuditProcedure": "**From Google Cloud Console** **Determine if Access Transparency is Enabled as it is a Dependency** 1. From the Google Cloud Home inside the project you wish to audit, click on the Navigation hamburger menu in the top left. Hover over the `IAM & Admin` Menu. Select `settings` in the middle of the column that opens. 2. The status should be Enabled' under the heading `Access Transparency` **Determine if Access Approval is Enabled** 1. From the Google Cloud Home, within the project you wish to check, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. The status will be displayed here. If you see a screen saying you need to enroll in Access Approval, it is not enabled. **From Google Cloud CLI** **Determine if Access Approval is Enabled** 1. From within the project you wish to audit, run the following command. gcloud access-approval settings get 2. The status will be displayed in the output. IF Access Approval is not enabled you should get this output: API [accessapproval.googleapis.com] not enabled on project [-----]. Would you like to enable and retry (this will take a few minutes)? (y/N)? After entering `Y` if you get the following output, it means that `Access Transparency` is not enabled: ERROR: (gcloud.access-approval.settings.get) FAILED_PRECONDITION: Precondition check failed. ", + "AdditionalInformation": "The recipients of Access Requests will also need to be logged into a Google Cloud account associated with an email address in this list. To approve requests they can click approve within the email. Or they can view requests at the the Access Approval page within the Security submenu.", + "References": "https://cloud.google.com/cloud-provider-access-management/access-approval/docs:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/overview:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/quickstart-custom-key:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/supported-services:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/view-historical-requests", + "DefaultValue": "By default Access Approval and its dependency of Access Transparency are not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "2.17", + "Description": "Ensure Logging is enabled for HTTP(S) Load Balancer", + "Checks": [ + "compute_loadbalancer_logging_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Logging enabled on a HTTPS Load Balancer will show all network traffic and its destination.", + "RationaleStatement": "Logging will allow you to view HTTPS network traffic to your web applications.", + "ImpactStatement": "On high use systems with a high percentage sample rate, the logging file may grow to high capacity in a short amount of time. Ensure that the sample rate is set appropriately so that storage costs are not exorbitant.", + "RemediationProcedure": "**From Google Cloud Console** 1. From Google Cloud home open the Navigation Menu in the top left. 1. Under the `Networking` heading select `Network services`. 1. Select the HTTPS load-balancer you wish to audit. 1. Select `Edit` then `Backend Configuration`. 1. Select `Edit` on the corresponding backend service. 1. Click `Enable Logging`. 1. Set `Sample Rate` to a desired value. This is a percentage as a decimal point. 1.0 is 100%. **From Google Cloud CLI** 1. Run the following command gcloud compute backend-services update --region=REGION --enable-logging --logging-sample-rate= ", + "AuditProcedure": "**From Google Cloud Console** 1. From Google Cloud home open the Navigation Menu in the top left. 1. Under the `Networking` heading select `Network services`. 1. Select the HTTPS load-balancer you wish to audit. 1. Select `Edit` then `Backend Configuration`. 1. Select `Edit` on the corresponding backend service. 1. Ensure that `Enable Logging` is selected. Also ensure that `Sample Rate` is set to an appropriate level for your needs. **From Google Cloud CLI** 1. Run the following command gcloud compute backend-services describe 1. Ensure that enable-logging is enabled and sample rate is set to your desired level.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/load-balancing/:https://cloud.google.com/load-balancing/docs/https/https-logging-monitoring#gcloud:-global-mode:https://cloud.google.com/sdk/gcloud/reference/compute/backend-services/", + "DefaultValue": "By default logging for https load balancing is disabled. When logging is enabled it sets the default sample rate as 1.0 or 100%. Ensure this value fits the need of your organization to avoid high storage costs.", + "SubSection": null + } + ] + }, + { + "Id": "3.1", + "Description": "Ensure That the Default Network Does Not Exist in a Project", + "Checks": [ + "compute_network_default_in_use" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "To prevent use of `default` network, a project should not have a `default` network.", + "RationaleStatement": "The `default` network has a preconfigured network configuration and automatically generates the following insecure firewall rules: - default-allow-internal: Allows ingress connections for all protocols and ports among instances in the network. - default-allow-ssh: Allows ingress connections on TCP port 22(SSH) from any source to any instance in the network. - default-allow-rdp: Allows ingress connections on TCP port 3389(RDP) from any source to any instance in the network. - default-allow-icmp: Allows ingress ICMP traffic from any source to any instance in the network. These automatically created firewall rules do not get audit logged by default. Furthermore, the default network is an auto mode network, which means that its subnets use the same predefined range of IP addresses, and as a result, it's not possible to use Cloud VPN or VPC Network Peering with the default network. Based on organization security and networking requirements, the organization should create a new network and delete the `default` network.", + "ImpactStatement": "When an organization deletes the default network, it will need to remove all asests from that network and migrate them to a new network.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VPC networks` page by visiting: [https://console.cloud.google.com/networking/networks/list](https://console.cloud.google.com/networking/networks/list). 2. Click the network named `default`. 2. On the network detail page, click `EDIT`. 3. Click `DELETE VPC NETWORK`. 4. If needed, create a new network to replace the default network. **From Google Cloud CLI** For each Google Cloud Platform project, 1. Delete the default network: gcloud compute networks delete default 2. If needed, create a new network to replace it: gcloud compute networks create NETWORK_NAME **Prevention:** The user can prevent the default network and its insecure default firewall rules from being created by setting up an Organization Policy to `Skip default network creation` at [https://console.cloud.google.com/iam-admin/orgpolicies/compute-skipDefaultNetworkCreation](https://console.cloud.google.com/iam-admin/orgpolicies/compute-skipDefaultNetworkCreation).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VPC networks` page by visiting: [https://console.cloud.google.com/networking/networks/list](https://console.cloud.google.com/networking/networks/list). 2. Ensure that a network with the name `default` is not present. **From Google Cloud CLI** 1. Set the project name in the Google Cloud Shell: gcloud config set project PROJECT_ID 2. List the networks configured in that project: gcloud compute networks list It should not list `default` as one of the available networks in that project.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/networking#firewall_rules:https://cloud.google.com/compute/docs/reference/latest/networks/insert:https://cloud.google.com/compute/docs/reference/latest/networks/delete:https://cloud.google.com/vpc/docs/firewall-rules-logging:https://cloud.google.com/vpc/docs/vpc#default-network:https://cloud.google.com/sdk/gcloud/reference/compute/networks/delete", + "DefaultValue": "By default, for each project, a `default` network is created.", + "SubSection": null + } + ] + }, + { + "Id": "3.2", + "Description": "Ensure Legacy Networks Do Not Exist for Older Projects", + "Checks": [ + "compute_network_not_legacy" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "In order to prevent use of legacy networks, a project should not have a legacy network configured. As of now, Legacy Networks are gradually being phased out, and you can no longer create projects with them. This recommendation is to check older projects to ensure that they are not using Legacy Networks.", + "RationaleStatement": "Legacy networks have a single network IPv4 prefix range and a single gateway IP address for the whole network. The network is global in scope and spans all cloud regions. Subnetworks cannot be created in a legacy network and are unable to switch from legacy to auto or custom subnet networks. Legacy networks can have an impact for high network traffic projects and are subject to a single point of contention or failure.", + "ImpactStatement": "None.", + "RemediationProcedure": "**From Google Cloud CLI** For each Google Cloud Platform project, 1. Follow the documentation and create a non-legacy network suitable for the organization's requirements. 2. Follow the documentation and delete the networks in the `legacy` mode.", + "AuditProcedure": "**From Google Cloud CLI** For each Google Cloud Platform project, 1. Set the project name in the Google Cloud Shell: gcloud config set project 2. List the networks configured in that project: gcloud compute networks list None of the listed networks should be in the `legacy` mode.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/vpc/docs/using-legacy#creating_a_legacy_network:https://cloud.google.com/vpc/docs/using-legacy#deleting_a_legacy_network", + "DefaultValue": "By default, networks are not created in the `legacy` mode.", + "SubSection": null + } + ] + }, + { + "Id": "3.3", + "Description": "Ensure That DNSSEC Is Enabled for Cloud DNS", + "Checks": [ + "dns_dnssec_disabled" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud Domain Name System (DNS) is a fast, reliable and cost-effective domain name system that powers millions of domains on the internet. Domain Name System Security Extensions (DNSSEC) in Cloud DNS enables domain owners to take easy steps to protect their domains against DNS hijacking and man-in-the-middle and other attacks.", + "RationaleStatement": "Domain Name System Security Extensions (DNSSEC) adds security to the DNS protocol by enabling DNS responses to be validated. Having a trustworthy DNS that translates a domain name like www.example.com into its associated IP address is an increasingly important building block of today’s web-based applications. Attackers can hijack this process of domain/IP lookup and redirect users to a malicious site through DNS hijacking and man-in-the-middle attacks. DNSSEC helps mitigate the risk of such attacks by cryptographically signing DNS records. As a result, it prevents attackers from issuing fake DNS responses that may misdirect browsers to nefarious websites.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Cloud DNS` by visiting [https://console.cloud.google.com/net-services/dns/zones](https://console.cloud.google.com/net-services/dns/zones). 2. For each zone of `Type` `Public`, set `DNSSEC` to `On`. **From Google Cloud CLI** Use the below command to enable `DNSSEC` for Cloud DNS Zone Name. gcloud dns managed-zones update ZONE_NAME --dnssec-state on ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Cloud DNS` by visiting [https://console.cloud.google.com/net-services/dns/zones](https://console.cloud.google.com/net-services/dns/zones). 2. For each zone of `Type` `Public`, ensure that `DNSSEC` is set to `On`. **From Google Cloud CLI** 1. List all the Managed Zones in a project: gcloud dns managed-zones list 2. For each zone of `VISIBILITY` `public`, get its metadata: gcloud dns managed-zones describe ZONE_NAME 3. Ensure that `dnssecConfig.state` property is `on`.", + "AdditionalInformation": "", + "References": "https://cloudplatform.googleblog.com/2017/11/DNSSEC-now-available-in-Cloud-DNS.html:https://cloud.google.com/dns/dnssec-config#enabling:https://cloud.google.com/dns/dnssec", + "DefaultValue": "By default DNSSEC is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "3.4", + "Description": "Ensure That RSASHA1 Is Not Used for the Key-Signing Key in Cloud DNS DNSSEC", + "Checks": [ + "dns_rsasha1_in_use_to_key_sign_in_dnssec" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "NOTE: Currently, the SHA1 algorithm has been removed from general use by Google, and, if being used, needs to be whitelisted on a project basis by Google and will also, therefore, require a Google Cloud support contract. DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong.", + "RationaleStatement": "Domain Name System Security Extensions (DNSSEC) algorithm numbers in this registry may be used in CERT RRs. Zonesigning (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong. When enabling DNSSEC for a managed zone, or creating a managed zone with DNSSEC, the user can select the DNSSEC signing algorithms and the denial-of-existence type. Changing the DNSSEC settings is only effective for a managed zone if DNSSEC is not already enabled. If there is a need to change the settings for a managed zone where it has been enabled, turn DNSSEC off and then re-enable it with different settings.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud CLI** 1. If it is necessary to change the settings for a managed zone where it has been enabled, DNSSEC must be turned off and re-enabled with different settings. To turn off DNSSEC, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state off 2. To update key-signing for a reported managed DNS Zone, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state on --ksk-algorithm KSK_ALGORITHM --ksk-key-length KSK_KEY_LENGTH --zsk-algorithm ZSK_ALGORITHM --zsk-key-length ZSK_KEY_LENGTH --denial-of-existence DENIAL_OF_EXISTENCE Supported algorithm options and key lengths are as follows. Algorithm KSK Length ZSK Length --------- ---------- ---------- RSASHA1 1024,2048 1024,2048 RSASHA256 1024,2048 1024,2048 RSASHA512 1024,2048 1024,2048 ECDSAP256SHA256 256 256 ECDSAP384SHA384 384 384", + "AuditProcedure": "**From Google Cloud CLI** Ensure the property algorithm for keyType keySigning is not using `RSASHA1`. gcloud dns managed-zones describe ZONENAME --format=json(dnsName,dnssecConfig.state,dnssecConfig.defaultKeySpecs)", + "AdditionalInformation": "1. RSASHA1 key-signing support may be required for compatibility reasons. 2. Remediation CLI works well with gcloud-cli version 221.0.0 and later.", + "References": "https://cloud.google.com/dns/dnssec-advanced#advanced_signing_options", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.5", + "Description": "Ensure That RSASHA1 Is Not Used for the Zone-Signing Key in Cloud DNS DNSSEC", + "Checks": [ + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "NOTE: Currently, the SHA1 algorithm has been removed from general use by Google, and, if being used, needs to be whitelisted on a project basis by Google and will also, therefore, require a Google Cloud support contract. DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong.", + "RationaleStatement": "DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong. When enabling DNSSEC for a managed zone, or creating a managed zone with DNSSEC, the DNSSEC signing algorithms and the denial-of-existence type can be selected. Changing the DNSSEC settings is only effective for a managed zone if DNSSEC is not already enabled. If the need exists to change the settings for a managed zone where it has been enabled, turn DNSSEC off and then re-enable it with different settings.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud CLI** 1. If the need exists to change the settings for a managed zone where it has been enabled, DNSSEC must be turned off and then re-enabled with different settings. To turn off DNSSEC, run following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state off 2. To update zone-signing for a reported managed DNS Zone, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state on --ksk-algorithm KSK_ALGORITHM --ksk-key-length KSK_KEY_LENGTH --zsk-algorithm ZSK_ALGORITHM --zsk-key-length ZSK_KEY_LENGTH --denial-of-existence DENIAL_OF_EXISTENCE Supported algorithm options and key lengths are as follows. Algorithm KSK Length ZSK Length --------- ---------- ---------- RSASHA1 1024,2048 1024,2048 RSASHA256 1024,2048 1024,2048 RSASHA512 1024,2048 1024,2048 ECDSAP256SHA256 256 384 ECDSAP384SHA384 384 384", + "AuditProcedure": "**From Google Cloud CLI** Ensure the property algorithm for keyType zone signing is not using RSASHA1. gcloud dns managed-zones describe --format=json(dnsName,dnssecConfig.state,dnssecConfig.defaultKeySpecs) ", + "AdditionalInformation": "1. RSASHA1 zone-signing support may be required for compatibility reasons. 2. The remediation CLI works well with gcloud-cli version 221.0.0 and later.", + "References": "https://cloud.google.com/dns/dnssec-advanced#advanced_signing_options", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.6", + "Description": "Ensure That SSH Access Is Restricted From the Internet", + "Checks": [ + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow the user to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, only an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the internet to VPC or VM instance using `SSH` on `Port 22` can be avoided.", + "RationaleStatement": "GCP `Firewall Rules` within a `VPC Network` apply to outgoing (egress) traffic from instances and incoming (ingress) traffic to instances in the network. Egress and ingress traffic flows are controlled even if the traffic stays within the network (for example, instance-to-instance communication). For an instance to have outgoing Internet access, the network must have a valid Internet gateway route or custom route whose destination IP is specified. This route simply defines the path to the Internet, to avoid the most general `(0.0.0.0/0)` destination `IP Range` specified from the Internet through `SSH` with the default `Port 22`. Generic access from the Internet to a specific IP Range needs to be restricted.", + "ImpactStatement": "All Secure Shell (SSH) connections from outside of the network to the concerned VPC(s) will be blocked. There could be a business need where SSH access is required from outside of the network to access resources associated with the VPC. In that case, specific source IP(s) should be mentioned in firewall rules to white-list access to SSH port for the concerned VPC(s).", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `VPC Network`. 2. Go to the `Firewall Rules`. 3. Click the `Firewall Rule` you want to modify. 4. Click `Edit`. 5. Modify `Source IP ranges` to specific `IP`. 6. Click `Save`. **From Google Cloud CLI** 1.Update the Firewall rule with the new `SOURCE_RANGE` from the below command: gcloud compute firewall-rules update FirewallName --allow=[PROTOCOL[:PORT[-PORT]],...] --source-ranges=[CIDR_RANGE,...]", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `VPC network`. 2. Go to the `Firewall Rules`. 3. Ensure that `Port` is not equal to `22` and `Action` is not set to `Allow`. 4. Ensure `IP Ranges` is not equal to `0.0.0.0/0` under `Source filters`. **From Google Cloud CLI** gcloud compute firewall-rules list --format=table'(name,direction,sourceRanges,allowed)' Ensure that there is no rule matching the below criteria: - `SOURCE_RANGES` is `0.0.0.0/0` - AND `DIRECTION` is `INGRESS` - AND IPProtocol is `tcp` or `ALL` - AND `PORTS` is set to `22` or `range containing 22` or `Null (not set)` Note: - When ALL TCP ports are allowed in a rule, PORT does not have any value set (`NULL`) - When ALL Protocols are allowed in a rule, PORT does not have any value set (`NULL`)", + "AdditionalInformation": "Currently, GCP VPC only supports IPV4; however, Google is already working on adding IPV6 support for VPC. In that case along with source IP range `0.0.0.0`, the rule should be checked for IPv6 equivalent `::/0` as well.", + "References": "https://cloud.google.com/vpc/docs/firewalls#blockedtraffic:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.7", + "Description": "Ensure That RDP Access Is Restricted From the Internet", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.", + "RationaleStatement": "GCP `Firewall Rules` within a `VPC Network`. These rules apply to outgoing (egress) traffic from instances and incoming (ingress) traffic to instances in the network. Egress and ingress traffic flows are controlled even if the traffic stays within the network (for example, instance-to-instance communication). For an instance to have outgoing Internet access, the network must have a valid Internet gateway route or custom route whose destination IP is specified. This route simply defines the path to the Internet, to avoid the most general `(0.0.0.0/0)` destination `IP Range` specified from the Internet through `RDP` with the default `Port 3389`. Generic access from the Internet to a specific IP Range should be restricted.", + "ImpactStatement": "All Remote Desktop Protocol (RDP) connections from outside of the network to the concerned VPC(s) will be blocked. There could be a business need where secure shell access is required from outside of the network to access resources associated with the VPC. In that case, specific source IP(s) should be mentioned in firewall rules to white-list access to RDP port for the concerned VPC(s).", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `VPC Network`. 2. Go to the `Firewall Rules`. 3. Click the `Firewall Rule` to be modified. 4. Click `Edit`. 5. Modify `Source IP ranges` to specific `IP`. 6. Click `Save`. **From Google Cloud CLI** 1.Update RDP Firewall rule with new `SOURCE_RANGE` from the below command: gcloud compute firewall-rules update FirewallName --allow=[PROTOCOL[:PORT[-PORT]],...] --source-ranges=[CIDR_RANGE,...]", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `VPC network`. 2. Go to the `Firewall Rules`. 3. Ensure `Port` is not equal to `3389` and `Action` is not `Allow`. 4. Ensure `IP Ranges` is not equal to `0.0.0.0/0` under `Source filters`. **From Google Cloud CLI** gcloud compute firewall-rules list --format=table'(name,direction,sourceRanges,allowed)' Ensure that there is no rule matching the below criteria: - `SOURCE_RANGES` is `0.0.0.0/0` - AND `DIRECTION` is `INGRESS` - AND IPProtocol is `TCP` or `ALL` - AND `PORTS` is set to `3389` or `range containing 3389` or `Null (not set)` Note: - When ALL TCP ports are allowed in a rule, PORT does not have any value set (`NULL`) - When ALL Protocols are allowed in a rule, PORT does not have any value set (`NULL`)", + "AdditionalInformation": "Currently, GCP VPC only supports IPV4; however, Google is already working on adding IPV6 support for VPC. In that case along with source IP range `0.0.0.0`, the rule should be checked for IPv6 equivalent `::/0` as well.", + "References": "https://cloud.google.com/vpc/docs/firewalls#blockedtraffic:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "3.8", + "Description": "Ensure VPC Service Controls Is Enabled for Supported Google Cloud Services", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that VPC Service Controls are configured to create security perimeters around sensitive Google Cloud resources, preventing unauthorized data exfiltration and limiting access to resources from approved networks and identities. VPC Service Controls provide an additional layer of defense-in-depth by allowing organizations to define trust boundaries around GCP services and control data movement across those boundaries.", + "RationaleStatement": "VPC Service Controls provide critical protection against data exfiltration attacks by creating security perimeters that restrict data access based on client identity, device state, and network origin. Without VPC Service Controls, an attacker who compromises credentials or exploits misconfigurations can freely move data between projects, organizations, or external systems. VPC Service Controls enforce defense-in-depth by adding network-level access controls that complement IAM policies. This control is essential for organizations handling regulated data or implementing zero-trust security architectures, and it also protects against supply chain attacks by restricting which services and APIs can interact with protected resources.", + "ImpactStatement": "Implementing VPC Service Controls requires careful planning as it restricts network access to protected resources. Services outside the perimeter will be denied access to resources inside the perimeter by default, which may impact application connectivity, cross-project data pipelines, third-party integrations, and administrative access from non-corporate networks. Organizations should use VPC Service Controls dry-run mode to test policies before enforcement. There is no direct performance impact on services within the perimeter.", + "AuditProcedure": "Note: The following audit instructions use Cloud Storage (GCS) as an example. Similar steps should be followed for other supported services (BigQuery, Cloud SQL, Bigtable, etc.).\n\n**From Google Cloud CLI**\n\n1. Check if the Access Context Manager API is enabled:\n`gcloud services list --enabled --filter=\"name:accesscontextmanager.googleapis.com\" --format=\"value(name)\"`\n2. List Access Context Manager policies and perimeters in the organization:\n`gcloud access-context-manager policies list --organization=`\n`gcloud access-context-manager perimeters list --policy=`\n3. Check if a project's resources are protected by a perimeter and verify perimeter configuration with `gcloud access-context-manager perimeters describe --policy=`.\n4. Check whether perimeters have access levels configured for conditional access.", + "RemediationProcedure": "Note: The following remediation instructions use Cloud Storage (GCS) as an example. Similar steps should be followed for other supported services.\n\n**From Google Cloud CLI**\n\n1. Create an Access Context Manager policy if none exists: `gcloud access-context-manager policies create --organization= --title=\"VPC Service Controls Policy\"`.\n2. Create a service perimeter to protect resources: `gcloud access-context-manager perimeters create --resources=projects/ --restricted-services=storage.googleapis.com --policy= --perimeter-type=regular`.\n3. Add additional projects and restricted services to the perimeter as needed.\n4. Test with dry-run mode before enforcement and monitor dry-run logs for blocked requests, then enforce the perimeter with `gcloud access-context-manager perimeters dry-run enforce`.\n5. Optionally create access levels for conditional access and add them to the perimeter.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, VPC Service Controls are not configured and no service perimeters protect Google Cloud resources." + } + ] + }, + { + "Id": "3.9", + "Description": "Ensure Private Service Connect is Used for Access to Google APIs", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that VPCs use Private Service Connect endpoints for access to Google APIs such as Cloud Storage, BigQuery, Pub/Sub, Artifact Registry, and other googleapis.com services, so that traffic from workloads to these APIs stays on the Google private network instead of traversing the public internet. Private Service Connect for Google APIs creates a dedicated endpoint in your VPC with firewall rule enforcement, providing more granular control than Private Google Access.", + "RationaleStatement": "Accessing Google APIs over the public internet exposes traffic to network-level threats, requires managing NAT gateways or external IPs, and makes it harder to enforce egress controls. Private Service Connect for Google APIs allows workloads to reach googleapis.com services over Google's private network using a dedicated endpoint in your VPC. Combined with VPC firewall rules, PSC ensures only authorized workloads can access Google APIs, preventing unauthorized API usage and enforcing least-privilege network access. This control eliminates reliance on internet connectivity for API access, enables granular network segmentation per API consumer, supports VPC Service Controls integration, and is essential for zero-trust architectures.", + "ImpactStatement": "Implementing Private Service Connect for Google APIs requires creating PSC endpoints in each VPC and configuring DNS to resolve googleapis.com domains to the private endpoint IP addresses. This introduces per-endpoint charges and operational overhead for DNS management. Migrating from public API access requires updating DNS configurations and may require changes to firewall rules. Workloads without proper firewall rules or DNS configuration will be unable to reach Google APIs after migration.", + "AuditProcedure": "**From Google Cloud CLI**\n\n1. Identify in-scope VPCs that host production or sensitive workloads accessing Google APIs: `gcloud compute networks list --format=\"table(name)\"`.\n2. For each in-scope VPC, check for a Private Service Connect endpoint for Google APIs: `gcloud compute addresses list --filter=\"purpose=PRIVATE_SERVICE_CONNECT\"`. At least one PSC endpoint should exist per VPC that needs Google API access.\n3. Verify the PSC endpoint targets Google's service attachment by listing global forwarding rules and looking for targets containing `all-apis` or `vpc-sc`.\n4. Check Cloud DNS for a private zone for googleapis.com with a wildcard A record pointing to the PSC endpoint IP.\n5. Verify firewall rules restrict access to the PSC endpoint using specific sourceRanges/sourceTags and do not allow 0.0.0.0/0.", + "RemediationProcedure": "**From Google Cloud CLI**\n\n1. Reserve an internal IP for the PSC endpoint: `gcloud compute addresses create psc-apis-endpoint --global --purpose=PRIVATE_SERVICE_CONNECT --addresses= --network=projects//global/networks/`.\n2. Create the PSC endpoint forwarding rule targeting Google's all-apis bundle: `gcloud compute forwarding-rules create pscapis --global --network= --address=psc-apis-endpoint --target-google-apis-bundle=all-apis`.\n3. Configure DNS by creating a private zone for googleapis.com and a wildcard A record pointing to the PSC endpoint IP.\n4. Configure VPC firewall rules to restrict PSC endpoint access to specific subnets only.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, workloads access Google APIs over public Google API endpoints rather than through Private Service Connect endpoints." + } + ] + }, + { + "Id": "3.10", + "Description": "Ensure that VPC Flow Logs is Enabled for Every Subnet in a VPC Network", + "Checks": [ + "compute_subnet_flow_logs_enabled" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Flow Logs is a feature that enables users to capture information about the IP traffic going to and from network interfaces in the organization's VPC Subnets. Once a flow log is created, the user can view and retrieve its data in Stackdriver Logging. It is recommended that Flow Logs be enabled for every business-critical VPC subnet.", + "RationaleStatement": "VPC networks and subnetworks not reserved for internal HTTP(S) load balancing provide logically isolated and secure network partitions where GCP resources can be launched. When Flow Logs are enabled for a subnet, VMs within that subnet start reporting on all Transmission Control Protocol (TCP) and User Datagram Protocol (UDP) flows. Each VM samples the TCP and UDP flows it sees, inbound and outbound, whether the flow is to or from another VM, a host in the on-premises datacenter, a Google service, or a host on the Internet. If two GCP VMs are communicating, and both are in subnets that have VPC Flow Logs enabled, both VMs report the flows. Flow Logs supports the following use cases: - Network monitoring - Understanding network usage and optimizing network traffic expenses - Network forensics - Real-time security analysis Flow Logs provide visibility into network traffic for each VM inside the subnet and can be used to detect anomalous traffic or provide insight during security workflows. The Flow Logs must be configured such that all network traffic is logged, the interval of logging is granular to provide detailed information on the connections, no logs are filtered, and metadata to facilitate investigations are included. **Note**: Subnets reserved for use by internal HTTP(S) load balancers do not support VPC flow logs.", + "ImpactStatement": "Standard pricing for Stackdriver Logging, BigQuery, or Cloud Pub/Sub applies. VPC Flow Logs generation will be charged starting in GA as described in reference: https://cloud.google.com/vpc/", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the VPC network GCP Console visiting `https://console.cloud.google.com/networking/networks/list` 2. Click the name of a subnet, The `Subnet details` page displays. 3. Click the `EDIT` button. 4. Set `Flow Logs` to `On`. 5. Expand the `Configure Logs` section. 6. Set `Aggregation Interval` to `5 SEC`. 7. Check the box beside `Include metadata`. 8. Set `Sample rate` to `100`. 9. Click Save. **Note**: It is not possible to configure a Log filter from the console. **From Google Cloud CLI** To enable VPC Flow Logs for a network subnet, run the following command: gcloud compute networks subnets update [SUBNET_NAME] --region [REGION] --enable-flow-logs --logging-aggregation-interval=interval-5-sec --logging-flow-sampling=1 --logging-metadata=include-all ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the VPC network GCP Console visiting `https://console.cloud.google.com/networking/networks/list` 2. From the list of network subnets, make sure for each subnet: - `Flow Logs` is set to `On` - `Aggregation Interval` is set to `5 sec` - `Include metadata` checkbox is checked - `Sample rate` is set to `100%` **Note**: It is not possible to determine if a Log filter has been defined from the console. **From Google Cloud CLI** gcloud compute networks subnets list --format json | jq -r '([Subnet,Purpose,Flow_Logs,Aggregation_Interval,Flow_Sampling,Metadata,Logs_Filtered] | (., map(length*-))), (.[] | [ .name, .purpose, (if has(enableFlowLogs) and .enableFlowLogs == true then Enabled else Disabled end), (if has(logConfig) then .logConfig.aggregationInterval else N/A end), (if has(logConfig) then .logConfig.flowSampling else N/A end), (if has(logConfig) then .logConfig.metadata else N/A end), (if has(logConfig) then (.logConfig | has(filterExpr)) else N/A end) ] ) | @tsv' | column -t The output of the above command will list: - each subnet - the subnet's purpose - a `Enabled` or `Disabled` value if `Flow Logs` are enabled - the value for `Aggregation Interval` or `N/A` if disabled, the value for `Flow Sampling` or `N/A` if disabled - the value for `Metadata` or `N/A` if disabled - 'true' or 'false' if a Logging Filter is configured or 'N/A' if disabled. If the subnet's purpose is `PRIVATE` then `Flow Logs` should be `Enabled`. If `Flow Logs` is enabled then: - `Aggregation_Interval` should be `INTERVAL_5_SEC` - `Flow_Sampling` should be 1 - `Metadata` should be `INCLUDE_ALL_METADATA` - `Logs_Filtered` should be `false`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/vpc/docs/using-flow-logs#enabling_vpc_flow_logging:https://cloud.google.com/vpc/", + "DefaultValue": "By default, Flow Logs is set to Off when a new VPC network subnet is created.", + "SubSection": null + } + ] + }, + { + "Id": "3.11", + "Description": "Ensure No HTTPS or SSL Proxy Load Balancers Permit SSL Policies With Weak Cipher Suites", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Secure Sockets Layer (SSL) policies determine what port Transport Layer Security (TLS) features clients are permitted to use when connecting to load balancers. To prevent usage of insecure features, SSL policies should use (a) at least TLS 1.2 with the MODERN profile; or (b) the RESTRICTED profile, because it effectively requires clients to use TLS 1.2 regardless of the chosen minimum TLS version; or (3) a CUSTOM profile that does not support any of the following features: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA ", + "RationaleStatement": "Load balancers are used to efficiently distribute traffic across multiple servers. Both SSL proxy and HTTPS load balancers are external load balancers, meaning they distribute traffic from the Internet to a GCP network. GCP customers can configure load balancer SSL policies with a minimum TLS version (1.0, 1.1, or 1.2) that clients can use to establish a connection, along with a profile (Compatible, Modern, Restricted, or Custom) that specifies permissible cipher suites. To comply with users using outdated protocols, GCP load balancers can be configured to permit insecure cipher suites. In fact, the GCP default SSL policy uses a minimum TLS version of 1.0 and a Compatible profile, which allows the widest range of insecure cipher suites. As a result, it is easy for customers to configure a load balancer without even knowing that they are permitting outdated cipher suites.", + "ImpactStatement": "Creating more secure SSL policies can prevent clients using older TLS versions from establishing a connection.", + "RemediationProcedure": "**From Google Cloud Console** If the TargetSSLProxy or TargetHttpsProxy does not have an SSL policy configured, create a new SSL policy. Otherwise, modify the existing insecure policy. 1. Navigate to the `SSL Policies` page by visiting: [https://console.cloud.google.com/net-security/sslpolicies](https://console.cloud.google.com/net-security/sslpolicies) 2. Click on the name of the insecure policy to go to its `SSL policy details` page. 3. Click `EDIT`. 4. Set `Minimum TLS version` to `TLS 1.2`. 5. Set `Profile` to `Modern` or `Restricted`. 6. Alternatively, if teh user selects the profile `Custom`, make sure that the following features are disabled: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA **From Google Cloud CLI** 1. For each insecure SSL policy, update it to use secure cyphers: gcloud compute ssl-policies update NAME [--profile COMPATIBLE|MODERN|RESTRICTED|CUSTOM] --min-tls-version 1.2 [--custom-features FEATURES] 2. If the target proxy has a GCP default SSL policy, use the following command corresponding to the proxy type to update it. gcloud compute target-ssl-proxies update TARGET_SSL_PROXY_NAME --ssl-policy SSL_POLICY_NAME gcloud compute target-https-proxies update TARGET_HTTPS_POLICY_NAME --ssl-policy SSL_POLICY_NAME ", + "AuditProcedure": "**From Google Cloud Console** 1. See all load balancers by visiting [https://console.cloud.google.com/net-services/loadbalancing/loadBalancers/list](https://console.cloud.google.com/net-services/loadbalancing/loadBalancers/list). 2. For each load balancer for `SSL (Proxy)` or `HTTPS`, click on its name to go the `Load balancer details` page. 3. Ensure that each target proxy entry in the `Frontend` table has an `SSL Policy` configured. 4. Click on each SSL policy to go to its `SSL policy details` page. 5. Ensure that the SSL policy satisfies one of the following conditions: - has a `Min TLS` set to `TLS 1.2` and `Profile` set to `Modern` profile, or - has `Profile` set to `Restricted`. Note that a Restricted profile effectively requires clients to use TLS 1.2 regardless of the chosen minimum TLS version, or - has `Profile` set to `Custom` and the following features are all disabled: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA **From Google Cloud CLI** 1. List all TargetHttpsProxies and TargetSslProxies. gcloud compute target-https-proxies list gcloud compute target-ssl-proxies list 2. For each target proxy, list its properties: gcloud compute target-https-proxies describe TARGET_HTTPS_PROXY_NAME gcloud compute target-ssl-proxies describe TARGET_SSL_PROXY_NAME 3. Ensure that the `sslPolicy` field is present and identifies the name of the SSL policy: sslPolicy: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/sslPolicies/SSL_POLICY_NAME If the `sslPolicy` field is missing from the configuration, it means that the GCP default policy is used, which is insecure. 4. Describe the SSL policy: gcloud compute ssl-policies describe SSL_POLICY_NAME 5. Ensure that the policy satisfies one of the following conditions: - has `Profile` set to `Modern` and `minTlsVersion` set to `TLS_1_2`, or - has `Profile` set to `Restricted`, or - has `Profile` set to `Custom` and `enabledFeatures` does not contain any of the following values: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/load-balancing/docs/use-ssl-policies:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-52r2.pdf", + "DefaultValue": "The GCP default SSL policy is the least secure setting: Min TLS 1.0 and Compatible profile", + "SubSection": null + } + ] + }, + { + "Id": "3.12", + "Description": "Use Identity Aware Proxy (IAP) to Ensure Only Traffic From Google IP Addresses are 'Allowed'", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "IAP authenticates the user requests to your apps via a Google single sign in. You can then manage these users with permissions to control access. It is recommended to use both IAP permissions and firewalls to restrict this access to your apps with sensitive information.", + "RationaleStatement": "IAP ensure that access to VMs is controlled by authenticating incoming requests. Access to your apps and the VMs should be restricted by firewall rules that allow only the proxy IAP IP addresses contained in the 35.235.240.0/20 subnet. Otherwise, unauthenticated requests can be made to your apps. To ensure that load balancing works correctly health checks should also be allowed.", + "ImpactStatement": "If firewall rules are not configured correctly, legitimate business services could be negatively impacted. It is recommended to make these changes during a time of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud Console [VPC network > Firewall rules](https://console.cloud.google.com/networking/firewalls/list?_ga=2.72166934.480049361.1580860862-1336643914.1580248695). 2. Select the checkbox next to the following rules: - default-allow-http - default-allow-https - default-allow-internal 3. Click `Delete`. 4. Click `Create firewall rule` and set the following values: - Name: allow-iap-traffic - Targets: All instances in the network - Source IP ranges (press Enter after you paste each value in the box, copy each full CIDR IP address): - IAP Proxy Addresses `35.235.240.0/20` - Google Health Check `130.211.0.0/22` - Google Health Check `35.191.0.0/16` - Protocols and ports: - Specified protocols and ports required for access and management of your app. For example most health check connection protocols would be covered by; - tcp:80 (Default HTTP Health Check port) - tcp:443 (Default HTTPS Health Check port) **Note: if you have custom ports used by your load balancers, you will need to list them here** 5. When you're finished updating values, click `Create`.", + "AuditProcedure": "**From Google Cloud Console** 1. For each of your apps that have IAP enabled go to the Cloud Console VPC network > Firewall rules. 2. Verify that the only rules correspond to the following values: - Targets: All instances in the network - Source IP ranges: - IAP Proxy Addresses `35.235.240.0/20` - Google Health Check `130.211.0.0/22` - Google Health Check `35.191.0.0/16` - Protocols and ports: - Specified protocols and ports required for access and management of your app. For example most health check connection protocols would be covered by; - tcp:80 (Default HTTP Health Check port) - tcp:443 (Default HTTPS Health Check port) **Note: if you have custom ports used by your load balancers, you will need to list them here**", + "AdditionalInformation": "", + "References": "https://cloud.google.com/iap/docs/concepts-overview:https://cloud.google.com/iap/docs/load-balancer-howto:https://cloud.google.com/load-balancing/docs/health-checks:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "By default all traffic is allowed.", + "SubSection": null + } + ] + }, + { + "Id": "4.1", + "Description": "Ensure That Instances Are Not Configured To Use the Default Service Account", + "Checks": [ + "compute_instance_default_service_account_in_use" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to configure your instance to not use the default Compute Engine service account because it has the Editor role on the project.", + "RationaleStatement": "When a default Compute Engine service account is created, it is automatically granted the Editor role (roles/editor) on your project which allows read and write access to most Google Cloud Services. This role includes a very large number of permissions. To defend against privilege escalations if your VM is compromised and prevent an attacker from gaining access to all of your project, you should either revoke the Editor role from the default Compute Engine service account or create a new service account and assign only the permissions needed by your instance. To mitigate this at scale, we strongly recommend that you disable the automatic role grant by adding a constraint to your organization policy. The default Compute Engine service account is named `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to go to its `VM instance details` page. 3. Click `STOP` and then click `EDIT`. 4. Under the section `API and identity management`, select a service account other than the default Compute Engine service account. You may first need to create a new service account. 5. Click `Save` and then click `START`. **From Google Cloud CLI** 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances set-service-account --service-account= 3. Restart the instance: gcloud compute instances start ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `API and identity management`, ensure that the default Compute Engine service account is not used. This account is named `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json | jq -r '. | SA: (.[].serviceAccounts[].email) Name: (.[].name)' 2. Ensure that the service account section has an email that does not match the pattern `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/access/service-accounts:https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances:https://cloud.google.com/sdk/gcloud/reference/compute/instances/set-service-account", + "DefaultValue": "By default, Compute instances are configured to use the default Compute Engine service account.", + "SubSection": null + } + ] + }, + { + "Id": "4.2", + "Description": "Ensure That Instances Are Not Configured To Use the Default Service Account With Full Access to All Cloud APIs", + "Checks": [ + "compute_instance_default_service_account_in_use_with_full_api_access" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "To support principle of least privileges and prevent potential privilege escalation it is recommended that instances are not assigned to default service account `Compute Engine default service account` with Scope `Allow full access to all Cloud APIs`.", + "RationaleStatement": "Along with ability to optionally create, manage and use user managed custom service accounts, Google Compute Engine provides default service account `Compute Engine default service account` for an instances to access necessary cloud services. `Project Editor` role is assigned to `Compute Engine default service account` hence, This service account has almost all capabilities over all cloud services except billing. However, when `Compute Engine default service account` assigned to an instance it can operate in 3 scopes. 1. Allow default access: Allows only minimum access required to run an Instance (Least Privileges) 2. Allow full access to all Cloud APIs: Allow full access to all the cloud APIs/Services (Too much access) 3. Set access for each API: Allows Instance administrator to choose only those APIs that are needed to perform specific business functionality expected by instance When an instance is configured with `Compute Engine default service account` with Scope `Allow full access to all Cloud APIs`, based on IAM roles assigned to the user(s) accessing Instance, it may allow user to perform cloud operations/API calls that user is not supposed to perform leading to successful privilege escalation.", + "ImpactStatement": "In order to change service account or scope for an instance, it needs to be stopped.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the impacted VM instance. 3. If the instance is not stopped, click the `Stop` button. Wait for the instance to be stopped. 4. Next, click the `Edit` button. 5. Scroll down to the `Service Account` section. 6. Select a different service account or ensure that `Allow full access to all Cloud APIs` is not selected. 7. Click the `Save` button to save your changes and then click `START`. **From Google Cloud CLI** 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances set-service-account --service-account= --scopes [SCOPE1, SCOPE2...] 3. Restart the instance: gcloud compute instances start ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the `API and identity management`, ensure that `Cloud API access scopes` is not set to `Allow full access to all Cloud APIs`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json | jq -r '. | SA Scopes: (.[].serviceAccounts[].scopes) Name: (.[].name) Email: (.[].serviceAccounts[].email)' 2. Ensure that the service account section has an email that does not match the pattern `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node", + "AdditionalInformation": "- User IAM roles will override service account scope but configuring minimal scope ensures defense in depth - Non-default service accounts do not offer selection of access scopes like default service account. IAM roles with non-default service accounts should be used to control VM access.", + "References": "https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances:https://cloud.google.com/compute/docs/access/service-accounts", + "DefaultValue": "While creating an VM instance, default service account is used with scope `Allow default access`.", + "SubSection": null + } + ] + }, + { + "Id": "4.3", + "Description": "Ensure “Block Project-Wide SSH Keys” Is Enabled for VM Instances", + "Checks": [ + "compute_instance_block_project_wide_ssh_keys_disabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to use Instance specific SSH key(s) instead of using common/shared project-wide SSH key(s) to access Instances.", + "RationaleStatement": "Project-wide SSH keys are stored in Compute/Project-meta-data. Project wide SSH keys can be used to login into all the instances within project. Using project-wide SSH keys eases the SSH key management but if compromised, poses the security risk which can impact all the instances within project. It is recommended to use Instance specific SSH keys which can limit the attack surface if the SSH keys are compromised.", + "ImpactStatement": "Users already having Project-wide ssh key pairs and using third party SSH clients will lose access to the impacted Instances. For Project users using gcloud or GCP Console based SSH option, no manual key creation and distribution is required and will be handled by GCE (Google Compute Engine) itself. To access Instance using third party SSH clients Instance specific SSH key pairs need to be created and distributed to the required users.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). It will list all the instances in your project. 2. Click on the name of the Impacted instance 3. Click `Edit` in the toolbar 4. Under SSH Keys, go to the `Block project-wide SSH keys` checkbox 5. To block users with project-wide SSH keys from connecting to this instance, select `Block project-wide SSH keys` 6. Click `Save` at the bottom of the page 7. Repeat steps for every impacted Instance **From Google Cloud CLI** To block project-wide public SSH keys, set the metadata value to `TRUE`: gcloud compute instances add-metadata --metadata block-project-ssh-keys=TRUE ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). It will list all the instances in your project. 2. For every instance, click on the name of the instance. 3. Under `SSH Keys`, ensure `Block project-wide SSH keys` is selected. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Ensure `key: block-project-ssh-keys` is set to `value: 'true'`.", + "AdditionalInformation": "If OS Login is enabled, SSH keys in instance metadata are ignored, and therefore blocking project-wide SSH keys is not necessary.", + "References": "https://cloud.google.com/compute/docs/instances/adding-removing-ssh-keys:https://cloud.google.com/sdk/gcloud/reference/topic/formats", + "DefaultValue": "By Default `Block Project-wide SSH keys` is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "4.4", + "Description": "Ensure Oslogin Is Enabled for a Project", + "Checks": [ + "compute_project_os_login_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling OS login binds SSH certificates to IAM users and facilitates effective SSH certificate management.", + "RationaleStatement": "Enabling osLogin ensures that SSH keys used to connect to instances are mapped with IAM users. Revoking access to IAM user will revoke all the SSH keys associated with that particular user. It facilitates centralized and automated SSH key pair management which is useful in handling cases like response to compromised SSH key pairs and/or revocation of external/third-party/Vendor users.", + "ImpactStatement": "Enabling OS Login on project disables metadata-based SSH key configurations on all instances from a project. Disabling OS Login restores SSH keys that you have configured in project or instance meta-data.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the VM compute metadata page by visiting: [https://console.cloud.google.com/compute/metadata](https://console.cloud.google.com/compute/metadata). 2. Click `Edit`. 3. Add a metadata entry where the key is `enable-oslogin` and the value is `TRUE`. 4. Click `Save` to apply the changes. 5. For every instance that overrides the project setting, go to the `VM Instances` page at [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 6. Click the name of the instance on which you want to remove the metadata value. 7. At the top of the instance details page, click `Edit` to edit the instance settings. 8. Under `Custom metadata`, remove any entry with key `enable-oslogin` and the value is `FALSE` 9. At the bottom of the instance details page, click `Save` to apply your changes to the instance. **From Google Cloud CLI** 1. Configure oslogin on the project: gcloud compute project-info add-metadata --metadata enable-oslogin=TRUE 2. Remove instance metadata that overrides the project setting. gcloud compute instances remove-metadata --keys=enable-oslogin Optionally, you can enable two factor authentication for OS login. For more information, see: [https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication](https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the VM compute metadata page by visiting [https://console.cloud.google.com/compute/metadata](https://console.cloud.google.com/compute/metadata). 2. Ensure that key `enable-oslogin` is present with value set to `TRUE`. 3. Because instances can override project settings, ensure that no instance has custom metadata with key `enable-oslogin` and value `FALSE`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Verify that the section `commonInstanceMetadata` has a key `enable-oslogin` set to value `TRUE`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node`", + "AdditionalInformation": "1. In order to use osLogin, instance using Custom Images must have the latest version of the Linux Guest Environment installed. The following image families do not yet support OS Login: Project cos-cloud (Container-Optimized OS) image family cos-stable. All project coreos-cloud (CoreOS) image families Project suse-cloud (SLES) image family sles-11 All Windows Server and SQL Server image families 2. Project enable-oslogin can be over-ridden by setting enable-oslogin parameter to an instance metadata individually.", + "References": "https://cloud.google.com/compute/docs/instances/managing-instance-access:https://cloud.google.com/compute/docs/instances/managing-instance-access#enable_oslogin:https://cloud.google.com/sdk/gcloud/reference/compute/instances/remove-metadata:https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication", + "DefaultValue": "By default, parameter `enable-oslogin` is not set, which is equivalent to setting it to `FALSE`.", + "SubSection": null + } + ] + }, + { + "Id": "4.5", + "Description": "Ensure ‘Enable Connecting to Serial Ports’ Is Not Enabled for VM Instance", + "Checks": [ + "compute_instance_serial_ports_in_use" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Interacting with a serial port is often referred to as the serial console, which is similar to using a terminal window, in that input and output is entirely in text mode and there is no graphical interface or mouse support. If you enable the interactive serial console on an instance, clients can attempt to connect to that instance from any IP address. Therefore interactive serial console support should be disabled.", + "RationaleStatement": "A virtual machine instance has four virtual serial ports. Interacting with a serial port is similar to using a terminal window, in that input and output is entirely in text mode and there is no graphical interface or mouse support. The instance's operating system, BIOS, and other system-level entities often write output to the serial ports, and can accept input such as commands or answers to prompts. Typically, these system-level entities use the first serial port (port 1) and serial port 1 is often referred to as the serial console. The interactive serial console does not support IP-based access restrictions such as IP whitelists. If you enable the interactive serial console on an instance, clients can attempt to connect to that instance from any IP address. This allows anybody to connect to that instance if they know the correct SSH key, username, project ID, zone, and instance name. Therefore interactive serial console support should be disabled.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Login to Google Cloud console 2. Go to Computer Engine 3. Go to VM instances 4. Click on the Specific VM 5. Click `EDIT` 6. Unselect `Enable connecting to serial ports` below `Remote access` block. 7. Click `Save` **From Google Cloud CLI** Use the below command to disable gcloud compute instances add-metadata --zone= --metadata=serial-port-enable=false or gcloud compute instances add-metadata --zone= --metadata=serial-port-enable=0 **Prevention:** You can prevent VMs from having serial port access enable by `Disable VM serial port access` organization policy: [https://console.cloud.google.com/iam-admin/orgpolicies/compute-disableSerialPortAccess](https://console.cloud.google.com/iam-admin/orgpolicies/compute-disableSerialPortAccess).", + "AuditProcedure": "**From Google Cloud Console** 1. Login to Google Cloud console 2. Go to Compute Engine 3. Go to VM instances 4. Click on the Specific VM 5. Ensure the statement `Connecting to serial serial ports is disabled` is displayed at the top of the details tab, just below the `Connect to serial console` drop-down.. **From Google Cloud CLI** Ensure the below command's output shows `null`: gcloud compute instances describe --zone= --format=json(metadata.items[].key,metadata.items[].value) or `key` and `value` properties from below command's json response are equal to `serial-port-enable` and `0` or `false` respectively. { metadata: { items: [ { key: serial-port-enable, value: 0 } ] } } ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/instances/interacting-with-serial-console", + "DefaultValue": "By default, connecting to serial ports is not enabled.", + "SubSection": null + } + ] + }, + { + "Id": "4.6", + "Description": "Ensure That IP Forwarding Is Not Enabled on Instances", + "Checks": [ + "compute_instance_ip_forwarding_is_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Compute Engine instance cannot forward a packet unless the source IP address of the packet matches the IP address of the instance. Similarly, GCP won't deliver a packet whose destination IP address is different than the IP address of the instance receiving the packet. However, both capabilities are required if you want to use instances to help route packets. Forwarding of data packets should be disabled to prevent data loss or information disclosure.", + "RationaleStatement": "Compute Engine instance cannot forward a packet unless the source IP address of the packet matches the IP address of the instance. Similarly, GCP won't deliver a packet whose destination IP address is different than the IP address of the instance receiving the packet. However, both capabilities are required if you want to use instances to help route packets. To enable this source and destination IP check, disable the `canIpForward` field, which allows an instance to send and receive packets with non-matching destination or source IPs.", + "ImpactStatement": "", + "RemediationProcedure": "You only edit the `canIpForward` setting at instance creation or using CLI. **From Google Cloud CLI** 1. Use the instances export command to export the existing instance properties: gcloud compute instances export --project --zone --destination= **Note**Replace the following: INSTANCE_NAME the name for the instance that you want to export. PROJECT_ID: the project ID for this request. ZONE: the zone for this instance. FILE_PATH: the output path where you want to save the instance configuration file on your local workstation. 2. Use a text editor to modify this file Replace `canIpForward: true` with `canIpForward: false` 3. Run this command to import the file you just modified gcloud compute instances update-from-file INSTANCE_NAME --project PROJECT_ID --zone ZONE --source=FILE_PATH --most-disruptive-allowed-action=REFRESH If the update request is valid and the required resources are available, the instance update process begins. You can monitor the status of this operation by viewing the audit logs. This update requires only a REFRESH not a full restart.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM Instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. For every instance, click on its name to go to the `VM instance details` page. 3. Under the `Network interfaces` section, ensure that `IP forwarding` is set to `Off` for every network interface. **From Google Cloud CLI** 1. List all instances: gcloud compute instances list --format='table(name,canIpForward)' 2. Ensure that `CAN_IP_FORWARD` column in the output of above command does not contain `True` for any VM instance. **Exception:** Instances created by GKE should be excluded because they need to have IP forwarding enabled and cannot be changed. Instances created by GKE have names that start with gke-.", + "AdditionalInformation": "You can only set the `canIpForward` field at instance creation time or using CLI.", + "References": "https://cloud.google.com/vpc/docs/using-routes#canipforward:https://cloud.google.com/compute/docs/instances/update-instance-properties", + "DefaultValue": "By default, instances are not configured to allow IP forwarding.", + "SubSection": null + } + ] + }, + { + "Id": "4.7", + "Description": "Ensure VM Disks for Critical VMs Are Encrypted With Customer-Supplied Encryption Keys (CSEK)", + "Checks": [ + "compute_instance_encryption_with_csek_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Customer-Supplied Encryption Keys (CSEK) are a feature in Google Cloud Storage and Google Compute Engine. If you supply your own encryption keys, Google uses your key to protect the Google-generated keys used to encrypt and decrypt your data. By default, Google Compute Engine encrypts all data at rest. Compute Engine handles and manages this encryption for you without any additional actions on your part. However, if you wanted to control and manage this encryption yourself, you can provide your own encryption keys.", + "RationaleStatement": "By default, Google Compute Engine encrypts all data at rest. Compute Engine handles and manages this encryption for you without any additional actions on your part. However, if you wanted to control and manage this encryption yourself, you can provide your own encryption keys. If you provide your own encryption keys, Compute Engine uses your key to protect the Google-generated keys used to encrypt and decrypt your data. Only users who can provide the correct key can use resources protected by a customer-supplied encryption key. Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. At least business critical VMs should have VM disks encrypted with CSEK.", + "ImpactStatement": "If you lose your encryption key, you will not be able to recover the data.", + "RemediationProcedure": "Currently there is no way to update the encryption of an existing disk. Therefore you should create a new disk with `Encryption` set to `Customer supplied`. **From Google Cloud Console** 1. Go to Compute Engine `Disks` by visiting: [https://console.cloud.google.com/compute/disks](https://console.cloud.google.com/compute/disks). 2. Click `CREATE DISK`. 3. Set `Encryption type` to `Customer supplied`, 4. Provide the `Key` in the box. 5. Select `Wrapped key`. 6. Click `Create`. **From Google Cloud CLI** In the gcloud compute tool, encrypt a disk using the --csek-key-file flag during instance creation. If you are using an RSA-wrapped key, use the gcloud beta component: gcloud compute instances create --csek-key-file To encrypt a standalone persistent disk: gcloud compute disks create --csek-key-file ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to Compute Engine `Disks` by visiting: [https://console.cloud.google.com/compute/disks](https://console.cloud.google.com/compute/disks). 2. Click on the disk for your critical VMs to see its configuration details. 4. Ensure that `Encryption type` is set to `Customer supplied`. **From Google Cloud CLI** Ensure `diskEncryptionKey` property in the below command's response is not null, and contains key `sha256` with corresponding value gcloud compute disks describe --zone --format=json(diskEncryptionKey,name) ", + "AdditionalInformation": "`Note 1:` When you delete a persistent disk, Google discards the cipher keys, rendering the data irretrievable. This process is irreversible. `Note 2:` It is up to you to generate and manage your key. You must provide a key that is a 256-bit string encoded in RFC 4648 standard base64 to Compute Engine. `Note 3:` An example key file looks like this. [ { uri: https://www.googleapis.com/compute/v1/projects/myproject/zones/us-central1-a/disks/example-disk, key: acXTX3rxrKAFTF0tYVLvydU1riRZTvUNC4g5I11NY-c=, key-type: raw }, { uri: https://www.googleapis.com/compute/v1/projects/myproject/global/snapshots/my-private-snapshot, key: ieCx/NcW06PcT7Ep1X6LUTc/hLvUDYyzSZPPVCVPTVEohpeHASqC8uw5TzyO9U+Fka9JFHz0mBibXUInrC/jEk014kCK/NPjYgEMOyssZ4ZINPKxlUh2zn1bV+MCaTICrdmuSBTWlUUiFoDD6PYznLwh8ZNdaheCeZ8ewEXgFQ8V+sDroLaN3Xs3MDTXQEMMoNUXMCZEIpg9Vtp9x2oeQ5lAbtt7bYAAHf5l+gJWw3sUfs0/Glw5fpdjT8Uggrr+RMZezGrltJEF293rvTIjWOEB3z5OHyHwQkvdrPDFcTqsLfh+8Hr8g+mf+7zVPEC8nEbqpdl3GPv3A7AwpFp7MA== key-type: rsa-encrypted } ]", + "References": "https://cloud.google.com/compute/docs/disks/customer-supplied-encryption#encrypt_a_new_persistent_disk_with_your_own_keys:https://cloud.google.com/compute/docs/reference/rest/v1/disks/get:https://cloud.google.com/compute/docs/disks/customer-supplied-encryption#key_file", + "DefaultValue": "By default, VM disks are encrypted with Google-managed keys. They are not encrypted with Customer-Supplied Encryption Keys.", + "SubSection": null + } + ] + }, + { + "Id": "4.8", + "Description": "Ensure Compute Instances Are Launched With Shielded VM Enabled", + "Checks": [ + "compute_instance_shielded_vm_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "To defend against advanced threats and ensure that the boot loader and firmware on your VMs are signed and untampered, it is recommended that Compute instances are launched with Shielded VM enabled.", + "RationaleStatement": "Shielded VMs are virtual machines (VMs) on Google Cloud Platform hardened by a set of security controls that help defend against rootkits and bootkits. Shielded VM offers verifiable integrity of your Compute Engine VM instances, so you can be confident your instances haven't been compromised by boot- or kernel-level malware or rootkits. Shielded VM's verifiable integrity is achieved through the use of Secure Boot, virtual trusted platform module (vTPM)-enabled Measured Boot, and integrity monitoring. Shielded VM instances run firmware which is signed and verified using Google's Certificate Authority, ensuring that the instance's firmware is unmodified and establishing the root of trust for Secure Boot. Integrity monitoring helps you understand and make decisions about the state of your VM instances and the Shielded VM vTPM enables Measured Boot by performing the measurements needed to create a known good boot baseline, called the integrity policy baseline. The integrity policy baseline is used for comparison with measurements from subsequent VM boots to determine if anything has changed. Secure Boot helps ensure that the system only runs authentic software by verifying the digital signature of all boot components, and halting the boot process if signature verification fails.", + "ImpactStatement": "", + "RemediationProcedure": "To be able turn on `Shielded VM` on an instance, your instance must use an image with Shielded VM support. **From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its `VM instance details` page. 3. Click `STOP` to stop the instance. 4. When the instance has stopped, click `EDIT`. 5. In the Shielded VM section, select `Turn on vTPM` and `Turn on Integrity Monitoring`. 6. Optionally, if you do not use any custom or unsigned drivers on the instance, also select `Turn on Secure Boot`. 7. Click the `Save` button to modify the instance and then click `START` to restart it. **From Google Cloud CLI** You can only enable Shielded VM options on instances that have Shielded VM support. For a list of Shielded VM public images, run the gcloud compute images list command with the following flags: gcloud compute images list --project gce-uefi-images --no-standard-images 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances update --shielded-vtpm --shielded-vm-integrity-monitoring 3. Optionally, if you do not use any custom or unsigned drivers on the instance, also turn on secure boot. gcloud compute instances update --shielded-vm-secure-boot 4. Restart the instance: gcloud compute instances start **Prevention:** You can ensure that all new VMs will be created with Shielded VM enabled by setting up an Organization Policy to for `Shielded VM` at [https://console.cloud.google.com/iam-admin/orgpolicies/compute-requireShieldedVm](https://console.cloud.google.com/iam-admin/orgpolicies/compute-requireShieldedVm). Learn more at: [https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint](https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its `VM instance details` page. 3. Under the section `Shielded VM`, ensure that `vTPM` and `Integrity Monitoring` are `on`. **From Google Cloud CLI** 1. For each instance in your project, get its metadata: gcloud compute instances list --format=json | jq -r '. | vTPM: (.[].shieldedInstanceConfig.enableVtpm) IntegrityMonitoring: (.[].shieldedInstanceConfig.enableIntegrityMonitoring) Name: (.[].name)' 2. Ensure that there is a `shieldedInstanceConfig` configuration and that configuration has the `enableIntegrityMonitoring` and `enableVtpm` set to `true`. If the VM is not a Shield VM image, you will not see a shieldedInstanceConfig` in the output.", + "AdditionalInformation": "If you do use custom or unsigned drivers on the instance, enabling Secure Boot will cause the machine to no longer boot. Turn on Secure Boot only on instances that have been verified to not have any custom drivers installed.", + "References": "https://cloud.google.com/compute/docs/instances/modifying-shielded-vm:https://cloud.google.com/shielded-vm:https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint", + "DefaultValue": "By default, Compute Instances do not have Shielded VM enabled.", + "SubSection": null + } + ] + }, + { + "Id": "4.9", + "Description": "Ensure That Compute Instances Do Not Have Public IP Addresses", + "Checks": [ + "compute_instance_public_ip" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Compute instances should not be configured to have external IP addresses.", + "RationaleStatement": "To reduce your attack surface, Compute instances should not have public IP addresses. Instead, instances should be configured behind load balancers, to minimize the instance's exposure to the internet.", + "ImpactStatement": "Removing the external IP address from your Compute instance may cause some applications to stop working.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to go the the `Instance detail page`. 3. Click `Edit`. 4. For each Network interface, ensure that `External IP` is set to `None`. 5. Click `Done` and then click `Save`. **From Google Cloud CLI** 1. Describe the instance properties: gcloud compute instances describe --zone= 2. Identify the access config name that contains the external IP address. This access config appears in the following format: networkInterfaces: - accessConfigs: - kind: compute#accessConfig name: External NAT natIP: 130.211.181.55 type: ONE_TO_ONE_NAT 3. Delete the access config. gcloud compute instances delete-access-config --zone= --access-config-name In the above example, the `ACCESS_CONFIG_NAME` is `External NAT`. The name of your access config might be different. **Prevention:** You can configure the `Define allowed external IPs for VM instances` Organization Policy to prevent VMs from being configured with public IP addresses. Learn more at: [https://console.cloud.google.com/orgpolicies/compute-vmExternalIpAccess](https://console.cloud.google.com/orgpolicies/compute-vmExternalIpAccess)", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. For every VM, ensure that there is no `External IP` configured. **From Google Cloud CLI** gcloud compute instances list --format=json 1. The output should not contain an `accessConfigs` section under `networkInterfaces`. Note that the `natIP` value is present only for instances that are running or for instances that are stopped but have a static IP address. For instances that are stopped and are configured to have an ephemeral public IP address, the `natIP` field will not be present. Example output: networkInterfaces: - accessConfigs: - kind: compute#accessConfig name: External NAT networkTier: STANDARD type: ONE_TO_ONE_NAT **Exception:** Instances created by GKE should be excluded because some of them have external IP addresses and cannot be changed by editing the instance settings. Instances created by GKE should be excluded. These instances have names that start with gke- and are labeled goog-gke-node.", + "AdditionalInformation": "You can connect to Linux VMs that do not have public IP addresses by using Identity-Aware Proxy for TCP forwarding. Learn more at [https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances](https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances) For Windows VMs, see [https://cloud.google.com/compute/docs/instances/connecting-to-instance](https://cloud.google.com/compute/docs/instances/connecting-to-instance).", + "References": "https://cloud.google.com/load-balancing/docs/backend-service#backends_and_external_ip_addresses:https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances:https://cloud.google.com/compute/docs/instances/connecting-to-instance:https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#unassign_ip:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints", + "DefaultValue": "By default, Compute instances have a public IP address.", + "SubSection": null + } + ] + }, + { + "Id": "4.10", + "Description": "Ensure That App Engine Applications Enforce HTTPS Connections", + "Checks": [], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "In order to maintain the highest level of security all connections to an application should be secure by default.", + "RationaleStatement": "Insecure HTTP connections maybe subject to eavesdropping which can expose sensitive data.", + "ImpactStatement": "All connections to appengine will automatically be redirected to the HTTPS endpoint ensuring that all connections are secured by TLS.", + "RemediationProcedure": "Add a line to the app.yaml file controlling the application which enforces secure connections. For example handlers: - url: /.* **secure: always** redirect_http_response_code: 301 script: auto [https://cloud.google.com/appengine/docs/standard/python3/config/appref]", + "AuditProcedure": "Verify that the app.yaml file controlling the application contains a line which enforces secure connections. For example handlers: - url: /.* secure: always redirect_http_response_code: 301 script: auto [https://cloud.google.com/appengine/docs/standard/python3/config/appref](https://cloud.google.com/appengine/docs/standard/python3/config/appref)", + "AdditionalInformation": "", + "References": "https://cloud.google.com/appengine/docs/standard/python3/config/appref:https://cloud.google.com/appengine/docs/flexible/nodejs/configuring-your-app-with-app-yaml", + "DefaultValue": "By default both HTTP and HTTP are supported", + "SubSection": null + } + ] + }, + { + "Id": "4.11", + "Description": "Ensure That Compute Instances Have Confidential Computing Enabled", + "Checks": [ + "compute_instance_confidential_computing_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Google Cloud encrypts data at-rest and in-transit, but customer data must be decrypted for processing. Confidential Computing is a breakthrough technology that encrypts data in-use while it is being processed. Confidential Computing environments keep data encrypted in memory and elsewhere outside the central processing unit (CPU). Confidential VMs leverage hardware-based memory encryption technologies, such as AMD Secure Encrypted Virtualization (SEV), AMD SEV-SNP, and Intel Trust Domain Extensions (TDX), depending on the chosen machine type and CPU platform. Customer data will stay encrypted while it is used, indexed, queried, or trained on. Encryption keys are generated by and reside solely in dedicated hardware and are not exportable, enhancing isolation and security. Built-in hardware optimizations ensure Confidential Computing workloads experience minimal to no significant performance penalties.", + "RationaleStatement": "Confidential Computing enables customers' sensitive code and other data encrypted in memory during processing. Google does not have access to the encryption keys. Confidential VM can help alleviate concerns about risk related to either dependency on Google infrastructure or Google insiders' access to customer data in the clear.", + "ImpactStatement": "- Confidential Computing for Compute instances does not support live migration. Unlike regular Compute instances, Confidential VMs experience disruptions during maintenance events like a software or hardware update. - Additional charges may be incurred when enabling this security feature. See [https://cloud.google.com/compute/confidential-vm/pricing](https://cloud.google.com/compute/confidential-vm/pricing) for more info.", + "RemediationProcedure": "Confidential Computing can only be enabled when an instance is created. You must delete the current instance and create a new one. **From Google Cloud Console** 1. Go to the VM instances page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click `CREATE INSTANCE`. 3. Fill out the desired configuration for your instance. 4. Under the `Confidential VM service` section, check the option `Enable the Confidential Computing service on this VM instance`. 5. Click `Create`. **From Google Cloud CLI** Create a new instance with Confidential Compute enabled. gcloud compute instances create --zone --confidential-compute --maintenance-policy=TERMINATE ", + "AuditProcedure": "Note: Confidential Computing is currently only supported on limited VM configurations. To learn more about VM configurations supported by Confidential Computing, visit [https://cloud.google.com/confidential-computing/confidential-vm/docs/supported-configurations](https://cloud.google.com/confidential-computing/confidential-vm/docs/supported-configurations) **From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its VM instance details page. 3. Ensure that `Confidential VM service` is `Enabled`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Ensure that `enableConfidentialCompute` is set to `true` for all instances with machine type starting with n2d-. confidentialInstanceConfig: enableConfidentialCompute: true ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/confidential-vm/docs/creating-cvm-instance:https://cloud.google.com/compute/confidential-vm/docs/about-cvm:https://cloud.google.com/confidential-computing:https://cloud.google.com/blog/products/identity-security/introducing-google-cloud-confidential-computing-with-confidential-vms", + "DefaultValue": "By default, Confidential Computing is disabled for Compute instances.", + "SubSection": null + } + ] + }, + { + "Id": "4.12", + "Description": "Ensure the Latest Operating System Updates Are Installed On Your Virtual Machines in All Projects", + "Checks": [], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Google Cloud Virtual Machines have the ability via an OS Config agent API to periodically (about every 10 minutes) report OS inventory data. A patch compliance API periodically reads this data, and cross references metadata to determine if the latest updates are installed. This is not the only Patch Management solution available to your organization and you should weigh your needs before committing to using this method.", + "RationaleStatement": "Keeping virtual machine operating systems up to date is a security best practice. Using this service will simplify this process.", + "ImpactStatement": "Most Operating Systems require a restart or changing critical resources to apply the updates. Using the Google Cloud VM manager for its OS Patch management will incur additional costs for each VM managed by it. Please view the VM manager pricing reference for further information.", + "RemediationProcedure": "**From Google Cloud Console** **Enabling OS Patch Management on a Project by Project Basis** **Install OS Config API for the Project** 1. Navigate into a project. In the expanded portal menu located at the top left of the screen hover over APIs & Services. Then in the menu right of that select API Libraries 2. Search for VM Manager (OS Config API) or scroll down in the left hand column and select the filter labeled Compute where it is the last listed. Open this API. 3. Click the blue 'Enable' button. **Add MetaData Tags for OSConfig Parsing** 1. From the main Google Cloud console, open the portal menu in the top left. Mouse over Computer Engine to expand the menu next to it. 2. Under the Settings heading, select Metadata. 3. In this view there will be a list of the project wide metadata tags for VMs. Click edit and 'add item' in the key column type 'enable-osconfig' and in the value column set it to 'true'. From Command Line 1. For project wide tagging, run the following command gcloud compute project-info add-metadata --project --metadata=enable-osconfig=TRUE Please see the reference /compute/docs/troubleshooting/vm-manager/verify-setup#metadata-enabled at the bottom for more options like instance specific tagging. Note: Adding a new tag via commandline may overwrite existing tags. You will need to do this at a time of low usage for the least impact. **Install and Start the Local OSConfig for Data Parsing** There is no way to centrally manage or start the Local OSConfig agent. Please view the reference of manage-os#agent-install to view specific operating system commands. **Setup a project wide Service Account** Please view Recommendation 4.1 to view how to setup a service account. Rerun the audit procedure to test if it has taken effect. **Enable NAT or Configure Private Google Access to allow Access to Public Update Hosting** For the sake of brevity, please see the attached resources to enable NAT or Private Google Access. Rerun the audit procedure to test if it has taken effect. From Command Line: **Install OS Config API for the Project** 1. In each project you wish to audit run gcloud services enable osconfig.googleapis.com **Install and Start the Local OSConfig for Data Parsing** Please view the reference of manage-os#agent-install to view specific operating system commands. **Setup a project wide Service Account** Please view Recommendation 4.1 to view how to setup a service account. Rerun the audit procedure to test if it has taken effect. **Enable NAT or Configure Private Google Access to allow Access to Public Update Hosting** For the sake of brevity, please see the attached resources to enable NAT or Private Google Access. Rerun the audit procedure to test if it has taken effect. Determine if Instances can connect to public update hosting Linux Debian Based Operating Systems sudo apt update The output should have a numbered list of lines with Hit: URL of updates. Redhat Based Operating Systems yum check-update The output should show a list of packages that have updates available. Windows ping http://windowsupdate.microsoft.com/ The ping should successfully be delivered and received.", + "AuditProcedure": "**From Google Cloud Console** **Determine if OS Config API is Enabled for the Project** 1. Navigate into a project. In the expanded navigation menu located at the top left of the screen hover over `APIs & Services`. Then in the menu right of that select `API Libraries` 2. Search for VM Manager (OS Config API) or scroll down in the left hand column and select the filter labeled Compute where it is the last listed. Open this API. 3. Verify the blue button at the top is enabled. **Determine if VM Instances have correct metadata tags for OSConfig parsing** 1. From the main Google Cloud console, open the hamburger menu in the top left. Mouse over Computer Engine to expand the menu next to it. 1. Under the Settings heading, select Metadata. 1. In this view there will be a list of the project wide metadata tags for VMs. Determine if the tag enable-osconfig is set to true. **Determine if the Operating System of VM Instances have the local OS-Config Agent running** There is no way to determine this from the Google Cloud console. The only way is to run operating specific commands locally inside the operating system via remote connection. For the sake of brevity of this recommendation please view the docs/troubleshooting/vm-manager/verify-setup reference at the bottom of the page. If you initialized your VM instance with a Google Supplied OS Image with a build date of later than v20200114 it will have the service installed. You should still determine its status for proper operation. **Verify the service account you have setup for the project in Recommendation 4.1 is running** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `Service Account`, take note of the service account 4. Run the commands locally for your operating system that are located at the docs/troubleshooting/vm-manager/verify-setup#service-account-enabled reference located at the bottom of this page. They should return the name of your service account. **Determine if Instances can connect to public update hosting** Each type of operating system has its own update process. You will need to determine on each operating system that it can reach the update servers via its network connection. The VM Manager doesn't host the updates, it will only allow you to centrally issue a command to each VM to update. **Determine if OS Config API is Enabled for the Project** 1. In each project you wish to enable run the following command gcloud services list 2. If osconfig.googleapis.com is in the left hand column it is enabled for this project. **Determine if VM Manager is Enabled for the Project** 1. Within the project run the following command: gcloud compute instances os-inventory describe VM-NAME --zone=ZONE The output will look like INSTANCE_ID INSTANCE_NAME OS OSCONFIG_AGENT_VERSION UPDATE_TIME 29255009728795105 centos7 CentOS Linux 7 (Core) 20210217.00-g1.el7 2021-04-12T22:19:36.559Z 5138980234596718741 rhel-8 Red Hat Enterprise Linux 8.3 (Ootpa) 20210316.00-g1.el8 2021-09-16T17:19:24Z 7127836223366142250 windows Microsoft Windows Server 2019 Datacenter 20210316.00.0+win@1 2021-09-16T17:13:18Z **Determine if VM Instances have correct metadata tags for OSConfig parsing** 1. Select the project you want to view tagging in. **From Google Cloud Console** 1. From the main Google Cloud console, open the hamburger menu in the top left. Mouse over Computer Engine to expand the menu next to it. 2. Under the Settings heading, select Metadata. 3. In this view there will be a list of the project wide metadata tags for Vms. Verify a tag of ‘enable-osconfig’ is in this list and it is set to ‘true’. **From Command Line** Run the following command to view instance data gcloud compute instances list --format=table(name,status,tags.list()) On each instance it should have a tag of ‘enable-osconfig’ set to ‘true’ **Determine if the Operating System of VM Instances have the local OS-Config Agent running** There is no way to determine this from the Google Cloud CLI. The best way is to run the the commands inside the operating system located at 'Check OS-Config agent is installed and running' at the /docs/troubleshooting/vm-manager/verify-setup reference at the bottom of the page. If you initialized your VM instance with a Google Supplied OS Image with a build date of later than v20200114 it will have the service installed. You should still determine its status. **Verify the service account you have setup for the project in Recommendation 4.1 is running** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `Service Account`, take note of the service account 4. View the compute/docs/troubleshooting/vm-manager/verify-setup#service-account-enabled resource at the bottom of the page for operating system specific commands to run locally. **Determine if Instances can connect to public update hosting** Linux Debian Based Operating Systems sudo apt update The output should have a numbered list of lines with Hit: URL of updates. Redhat Based Operating Systems yum check-update The output should show a list of packages that have updates available. Windows ping http://windowsupdate.microsoft.com/ The ping should successfully be delivered and received.", + "AdditionalInformation": "This is not your only solution to handle updates. This is a Google Cloud specific recommendation to leverage a resource to solve the need for comprehensive update procedures and policy. If you have a solution already in place you do not need to make the switch. There are also further resources that would be out of the scope of this recommendation. If you need to allow your VMs to access public hosted updates, please see the reference to setup NAT or Private Google Access.", + "References": "https://cloud.google.com/compute/docs/manage-os:https://cloud.google.com/compute/docs/os-patch-management:https://cloud.google.com/compute/docs/vm-manager:https://cloud.google.com/compute/docs/images/os-details#vm-manager:https://cloud.google.com/compute/docs/vm-manager#pricing:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup:https://cloud.google.com/compute/docs/instances/view-os-details#view-data-tools:https://cloud.google.com/compute/docs/os-patch-management/create-patch-job:https://cloud.google.com/nat/docs/set-up-network-address-translation:https://cloud.google.com/vpc/docs/configure-private-google-access:https://workbench.cisecurity.org/sections/811638/recommendations/1334335:https://cloud.google.com/compute/docs/manage-os#agent-install:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup#service-account-enabled:https://cloud.google.com/compute/docs/os-patch-management#use-dashboard:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup#metadata-enabled", + "DefaultValue": "By default most operating systems and programs do not update themselves. The Google Cloud VM Manager which is a dependency of the OS Patch management feature is installed on Google Built OS images with a build date of v20200114 or later. The VM manager is not enabled in a project by default and will need to be setup.", + "SubSection": null + } + ] + }, + { + "Id": "5.1", + "Description": "Ensure That Cloud Storage Bucket Is Not Anonymously or Publicly Accessible", + "Checks": [ + "cloudstorage_bucket_public_access" + ], + "Attributes": [ + { + "Section": "5 Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that IAM policy on Cloud Storage bucket does not allows anonymous or public access.", + "RationaleStatement": "Allowing anonymous or public access grants permissions to anyone to access bucket content. Such access might not be desired if you are storing any sensitive data. Hence, ensure that anonymous or public access to a bucket is not allowed.", + "ImpactStatement": "No storage buckets would be publicly accessible. You would have to explicitly administer bucket access.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Storage browser` by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. Click on the bucket name to go to its `Bucket details` page. 3. Click on the `Permissions` tab. 4. Click `Delete` button in front of `allUsers` and `allAuthenticatedUsers` to remove that particular role assignment. **From Google Cloud CLI** Remove `allUsers` and `allAuthenticatedUsers` access. gsutil iam ch -d allUsers gs://BUCKET_NAME gsutil iam ch -d allAuthenticatedUsers gs://BUCKET_NAME **Prevention:** You can prevent Storage buckets from becoming publicly accessible by setting up the `Domain restricted sharing` organization policy at:[ https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains ](https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Storage browser` by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. Click on each bucket name to go to its `Bucket details` page. 3. Click on the `Permissions` tab. 4. Ensure that `allUsers` and `allAuthenticatedUsers` are not in the `Members` list. **From Google Cloud CLI** 1. List all buckets in a project gsutil ls 2. Check the IAM Policy for each bucket: gsutil iam get gs://BUCKET_NAME No role should contain `allUsers` and/or `allAuthenticatedUsers` as a member. **Using Rest API** 1. List all buckets in a project Get https://www.googleapis.com/storage/v1/b?project= 2. Check the IAM Policy for each bucket GET https://www.googleapis.com/storage/v1/b//iam No role should contain `allUsers` and/or `allAuthenticatedUsers` as a member.", + "AdditionalInformation": "To implement Access restrictions on buckets, configuring Bucket IAM is preferred way than configuring Bucket ACL. On GCP console, Edit Permissions for bucket exposes IAM configurations only. Bucket ACLs are configured automatically as per need in order to implement/support User enforced Bucket IAM policy. In-case administrator changes bucket ACL using command-line(gsutils)/API bucket IAM also gets updated automatically.", + "References": "https://cloud.google.com/storage/docs/access-control/iam-reference:https://cloud.google.com/storage/docs/access-control/making-data-public:https://cloud.google.com/storage/docs/gsutil/commands/iam", + "DefaultValue": "By Default, Storage buckets are not publicly shared.", + "SubSection": null + } + ] + }, + { + "Id": "5.2", + "Description": "Ensure That Cloud Storage Buckets Have Uniform Bucket-Level Access Enabled", + "Checks": [ + "cloudstorage_bucket_uniform_bucket_level_access" + ], + "Attributes": [ + { + "Section": "5 Storage", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that uniform bucket-level access is enabled on Cloud Storage buckets.", + "RationaleStatement": "It is recommended to use uniform bucket-level access to unify and simplify how you grant access to your Cloud Storage resources. Cloud Storage offers two systems for granting users permission to access your buckets and objects: Cloud Identity and Access Management (Cloud IAM) and Access Control Lists (ACLs). These systems act in parallel - in order for a user to access a Cloud Storage resource, only one of the systems needs to grant the user permission. Cloud IAM is used throughout Google Cloud and allows you to grant a variety of permissions at the bucket and project levels. ACLs are used only by Cloud Storage and have limited permission options, but they allow you to grant permissions on a per-object basis. In order to support a uniform permissioning system, Cloud Storage has uniform bucket-level access. Using this feature disables ACLs for all Cloud Storage resources: access to Cloud Storage resources then is granted exclusively through Cloud IAM. Enabling uniform bucket-level access guarantees that if a Storage bucket is not publicly accessible, no object in the bucket is publicly accessible either.", + "ImpactStatement": "If you enable uniform bucket-level access, you revoke access from users who gain their access solely through object ACLs. Certain Google Cloud services, such as Stackdriver, Cloud Audit Logs, and Datastore, cannot export to Cloud Storage buckets that have uniform bucket-level access enabled.", + "RemediationProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting: [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser) 2. In the list of buckets, click on the name of the desired bucket. 3. Select the `Permissions` tab near the top of the page. 4. In the text box that starts with `This bucket uses fine-grained access control...`, click `Edit`. 5. In the pop-up menu that appears, select `Uniform`. 6. Click `Save`. **From Google Cloud CLI** Use the on option in a uniformbucketlevelaccess set command: gsutil uniformbucketlevelaccess set on gs://BUCKET_NAME/ **Prevention** You can set up an Organization Policy to enforce that any new bucket has uniform bucket level access enabled. Learn more at: [https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket](https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket)", + "AuditProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting: [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser) 2. For each bucket, make sure that `Access control` column has the value `Uniform`. **From Google Cloud CLI** 1. List all buckets in a project gsutil ls 2. For each bucket, verify that uniform bucket-level access is enabled. gsutil uniformbucketlevelaccess get gs://BUCKET_NAME/ If uniform bucket-level access is enabled, the response looks like: Uniform bucket-level access setting for gs://BUCKET_NAME/: Enabled: True LockedTime: LOCK_DATE ", + "AdditionalInformation": "Uniform bucket-level access can no longer be disabled if it has been active on a bucket for 90 consecutive days.", + "References": "https://cloud.google.com/storage/docs/uniform-bucket-level-access:https://cloud.google.com/storage/docs/using-uniform-bucket-level-access:https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket", + "DefaultValue": "By default, Cloud Storage buckets do not have uniform bucket-level access enabled.", + "SubSection": null + } + ] + }, + { + "Id": "6.1.1", + "Description": "Ensure That a MySQL Instance Does Not Allow Anyone To Connect With Administrative Privileges", + "Checks": [], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended to set a password for the administrative user (`root` by default) to prevent unauthorized access to the SQL database instances. This recommendation is applicable only for MySQL Instances. PostgreSQL does not offer any setting for No Password from the cloud console.", + "RationaleStatement": "At the time of MySQL Instance creation, not providing an administrative password allows anyone to connect to the SQL database instance with administrative privileges. The root password should be set to ensure only authorized users have these privileges.", + "ImpactStatement": "Connection strings for administrative clients need to be reconfigured to use a password.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Platform Console using `https://console.cloud.google.com/sql/` 2. Select the instance to open its Overview page. 3. Select `Access Control > Users`. 4. Click the `More actions icon` for the user to be updated. 5. Select `Change password`, specify a `New password`, and click `OK`. **From Google Cloud CLI** 1. Set a password to a MySql instance: gcloud sql users set-password root --host= --instance= --prompt-for-password 2. A prompt will appear, requiring the user to enter a password: Instance Password: 3. With a successful password configured, the following message should be seen: Updating Cloud SQL user...done. ", + "AuditProcedure": "**From Google Cloud CLI** 1. List All SQL database instances of type MySQL: gcloud sql instances list --filter='DATABASE_VERSION:MYSQL*' --project --format=(NAME,PRIMARY_ADDRESS) 2. For every MySQL instance try to connect using the `PRIMARY_ADDRESS`, if available: mysql -u root -h The command should return either an error message or a password prompt. Sample Error message: ERROR 1045 (28000): Access denied for user 'root'@'' (using password: NO) If a command produces the `mysql>` prompt, the MySQL instance allows anyone to connect with administrative privileges without needing a password. **Note:** The `No Password` setting is exposed only at the time of MySQL instance creation. Once the instance is created, the Google Cloud Platform Console does not expose the set to confirm whether a password for an administrative user is set to a MySQL instance.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/sql/docs/mysql/create-manage-users:https://cloud.google.com/sql/docs/mysql/create-instance", + "DefaultValue": "From the Google Cloud Platform Console, the `Create Instance` workflow enforces the rule to enter the root password unless the option `No Password` is selected explicitly." + } + ] + }, + { + "Id": "6.1.2", + "Description": "Ensure ‘Skip_show_database’ Database Flag for Cloud SQL MySQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_mysql_skip_show_database_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `skip_show_database` database flag for Cloud SQL Mysql instance to `on`", + "RationaleStatement": "`skip_show_database` database flag prevents people from using the SHOW DATABASES statement if they do not have the SHOW DATABASES privilege. This can improve security if you have concerns about users being able to see databases belonging to other users. Its effect depends on the SHOW DATABASES privilege: If the variable value is ON, the SHOW DATABASES statement is permitted only to users who have the SHOW DATABASES privilege, and the statement displays all database names. If the value is OFF, SHOW DATABASES is permitted to all users, but displays the names of only those databases for which the user has the SHOW DATABASES or other privilege. This recommendation is applicable to Mysql database instances.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the Mysql instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `skip_show_database` from the drop-down menu, and set its value to `on`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Configure the `skip_show_database` database flag for every Cloud SQL Mysql database instance using the below command. gcloud sql instances patch --database-flags skip_show_database=on **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `skip_show_database` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Ensure the below command returns `on` for every Cloud SQL Mysql database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==skip_show_database)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/mysql/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/mysql/flags:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_skip_show_database", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.3", + "Description": "Ensure That the ‘Local_infile’ Database Flag for a Cloud SQL MySQL Instance Is Set to ‘Off’", + "Checks": [ + "cloudsql_instance_mysql_local_infile_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set the `local_infile` database flag for a Cloud SQL MySQL instance to `off`.", + "RationaleStatement": "The `local_infile` flag controls the server-side LOCAL capability for LOAD DATA statements. Depending on the `local_infile` setting, the server refuses or permits local data loading by clients that have LOCAL enabled on the client side. To explicitly cause the server to refuse LOAD DATA LOCAL statements (regardless of how client programs and libraries are configured at build time or runtime), start mysqld with local_infile disabled. local_infile can also be set at runtime. Due to security issues associated with the `local_infile` flag, it is recommended to disable it. This recommendation is applicable to MySQL database instances.", + "ImpactStatement": "Disabling `local_infile` makes the server refuse local data loading by clients that have LOCAL enabled on the client side.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the MySQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `local_infile` from the drop-down menu, and set its value to `off`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. Configure the `local_infile` database flag for every Cloud SQL Mysql database instance using the below command: gcloud sql instances patch --database-flags local_infile=off **Note**: This command will overwrite all database flags that were previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `local_infile` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. List all Cloud SQL database instances: gcloud sql instances list 2. Ensure the below command returns `off` for every Cloud SQL MySQL database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==local_infile)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require the instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/mysql/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines.", + "References": "https://cloud.google.com/sql/docs/mysql/flags:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_local_infile:https://dev.mysql.com/doc/refman/5.7/en/load-data-local.html", + "DefaultValue": "By default `local_infile` is `on`." + } + ] + }, + { + "Id": "6.2.1", + "Description": "Ensure ‘Log_error_verbosity’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘DEFAULT’ or Stricter", + "Checks": [ + "cloudsql_instance_postgres_log_error_verbosity_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "The `log_error_verbosity` flag controls the verbosity/details of messages logged. Valid values are: - `TERSE` - `DEFAULT` - `VERBOSE` `TERSE` excludes the logging of `DETAIL`, `HINT`, `QUERY`, and `CONTEXT` error information. `VERBOSE` output includes the `SQLSTATE` error code, source code file name, function name, and line number that generated the error. Ensure an appropriate value is set to 'DEFAULT' or stricter.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_error_verbosity` is not set to the correct value, too many details or too few details may be logged. This flag should be configured with a value of 'DEFAULT' or stricter. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting https://console.cloud.google.com/sql/instances. 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_error_verbosity` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the log_error_verbosity database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch INSTANCE_NAME --database-flags log_error_verbosity= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_error_verbosity` flag is set to 'DEFAULT' or stricter. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_error_verbosity` gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_error_verbosity)|.value' In the output, database flags are listed under the settings as the collection databaseFlags.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-ERROR-VERBOSITY", + "DefaultValue": "By default `log_error_verbosity` is `DEFAULT`." + } + ] + }, + { + "Id": "6.2.2", + "Description": "Ensure That the ‘Log_connections’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling the `log_connections` setting causes each attempted connection to the server to be logged, along with successful completion of client authentication. This parameter cannot be changed after the session starts.", + "RationaleStatement": "PostgreSQL does not log attempted connections by default. Enabling the `log_connections` setting will create log entries for each attempted connection as well as successful completion of client authentication which can be useful in troubleshooting issues and to determine any unusual connection attempts to the server. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting https://console.cloud.google.com/sql/instances. 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_connections` from the drop-down menu and set the value as `on`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_connections` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_connections=on **Note**: This command will overwrite all previously set database flags. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_connections` flag to determine if it is configured as expected. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL PostgreSQL database instance: gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_connections)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see the Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-CONNECTIONS", + "DefaultValue": "By default `log_connections` is `off`." + } + ] + }, + { + "Id": "6.2.3", + "Description": "Ensure That the ‘Log_disconnections’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_postgres_log_disconnections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling the `log_disconnections` setting logs the end of each session, including the session duration.", + "RationaleStatement": "PostgreSQL does not log session details such as duration and session end by default. Enabling the `log_disconnections` setting will create log entries at the end of each session which can be useful in troubleshooting issues and determine any unusual activity across a time period. The `log_disconnections` and `log_connections` work hand in hand and generally, the pair would be enabled/disabled together. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_disconnections` from the drop-down menu and set the value as `on`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_disconnections` database flag for every Cloud SQL PosgreSQL database instance using the below command: gcloud sql instances patch --database-flags log_disconnections=on **Note**: This command will overwrite all previously set database flags. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_disconnections` flag is configured as expected. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL PostgreSQL database instance: gcloud sql instances list --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_disconnections)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-DISCONNECTIONS", + "DefaultValue": "By default `log_disconnections` is off." + } + ] + }, + { + "Id": "6.2.4", + "Description": "Ensure ‘Log_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set Appropriately", + "Checks": [ + "cloudsql_instance_postgres_log_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "The value of `log_statement` flag determined the SQL statements that are logged. Valid values are: - `none` - `ddl` - `mod` - `all` The value `ddl` logs all data definition statements. The value `mod` logs all ddl statements, plus data-modifying statements. The statements are logged after a basic parsing is done and statement type is determined, thus this does not logs statements with errors. When using extended query protocol, logging occurs after an Execute message is received and values of the Bind parameters are included. A value of 'ddl' is recommended unless otherwise directed by your organization's logging policy.", + "RationaleStatement": "Auditing helps in forensic analysis. If `log_statement` is not set to the correct value, too many statements may be logged leading to issues in finding the relevant information from the logs, or too few statements may be logged with relevant information missing from the logs. Setting log_statement to align with your organization's security and logging policies facilitates later auditing and review of database activities. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_statement` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_statement` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_statement= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_statement` flag is set to appropriately. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_statement` gcloud sql instances list --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_statement)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-STATEMENT", + "DefaultValue": "`none`" + } + ] + }, + { + "Id": "6.2.5", + "Description": "Ensure that the ‘Log_min_messages’ Flag for a Cloud SQL PostgreSQL Instance is set at minimum to 'Warning'", + "Checks": [ + "cloudsql_instance_postgres_log_min_messages_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_messages` flag defines the minimum message severity level that is considered as an error statement. Messages for error statements are logged with the SQL statement. Valid values include (from lowest to highest severity) `DEBUG5`, `DEBUG4`, `DEBUG3`, `DEBUG2`, `DEBUG1`, `INFO`, `NOTICE`, `WARNING`, `ERROR`, `LOG`, `FATAL`, and `PANIC`. Each severity level includes the subsequent levels mentioned above. ERROR is considered the best practice setting. Changes should only be made in accordance with the organization's logging policy.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_min_messages` is not set to the correct value, messages may not be classified as error messages appropriately. Setting the threshold to 'Warning' will log messages for the most needed error messages. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Setting the threshold too low will might result in increased log storage size and length, making it difficult to find actual errors. Higher severity levels may cause errors needed to troubleshoot to not be logged. An organization will need to decide their own threshold for logging `log_min_messages` flag. **Note**: To effectively turn off logging failing statements, set this parameter to PANIC.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_min_messages` from the drop-down menu and set appropriate value. 6. Click `Save` to save the changes. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_min_messages` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_min_messages= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_min_messages` flag is set to `warning` or higher (WARNING|ERROR|LOG|FATAL|PANIC). **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify that the value of `log_min_messages` is set to `warning` or higher . gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_min_messages)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-MESSAGES", + "DefaultValue": "By default `log_min_messages` is `ERROR`." + } + ] + }, + { + "Id": "6.2.6", + "Description": "Ensure ‘Log_min_error_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘Error’ or Stricter", + "Checks": [ + "cloudsql_instance_postgres_log_min_error_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_error_statement` flag defines the minimum message severity level that are considered as an error statement. Messages for error statements are logged with the SQL statement. Valid values include (from lowest to highest severity) `DEBUG5`, `DEBUG4`, `DEBUG3`, `DEBUG2`, `DEBUG1`, `INFO`, `NOTICE`, `WARNING`, `ERROR`, `LOG`, `FATAL`, and `PANIC`. Each severity level includes the subsequent levels mentioned above. Ensure a value of `ERROR` or stricter is set.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_min_error_statement` is not set to the correct value, messages may not be classified as error messages appropriately. Considering general log messages as error messages would make is difficult to find actual errors and considering only stricter severity levels as error messages may skip actual errors to log their SQL statements. The `log_min_error_statement` flag should be set to `ERROR` or stricter. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `log_min_error_statement` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_min_error_statement` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_min_error_statement= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_min_error_statement` flag is configured as to `ERROR` or stricter. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_min_error_statement` is set to `ERROR` or stricter. gcloud sql instances describe --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_min_error_statement)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-ERROR-STATEMENT", + "DefaultValue": "By default `log_min_error_statement` is `ERROR`." + } + ] + }, + { + "Id": "6.2.7", + "Description": "Ensure That the ‘Log_min_duration_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to '-1' (Disabled)", + "Checks": [ + "cloudsql_instance_postgres_log_min_duration_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_duration_statement` flag defines the minimum amount of execution time of a statement in milliseconds where the total duration of the statement is logged. Ensure that `log_min_duration_statement` is disabled, i.e., a value of `-1` is set.", + "RationaleStatement": "Logging SQL statements may include sensitive information that should not be recorded in logs. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `log_min_duration_statement` from the drop-down menu and set a value of `-1`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. Configure the `log_min_duration_statement` flag for every Cloud SQL PosgreSQL database instance using the below command: gcloud sql instances patch --database-flags log_min_duration_statement=-1 **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check that the value of `log_min_duration_statement` flag is set to `-1`. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_min_duration_statement` is set to `-1`. gcloud sql instances describe --format=json| jq '.settings.databaseFlags[] | select(.name==log_min_duration_statement)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-DURATION-STATEMENT", + "DefaultValue": "By default `log_min_duration_statement` is `-1`." + } + ] + }, + { + "Id": "6.2.8", + "Description": "Ensure That 'cloudsql.enable_pgaudit' Database Flag for each Cloud Sql Postgresql Instance Is Set to 'on' For Centralized Logging", + "Checks": [ + "cloudsql_instance_postgres_enable_pgaudit_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure `cloudsql.enable_pgaudit` database flag for Cloud SQL PostgreSQL instance is set to `on` to allow for centralized logging.", + "RationaleStatement": "As numerous other recommendations in this section consist of turning on flags for logging purposes, your organization will need a way to manage these logs. You may have a solution already in place. If you do not, consider installing and enabling the open source pgaudit extension within PostgreSQL and enabling its corresponding flag of `cloudsql.enable_pgaudit`. This flag and installing the extension enables database auditing in PostgreSQL through the open-source pgAudit extension. This extension provides detailed session and object logging to comply with government, financial, & ISO standards and provides auditing capabilities to mitigate threats by monitoring security events on the instance. Enabling the flag and settings later in this recommendation will send these logs to Google Logs Explorer so that you can access them in a central location. to This recommendation is applicable only to PostgreSQL database instances.", + "ImpactStatement": "Enabling the pgAudit extension can lead to increased data storage requirements and to ensure durability of pgAudit log records in the event of unexpected storage issues, it is recommended to enable the `Enable automatic storage increases` setting on the instance. Enabling flags via the command line will also overwrite all existing flags, so you should apply all needed flags in the CLI command. Also flags may require a restart of the server to be implemented or will break existing functionality so update your servers at a time of low usage.", + "RemediationProcedure": "**Initialize the pgAudit flag** **From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. To set a flag that has not been set on the instance before, click `Add item`. 6. Enter `cloudsql.enable_pgaudit` for the flag name and set the flag to `on`. 7. Click `Done`. 8. Click `Save` to update the configuration. 9. Confirm your changes under `Flags` on the `Overview` page. **From Google Cloud CLI** Run the below command by providing `` to enable `cloudsql.enable_pgaudit` flag. gcloud sql instances patch --database-flags cloudsql.enable_pgaudit=on Note: `RESTART` is required to get this configuration in effect. **Creating the extension** 1. Connect to the the server running PostgreSQL or through a SQL client of your choice. 2. Run the following command as a superuser. CREATE EXTENSION pgaudit; **Updating the previously created pgaudit.log flag for your Logging Needs** **From Console:** Note: there are multiple options here. This command will enable logging for all databases on a server. Please see the customizing database audit logging reference for more flag options. 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. To set a flag that has not been set on the instance before, click `Add item`. 6. Enter `pgaudit.log=all` for the flag name and set the flag to `on`. 7. Click `Done`. 8. Click `Save` to update the configuration. 9. Confirm your changes under `Flags` on the `Overview` page. **From Command Line:** Run the command gcloud sql instances patch --database-flags cloudsql.enable_pgaudit=on,pgaudit.log=all **Determine if logs are being sent to Logs Explorer** 1. From the Google Console home page, open the hamburger menu in the top left. 2. In the menu that pops open, scroll down to Logs Explorer under Operations. 3. In the query box, paste the following and search resource.type=cloudsql_database logName=projects//logs/cloudaudit.googleapis.com%2Fdata_access protoPayload.request.@type=type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry If it returns any log sources, they are correctly setup.", + "AuditProcedure": "**Determining if the pgAudit Flag is set to 'on'** **From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. Ensure that `cloudsql.enable_pgaudit` flag is set to `on`. **From Google Cloud CLI** Run the command by providing ``. Ensure the value of the flag is `on`. gcloud sql instances describe --format=json | jq '.settings|.|.databaseFlags[]|select(.name==cloudsql.enable_pgaudit)|.value' **Determine if the pgAudit extension is installed** 1. Connect to the the server running PostgreSQL or through a SQL client of your choice. 2. Run the following command SELECT * FROM pg_extension; 3. If pgAudit is in this list. If so, it is installed. **Determine if Data Access Audit logs are enabled for your project and have sufficient privileges** 1. From the homepage open the hamburger menu in the top left. 2. Scroll down to `IAM & Admin`and hover over it. 3. In the menu that opens up, select `Audit Logs` 4. In the middle of the page, in the search box next to `filter` search for `Cloud Composer API` 5. Select it, and ensure that both 'Admin Read' and 'Data Read' are checked. **Determine if logs are being sent to Logs Explorer** 1. From the Google Console home page, open the hamburger menu in the top left. 2. In the menu that pops open, scroll down to Logs Explorer under Operations. 3. In the query box, paste the following and search resource.type=cloudsql_database logName=projects//logs/cloudaudit.googleapis.com%2Fdata_access protoPayload.request.@type=type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry 4. If it returns any log sources, they are correctly setup.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. Note: Configuring the 'cloudsql.enable_pgaudit' database flag requires restarting the Cloud SQL PostgreSQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags#list-flags-postgres:https://cloud.google.com/sql/docs/postgres/pg-audit#enable-auditing-flag:https://cloud.google.com/sql/docs/postgres/pg-audit#customizing-database-audit-logging:https://cloud.google.com/logging/docs/audit/configure-data-access#config-console-enable", + "DefaultValue": "By default `cloudsql.enable_pgaudit` database flag is set to `off` and the extension is not enabled." + } + ] + }, + { + "Id": "6.3.1", + "Description": "Ensure 'external scripts enabled' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_external_scripts_enabled_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `external scripts enabled` database flag for Cloud SQL SQL Server instance to `off`", + "RationaleStatement": "`external scripts enabled` enable the execution of scripts with certain remote language extensions. This property is OFF by default. When Advanced Analytics Services is installed, setup can optionally set this property to true. As the External Scripts Enabled feature allows scripts external to SQL such as files located in an R library to be executed, which could adversely affect the security of the system, hence this should be disabled. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `external scripts enabled` from the drop-down menu, and set its value to `off`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `external scripts enabled` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags external scripts enabled=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `external scripts enabled` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==external scripts enabled)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/external-scripts-enabled-server-configuration-option?view=sql-server-ver15:https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/advanced-analytics/concepts/security?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79347", + "DefaultValue": "By default `external scripts enabled` is `off`" + } + ] + }, + { + "Id": "6.3.2", + "Description": "Ensure 'cross db ownership chaining' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `cross db ownership chaining` database flag for Cloud SQL SQL Server instance to `off`. This flag is deprecated for all SQL Server versions in CGP. Going forward, you can't set its value to on. However, if you have this flag enabled, we strongly recommend that you either remove the flag from your database or set it to off. For cross-database access, use the [Microsoft tutorial for signing stored procedures with a certificate](https://learn.microsoft.com/en-us/sql/relational-databases/tutorial-signing-stored-procedures-with-a-certificate?view=sql-server-ver16).", + "RationaleStatement": "Use the `cross db ownership` for chaining option to configure cross-database ownership chaining for an instance of Microsoft SQL Server. This server option allows you to control cross-database ownership chaining at the database level or to allow cross-database ownership chaining for all databases. Enabling `cross db ownership` is not recommended unless all of the databases hosted by the instance of SQL Server must participate in cross-database ownership chaining and you are aware of the security implications of this setting. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Updating flags may cause the database to restart. This may cause it to unavailable for a short amount of time, so this is best done at a time of low usage. You should also determine if the tables in your databases reference another table without using credentials for that database, as turning off cross database ownership will break this relationship.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `cross db ownership chaining` from the drop-down menu, and set its value to `off`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `cross db ownership chaining` database flag for every Cloud SQL SQL Server database instance using the below command: gcloud sql instances patch --database-flags cross db ownership chaining=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**NOTE:** This flag is deprecated for all SQL Server versions. Going forward, you can't set its value to on. However, if you have this flag enabled it should be removed from your database or set to off. **From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console. 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `cross db ownership chaining` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance: gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==cross db ownership chaining)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/cross-db-ownership-chaining-server-configuration-option?view=sql-server-ver15", + "DefaultValue": "This flag is deprecated for all SQL Server versions. Going forward, you can't set its value to on." + } + ] + }, + { + "Id": "6.3.3", + "Description": "Ensure 'user Connections' Database Flag for Cloud SQL SQL Server Instance Is Set to a Non-limiting Value", + "Checks": [ + "cloudsql_instance_sqlserver_user_connections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to check the `user connections` for a Cloud SQL SQL Server instance to ensure that it is not artificially limiting connections.", + "RationaleStatement": "The `user connections` option specifies the maximum number of simultaneous user connections that are allowed on an instance of SQL Server. The actual number of user connections allowed also depends on the version of SQL Server that you are using, and also the limits of your application or applications and hardware. SQL Server allows a maximum of 32,767 user connections. Because user connections is by default a self-configuring value, with SQL Server adjusting the maximum number of user connections automatically as needed, up to the maximum value allowable. For example, if only 10 users are logged in, 10 user connection objects are allocated. In most cases, you do not have to change the value for this option. The default is 0, which means that the maximum (32,767) user connections are allowed. However if there is a number defined here that limits connections, SQL Server will not allow anymore above this limit. If the connections are at the limit, any new requests will be dropped, potentially causing lost data or outages for those using the database.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `user connections` from the drop-down menu, and set its value to your organization recommended value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `user connections` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags user connections=[0-32,767] **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `user connections` listed under the `Database flags` section is 0. **From Google Cloud CLI** 1. Ensure the below command returns a value of 0, for every Cloud SQL SQL Server database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==user connections)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-user-connections-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79119", + "DefaultValue": "By default `user connections` is set to '0' which does not limit the number of connections, giving the server free reign to facilitate a max of 32,767 connections." + } + ] + }, + { + "Id": "6.3.4", + "Description": "Ensure 'user options' Database Flag for Cloud SQL SQL Server Instance Is Not Configured", + "Checks": [ + "cloudsql_instance_sqlserver_user_options_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `user options` option specifies global defaults for all users. A list of default query processing options is established for the duration of a user's work session. The user options option allows you to change the default values of the SET options (if the server's default settings are not appropriate). A user can override these defaults by using the SET statement. You can configure user options dynamically for new logins. After you change the setting of user options, new login sessions use the new setting; current login sessions are not affected. This recommendation is applicable to SQL Server database instances.", + "RationaleStatement": "It is recommended that, `user options` database flag for Cloud SQL SQL Server instance should not be configured. A user can override these defaults set with `user options` by using the SET statement. Some of these features/options could adversely affect the security of the system if enabled.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. Click the X next `user options` flag shown 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Clear the `user options` database flag for every Cloud SQL SQL Server database instance using either of the below commands. Clearing all flags to their default value gcloud sql instances patch --clear-database-flags OR To clear only `user options` database flag, configure the database flag by overriding the `user options`. Exclude `user options` flag and its value, and keep all other flags you want to configure. gcloud sql instances patch --database-flags [FLAG1=VALUE1,FLAG2=VALUE2] **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `user options` that has been set is not listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns empty result for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==user options)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-user-options-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79335", + "DefaultValue": "By default 'user options' is not configured." + } + ] + }, + { + "Id": "6.3.5", + "Description": "Ensure 'remote access' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_remote_access_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `remote access` database flag for Cloud SQL SQL Server instance to `off`.", + "RationaleStatement": "The `remote access` option controls the execution of stored procedures from local or remote servers on which instances of SQL Server are running. This default value for this option is 1. This grants permission to run local stored procedures from remote servers or remote stored procedures from the local server. To prevent local stored procedures from being run from a remote server or remote stored procedures from being run on the local server, this must be disabled. The Remote Access option controls the execution of local stored procedures on remote servers or remote stored procedures on local server. 'Remote access' functionality can be abused to launch a Denial-of-Service (DoS) attack on remote servers by off-loading query processing to a target, hence this should be disabled. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `remote access` from the drop-down menu, and set its value to `off`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `remote access` database flag for every Cloud SQL SQL Server database instance using the below command gcloud sql instances patch --database-flags remote access=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `remote access` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==remote access)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-remote-access-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79337", + "DefaultValue": "By default 'remote access' is 'on'." + } + ] + }, + { + "Id": "6.3.6", + "Description": "Ensure '3625 (trace flag)' Database Flag for all Cloud SQL SQL Server Instances Is Set to 'on'", + "Checks": [ + "cloudsql_instance_sqlserver_trace_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `3625 (trace flag)` database flag for Cloud SQL SQL Server instance to `on`.", + "RationaleStatement": "Microsoft SQL Trace Flags are frequently used to diagnose performance issues or to debug stored procedures or complex computer systems, but they may also be recommended by Microsoft Support to address behavior that is negatively impacting a specific workload. All documented trace flags and those recommended by Microsoft Support are fully supported in a production environment when used as directed. `3625(trace log)` Limits the amount of information returned to users who are not members of the sysadmin fixed server role, by masking the parameters of some error messages using '******'. Setting this in a Google Cloud flag for the instance allows for security through obscurity and prevents the disclosure of sensitive information, hence this is recommended to set this flag globally to on to prevent the flag having been left off, or changed by bad actors. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Changing flags on a database may cause it to be restarted. The best time to do this is at a time where there is low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `3625` from the drop-down menu, and set its value to `on`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `3625` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags 3625=on **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `3625` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==3625)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-traceon-trace-flags-transact-sql?view=sql-server-ver15#trace-flags:https://github.com/ktaranov/sqlserver-kit/blob/master/SQL%20Server%20Trace%20Flag.md", + "DefaultValue": "MS SQL Server implementations by default have trace flags, including the '3625' flag, turned off, as they are used for logging purposes.", + "SubSection": "6.3 SQL Server" + } + ] + }, + { + "Id": "6.3.7", + "Description": "Ensure 'contained database authentication' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_contained_database_authentication_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A contained database includes all database settings and metadata required to define the database and has no configuration dependencies on the instance of the Database Engine where the database is installed. Users can connect to the database without authenticating a login at the Database Engine level. Isolating the database from the Database Engine makes it possible to easily move the database to another instance of SQL Server. Contained databases have some unique threats that should be understood and mitigated by SQL Server Database Engine administrators. Most of the threats are related to the USER WITH PASSWORD authentication process, which moves the authentication boundary from the Database Engine level to the database level, hence this is recommended not to enable this flag. This recommendation is applicable to SQL Server database instances.", + "RationaleStatement": "When contained databases are enabled, database users with the ALTER ANY USER permission, such as members of the db_owner and db_accessadmin database roles, can grant access to databases and by doing so, grant access to the instance of SQL Server. This means that control over access to the server is no longer limited to members of the sysadmin and securityadmin fixed server role, and logins with the server level CONTROL SERVER and ALTER ANY LOGIN permission. It is recommended to set `contained database authentication` database flag for Cloud SQL on the SQL Server instance to `off`.", + "ImpactStatement": "When `contained database authentication` is off (0) for the instance, contained databases cannot be created, or attached to the Database Engine. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. If the flag `contained database authentication` is present and its value is set to 'on', then change it to 'off'. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. If any Cloud SQL for SQL Server instance has the database flag `contained database authentication` set to 'on', then change it to 'off' using the below command: gcloud sql instances patch --database-flags contained database authentication=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Under the 'Database flags' section, if the database flag `contained database authentication` is present, then ensure that it is set to 'off'. **From Google Cloud CLI** 1. Ensure the below command returns `off` for any Cloud SQL for SQL Server database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==contained database authentication)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/contained-database-authentication-server-configuration-option?view=sql-server-ver15:https://docs.microsoft.com/en-us/sql/relational-databases/databases/security-best-practices-with-contained-databases?view=sql-server-ver15", + "DefaultValue": "", + "SubSection": "6.3 SQL Server" + } + ] + }, + { + "Id": "6.4", + "Description": "Ensure That the Cloud SQL Database Instance Requires All Incoming Connections To Use SSL", + "Checks": [ + "cloudsql_instance_ssl_connections" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to enforce all incoming connections to SQL database instance to use SSL.", + "RationaleStatement": "SQL database connections if successfully trapped (MITM); can reveal sensitive data like credentials, database queries, query outputs etc. For security, it is recommended to always use SSL encryption when connecting to your instance. This recommendation is applicable for Postgresql, MySql generation 1, MySql generation 2 and SQL Server 2017 instances.", + "ImpactStatement": "After enforcing SSL requirement for connections, existing client will not be able to communicate with Cloud SQL database instance unless they use SSL encrypted connections to communicate to Cloud SQL database instance.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click on an instance name to see its configuration overview. 3. In the left-side panel, select `Connections`. 3. In the `security` section, select SSL mode as `Allow only SSL connections`. 4. Under `Configure SSL server certificates` click `Create new certificate` and save the setting **From Google Cloud CLI** To enforce SSL encryption for an instance run the command: gcloud sql instances patch INSTANCE_NAME --ssl-mode= ENCRYPTED_ONLY Note: `RESTART` is required for type MySQL Generation 1 Instances (`backendType: FIRST_GEN`) to get this configuration in effect.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click on an instance name to see its configuration overview. 3. In the left-side panel, select `Connections`. 3. In the `Security` section, ensure that `Allow only SSL connections` option is selected. **From Google Cloud CLI** 1. Get the detailed configuration for every SQL database instance using the following command: gcloud sql instances list --format=json Ensure that section `settings: ipConfiguration` has the parameter `sslMode` set to `ENCRYPTED_ONLY `.", + "AdditionalInformation": "By default `Settings: ipConfiguration` has no `authorizedNetworks` set/configured. In that case even if by default `sslMode` is not set, which is equivalent to `sslMode:ALLOW_UNENCRYPTED_AND_ENCRYPTED ` there is no risk as instance cannot be accessed outside of the network unless `authorizedNetworks` are configured. However, If default for `sslMode` is not updated to `ENCRYPTED_ONLY ` any `authorizedNetworks` created later on will not enforce SSL only connection.", + "References": "https://cloud.google.com/sql/docs/postgres/configure-ssl-instance/", + "DefaultValue": "By default parameter `settings: ipConfiguration: sslMode` is not set which is equivalent to `sslMode:ALLOW_UNENCRYPTED_AND_ENCRYPTED`.", + "SubSection": null + } + ] + }, + { + "Id": "6.5", + "Description": "Ensure That Cloud SQL Database Instances Do Not Implicitly Whitelist All Public IP Addresses", + "Checks": [ + "cloudsql_instance_public_access" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Database Server should accept connections only from trusted Network(s)/IP(s) and restrict access from public IP addresses.", + "RationaleStatement": "To minimize attack surface on a Database server instance, only trusted/known and required IP(s) should be white-listed to connect to it. An authorized network should not have IPs/networks configured to `0.0.0.0/0` which will allow access to the instance from anywhere in the world. Note that authorized networks apply only to instances with public IPs.", + "ImpactStatement": "The Cloud SQL database instance would not be available to public IP addresses.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its `Instance details` page. 3. Under the `Configuration` section click `Edit configurations` 4. Under `Configuration options` expand the `Connectivity` section. 5. Click the `delete` icon for the authorized network `0.0.0.0/0`. 6. Click `Save` to update the instance. **From Google Cloud CLI** Update the authorized network list by dropping off any addresses. gcloud sql instances patch --authorized-networks=IP_ADDR1,IP_ADDR2... **Prevention:** To prevent new SQL instances from being configured to accept incoming connections from any IP addresses, set up a `Restrict Authorized Networks on Cloud SQL instances` Organization Policy at: [https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks](https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its `Instance details` page. 3. Under the `Configuration` section click `Edit configurations` 4. Under `Configuration options` expand the `Connectivity` section. 5. Ensure that no authorized network is configured to allow `0.0.0.0/0`. **From Google Cloud CLI** 1. Get detailed configuration for every Cloud SQL database instance. gcloud sql instances list --format=json Ensure that the section `settings: ipConfiguration : authorizedNetworks` does not have any parameter `value` containing `0.0.0.0/0`.", + "AdditionalInformation": "There is no IPv6 configuration found for Google cloud SQL server services.", + "References": "https://cloud.google.com/sql/docs/mysql/configure-ip:https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints:https://cloud.google.com/sql/docs/mysql/connection-org-policy", + "DefaultValue": "By default, authorized networks are not configured. Remote connection to Cloud SQL database instance is not possible unless authorized networks are configured.", + "SubSection": null + } + ] + }, + { + "Id": "6.6", + "Description": "Ensure Cloud SQL Database Instances Have IAM Database Authentication Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended to enable IAM database authentication for Cloud SQL instances (PostgreSQL and MySQL) to eliminate static password-based authentication and instead use short-lived IAM tokens for database access. When enabled, database users can be created that authenticate via IAM, and connections use automatically-generated, short-lived tokens instead of static passwords. This recommendation is applicable to Cloud SQL for PostgreSQL and Cloud SQL for MySQL instances. Cloud SQL for SQL Server does not support IAM database authentication.", + "RationaleStatement": "IAM database authentication eliminates static database passwords by using short-lived IAM tokens for database access. This provides automatic credential rotation, centralized access control through Cloud IAM, and immediate revocation capabilities without password distribution across applications. Database access is correlated with GCP IAM principals in Cloud Logging, enabling better audit trails and attribution of database queries to specific service accounts or users. This recommendation is applicable to Cloud SQL for PostgreSQL and MySQL instances.", + "ImpactStatement": "Enabling IAM database authentication may require application changes, including updating connection methods and replacing password-based database users with IAM principals. Existing users and scripts that depend on static credentials may need to be reworked, and rollout should be coordinated carefully to avoid service disruption.", + "AuditProcedure": "**From Google Cloud CLI**\n\n1. List all Cloud SQL instances: `gcloud sql instances list`.\n2. For every PostgreSQL instance, verify the IAM authentication flag is enabled: `gcloud sql instances describe --format=\"json\" | jq '.settings.databaseFlags[] | select(.name==\"cloudsql.iam_authentication\")'`. It should return value `on`.\n3. For every MySQL instance, verify the flag `cloudsql_iam_authentication` is set to `on`. If the command returns no output, the flag is not set.", + "RemediationProcedure": "**From Google Cloud CLI**\n\n1. For PostgreSQL instances, enable IAM authentication: `gcloud sql instances patch --database-flags=cloudsql.iam_authentication=on`. For MySQL instances, use `cloudsql_iam_authentication=on`. Note: patching database flags overwrites previously set flags, so include all required flags.\n2. Create database users configured for IAM authentication using `gcloud sql users create ... --type=CLOUD_IAM_USER` (or `CLOUD_IAM_SERVICE_ACCOUNT`).\n3. Grant the necessary privileges to the database users via SQL GRANT commands.\n4. Ensure IAM principals have the roles `roles/cloudsql.client` and `roles/cloudsql.instanceUser`.\n5. Test the configuration by connecting using IAM authentication via the Cloud SQL Proxy.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, IAM database authentication is not enabled on Cloud SQL instances." + } + ] + }, + { + "Id": "6.7", + "Description": "Ensure That Cloud SQL Database Instances Do Not Have Public IPs", + "Checks": [ + "cloudsql_instance_public_access" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended to configure Second Generation Sql instance to use private IPs instead of public IPs.", + "RationaleStatement": "To lower the organization's attack surface, Cloud SQL databases should not have public IPs. Private IPs provide improved network security and lower latency for your application.", + "ImpactStatement": "Removing the public IP address on SQL instances may break some applications that relied on it for database connectivity.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console: [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Click the instance name to open its Instance details page. 3. Select the `Connections` tab. 4. Deselect the `Public IP` checkbox. 5. Click `Save` to update the instance. **From Google Cloud CLI** 1. For every instance remove its public IP and assign a private IP instead: gcloud sql instances patch --network= --no-assign-ip 2. Confirm the changes using the following command:: gcloud sql instances describe **Prevention:** To prevent new SQL instances from getting configured with public IP addresses, set up a `Restrict Public IP access on Cloud SQL instances` Organization policy at: [https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp](https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console: [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Ensure that every instance has a private IP address and no public IP address configured. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. For every instance of type `instanceType: CLOUD_SQL_INSTANCE` with `backendType: SECOND_GEN`, get detailed configuration. Ignore instances of type `READ_REPLICA_INSTANCE` because these instances inherit their settings from the primary instance. Also, note that first generation instances cannot be configured to have a private IP address. gcloud sql instances describe 3. Ensure that the setting `ipAddresses` has an IP address configured of `type: PRIVATE` and has no IP address of `type: PRIMARY`. `PRIMARY` IP addresses are public addresses. An instance can have both a private and public address at the same time. Note also that you cannot use private IP with First Generation instances.", + "AdditionalInformation": "Replicas inherit their private IP status from their primary instance. You cannot configure a private IP directly on a replica.", + "References": "https://cloud.google.com/sql/docs/mysql/configure-private-ip:https://cloud.google.com/sql/docs/mysql/private-ip:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints:https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp", + "DefaultValue": "By default, Cloud Sql instances have a public IP.", + "SubSection": null + } + ] + }, + { + "Id": "6.8", + "Description": "Ensure That Cloud SQL Database Instances Are Configured With Automated Backups", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to have all SQL database instances set to enable automated backups.", + "RationaleStatement": "Backups provide a way to restore a Cloud SQL instance to recover lost data or recover from a problem with that instance. Automated backups need to be set for any instance that contains data that should be protected from loss or damage. This recommendation is applicable for SQL Server, PostgreSql, MySql generation 1 and MySql generation 2 instances.", + "ImpactStatement": "Automated Backups will increase required size of storage and costs associated with it.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance where the backups need to be configured. 3. Click `Edit`. 4. In the `Backups` section, check `Enable automated backups', and choose a backup window. 5. Click `Save`. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list --format=json | jq '. | map(select(.instanceType != READ_REPLICA_INSTANCE)) | .[].name' NOTE: gcloud command has been added with the filter to exclude read-replicas instances, as GCP do not provide Automated Backups for read-replica instances. 2. Enable `Automated backups` for every Cloud SQL database instance using the below command: gcloud sql instances patch --backup-start-time <[HH:MM]> The `backup-start-time` parameter is specified in 24-hour time, in the UTC±00 time zone, and specifies the start of a 4-hour backup window. Backups can start any time during the backup window.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its instance details page. 3. Go to the `Backups` menu. 4. Ensure that `Automated backups` is set to `Enabled` and `Backup time` is mentioned. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list --format=json | jq '. | map(select(.instanceType != READ_REPLICA_INSTANCE)) | .[].name' NOTE: gcloud command has been added with the filter to exclude read-replicas instances, as GCP do not provide Automated Backups for read-replica instances. 2. Ensure that the below command returns `True` for every Cloud SQL database instance. gcloud sql instances describe --format=value('Enabled':settings.backupConfiguration.enabled) ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/sql/docs/mysql/backup-recovery/backups:https://cloud.google.com/sql/docs/postgres/backup-recovery/backups:https://cloud.google.com/sql/docs/sqlserver/backup-recovery/backups:https://cloud.google.com/sql/docs/mysql/backup-recovery/backing-up:https://cloud.google.com/sql/docs/postgres/backup-recovery/backing-up:https://cloud.google.com/sql/docs/sqlserver/backup-recovery/backing-up", + "DefaultValue": "By default, automated backups are not configured for Cloud SQL instances.", + "SubSection": null + } + ] + }, + { + "Id": "6.9", + "Description": "Ensure Cloud SQL Database Instances Have Deletion Protection Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that deletion protection is enabled on all Cloud SQL database instances to prevent accidental or unauthorized deletion. This setting safeguards critical databases by requiring explicit disabling of deletion protection before deletion, reducing the risk of data loss through human error or malicious activity.", + "RationaleStatement": "Cloud SQL instances without deletion protection can be permanently deleted with a single API call or console action, allowing compromised credentials, operational errors, or malicious insiders to cause catastrophic data loss. Deletion protection provides a critical safeguard against inadvertent or malicious deletion of production databases. By requiring deliberate action to disable deletion protection before an instance can be deleted, organizations mitigate risks associated with accidental data deletion and enhance the overall resilience of their data storage platform. This control is particularly important for production workloads where data loss could result in significant business disruption, regulatory compliance violations, and reputational damage.", + "ImpactStatement": "There is no performance impact to enabling deletion protection on Cloud SQL instances. The setting only affects delete operations. Administrators will need to explicitly disable deletion protection before deleting an instance, adding an additional step to the deletion workflow.", + "AuditProcedure": "**From Google Cloud CLI**\n\n1. List all Cloud SQL database instances to identify those without deletion protection: `gcloud sql instances list --format=\"table(name,settings.deletionProtectionEnabled)\"`.\n2. For each instance, verify deletion protection is enabled: `gcloud sql instances describe --format=\"value(settings.deletionProtectionEnabled)\"`. The command should return `True`. If it returns `False` or empty, deletion protection is not enabled.", + "RemediationProcedure": "**From Google Cloud CLI**\n\n1. To enable deletion protection on an existing Cloud SQL instance: `gcloud sql instances patch --deletion-protection`.\n2. To enable deletion protection when creating a new Cloud SQL instance, include the `--deletion-protection` flag in the `gcloud sql instances create` command.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, deletion protection is not enabled on Cloud SQL database instances." + } + ] + }, + { + "Id": "7.1", + "Description": "Ensure That BigQuery Datasets Are Not Anonymously or Publicly Accessible", + "Checks": [ + "bigquery_dataset_public_access" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the IAM policy on BigQuery datasets does not allow anonymous and/or public access.", + "RationaleStatement": "Granting permissions to `allUsers` or `allAuthenticatedUsers` allows anyone to access the dataset. Such access might not be desirable if sensitive data is being stored in the dataset. Therefore, ensure that anonymous and/or public access to a dataset is not allowed.", + "ImpactStatement": "The dataset is not publicly accessible. Explicit modification of IAM privileges would be necessary to make them publicly accessible.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `BigQuery` by visiting: [https://console.cloud.google.com/bigquery](https://console.cloud.google.com/bigquery). 2. Select the dataset from 'Resources'. 3. Click `SHARING` near the right side of the window and select `Permissions`. 4. Review each attached role. 5. Click the delete icon for each member `allUsers` or `allAuthenticatedUsers`. On the popup click `Remove`. **From Google Cloud CLI** List the name of all datasets. bq ls Retrieve the data set details: bq show --format=prettyjson PROJECT_ID:DATASET_NAME > PATH_TO_FILE In the access section of the JSON file, update the dataset information to remove all roles containing `allUsers` or `allAuthenticatedUsers`. Update the dataset: bq update --source PATH_TO_FILE PROJECT_ID:DATASET_NAME **Prevention:** You can prevent Bigquery dataset from becoming publicly accessible by setting up the `Domain restricted sharing` organization policy at: https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains .", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `BigQuery` by visiting: [https://console.cloud.google.com/bigquery](https://console.cloud.google.com/bigquery). 2. Select a dataset from `Resources`. 3. Click `SHARING` near the right side of the window and select `Permissions`. 4. Validate that none of the attached roles contain `allUsers` or `allAuthenticatedUsers`. **From Google Cloud CLI** List the name of all datasets. bq ls Retrieve each dataset details using the following command: bq show PROJECT_ID:DATASET_NAME Ensure that `allUsers` and `allAuthenticatedUsers` have not been granted access to the dataset.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/dataset-access-controls", + "DefaultValue": "By default, BigQuery datasets are not publicly accessible.", + "SubSection": null + } + ] + }, + { + "Id": "7.2", + "Description": "Ensure That All BigQuery Tables Are Encrypted With Customer-Managed Encryption Key (CMEK)", + "Checks": [ + "bigquery_table_cmk_encryption" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. The data is encrypted using the `data encryption keys` and data encryption keys themselves are further encrypted using `key encryption keys`. This is seamless and do not require any additional input from the user. However, if you want to have greater control, Customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets. If CMEK is used, the CMEK is used to encrypt the data encryption keys instead of using google-managed encryption keys.", + "RationaleStatement": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. This is seamless and does not require any additional input from the user. For greater control over the encryption, customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery tables. The CMEK is used to encrypt the data encryption keys instead of using google-managed encryption keys. BigQuery stores the table and CMEK association and the encryption/decryption is done automatically. Applying the Default Customer-managed keys on BigQuery data sets ensures that all the new tables created in the future will be encrypted using CMEK but existing tables need to be updated to use CMEK individually. Note: Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. ", + "ImpactStatement": "Using Customer-managed encryption keys (CMEK) will incur additional labor-hour investment to create, protect, and manage the keys.", + "RemediationProcedure": "**From Google Cloud CLI** Use the following command to copy the data. The source and the destination needs to be same in case copying to the original table. bq cp --destination_kms_key source_dataset.source_table destination_dataset.destination_table ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Analytics` 2. Go to `BigQuery` 3. Under `SQL Workspace`, select the project 4. Select Data Set, select the table 5. Go to `Details` tab 6. Under `Table info`, verify `Customer-managed key` is present. 7. Repeat for each table in all data sets for all projects. **From Google Cloud CLI** List all dataset names bq ls Use the following command to view the table details. Verify the `kmsKeyName` is present. bq show ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/customer-managed-encryption", + "DefaultValue": "Google Managed keys are used as `key encryption keys`.", + "SubSection": null + } + ] + }, + { + "Id": "7.3", + "Description": "Ensure That a Default Customer-Managed Encryption Key (CMEK) Is Specified for All BigQuery Data Sets", + "Checks": [ + "bigquery_dataset_cmk_encryption" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. The data is encrypted using the `data encryption keys` and data encryption keys themselves are further encrypted using `key encryption keys`. This is seamless and do not require any additional input from the user. However, if you want to have greater control, Customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets.", + "RationaleStatement": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. This is seamless and does not require any additional input from the user. For greater control over the encryption, customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets. Setting a Default Customer-managed encryption key (CMEK) for a data set ensure any tables created in future will use the specified CMEK if none other is provided. Note: Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. ", + "ImpactStatement": "Using Customer-managed encryption keys (CMEK) will incur additional labor-hour investment to create, protect, and manage the keys.", + "RemediationProcedure": "**From Google Cloud CLI** The default CMEK for existing data sets can be updated by specifying the default key in the `EncryptionConfiguration.kmsKeyName` field when calling the `datasets.insert` or `datasets.patch` methods", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Analytics` 2. Go to `BigQuery` 3. Under `Analysis` click on `SQL Workspaces`, select the project 4. Select Data Set 5. Ensure `Customer-managed key` is present under `Dataset info` section. 6. Repeat for each data set in all projects. **From Google Cloud CLI** List all dataset names bq ls Use the following command to view each dataset details. bq show Verify the `kmsKeyName` is present.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/customer-managed-encryption", + "DefaultValue": "Google Managed keys are used as `key encryption keys`.", + "SubSection": null + } + ] + }, + { + "Id": "7.4", + "Description": "Ensure all data in BigQuery has been classified", + "Checks": [], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "BigQuery tables can contain sensitive data that for security purposes should be discovered, monitored, classified, and protected. Google Cloud's Sensitive Data Protection tools can automatically provide data classification of all BigQuery data across an organization.", + "RationaleStatement": "Using a cloud service or 3rd party software to continuously monitor and automate the process of data discovery and classification for BigQuery tables is an important part of protecting the data. Sensitive Data Protection is a fully managed data protection and data privacy platform that uses machine learning and pattern matching to discover and classify sensitive data in Google Cloud.", + "ImpactStatement": "There is a cost associated with using Sensitive Data Protection. There is also typically a cost associated with 3rd party tools that perform similar processes and protection.", + "RemediationProcedure": "**Enable profiling:** 1. Go to Cloud DLP by visiting https://console.cloud.google.com/dlp/landing/dataProfiles/configurations 1. Click Create Configuration 1. For projects follow https://cloud.google.com/dlp/docs/profile-project. For organizations or folders follow https://cloud.google.com/dlp/docs/profile-org-folder **Review findings:** - Columns or tables with high data risk have evidence of sensitive information without additional protections. To lower the data risk score, consider doing the following: - For columns containing sensitive data, apply a BigQuery policy tag to restrict access to accounts with specific access rights. - De-identify the raw sensitive data using de-identification techniques like masking and tokenization. **Incorporate findings into your security and governance operations:** - Enable sending findings into your security and posture services. You can publish data profiles to Security Command Center and Chronicle. - Automate remediation or enable alerting of new or changed data risk with Pub/Sub.", + "AuditProcedure": "1. Go to Cloud DLP by visiting https://console.cloud.google.com/dlp/landing/dataProfiles/configurations. 2. Verify there is a discovery scan configuration either for the organization or project.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/dlp/docs/data-profiles:https://cloud.google.com/dlp/docs/analyze-data-profiles:https://cloud.google.com/dlp/docs/data-profiles-remediation:https://cloud.google.com/dlp/docs/send-profiles-to-scc:https://cloud.google.com/dlp/docs/profile-org-folder#chronicle:https://cloud.google.com/dlp/docs/profile-org-folder#publish-pubsub", + "DefaultValue": "", + "SubSection": null + } + ] + }, + { + "Id": "8.1", + "Description": "Ensure that Dataproc Cluster is encrypted using Customer-Managed Encryption Key", + "Checks": [ + "dataproc_encrypted_with_cmks_disabled" + ], + "Attributes": [ + { + "Section": "8 Dataproc", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "When you use Dataproc, cluster and job data is stored on Persistent Disks (PDs) associated with the Compute Engine VMs in your cluster and in a Cloud Storage staging bucket. This PD and bucket data is encrypted using a Google-generated data encryption key (DEK) and key encryption key (KEK). The CMEK feature allows you to create, use, and revoke the key encryption key (KEK). Google still controls the data encryption key (DEK).", + "RationaleStatement": "Cloud services offer the ability to protect data related to those services using encryption keys managed by the customer within Cloud KMS. These encryption keys are called customer-managed encryption keys (CMEK). When you protect data in Google Cloud services with CMEK, the CMEK key is within your control.", + "ImpactStatement": "Using Customer Managed Keys involves additional overhead in maintenance by administrators.", + "RemediationProcedure": "**From Google Cloud Console** 1. Login to the GCP Console and navigate to the Dataproc Cluster page by visiting https://console.cloud.google.com/dataproc/clusters. 1. Select the project from the projects dropdown list. 1. On the `Dataproc Cluster` page, click on the `Create Cluster` to create a new cluster with Customer managed encryption keys. 1. On `Create a cluster` page, perform below steps: - Inside `Set up cluster` section perform below steps: -In the `Name` textbox, provide a name for your cluster. - From `Location` select the location in which you want to deploy a cluster. - Configure other configurations as per your requirements. - Inside `Configure Nodes` and `Customize cluster` section configure the settings as per your requirements. - Inside `Manage security` section, perform below steps: - From `Encryption`, select `Customer-managed key`. - Select a customer-managed key from dropdown list. - Ensure that the selected KMS Key have Cloud KMS CryptoKey Encrypter/Decrypter role assign to Dataproc Cluster service account (serviceAccount:service-@compute-system.iam.gserviceaccount.com). - Click on `Create` to create a cluster. - Once the cluster is created migrate all your workloads from the older cluster to the new cluster and delete the old cluster by performing the below steps: - On the `Clusters` page, select the old cluster and click on `Delete cluster`. - On the `Confirm deletion` window, click on `Confirm` to delete the cluster. - Repeat step above for other Dataproc clusters available in the selected project. - Change the project from the project dropdown list and repeat the remediation procedure for other Dataproc clusters available in other projects. **From Google Cloud CLI** Before creating cluster ensure that the selected KMS Key have Cloud KMS CryptoKey Encrypter/Decrypter role assign to Dataproc Cluster service account (serviceAccount:service-@compute-system.iam.gserviceaccount.com). Run clusters create command to create new cluster with customer-managed key: gcloud dataproc clusters create --region=us-central1 --gce-pd-kms-key= The above command will create a new cluster in the selected region. Once the cluster is created migrate all your workloads from the older cluster to the new cluster and Run clusters delete command to delete cluster: gcloud dataproc clusters delete --region=us-central1 Repeat step no. 1 to create a new Dataproc cluster. Change the project by running the below command and repeat the remediation procedure for other projects: gcloud config set project ", + "AuditProcedure": "**From Google Cloud Console** 1. Login to the GCP Console and navigate to the Dataproc Cluster page by visiting https://console.cloud.google.com/dataproc/clusters. 1. Select the project from the project dropdown list. 1. On the `Dataproc Clusters` page, select the cluster and click on the Name attribute value that you want to examine. 1. On the `details` page, select the `Configurations` tab. 1. On the `Configurations` tab, check the `Encryption type` configuration attribute value. If the value is set to `Google-managed key`, then Dataproc Cluster is not encrypted with Customer managed encryption keys. Repeat step no. 3 - 5 for other Dataproc Clusters available in the selected project. 6. Change the project from the project dropdown list and repeat the audit procedure for other projects. **From Google Cloud CLI** 1. Run clusters list command to list all the Dataproc Clusters available in the region: gcloud dataproc clusters list --region='us-central1' 2. Run clusters describe command to get the key details of the selected cluster: gcloud dataproc clusters describe --region=us-central1 --flatten=config.encryptionConfig.gcePdKmsKeyName 3. If the above command output return null, then the selected cluster is not encrypted with Customer managed encryption keys. 4. Repeat step no. 2 and 3 for other Dataproc Clusters available in the selected region. Change the region by updating --region and repeat step no. 2 for other clusters available in the project. Change the project by running the below command and repeat the audit procedure for other Dataproc clusters available in other projects: gcloud config set project ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/security/encryption/default-encryption", + "DefaultValue": "", + "SubSection": null + } + ] + } + ] +} diff --git a/prowler/compliance/gcp/csa_ccm_4.0_gcp.json b/prowler/compliance/gcp/csa_ccm_4.0_gcp.json deleted file mode 100644 index 6623fe5eca..0000000000 --- a/prowler/compliance/gcp/csa_ccm_4.0_gcp.json +++ /dev/null @@ -1,7386 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "GCP", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "iam_cloud_asset_inventory_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "gcr_container_scanning_enabled", - "artifacts_container_analysis_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "gcr_container_scanning_enabled", - "artifacts_container_analysis_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_automated_backups", - "cloudstorage_bucket_versioning_enabled", - "cloudstorage_bucket_soft_delete_enabled" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_automatic_restart_enabled", - "compute_instance_group_autohealing_enabled", - "compute_instance_group_load_balancer_attached", - "compute_instance_group_multiple_zones", - "compute_instance_on_host_maintenance_migrate" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_encryption_with_csek_enabled", - "compute_instance_confidential_computing_enabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "cloudsql_instance_ssl_connections", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "dns_rsasha1_in_use_to_key_sign_in_dnssec", - "dns_rsasha1_in_use_to_zone_sign_in_dnssec" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "compute_instance_encryption_with_csek_enabled", - "dataproc_encrypted_with_cmks_disabled", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "iam_sa_user_managed_key_rotate_90_days", - "apikeys_key_rotated_in_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "iam_role_kms_enforce_separation_of_duties" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_lifecycle_management_enabled" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled", - "iam_audit_logs_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_public_access", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "cloudstorage_bucket_public_access", - "compute_image_not_publicly_shared", - "compute_instance_public_ip", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_lifecycle_management_enabled", - "cloudstorage_bucket_sufficient_retention_period" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "bigquery_dataset_public_access", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "compute_instance_public_ip", - "compute_image_not_publicly_shared", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_organization_essential_contacts_configured" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_role_sa_enforce_separation_of_duties", - "iam_role_kms_enforce_separation_of_duties", - "iam_no_service_roles_at_project_level" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account", - "iam_no_service_roles_at_project_level", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "iam_sa_user_managed_key_rotate_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_no_service_roles_at_project_level", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "compute_project_os_login_enabled" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "compute_project_os_login_2fa_enabled", - "compute_project_os_login_enabled" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "cloudstorage_bucket_uniform_bucket_level_access", - "compute_instance_default_service_account_in_use_with_full_api_access", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_group_autohealing_enabled", - "compute_instance_group_load_balancer_attached", - "compute_instance_group_multiple_zones" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_ip_forwarding_is_enabled", - "compute_network_default_in_use", - "compute_network_dns_logging_enabled", - "compute_network_not_legacy", - "compute_subnet_flow_logs_enabled" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_shielded_vm_enabled", - "compute_project_os_login_enabled", - "compute_instance_serial_ports_in_use", - "compute_instance_block_project_wide_ssh_keys_disabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_private_ip_assignment", - "cloudstorage_uses_vpc_service_controls", - "compute_instance_public_ip", - "compute_network_default_in_use", - "compute_network_not_legacy" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_loadbalancer_logging_enabled", - "compute_public_address_shodan", - "dns_dnssec_disabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_bucket_level_access", - "iam_audit_logs_enabled", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_audit_logs_enabled", - "cloudstorage_bucket_logging_enabled", - "compute_network_dns_logging_enabled", - "compute_subnet_flow_logs_enabled", - "iam_audit_logs_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "compute_subnet_flow_logs_enabled", - "compute_loadbalancer_logging_enabled", - "compute_network_dns_logging_enabled", - "cloudstorage_audit_logs_enabled", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_statement_flag", - "cloudsql_instance_postgres_enable_pgaudit_flag" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_uniform_bucket_level_access" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_audit_logs_enabled", - "iam_audit_logs_enabled", - "logging_sink_created" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "iam_organization_essential_contacts_configured" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "compute_public_address_shodan", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "compute_instance_confidential_computing_enabled", - "compute_instance_encryption_with_csek_enabled", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/compliance/gcp/prowler_threatscore_gcp.json b/prowler/compliance/gcp/prowler_threatscore_gcp.json index 46fc776fbe..923a1acfc2 100644 --- a/prowler/compliance/gcp/prowler_threatscore_gcp.json +++ b/prowler/compliance/gcp/prowler_threatscore_gcp.json @@ -117,7 +117,7 @@ "Id": "1.2.4", "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { diff --git a/prowler/compliance/github/cis_1.0_github.json b/prowler/compliance/github/cis_1.0_github.json index e9dbccc7f6..2a60df6bcd 100644 --- a/prowler/compliance/github/cis_1.0_github.json +++ b/prowler/compliance/github/cis_1.0_github.json @@ -73,7 +73,9 @@ { "Id": "1.1.4", "Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.", - "Checks": [], + "Checks": [ + "repository_default_branch_dismisses_stale_reviews" + ], "Attributes": [ { "Section": "1 Source Code", diff --git a/prowler/compliance/github/cis_1.2.0_github.json b/prowler/compliance/github/cis_1.2.0_github.json new file mode 100644 index 0000000000..840b5aa3d8 --- /dev/null +++ b/prowler/compliance/github/cis_1.2.0_github.json @@ -0,0 +1,2574 @@ +{ + "Framework": "CIS", + "Name": "CIS GitHub Benchmark v1.2.0", + "Version": "1.2.0", + "Provider": "GitHub", + "Description": "This document provides prescriptive guidance for establishing a secure configuration posture for securing GitHub.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Manage all code projects in a version control platform.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Manage all code projects in a version control platform.", + "RationaleStatement": "Version control platforms keep track of every modification to code. They represent the cornerstone of code security, as well as allowing for better code collaboration within engineering teams. With granular access management, change tracking, and key signing of code edits, version control platforms are the first step in securing the software supply chain.", + "ImpactStatement": "", + "RemediationProcedure": "Upload existing code projects to a dedicated Github organization and repositories and create an identity for each active team member who might contribute or need access to it.", + "AuditProcedure": "Ensure that all code activity is managed through Github repository for every micro-service or application developed by an organization.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.2", + "Description": "Use a task management system to trace any code back to its associated task.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use a task management system to trace any code back to its associated task.", + "RationaleStatement": "The ability to trace each piece of code back to its associated task simplifies the Agile and DevOps process by enabling transparency of any code changes. This allows faster remediation of bugs and security issues, while also making it harder to push unauthorized code changes to sensitive projects. Additionally, using a task management system simplifies achieving compliance, as it is easier to track each regulation.", + "ImpactStatement": "", + "RemediationProcedure": "Use a task management system to manage tasks as the starting point for each code change. Whether it is a new feature, bug fix, or security fix - all should originate from a dedicated task (ticket) in your organization's task management system. These tasks should also be linked to the code changes themselves in a way that is easy to follow: from code to task, and from task back to code.", + "AuditProcedure": "Ensure every code change can be traced back to its origin task in a task management system.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.3", + "Description": "Ensure that every code change is reviewed and approved by two authorized contributors who are both strongly authenticated - using Multi-Factor Authentication (MFA), from the team relevant to the code change.", + "Checks": [ + "repository_default_branch_requires_multiple_approvals" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that every code change is reviewed and approved by two authorized contributors who are both strongly authenticated - using Multi-Factor Authentication (MFA), from the team relevant to the code change.", + "RationaleStatement": "To prevent malicious or unauthorized code changes, the first layer of protection is the process of code review. This process involves engineer teammates reviewing each other's code for errors, optimizations, and general knowledge-sharing. With proper peer reviews in place, an organization can detect unwanted code changes very early in the process of release. In order to help facilitate code review, companies should employ automation to verify that every code change has been reviewed and approved by at least two team members before it is pushed into the code base. These team members should be from the team that is related to the code change, so it will be a meaningful review.", + "ImpactStatement": "To enforce a code review requirement, verification for a minimum of two reviewers must be put into place. This will ensure new code will not be able to be pushed to the code base before it has received two independent approvals.", + "RemediationProcedure": "For every code repository in use, perform the next steps to require two approvals from the specific code repository team in order to push new code to the code base:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you added the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Check **Require a pull request before merging** and **Require approvals**, and set **Required number of approvals before merging** to 2.\n5. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, perform the next steps to verify that two approvals from the specific code repository team are required to push new code to the code base:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require a pull request before merging** and **Require approvals** are checked, and verify that **Required number of approvals before merging** is set to 2.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "0" + } + ] + }, + { + "Id": "1.1.4", + "Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.", + "Checks": [ + "repository_default_branch_dismisses_stale_reviews" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.", + "RationaleStatement": "An approval process is necessary when code changes are suggested. Through this approval process, however, changes can still be made to the original proposal even after some approvals have already been given. This means malicious code can find its way into the code base even if the organization has enforced a review policy. To ensure this is not possible, outdated approvals must be declined when changes to the suggestion are introduced.", + "ImpactStatement": "If new code changes are pushed to a specific proposal, all previously accepted code change proposals must be declined.", + "RemediationProcedure": "For each code repository in use, perform the next steps to enforce dismissal of given approvals to code change suggestions if those suggestions were updated:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you added the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require pull request reviews before merging** and then **Dismiss stale pull request approvals when new commits are pushed**.\n5. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, perform the next steps to verify that each updated code suggestion declines the previously received approvals:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require a pull request before merging** is checked, and verify that **Dismiss stale pull request approvals when new commits are pushed** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.5", + "Description": "Only trusted users should be allowed to dismiss code change reviews.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Only trusted users should be allowed to dismiss code change reviews.", + "RationaleStatement": "Dismissing a code change review permits users to merge new suggested code changes without going through the standard process of approvals. Controlling who can perform this action will prevent malicious actors from simply dismissing the required reviews to code changes and merging malicious or dysfunctional code into the code base.", + "ImpactStatement": "In cases where a code change proposal has been updated since it was last reviewed and the person who reviewed it isn't available for approval, a general collaborator would not be able to merge their code changes until a user with \"dismiss review\" abilities could dismiss the open review.\n\nUsers who are not allowed to dismiss code change reviews will not be permitted to do so, and thus are unable to waive the standard flow of approvals.", + "RemediationProcedure": "For each code repository in use, perform the next steps to restrict dismissal of code changes reviews unless it is necessary:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you added the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n4. Select **Require pull request reviews before merging** and **Restrict who can dismiss pull request reviews**.\n5. Do not add any user or team unless it is obligatory. If it is obligatory, carefully select the users or teams whom you trust with the ability to dismiss code change reviews.\n6. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, perform the next steps to verify that only trusted users are allowed to dismiss code change reviews: \n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n4. Verify that **Require a pull request before merging** and **Restrict who can dismiss pull request reviews** is checked.\n5. Verify that no users and teams are specified except for organization and repository admins. If it is obligatory, verify that the users or teams specified were carefully selected to be trusted with the ability to dismiss code change reviews.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, all users who have write access to the code repository are able to dismiss code change reviews." + } + ] + }, + { + "Id": "1.1.6", + "Description": "Code owners are trusted users that are responsible for reviewing and managing an important piece of code or configuration. An organization is advised to set code owners for every extremely sensitive code or configuration.", + "Checks": [ + "repository_has_codeowners_file" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Code owners are trusted users that are responsible for reviewing and managing an important piece of code or configuration. An organization is advised to set code owners for every extremely sensitive code or configuration.", + "RationaleStatement": "Configuring code owners protects data by verifying that trusted users will notice and review every edit, thus preventing unwanted or malicious changes from potentially compromising sensitive code or configurations.", + "ImpactStatement": "Code owner users will receive notifications for every change that occurs to the code and subsequently added as reviewers of pull requests automatically.", + "RemediationProcedure": "In every code repository create a CODEOWNERS file in the root, docs/, or .github/ directory of the repository.\nIn the file, specify codeowners for the .github/workflows/ directory atleast. Specify organization members you trust. For example:\n```\n.github/workflows/ @user1 @user2\n```", + "AuditProcedure": "In every code repository, verify that a file named CODEOWNERS exists in the root, docs/, or .github/ directory of the repository.\nIn the CODEOWNERS file, verify that the users specified are users you trust.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners", + "DefaultValue": "None" + } + ] + }, + { + "Id": "1.1.7", + "Description": "Ensure trusted code owners are required to review and approve any code change proposal made to their respective owned areas in the code base.", + "Checks": [ + "repository_default_branch_requires_codeowners_review" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure trusted code owners are required to review and approve any code change proposal made to their respective owned areas in the code base.", + "RationaleStatement": "Configuring code owners ensures that no code, especially code which could prove malicious, will slip into the source code or configuration files of a repository. This allows an organization to mark areas in the code base that are especially sensitive or more prone to an attack. It can also enforce review by specific individuals who are designated as owners to those areas so that they may filter out unauthorized or unwanted changes beforehand.", + "ImpactStatement": "If an organization enforces code owner-based reviews, some code change proposals would not be able to be merged to the codebase before specific, trusted individuals approve them.", + "RemediationProcedure": "For every code repository in use, perform the following steps to require code owners' approvals for each change proposal related to code they own:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require pull request reviews before merging** and **Require review from Code Owners**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, perform the following steps to verify that code owners are required to review all code change proposals relevant to areas they own before code merge:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n4. Ensure that **Require a pull request before merging** and **Require review from Code Owners** are checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Code owners are not required to review changes by default." + } + ] + }, + { + "Id": "1.1.8", + "Description": "Keep track of code branches that are inactive for a lengthy period of time and periodically remove them.", + "Checks": [ + "repository_inactive_not_archived", + "repository_branch_delete_on_merge_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Keep track of code branches that are inactive for a lengthy period of time and periodically remove them.", + "RationaleStatement": "Git branches that have been inactive (i.e., no new changes introduced) for a long period of time are enlarging the surface of attack for malicious code injection, sensitive data leaks, and CI pipeline exploitation. They potentially contain outdated dependencies which may leave them highly vulnerable. They are more likely to be improperly managed, and could possibly be accessed by a large number of members of the organization.", + "ImpactStatement": "Removing inactive Git branches means that any code changes they contain would be removed along with them, thus work done in the past might not be accessible after auditing for inactivity.", + "RemediationProcedure": "For each code repository in use, review existing Git branches and remove those which have not been active for a period of time by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Above the list of files, click **Branches**.\n3. Use the navigation at the top of the page to view the Stale branches. The Stale view shows all branches that no one has committed to in the last three months, ordered by the branches with the oldest commits first.\n4. For each branch listed, either delete it by clicking the trash bin icon, or find the valid reason it still exists.\n\nYou can perform the next steps to prevent pull request branches from becoming stale branches:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click Settings.\n3. Under \"Pull Requests\", select **Automatically delete head branches**.", + "AuditProcedure": "For each code repository in use, verify that all existing Git branches are active or have yet to be checked for inactivity by performing the next steps:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Above the list of files, click **Branches**.\n3. Use the navigation at the top of the page to view the Stale branches. The Stale view shows all branches that no one has committed to in the last three months, ordered by the branches with the oldest commits first.\n4. If the list is empty, you are compliant. If the list is not empty, but there is a valid reason the branches listed are not deleted, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, newly opened Git branches would never be removed, regardless of activity or inactivity." + } + ] + }, + { + "Id": "1.1.9", + "Description": "Before a code change request can be merged to the code base, all predefined checks must successfully pass.", + "Checks": [ + "repository_default_branch_status_checks_required" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Before a code change request can be merged to the code base, all predefined checks must successfully pass.", + "RationaleStatement": "On top of manual reviews of code changes, a code protect should contain a set of prescriptive checks which validate each change. Organizations should enforce those status checks so that changes can only be introduced if all checks have successfully passed. This set of checks should serve as the absolute quality, stability, and security conditions which must be met in order to merge new code to a project.", + "ImpactStatement": "Code changes in which all checks do not pass successfully would not be able to be pushed into the code base of the specific code repository.", + "RemediationProcedure": "For each code repository in use, require all status checks to pass before permitting a merge of new code by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", check if there is a rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require status checks to pass before merging**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, verify that status checks are required to pass before allowing any code change proposal merge by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require status checks to pass before merging** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, no checks are defined per project, and thus no enforcement of checks is made." + } + ] + }, + { + "Id": "1.1.10", + "Description": "Organizations should make sure each suggested code change is in full sync with the existing state of its origin code repository before allowing merging.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Organizations should make sure each suggested code change is in full sync with the existing state of its origin code repository before allowing merging.", + "RationaleStatement": "Git branches can easily become outdated since the origin code repository is constantly being edited. This means engineers working on separate code branches can accidentally include outdated code with potential security issues which might have already been fixed, overriding the potential solutions for those security issues when merging their own changes.", + "ImpactStatement": "If enforced, outdated branches would not be able to be merged into their origin repository without first being updated to contain any recent changes.", + "RemediationProcedure": "For each code repository in use, enforce a policy to only allow merging open branches if they are current with the latest change from their original repository by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require status checks to pass before merging** and **Require branches to be up to date before merging**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For each code repository in use, verify that open branches must be updated before merging is permitted by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require status checks to pass before merging** and **Require branches to be up to date before merging** are checked.", + "AdditionalInformation": "", + "References": "https://github.blog/changelog/2022-02-03-more-ways-to-keep-your-pull-request-branch-up-to-date/", + "DefaultValue": "By default, there is no requirement to update a branch before merging it." + } + ] + }, + { + "Id": "1.1.11", + "Description": "Organizations should enforce a \"no open comments\" policy before allowing code change merging.", + "Checks": [ + "repository_default_branch_requires_conversation_resolution" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Organizations should enforce a \"no open comments\" policy before allowing code change merging.", + "RationaleStatement": "In an open code change proposal, reviewers can leave comments containing their questions and suggestions. These comments can also include potential bugs and security issues. Requiring all comments on a code change proposal to be resolved before it can be merged ensures that every concern is properly addressed or acknowledged before the new code changes are introduced to the code base.", + "ImpactStatement": "Code change proposals containing open comments would not be able to be merged into the code base.", + "RemediationProcedure": "For each code repository in use, require open comments to be resolved before the relevant code change can be merged by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require conversation resolution before merging**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, verify that each merged code change does not contain open, unattended comments by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require conversation resolution before merging** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, code changes with open comments on them are able to be merged into the code \nbase." + } + ] + }, + { + "Id": "1.1.12", + "Description": "Ensure every commit in a pull request is signed and verified before merging.", + "Checks": [ + "repository_default_branch_requires_signed_commits" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure every commit in a pull request is signed and verified before merging.", + "RationaleStatement": "Signing commits, or requiring to sign commits, gives other users confidence about the origin of a specific code change. It ensures that the author of the change is not hidden and is verified by the version control system, thus the change comes from a trusted source.", + "ImpactStatement": "Pull requests with unsigned commits cannot be merged.", + "RemediationProcedure": "For each repository in use, enforce the branch protection rule of requiring signed commits, and make sure only signed commits are capable of merging by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require signed commits**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "Ensure only signed commits can be merged for every branch, especially the main branch, via branch protection rules by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require signed commits** is checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.13", + "Description": "Linear history is the name for Git history where all commits are listed in chronological order, one after another. Such history exists if a pull request is merged either by rebase merge (re-order the commits history) or squash merge (squashes all commits to one). Ensure that linear history is required by requiring the use of rebase or squash merge when merging a pull request.", + "Checks": [ + "repository_default_branch_requires_linear_history" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Linear history is the name for Git history where all commits are listed in chronological order, one after another. Such history exists if a pull request is merged either by rebase merge (re-order the commits history) or squash merge (squashes all commits to one). Ensure that linear history is required by requiring the use of rebase or squash merge when merging a pull request.", + "RationaleStatement": "Enforcing linear history produces a clear record of activity, and as such it offers specific advantages: it is easier to follow, easier to revert a change, and bugs can be found more easily.", + "ImpactStatement": "Pull request cannot be merged except squash or rebase merge.", + "RemediationProcedure": "For every code repository in use, perform the following steps to require linear history and/or allow only rebase merge and squash merge:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Require linear history**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, perform the following steps to verify that linear history is required and/or that only squash merge and rebase merge are allowed:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Require linear history** is checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.14", + "Description": "Ensure administrators are subject to branch protection rules.", + "Checks": [ + "repository_default_branch_protection_applies_to_admins" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure administrators are subject to branch protection rules.", + "RationaleStatement": "Administrators by default are excluded from any branch protection rules. This means these privileged users (both on the repository and organization levels) are not subject to protections meant to prevent untrusted code insertion, including malicious code. This is extremely important since administrator accounts are often targeted for account hijacking due to their privileged role.", + "ImpactStatement": "Administrator users won't be able to push code directly to the protected branch without being compliant with listed branch protection rules.", + "RemediationProcedure": "For every code repository in use, enforce branch protection rules on administrators as well, by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Do not allow bypassing the above settings**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, validate branch protection rules also apply to administrator accounts by performing the next steps:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Do not allow bypassing the above settings** is checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "Administrator accounts are not subject to branch protection rules by default." + } + ] + }, + { + "Id": "1.1.15", + "Description": "Ensure that only trusted users can push or merge new code to protected branches.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that only trusted users can push or merge new code to protected branches.", + "RationaleStatement": "Requiring that only trusted users may push or merge new changes reduces the risk of unverified code, especially malicious code, to a protected branch by reducing the number of trusted users who are capable of doing such.", + "ImpactStatement": "Only administrators and trusted users can push or merge to the protected branch.", + "RemediationProcedure": "For every code repository in use, allow only trusted and responsible users to push or merge new code by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4.Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Select **Restrict who can push to matching branches** and choose trusted and responsible users and teams who will have the permission to do so. \n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, ensure only trusted and responsible users can push or merge new code by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Restrict who can push to matching branches** is checked and that only trusted and responsible users and teams are selected.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.16", + "Description": "The \"Force Push\" option allows users with \"Push\" permissions to force their changes directly to the branch without a pull request, and thus should be disabled.", + "Checks": [ + "repository_default_branch_disallows_force_push" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The \"Force Push\" option allows users with \"Push\" permissions to force their changes directly to the branch without a pull request, and thus should be disabled.", + "RationaleStatement": "The \"Force Push\" option allows users to override the existing code with their own code. This can lead to both intentional and unintentional data loss, as well as data infection with malicious code. Disabling the \"Force Push\" option prohibits users from forcing their changes to the master branch, which ultimately prevents malicious code from entering source code.", + "ImpactStatement": "Users cannot force push to protected branches.", + "RemediationProcedure": "For each repository in use, block the option to \"Force Push\" code by performing the following:\n\nEnsure that Trunk / Long-standing branches are protected by Policies and submitted to PR while applying the 4-eyes approach.\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Uncheck **Allow force pushes**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For every code repository in use, validate that no one can force push code by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Allow force pushes** is not checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.17", + "Description": "Ensure that users with only push access are incapable of deleting a protected branch.", + "Checks": [ + "repository_default_branch_deletion_disabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that users with only push access are incapable of deleting a protected branch.", + "RationaleStatement": "When enabling deletion of a protected branch, any user with at least push access to the repository can delete a branch. This can be potentially dangerous, as a simple human mistake or a hacked account can lead to data loss if a branch is deleted. It is therefore crucial to prevent such incidents by denying protected branch deletion.", + "ImpactStatement": "Protected branches cannot be deleted.", + "RemediationProcedure": "For each repository that is being used, block the option to delete protected branches by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, click **Add rule**.\n5. If you add the rule, under \"Branch name pattern\", type the branch name or pattern you want to protect.\n6. Uncheck **Allow deletions**.\n7. Click **Create** or **Save changes**.", + "AuditProcedure": "For each repository that is being used, verify that protected branches cannot be deleted by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", verify that there is at least one rule for your main branch. If there is, click **Edit** to its right. If there isn't, you are not compliant.\n5. Ensure that **Allow deletions** is not checked.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/managing-a-branch-protection-rule", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.18", + "Description": "Ensure that every pull request is required to be scanned for risks.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that every pull request is required to be scanned for risks.", + "RationaleStatement": "Scanning pull requests to detect risks allows for early detection of vulnerable code and/or dependencies and helps mitigate potentially malicious code.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository in use, enforce risk scanning on every pull request by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Actions**.\n3. If the repository already has at least one workflow set up and running, click **New workflow** and go to step 5. If there are currently no workflows configured for the repository, go to the next step.\n4. Scroll down to the \"Security\" category and click **Configure** under the workflow you want to configure or click **View all** to see all available security workflows.\n5. On the right pane of the workflow page, click **Documentation** and follow the on-screen instructions to tailor the workflow to your needs.", + "AuditProcedure": "For each repository in use, ensure that every pull request must be scanned for risks by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Actions**.\n3. Ensure that at least one workflow is configured to run like that: \n```\non:\n push:\n branches: [ \"main\" ]\n pull_request:\n branches: [ \"main\" ]\n```\nand that it has a step that runs code scanning on the code.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.19", + "Description": "Ensure that changes in the branch protection rules are audited.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that changes in the branch protection rules are audited.", + "RationaleStatement": "Branch protection rules should be configured on every repository. The only users who may change such rules are administrators. In a case of an attack on an administrator account or of human error on the part of an administrator, protection rules could be disabled, and thus decrease source code confidentiality as a result. It is important to track and audit such changes to prevent potential incidents as soon as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Use the audit log to audit changes in branch protection rules by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the action qualifier in your query and look for **protected_branch** category. Ensure every action is reasonable and secure and investigate if not.", + "AuditProcedure": "Ensure changes in branch protection rules are audited by performing the following regularly:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the action qualifier in your query and look for **protected_branch** category. Ensure every action is reasonable and secure and is investigated if not.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.1.20", + "Description": "Enforce branch protection on the default and main branch.", + "Checks": [ + "repository_default_branch_protection_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.1 Code Changes", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce branch protection on the default and main branch.", + "RationaleStatement": "The default or main branch of repositories is considered very important, as it is eventually gets deployed to the production. Therefore it needs protection. By enforcing branch protection rules on this branch, it is secured from unwanted or unauthorized changes. It can also be protected from untested and unreviewed changes and more.", + "ImpactStatement": "", + "RemediationProcedure": "Perform the following to enforce branch protection on the main branch:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Next to \"Branch protection rules\", click **Add rule**.\n5. Under \"Branch name pattern\", type the branch name or pattern you want to protect. Ensure it applies to the main branch name.\n6. Configure policies you want.\n7. Click Create.", + "AuditProcedure": "Perform the following to ensure branch protection is enforced on the main branch:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Code and automation\" section of the sidebar, click **Branches**.\n4. Under **Branch protection rules**, verify that there is a rule applied to the \"main\" or default branch.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.1", + "Description": "A SECURITY.md file is a security policy file that offers instruction on reporting security vulnerabilities in a project. When someone creates an issue within a specific project, a link to the SECURITY.md file will subsequently be shown.", + "Checks": [ + "repository_public_has_securitymd_file" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A SECURITY.md file is a security policy file that offers instruction on reporting security vulnerabilities in a project. When someone creates an issue within a specific project, a link to the SECURITY.md file will subsequently be shown.", + "RationaleStatement": "A SECURITY.md file provides users with crucial security information. It can also serve an important role in project maintenance, encouraging users to think ahead about how to properly handle potential security issues, updates, and general security practices.", + "ImpactStatement": "", + "RemediationProcedure": "Enforce that each public repository has a SECURITY.md file by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. In the left sidebar, click **Security policy**.\n4. Click **Start setup**.\n5. In the new SECURITY.md file, add information about supported versions of your project and how to report a vulnerability.\n6. At the bottom of the page, type a commit message.\n7. Below the commit message fields, choose to create a new branch for your commit and then create a pull request.\n8. Click **Propose file change**.", + "AuditProcedure": "Verify that each public repository has a SECURITY.md file by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. Verify that **Security policy** is enabled.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.2", + "Description": "Limit the ability to create repositories to trusted users and teams.", + "Checks": [ + "organization_repository_creation_limited" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Limit the ability to create repositories to trusted users and teams.", + "RationaleStatement": "Restricting repository creation to trusted users and teams is recommended in order to keep the organization properly structured, track fewer items, prevent impersonation, and to not overload the version-control system. It will allow administrators easier source code tracking and management capabilities, as they will have fewer repositories to track. The process of detecting potential attacks also becomes far more straightforward, as well, since the easier it is to track the source code, the easier it is to detect malicious acts within it. Additionally, the possibility of a member creating a public repository and sharing the organization's data externally is significantly decreased.", + "ImpactStatement": "Specific users will not be permitted to create repositories.", + "RemediationProcedure": "Restrict repository creation to trusted users and teams only by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Repository creation\", unselect both options - **Public** and **Private**.\n5. Click **Save**.", + "AuditProcedure": "Verify that only trusted users and teams can create repositories by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Repository creation\", ensure that **Public** and **Private** are not checked. This means only owners are able to create repositories.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.3", + "Description": "Ensure only a limited number of trusted users can delete repositories.", + "Checks": [ + "organization_repository_deletion_limited" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure only a limited number of trusted users can delete repositories.", + "RationaleStatement": "Restricting the ability to delete repositories protects the organization from intentional and unintentional data loss. This ensures that users cannot delete repositories or cause other potential damage — whether by accident or due to their account being hacked — unless they have the correct privileges.", + "ImpactStatement": "Certain users will not be permitted to delete repositories.", + "RemediationProcedure": "Enforce repository deletion by a few trusted and responsible users only by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete or transfer repositories for this organization is selected, allow only trusted members to have admin privileges:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Change their role by clicking the **Role** dropdown next to the username until there are only two trusted and qualified users with admin privileges. \n\nIf it is not selected, allow only trusted users to become an organization owner:\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click Role and select Owners. Check every member you want to change permissions to. Above the list of members, use the drop-down menu and click Change role and choose Member > change role. Do that until there are only a few trusted and qualified users with organization owner privileges. \n\nIn any case, only members with administrator or organization owner privileges can delete repositories, regardless of your setting.", + "AuditProcedure": "Verify that only a limited number of trusted users can delete repositories by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete or transfer repositories for this organization is selected, verify that every admin member is trusted by you:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Verify that there are only two of them and that they are trusted and qualified. \n\nIf it is not selected, verify that every organization owner is trusted by you:\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click Role and select Owners. Verify that there are only a few of them and that they are trusted and qualified. \n\nIn any case, only members with administrator or organization owner privileges can delete repositories, regardless of your setting.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Only organization owners or members with admin privileges can delete repositories." + } + ] + }, + { + "Id": "1.2.4", + "Description": "Ensure only trusted and responsible users can delete issues.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure only trusted and responsible users can delete issues.", + "RationaleStatement": "Issues are a way to keep track of things happening in repositories, such as setting new milestones or requesting urgent fixes. Deleting an issue is not a benign activity, as it might harm the development workflow or attempt to hide malicious behavior. Because of this, it should be restricted and allowed only by trusted and responsible users.", + "ImpactStatement": "Certain users will not be permitted to delete issues.", + "RemediationProcedure": "Restrict issue deletion to a few trusted and responsible users only by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete issues for this organization is selected, allow only trusted members to have admin privileges:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Change their role by clicking the **Role** dropdown next to the username until there are only two trusted and qualified users with admin privileges.\n\nIf it is not selected, allow only trusted users to become an organization owner:\n1. In the top right corner of GitHub.com, click your profile photo, then click Your organizations.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click **Role** and select **Owners**. Check every member you want to change permissions to. Above the list of members, use the drop-down menu and click **Change role** and choose Member > change role. Do that until there are only a few trusted and qualified users with organization owner privileges.\n\nIn any case, only members with administrator or organization owner privileges can delete issues, regardless of your setting.", + "AuditProcedure": "Verify that only a limited number of trusted users can delete issues by performing either of the following steps:\n\nIf Your organizations > Settings > Access > Member privileges > Allow members to delete issues for this organization is selected, verify that every admin member is trusted by you:\n1. In every repository, on GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings** and then in the \"Access\" section of the sidebar, click **Collaborators & teams**.\n3. Under \"Manage access\" use the dropdown **Role** menu to filter and search for admin members. Verify that there are only two of them and that they are trusted and qualified.\n\nIf it is not selected, verify that every organization owner is trusted by you:\n1. In the top right corner of GitHub.com, click your profile photo, then click Your organizations.\n2. Click the name of your organization and under your organization name, click **People**.\n3. You will see a list of the people in your organization. Click **Role** and select **Owners**. Verify that there are only a few of them and that they are trusted and qualified.\n\nIn any case, only members with administrator or organization owner privileges can delete issues, regardless of your setting.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Only organization owners or members with admin privileges can delete issues." + } + ] + }, + { + "Id": "1.2.5", + "Description": "Track every fork of code and ensure it is accounted for.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track every fork of code and ensure it is accounted for.", + "RationaleStatement": "A fork is a copy of a repository. On top of being a plain copy, any updates to the original repository itself can be pulled and reflected by the fork under certain conditions. A large number of repository copies (forks) become difficult to manage and properly secure. New and sensitive changes can often be pushed into a critical repository without developer knowledge of an updated copy of the very same repository. If there is no limit on doing this, then it is recommended to track and delete copies of organization repositories as needed.", + "ImpactStatement": "Disabling forks completely may slow down the development process as more actions will be necessary to take in order to fork a repository.", + "RemediationProcedure": "Track forks and examine them by performing the following on a regular basis:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Insights**.\n3. In the left sidebar, click **Forks**.\n4. Examine the forks listed there.", + "AuditProcedure": "Verify that the following steps are done regularly to track and examine forks:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Insights**.\n3. In the left sidebar, click **Forks**.\n4. Examine the forks listed there.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.6", + "Description": "Ensure every change in visibility of projects is tracked.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure every change in visibility of projects is tracked.", + "RationaleStatement": "Visibility of projects determines who can access a project and/or fork it: anyone, designated users, or only members of the organization. If a private project becomes public, this may point to a potential attack, which can ultimately lead to data loss, the leaking of sensitive information, and finally to a supply chain attack. It is crucial to track these changes in order to prevent such incidents.", + "ImpactStatement": "", + "RemediationProcedure": "Track every change in project visibility and investigate if suspicious behavior occurs, by performing the following regularly:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the **repo** qualifier in your query and look for **access** category. Ensure every change is reasonable and secure and investigate if it is not.", + "AuditProcedure": "Ensure that every change in project visibility is tracked and investigated, by performing the following regularly:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Archives\" section of the sidebar, click **Logs**, then click **Audit log**.\n4. Use the **repo** qualifier in your query and look for **access** category. Ensure every change is reasonable and secure and is investigated if it is not.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.2.7", + "Description": "Track inactive repositories and remove them periodically.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.2 Repository Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track inactive repositories and remove them periodically.", + "RationaleStatement": "Inactive repositories (i.e., no new changes introduced for a long period of time) can enlarge the surface of a potential attack or data leak. These repositories are more likely to be improperly managed, and thus could possibly be accessed by a large number of users in an organization.", + "ImpactStatement": "Bug fixes and deployment of necessary changes could prove complicated for archived repositories.", + "RemediationProcedure": "Perform the following to review all inactive repositories and archive them periodically:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click on your organization name and then on **repositories**.\n3. Ensure every repository listed has been active in the last 3 to 6 months. Every repository that isn't active you should either review or archive by performing the next steps:\n 1. On GitHub.com, navigate to the main page of the repository.\n 2. Under your repository name, click **Settings**.\n 3. Under \"Danger Zone\", click **Archive this repository**.\n 4. Read the warnings.\n 5. Type the name of the repository you want to archive.\n 6. Click **I understand the consequences, archive this repository**.", + "AuditProcedure": "Perform the following to ensure that all the repositories in the organization are active, and those that are not reviewed or archived:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click on your organization name and then on **repositories**.\n3. Ensure every repository listed has been active in the last 3 to 6 months. If it's not, then ensure it is archived or reviewed regularly.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.1", + "Description": "Track inactive user accounts and periodically remove them.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Track inactive user accounts and periodically remove them.", + "RationaleStatement": "User accounts that have been inactive for a long period of time are enlarging the surface of attack. Inactive users with high-level privileges are of particular concern, as these accounts are more likely to be targets for attackers. This could potentially allow access to large portions of an organization should such an attack prove successful. It is recommended to remove them as soon as possible in order to prevent this.", + "ImpactStatement": "", + "RemediationProcedure": "If you have GitHub Enterprise Cloud, perform the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view.\n3. In the enterprise account sidebar, click **Compliance**.\n4. To download your Dormant Users (beta) report as a CSV file, under \"Other\", click **Download**.\n5. Find the users listed in the file under **Your organizations** > your organization > **People** and select them.\n6. Click **Remove from organization** and **Remove members**.", + "AuditProcedure": "If you have GitHub Enterprise Cloud, perform the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view.\n3. In the enterprise account sidebar, click **Compliance**.\n4. To download your Dormant Users (beta) report as a CSV file, under \"Other\", click **Download**.\n5. Verify that there are no users listed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.2", + "Description": "Limit ability to create teams to trusted and specific users.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Limit ability to create teams to trusted and specific users.", + "RationaleStatement": "The ability to create new teams should be restricted to specific members in order to keep the organization orderly and ensure users have access to only the lowest privilege level necessary. Teams typically inherit permissions from their parent team, thus if base permissions are less restricted and any user has the ability to create a team, a permission leverage could occur in which certain data is made available to users who should not have access to it. Such a situation could potentially lead to the creation of shadow teams by an attacker. Restricting team creation will also reduce additional clutter in the organizational structure, and as a result will make it easier to track changes and anomalies.", + "ImpactStatement": "Only specific users will be able to create new teams.", + "RemediationProcedure": "For every organization, limit team creation to specific, trusted users by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Team creation rules\", deselect **Allow members to create teams**.\n5. Click **Save**.", + "AuditProcedure": "For every organization, ensure that team creation is limited to specific, trusted users by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Team creation rules\", verify that **Allow members to create teams** is not selected.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.3", + "Description": "Ensure the organization has a minimum number of administrators.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the organization has a minimum number of administrators.", + "RationaleStatement": "Organization administrators have the highest level of permissions, including the ability to add/remove collaborators, create or delete repositories, change branch protection policy, and convert to a publicly-accessible repository. Due to the permissive access granted to an organization administrator, it is highly recommended to keep the number of administrator accounts as minimal as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Set the minimum number of administrators in your organization by performing the following:\n\n1. In the top right corner of GitHub, click your profile photo, then click **Your profile**.\n2. On the left side of your profile page, under \"Organizations\", click the icon for your organization.\n3. Under your organization name, click **People**. \n4. In the Role drop-down, choose **Owners**.\n5. Select the person or people you'd like to remove from owner role.\n6. Above the list of members, use the drop-down menu and click Change role.\n7. Select **Member**, then click **Change role**.", + "AuditProcedure": "Verify the minimum number of administrators in your organization by performing the following:\n\n1. In the top right corner of GitHub, click your profile photo, then click **Your profile**.\n2. On the left side of your profile page, under \"Organizations\", click the icon for your organization.\n3. Under your organization name, click **People**. \n4. In the Role drop-down, choose **Owners**.\n5. If there are minimum number of members in the list, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.4", + "Description": "Require collaborators from outside the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "Checks": [ + "organization_members_mfa_required" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Require collaborators from outside the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "RationaleStatement": "By default every user authenticates within the system by password only. If the password of a user is compromised, however, the user account and every repository to which they have access are in danger of data loss, malicious code commits, and data theft. It is therefore recommended that each user has Multi-Factor Authentication enabled. This adds an additional layer of protection to ensure the account remains secure even if the user's password is compromised.", + "ImpactStatement": "A member without enabled Multi-Factor Authentication cannot contribute to the project. They must enable Multi-Factor Authentication a before they can contribute any code.", + "RemediationProcedure": "For each repository in use, enforce Multi-Factor Authentication is the only way to authenticate for contributors, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", select **Require two-factor authentication for everyone in your organization**, then click **Save**.", + "AuditProcedure": "For each repository in use, verify that Multi-Factor Authentication is enforced for contributors and is the only way to authenticate, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", check if **Require two-factor authentication for everyone in your organization** is checked. If so, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.5", + "Description": "Require members of the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "Checks": [ + "organization_members_mfa_required" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Require members of the organization to use Multi-Factor Authentication (MFA) in addition to a standard user name and password when authenticating to the source code management platform.", + "RationaleStatement": "By default every user authenticates within the system by password only. If the password of a user is compromised, however, the user account and every repository to which they have access are in danger of data loss, malicious code commits, and data theft. It is therefore recommended that each user has Multi-Factor Authentication enabled. This adds an additional layer of protection to ensure the account remains secure even if the user's password is compromised.", + "ImpactStatement": "Members will be removed from the organization if they don't have Multi-Factor Authentication already enabled. If this is the case, it is recommended that an invitation be sent to reinstate the user's access and former privileges. They must enable Multi-Factor Authentication to accept the invitation.", + "RemediationProcedure": "For every organization that exists in your GitHub platform, enforce Multi-Factor Authentication and define it as the only way to authenticate, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", select **Require two-factor authentication for everyone in your organization**, then click **Save**.\n5. If prompted, read the information about members and outside collaborators who will be removed from the organization. Type your organization's name to confirm the change, then click **Remove members & require two-factor authentication**.", + "AuditProcedure": "For every organization that exists in your GitHub platform, verify that Multi-Factor Authentication is enforced and is the only way to authenticate, by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Under \"Authentication\", check if **Require two-factor authentication for everyone in your organization** is checked. If so, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.6", + "Description": "Existing members of an organization can invite new members to join, however new members must only be invited with their company-approved email.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Existing members of an organization can invite new members to join, however new members must only be invited with their company-approved email.", + "RationaleStatement": "Ensuring new members of an organization have company-approved email prevents existing members of the organization from inviting arbitrary new users to join. Without this verification, they can invite anyone who is using the organization's version control system or has an active email account, thus allowing outside users (and potential threat actors) to easily gain access to company private code and resources. This practice will subsequently reduce the chance of human error or typos when inviting a new member.", + "ImpactStatement": "Existing members would not be able to invite new users who do not have a company-approved email address.", + "RemediationProcedure": "For each organization, allow only users with company-approved email to be invited. If a user was invited without company-approved email, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. On the People tab, click **Invitations**. Next to the username or email address of the person whose invitation you'd like to cancel, click **Edit invitation**.\n4. To cancel the user's invitation to join your organization, click **Cancel invitation**.", + "AuditProcedure": "For each organization in use, verify for every invitation that the invited email address is company-approved by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Click the name of your organization and under your organization name, click **People**.\n3. On the People tab, click **Invitations**. Verify that each invitation email is company approved by your company.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.7", + "Description": "Ensure every repository has two users with administrative permissions.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure every repository has two users with administrative permissions.", + "RationaleStatement": "Repository administrators have the highest permissions to said repository. These include the ability to add/remove collaborators, change branch protection policy, and convert to a publicly-accessible repository. Due to the liberal access granted to a repository administrator, it is highly recommended that only two contributors occupy this role.", + "ImpactStatement": "Removing administrative users from a repository would result in them losing high-level access to that repository.", + "RemediationProcedure": "For every repository in use, set two administrators by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Collaborators & teams**.\n4. Under **Manage access**, find the team or person whose you'd like to revoke admin permissions, then select the Role drop-down and click a new role.", + "AuditProcedure": "For every repository in use, verify there are two administrators by performing the following:\n\n1. On GitHub, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Collaborators & teams**.\n4. Under **Manage access**, verify that there are only 2 members with Admin permission.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.8", + "Description": "Base permissions define the permission level automatically granted to all organization members. Define strict base access permissions for all of the repositories in the organization, including new ones.", + "Checks": [ + "organization_default_repository_permission_strict" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Base permissions define the permission level automatically granted to all organization members. Define strict base access permissions for all of the repositories in the organization, including new ones.", + "RationaleStatement": "Defining strict base permissions is the best practice in every role-based access control (RBAC) system. If the base permission is high — for example, \"write\" permission — every member of the organization will have \"write\" permission to every repository in the organization. This will apply regardless of the specific permissions a user might need, which generally differ between organization repositories. The higher the permission, the higher the risk for incidents such as bad code commit or data breach. It is therefore recommended to set the base permissions to the strictest level possible.", + "ImpactStatement": "Users might not be able to access organization repositories or perform some acts as commits. These specific permissions should be granted individually for each user or team, as needed.", + "RemediationProcedure": "Set strict base permissions for the organization repositories with the next steps:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Base permissions\", use the drop-down to select new base permissions - \"Read\" or \"None\".\n5. Review the changes. To confirm, click **Change default permission to PERMISSION**.", + "AuditProcedure": "Verify that strict base permissions are set for the organization repositories by doing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Access\" section of the sidebar, click **Member privileges**.\n4. Under \"Base permissions\", verify that it is set to \"Read\" or \"None\". If it does, you are compliant.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Read permission" + } + ] + }, + { + "Id": "1.3.9", + "Description": "Confirm the domains an organization owns with a \"Verified\" badge.", + "Checks": [ + "organization_verified_badge" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Confirm the domains an organization owns with a \"Verified\" badge.", + "RationaleStatement": "Verifying the organization's domain gives developers assurance that a given domain is truly the official home for a public organization. Attackers can pretend to be an organization and steal information via a faked/spoof domain, therefore the use of a \"Verified\" badge instills more confidence and trust between developers and the open-source community.", + "ImpactStatement": "", + "RemediationProcedure": "Only if you have an enterprise account, verify the organization's domains and secure a \"Verified\" badge next to its name by performing the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view. Then in the enterprise account sidebar, click **Settings**.\n3. Under \"Settings\", click **Verified & approved domains**.\n4. Click **Add a domain**.\n5. In the domain field, type the domain you'd like to verify, then click **Add domain**.\n6. Follow the instructions under **Add a DNS TXT record** to create a DNS TXT record with your domain hosting service. Wait for your DNS configuration to change, which may take up to 72 hours. You can confirm your DNS configuration has changed by running the `dig` command on the command line, replacing ENTERPRISE-ACCOUNT with the name of your enterprise account, and example.com with the domain you'd like to verify. You should see your new TXT record listed in the command output.\n```\ndig _github-challenge-ENTERPRISE-ACCOUNT.DOMAIN-NAME +nostats +nocomments +nocmd TXT\n```\n7. After confirming your TXT record is added to your DNS, follow steps one through three above to navigate to your enterprise account's approved and verified domains.\n8. To the right of the domain that's pending verification, click the 3-dots, then click **Continue verifying**. Click **Verify**.\n9. Optionally, after the \"Verified\" badge is visible on your organizations' profiles, delete the TXT entry from the DNS record at your domain hosting service.", + "AuditProcedure": "Only if you have an enterprise account, view the enterprise organization profile page and ensure it has a \"Verified\" badge in it.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/organizations/managing-organization-settings/verifying-or-approving-a-domain-for-your-organization", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.10", + "Description": "Restrict the Source Code Management (SCM) organization's email notifications to approved domains only.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Restrict the Source Code Management (SCM) organization's email notifications to approved domains only.", + "RationaleStatement": "Restricting Source Code Management email notifications to verified domains only prevents data leaks, as personal emails and custom domains are more prone to account takeover via DNS hijacking or password breach.", + "ImpactStatement": "Only members with approved email would be able to receive Source Code Management notifications.", + "RemediationProcedure": "Only if you have an enterprise account, restrict Source Code Management email notifications to approved domains only by performing the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view. Then in the enterprise account sidebar, click **Settings**.\n3. Under \"Settings\", click **Verified & approved domains**.\n4. Under \"Notification preferences\", select **Restrict email notifications to only approved or verified domains**.\n5. Click **Save**.", + "AuditProcedure": "Only if you have an enterprise account, ensure Source Code Management email notifications are restricted to approved domains only by performing the following:\n\n1. In the top-right corner of GitHub.com, click your profile photo, then click **Your enterprises**.\n2. In the list of enterprises, click the enterprise you want to view. Then in the enterprise account sidebar, click **Settings**.\n3. Under \"Settings\", click **Verified & approved domains**.\n4. Under \"Notification preferences\", verify that **Restrict email notifications to only approved or verified domains** is selected.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.11", + "Description": "As an organization, become an SSH Certificate Authority and provide SSH keys for accessing repositories.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "As an organization, become an SSH Certificate Authority and provide SSH keys for accessing repositories.", + "RationaleStatement": "There are two ways for remotely working with Source Code Management: via HTTPS, which requires authentication by user/password, or via SSH, which requires the use of SSH keys. SSH authentication is better in terms of security; key creation and distribution, however, must be done in a secure manner. This can be accomplished by implementing SSH certificates, which are used to validate the server's identity. A developer will not be able to connect to a Git server if its key cannot be verified by the SSH Certificate Authority (CA) server.\nAs an organization, one can verify the SSH certificate signature used to authenticate if a CA is defined and used. This ensures that only verified developers can access organization repositories, as their SSH key will be the only one signed by the CA certificate. This reduces the risk of misuse and malicious code commits.", + "ImpactStatement": "Members with unverified keys will not be able to clone organization repositories. Signing, certification, and verification might also slow down the development process.", + "RemediationProcedure": "Only if you have an enterprise account, deploy an SSH Certificate Authority server and configure it to provide an SSH certificate with which to sign keys by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. To the right of \"SSH Certificate Authorities\", click **New CA**.\n5. Under \"Key,\" paste your public SSH key.\n6. Click **Add CA**.\n7. Click **Save**.", + "AuditProcedure": "Only if you have an enterprise account, verify that the enterprise organization has an SSH Certificate Authority server and provides an SSH certificate with which to sign keys by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Verify that there's an SSH certificate authority listed there.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.12", + "Description": "Limit Git access based on IP addresses by having a allowlist of IP addresses from which connection is possible.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Limit Git access based on IP addresses by having a allowlist of IP addresses from which connection is possible.", + "RationaleStatement": "Allowing access to Git repositories (source code) only from specific IP addresses adds yet another layer of restriction and reduces the risk of unauthorized connection to the organization's assets. This will prevent attackers from accessing Source Code Management (SCM), as they would first need to know the allowed IP addresses to gain access to them.", + "ImpactStatement": "Only members with allowlisted IP addresses will be able to access the organization's Git repositories.", + "RemediationProcedure": "Only if you have an enterprise account, create an IP allowlist and forbid all other IPs from accessing the source code by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. At the bottom of the \"IP allow list\" section, enter an IP address, or a range of addresses in CIDR notation. Optionally, enter a description of the allowed IP address or range.\n5. Click **Add**.\n6. After that, under \"IP allow list\", select **Enable IP allow list**.\n7. Click **Save**.", + "AuditProcedure": "Only if you have an enterprise account, in every organization of yours, ensure access is allowed only by IP allowlist, and that access is forbidden for all other IPs by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Authentication security**.\n4. Verify that there's an IP address, or a range of addresses in CIDR notation listed. Also verify that **Enable IP allow list** is selected.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-allowed-ip-addresses-for-your-organization", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.3.13", + "Description": "Track code anomalies.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.3 Contribution Access", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track code anomalies.", + "RationaleStatement": "Carefully analyze any code anomalies within the organization. For example, a code anomaly could be a push made outside of working hours. Such a code push has a higher likelihood of being the result of an attack, as most if not all members of the organization would likely be outside the office. Another example is an activity that exceeds the average activity of a particular user.\nTracking and auditing such behaviors creates additional layers of security and can aid in early detection of potential attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository in use, track and investigate anomalous code behavior and activity.", + "AuditProcedure": "For every repository in use, ensure code anomalies relevant to the organization are promptly investigated.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.4.1", + "Description": "Ensure an administrator approval is required when installing applications.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure an administrator approval is required when installing applications.", + "RationaleStatement": "Applications are typically automated integrations that improve the workflow of an organization. They are written by third-party developers, and therefore should be validated before using in case they're malicious or not trustable. Because administrators are expected to be the most qualified and trusted members of the organization, they should review the applications being installed and decide whether they are both trusted and necessary.", + "ImpactStatement": "Applications will not be installed without administrator approval.", + "RemediationProcedure": "Require an administrator approval for every installed application:\n\na. For GitHub Apps, you are compliant by default. That is because by default only organization owners and administrators can install them.\n\nb. For OAuth Apps, perform the following:\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Third-party Access\" section of the sidebar, click **OAuth application policy**.\n4. Under \"Third-party application access policy\", click **Setup application access restrictions**.\n5. After you review the information about third-party access restrictions, click **Restrict third-party application access**.", + "AuditProcedure": "Verify that applications are installed only after receiving administrator approval:\n\na. For GitHub Apps, you are compliant by default. That is because by default only organization owners and administrators can install them.\n\nb. For OAuth Apps, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Third-party Access\" section of the sidebar, click **OAuth application policy**.\n4. Under \"Third-party application access policy\" verify that the policy status is **Access restricted**.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Maintainers are organization owners." + } + ] + }, + { + "Id": "1.4.2", + "Description": "Ensure stale (inactive) applications are reviewed and removed if no longer in use.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure stale (inactive) applications are reviewed and removed if no longer in use.", + "RationaleStatement": "Applications that have been inactive for a long period of time are enlarging the surface of attack for data leaks. They are more likely to be improperly managed, and could possibly be accessed by third-party developers as a tool for collecting internal data of the organization or repository in which they are installed. It is important to remove these inactive applications as soon as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Review all stale applications and periodically remove them.", + "AuditProcedure": "Verify that all the applications in the organization are actively used, and remove those that are no longer in use.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.4.3", + "Description": "Ensure installed application permissions are limited to the lowest privilege level required.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure installed application permissions are limited to the lowest privilege level required.", + "RationaleStatement": "Applications are typically automated integrations that can improve the workflow of an organization. They are written by third-party developers, and therefore should be reviewed carefully before use. It is recommended to use the \"least privilege\" principle, granting applications the lowest level of permissions required. This may prevent harm from a potentially malicious application with unnecessarily high-level permissions leaking data or modifying source code.", + "ImpactStatement": "", + "RemediationProcedure": "Grant permissions to applications by the \"least privilege\" principle, meaning the lowest possible permission necessary:\n\na. For GitHub Apps, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Integrations\" section of the sidebar, click **GitHub Apps**.\n4. Next to every GitHub App, click **Configure**.\n5. Review the GitHub App's permissions and repository access. Edit the permissions granted to the least possible. For example, restrict the number of repositories the App can access.\n6. Click **Save**.", + "AuditProcedure": "Verify that each installed application has the least privilege needed:\n\na. For GitHub Apps, perform the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the \"Integrations\" section of the sidebar, click **GitHub Apps**.\n4. Next to every GitHub App, click **Configure**.\n5. Review the GitHub App's permissions and repository access. Verify that the App permissions are the least possible and that it can access only necessary repositories.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.4.4", + "Description": "Use only secured webhooks in the source code management platform.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.4 Third-Party", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use only secured webhooks in the source code management platform.", + "RationaleStatement": "A webhook is an event listener, attached to critical and sensitive parts of the software delivery process. It is triggered by a list of events (such as a new code being committed), and when triggered, the webhook sends out a notification with some payload to specific internet endpoints. Since the payload of the webhook contains sensitive organization data, it's important all webhooks are directed to an endpoint (URL) protected by SSL verification (HTTPS). This helps ensure that the data sent is delivered to securely without any man-in-the-middle, who could easily access and even alter the payload of the request.", + "ImpactStatement": "Perform the following to ensure all webhooks used are secured (HTTPS):\n\n1. Navigate to your organization or repository and select **Settings**.\n2. Select **Webhooks** on the side menu.\n3. Verify that each webhook URL starts with 'https'.", + "RemediationProcedure": "Perform the following to secure all webhooks used(over HTTPS):\n\n1. Navigate to your organization or repository and select **Settings**.\n2. Select **Webhooks** on the side menu.\n3. Find the webhooks that starts with 'http' and not 'https'.\n4. Ensure the endpoint (URL) of the webhook listens to secured port (443) and uses certificate.\n5. Click **Edit**.\n6. Change the payload URL to https and ensure **Enable SSL verification** is checked.\n7. Click **Update webhook**.", + "AuditProcedure": "Perform the following to secure all webhooks used(over HTTPS):\n\n1. Navigate to your organization or repository and select **Settings**.\n2. Select **Webhooks** on the side menu.\n3. Ensure all webhooks starts with 'https'.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.1", + "Description": "Detect and prevent sensitive data in code, such as confidential ID numbers, passwords, etc.", + "Checks": [ + "repository_secret_scanning_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent sensitive data in code, such as confidential ID numbers, passwords, etc.", + "RationaleStatement": "Having sensitive data in the source code makes it easier for attackers to maliciously use such information. In order to avoid this, designate scanners to identify and prevent the existence of sensitive data in the code.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository in use, designate scanners to identify and prevent sensitive data in code by performing the following (enterprise only):\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n4. Scroll down to the bottom of the page and click **Enable** for secret scanning.", + "AuditProcedure": "For every repository in use, verify that scanners are set to identify and prevent the existence of sensitive data in code by performing the following (enterprise only):\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n4. Scroll down to the bottom of the page. If you see a **Disable** button, it means that secret scanning is already enabled for the repository.", + "AdditionalInformation": "By January 2023, this feature is supposed to be open to all plans. Until then it is only for enterprise users.", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.2", + "Description": "Detect and prevent misconfigurations and insecure instructions in CI pipelines", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent misconfigurations and insecure instructions in CI pipelines", + "RationaleStatement": "Detecting and fixing misconfigurations or insecure instructions in CI pipelines decreases the risk for a successful attack through or on the CI pipeline. The more secure the pipeline, the less risk there is for potential exposure of sensitive data, a deployment being compromised, or external access mistakenly being granted to the CI infrastructure or the source code.", + "ImpactStatement": "", + "RemediationProcedure": "Set a CI instructions scanning tool to identify and prevent misconfigurations and insecure instructions and scans all CI pipelines.", + "AuditProcedure": "Verify that a CI instructions scanning tool is set to identify and prevent misconfigurations and insecure instructions and that it scans all CI pipelines.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.3", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "RationaleStatement": "Detecting and fixing misconfigurations and/or insecure instructions in IaC (Infrastructure as Code) files decreases the risk for data leak or data theft. It is important to secure IaC instructions in order to prevent further problems of deployment, exposed assets, or improper configurations, which can ultimately lead to easier ways to attack and steal organization data.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository that holds IaC instructions files, set a scanning tool to identify and prevent misconfigurations and insecure instructions.", + "AuditProcedure": "For every repository that holds IaC instructions files, verify that a scanning tool is set to identify and prevent misconfigurations and insecure instructions.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.4", + "Description": "Detect and prevent known open source vulnerabilities in the code.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent known open source vulnerabilities in the code.", + "RationaleStatement": "Open source code blocks are used a lot in developed software. This has its own advantages, but it also has risks. Because the code is open for everyone, it means that attackers can publish or add malicious code to these open-source code blocks, or use their knowledge to find vulnerabilities in an existing code. Detecting and fixing such code vulnerabilities, by SCA (Software Composition Analysis) prevents insecure flaws from reaching production. It gives another opportunity for developers to secure the source code before it is deployed in production, where it is far more exposed and vulnerable to attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For every repository that is in use, set a scanning tool to identify and prevent code vulnerabilities by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. To the right of \"Code scanning alerts\", click **Set up code scanning**.\n4. Under \"Get started with code scanning\", click Set up this workflow on a workflow of your choice.\n5. To customize how code scanning scans your code, edit the workflow.\n6. Use the **Start commit** drop-down and type a commit message.\n7. Choose whether you'd like to commit directly to the default branch or create a new branch and start a pull request.\n8. Click **Commit new file** or **Propose new file**.", + "AuditProcedure": "For every repository that is in use, verify that a scanning tool is set to identify and prevent code vulnerabilities by performing the following:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under the repository name, click **Security**.\n3. Verify that \"Code scanning alerts\" is **Enabled** or that there is a workflow that scans your code.", + "AdditionalInformation": "All public repositories in all plans can use code scanning in GitHub. In private repositories, only GitHub Enterprise can use code scanning.", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.5", + "Description": "Detect, prevent and monitor known open-source vulnerabilities in packages that are being used.", + "Checks": [ + "repository_dependency_scanning_enabled" + ], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect, prevent and monitor known open-source vulnerabilities in packages that are being used.", + "RationaleStatement": "Open-source vulnerabilities might exist before one starts to use a package, but they are also discovered over time. New attacks and vulnerabilities are announced every now and then. It is important to keep track of these and to monitor whether the dependencies used are affected by the recent vulnerability. Detecting and fixing those packages' vulnerabilities decreases the attack surface within deployed and running applications that use such packages. It prevents security flaws from reaching the production environment which could eventually lead to a security breach.", + "ImpactStatement": "", + "RemediationProcedure": "Set scanners that will monitor, identify, and prevent open-source vulnerabilities in packages by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n3. Under \"Code security and analysis\", to the right of Dependabot alerts, click **Disable all** or **Enable all**.\n4. Check the **Enable by default for new repositories** option and then click **Enable Dependabot alerts**.\n\nIt is also recommended to use another additional solution for package scanning because GitHub doesn't always recognize the vulnerabilities.", + "AuditProcedure": "Verify that scanners are set to monitor, identify, and prevent open-source vulnerabilities in packages by performing the following:\n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**. In the \"Security\" section of the sidebar, click **Code security and analysis**.\n3. Verify that the Dependabot alerts is enabled and that **Enable by default for new repositories** is checked.\n\nIt is also recommended to verify that you're using another additional solution for package scanning because GitHub doesn't always recognize the vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.5.6", + "Description": "Detect open-source license problems in used dependencies and fix them.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Source Code", + "Subsection": "1.5 Code Risks", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect open-source license problems in used dependencies and fix them.", + "RationaleStatement": "A software license is a legal document that establishes several key conditions between a software company or developer and a user in order to allow the use of software. Software licenses have the potential to create code dependencies. Not following the conditions in the software license can also lead to lawsuits. When using packages with a software license, especially commercial ones (which are the most permissive), it is important to verify what is allowed by that license in order to be protected against lawsuits.", + "ImpactStatement": "", + "RemediationProcedure": "Designate a license scanning tool to identify open-source license problems and fix them and scan every package you use.", + "AuditProcedure": "Ensure a license scanning tool is set up to identify open-source license problems and that every package you use is scanned by it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.1", + "Description": "Ensure each pipeline has a single responsibility in the build process.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure each pipeline has a single responsibility in the build process.", + "RationaleStatement": "Build pipelines generally have access to multiple secrets depending on their purposes. There are, for example, secrets of the test environment for the test phase, repository and artifact credentials for the build phase, etc. Limiting access to these credentials/secrets is therefore recommended by dividing pipeline responsibilities, as well as having a dedicated pipeline for each phase with the lowest privilege instead of a single pipeline for all. This will ensure that any potential damage caused by attacks on a workflow will be limited.", + "ImpactStatement": "", + "RemediationProcedure": "Divide each multi-responsibility pipeline into multiple pipelines, each having a single responsibility with the least privilege. Additionally, create all new pipelines with a sole purpose going forward.", + "AuditProcedure": "For each pipeline, ensure it has only one responsibility in the build process.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.2", + "Description": "Ensure the pipeline orchestrator and its configuration are immutable.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the pipeline orchestrator and its configuration are immutable.", + "RationaleStatement": "An immutable infrastructure is one that cannot be changed during execution of the pipeline. This can be done, for example, by using Infrastructure as Code for configuring the pipeline and the pipeline environment. Utilizing such infrastructure creates a more predictable environment because updates will require re-deployment to prevent any previous configuration from interfering. Because it is dependent on automation, it is easier to revert changes. Testing code is also simpler because it is based on virtualization. Most importantly, an immutable pipeline infrastructure ensures that a potential attacker seeking to compromise the build environment itself would not be able to do so if the orchestrator, its configuration, and any other component cannot be changed. Verifying that all aspects of the pipeline infrastructure and configuration are immutable therefore keeps them safe from malicious tampering attempts.", + "ImpactStatement": "", + "RemediationProcedure": "Use an immutable pipeline orchestrator and ensure that its configuration and all other aspects of the built environment are immutable, as well.", + "AuditProcedure": "Verify that the pipeline orchestrator, its configuration, and all other aspects of the build environment are immutable.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.3", + "Description": "Keep build logs of the build environment detailing configuration and all activity within it. Also, consider to store them in a centralized organizational log store.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Keep build logs of the build environment detailing configuration and all activity within it. Also, consider to store them in a centralized organizational log store.", + "RationaleStatement": "Logging the environment is important for two primary reasons: one, for debugging and investigating the environment in case of a bug or security incident; and two, for reproducing the environment easily when needed. Storing these logs in a centralized organizational log store allows the organization to generate useful insights and identify anomalies in the build process faster.", + "ImpactStatement": "", + "RemediationProcedure": "Keep logs of the build environment. For example, use the .buildinfo file for Debian build workers. Also, store the logs in a centralized organizational log store.", + "AuditProcedure": "Verify that the build environment is logged and stored in a centralized organizational log store.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.4", + "Description": "Automate the creation of the build environment.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Automate the creation of the build environment.", + "RationaleStatement": "Automating the deployment of the build environment reduces the risk for human mistakes — such as a wrong configuration or exposure of sensitive data — because it requires less human interaction and intervention. It also eases re-deployment of the environment. It is best to automate with Infrastructure as Code because it offers more control over changes made to the environment creation configuration and stores to a version control platform.", + "ImpactStatement": "", + "RemediationProcedure": "Automate the deployment of the build environment.", + "AuditProcedure": "Verify that the deployment of the build environment is automated and can be easily redeployed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.5", + "Description": "Restrict access to the build environment (orchestrator, pipeline executor, their environment, etc.) to trusted and qualified users only.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to the build environment (orchestrator, pipeline executor, their environment, etc.) to trusted and qualified users only.", + "RationaleStatement": "A build environment contains sensitive data such as environment variables, secrets, and the source code itself. Any user that has access to this environment can make changes to the build process, including changes to the code within it. Restricting access to the build environment to trusted and qualified users only will reduce the risk for mistakes such as exposure of secrets or misconfiguration. Limiting access also reduces the number of accounts that are vulnerable to hijacking in order to potentially harm the build environment.", + "ImpactStatement": "Reducing the number of users who have access to the build process means those users would lose their ability to make direct changes to that process.", + "RemediationProcedure": "Restrict access to the build environment to trusted and qualified users.", + "AuditProcedure": "Verify each build environment is accessible only to known and authorized users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.6", + "Description": "Require users to login in to access the build environment - where the orchestrator, the pipeline executer, where the build workers are running, etc.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Require users to login in to access the build environment - where the orchestrator, the pipeline executer, where the build workers are running, etc.", + "RationaleStatement": "Requiring users to authenticate and disabling anonymous access to the build environment allows organization to track every action on that environment, good or bad, to its actor. This will help recognizing attack and its attacker because the authentication is required.", + "ImpactStatement": "Anonymous users won't be able to access the build environment.", + "RemediationProcedure": "Require authentication to access the build environment and disable anonymous access.", + "AuditProcedure": "Ensure authentication is required to access the build environment.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.7", + "Description": "Build tools providers offer a secure way to store secrets that should be used during the build process.\nThese secrets will often be credentials used to access other tools, for example for pulling code or for uploading artifacts.\nAccess to these secrets can be defined on various scopes, for example in github it could be on an organization level or a repository level and there is also control on whether these secrets are passed to forked pull request.\nTo protect these critical assets it is important to choose the most restrictive scope necessary.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Build tools providers offer a secure way to store secrets that should be used during the build process.\nThese secrets will often be credentials used to access other tools, for example for pulling code or for uploading artifacts.\nAccess to these secrets can be defined on various scopes, for example in github it could be on an organization level or a repository level and there is also control on whether these secrets are passed to forked pull request.\nTo protect these critical assets it is important to choose the most restrictive scope necessary.", + "RationaleStatement": "Allowing over permissive access to these secrets may affect on their exposure.\nFor example if a secret is defined in an organization level, and users can create new repositories, there is a scenario where a user can create a new repo and run a controlled build just to exfiltrate these secrets.", + "ImpactStatement": "Increased risk of exposure of build related secrets.", + "RemediationProcedure": "For each build tool, review the secrets defined and their permissions scope and change over permissive scopes to more restrictive ones based on the required access.", + "AuditProcedure": "For each build tool in use, review the secrets defined and the permission scopes they are assigned.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.8", + "Description": "Scan the build infrastructure and its dependencies for vulnerabilities. It is recommended that this be done automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan the build infrastructure and its dependencies for vulnerabilities. It is recommended that this be done automatically.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known vulnerabilities in the tooling used by the build infrastructure and its dependencies. These vulnerabilities can lead\nto a potentially massive breach if not handled as fast as possible, as attackers might also be\naware of such vulnerabilities.", + "ImpactStatement": "", + "RemediationProcedure": "Set an automated vulnerability scanning for your build infrastructure.", + "AuditProcedure": "Verify that your build infrastructure is automatically scanned for vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.9", + "Description": "Do not use default passwords of build tools and components.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not use default passwords of build tools and components.", + "RationaleStatement": "Sometimes build tools and components are provided with default passwords for the first login. This password is intended to be used only on the first login and should be changed immediately after. Using the default password substantially increases the attack risk. It is especially important to ensure that default passwords are not used in build tools and components.", + "ImpactStatement": "", + "RemediationProcedure": "For each build tool, change the default password.", + "AuditProcedure": "For each build tool, ensure the password used is not the default one.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.10", + "Description": "Use secured webhooks of the build environment.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use secured webhooks of the build environment.", + "RationaleStatement": "Webhooks are used for triggering an HTTP request based on an action made in the platform. Typically, build environment feature webhooks for a pipeline trigger based on source code event. Since webhooks are an HTTP POST request, they can be malformed if not secured over SSL. To prevent a potential hack and compromise of the webhook or to the environment or web server excepting the request, use only secured webhooks.", + "ImpactStatement": "", + "RemediationProcedure": "For each webhook in use, change it to secured (over HTTPS).", + "AuditProcedure": "For each webhook in use, ensure it is secured (HTTPS).", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.1.11", + "Description": "Ensure the build environment has a minimum number of administrators.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.1 Build Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the build environment has a minimum number of administrators.", + "RationaleStatement": "Build environment administrators have the highest level of permissions, including the ability to add/remove users, create or delete pipelines, control build workers, change build trigger permissions and more. Due to the permissive access granted to a build environment administrator, it is highly recommended to keep the number of administrator accounts as minimal as possible.", + "ImpactStatement": "", + "RemediationProcedure": "Set the minimum number of administrators in the build environment.", + "AuditProcedure": "Verify that the build environment has only the minimum number of administrators.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.1", + "Description": "Use a clean instance of build worker for every pipeline run.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use a clean instance of build worker for every pipeline run.", + "RationaleStatement": "Using a clean instance of build worker for every pipeline run eliminates the risks of data theft, data integrity breaches, and unavailability. It limits the pipeline's access to data stored on the file system from previous runs, and the cache is volatile. This prevents malicious changes from affecting other pipelines or the Continuous Integration/Continuous Delivery system itself.", + "ImpactStatement": "Data and cache will not be saved in different pipeline runs.", + "RemediationProcedure": "Create a clean build worker for every pipeline that is being run, or use build platform-hosted runners, as they typically offer a clean instance for every run.", + "AuditProcedure": "Ensure that every pipeline that is being run has its own clean, new runner.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.2", + "Description": "A worker’s environment can be passed (for example, a pod in a Kubernetes cluster in which an environment variable is passed to it). It also can be pulled, like a virtual machine that is installing a package. Ensure that the environment and commands are passed to the workers and not pulled from it.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A worker’s environment can be passed (for example, a pod in a Kubernetes cluster in which an environment variable is passed to it). It also can be pulled, like a virtual machine that is installing a package. Ensure that the environment and commands are passed to the workers and not pulled from it.", + "RationaleStatement": "Passing an environment means additional configuration happens in the build time phase and not in run time. It will also pass locally and not remotely. Passing a worker environment, instead of pulling it from an outer source, reduces the possibility for an attacker to gain access and potentially pull malicious code into it. By passing locally and not pulling from remote, there is also less chance of an attack based on the remote connection, such as a man-in-the-middle or malicious scripts that can run from remote. This therefore prevents possible infection of the build worker.", + "ImpactStatement": "", + "RemediationProcedure": "For each build worker, pass its environment and commands to it instead of pulling it.", + "AuditProcedure": "For each build worker, ensure its environment and commands are passed and not pulled.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.3", + "Description": "Separate responsibilities in the build workflow, such as testing, compiling, pushing artifacts, etc., to different build workers so that each worker will have a single duty.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Separate responsibilities in the build workflow, such as testing, compiling, pushing artifacts, etc., to different build workers so that each worker will have a single duty.", + "RationaleStatement": "Separating duties and allocating them to many workers makes it easier to verify each step in the build process and ensure there is no corruption. It also limits the effect of an attack on a build worker, as such an attack would be less critical if the worker has less access and duties that are subject to harm.", + "ImpactStatement": "", + "RemediationProcedure": "For each build worker, limit its responsibility to one duty.", + "AuditProcedure": "For each build worker, ensure it has the least responsibility possible, preferably only one duty.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.4", + "Description": "Ensure that build workers have minimal network connectivity.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that build workers have minimal network connectivity.", + "RationaleStatement": "Restricting the network connectivity of build workers decreases the possibility that an attacker would be capable of entering the organization from the outside. If the build workers are connected to the public internet without any restriction, it is far simpler for attackers to compromise them. Limiting network connectivity between build workers also protects the organization in case an attacker was successful and subsequently attempts to spread the attack to other components of the environment.", + "ImpactStatement": "Developers will not have connectivity to every resource they might need from the outside. Workers will also only be able to exchange data through shareable storage.", + "RemediationProcedure": "Limit the network connectivity of build workers, environment, and any other components to the necessary minimum.", + "AuditProcedure": "Verify that build workers, environment, and any other components have only the required minimum of network connectivity.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.5", + "Description": "Add traces to build workers' operating systems and installed applications so that in run time, collected events can be analyzed to detect suspicious behavior patterns and malware.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Add traces to build workers' operating systems and installed applications so that in run time, collected events can be analyzed to detect suspicious behavior patterns and malware.", + "RationaleStatement": "Build workers are exposed to data exfiltration attacks, code injection attacks, and more while running. It is important to secure them from such attacks by enforcing run-time security on the build worker itself. This will identify attempted attacks in real time and prevent them.", + "ImpactStatement": "", + "RemediationProcedure": "Deploy and enforce a run-time security solution on build workers.", + "AuditProcedure": "Verify that a run-time security solution is enforced on every active build worker.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.6", + "Description": "Scan build workers for vulnerabilities. It is recommended that this be done automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan build workers for vulnerabilities. It is recommended that this be done automatically.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known weaknesses in environmental sources in use, such as docker images or kernel versions. Such vulnerabilities can lead to a massive breach if these environments are not replaced as fast as possible, since attackers also know about these vulnerabilities and often try to take advantage of them. Setting automatic scanning which scans environmental sources ensures that if any new vulnerability is revealed, it can be replaced quickly and easily. This protects the worker from being exposed to attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For each build worker, automatically scan its environmental sources, such as docker image, for vulnerabilities.", + "AuditProcedure": "For each build worker, ensure the environmental sources it uses are scanned for vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.7", + "Description": "Store the deployment configuration of build workers in a version control platform, such as Github.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Store the deployment configuration of build workers in a version control platform, such as Github.", + "RationaleStatement": "Build workers are a sensitive part of the build phase. They generally have access to the code repository, the Continuous Integration platform, the deployment platform, etc. This means that an attacker gaining access to a build worker may compromise other platforms in the organization and cause a major incident. One thing that can protect workers is to ensure that their deployment configuration is safe and well-configured. Storing the deployment configuration in version control enables more observability of these configurations because everything is catalogued in a single place. It adds another layer of security, as every change will be reviewed and noticed, and thus malicious changes will theoretically occur less. In the case of a mistake, bug, or security incident, it also offers an easier way to \"revert\" back to a safe version or add a \"hot fix\" quickly.", + "ImpactStatement": "Changes in deployment configuration may only be applied by declaration in the version control platform. This could potentially slow down the development process.", + "RemediationProcedure": "Document and store every deployment configuration of build workers in a version control platform.", + "AuditProcedure": "Verify that the deployment configuration of build workers is stored in a version control platform.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.2.8", + "Description": "Monitor the resource consumption of build workers and set alerts for high consumption that can lead to resource exhaustion.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.2 Build Worker", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Monitor the resource consumption of build workers and set alerts for high consumption that can lead to resource exhaustion.", + "RationaleStatement": "Resource exhaustion is when machine resources or services are highly consumed until exhausted. Resource exhaustion may lead to DOS (Denial of Service). When such a situation happens to build workers, it slows down and even stops the build process, which harms the production of artifacts and the organization's ability to deliver software on schedule. To prevent that, it is recommended to monitor resources consumption in the build workers and set alerts to notify when they are highly consumed. That way resource exhaustion can be acknowledged and prevented at an early stage.", + "ImpactStatement": "", + "RemediationProcedure": "Set reources consumption monitoring for each build worker.", + "AuditProcedure": "Verify that there is monitoring of resources consumption for each build worker.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.1", + "Description": "Use pipeline as code for build pipelines and their defined steps.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use pipeline as code for build pipelines and their defined steps.", + "RationaleStatement": "Storing pipeline instructions as code in a version control system means automation of the build steps and less room for human error, which could potentially lead to a security breach. Additionally, It creates the ability to revert back to a previous pipeline configuration in order to pinpoint the affected change should a malicious incident occur.", + "ImpactStatement": "", + "RemediationProcedure": "Convert pipeline instructions into code-based syntax and upload them to the organization's version control platform.", + "AuditProcedure": "Verify that all build steps are defined as code and stored in a version control system.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.2", + "Description": "Define clear expected input and output for each build stage.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Define clear expected input and output for each build stage.", + "RationaleStatement": "In order to have more control over data flow in the build pipeline, clearly define the input and output of the pipeline steps. If anything malicious happens during the build stage, it will be recognized more easily and stand out as an anomaly.", + "ImpactStatement": "", + "RemediationProcedure": "For each build stage, clearly define what is expected for input and output.", + "AuditProcedure": "For each build stage, verify that the expected input and output are clearly defined.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.3", + "Description": "Write pipeline output artifacts to a secured storage repository.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Write pipeline output artifacts to a secured storage repository.", + "RationaleStatement": "To maintain output artifacts securely and reduce the potential surface for attack, store such artifacts separately in secure storage. This separation enforces the Single Responsibility Principle by ensuring the orchestration platform will not be the same as the artifact storage, which reduces the potential harm of an attack. Using the same security considerations as the input (for example, the source code) will protect artifacts stored and will make it harder for a malicious actor to successfully execute an attack.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline that produces output artifacts, write them to a secured storage repository.", + "AuditProcedure": "For each pipeline that produces output artifacts, ensure that they're written to a secured storage repository.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.4", + "Description": "Track and review changes to pipeline files.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Track and review changes to pipeline files.", + "RationaleStatement": "Pipeline files are sensitive files. They have the ability to access sensitive data and control the build process, thus it is just as important to review changes to pipeline files as it is to verify source code. Malicious actors can potentially add harmful code to these files, which may lead to sensitive data exposure and hijacking of the build environment or artifacts.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline file, track changes to it and review them.", + "AuditProcedure": "For each pipeline file, ensure changes to it are being tracked and reviewed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.5", + "Description": "Restrict access to pipeline triggers.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to pipeline triggers.", + "RationaleStatement": "Build pipelines are used for multiple reasons. Some are very sensitive, such as pipelines which deploy to production. In order to protect the environment from malicious acts or human mistakes, such as a developer deploying a bug to production, it is important to apply the Least Privilege principle to pipeline triggering. This principle requires restrictions placed on which users can run which pipeline. It allows for sensitive pipelines to only be run by administrators, who are generally the most trusted and skilled members of the organization.", + "ImpactStatement": "", + "RemediationProcedure": "For every pipeline in use, grant only the necessary users permission to trigger it.", + "AuditProcedure": "For every pipeline in use, verify only the necessary users have permission to trigger it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.6", + "Description": "Scan the pipeline for misconfigurations. It is recommended that this be performed automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan the pipeline for misconfigurations. It is recommended that this be performed automatically.", + "RationaleStatement": "Automatic scans for misconfigurations detect human mistakes and misconfigured tasks. This protects the environment from backdoors caused by such mistakes, which create easier access for attackers. For example, a task that mistakenly configures credentials to persist on the disk makes it easier for an attacker to steal them. This type of incident can be prevented by auto-scanning.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, set automated misconfiguration scanning.", + "AuditProcedure": "For each pipeline, verify that it is automatically scanned for misconfigurations.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.7", + "Description": "Scan pipelines for vulnerabilities. It is recommended that this be implemented automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan pipelines for vulnerabilities. It is recommended that this be implemented automatically.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known vulnerabilities in pipeline instructions and components, allowing faster patching in case one is found. These vulnerabilities can lead to a potentially massive breach if not handled as fast as possible, as attackers might also be aware of such vulnerabilities.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, set automated vulnerability scanning.", + "AuditProcedure": "For each pipeline, verify that it is automatically scanned for vulnerabilities.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.3.8", + "Description": "Detect and prevent sensitive data, such as confidential ID numbers, passwords, etc., in pipelines.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.3 Pipeline Instructions", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Detect and prevent sensitive data, such as confidential ID numbers, passwords, etc., in pipelines.", + "RationaleStatement": "Sensitive data in pipeline configuration, such as cloud provider credentials or repository credentials, create vulnerabilities with which malicious actors could steal such information if they gain access to a pipeline. In order to mitigate this, set scanners that will identify and prevent the existence of sensitive data in the pipeline.", + "ImpactStatement": "", + "RemediationProcedure": "For every pipeline that is in use, set scanners that will identify and prevent sensitive data within it.", + "AuditProcedure": "For every pipeline that is in use, verify that scanners are set to identify and prevent the existence of sensitive data within it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.1", + "Description": "Sign all artifacts in all releases with user or organization keys.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Sign all artifacts in all releases with user or organization keys.", + "RationaleStatement": "Signing artifacts is used to validate both their integrity and security. Organizations signal that artifacts may be trusted and they themselves produced them by ensuring that every artifact is properly signed. The presence of this signature also makes potentially malicious activity far more difficult.", + "ImpactStatement": "", + "RemediationProcedure": "For every artifact in every release, verify that all are properly signed.", + "AuditProcedure": "Ensure every artifact in every release is signed.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.2", + "Description": "External dependencies may be public packages needed in the pipeline, or perhaps the public image being used for the build worker. Lock these external dependencies in every build pipeline.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "External dependencies may be public packages needed in the pipeline, or perhaps the public image being used for the build worker. Lock these external dependencies in every build pipeline.", + "RationaleStatement": "External dependencies are sources of code that aren't under organizational control. They might be intentionally or unintentionally infected with malicious code or have known vulnerabilities, which could result in sensitive data exposure, data harvesting, or the erosion of trust in an organization. Locking each external dependency to a specific, safe version gives more control and less chance for risk.", + "ImpactStatement": "", + "RemediationProcedure": "For all external dependencies being used in pipelines, verify they are locked.", + "AuditProcedure": "Ensure every external dependency being used in pipelines is locked.", + "AdditionalInformation": "", + "References": "https://argon.io/blog/pipeline-composition-analysis-how-your-ci-pipeline-presents-new-opportunities-for-attackers/", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.3", + "Description": "Validate every dependency of the pipeline before use.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Validate every dependency of the pipeline before use.", + "RationaleStatement": "To ensure that a dependency used in a pipeline is trusted and has not been infected by malicious actor (for example, the codecov incident), validate dependencies before using them. This can be accomplished by comparing the checksum of the dependency to its checksum in a trusted source. If a difference arises, this is a sign that an unknown actor has interfered and may have added malevolent code. If this dependency is used, it will infect the environment, which could end in a massive breach and leave the organization exposed to data leaks, etc.", + "ImpactStatement": "", + "RemediationProcedure": "For every dependency used in every pipeline, validate each one.", + "AuditProcedure": "For every dependency used in every pipeline, ensure it has been validated.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.4", + "Description": "Verify that the build pipeline creates reproducible artifacts, meaning that an artifact of the build pipeline is the same in every run when given the same input.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Verify that the build pipeline creates reproducible artifacts, meaning that an artifact of the build pipeline is the same in every run when given the same input.", + "RationaleStatement": "A reproducible build is a build that produces the same artifact when given the same input data. Ensuring that the build pipeline produces the same artifact when given the same input helps verify that no change has been made to the artifact. This action allows an organization to trust that its artifacts are built only from safe code that has been reviewed and tested and has not been tainted or changed abruptly.", + "ImpactStatement": "", + "RemediationProcedure": "Create build pipelines that produce the same artifact given the same input (for example, artifacts that do not rely on timestamps).", + "AuditProcedure": "Ensure that build pipelines create reproducible artifacts.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.5", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. Generate an SBOM after each run of a pipeline.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. Generate an SBOM after each run of a pipeline.", + "RationaleStatement": "Generating a Software Bill of Materials after each run of a pipeline will validate the integrity and security of that pipeline. Recording every step or component role in the pipeline ensures that no malicious acts have been committed during the pipeline's run.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, configure it to produce a Software Bill of Materials on every run.", + "AuditProcedure": "For each pipeline, ensure it produces a Software Bill of Materials on every run.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.4.6", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. It should be generated after every pipeline run. After it is generated, it must then be signed.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Build Pipelines", + "Subsection": "2.4 Pipeline Integrity", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "SBOM (Software Bill of Materials) is a file that specifies each component of software or a build process. It should be generated after every pipeline run. After it is generated, it must then be signed.", + "RationaleStatement": "Software Bill of Materials (SBOM) is a file used to validate the integrity and security of a build pipeline. Signing it ensures that no one tampered with the file when it was delivered. Such interference can happen if someone tries to hide unusual activity. Validating the SBOM signature can detect this activity and prevent much greater incident.", + "ImpactStatement": "", + "RemediationProcedure": "For each pipeline, configure it to sign its produced Software Bill of Materials on every run.", + "AuditProcedure": "For each pipeline, ensure it signs the Software Bill of Materials it produces on every run.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.1", + "Description": "Ensure third-party artifacts and open-source libraries in use are trusted and verified.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure third-party artifacts and open-source libraries in use are trusted and verified.", + "RationaleStatement": "Verify third-party artifacts used in code are trusted and have not been infected by a malicious actor before use. This can be accomplished, for example, by comparing the checksum of the dependency to its checksum in a trusted source. If a difference arises, this may be a sign that someone interfered and added malicious code. If this dependency is used, it will infect the environment and could end in a massive breach, leaving the organization exposed to data leaks and more.", + "ImpactStatement": "", + "RemediationProcedure": "Verify every GitHub action (building block of a GitHub workflow) in use by performing the following: \n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Policies\", check **Allow OWNER, and select non-OWNER, actions and reusable workflows**, and then **Allow actions created by GitHub**.\n5. Click **Save**.\n\nAlternatively, perform the following for each repository where GitHub actions are used:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Actions permissions\", check **Allow OWNER, and select non-OWNER, actions and reusable workflows**, and then **Allow actions created by GitHub**.\n5. Click **Save**.", + "AuditProcedure": "For every GitHub action (building block of a GitHub workflow), ensure verification before use by performing the following: \n\n1. In the top right corner of GitHub.com, click your profile photo, then click **Your organizations**.\n2. Next to the organization, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Policies\", ensure **Allow OWNER, and select non-OWNER, actions and reusable workflows**, and **Allow actions created by GitHub** are checked.\n\nAlternatively, perform the following for each repository where GitHub actions are used:\n\n1. On GitHub.com, navigate to the main page of the repository.\n2. Under your repository name, click **Settings**.\n3. In the left sidebar, click **Actions**, then click **General**.\n4. Under \"Actions permissions\", ensure **Allow OWNER, and select non-OWNER, actions and reusable workflows** and **Allow actions created by GitHub** are checked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.2", + "Description": "A Software Bill Of Materials (SBOM) is a file that specifies each component of software or a build process. Require an SBOM from every third-party provider.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A Software Bill Of Materials (SBOM) is a file that specifies each component of software or a build process. Require an SBOM from every third-party provider.", + "RationaleStatement": "A Software Bill of Materials (SBOM) for every third-party artifact helps to ensure an artifact is safe to use and fully compliant. This file lists all important metadata, especially all the dependencies of an artifact, and allows for verification of each dependency. If one of the dependencies/artifacts are attacked or has a new vulnerability (for example, the \"SolarWinds\" or even \"log4j\" attacks), it is easier to detect what has been affected by this incident because dependencies in use are listed in the SBOM file.", + "ImpactStatement": "", + "RemediationProcedure": "For every third-party dependency in use, require a Software Bill of Materials from its supplier.", + "AuditProcedure": "For every third-party dependency in use, ensure it has a Software Bill of Materials.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.3", + "Description": "Require and verify signed metadata of the build process for all dependencies in use.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Require and verify signed metadata of the build process for all dependencies in use.", + "RationaleStatement": "The metadata of a build process lists every action that took place during an artifact build. It is used to ensure that an artifact has not been compromised during the build, that no malicious code was injected into it, and that no nefarious dependencies were added during the build phase. This creates trust between user and vendor that the software supplied is exactly the software that was promised. Signing this metadata adds a checksum to ensure there have been no revisions since its creation, as this checksum changes when the metadata is altered. Verification of proper metadata signature with Certificate Authority confirms that the signature was produced by a trusted entity.", + "ImpactStatement": "", + "RemediationProcedure": "For each artifact in use, require and verify signed metadata of the build process.", + "AuditProcedure": "For each artifact used, ensure it was supplied with verified and signed metadata of its build process. The signature should be the organizational signature and should be verifiable by common Certificate Authority servers.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.4", + "Description": "Monitor, or ask software suppliers to monitor, dependencies between open-source components in use.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Monitor, or ask software suppliers to monitor, dependencies between open-source components in use.", + "RationaleStatement": "Monitoring dependencies between open-source components helps to detect if software has fallen victim to attack on a common open-source component. Swift detection can aid in quick application of a fix. It also helps find potential compliance problems with components usage. Some dependencies might not be compatible with the organization's policies, and other dependencies might have a license that is not compatible with how the organization uses this specific dependency. If dependencies are monitored, such situations can be detected and mitigated sooner, potentially deterring malicious attacks.", + "ImpactStatement": "", + "RemediationProcedure": "For each open-source component, monitor its dependencies.", + "AuditProcedure": "For each open-source component, ensure its dependencies are monitored.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.5", + "Description": "Prioritize trusted package registries over others when pulling a package.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Prioritize trusted package registries over others when pulling a package.", + "RationaleStatement": "When pulling a package by name, the package manager might look for it in several package registries, some of which may be untrusted or badly configured. If the package is pulled from such a registry, there is a higher likelihood that it could prove malicious. In order to avoid this, configure packages to be pulled from trusted package registries.", + "ImpactStatement": "", + "RemediationProcedure": "For each package to be downloaded, configure it to be downloaded from a trusted source.", + "AuditProcedure": "For each package registry in use, ensure it is trusted.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.6", + "Description": "A Software Bill of Materials (SBOM) is a file that specifies each component of software or a build process. When using a dependency, demand its SBOM and ensure it is signed for validation purposes.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A Software Bill of Materials (SBOM) is a file that specifies each component of software or a build process. When using a dependency, demand its SBOM and ensure it is signed for validation purposes.", + "RationaleStatement": "A Software Bill of Materials (SBOM) creates trust between its provider and its users by ensuring that the software supplied is the software described, without any potential interference in between. Signing an SBOM creates a checksum for it, which will change if the SBOM's content was changed. With that checksum, a software user can be certain nothing had happened to it during the supply chain, engendering trust in the software. When there is no such trust in the software, the risk surface is increased because one cannot know if the software is potentially vulnerable. Demanding a signed SBOM and validating it decreases that risk.", + "ImpactStatement": "", + "RemediationProcedure": "For every artifact supplied, require and verify a signed Software Bill of Materials from its supplier.", + "AuditProcedure": "For every artifact supplied, ensure it has a validated, signed Software Bill of Materials.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.7", + "Description": "Pin dependencies to a specific version. Avoid using the \"latest\" tag or broad version.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Pin dependencies to a specific version. Avoid using the \"latest\" tag or broad version.", + "RationaleStatement": "When using a wildcard version of a package, or the \"latest\" tag, the risk of encountering a new, potentially malicious package increases. The \"latest\" tag pulls the last package pushed to the registry. This means that if an attacker pushes a new, malicious package successfully to the registry, the next user who pulls the \"latest\" will pull it and risk attack. This same rule applies to a wildcard version - assuming one is using version v1.*, it will install the latest version of the major version 1, meaning that if an attacker can push a malicious package with that same version, those using it will be subject to possible attack. By using a secure, verified version, use is restricted to this version only and no other may be pulled, decreasing the risk for any malicious package.", + "ImpactStatement": "", + "RemediationProcedure": "For every dependency in use, pin to a specific version.", + "AuditProcedure": "For every dependency in use, ensure it is pinned to a specific version.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.1.8", + "Description": "Use packages that are more than 60 days old.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.1 Third-Party Packages", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Use packages that are more than 60 days old.", + "RationaleStatement": "Third-party packages are a major risk since an organization cannot control their source code, and there is always the possibility these packages could be malicious. It is therefore good practice to remain cautious with any third-party or open-source package, especially new ones, until they can be verified that they are safe to use. Avoiding a new package allows the organization to fully examine it, its maintainer, and its behavior, and gives enough time to determine whether or not to use it.", + "ImpactStatement": "Developers may not use packages that are less than 60 days old.", + "RemediationProcedure": "If a package used is less than 60 days old, stop using it and find another solution.", + "AuditProcedure": "For every package used, ensure it is more than 60 days old.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.1", + "Description": "Enforce a policy for dependency usage across the organization. For example, disallow the use of packages less than 60 days old.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce a policy for dependency usage across the organization. For example, disallow the use of packages less than 60 days old.", + "RationaleStatement": "Enforcing a policy for dependency usage in an organization helps to manage dependencies across the organization and ensure that all usage is compliant with security policy. If, for example, the policy limits the package managers that can be used, enforcing it will make sure that every dependency is installed only from these package managers, and limit the risk of installing from any untrusted source.", + "ImpactStatement": "", + "RemediationProcedure": "Enforce policies for dependency usage across the organization.", + "AuditProcedure": "Verify that a policy for dependency usage is enforced across the organization.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.2", + "Description": "Automatically scan every package for vulnerabilities.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Automatically scan every package for vulnerabilities.", + "RationaleStatement": "Automatic scanning for vulnerabilities detects known vulnerabilities in packages and dependencies in use, allowing faster patching when one is found. Such vulnerabilities can lead to a massive breach if not handled as fast as possible, as attackers will also know about those vulnerabilities and swiftly try to take advantage of them. Scanning packages regularly for vulnerabilities can also verify usage compliance with the organization's security policy.", + "ImpactStatement": "", + "RemediationProcedure": "Set automatic scanning of packages for vulnerabilities.", + "AuditProcedure": "Ensure automatic scanning of packages for vulnerabilities is enabled.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.3", + "Description": "A software license is a document that provides legal conditions and guidelines for the use and distribution of software, usually defined by the author. It is recommended to scan for any legal implications automatically.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A software license is a document that provides legal conditions and guidelines for the use and distribution of software, usually defined by the author. It is recommended to scan for any legal implications automatically.", + "RationaleStatement": "When using packages with software licenses, especially commercial ones which tend to be the strictest, it is important to verify that the use of the package meets the conditions of the license. If the use of the package violates the licensing agreement, it exposes the organization to possible lawsuits. Scanning used packages for such license implications leads to faster detection and quicker fixes of such violations, and also reduces the risk for a lawsuit.", + "ImpactStatement": "", + "RemediationProcedure": "Set automatic package scanning for license implications.", + "AuditProcedure": "Ensure license implication rules are configured and are scanned automatically.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.2.4", + "Description": "Scan every package automatically for ownership change.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Dependencies", + "Subsection": "3.2 Validate Packages", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan every package automatically for ownership change.", + "RationaleStatement": "A change in package ownership is not a regular action. In some cases it can lead to a massive problem (for example, the \"event-stream\" incident). Open-source contributors are not always trusted, since by its very nature everyone can contribute. This means malicious actors can become contributors as well. Package maintainers might transfer their ownership to someone they do not know if maintaining the package is too much for them, in some cases without the other user's knowledge. This has led to known security breaches in the past. It is best to be aware of such activity as soon as it happens and to carefully examine the situation before continuing using the package in order to determine its safety.", + "ImpactStatement": "", + "RemediationProcedure": "Set automatic scanning of packages for ownership change.", + "AuditProcedure": "Ensure automatic scanning of packages for ownership change is set.", + "AdditionalInformation": "", + "References": "https://blog.npmjs.org/post/182828408610/the-security-risks-of-changing-package-owners.html:https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.1.1", + "Description": "Configure the build pipeline to sign every artifact it produces and verify that each artifact has the appropriate signature.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.1 Verification", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure the build pipeline to sign every artifact it produces and verify that each artifact has the appropriate signature.", + "RationaleStatement": "A cryptographic signature can be used to verify artifact authenticity. The signature created with a certain key is unique and not reversible, thus making it unique to the author. This means that an attacker tampering with a signed artifact will be noticed immediately using a simple verification step because the signature will change. Signing artifacts by the build pipeline that produces them ensures the integrity of those artifacts.", + "ImpactStatement": "", + "RemediationProcedure": "Sign every artifact produced with the build pipeline that created it. Configure the build pipeline to sign each artifact.\n\nSteps from GitHub Documentation:\n\nYou can follow the steps below to sign artifacts in GitHub actions. The trick involves loading in your private key into GitHub Actions using the gpg command-line commands.\n\nExport your gpg private key from the system that you have created it.\nFind your key-id (using gpg --list-secret-keys --keyid-format=long)\nExport the gpg secret key to an ASCII file using gpg --export-secret-keys -a > secret.txt\nEdit secret.txt using a plain text editor, and replace all newlines with a literal \"\\n\" until everything is on a single line\nSet up GitHub Actions secrets\nCreate a secret called OSSRH_GPG_SECRET_KEY using the text from your edited secret.txt file (the whole text should be in a single line)\nCreate a secret called OSSRH_GPG_SECRET_KEY_PASSWORD containing the password for your gpg secret key\nCreate a GitHub Actions step to install the gpg secret key\nAdd an action similar to:\n```\n- id: install-secret-key\n name: Install gpg secret key\n run: |\n cat <(echo -e \"${{ secrets.OSSRH_GPG_SECRET_KEY }}\") | gpg --batch --import\n gpg --list-secret-keys --keyid-format LONG\n```\nVerify that the secret key is shown in the GitHub Actions logs\nYou can remove the output from list secret keys if you are confident that this action will work, but it is better to leave it in there\nBring it all together, and create a GitHub Actions step to publish\nAdd an action similar to:\n```\n- id: publish-to-central\n name: Publish to Central Repository\n env:\n MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }}\n MAVEN_PASSWORD: ${{ secrets.OSSRH_TOKEN }}\n run: |\n mvn \\\n --no-transfer-progress \\\n --batch-mode \\\n -Dgpg.passphrase=${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} \\\n clean deploy\n```\nAfter a couple of hours, verify that the artifact got published to The Central Repository", + "AuditProcedure": "Verify that the build pipeline signs every new artifact it produces and all artifacts are signed.\n\nThere are many different signing tools or options each have there own method or commands to verify that the code or package created is signed.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/code-security/supply-chain-security/end-to-end-supply-chain/securing-builds:https://gist.github.com/sualeh/ae78dc16123899d7942bc38baba5203c", + "DefaultValue": "Artifacts are not signed by Default." + } + ] + }, + { + "Id": "4.1.2", + "Description": "Encrypt artifacts before they are distributed and ensure only trusted platforms have decryption capabilities.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.1 Verification", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Encrypt artifacts before they are distributed and ensure only trusted platforms have decryption capabilities.", + "RationaleStatement": "Build artifacts might contain sensitive data such as production configurations. In order to protect them and decrease the risk for breach, it is recommended to encrypt them before delivery. Encryption makes data unreadable, so even if attackers gain access to these artifacts, they won't be able to harvest sensitive data from them without the decryption key.", + "ImpactStatement": "", + "RemediationProcedure": "Encrypt every artifact before distribution.", + "AuditProcedure": "Ensure every artifact is encrypted before it is delivered.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Artifacts do not get encrypted by default." + } + ] + }, + { + "Id": "4.1.3", + "Description": "Grant decryption capabilities of artifacts only to trusted and authorized platforms.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.1 Verification", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Grant decryption capabilities of artifacts only to trusted and authorized platforms.", + "RationaleStatement": "Build artifacts might contain sensitive data such as production configuration. To protect them and decrease the risk of a breach, it is recommended to encrypt them before delivery. This will make them unreadable for every unauthorized user who doesn't have the decryption key. By implementing this, the decryption capabilities become overly sensitive in order to prevent a data leak or theft. Ensuring that only trusted and authorized platforms can decrypt the organization's packages decreases the possibility for an attacker to gain access to the critical data in artifacts.", + "ImpactStatement": "", + "RemediationProcedure": "Grant decryption capabilities of the organization's artifacts only for trusted and authorized platforms.", + "AuditProcedure": "Ensure only trusted and authorized platforms have decryption capabilities of the organization's artifacts.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.1", + "Description": "Software certification is used to verify the safety of certain software usage and to establish trust between the supplier and the consumer. Any artifact can be certified. Limit the authority to certify different artifacts.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Software certification is used to verify the safety of certain software usage and to establish trust between the supplier and the consumer. Any artifact can be certified. Limit the authority to certify different artifacts.", + "RationaleStatement": "Artifact certification is a powerful tool in establishing trust. Clients use a software certificate to verify that the artifact is safe to use according to their security policies. Because of this, certifying artifacts is considered sensitive. If an artifact is for debugging or internal use, or if it were compromised, the organization would not want certification. An attacker gaining access to both certificate authority and the artifact registry might also be able to certify its own artifact and cause a major breach. To prevent these issues, limit which artifacts can be certified by which platform so there will be minimal access to certification.", + "ImpactStatement": "", + "RemediationProcedure": "Limit which artifact can be certified by which authority.", + "AuditProcedure": "Ensure only certain artifacts can be certified by certain parties.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.2", + "Description": "Minimize ability to upload artifacts to the lowest number of trusted users possible.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Minimize ability to upload artifacts to the lowest number of trusted users possible.", + "RationaleStatement": "Artifacts might contain sensitive data. Even the simplest mistake can also lead to trust issues with customers and harm the integrity of the product. To decrease these risks, allow only trusted and qualified users to upload new artifacts. Those users are less likely to make mistakes. Having the lowest number of such users possible will also decrease the risk of hacked user accounts, which could lead to a massive breach or artifact compromising.", + "ImpactStatement": "", + "RemediationProcedure": "Allow only trusted and qualified users to upload new artifacts and limit them in number.", + "AuditProcedure": "Ensure only trusted and qualified users can upload new artifacts, and that their number is the lowest possible.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.3", + "Description": "Enforce Multi-Factor Authentication (MFA) for user access to the package registry.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Enforce Multi-Factor Authentication (MFA) for user access to the package registry.", + "RationaleStatement": "By default, every user authenticates to the system by password only. If a user's password is compromised, the user account and all its related packages are in danger of data theft and malicious builds. It is therefore recommended that each user enables Multi-Factor Authentication. This additional step guarantees that the account stays secure even if the user's password is compromised, as it adds another layer of authentication.", + "ImpactStatement": "", + "RemediationProcedure": "For each package registry in use, enforce Multi-Factor Authentication as the only way to authenticate.", + "AuditProcedure": "For each package registry in use, verify that Multi-Factor Authentication is enforced and is the only way to authenticate.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.4", + "Description": "Manage users and their access to the package registry with an external authentication server and not with the package registry itself.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Manage users and their access to the package registry with an external authentication server and not with the package registry itself.", + "RationaleStatement": "Some package registries offer a tool for user management, aside from the main Lightweight Directory Access Protocol (LDAP) or Active Directory (AD) server of the organization. That tool usually offers simple authentication and role-based permissions, which might not be granular enough. Having multiple user management tools in the organization could result in confusion and privilege escalation, as there will be more to manage. To avoid a situation where users escalate their privileges because someone missed them, manage user access to the package registry via the main authentication server and not locally on the package registry.", + "ImpactStatement": "", + "RemediationProcedure": "For each package registry, use the main authentication server of the organization for user management and do not manage locally.", + "AuditProcedure": "For each package registry, verify that its user access is not managed locally, but instead with the main authentication server of the organization.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.5", + "Description": "For GitHub Private or Internal repositories anonymous access is not available. Verify that all repos that require access controls are Private or Internal.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "For GitHub Private or Internal repositories anonymous access is not available. Verify that all repos that require access controls are Private or Internal.", + "RationaleStatement": "Disable the option to view artifacts as an anonymous user in order to protect private artifacts from being exposed.", + "ImpactStatement": "Only logged and authorized users will be able to access artifacts.", + "RemediationProcedure": "Changing a repository's visibility\n\n1. On your GitHub Enterprise Server instance, navigate to the main page of the repository.\n1. Under your repository name, click Settings\n1. Under \"Danger Zone\", to the right of to \"Change repository visibility\", click Change visibility.\n1. Select a visibility.\n\n1. Choose \n - Mark private\n - Make Internal\n\n6. To verify that you're changing the correct repository's visibility, type the name of the repository you want to change the visibility of.", + "AuditProcedure": "To Audit the existing settings:\n\n1. On your GitHub Enterprise Server instance, navigate to the main page of the repository.\n1. Under your repository name, click Settings\n3. Under \"Danger Zone\", to the right of to \"Change repository visibility\", click Change visibility.\n\nReview the current selection.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/enterprise-server@3.3/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/setting-repository-visibility", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.6", + "Description": "Ensure the package registry has a minimum number of administrators.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.2 Access to Artifacts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure the package registry has a minimum number of administrators.", + "RationaleStatement": "Package registry admins have the ability to add/remove users, repositories, packages. Due to the permissive access granted to an admin, it is highly recommended to keep the number of administrator accounts as minimal as possible.", + "ImpactStatement": "Administrator privileges are required to provide and maintain a secure and stable platform but allowing extraneous administrator accounts can create a vulnerability.", + "RemediationProcedure": "Set the minimum number of administrators in your package registry.\n\nTo accomplish this:\n\nFor each repository that you administer on GitHub, you can see an overview of every team or person with access to the repository. From the overview, choose Manage access and provide access to the appropriate people or teams.", + "AuditProcedure": "Verify that your package registry has only the minimum number of administrators.\n\nFor each repository that you administer on GitHub, you can see an overview of every team or person with access to the repository. From the overview, you can also invite new teams or people, change each team or person's role for the repository, or remove access to the repository.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/managing-teams-and-people-with-access-to-your-repository", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.3.1", + "Description": "Validate artifact signatures before uploading to the package registry.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Validate artifact signatures before uploading to the package registry.", + "RationaleStatement": "Cryptographic signature is a tool to verify artifact authenticity. Every artifact is supposed to be signed by its creator in order to confirm that it was not compromised before reaching the client. Validating an artifact signature before delivering it is another level of protection which ensures the signature has not been changed, meaning no one tried or succeeded in tampering with the artifact. This creates trust between the supplier and the client.", + "ImpactStatement": "", + "RemediationProcedure": "Validate every artifact with its signature before uploading it to the package registry. It is recommended to do so automatically.", + "AuditProcedure": "Ensure every artifact in the package registry has been validated with its signature.\n\n1. On GitHub, navigate to a pull request\n2. On the pull request, click <> Commits and view the detailed information regarding the signature.", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification:https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits", + "DefaultValue": "Artifacts are not scanned by default." + } + ] + }, + { + "Id": "4.3.2", + "Description": "Validate the signature of all versions of an existing artifact.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Validate the signature of all versions of an existing artifact.", + "RationaleStatement": "In order to be certain a version of an existing and trusted artifact is not malicious or delivered by someone looking to interfere with the supply chain, it is a good practice to validate the signatures of each version. Doing so decreases the risk of using a compromised artifact, which might lead to a breach.", + "ImpactStatement": "", + "RemediationProcedure": "For each artifact, sign and validate each version before uploading or using the artifact.", + "AuditProcedure": "For each artifact, ensure that all of its versions are signed and validated before it is uploaded or used.\n\nEnsure every artifact in the package registry has been validated with its signature.\n\nOn GitHub, navigate to a pull request\nOn the pull request, click <> Commits and view the detailed information regarding the signature.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.3.3", + "Description": "Audit changes of the package registry configuration.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Audit changes of the package registry configuration.", + "RationaleStatement": "The package registry is a crucial component in the software supply chain. It stores artifacts with potentially sensitive data that will eventually be deployed and used in production. Every change made to the package registry configuration must be examined carefully to ensure no exposure of the registry's sensitive data. This examination also ensures no malicious actors have performed modifications to a stored artifact. Auditing the configuration and its changes helps in decreasing such risks.", + "ImpactStatement": "", + "RemediationProcedure": "Audit the changes to the package registry configuration.", + "AuditProcedure": "Verify that all changes to the packages registry configuration are audited.\n\nSearch the audit log with\n\nrepo category actions", + "AdditionalInformation": "", + "References": "https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization", + "DefaultValue": "GitHub audits this by default." + } + ] + }, + { + "Id": "4.3.4", + "Description": "Use secured webhooks to reduce the possibility of malicious payloads.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.3 Package Registries", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use secured webhooks to reduce the possibility of malicious payloads.", + "RationaleStatement": "Webhooks are used for triggering an HTTP request based on an action made in the platform. Typically, package registries feature webhooks when a package receives an update. Since webhooks are an HTTP POST request, they can be malformed if not secured over SSL. To prevent a potential hack and compromise of the webhook or to the registry or web server excepting the request, use only secured webhooks.", + "ImpactStatement": "Reduces the payloads that the web hook can listen for and receive.", + "RemediationProcedure": "For each webhook in use, change it to secured (over HTTPS).", + "AuditProcedure": "", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.4.1", + "Description": "When delivering artifacts, ensure they have information about their origin. This may be done by providing a Software Bill of Manufacture (SBOM) or some metadata files.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Artifacts", + "Subsection": "4.4 Origin Traceability", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "When delivering artifacts, ensure they have information about their origin. This may be done by providing a Software Bill of Manufacture (SBOM) or some metadata files.", + "RationaleStatement": "Information about artifact origin can be used for verification purposes. Having this kind of information allows the user to decide if the organization supplying the artifact is trusted. In a case of potential vulnerability or version update, this can be used to verify that the organization issuing it is the actual origin and not someone else. If users need to report problems with the artifact, they will have an address to contact as well.", + "ImpactStatement": "", + "RemediationProcedure": "For each artifact supplied, supply information about its origin. For each artifact in use, ask for information about its origin.", + "AuditProcedure": "For each artifact, ensure it has information about its origin.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.1", + "Description": "Deployment configurations are often stored in a version control system. Separate deployment configuration files from source code repositories.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Deployment configurations are often stored in a version control system. Separate deployment configuration files from source code repositories.", + "RationaleStatement": "Deployment configuration manifests are often stored in version control systems. Storing them in dedicated repositories, separately from source code repositories, has several benefits. First, it adds order to both maintenance and version control history. This makes it easier to track code or manifest changes, as well as spot any malicious code or misconfigurations. Second, it helps achieve the Least Privilege principle. Because access can be configured differently for each repository, fewer users will have access to this configuration, which is typically sensitive.", + "ImpactStatement": "", + "RemediationProcedure": "Store each deployment configuration file in a dedicated repository separately from source code.", + "AuditProcedure": "Ensure each deployment configuration file is stored separately from source code.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.2", + "Description": "Audit and track changes made in deployment configuration.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Audit and track changes made in deployment configuration.", + "RationaleStatement": "Deployment configuration is sensitive in nature. The tiniest mistake can lead to downtime or bugs in production, which consequently may have a direct effect on both product integrity and customer trust. Misconfigurations might also be used by malicious actors to attack the production platform. Because of this, every change in the configuration needs a review and possible \"revert\" in case of a mistake or malicious change. Auditing every change and tracking them helps detect and fix such incidents more quickly.", + "ImpactStatement": "", + "RemediationProcedure": "For each deployment configuration, track and audit changes made to it.", + "AuditProcedure": "For each deployment configuration, ensure changes made to it are audited and tracked.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.3", + "Description": "Detect and prevent sensitive data – such as confidential ID numbers, passwords, etc. – in deployment configurations.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent sensitive data – such as confidential ID numbers, passwords, etc. – in deployment configurations.", + "RationaleStatement": "Sensitive data in deployment configurations might create a major incident if an attacker gains access to it, as this can cause data loss and theft. It is important to keep sensitive data safe and to not expose it in the configuration. In order to prevent a possible exposure, set scanners that will identify and prevent such data in deployment configurations.", + "ImpactStatement": "", + "RemediationProcedure": "For each deployment configuration file, set scanners to identify and prevent sensitive data within it.", + "AuditProcedure": "For each deployment configuration file, verify that scanners are set to identify and prevent the existence of sensitive data within it.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.4", + "Description": "Restrict access to the deployment configuration to trusted and qualified users only.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to the deployment configuration to trusted and qualified users only.", + "RationaleStatement": "Deployment configurations are sensitive in nature. The tiniest mistake can lead to downtime or bugs in production, which can have a direct effect on the product's integrity and customer trust. Misconfigurations might also be used by malicious actors to attack the production platform. To avoid such harm as much as possible, ensure only trusted and qualified users have access to such configurations. This will also reduce the number of accounts that might affect the environment in case of an attack.", + "ImpactStatement": "Reducing the number of users who have access to the deployment configuration means those users would lose their ability to make direct changes to that configuration.", + "RemediationProcedure": "Restrict access to the deployment configuration to trusted and qualified users.", + "AuditProcedure": "Verify each deployment configuration is accessible only to known and authorized users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.5", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Detect and prevent misconfigurations or insecure instructions in Infrastructure as Code (IaC) files, such as Terraform files.", + "RationaleStatement": "Infrastructure as Code (IaC) files are used for production environment and application deployment. These are sensitive parts of the software supply chain because they are always in touch with customers, and thus might affect their opinion of or trust in the product. Attackers often target these environments. Detecting and fixing misconfigurations and/or insecure instructions in IaC files decreases the risk for data leak or data theft. It is important to secure IaC instructions in order to prevent further problems of deployment, exposed assets, or improper configurations, which might ultimately lead to easier ways to attack and steal organization data.", + "ImpactStatement": "", + "RemediationProcedure": "For every Infrastructure as Code (IaC) instructions file, set scanners to identify and prevent misconfigurations and insecure instructions.", + "AuditProcedure": "For every Infrastructure as Code (IaC) instructions file, verify that scanners are set to identify and prevent misconfigurations and insecure instructions.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.6", + "Description": "Verify the deployment configuration manifests.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Verify the deployment configuration manifests.", + "RationaleStatement": "To ensure that the configuration manifests used are trusted and have not been infected by malicious actors before arriving at the platform, it is important to verify the manifests. This may be done by comparing the checksum of the manifest file to its checksum in a trusted source. If a difference arises, this is a sign that an unknown actor has interfered and may have added malicious instructions. If this manifest is used, it might harm the environment and application deployment, which could end in a massive breach and leave the organization exposed to data leaks, etc.", + "ImpactStatement": "", + "RemediationProcedure": "Verify each deployment configuration manifest in use.", + "AuditProcedure": "For each deployment configuration manifest in use, ensure it has been verified.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.7", + "Description": "Deployment configuration is often stored in a version control system and is pulled from there. Pin the configuration used to a specific, verified version or commit Secure Hash Algorithm (SHA). Avoid referring configuration without its version tag specified.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.1 Deployment Configuration", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Deployment configuration is often stored in a version control system and is pulled from there. Pin the configuration used to a specific, verified version or commit Secure Hash Algorithm (SHA). Avoid referring configuration without its version tag specified.", + "RationaleStatement": "Deployment configuration manifests are often stored in version control systems and pulled from there either by automation platforms, for example Ansible, or GitOps platforms, such as ArgoCD. When a manifest is pulled from a version control system without tag or commit Secure Hash Algorithm (SHA) specified, it is pulled from the HEAD revision, which is equal to the 'latest' tag, and pulls the last change made. This increases the risk of encountering a new, potentially malicious configuration. If an attacker pushes malicious configuration to the version control system, the next user who pulls the HEAD revision will pull it and risk attack. To avoid that risk, use a version tag of verified version or a commit SHA of a trusted commit, which will ensure this is the only version pulled.", + "ImpactStatement": "Changes in deployment configuration will not be pulled unless their version tag or commit Secure Hash Algorithm (SHA) is specified. This might slow down the deployment process.", + "RemediationProcedure": "For every deployment configuration manifest in use, pin to a specific version or commit Secure Hash Algorithm (SHA).", + "AuditProcedure": "For every deployment configuration manifest in use, ensure it is pinned to a specific version or commit Secure Hash Algorithm (SHA).", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.1", + "Description": "Automate deployments of production environment and application.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Automate deployments of production environment and application.", + "RationaleStatement": "Automating the deployments of both production environment and applications reduces the risk for human mistakes — such as a wrong configuration or exposure of sensitive data — because it requires less human interaction or intervention. It also eases redeployment of the environment. It is best to automate with Infrastructure as Code (IaC) because it offers more control over changes made to the environment creation configuration and stores to a version control platform.", + "ImpactStatement": "", + "RemediationProcedure": "Automate each deployment process of the production environment and application.", + "AuditProcedure": "For each deployment process, ensure it is automated.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.2", + "Description": "Verify that the deployment environment – the orchestrator and the production environment where the application is deployed – is reproducible. This means that the environment stays the same in each deployment if the configuration has not changed.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Verify that the deployment environment – the orchestrator and the production environment where the application is deployed – is reproducible. This means that the environment stays the same in each deployment if the configuration has not changed.", + "RationaleStatement": "A reproducible build is a build that produces the same artifact when given the same input data, and in this case the same environment. Ensuring that the same environment is produced when given the same input helps verify that no change has been made to it. This action allows an organization to trust that its deployment environment is built only from safe code and configuration that has been reviewed and tested and has not been tainted or changed abruptly.", + "ImpactStatement": "", + "RemediationProcedure": "Adjust the process that deploys the deployment/production environment to build the same environment each time when the configuration has not changed.", + "AuditProcedure": "Verify that the deployment/production environment is reproducible.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.3", + "Description": "Restrict access to the production environment to a few trusted and qualified users only.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to the production environment to a few trusted and qualified users only.", + "RationaleStatement": "The production environment is an extremely sensitive one. It directly affects the customer experience and trust in a product, which has serious effects on the organization itself. Because of this sensitive nature, it is important to restrict access to the production environment to only a few trusted and qualified users. This will reduce the risk of mistakes such as exposure of secrets or misconfiguration. This restriction also reduces the number of accounts that are vulnerable to hijacking in order to potentially harm the production environment.", + "ImpactStatement": "Reducing the number of users who have access to the production environment means those users would lose their ability to make direct changes to that environment.", + "RemediationProcedure": "Restrict access to the production environment to trusted and qualified users.", + "AuditProcedure": "Verify that the production environment is accessible only to trusted and qualified users.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.4", + "Description": "Do not use default passwords of deployment tools and components.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Deployment", + "Subsection": "5.2 Deployment Environment", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not use default passwords of deployment tools and components.", + "RationaleStatement": "Many deployment tools and components are provided with default passwords for the first login. This password is intended to be used only on the first login and should be changed immediately after. Using the default password substantially increases the attack risk. It is very important to ensure that default passwords are not used in deployment tools and components.", + "ImpactStatement": "", + "RemediationProcedure": "For each deployment tool, change the password.", + "AuditProcedure": "For each deployment tool, ensure the password is not the default one.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + } + ] +} diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index fa32397826..167842af4b 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -54,7 +54,9 @@ { "Id": "1.1.3", "Description": "Ensure super admin accounts are used only for super admin activities", - "Checks": [], + "Checks": [ + "directory_super_admin_only_admin_roles" + ], "Attributes": [ { "Section": "1 Directory", @@ -96,7 +98,9 @@ { "Id": "3.1.1.1.1", "Description": "Ensure external sharing options for primary calendars are configured", - "Checks": [], + "Checks": [ + "calendar_external_sharing_primary_calendar" + ], "Attributes": [ { "Section": "3 Apps", @@ -138,7 +142,9 @@ { "Id": "3.1.1.1.3", "Description": "Ensure external invitation warnings for Google Calendar are configured", - "Checks": [], + "Checks": [ + "calendar_external_invitations_warning" + ], "Attributes": [ { "Section": "3 Apps", @@ -159,7 +165,9 @@ { "Id": "3.1.1.2.1", "Description": "Ensure external sharing options for secondary calendars are configured", - "Checks": [], + "Checks": [ + "calendar_external_sharing_secondary_calendar" + ], "Attributes": [ { "Section": "3 Apps", @@ -222,7 +230,9 @@ { "Id": "3.1.2.1.1.1", "Description": "Ensure users are warned when they share a file outside their domain", - "Checks": [], + "Checks": [ + "drive_external_sharing_warn_users" + ], "Attributes": [ { "Section": "3 Apps", @@ -243,7 +253,9 @@ { "Id": "3.1.2.1.1.2", "Description": "Ensure users cannot publish files to the web or make visible to the world as public or unlisted", - "Checks": [], + "Checks": [ + "drive_publishing_files_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -264,7 +276,9 @@ { "Id": "3.1.2.1.1.3", "Description": "Ensure document sharing is being controlled by domain with allowlists", - "Checks": [], + "Checks": [ + "drive_sharing_allowlisted_domains" + ], "Attributes": [ { "Section": "3 Apps", @@ -285,7 +299,9 @@ { "Id": "3.1.2.1.1.4", "Description": "Ensure users are warned when they share a file with users in an allowlisted domain", - "Checks": [], + "Checks": [ + "drive_warn_sharing_with_allowlisted_domains" + ], "Attributes": [ { "Section": "3 Apps", @@ -306,7 +322,9 @@ { "Id": "3.1.2.1.1.5", "Description": "Ensure Access Checker is configured to limit file access", - "Checks": [], + "Checks": [ + "drive_access_checker_recipients_only" + ], "Attributes": [ { "Section": "3 Apps", @@ -327,7 +345,9 @@ { "Id": "3.1.2.1.1.6", "Description": "Ensure only users inside your organization can distribute content externally", - "Checks": [], + "Checks": [ + "drive_internal_users_distribute_content" + ], "Attributes": [ { "Section": "3 Apps", @@ -348,7 +368,9 @@ { "Id": "3.1.2.1.2.1", "Description": "Ensure users can create new shared drives", - "Checks": [], + "Checks": [ + "drive_shared_drive_creation_allowed" + ], "Attributes": [ { "Section": "3 Apps", @@ -369,7 +391,9 @@ { "Id": "3.1.2.1.2.2", "Description": "Ensure manager access members cannot modify shared drive settings", - "Checks": [], + "Checks": [ + "drive_shared_drive_managers_cannot_override" + ], "Attributes": [ { "Section": "3 Apps", @@ -390,7 +414,9 @@ { "Id": "3.1.2.1.2.3", "Description": "Ensure shared drive file access is restricted to members only", - "Checks": [], + "Checks": [ + "drive_shared_drive_members_only_access" + ], "Attributes": [ { "Section": "3 Apps", @@ -411,7 +437,9 @@ { "Id": "3.1.2.1.2.4", "Description": "Ensure viewers and commenters ability to download, print, and copy files is disabled", - "Checks": [], + "Checks": [ + "drive_shared_drive_disable_download_print_copy" + ], "Attributes": [ { "Section": "3 Apps", @@ -453,7 +481,9 @@ { "Id": "3.1.2.2.2", "Description": "Ensure desktop access to Drive is disabled", - "Checks": [], + "Checks": [ + "drive_desktop_access_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -495,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", @@ -621,7 +653,9 @@ { "Id": "3.1.3.4.1.1", "Description": "Ensure protection against encrypted attachments from untrusted senders is enabled", - "Checks": [], + "Checks": [ + "gmail_encrypted_attachment_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -642,7 +676,9 @@ { "Id": "3.1.3.4.1.2", "Description": "Ensure protection against attachments with scripts from untrusted senders is enabled", - "Checks": [], + "Checks": [ + "gmail_script_attachment_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -663,7 +699,9 @@ { "Id": "3.1.3.4.1.3", "Description": "Ensure protection against anomalous attachment types in emails is enabled", - "Checks": [], + "Checks": [ + "gmail_anomalous_attachment_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -684,7 +722,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", @@ -705,7 +745,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", @@ -726,7 +768,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", @@ -747,7 +791,9 @@ { "Id": "3.1.3.4.3.1", "Description": "Ensure protection against domain spoofing based on similar domain names is enabled", - "Checks": [], + "Checks": [ + "gmail_domain_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -768,7 +814,9 @@ { "Id": "3.1.3.4.3.2", "Description": "Ensure protection against spoofing of employee names is enabled", - "Checks": [], + "Checks": [ + "gmail_employee_name_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -789,7 +837,9 @@ { "Id": "3.1.3.4.3.3", "Description": "Ensure protection against inbound emails spoofing your domain is enabled", - "Checks": [], + "Checks": [ + "gmail_inbound_domain_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -810,7 +860,9 @@ { "Id": "3.1.3.4.3.4", "Description": "Ensure protection against any unauthenticated emails is enabled", - "Checks": [], + "Checks": [ + "gmail_unauthenticated_email_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -831,7 +883,9 @@ { "Id": "3.1.3.4.3.5", "Description": "Ensure groups are protected from inbound emails spoofing your domain", - "Checks": [], + "Checks": [ + "gmail_groups_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -852,7 +906,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", @@ -873,7 +929,9 @@ { "Id": "3.1.3.5.2", "Description": "Ensure automatic forwarding options are disabled", - "Checks": [], + "Checks": [ + "gmail_auto_forwarding_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -894,7 +952,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", @@ -936,7 +996,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", @@ -978,7 +1040,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", @@ -1020,7 +1084,9 @@ { "Id": "3.1.4.1.1", "Description": "Ensure external filesharing in Google Chat and Hangouts is disabled", - "Checks": [], + "Checks": [ + "chat_external_file_sharing_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -1041,7 +1107,9 @@ { "Id": "3.1.4.1.2", "Description": "Ensure internal filesharing in Google Chat and Hangouts is disabled", - "Checks": [], + "Checks": [ + "chat_internal_file_sharing_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -1062,7 +1130,9 @@ { "Id": "3.1.4.2.1", "Description": "Ensure Google Chat externally is restricted to allowed domains", - "Checks": [], + "Checks": [ + "chat_external_messaging_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1083,7 +1153,9 @@ { "Id": "3.1.4.3.1", "Description": "Ensure external spaces in Google Chat and Hangouts are restricted", - "Checks": [], + "Checks": [ + "chat_external_spaces_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1104,7 +1176,9 @@ { "Id": "3.1.4.4.1", "Description": "Ensure allow users to install Chat apps is disabled", - "Checks": [], + "Checks": [ + "chat_apps_installation_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -1125,7 +1199,9 @@ { "Id": "3.1.4.4.2", "Description": "Ensure allow users to add and use incoming webhooks is disabled", - "Checks": [], + "Checks": [ + "chat_incoming_webhooks_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -1146,7 +1222,9 @@ { "Id": "3.1.6.1", "Description": "Ensure accessing groups from outside this organization is set to private", - "Checks": [], + "Checks": [ + "groups_external_access_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1167,7 +1245,9 @@ { "Id": "3.1.6.2", "Description": "Ensure creating groups is restricted", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1188,7 +1268,9 @@ { "Id": "3.1.6.3", "Description": "Ensure default for permission to view conversations is restricted", - "Checks": [], + "Checks": [ + "groups_view_conversations_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1209,7 +1291,9 @@ { "Id": "3.1.7.1", "Description": "Ensure service status for Google Sites is set to off", - "Checks": [], + "Checks": [ + "sites_service_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -1230,7 +1314,9 @@ { "Id": "3.1.8.1", "Description": "Ensure access to external Google Groups is OFF for Everyone", - "Checks": [], + "Checks": [ + "additionalservices_external_groups_disabled" + ], "Attributes": [ { "Section": "3 Apps", @@ -1251,7 +1337,9 @@ { "Id": "3.1.9.1.1", "Description": "Ensure users access to Google Workspace Marketplace apps is restricted", - "Checks": [], + "Checks": [ + "marketplace_apps_access_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1272,7 +1360,9 @@ { "Id": "4.1.1.1", "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles", - "Checks": [], + "Checks": [ + "security_2sv_enforced" + ], "Attributes": [ { "Section": "4 Security", @@ -1293,7 +1383,9 @@ { "Id": "4.1.1.2", "Description": "Ensure hardware security keys are used for all users in administrative roles and other high-value accounts", - "Checks": [], + "Checks": [ + "security_2sv_hardware_keys_admins" + ], "Attributes": [ { "Section": "4 Security", @@ -1314,7 +1406,9 @@ { "Id": "4.1.1.3", "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users", - "Checks": [], + "Checks": [ + "security_2sv_enforced" + ], "Attributes": [ { "Section": "4 Security", @@ -1335,7 +1429,9 @@ { "Id": "4.1.2.1", "Description": "Ensure Super Admin account recovery is disabled", - "Checks": [], + "Checks": [ + "security_super_admin_recovery_disabled" + ], "Attributes": [ { "Section": "4 Security", @@ -1356,7 +1452,9 @@ { "Id": "4.1.2.2", "Description": "Ensure User account recovery is enabled", - "Checks": [], + "Checks": [ + "security_user_recovery_enabled" + ], "Attributes": [ { "Section": "4 Security", @@ -1377,7 +1475,9 @@ { "Id": "4.1.3.1", "Description": "Ensure Advanced Protection Program is configured", - "Checks": [], + "Checks": [ + "security_advanced_protection_configured" + ], "Attributes": [ { "Section": "4 Security", @@ -1398,7 +1498,9 @@ { "Id": "4.1.4.1", "Description": "Ensure login challenges are enforced", - "Checks": [], + "Checks": [ + "security_login_challenges_configured" + ], "Attributes": [ { "Section": "4 Security", @@ -1419,7 +1521,9 @@ { "Id": "4.1.5.1", "Description": "Ensure password policy is configured for enhanced security", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "4 Security", @@ -1440,7 +1544,9 @@ { "Id": "4.2.1.1", "Description": "Ensure application access to Google services is restricted", - "Checks": [], + "Checks": [ + "security_app_access_restricted" + ], "Attributes": [ { "Section": "4 Security", @@ -1482,7 +1588,9 @@ { "Id": "4.2.1.3", "Description": "Ensure internal apps can access Google Workspace APIs", - "Checks": [], + "Checks": [ + "security_internal_apps_trusted" + ], "Attributes": [ { "Section": "4 Security", @@ -1545,7 +1653,9 @@ { "Id": "4.2.3.1", "Description": "Ensure DLP policies for Google Drive are configured", - "Checks": [], + "Checks": [ + "security_dlp_drive_rules_configured" + ], "Attributes": [ { "Section": "4 Security", @@ -1566,7 +1676,9 @@ { "Id": "4.2.4.1", "Description": "Ensure Google session control is configured", - "Checks": [], + "Checks": [ + "security_session_duration_limited" + ], "Attributes": [ { "Section": "4 Security", @@ -1608,7 +1720,9 @@ { "Id": "4.2.6.1", "Description": "Ensure less secure app access is disabled", - "Checks": [], + "Checks": [ + "security_less_secure_apps_disabled" + ], "Attributes": [ { "Section": "4 Security", @@ -1713,7 +1827,9 @@ { "Id": "6.1", "Description": "Ensure User's password changed is configured", - "Checks": [], + "Checks": [ + "rules_password_changed_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1734,7 +1850,9 @@ { "Id": "6.2", "Description": "Ensure Government-backed attacks is configured", - "Checks": [], + "Checks": [ + "rules_government_backed_attacks_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1755,7 +1873,9 @@ { "Id": "6.3", "Description": "Ensure User suspended due to suspicious activity is configured", - "Checks": [], + "Checks": [ + "rules_suspicious_activity_suspension_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1776,7 +1896,9 @@ { "Id": "6.4", "Description": "Ensure User granted Admin privilege is configured", - "Checks": [], + "Checks": [ + "rules_admin_privilege_granted_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1797,7 +1919,9 @@ { "Id": "6.5", "Description": "Ensure Suspicious programmatic login is configured", - "Checks": [], + "Checks": [ + "rules_suspicious_programmatic_login_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1818,7 +1942,9 @@ { "Id": "6.6", "Description": "Ensure Suspicious login is configured", - "Checks": [], + "Checks": [ + "rules_suspicious_login_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1839,7 +1965,9 @@ { "Id": "6.7", "Description": "Ensure Leaked password is configured", - "Checks": [], + "Checks": [ + "rules_leaked_password_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1860,7 +1988,9 @@ { "Id": "6.8", "Description": "Ensure Gmail potential employee spoofing is configured", - "Checks": [], + "Checks": [ + "rules_gmail_employee_spoofing_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", diff --git a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json index 17c2b88b2c..72ff97d97d 100644 --- a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json @@ -8,7 +8,10 @@ { "Id": "GWS.COMMONCONTROLS.1.1", "Description": "Phishing-resistant MFA SHALL be required for all users", - "Checks": [], + "Checks": [ + "security_2sv_enforced", + "security_2sv_hardware_keys_admins" + ], "Attributes": [ { "Section": "Common Controls", @@ -21,7 +24,9 @@ { "Id": "GWS.COMMONCONTROLS.1.2", "Description": "If phishing-resistant MFA is not yet tenable, an MFA method from the list of acceptable MFA methods SHALL be used as an interim solution", - "Checks": [], + "Checks": [ + "security_2sv_enforced" + ], "Attributes": [ { "Section": "Common Controls", @@ -112,7 +117,9 @@ { "Id": "GWS.COMMONCONTROLS.4.1", "Description": "Google Workspace sessions SHALL re-authenticate after 12 hours", - "Checks": [], + "Checks": [ + "security_session_duration_limited" + ], "Attributes": [ { "Section": "Common Controls", @@ -125,7 +132,9 @@ { "Id": "GWS.COMMONCONTROLS.5.1", "Description": "Password strength SHALL be enforced", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -138,7 +147,9 @@ { "Id": "GWS.COMMONCONTROLS.5.2", "Description": "Minimum password length SHALL be at least 12 characters", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -151,7 +162,9 @@ { "Id": "GWS.COMMONCONTROLS.5.3", "Description": "Minimum password length SHOULD be at least 15 characters", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -164,7 +177,9 @@ { "Id": "GWS.COMMONCONTROLS.5.4", "Description": "Password policy SHALL be enforced at next sign-in", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -177,7 +192,9 @@ { "Id": "GWS.COMMONCONTROLS.5.5", "Description": "Password reuse SHALL be restricted", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -244,7 +261,9 @@ { "Id": "GWS.COMMONCONTROLS.8.1", "Description": "Account recovery for super admins SHALL be disabled", - "Checks": [], + "Checks": [ + "security_super_admin_recovery_disabled" + ], "Attributes": [ { "Section": "Common Controls", @@ -283,7 +302,9 @@ { "Id": "GWS.COMMONCONTROLS.9.1", "Description": "Privileged accounts SHALL be enrolled in the Advanced Protection Program", - "Checks": [], + "Checks": [ + "security_advanced_protection_configured" + ], "Attributes": [ { "Section": "Common Controls", @@ -296,7 +317,9 @@ { "Id": "GWS.COMMONCONTROLS.9.2", "Description": "Sensitive user accounts SHOULD be enrolled in the Advanced Protection Program", - "Checks": [], + "Checks": [ + "security_advanced_protection_configured" + ], "Attributes": [ { "Section": "Common Controls", @@ -361,7 +384,9 @@ { "Id": "GWS.COMMONCONTROLS.10.5", "Description": "Internal apps SHALL be allowed to access restricted Google Workspace APIs", - "Checks": [], + "Checks": [ + "security_internal_apps_trusted" + ], "Attributes": [ { "Section": "Common Controls", @@ -374,7 +399,9 @@ { "Id": "GWS.COMMONCONTROLS.11.1", "Description": "Only approved Marketplace apps SHALL be allowed for installation", - "Checks": [], + "Checks": [ + "marketplace_apps_access_restricted" + ], "Attributes": [ { "Section": "Common Controls", @@ -400,7 +427,16 @@ { "Id": "GWS.COMMONCONTROLS.13.1", "Description": "All system-defined alerting rules SHALL be enabled with alerts sent to admin email addresses", - "Checks": [], + "Checks": [ + "rules_password_changed_alert_configured", + "rules_government_backed_attacks_alert_configured", + "rules_suspicious_activity_suspension_alert_configured", + "rules_admin_privilege_granted_alert_configured", + "rules_suspicious_programmatic_login_alert_configured", + "rules_suspicious_login_alert_configured", + "rules_leaked_password_alert_configured", + "rules_gmail_employee_spoofing_alert_configured" + ], "Attributes": [ { "Section": "Common Controls", @@ -504,7 +540,9 @@ { "Id": "GWS.COMMONCONTROLS.18.1", "Description": "A DLP policy SHALL be configured for Drive", - "Checks": [], + "Checks": [ + "security_dlp_drive_rules_configured" + ], "Attributes": [ { "Section": "Common Controls", @@ -556,7 +594,9 @@ { "Id": "GWS.GMAIL.1.1", "Description": "Mail Delegation SHOULD be disabled", - "Checks": [], + "Checks": [ + "gmail_mail_delegation_disabled" + ], "Attributes": [ { "Section": "Gmail", @@ -647,7 +687,9 @@ { "Id": "GWS.GMAIL.5.1", "Description": "Protect against encrypted attachments from untrusted senders SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_encrypted_attachment_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -660,7 +702,9 @@ { "Id": "GWS.GMAIL.5.2", "Description": "Protect against attachments with scripts from untrusted senders SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_script_attachment_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -673,7 +717,9 @@ { "Id": "GWS.GMAIL.5.3", "Description": "Protect against anomalous attachment types in emails SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_anomalous_attachment_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -725,7 +771,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 +786,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 +801,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", @@ -790,7 +842,9 @@ { "Id": "GWS.GMAIL.7.1", "Description": "Protect against domain spoofing based on similar domain names SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_domain_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -803,7 +857,9 @@ { "Id": "GWS.GMAIL.7.2", "Description": "Protect against spoofing of employee names SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_employee_name_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -816,7 +872,9 @@ { "Id": "GWS.GMAIL.7.3", "Description": "Protect against inbound emails spoofing your domain SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_inbound_domain_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -829,7 +887,9 @@ { "Id": "GWS.GMAIL.7.4", "Description": "Protect against any unauthenticated emails SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_unauthenticated_email_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -842,7 +902,9 @@ { "Id": "GWS.GMAIL.7.5", "Description": "Protect your Groups from inbound emails spoofing your domain SHALL be enabled", - "Checks": [], + "Checks": [ + "gmail_groups_spoofing_protection_enabled" + ], "Attributes": [ { "Section": "Gmail", @@ -907,7 +969,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 +997,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 +1012,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 +1053,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 +1107,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", @@ -1089,7 +1161,9 @@ { "Id": "GWS.DRIVEDOCS.1.1", "Description": "External sharing SHALL be restricted to allowlisted domains", - "Checks": [], + "Checks": [ + "drive_sharing_allowlisted_domains" + ], "Attributes": [ { "Section": "Drive and Docs", @@ -1115,7 +1189,9 @@ { "Id": "GWS.DRIVEDOCS.1.3", "Description": "Warnings SHALL be enabled when a user is attempting to share with someone not in allowlisted domains", - "Checks": [], + "Checks": [ + "drive_warn_sharing_with_allowlisted_domains" + ], "Attributes": [ { "Section": "Drive and Docs", @@ -1141,7 +1217,9 @@ { "Id": "GWS.DRIVEDOCS.1.5", "Description": "Any OUs that do allow external sharing SHOULD disable making content available to anyone with the link", - "Checks": [], + "Checks": [ + "drive_publishing_files_disabled" + ], "Attributes": [ { "Section": "Drive and Docs", @@ -1154,7 +1232,9 @@ { "Id": "GWS.DRIVEDOCS.1.6", "Description": "Agencies SHALL set access checking to recipients only", - "Checks": [], + "Checks": [ + "drive_access_checker_recipients_only" + ], "Attributes": [ { "Section": "Drive and Docs", @@ -1193,7 +1273,9 @@ { "Id": "GWS.DRIVEDOCS.1.9", "Description": "Out-of-Domain file-level warnings SHALL be enabled", - "Checks": [], + "Checks": [ + "drive_external_sharing_warn_users" + ], "Attributes": [ { "Section": "Drive and Docs", @@ -1232,7 +1314,9 @@ { "Id": "GWS.DRIVEDOCS.2.1", "Description": "Agencies SHOULD NOT allow members with manager access to override shared Google Drive creation settings", - "Checks": [], + "Checks": [ + "drive_shared_drive_managers_cannot_override" + ], "Attributes": [ { "Section": "Drive and Docs", @@ -1310,7 +1394,9 @@ { "Id": "GWS.CALENDAR.1.1", "Description": "External Sharing Options for Primary Calendars SHALL be configured to Only free/busy information (hide event details)", - "Checks": [], + "Checks": [ + "calendar_external_sharing_primary_calendar" + ], "Attributes": [ { "Section": "Calendar", @@ -1323,7 +1409,9 @@ { "Id": "GWS.CALENDAR.1.2", "Description": "External sharing options for secondary calendars SHALL be configured to Only free/busy information (hide event details)", - "Checks": [], + "Checks": [ + "calendar_external_sharing_secondary_calendar" + ], "Attributes": [ { "Section": "Calendar", @@ -1336,7 +1424,9 @@ { "Id": "GWS.CALENDAR.2.1", "Description": "External invitations warnings SHALL be enabled to prompt users before sending invitations", - "Checks": [], + "Checks": [ + "calendar_external_invitations_warning" + ], "Attributes": [ { "Section": "Calendar", @@ -1414,7 +1504,9 @@ { "Id": "GWS.CHAT.2.1", "Description": "External file sharing SHALL be disabled to protect sensitive information from unauthorized or accidental sharing", - "Checks": [], + "Checks": [ + "chat_external_file_sharing_disabled" + ], "Attributes": [ { "Section": "Chat", @@ -1440,7 +1532,9 @@ { "Id": "GWS.CHAT.4.1", "Description": "External chat messaging SHALL be restricted to allowlisted domains only", - "Checks": [], + "Checks": [ + "chat_external_messaging_restricted" + ], "Attributes": [ { "Section": "Chat", @@ -1570,7 +1664,9 @@ { "Id": "GWS.GROUPS.1.1", "Description": "Group access from outside the organization SHALL be disabled unless explicitly granted by the group owner", - "Checks": [], + "Checks": [ + "groups_external_access_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1583,7 +1679,9 @@ { "Id": "GWS.GROUPS.1.2", "Description": "Group owners' ability to add external members to groups SHOULD be disabled unless necessary for agency mission fulfillment", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1596,7 +1694,9 @@ { "Id": "GWS.GROUPS.1.3", "Description": "Group owners' ability to allow posting to a group by an external, non-group member SHOULD be disabled unless necessary for agency mission fulfillment", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1609,7 +1709,9 @@ { "Id": "GWS.GROUPS.2.1", "Description": "Group creation SHOULD be restricted to admins within the organization unless necessary for agency mission fulfillment", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1622,7 +1724,9 @@ { "Id": "GWS.GROUPS.3.1", "Description": "The default permission to view conversations SHOULD be set to All Group Members", - "Checks": [], + "Checks": [ + "groups_view_conversations_restricted" + ], "Attributes": [ { "Section": "Groups", @@ -1648,7 +1752,9 @@ { "Id": "GWS.SITES.1.1", "Description": "Sites Service SHOULD be disabled for all users", - "Checks": [], + "Checks": [ + "sites_service_disabled" + ], "Attributes": [ { "Section": "Sites", diff --git a/prowler/compliance/kubernetes/cis_1.10_kubernetes.json b/prowler/compliance/kubernetes/cis_1.10_kubernetes.json index a049efc040..ed0e90d88f 100644 --- a/prowler/compliance/kubernetes/cis_1.10_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.10_kubernetes.json @@ -820,6 +820,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -858,6 +866,14 @@ "DefaultValue": "By default, auditing is not enabled.", "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -881,6 +897,14 @@ "DefaultValue": "By default, auditing is not enabled.", "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1109,6 +1133,18 @@ "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers", "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2069,6 +2105,23 @@ "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers", "References": "" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_1.11_kubernetes.json b/prowler/compliance/kubernetes/cis_1.11_kubernetes.json index 6a19ea4161..25d725683f 100644 --- a/prowler/compliance/kubernetes/cis_1.11_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.11_kubernetes.json @@ -820,6 +820,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -858,6 +866,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -881,6 +897,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1109,6 +1133,18 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2112,6 +2148,23 @@ "References": "", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_1.12_kubernetes.json b/prowler/compliance/kubernetes/cis_1.12_kubernetes.json index 1ba1e55a88..ded2d6944a 100644 --- a/prowler/compliance/kubernetes/cis_1.12_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.12_kubernetes.json @@ -820,6 +820,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -858,6 +866,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -881,6 +897,14 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1109,6 +1133,18 @@ "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2090,6 +2126,23 @@ "References": "", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_1.8_kubernetes.json b/prowler/compliance/kubernetes/cis_1.8_kubernetes.json index 24a5733a05..a09d753f58 100644 --- a/prowler/compliance/kubernetes/cis_1.8_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.8_kubernetes.json @@ -843,6 +843,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Section": "1 Control Plane Components", @@ -881,6 +889,14 @@ "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -904,6 +920,14 @@ "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/concepts/cluster-administration/audit/:https://github.com/kubernetes/features/issues/22", "DefaultValue": "By default, auditing is not enabled." } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { @@ -1132,6 +1156,18 @@ "References": "https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } ] }, { @@ -2092,6 +2128,23 @@ "References": "", "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { diff --git a/prowler/compliance/kubernetes/cis_2.0.1_kubernetes.json b/prowler/compliance/kubernetes/cis_2.0.1_kubernetes.json new file mode 100644 index 0000000000..2b9e695dd2 --- /dev/null +++ b/prowler/compliance/kubernetes/cis_2.0.1_kubernetes.json @@ -0,0 +1,2968 @@ +{ + "Framework": "CIS", + "Name": "CIS Kubernetes Benchmark v2.0.1", + "Version": "2.0.1", + "Provider": "Kubernetes", + "Description": "This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.34 - v1.35", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Ensure that the API server pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the API server pod specification file has permissions of `600` or more restrictive.", + "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-apiserver.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-apiserver.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the `kube-apiserver.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.2", + "Description": "Ensure that the API server pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the API server pod specification file ownership is set to `root:root`.", + "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/kube-apiserver.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/kube-apiserver.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the `kube-apiserver.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.3", + "Description": "Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", + "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the `kube-controller-manager.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.4", + "Description": "Ensure that the controller manager pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the controller manager pod specification file ownership is set to `root:root`.", + "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of various components of the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/kube-controller-manager.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager", + "DefaultValue": "By default, `kube-controller-manager.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.5", + "Description": "Ensure that the scheduler pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the scheduler pod specification file has permissions of `600` or more restrictive.", + "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the Scheduler service in the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-scheduler.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-scheduler.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-scheduler/", + "DefaultValue": "By default, `kube-scheduler.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.6", + "Description": "Ensure that the scheduler pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the scheduler pod specification file ownership is set to `root:root`.", + "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the `kube-scheduler` service in the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/kube-scheduler.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/kube-scheduler.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-scheduler/", + "DefaultValue": "By default, `kube-scheduler.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.7", + "Description": "Ensure that the etcd pod specification file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file has permissions of `600` or more restrictive.", + "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/etcd.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/etcd.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, `/etc/kubernetes/manifests/etcd.yaml` file has permissions of `640`." + } + ] + }, + { + "Id": "1.1.8", + "Description": "Ensure that the etcd pod specification file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file ownership is set to `root:root`.", + "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/manifests/etcd.yaml ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/manifests/etcd.yaml ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, `/etc/kubernetes/manifests/etcd.yaml` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.9", + "Description": "Ensure that the Container Network Interface file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Container Network Interface files have permissions of `600` or more restrictive.", + "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/cluster-administration/networking/", + "DefaultValue": "NA" + } + ] + }, + { + "Id": "1.1.10", + "Description": "Ensure that the Container Network Interface file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Container Network Interface files have ownership set to `root:root`.", + "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/cluster-administration/networking/", + "DefaultValue": "NA" + } + ] + }, + { + "Id": "1.1.11", + "Description": "Ensure that the etcd data directory permissions are set to 700 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the etcd data directory has permissions of `700` or more restrictive.", + "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should not be readable or writable by any group members or the world.", + "ImpactStatement": "None", + "RemediationProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` chmod 700 /var/lib/etcd ```", + "AuditProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` stat -c %a /var/lib/etcd ``` Verify that the permissions are `700` or more restrictive.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/configuration.html#data-dir:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, etcd data directory has permissions of `755`." + } + ] + }, + { + "Id": "1.1.12", + "Description": "Ensure that the etcd data directory ownership is set to etcd:etcd", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the etcd data directory ownership is set to `etcd:etcd`.", + "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should be owned by `etcd:etcd`.", + "ImpactStatement": "None", + "RemediationProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` chown etcd:etcd /var/lib/etcd ```", + "AuditProcedure": "On the etcd server node, get the etcd data directory, passed as an argument `--data-dir`, from the below command: ``` ps -ef | grep etcd ``` Run the below command (based on the etcd data directory found above). For example, ``` stat -c %U:%G /var/lib/etcd ``` Verify that the ownership is set to `etcd:etcd`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/configuration.html#data-dir:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, etcd data directory ownership is set to `etcd:etcd`." + } + ] + }, + { + "Id": "1.1.13", + "Description": "Ensure that the default administrative credential file permissions are set to 600", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `admin.conf` file (and `super-admin.conf` file, where it exists) have permissions of `600`.", + "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should restrict their file permissions to maintain the integrity and confidentiality of the file(s). The file(s) should be readable and writable by only the administrators on the system.", + "ImpactStatement": "None.", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/admin.conf ``` On Kubernetes 1.29+ the `super-admin.conf` file should also be modified, if present. For example, ``` chmod 600 /etc/kubernetes/super-admin.conf ```", + "AuditProcedure": "Run the following command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/admin.conf ``` On Kubernetes version 1.29 and higher run the following command as well :- ``` stat -c %a /etc/kubernetes/super-admin.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/:https://raesene.github.io/blog/2024/01/06/when-is-admin-not-admin/", + "DefaultValue": "By default, admin.conf and super-admin.conf have permissions of `600`." + } + ] + }, + { + "Id": "1.1.14", + "Description": "Ensure that the default administrative credential file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `admin.conf` (and `super-admin.conf` file, where it exists) file ownership is set to `root:root`.", + "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should set their file ownership to maintain the integrity and confidentiality of the file. The file(s) should be owned by `root:root`.", + "ImpactStatement": "None.", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/admin.conf ``` On Kubernetes 1.29+ the super-admin.conf file should also be modified, if present. For example, ``` chown root:root /etc/kubernetes/super-admin.conf ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/admin.conf ``` On Kubernetes version 1.29 and higher run the following command as well :- ``` stat -c %U:%G /etc/kubernetes/super-admin.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubeadm/:https://raesene.github.io/blog/2024/01/06/when-is-admin-not-admin/", + "DefaultValue": "By default, `admin.conf` and `super-admin.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.15", + "Description": "Ensure that the scheduler.conf file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `scheduler.conf` file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/scheduler.conf ```", + "AuditProcedure": "Run the following command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/scheduler.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/", + "DefaultValue": "By default, `scheduler.conf` has permissions of `640`." + } + ] + }, + { + "Id": "1.1.16", + "Description": "Ensure that the scheduler.conf file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `scheduler.conf` file ownership is set to `root:root`.", + "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/scheduler.conf ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/scheduler.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubeadm/", + "DefaultValue": "By default, `scheduler.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.17", + "Description": "Ensure that the controller-manager.conf file permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `controller-manager.conf` file has permissions of 600 or more restrictive.", + "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/controller-manager.conf ```", + "AuditProcedure": "Run the following command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/controller-manager.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `controller-manager.conf` has permissions of `640`." + } + ] + }, + { + "Id": "1.1.18", + "Description": "Ensure that the controller-manager.conf file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `controller-manager.conf` file ownership is set to `root:root`.", + "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown root:root /etc/kubernetes/controller-manager.conf ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %U:%G /etc/kubernetes/controller-manager.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `controller-manager.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "1.1.19", + "Description": "Ensure that the Kubernetes PKI directory and file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the Kubernetes PKI directory and file ownership is set to `root:root`.", + "RationaleStatement": "Kubernetes makes use of a number of certificates as part of its operation. You should set the ownership of the directory containing the PKI information and all files in that directory to maintain their integrity. The directory and files should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chown -R root:root /etc/kubernetes/pki/ ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` ls -laR /etc/kubernetes/pki/ ``` Verify that the ownership of all files and directories in this hierarchy is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the /etc/kubernetes/pki/ directory and all of the files and directories contained within it, are set to be owned by the root user." + } + ] + }, + { + "Id": "1.1.20", + "Description": "Ensure that the Kubernetes PKI certificate file permissions are set to 644 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Kubernetes PKI certificate files have permissions of `644` or more restrictive.", + "RationaleStatement": "Kubernetes makes use of a number of certificate files as part of the operation of its components. The permissions on these files should be set to `644` or more restrictive to protect their integrity and confidentiality.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod -R 644 /etc/kubernetes/pki/*.crt ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c '%a' /etc/kubernetes/pki/*.crt ``` Verify that the permissions are `644` or more restrictive. or ``` ls -l /etc/kubernetes/pki/*.crt ``` Verify -rw------", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the certificates used by Kubernetes are set to have permissions of `644`" + } + ] + }, + { + "Id": "1.1.21", + "Description": "Ensure that the Kubernetes PKI key file permissions are set to 600", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.1 Control Plane Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that Kubernetes PKI key files have permissions of `600`.", + "RationaleStatement": "Kubernetes makes use of a number of key files as part of the operation of its components. The permissions on these files should be set to `600` to protect their integrity and confidentiality.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod -R 600 /etc/kubernetes/pki/*.key ```", + "AuditProcedure": "Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c '%a' /etc/kubernetes/pki/*.key ``` Verify that the permissions are `600` or more restrictive. or ``` ls -l /etc/kubernetes/pki/*.key ``` Verify that the permissions are `-rw------`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, the keys used by Kubernetes are set to have permissions of `600`" + } + ] + }, + { + "Id": "1.2.1", + "Description": "Ensure that the --anonymous-auth argument is set to false", + "Checks": [ + "apiserver_anonymous_requests" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Disable anonymous requests to the API server.", + "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the API server. You should rely on authentication to authorize access and disallow anonymous requests. If you are using RBAC authorization, it is generally considered reasonable to allow anonymous access to the API Server for health checks and discovery purposes, and hence this recommendation is not scored. However, you should consider whether anonymous discovery is an acceptable risk for your purposes.", + "ImpactStatement": "Anonymous requests will be rejected.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the below parameter. ``` --anonymous-auth=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--anonymous-auth` argument is set to `false`. Alternative Audit Method ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[*]}{.spec.containers[*].command} {\\}{end}' | grep '--anonymous-auth' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/authentication/#anonymous-requests", + "DefaultValue": "By default, anonymous access is enabled." + } + ] + }, + { + "Id": "1.2.2", + "Description": "Ensure that the --token-auth-file parameter is not set", + "Checks": [ + "apiserver_no_token_auth_file" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not use token based authentication.", + "RationaleStatement": "The token-based authentication utilizes static tokens to authenticate requests to the apiserver. The tokens are stored in clear-text in a file on the apiserver, and cannot be revoked or rotated without restarting the apiserver. Hence, do not use static token-based authentication.", + "ImpactStatement": "You will have to configure and use alternate authentication mechanisms such as certificates. Static token based authentication could not be used.", + "RemediationProcedure": "Follow the documentation and configure alternate mechanisms for authentication. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and remove the `--token-auth-file=` parameter.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--token-auth-file` argument does not exist. Alternative Audit Method ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[*]}{.spec.containers[*].command} {\\}{end}' | grep '--token-auth-file' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authentication/#static-token-file:https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, `--token-auth-file` argument is not set." + } + ] + }, + { + "Id": "1.2.3", + "Description": "Ensure that the DenyServiceExternalIPs is set", + "Checks": [ + "apiserver_deny_service_external_ips" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "This admission controller rejects all net-new usage of the Service field externalIPs.", + "RationaleStatement": "Most users do not need the ability to set the `externalIPs` field for a `Service` at all, and cluster admins should consider disabling this functionality by enabling the `DenyServiceExternalIPs` admission controller. Clusters that do need to allow this functionality should consider using some custom policy to manage its usage.", + "ImpactStatement": "When enabled, users of the cluster may not create new Services which use externalIPs and may not add new values to externalIPs on existing Service objects.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and append the Kubernetes API server flag --enable-admission-plugins with the DenyServiceExternalIPs plugin. Note, the Kubernetes API server flag --enable-admission-plugins takes a comma-delimited list of admission control plugins to be enabled, even if they are in the list of plugins enabled by default. ``` kube-apiserver --enable-admission-plugins=DenyServiceExternalIPs ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `DenyServiceExternalIPs' argument exist as a string value in --enable-admission-plugins.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/:https://kubernetes.io/docs/admin/kube-apiserver/", + "DefaultValue": "By default, --enable-admission-plugins=DenyServiceExternalIP argument is not set, and the use of externalIPs is authorized." + } + ] + }, + { + "Id": "1.2.4", + "Description": "Ensure that the --kubelet-client-certificate and --kubelet-client-key arguments are set as appropriate", + "Checks": [ + "apiserver_kubelet_tls_auth" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable certificate based kubelet authentication.", + "RationaleStatement": "The apiserver, by default, does not authenticate itself to the kubelet's HTTPS endpoints. The requests from the apiserver are treated anonymously. You should set up certificate-based kubelet authentication to ensure that the apiserver authenticates itself to kubelets when submitting requests.", + "ImpactStatement": "You require TLS to be configured on apiserver as well as kubelets.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection between the apiserver and kubelets. Then, edit API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the kubelet client certificate and key parameters as below. ``` --kubelet-client-certificate= --kubelet-client-key= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--kubelet-client-certificate` and `--kubelet-client-key` arguments exist and they are set as appropriate. Alternative Audit ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[*]}{.spec.containers[*].command} {\\}{end}' | grep '--kubelet-client-certificate' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/:https://kubernetes.io/docs/concepts/cluster-administration/master-node-communication/#apiserver---kubelet", + "DefaultValue": "By default, certificate-based kubelet authentication is not set." + } + ] + }, + { + "Id": "1.2.5", + "Description": "Ensure that the --kubelet-certificate-authority argument is set as appropriate", + "Checks": [ + "apiserver_kubelet_cert_auth" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Verify kubelet's certificate before establishing connection.", + "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", + "ImpactStatement": "You require TLS to be configured on apiserver as well as kubelets.", + "RemediationProcedure": "Follow the Kubernetes documentation and setup the TLS connection between the apiserver and kubelets. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--kubelet-certificate-authority` parameter to the path to the cert file for the certificate authority. ``` --kubelet-certificate-authority= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--kubelet-certificate-authority` argument exists and is set as appropriate. Alternative Audit ``` kubectl get pod -nkube-system -lcomponent=kube-apiserver -o=jsonpath='{range .items[]}{.spec.containers[].command} {\\}{end}' | grep '--kubelet-certificate-authority' | grep -i false ``` If the exit code is '1', then the control isn't present / failed", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/:https://kubernetes.io/docs/concepts/cluster-administration/master-node-communication/#apiserver---kubelet", + "DefaultValue": "By default, `--kubelet-certificate-authority` argument is not set." + } + ] + }, + { + "Id": "1.2.6", + "Description": "Ensure that the --authorization-mode argument is not set to AlwaysAllow", + "Checks": [ + "apiserver_auth_mode_not_always_allow" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not always authorize all requests.", + "RationaleStatement": "The API Server, can be configured to allow all requests. This mode should not be used on any production cluster.", + "ImpactStatement": "Only authorized requests will be served.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--authorization-mode` parameter to values other than `AlwaysAllow`. One such example could be as below. ``` --authorization-mode=RBAC ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--authorization-mode` argument exists and is not set to `AlwaysAllow`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/admin/authorization/", + "DefaultValue": "By default, `AlwaysAllow` is not enabled." + } + ] + }, + { + "Id": "1.2.7", + "Description": "Ensure that the --authorization-mode argument includes Node", + "Checks": [ + "apiserver_auth_mode_include_node" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Restrict kubelet nodes to reading only objects associated with them.", + "RationaleStatement": "The `Node` authorization mode only allows kubelets to read `Secret`, `ConfigMap`, `PersistentVolume`, and `PersistentVolumeClaim` objects associated with their nodes.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--authorization-mode` parameter to a value that includes `Node`. ``` --authorization-mode=Node,RBAC ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--authorization-mode` argument exists and is set to a value to include `Node`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-apiserver/:https://kubernetes.io/docs/admin/authorization/node/:https://github.com/kubernetes/kubernetes/pull/46076:https://acotten.com/post/kube17-security", + "DefaultValue": "By default, `Node` authorization is not enabled." + } + ] + }, + { + "Id": "1.2.8", + "Description": "Ensure that the --authorization-mode argument includes RBAC", + "Checks": [ + "apiserver_auth_mode_include_rbac" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Turn on Role Based Access Control.", + "RationaleStatement": "Role Based Access Control (RBAC) allows fine-grained control over the operations that different entities can perform on different objects in the cluster. It is recommended to use the RBAC authorization mode.", + "ImpactStatement": "When RBAC is enabled you will need to ensure that appropriate RBAC settings (including Roles, RoleBindings and ClusterRoleBindings) are configured to allow appropriate access.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--authorization-mode` parameter to a value that includes `RBAC`, for example: ``` --authorization-mode=Node,RBAC ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--authorization-mode` argument exists and is set to a value to include `RBAC`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "DefaultValue": "By default, `RBAC` authorization is not enabled." + } + ] + }, + { + "Id": "1.2.9", + "Description": "Ensure that the admission control plugin EventRateLimit is set", + "Checks": [ + "apiserver_event_rate_limit" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Limit the rate at which the API server accepts requests.", + "RationaleStatement": "Using `EventRateLimit` admission control enforces a limit on the number of events that the API Server will accept in a given time slice. A misbehaving workload could overwhelm and DoS the API Server, making it unavailable. This particularly applies to a multi-tenant cluster, where there might be a small percentage of misbehaving tenants which could have a significant impact on the performance of the cluster overall. Hence, it is recommended to limit the rate of events that the API server will accept.", + "ImpactStatement": "You need to carefully tune in limits as per your environment.", + "RemediationProcedure": "Follow the Kubernetes documentation and set the desired limits in a configuration file. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` and set the below parameters. ``` --enable-admission-plugins=...,EventRateLimit,... --admission-control-config-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--enable-admission-plugins` argument is set to a value that includes `EventRateLimit`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#eventratelimit:https://github.com/staebler/community/blob/9873b632f4d99b5d99c38c9b15fe2f8b93d0a746/contributors/design-proposals/admission_control_event_rate_limit.md", + "DefaultValue": "By default, `EventRateLimit` is not set." + } + ] + }, + { + "Id": "1.2.10", + "Description": "Ensure that the admission control plugin AlwaysAdmit is not set", + "Checks": [ + "apiserver_no_always_admit_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not allow all requests.", + "RationaleStatement": "Setting admission control plugin `AlwaysAdmit` allows all requests and do not filter any requests. The `AlwaysAdmit` admission controller was deprecated in Kubernetes v1.13. Its behavior was equivalent to turning off all admission controllers.", + "ImpactStatement": "Only requests explicitly allowed by the admissions control plugins would be served.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and either remove the `--enable-admission-plugins` parameter, or set it to a value that does not include `AlwaysAdmit`.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that if the `--enable-admission-plugins` argument is set, its value does not include `AlwaysAdmit`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwaysadmit", + "DefaultValue": "`AlwaysAdmit` is not in the list of default admission plugins." + } + ] + }, + { + "Id": "1.2.11", + "Description": "Ensure that the admission control plugin AlwaysPullImages is set", + "Checks": [ + "apiserver_always_pull_images_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Always pull images.", + "RationaleStatement": "Setting admission control policy to `AlwaysPullImages` forces every new pod to pull the required images every time. In a multi-tenant cluster users can be assured that their private images can only be used by those who have the credentials to pull them. Without this admission control policy, once an image has been pulled to a node, any pod from any user can use it simply by knowing the image’s name, without any authorization check against the image ownership. When this plug-in is enabled, images are always pulled prior to starting containers, which means valid credentials are required.", + "ImpactStatement": "Credentials would be required to pull the private images every time. Also, in trusted environments, this might increases load on network, registry, and decreases speed. This setting could impact offline or isolated clusters, which have images preloaded and do not have access to a registry to pull in-use images. This setting is not appropriate for clusters which use this configuration.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--enable-admission-plugins` parameter to include `AlwaysPullImages`. ``` --enable-admission-plugins=...,AlwaysPullImages,... ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--enable-admission-plugins` argument is set to a value that includes `AlwaysPullImages`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages", + "DefaultValue": "By default, `AlwaysPullImages` is not set." + } + ] + }, + { + "Id": "1.2.12", + "Description": "Ensure that the admission control plugin ServiceAccount is set", + "Checks": [ + "apiserver_service_account_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Automate service accounts management.", + "RationaleStatement": "When you create a pod, if you do not specify a service account, it is automatically assigned the `default` service account in the same namespace. You should create your own service account and let the API server manage its security tokens.", + "ImpactStatement": "None.", + "RemediationProcedure": "Follow the documentation and create `ServiceAccount` objects as per your environment. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and ensure that the `--disable-admission-plugins` parameter is set to a value that does not include `ServiceAccount`.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--disable-admission-plugins` argument is set to a value that does not includes `ServiceAccount`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#serviceaccount:https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + "DefaultValue": "By default, `ServiceAccount` is set." + } + ] + }, + { + "Id": "1.2.13", + "Description": "Ensure that the admission control plugin NamespaceLifecycle is set", + "Checks": [ + "apiserver_namespace_lifecycle_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Reject creating objects in a namespace that is undergoing termination.", + "RationaleStatement": "Setting admission control policy to `NamespaceLifecycle` ensures that objects cannot be created in non-existent namespaces, and that namespaces undergoing termination are not used for creating the new objects. This is recommended to enforce the integrity of the namespace termination process and also for the availability of the newer objects.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--disable-admission-plugins` parameter to ensure it does not include `NamespaceLifecycle`.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--disable-admission-plugins` argument is set to a value that does not include `NamespaceLifecycle`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#namespacelifecycle", + "DefaultValue": "By default, `NamespaceLifecycle` is set." + } + ] + }, + { + "Id": "1.2.14", + "Description": "Ensure that the admission control plugin NodeRestriction is set", + "Checks": [ + "apiserver_node_restriction_plugin" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Limit the `Node` and `Pod` objects that a kubelet could modify.", + "RationaleStatement": "Using the `NodeRestriction` plug-in ensures that the kubelet is restricted to the `Node` and `Pod` objects that it could modify as defined. Such kubelets will only be allowed to modify their own `Node` API object, and only modify `Pod` API objects that are bound to their node.", + "ImpactStatement": "None", + "RemediationProcedure": "Follow the Kubernetes documentation and configure `NodeRestriction` plug-in on kubelets. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the `--enable-admission-plugins` parameter to a value that includes `NodeRestriction`. ``` --enable-admission-plugins=...,NodeRestriction,... ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--enable-admission-plugins` argument is set to a value that includes `NodeRestriction`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#noderestriction:https://kubernetes.io/docs/reference/access-authn-authz/node/", + "DefaultValue": "By default, `NodeRestriction` is not set." + } + ] + }, + { + "Id": "1.2.15", + "Description": "Ensure that the --profiling argument is set to false", + "Checks": [ + "apiserver_disable_profiling" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable profiling, if not needed.", + "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", + "ImpactStatement": "Profiling information would not be available.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the below parameter. ``` --profiling=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--profiling` argument is set to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, profiling is enabled." + } + ] + }, + { + "Id": "1.2.16", + "Description": "Ensure that the --audit-log-path argument is set", + "Checks": [ + "apiserver_audit_log_path_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable auditing on the Kubernetes API Server and set the desired audit log path.", + "RationaleStatement": "Auditing the Kubernetes API Server provides a security-relevant chronological set of records documenting the sequence of activities that have affected system by individual users, administrators or other components of the system. Even though currently, Kubernetes provides only basic audit capabilities, it should be enabled. You can enable it by setting an appropriate audit log path.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-path` parameter to a suitable path and file where you would like audit logs to be written, for example: ``` --audit-log-path=/var/log/apiserver/audit.log ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-path` argument is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ] + }, + { + "Id": "1.2.17", + "Description": "Ensure that the --audit-log-maxage argument is set to 30 or as appropriate", + "Checks": [ + "apiserver_audit_log_maxage_set" + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Retain the logs for at least 30 days or as appropriate.", + "RationaleStatement": "Retaining logs for at least 30 days ensures that you can go back in time and investigate or correlate any events. Set your audit log retention period to 30 days or as per your business requirements.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-maxage` parameter to 30 or as an appropriate number of days: ``` --audit-log-maxage=30 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-maxage` argument is set to `30` or as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ] + }, + { + "Id": "1.2.18", + "Description": "Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate", + "Checks": [ + "apiserver_audit_log_maxbackup_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Retain 10 or an appropriate number of old log files.", + "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. For example, if you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-maxbackup` parameter to 10 or to an appropriate value. ``` --audit-log-maxbackup=10 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-maxbackup` argument is set to `10` or as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } + ] + }, + { + "Id": "1.2.19", + "Description": "Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate", + "Checks": [ + "apiserver_audit_log_maxsize_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Rotate log files on reaching 100 MB or as appropriate.", + "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. If you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--audit-log-maxsize` parameter to an appropriate size in MB. For example, to set it as 100 MB: ``` --audit-log-maxsize=100 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-log-maxsize` argument is set to `100` or as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/:https://github.com/kubernetes/enhancements/issues/22", + "DefaultValue": "By default, auditing is not enabled." + } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } + ] + }, + { + "Id": "1.2.20", + "Description": "Ensure that the --request-timeout argument is set as appropriate", + "Checks": [ + "apiserver_request_timeout_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Set global request timeout for API server requests as appropriate.", + "RationaleStatement": "Setting global request timeout allows extending the API server request timeout limit to a duration appropriate to the user's connection speed. By default, it is set to 60 seconds which might be problematic on slower connections making cluster resources inaccessible once the data volume for requests exceeds what can be transmitted in 60 seconds. But, setting this timeout limit to be too large can exhaust the API server resources making it prone to Denial-of-Service attack. Hence, it is recommended to set this limit as appropriate and change the default limit of 60 seconds only if needed.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` and set the below parameter as appropriate and if needed. For example, ``` --request-timeout=300s ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--request-timeout` argument is either not set or set to an appropriate value.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/kubernetes/pull/51415", + "DefaultValue": "By default, `--request-timeout` is set to 60 seconds." + } + ] + }, + { + "Id": "1.2.21", + "Description": "Ensure that the --service-account-lookup argument is set to true", + "Checks": [ + "apiserver_service_account_lookup_true" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Validate service account before validating token.", + "RationaleStatement": "If `--service-account-lookup` is not enabled, the apiserver only verifies that the authentication token is valid, and does not validate that the service account token mentioned in the request is actually present in etcd. This allows using a service account token even after the corresponding service account is deleted. This is an example of time of check to time of use security issue.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the below parameter. ``` --service-account-lookup=true ``` Alternatively, you can delete the `--service-account-lookup` parameter from this file so that the default takes effect.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that if the `--service-account-lookup` argument exists it is set to `true`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/kubernetes/issues/24167:https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use", + "DefaultValue": "By default, `--service-account-lookup` argument is set to `true`." + } + ] + }, + { + "Id": "1.2.22", + "Description": "Ensure that the --service-account-key-file argument is set as appropriate", + "Checks": [ + "apiserver_service_account_key_file_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Explicitly set a service account public key file for service accounts on the apiserver.", + "RationaleStatement": "By default, if no `--service-account-key-file` is specified to the apiserver, it uses the private key from the TLS serving certificate to verify service account tokens. To ensure that the keys for service account tokens could be rotated as needed, a separate public/private key pair should be used for signing service account tokens. Hence, the public key should be specified to the apiserver with `--service-account-key-file`.", + "ImpactStatement": "The corresponding private key must be provided to the controller manager. You would need to securely maintain the key file and rotate the keys based on your organization's key rotation policy.", + "RemediationProcedure": "Edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the Control Plane node and set the `--service-account-key-file` parameter to the public key file for service accounts: ``` --service-account-key-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--service-account-key-file` argument exists and is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/kubernetes/issues/24167", + "DefaultValue": "By default, `--service-account-key-file` argument is not set." + } + ] + }, + { + "Id": "1.2.23", + "Description": "Ensure that the --etcd-certfile and --etcd-keyfile arguments are set as appropriate", + "Checks": [ + "apiserver_etcd_tls_config" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured to make use of TLS encryption for client connections.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a client certificate and key.", + "ImpactStatement": "TLS and client certificate authentication must be configured for etcd.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection between the apiserver and etcd. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the etcd certificate and key file parameters. ``` --etcd-certfile= --etcd-keyfile= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--etcd-certfile` and `--etcd-keyfile` arguments exist and they are set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, `--etcd-certfile` and `--etcd-keyfile` arguments are not set" + } + ] + }, + { + "Id": "1.2.24", + "Description": "Ensure that the --tls-cert-file and --tls-private-key-file arguments are set as appropriate", + "Checks": [ + "apiserver_tls_config" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Setup TLS connection on the API server.", + "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic.", + "ImpactStatement": "TLS and client certificate authentication must be configured for your Kubernetes cluster deployment.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection on the apiserver. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the TLS certificate and private key file parameters. ``` --tls-cert-file= --tls-private-key-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--tls-cert-file` and `--tls-private-key-file` arguments exist and they are set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kelseyhightower/docker-kubernetes-tls-guide", + "DefaultValue": "By default, `--tls-cert-file` and `--tls-private-key-file` are presented and created for use." + } + ] + }, + { + "Id": "1.2.25", + "Description": "Ensure that the --client-ca-file argument is set as appropriate", + "Checks": [ + "apiserver_client_ca_file_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Setup TLS connection on the API server.", + "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic. If `--client-ca-file` argument is set, any request presenting a client certificate signed by one of the authorities in the `client-ca-file` is authenticated with an identity corresponding to the CommonName of the client certificate.", + "ImpactStatement": "TLS and client certificate authentication must be configured for your Kubernetes cluster deployment.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection on the apiserver. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the client certificate authority file. ``` --client-ca-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--client-ca-file` argument exists and it is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kelseyhightower/docker-kubernetes-tls-guide", + "DefaultValue": "By default, `--client-ca-file` argument is not set." + } + ] + }, + { + "Id": "1.2.26", + "Description": "Ensure that the --etcd-cafile argument is set as appropriate", + "Checks": [ + "apiserver_etcd_cafile_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured to make use of TLS encryption for client connections.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a SSL Certificate Authority file.", + "ImpactStatement": "TLS and client certificate authentication must be configured for etcd.", + "RemediationProcedure": "Follow the Kubernetes documentation and set up the TLS connection between the apiserver and etcd. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the etcd certificate authority file parameter. ``` --etcd-cafile= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--etcd-cafile` argument exists and it is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, `--etcd-cafile` is not set." + } + ] + }, + { + "Id": "1.2.27", + "Description": "Ensure that the --encryption-provider-config argument is set as appropriate", + "Checks": [ + "apiserver_encryption_provider_config_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Encrypt etcd key-value store.", + "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted at rest to avoid any disclosures.", + "ImpactStatement": "None", + "RemediationProcedure": "Follow the Kubernetes documentation and configure a `EncryptionConfig` file. Then, edit the API server pod specification file `/etc/kubernetes/manifests/kube-apiserver.yaml` on the master node and set the `--encryption-provider-config` parameter to the path of that file: ``` --encryption-provider-config= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--encryption-provider-config` argument is set to a `EncryptionConfig` file. Additionally, ensure that the `EncryptionConfig` file has all the desired `resources` covered especially any secrets.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/:https://acotten.com/post/kube17-security:https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/enhancements/issues/92", + "DefaultValue": "By default, `--encryption-provider-config` is not set." + } + ] + }, + { + "Id": "1.2.28", + "Description": "Ensure that encryption providers are appropriately configured", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Where `etcd` encryption is used, appropriate providers should be configured.", + "RationaleStatement": "Where `etcd` encryption is used, it is important to ensure that the appropriate set of encryption providers is used. Currently, the `aescbc`, `kms`, and `secretbox` are likely to be appropriate options.", + "ImpactStatement": "None", + "RemediationProcedure": "Follow the Kubernetes documentation and configure a `EncryptionConfig` file. In this file, choose `aescbc`, `kms`, or `secretbox` as the encryption provider.", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Get the `EncryptionConfig` file set for `--encryption-provider-config` argument. Verify that `aescbc`, `kms`, or `secretbox` is set as the encryption provider for all the desired `resources`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/:https://acotten.com/post/kube17-security:https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/kubernetes/enhancements/issues/92:https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/#providers", + "DefaultValue": "By default, no encryption provider is set." + } + ] + }, + { + "Id": "1.2.29", + "Description": "Ensure that the API Server only makes use of Strong Cryptographic Ciphers", + "Checks": [ + "apiserver_strong_ciphers_only" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the API server is configured to only use strong cryptographic ciphers.", + "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS cipher suites including some that have security concerns, weakening the protection provided.", + "ImpactStatement": "API server clients that cannot support modern cryptographic ciphers will not be able to make connections to the API server.", + "RemediationProcedure": "Edit the API server pod specification file /etc/kubernetes/manifests/kube-apiserver.yaml on the Control Plane node and set the below parameter. ``` --tls-cipher-suites=TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256. ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the `--tls-cipher-suites` argument returns a value that is in this list `TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256`.", + "AdditionalInformation": "Insecure values: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_RSA_WITH_RC4_128_SHA, TLS_RSA_WITH_3DES_EDE_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_256_CBC_SHA, TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_RSA_WITH_RC4_128_SHA.", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/:https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices#23-use-secure-cipher-suites", + "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" + } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_strong_ciphers_only", + "ConfigKey": "apiserver_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256" + ] + } + ] + }, + { + "Id": "1.2.30", + "Description": "Ensure that the --service-account-extend-token-expiration parameter is set to false", + "Checks": [], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.2 API Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "By default Kubernetes extends service account token lifetimes to one year to aid in transition from the legacy token settings.", + "RationaleStatement": "This default setting is not ideal for security as it ignores other settings related to maximum token lifetime and means that a lost or stolen credential could be valid for an extended period of time.", + "ImpactStatement": "Disabling this setting means that the service account token expiry set in the cluster will be enforced, and service account tokens will expire at the end of that time frame.", + "RemediationProcedure": "Edit the API server pod specification file /etc/kubernetes/manifests/kube-apiserver.yaml on the Control Plane node and set the --service-account-extend-token-expiration parameter to false. ``` --service-account-extend-token-expiration=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-apiserver ``` Verify that the --service-account-extend-token-expiration argument is set to false.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "DefaultValue": "By default, this parameter is set to true" + } + ] + }, + { + "Id": "1.3.1", + "Description": "Ensure that the --terminated-pod-gc-threshold argument is set as appropriate", + "Checks": [ + "controllermanager_garbage_collection" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Activate garbage collector on pod termination, as appropriate.", + "RationaleStatement": "Garbage collection is important to ensure sufficient resource availability and avoiding degraded performance and availability. In the worst case, the system might crash or just be unusable for a long period of time. The current setting for garbage collection is 12,500 terminated pods which might be too high for your system to sustain. Based on your system resources and tests, choose an appropriate threshold value to activate garbage collection.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--terminated-pod-gc-threshold` to an appropriate threshold, for example: ``` --terminated-pod-gc-threshold=10 ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--terminated-pod-gc-threshold` argument is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://github.com/kubernetes/kubernetes/issues/28484", + "DefaultValue": "By default, `--terminated-pod-gc-threshold` is set to `12500`." + } + ] + }, + { + "Id": "1.3.2", + "Description": "Ensure that the --profiling argument is set to false", + "Checks": [ + "controllermanager_disable_profiling" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable profiling, if not needed.", + "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", + "ImpactStatement": "Profiling information would not be available.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the below parameter. ``` --profiling=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--profiling` argument is set to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://github.com/kubernetes/community/blob/master/contributors/devel/profiling.md", + "DefaultValue": "By default, profiling is enabled." + } + ] + }, + { + "Id": "1.3.3", + "Description": "Ensure that the --use-service-account-credentials argument is set to true", + "Checks": [ + "controllermanager_service_account_credentials" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Use individual service account credentials for each controller.", + "RationaleStatement": "The controller manager creates a service account per controller in the `kube-system` namespace, generates a credential for it, and builds a dedicated API client with that service account credential for each controller loop to use. Setting the `--use-service-account-credentials` to `true` runs each control loop within the controller manager using a separate service account credential. When used in combination with RBAC, this ensures that the control loops run with the minimum permissions required to perform their intended tasks.", + "ImpactStatement": "Whatever authorizer is configured for the cluster, it must grant sufficient permissions to the service accounts to perform their intended tasks. When using the RBAC authorizer, those roles are created and bound to the appropriate service accounts in the `kube-system` namespace automatically with default roles and rolebindings that are auto-reconciled on startup. If using other authorization methods (ABAC, Webhook, etc), the cluster deployer is responsible for granting appropriate permissions to the service accounts (the required permissions can be seen by inspecting the `controller-roles.yaml` and `controller-role-bindings.yaml` files for the RBAC roles.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node to set the below parameter. ``` --use-service-account-credentials=true ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--use-service-account-credentials` argument is set to `true`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://kubernetes.io/docs/admin/service-accounts-admin/:https://github.com/kubernetes/kubernetes/blob/release-1.6/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-roles.yaml:https://github.com/kubernetes/kubernetes/blob/release-1.6/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-role-bindings.yaml:https://kubernetes.io/docs/admin/authorization/rbac/#controller-roles", + "DefaultValue": "By default, `--use-service-account-credentials` is set to false." + } + ] + }, + { + "Id": "1.3.4", + "Description": "Ensure that the --service-account-private-key-file argument is set as appropriate", + "Checks": [ + "controllermanager_service_account_private_key_file" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Explicitly set a service account private key file for service accounts on the controller manager.", + "RationaleStatement": "To ensure that keys for service account tokens can be rotated as needed, a separate public/private key pair should be used for signing service account tokens. The private key should be specified to the controller manager with `--service-account-private-key-file` as appropriate.", + "ImpactStatement": "You would need to securely maintain the key file and rotate the keys based on your organization's key rotation policy.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--service-account-private-key-file` parameter to the private key file for service accounts. ``` --service-account-private-key-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--service-account-private-key-file` argument is set as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `--service-account-private-key-file` it not set." + } + ] + }, + { + "Id": "1.3.5", + "Description": "Ensure that the --root-ca-file argument is set as appropriate", + "Checks": [ + "controllermanager_root_ca_file_set" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Allow pods to verify the API server's serving certificate before establishing connections.", + "RationaleStatement": "Processes running within pods that need to contact the API server must verify the API server's serving certificate. Failing to do so could be a subject to man-in-the-middle attacks. Providing the root certificate for the API server's serving certificate to the controller manager with the `--root-ca-file` argument allows the controller manager to inject the trusted bundle into pods so that they can verify TLS connections to the API server.", + "ImpactStatement": "You need to setup and maintain root certificate authority file.", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--root-ca-file` parameter to the certificate bundle file`. ``` --root-ca-file= ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--root-ca-file` argument exists and is set to a certificate bundle file containing the root certificate for the API server's serving certificate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-controller-manager/:https://github.com/kubernetes/kubernetes/issues/11000", + "DefaultValue": "By default, `--root-ca-file` is not set." + } + ] + }, + { + "Id": "1.3.6", + "Description": "Ensure that the RotateKubeletServerCertificate argument is set to true", + "Checks": [ + "controllermanager_rotate_kubelet_server_cert" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable kubelet server certificate rotation on controller-manager.", + "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and set the `--feature-gates` parameter to include `RotateKubeletServerCertificate=true`. ``` --feature-gates=RotateKubeletServerCertificate=true ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that `RotateKubeletServerCertificate` argument exists and is set to `true`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet-tls-bootstrapping/#approval-controller:https://github.com/kubernetes/features/issues/267:https://github.com/kubernetes/kubernetes/pull/45059:https://kubernetes.io/docs/admin/kube-controller-manager/", + "DefaultValue": "By default, `RotateKubeletServerCertificate` is set to true this recommendation verifies that it has not been disabled." + } + ] + }, + { + "Id": "1.3.7", + "Description": "Ensure that the --bind-address argument is set to 127.0.0.1", + "Checks": [ + "controllermanager_bind_address" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.3 Controller Manager", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not bind the Controller Manager service to non-loopback insecure addresses.", + "RationaleStatement": "The Controller Manager API service which runs on port 10252/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Controller Manager pod specification file `/etc/kubernetes/manifests/kube-controller-manager.yaml` on the Control Plane node and ensure the correct value for the `--bind-address` parameter", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-controller-manager ``` Verify that the `--bind-address` argument is set to 127.0.0.1", + "AdditionalInformation": "Although the current Kubernetes documentation site says that `--address` is deprecated in favour of `--bind-address` Kubeadm 1.11 still makes use of `--address`", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/", + "DefaultValue": "By default, the `--bind-address` parameter is set to 0.0.0.0" + } + ] + }, + { + "Id": "1.4.1", + "Description": "Ensure that the --profiling argument is set to false", + "Checks": [ + "scheduler_profiling" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.4 Scheduler", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable profiling, if not needed.", + "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", + "ImpactStatement": "Profiling information would not be available.", + "RemediationProcedure": "Edit the Scheduler pod specification file `/etc/kubernetes/manifests/kube-scheduler.yaml` file on the Control Plane node and set the below parameter. ``` --profiling=false ```", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-scheduler ``` Verify that the `--profiling` argument is set to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-scheduler/:https://github.com/kubernetes/community/blob/master/contributors/devel/profiling.md", + "DefaultValue": "By default, profiling is enabled." + } + ] + }, + { + "Id": "1.4.2", + "Description": "Ensure that the --bind-address argument is set to 127.0.0.1", + "Checks": [ + "scheduler_bind_address" + ], + "Attributes": [ + { + "Section": "1 Control Plane Components", + "SubSection": "1.4 Scheduler", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not bind the scheduler service to non-loopback insecure addresses.", + "RationaleStatement": "The Scheduler API service which runs on port 10251/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the Scheduler pod specification file `/etc/kubernetes/manifests/kube-scheduler.yaml` on the Control Plane node and ensure the correct value for the `--bind-address` parameter", + "AuditProcedure": "Run the following command on the Control Plane node: ``` ps -ef | grep kube-scheduler ``` Verify that the `--bind-address` argument is set to 127.0.0.1", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-scheduler/", + "DefaultValue": "By default, the `--bind-address` parameter is set to 0.0.0.0" + } + ] + }, + { + "Id": "2.1", + "Description": "Ensure that the --cert-file and --key-file arguments are set as appropriate", + "Checks": [ + "etcd_tls_encryption" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Configure TLS encryption for the etcd service.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit.", + "ImpactStatement": "Client connections only over TLS would be served.", + "RemediationProcedure": "Follow the etcd service documentation and configure TLS encryption. Then, edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameters. ``` --cert-file= --key-file= ```", + "AuditProcedure": "Run the following command on the etcd server node ``` ps -ef | grep etcd ``` Verify that the `--cert-file` and the `--key-file` arguments are set as appropriate.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "By default, TLS encryption is not set." + } + ] + }, + { + "Id": "2.2", + "Description": "Ensure that the --client-cert-auth argument is set to true", + "Checks": [ + "etcd_client_cert_auth" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable client authentication on etcd service.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", + "ImpactStatement": "All clients attempting to access the etcd server will require a valid client certificate.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameter. ``` --client-cert-auth=true ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that the `--client-cert-auth` argument is set to `true`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#client-cert-auth", + "DefaultValue": "By default, the etcd service can be queried by unauthenticated clients." + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure that the --auto-tls argument is not set to true", + "Checks": [ + "etcd_no_auto_tls" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not use self-signed certificates for TLS.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", + "ImpactStatement": "Clients will not be able to use self-signed certificates for TLS.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and either remove the `--auto-tls` parameter or set it to `false`. ``` --auto-tls=false ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that if the `--auto-tls` argument exists, it is not set to `true`.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#auto-tls", + "DefaultValue": "By default, `--auto-tls` is set to `false`." + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure that the --peer-cert-file and --peer-key-file arguments are set as appropriate", + "Checks": [ + "etcd_peer_tls_config" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured to make use of TLS encryption for peer connections.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit and also amongst peers in the etcd clusters.", + "ImpactStatement": "etcd cluster peers would need to set up TLS for their communication.", + "RemediationProcedure": "Follow the etcd service documentation and configure peer TLS encryption as appropriate for your etcd cluster. Then, edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameters. ``` --peer-client-file= --peer-key-file= ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that the `--peer-cert-file` and `--peer-key-file` arguments are set as appropriate. **Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/", + "DefaultValue": "**Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable. By default, peer communication over TLS is not configured." + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure that the --peer-client-cert-auth argument is set to true", + "Checks": [ + "etcd_peer_client_cert_auth" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "etcd should be configured for peer authentication.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster.", + "ImpactStatement": "All peers attempting to communicate with the etcd server will require a valid client certificate for authentication.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameter. ``` --peer-client-cert-auth=true ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that the `--peer-client-cert-auth` argument is set to `true`. **Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#peer-client-cert-auth", + "DefaultValue": "**Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable. By default, `--peer-client-cert-auth` argument is set to `false`." + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure that the --peer-auto-tls argument is not set to true", + "Checks": [ + "etcd_no_peer_auto_tls" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not use automatically generated self-signed certificates for TLS connections between peers.", + "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster. Hence, do not use self-signed certificates for authentication.", + "ImpactStatement": "All peers attempting to communicate with the etcd server will require a valid client certificate for authentication.", + "RemediationProcedure": "Edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and either remove the `--peer-auto-tls` parameter or set it to `false`. ``` --peer-auto-tls=false ```", + "AuditProcedure": "Run the following command on the etcd server node: ``` ps -ef | grep etcd ``` Verify that if the `--peer-auto-tls` argument exists, it is not set to `true`. **Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html:https://kubernetes.io/docs/admin/etcd/:https://coreos.com/etcd/docs/latest/op-guide/configuration.html#peer-auto-tls", + "DefaultValue": "**Note:** This recommendation is applicable only for etcd clusters. If you are using only one etcd server in your environment then this recommendation is not applicable. By default, `--peer-auto-tls` argument is set to `false`." + } + ] + }, + { + "Id": "2.7", + "Description": "Ensure that a unique Certificate Authority is used for etcd", + "Checks": [ + "etcd_unique_ca" + ], + "Attributes": [ + { + "Section": "2 etcd", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Use a different certificate authority for etcd from the one used for Kubernetes.", + "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. Its access should be restricted to specifically designated clients and peers only. Authentication to etcd is based on whether the certificate presented was issued by a trusted certificate authority. There is no checking of certificate attributes such as common name or subject alternative name. As such, if any attackers were able to gain access to any certificate issued by the trusted certificate authority, they would be able to gain full access to the etcd database.", + "ImpactStatement": "Additional management of the certificates and keys for the dedicated certificate authority will be required.", + "RemediationProcedure": "Follow the etcd documentation and create a dedicated certificate authority setup for the etcd service. Then, edit the etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` on the master node and set the below parameter. ``` --trusted-ca-file= ```", + "AuditProcedure": "Review the CA used by the etcd environment and ensure that it does not match the CA certificate file used for the management of the overall Kubernetes cluster. Run the following command on the master node: ``` ps -ef | grep etcd ``` Note the file referenced by the `--trusted-ca-file` argument. Run the following command on the master node: ``` ps -ef | grep apiserver ``` Verify that the file referenced by the `--client-ca-file` for apiserver is different from the `--trusted-ca-file` used by etcd.", + "AdditionalInformation": "", + "References": "https://coreos.com/etcd/docs/latest/op-guide/security.html", + "DefaultValue": "By default, no etcd certificate is created and used." + } + ] + }, + { + "Id": "3.1.1", + "Description": "Client certificate authentication should not be used for users", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.1 Authentication and Authorization", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides the option to use client certificates for user authentication. However as there is no way to revoke these certificates when a user leaves an organization or loses their credential, they are not suitable for this purpose. It is not possible to fully disable client certificate use within a cluster as it is used for component to component authentication.", + "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Kubernetes client certificate authentication does not allow for this due to a lack of support for certificate revocation.", + "ImpactStatement": "External mechanisms for authentication generally require additional software to be deployed.", + "RemediationProcedure": "Alternative mechanisms provided by Kubernetes such as the use of OIDC should be implemented in place of client certificates.", + "AuditProcedure": "Review user access to the cluster and ensure that users are not making use of Kubernetes client certificate authentication.", + "AdditionalInformation": "The lack of certificate revocation was flagged up as a high risk issue in the recent Kubernetes security audit. Without this feature, client certificate authentication is not suitable for end users.", + "References": "", + "DefaultValue": "Client certificate authentication is enabled by default." + } + ] + }, + { + "Id": "3.1.2", + "Description": "Service account token authentication should not be used for users", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.1 Authentication and Authorization", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides service account tokens which are intended for use by workloads running in the Kubernetes cluster, for authentication to the API server. These tokens are not designed for use by end-users and do not provide for features such as revocation or expiry, making them insecure. A newer version of the feature (Bound service account token volumes) does introduce expiry but still does not allow for specific revocation.", + "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Service account token authentication does not allow for this due to the use of JWT tokens as an underlying technology.", + "ImpactStatement": "External mechanisms for authentication generally require additional software to be deployed.", + "RemediationProcedure": "Alternative mechanisms provided by Kubernetes such as the use of OIDC should be implemented in place of service account tokens.", + "AuditProcedure": "Review user access to the cluster and ensure that users are not making use of service account token authentication.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Service account token authentication is enabled by default." + } + ] + }, + { + "Id": "3.1.3", + "Description": "Bootstrap token authentication should not be used for users", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.1 Authentication and Authorization", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides bootstrap tokens which are intended for use by new nodes joining the cluster These tokens are not designed for use by end-users they are specifically designed for the purpose of bootstrapping new nodes and not for general authentication", + "RationaleStatement": "Bootstrap tokens are not intended for use as a general authentication mechanism and impose constraints on user and group naming that do not facilitate good RBAC design. They also cannot be used with MFA resulting in a weak authentication mechanism being available.", + "ImpactStatement": "External mechanisms for authentication generally require additional software to be deployed.", + "RemediationProcedure": "Alternative mechanisms provided by Kubernetes such as the use of OIDC should be implemented in place of bootstrap tokens.", + "AuditProcedure": "Review user access to the cluster and ensure that users are not making use of bootstrap token authentication.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Bootstrap token authentication is not enabled by default and requires an API server parameter to be set." + } + ] + }, + { + "Id": "3.2.1", + "Description": "Ensure that a minimal audit policy is created", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.2 Logging", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes can audit the details of requests made to the API server. The `--audit-policy-file` flag must be set for this logging to be enabled.", + "RationaleStatement": "Logging is an important detective control for all systems, to detect potential unauthorised access.", + "ImpactStatement": "Audit logs will be created on the master nodes, which will consume disk space. Care should be taken to avoid generating too large volumes of log information as this could impact the available of the cluster nodes.", + "RemediationProcedure": "Create an audit policy file for your cluster.", + "AuditProcedure": "Run the following command on one of the cluster master nodes: ``` ps -ef | grep kube-apiserver ``` Verify that the `--audit-policy-file` is set. Review the contents of the file specified and ensure that it contains a valid audit policy.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/debug-application-cluster/audit/", + "DefaultValue": "Unless the `--audit-policy-file` flag is specified, no auditing will be carried out." + } + ] + }, + { + "Id": "3.2.2", + "Description": "Ensure that the audit policy covers key security concerns", + "Checks": [], + "Attributes": [ + { + "Section": "3 Control Plane Configuration", + "SubSection": "3.2 Logging", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensure that the audit policy created for the cluster covers key security concerns.", + "RationaleStatement": "Security audit logs should cover access and modification of key resources in the cluster, to enable them to form an effective part of a security environment.", + "ImpactStatement": "Increasing audit logging will consume resources on the nodes or other log destination.", + "RemediationProcedure": "Consider modification of the audit policy in use on the cluster to include these items, at a minimum.", + "AuditProcedure": "Review the audit policy provided for the cluster and ensure that it covers at least the following areas :- - Access to Secrets managed by the cluster. Care should be taken to only log Metadata for requests to Secrets, ConfigMaps, and TokenReviews, in order to avoid the risk of logging sensitive data. - Modification of `pod` and `deployment` objects. - Use of `pods/exec`, `pods/portforward`, `pods/proxy` and `services/proxy`. - Use of the `CertificateSigningRequest` API which allows for creation of new credentials. - Use of the Token sub-resource of `ServiceAccount` objects which allows for creation of new credentials. For most requests, minimally logging at the Metadata level is recommended (the most basic level of logging). Exceptions should be created in the audit logging policy to ensure that system operations (e.g. the Kubelet creating new credentials) are not logged, to reduce operational load and the risk of false positives.", + "AdditionalInformation": "", + "References": "https://github.com/k8scop/k8s-security-dashboard/blob/master/configs/kubernetes/adv-audit.yaml:https://kubernetes.io/docs/tasks/debug-application-cluster/audit/#audit-policy:https://github.com/kubernetes/kubernetes/blob/master/cluster/gce/gci/configure-helper.sh#L735", + "DefaultValue": "By default Kubernetes clusters do not log audit information." + } + ] + }, + { + "Id": "4.1.1", + "Description": "Ensure that the kubelet service file permissions are set to 600 or more restrictive", + "Checks": [ + "kubelet_service_file_permissions" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet` service file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chmod 600 /etc/systemd/system/kubelet.service.d/kubeadm.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet service config file. Please set $kubelet_service_config= based on the file location on your system for example: ``` export kubelet_service_config=/etc/systemd/system/kubelet.service.d/kubeadm.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a /etc/systemd/system/kubelet.service.d/10-kubeadm.conf ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/#44-joining-your-nodes:https://kubernetes.io/docs/admin/kubeadm/#kubelet-drop-in", + "DefaultValue": "By default, the `kubelet` service file has permissions of `640`." + } + ] + }, + { + "Id": "4.1.2", + "Description": "Ensure that the kubelet service file ownership is set to root:root", + "Checks": [ + "kubelet_service_file_ownership_root" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet` service file ownership is set to `root:root`.", + "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chown root:root /etc/systemd/system/kubelet.service.d/kubeadm.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet service config file. Please set $kubelet_service_config= based on the file location on your system for example: ``` export kubelet_service_config=/etc/systemd/system/kubelet.service.d/kubeadm.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G /etc/systemd/system/kubelet.service.d/10-kubeadm.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/#44-joining-your-nodes:https://kubernetes.io/docs/admin/kubeadm/#kubelet-drop-in", + "DefaultValue": "By default, `kubelet` service file ownership is set to `root:root`." + } + ] + }, + { + "Id": "4.1.3", + "Description": "If proxy kubeconfig file exists ensure permissions are set to 600 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "If `kube-proxy` is running, and if it is using a file-based kubeconfig file, ensure that the proxy kubeconfig file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `kube-proxy` kubeconfig file controls various parameters of the `kube-proxy` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system. It is possible to run `kube-proxy` with the kubeconfig parameters configured as a Kubernetes ConfigMap instead of a file. In this case, there is no proxy kubeconfig file.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chmod 600 ```", + "AuditProcedure": "Find the kubeconfig file being used by `kube-proxy` by running the following command: ``` ps -ef | grep kube-proxy ``` If `kube-proxy` is running, get the kubeconfig file location from the `--kubeconfig` parameter. To perform the audit: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a ``` Verify that a file is specified and it exists with permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-proxy/", + "DefaultValue": "By default, proxy file has permissions of `640`." + } + ] + }, + { + "Id": "4.1.4", + "Description": "If proxy kubeconfig file exists ensure ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "If `kube-proxy` is running, ensure that the file ownership of its kubeconfig file is set to `root:root`.", + "RationaleStatement": "The kubeconfig file for `kube-proxy` controls various parameters for the `kube-proxy` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chown root:root ```", + "AuditProcedure": "Find the kubeconfig file being used by `kube-proxy` by running the following command: ``` ps -ef | grep kube-proxy ``` If `kube-proxy` is running, get the kubeconfig file location from the `--kubeconfig` parameter. To perform the audit: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kube-proxy/", + "DefaultValue": "By default, `proxy` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "4.1.5", + "Description": "Ensure that the --kubeconfig kubelet.conf file permissions are set to 600 or more restrictive", + "Checks": [ + "kubelet_conf_file_permissions" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet.conf` file has permissions of `600` or more restrictive.", + "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chmod 600 /etc/kubernetes/kubelet.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config file. Please set $kubelet_config= based on the file location on your system for example: ``` export kubelet_config=/etc/kubernetes/kubelet.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a /etc/kubernetes/kubelet.conf ``` Verify that the ownership is set to `root:root`.Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/", + "DefaultValue": "By default, `kubelet.conf` file has permissions of `600`." + } + ] + }, + { + "Id": "4.1.6", + "Description": "Ensure that the --kubeconfig kubelet.conf file ownership is set to root:root", + "Checks": [ + "kubelet_conf_file_ownership" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that the `kubelet.conf` file ownership is set to `root:root`.", + "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the below command (based on the file location on your system) on the each worker node. For example, ``` chown root:root /etc/kubernetes/kubelet.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config file. Please set $kubelet_config= based on the file location on your system for example: ``` export kubelet_config=/etc/kubernetes/kubelet.conf ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G /etc/kubernetes/kubelet.conf ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/", + "DefaultValue": "By default, `kubelet.conf` file ownership is set to `root:root`." + } + ] + }, + { + "Id": "4.1.7", + "Description": "Ensure that the certificate authorities file permissions are set to 644 or more restrictive", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the certificate authorities file has permissions of `644` or more restrictive.", + "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command to modify the file permissions of the `--client-ca-file` ``` chmod 644 ```", + "AuditProcedure": "Run the following command: ``` ps -ef | grep kubelet ``` Find the file specified by the `--client-ca-file` argument. Run the following command: ``` stat -c %a ``` Verify that the permissions are `644` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authentication/#x509-client-certs", + "DefaultValue": "By default no `--client-ca-file` is specified." + } + ] + }, + { + "Id": "4.1.8", + "Description": "Ensure that the client certificate authorities file ownership is set to root:root", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the certificate authorities file ownership is set to `root:root`.", + "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command to modify the ownership of the `--client-ca-file`. ``` chown root:root ```", + "AuditProcedure": "Run the following command: ``` ps -ef | grep kubelet ``` Find the file specified by the `--client-ca-file` argument. Run the following command: ``` stat -c %U:%G ``` Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authentication/#x509-client-certs", + "DefaultValue": "By default no `--client-ca-file` is specified." + } + ] + }, + { + "Id": "4.1.9", + "Description": "If the kubelet config.yaml configuration file is being used validate permissions set to 600 or more restrictive", + "Checks": [ + "kubelet_config_yaml_permissions" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file has permissions of 600 or more restrictive.", + "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command (using the config file location identified in the Audit step) ``` chmod 600 /var/lib/kubelet/config.yaml ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config yaml file. Please set $kubelet_config_yaml= based on the file location on your system for example: ``` export kubelet_config_yaml=/var/lib/kubelet/config.yaml ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %a /var/lib/kubelet/config.yaml ``` Verify that the permissions are `600` or more restrictive.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "DefaultValue": "By default, the /var/lib/kubelet/config.yaml file as set up by `kubeadm` has permissions of 600." + } + ] + }, + { + "Id": "4.1.10", + "Description": "If the kubelet config.yaml configuration file is being used validate file ownership is set to root:root", + "Checks": [ + "kubelet_config_yaml_ownership" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.1 Worker Node Configuration Files", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file is owned by root:root.", + "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be owned by root:root.", + "ImpactStatement": "None", + "RemediationProcedure": "Run the following command (using the config file location identied in the Audit step) ``` chown root:root /etc/kubernetes/kubelet.conf ```", + "AuditProcedure": "Automated AAC auditing has been modified to allow CIS-CAT to input a variable for the / of the kubelet config yaml file. Please set $kubelet_config_yaml= based on the file location on your system for example: ``` export kubelet_config_yaml=/var/lib/kubelet/config.yaml ``` To perform the audit manually: Run the below command (based on the file location on your system) on the each worker node. For example, ``` stat -c %U:%G /var/lib/kubelet/config.yaml ```Verify that the ownership is set to `root:root`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "DefaultValue": "By default, `/var/lib/kubelet/config.yaml` file as set up by `kubeadm` is owned by `root:root`." + } + ] + }, + { + "Id": "4.2.1", + "Description": "Ensure that the --anonymous-auth argument is set to false", + "Checks": [ + "kubelet_disable_anonymous_auth" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Disable anonymous requests to the Kubelet server.", + "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the Kubelet server. You should rely on authentication to authorize access and disallow anonymous requests.", + "ImpactStatement": "Anonymous requests will be rejected.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `authentication: anonymous: enabled` to `false`. If using executable arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_SYSTEM_PODS_ARGS` variable. ``` --anonymous-auth=false ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "If using a Kubelet configuration file, check that there is an entry for `authentication: anonymous: enabled` set to `false`. Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--anonymous-auth` argument is set to `false`. This executable argument may be omitted, provided there is a corresponding entry set to `false` in the Kubelet config file.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/#kubelet-authentication", + "DefaultValue": "By default, anonymous access is enabled." + } + ] + }, + { + "Id": "4.2.2", + "Description": "Ensure that the --authorization-mode argument is not set to AlwaysAllow", + "Checks": [ + "kubelet_authorization_mode" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Do not allow all requests. Enable explicit authorization.", + "RationaleStatement": "Kubelets, by default, allow all authenticated requests (even anonymous ones) without needing explicit authorization checks from the apiserver. You should restrict this behavior and only allow explicitly authorized requests.", + "ImpactStatement": "Unauthorized requests will be denied.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `authorization: mode` to `Webhook`. If using executable arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_AUTHZ_ARGS` variable. ``` --authorization-mode=Webhook ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` If the `--authorization-mode` argument is present check that it is not set to `AlwaysAllow`. If it is not present check that there is a Kubelet config file specified by `--config`, and that file sets `authorization: mode` to something other than `AlwaysAllow`. It is also possible to review the running configuration of a Kubelet via the `/configz` endpoint on the Kubelet API port (typically `10250/TCP`). Accessing these with appropriate credentials will provide details of the Kubelet's configuration.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/admin/kubelet-authentication-authorization/#kubelet-authentication", + "DefaultValue": "By default, `--authorization-mode` argument is set to `AlwaysAllow`." + } + ] + }, + { + "Id": "4.2.3", + "Description": "Ensure that the --client-ca-file argument is set as appropriate", + "Checks": [ + "kubelet_client_ca_file_set" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable Kubelet authentication using certificates.", + "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks. Enabling Kubelet certificate authentication ensures that the apiserver could authenticate the Kubelet before submitting any requests.", + "ImpactStatement": "You require TLS to be configured on apiserver as well as kubelets.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `authentication: x509: clientCAFile` to the location of the client CA file. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_AUTHZ_ARGS` variable. ``` --client-ca-file= ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--client-ca-file` argument exists and is set to the location of the client certificate authority file. If the `--client-ca-file` argument is not present, check that there is a Kubelet config file specified by `--config`, and that the file sets `authentication: x509: clientCAFile` to the location of the client certificate authority file.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet-authentication-authorization/", + "DefaultValue": "By default, `--client-ca-file` argument is not set." + } + ] + }, + { + "Id": "4.2.4", + "Description": "Verify that if defined, readOnlyPort is set to 0", + "Checks": [ + "kubelet_disable_read_only_port" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Disable the read-only port.", + "RationaleStatement": "The Kubelet process provides a read-only API in addition to the main Kubelet API. Unauthenticated access is provided to this read-only API which could possibly retrieve potentially sensitive information about the cluster.", + "ImpactStatement": "Removal of the read-only port will require that any service which made use of it will need to be re-configured to use the main Kubelet API.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `readOnlyPort` to `0`. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_SYSTEM_PODS_ARGS` variable. ``` --read-only-port=0 ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--read-only-port` argument exists and is set to `0`. If the `--read-only-port` argument is not present, check that there is a Kubelet config file specified by `--config`. Check that if there is a `readOnlyPort` entry in the file, it is set to `0`.", + "AdditionalInformation": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/blob/6cedc0853faa118df0ba3d41b48b993422ad3df6/staging/src/k8s.io/kubelet/config/v1beta1/types.go#L142", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.5", + "Description": "Ensure that the --streaming-connection-idle-timeout argument is not set to 0", + "Checks": [ + "kubelet_streaming_connection_timeout" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not disable timeouts on streaming connections.", + "RationaleStatement": "Setting idle timeouts ensures that you are protected against Denial-of-Service attacks, inactive connections and running out of ephemeral ports. **Note:** By default, `--streaming-connection-idle-timeout` is set to 4 hours which might be too high for your environment. Setting this as appropriate would additionally ensure that such streaming connections are timed out after serving legitimate use cases.", + "ImpactStatement": "Long-lived connections could be interrupted.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `streamingConnectionIdleTimeout` to a value other than 0. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_SYSTEM_PODS_ARGS` variable. ``` --streaming-connection-idle-timeout=5m ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `--streaming-connection-idle-timeout` argument is not set to `0`. If the argument is not present, and there is a Kubelet config file specified by `--config`, check that it does not set `streamingConnectionIdleTimeout` to 0.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/pull/18552", + "DefaultValue": "By default, `--streaming-connection-idle-timeout` is set to 4 hours." + } + ] + }, + { + "Id": "4.2.6", + "Description": "Ensure that the --make-iptables-util-chains argument is set to true", + "Checks": [ + "kubelet_manage_iptables" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Allow Kubelet to manage iptables.", + "RationaleStatement": "Kubelets can automatically manage the required changes to iptables based on how you choose your networking options for the pods. It is recommended to let kubelets manage the changes to iptables. This ensures that the iptables configuration remains in sync with pods networking configuration. Manually configuring iptables with dynamic pod network configuration changes might hamper the communication between pods/containers and to the outside world. You might have iptables rules too restrictive or too open.", + "ImpactStatement": "Kubelet would manage the iptables on the system and keep it in sync. If you are using any other iptables management solution, then there might be some conflicts.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `makeIPTablesUtilChains: true`. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and remove the `--make-iptables-util-chains` argument from the `KUBELET_SYSTEM_PODS_ARGS` variable. Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that if the `--make-iptables-util-chains` argument exists then it is set to `true`. If the `--make-iptables-util-chains` argument does not exist, and there is a Kubelet config file specified by `--config`, verify that the file does not set `makeIPTablesUtilChains` to `false`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/", + "DefaultValue": "By default, `--make-iptables-util-chains` argument is set to `true`." + } + ] + }, + { + "Id": "4.2.7", + "Description": "Ensure that the --hostname-override argument is not set", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not override node hostnames.", + "RationaleStatement": "Overriding hostnames could potentially break TLS setup between the kubelet and the apiserver. Additionally, with overridden hostnames, it becomes increasingly difficult to associate logs with a particular node and process them for security analytics. Hence, you should setup your kubelet nodes with resolvable FQDNs and avoid overriding the hostnames with IPs.", + "ImpactStatement": "Some cloud providers may require this flag to ensure that hostname matches names issued by the cloud provider. In these environments, this recommendation should not apply.", + "RemediationProcedure": "Edit the kubelet service file `/etc/systemd/system/kubelet.service.d/10-kubeadm.conf` on each worker node and remove the `--hostname-override` argument from the `KUBELET_SYSTEM_PODS_ARGS` variable. Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that `--hostname-override` argument does not exist. **Note** This setting is not configurable via the Kubelet config file.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/issues/22063", + "DefaultValue": "By default, `--hostname-override` argument is not set." + } + ] + }, + { + "Id": "4.2.8", + "Description": "Ensure that the eventRecordQPS argument is set to a level which ensures appropriate event capture", + "Checks": [ + "kubelet_event_record_qps" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Security relevant information should be captured. The eventRecordQPS on the Kubelet configuration can be used to limit the rate at which events are gathered and sets the maximum event creations per second. Setting this too low could result in relevant events not being logged, however the unlimited setting of `0` could result in a denial of service on the kubelet.", + "RationaleStatement": "It is important to capture all events and not restrict event creation. Events are an important source of security information and analytics that ensure that your environment is consistently monitored using the event data.", + "ImpactStatement": "Setting this parameter to `0` could result in a denial of service condition due to excessive events being created. The cluster's event processing and storage systems should be scaled to handle expected event loads.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `eventRecordQPS:` to an appropriate level. If using command line arguments, edit the kubelet service file `/etc/systemd/system/kubelet.service.d/10-kubeadm.conf` on each worker node and set the below parameter in `KUBELET_ARGS` variable. Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` sudo grep eventRecordQPS /etc/systemd/system/kubelet.service.d/10-kubeadm.conf ``` or If using command line arguments, kubelet service file is located /etc/systemd/system/kubelet.service.d/10-kubelet-args.conf ``` sudo grep eventRecordQPS /etc/systemd/system/kubelet.service.d/10-kubelet-args.conf ``` Review the value set for the argument and determine whether this has been set to an appropriate level for the cluster. If the argument does not exist, check that there is a Kubelet config file specified by `--config` and review the value in this location. If using command line arguments", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/kubelet/:https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/apis/kubeletconfig/v1beta1/types.go", + "DefaultValue": "By default, eventRecordQPS argument is set to `5`." + } + ] + }, + { + "Id": "4.2.9", + "Description": "Ensure that the --tls-cert-file and --tls-private-key-file arguments are set as appropriate", + "Checks": [ + "kubelet_tls_cert_and_key" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Setup TLS connection on the Kubelets.", + "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", + "ImpactStatement": "", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set tlsCertFile to the location of the certificate file to use to identify this Kubelet, and tlsPrivateKeyFile to the location of the corresponding private key file. If using command line arguments, edit the kubelet service file /etc/kubernetes/kubelet.conf on each worker node and set the below parameters in KUBELET_CERTIFICATE_ARGS variable. --tls-cert-file= --tls-private-key-file= Based on your system, restart the kubelet service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the --tls-cert-file and --tls-private-key-file arguments exist and they are set as appropriate. If these arguments are not present, check that there is a Kubelet config specified by --config and that it contains appropriate settings for tlsCertFile and tlsPrivateKeyFile.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "4.2.10", + "Description": "Ensure that the --rotate-certificates argument is not set to false", + "Checks": [ + "kubelet_rotate_certificates" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable kubelet client certificate rotation.", + "RationaleStatement": "The `--rotate-certificates` setting causes the kubelet to rotate its client certificates by creating new CSRs as its existing credentials expire. This automated periodic rotation ensures that the there is no downtime due to expired certificates and thus addressing availability in the CIA security triad. **Note:** This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself. **Note:** This feature also require the `RotateKubeletClientCertificate` feature gate to be enabled (which is the default since Kubernetes v1.7)", + "ImpactStatement": "None", + "RemediationProcedure": "If using a Kubelet config file, edit the file to add the line `rotateCertificates: true` or remove it altogether to use the default value. If using command line arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and remove `--rotate-certificates=false` argument from the `KUBELET_CERTIFICATE_ARGS` variable or set --rotate-certificates=true . Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that the `RotateKubeletServerCertificate` argument is not present, or is set to `true`. If the RotateKubeletServerCertificate argument is not present, verify that if there is a Kubelet config file specified by `--config`, that file does not contain `RotateKubeletServerCertificate: false`.", + "AdditionalInformation": "", + "References": "https://github.com/kubernetes/kubernetes/pull/41912:https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet-tls-bootstrapping/#kubelet-configuration:https://kubernetes.io/docs/imported/release/notes/:https://kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/", + "DefaultValue": "By default, kubelet client certificate rotation is enabled." + } + ] + }, + { + "Id": "4.2.11", + "Description": "Verify that the RotateKubeletServerCertificate argument is set to true", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enable kubelet server certificate rotation.", + "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", + "ImpactStatement": "None", + "RemediationProcedure": "Edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the below parameter in `KUBELET_CERTIFICATE_ARGS` variable. ``` --feature-gates=RotateKubeletServerCertificate=true ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "Ignore this check if serverTLSBootstrap is true in the kubelet config file or if the --rotate-server-certificates parameter is set on kubelet Run the following command on each node: ``` ps -ef | grep kubelet ``` Verify that `RotateKubeletServerCertificate` argument exists and is set to `true`.", + "AdditionalInformation": "", + "References": "https://github.com/kubernetes/kubernetes/pull/45059:https://kubernetes.io/docs/admin/kubelet-tls-bootstrapping/#kubelet-configuration", + "DefaultValue": "By default, kubelet server certificate rotation is enabled." + } + ] + }, + { + "Id": "4.2.12", + "Description": "Ensure that the Kubelet only makes use of Strong Cryptographic Ciphers", + "Checks": [ + "kubelet_strong_ciphers_only" + ], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Kubelet is configured to only use strong cryptographic ciphers.", + "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS ciphersuites including some that have security concerns, weakening the protection provided.", + "ImpactStatement": "Kubelet clients that cannot support modern cryptographic ciphers will not be able to make connections to the Kubelet API.", + "RemediationProcedure": "If using a Kubelet config file, edit the file to set `tlsCipherSuites:` to `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256` or to a subset of these values. If using executable arguments, edit the kubelet service file `/etc/kubernetes/kubelet.conf` on each worker node and set the `--tls-cipher-suites` parameter as follows, or to a subset of these values. ``` --tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256 ``` Based on your system, restart the `kubelet` service. For example: ``` systemctl daemon-reload systemctl restart kubelet.service ```", + "AuditProcedure": "The set of cryptographic ciphers currently considered secure is the following: - `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` - `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256` - `TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305` - `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384` - `TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305` - `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` - `TLS_RSA_WITH_AES_256_GCM_SHA384` - `TLS_RSA_WITH_AES_128_GCM_SHA256` Run the following command on each node: ``` ps -ef | grep kubelet ``` If the `--tls-cipher-suites` argument is present, ensure it only contains values included in this set. If it is not present check that there is a Kubelet config file specified by `--config`, and that file sets `tlsCipherSuites:` to only include values from this set.", + "AdditionalInformation": "The list chosen above should be fine for modern clients. It's essentially the list from the Mozilla Modern cipher option with the ciphersuites supporting CBC mode removed, as CBC has traditionally had a lot of issues", + "References": "", + "DefaultValue": "By default the Kubernetes API server supports a wide range of TLS ciphers" + } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } + ] + }, + { + "Id": "4.2.13", + "Description": "Ensure that a limit is set on pod PIDs", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Kubelet sets limits on the number of PIDs that can be created by pods running on the node.", + "RationaleStatement": "By default pods running in a cluster can consume any number of PIDs, potentially exhausting the resources available on the node. Setting an appropriate limit reduces the risk of a denial of service attack on cluster nodes.", + "ImpactStatement": "Setting this value will restrict the number of processes per pod. If this limit is lower than the number of PIDs required by a pod it will not operate.", + "RemediationProcedure": "Decide on an appropriate level for this parameter and set it, either via the `--pod-max-pids` command line parameter or the `PodPidsLimit` configuration file setting.", + "AuditProcedure": "Review the Kubelet's start-up parameters for the value of `--pod-max-pids`, and check the Kubelet configuration file for the `PodPidsLimit` . If neither of these values is set, then there is no limit in place.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/policy/pid-limiting/#pod-pid-limits", + "DefaultValue": "By default the number of PIDs is not limited." + } + ] + }, + { + "Id": "4.2.14", + "Description": "Ensure that the --seccomp-default parameter is set to true", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.2 Kubelet", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Ensure that the Kubelet enforces the use of the RuntimeDefault seccomp profile", + "RationaleStatement": "By default, Kubernetes disables the seccomp profile which ships with most container runtimes. Setting this parameter will ensure workloads running on the node are protected by the runtime's seccomp profile.", + "ImpactStatement": "Setting this will remove some rights from pods running on the node.", + "RemediationProcedure": "Set the parameter, either via the `--seccomp-default` command line parameter or the `seccompDefault` configuration file setting.", + "AuditProcedure": "Review the Kubelet's start-up parameters for the value of `--seccomp-default`, and check the Kubelet configuration file for the `seccompDefault` . If neither of these values is set, then the seccomp profile is not in use.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tutorials/security/seccomp/#enable-the-use-of-runtimedefault-as-the-default-seccomp-profile-for-all-workloads", + "DefaultValue": "By default the seccomp profile is not enabled." + } + ] + }, + { + "Id": "4.3.1", + "Description": "Ensure that the kube-proxy metrics service is bound to localhost", + "Checks": [], + "Attributes": [ + { + "Section": "4 Worker Nodes", + "SubSection": "4.3 kube-proxy", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not bind the kube-proxy metrics port to non-loopback addresses.", + "RationaleStatement": "kube-proxy has two APIs which provided access to information about the service and can be bound to network ports. The metrics API service includes endpoints (`/metrics` and `/configz`) which disclose information about the configuration and operation of kube-proxy. These endpoints should not be exposed to untrusted networks as they do not support encryption or authentication to restrict access to the data they provide.", + "ImpactStatement": "3rd party services which try to access metrics or configuration information related to kube-proxy will require access to the localhost interface of the node.", + "RemediationProcedure": "Modify or remove any values which bind the metrics service to a non-localhost address", + "AuditProcedure": "review the start-up flags provided to kube proxy Run the following command on each node: ``` ps -ef | grep -i kube-proxy ``` Ensure that the `--metrics-bind-address` parameter is not set to a value other than 127.0.0.1. From the output of this command gather the location specified in the `--config` parameter. Review any file stored at that location and ensure that it does not specify a value other than 127.0.0.1 for `metricsBindAddress`.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-proxy/", + "DefaultValue": "The default value is `127.0.0.1:10249`" + } + ] + }, + { + "Id": "5.1.1", + "Description": "Ensure that the cluster-admin role is only used where required", + "Checks": [ + "rbac_cluster_admin_usage" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The RBAC role `cluster-admin` provides wide-ranging powers over the environment and should be used only where and when needed.", + "RationaleStatement": "Kubernetes provides a set of default roles where RBAC is used. Some of these roles such as `cluster-admin` provide wide-ranging privileges which should only be applied where absolutely necessary. Roles such as `cluster-admin` allow super-user access to perform any action on any resource. When used in a `ClusterRoleBinding`, it gives full control over every resource in the cluster and in all namespaces. When used in a `RoleBinding`, it gives full control over every resource in the rolebinding's namespace, including the namespace itself.", + "ImpactStatement": "Care should be taken before removing any `clusterrolebindings` from the environment to ensure they were not required for operation of the cluster. Specifically, modifications should not be made to `clusterrolebindings` with the `system:` prefix as they are required for the operation of system components.", + "RemediationProcedure": "Identify all clusterrolebindings to the cluster-admin role. Check if they are used and if they need this role or if they could use a role with fewer privileges. Where possible, first bind users to a lower privileged role and then remove the clusterrolebinding to the cluster-admin role : ``` kubectl delete clusterrolebinding [name] ```", + "AuditProcedure": "Obtain a list of the principals who have access to the `cluster-admin` role by reviewing the `clusterrolebinding` output for each role binding that has access to the `cluster-admin` role. ``` kubectl get clusterrolebindings -o=custom-columns=NAME:.metadata.name,ROLE:.roleRef.name,SUBJECT:.subjects[*].name ``` Review each principal listed and ensure that `cluster-admin` privilege is required for it.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/authorization/rbac/#user-facing-roles", + "DefaultValue": "By default a single `clusterrolebinding` called `cluster-admin` is provided with the `system:masters` group as its principal." + } + ] + }, + { + "Id": "5.1.2", + "Description": "Minimize access to secrets", + "Checks": [ + "rbac_minimize_secret_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The Kubernetes API stores secrets, which may be service account tokens for the Kubernetes API or credentials used by workloads in the cluster. Access to these secrets should be restricted to the smallest possible group of users to reduce the risk of privilege escalation.", + "RationaleStatement": "Inappropriate access to secrets stored within the Kubernetes cluster can allow for an attacker to gain additional access to the Kubernetes cluster or external resources whose credentials are stored as secrets.", + "ImpactStatement": "Care should be taken not to remove access to secrets to system components which require this for their operation", + "RemediationProcedure": "Where possible, restrict access to secret objects in the cluster by removing get, list, and watch permissions.", + "AuditProcedure": "Review the users who have `get`, `list`, or `watch` access to `secrets` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default in a kubeadm cluster the following list of principals have `get` privileges on `secret` objects ``` CLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACE cluster-admin system:masters Group system:controller:clusterrole-aggregation-controller clusterrole-aggregation-controller ServiceAccount kube-system system:controller:expand-controller expand-controller ServiceAccount kube-system system:controller:generic-garbage-collector generic-garbage-collector ServiceAccount kube-system system:controller:namespace-controller namespace-controller ServiceAccount kube-system system:controller:persistent-volume-binder persistent-volume-binder ServiceAccount kube-system system:kube-controller-manager system:kube-controller-manager User ```" + } + ] + }, + { + "Id": "5.1.3", + "Description": "Minimize wildcard use in Roles and ClusterRoles", + "Checks": [ + "rbac_minimize_wildcard_use_roles" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Kubernetes Roles and ClusterRoles provide access to resources based on sets of objects and actions that can be taken on those objects. It is possible to set either of these to be the wildcard * which matches all items. Use of wildcards is not optimal from a security perspective as it may allow for inadvertent access to be granted when new resources are added to the Kubernetes API either as CRDs or in later versions of the product.", + "RationaleStatement": "The principle of least privilege recommends that users are provided only the access required for their role and nothing more. The use of wildcard rights grants is likely to provide excessive rights to the Kubernetes API.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible replace any use of wildcards in ClusterRoles and Roles with specific objects or actions.", + "AuditProcedure": "Retrieve the roles defined across each namespaces in the cluster and review for wildcards ``` kubectl get roles --all-namespaces -o yaml ``` Retrieve the cluster roles defined in the cluster and review for wildcards ``` kubectl get clusterroles -o yaml ```", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.4", + "Description": "Minimize access to create pods", + "Checks": [ + "rbac_minimize_pod_creation_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The ability to create pods in a namespace can provide a number of opportunities for privilege escalation, such as assigning privileged service accounts to these pods or mounting hostPaths with access to sensitive data (unless Pod Security Policies are implemented to restrict this access) As such, access to create new pods should be restricted to the smallest possible group of users.", + "RationaleStatement": "The ability to create pods in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", + "ImpactStatement": "Care should be taken not to remove access to pods to system components which require this for their operation", + "RemediationProcedure": "Where possible, remove `create` access to `pod` objects in the cluster.", + "AuditProcedure": "Review the users who have create access to pod objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default in a kubeadm cluster the following list of principals have `create` privileges on `pod` objects ``` CLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACE cluster-admin system:masters Group system:controller:clusterrole-aggregation-controller clusterrole-aggregation-controller ServiceAccount kube-system system:controller:daemon-set-controller daemon-set-controller ServiceAccount kube-system system:controller:job-controller job-controller ServiceAccount kube-system system:controller:persistent-volume-binder persistent-volume-binder ServiceAccount kube-system system:controller:replicaset-controller replicaset-controller ServiceAccount kube-system system:controller:replication-controller replication-controller ServiceAccount kube-system system:controller:statefulset-controller statefulset-controller ServiceAccount kube-system ```" + } + ] + }, + { + "Id": "5.1.5", + "Description": "Ensure that default service accounts are not actively used.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The `default` service account should not be used to ensure that rights granted to applications can be more easily audited and reviewed.", + "RationaleStatement": "Kubernetes provides a default service account which is used by cluster workloads where no specific service account is assigned to the pod. Where access to the Kubernetes API from a pod is required, a specific service account should be created for that pod, and rights granted to that service account. The default service account should be configured to ensure that it does not automatically provide a service account token, and it must not have any non-default role bindings or custom role assignments", + "ImpactStatement": "All workloads which require access to the Kubernetes API will require an explicit service account to be created.", + "RemediationProcedure": "Create explicit service accounts wherever a Kubernetes workload requires specific access to the Kubernetes API server. Modify the configuration of each default service account to include this value ``` automountServiceAccountToken: false ```", + "AuditProcedure": "For each namespace in the cluster, review the rights assigned to the default service account and ensure that it has no roles or cluster roles bound to it apart from the defaults. Additionally ensure that the `automountServiceAccountToken: false` setting is in place for each default service account.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + "DefaultValue": "By default the `default` service account allows for its service account token to be mounted in pods in its namespace." + } + ] + }, + { + "Id": "5.1.6", + "Description": "Ensure that Service Account Tokens are only mounted where necessary", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Service accounts tokens should not be mounted in pods except where the workload running in the pod explicitly needs to communicate with the API server", + "RationaleStatement": "Mounting service account tokens inside pods can provide an avenue for privilege escalation attacks where an attacker is able to compromise a single pod in the cluster. Avoiding mounting these tokens removes this attack avenue.", + "ImpactStatement": "Pods mounted without service account tokens will not be able to communicate with the API server, except where the resource is available to unauthenticated principals.", + "RemediationProcedure": "Modify the definition of pods and service accounts which do not need to mount service account tokens to disable it.", + "AuditProcedure": "Review pod and service account objects in the cluster and ensure that the option below is set, unless the resource explicitly requires this access. ``` automountServiceAccountToken: false ```", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + "DefaultValue": "By default, all pods get a service account token mounted in them." + } + ] + }, + { + "Id": "5.1.7", + "Description": "Avoid use of system:masters group", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The special group `system:masters` should not be used to grant permissions to any user or service account, except where strictly necessary (e.g. bootstrapping access prior to RBAC being fully available)", + "RationaleStatement": "The `system:masters` group has unrestricted access to the Kubernetes API hard-coded into the API server source code. An authenticated user who is a member of this group cannot have their access reduced, even if all bindings and cluster role bindings which mention it, are removed. When combined with client certificate authentication, use of this group can allow for irrevocable cluster-admin level credentials to exist for a cluster.", + "ImpactStatement": "Once the RBAC system is operational in a cluster `system:masters` should not be specifically required, as ordinary bindings from principals to the `cluster-admin` cluster role can be made where unrestricted access is required.", + "RemediationProcedure": "Remove the `system:masters` group from all users in the cluster.", + "AuditProcedure": "Review a list of all credentials which have access to the cluster and ensure that the group `system:masters` is not used.", + "AdditionalInformation": "", + "References": "https://github.com/kubernetes/kubernetes/blob/master/pkg/registry/rbac/escalation_check.go#L38", + "DefaultValue": "By default some clusters will create a break glass client certificate which is a member of this group. Access to this client certificate should be carefully controlled and it should not be used for general cluster operations." + } + ] + }, + { + "Id": "5.1.8", + "Description": "Limit use of the Bind, Impersonate and Escalate permissions in the Kubernetes cluster", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Cluster roles and roles with the impersonate, bind or escalate permissions should not be granted unless strictly required. Each of these permissions allow a particular subject to escalate their privileges beyond those explicitly granted by cluster administrators", + "RationaleStatement": "The impersonate privilege allows a subject to impersonate other users gaining their rights to the cluster. The bind privilege allows the subject to add a binding to a cluster role or role which escalates their effective permissions in the cluster. The escalate privilege allows a subject to modify cluster roles to which they are bound, increasing their rights to that level. Each of these permissions has the potential to allow for privilege escalation to cluster-admin level.", + "ImpactStatement": "There are some cases where these permissions are required for cluster service operation, and care should be taken before removing these permissions from system service accounts.", + "RemediationProcedure": "Where possible, remove the impersonate, bind, and escalate rights from subjects.", + "AuditProcedure": "Review the users who have access to cluster roles or roles which provide the impersonate, bind, or escalate privileges.", + "AdditionalInformation": "", + "References": "https://raesene.github.io/blog/2020/12/12/Escalating_Away/:https://raesene.github.io/blog/2021/01/16/Getting-Into-A-Bind-with-Kubernetes/", + "DefaultValue": "In a default kubeadm cluster, the system:masters group and clusterrole-aggregation-controller service account have access to the escalate privilege. The system:masters group also has access to bind and impersonate." + } + ] + }, + { + "Id": "5.1.9", + "Description": "Minimize access to create persistent volumes", + "Checks": [ + "rbac_minimize_pv_creation_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The ability to create persistent volumes in a cluster can provide an opportunity for privilege escalation, via the creation of `hostPath` volumes. As persistent volumes are not covered by Pod Security Admission, a user with access to create persistent volumes may be able to get access to sensitive files from the underlying host even where restrictive Pod Security Admission policies are in place.", + "RationaleStatement": "The ability to create persistent volumes in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove `create` access to `PersistentVolume` objects in the cluster.", + "AuditProcedure": "Review the users who have create access to `PersistentVolume` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#persistent-volume-creation", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.10", + "Description": "Minimize access to the proxy sub-resource of nodes", + "Checks": [ + "rbac_minimize_node_proxy_subresource_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with access to the `Proxy` sub-resource of `Node` objects automatically have permissions to use the kubelet API, which may allow for privilege escalation or bypass cluster security controls such as audit logs. The kubelet provides an API which includes rights to execute commands in any container running on the node. Access to this API is covered by permissions to the main Kubernetes API via the `node` object. The proxy sub-resource specifically allows wide ranging access to the kubelet API. Direct access to the kubelet API bypasses controls like audit logging (there is no audit log of kubelet API access) and admission control.", + "RationaleStatement": "The ability to use the `proxy` sub-resource of `node` objects opens up possibilities for privilege escalation and should be restricted, where possible.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `proxy` sub-resource of `node` objects.", + "AuditProcedure": "Review the users who have access to the `proxy` sub-resource of `node` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#access-to-proxy-subresource-of-nodes:https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.11", + "Description": "Minimize access to the approval sub-resource of certificatesigningrequests objects", + "Checks": [ + "rbac_minimize_csr_approval_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with access to the update the `approval` sub-resource of `CertificateSigningRequests` objects can approve new client certificates for the Kubernetes API effectively allowing them to create new high-privileged user accounts. This can allow for privilege escalation to full cluster administrator, depending on users configured in the cluster", + "RationaleStatement": "The ability to update certificate signing requests should be limited.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `approval` sub-resource of `CertificateSigningRequests` objects.", + "AuditProcedure": "Review the users who have access to update the `approval` sub-resource of `CertificateSigningRequests` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#csrs-and-certificate-issuing", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.12", + "Description": "Minimize access to webhook configuration objects", + "Checks": [ + "rbac_minimize_webhook_config_access" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with rights to create/modify/delete `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` can control webhooks that can read any object admitted to the cluster, and in the case of mutating webhooks, also mutate admitted objects. This could allow for privilege escalation or disruption of the operation of the cluster.", + "RationaleStatement": "The ability to manage webhook configuration should be limited", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` objects", + "AuditProcedure": "Review the users who have access to `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#control-admission-webhooks", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.1.13", + "Description": "Minimize access to the service account token creation", + "Checks": [ + "rbac_minimize_service_account_token_creation" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.1 RBAC and Service Accounts", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Users with rights to create new service account tokens at a cluster level, can create long-lived privileged credentials in the cluster. This could allow for privilege escalation and persistent access to the cluster, even if the users account has been revoked.", + "RationaleStatement": "The ability to create service account tokens should be limited.", + "ImpactStatement": "", + "RemediationProcedure": "Where possible, remove access to the `token` sub-resource of `serviceaccount` objects.", + "AuditProcedure": "Review the users who have access to create the `token` sub-resource of `serviceaccount` objects in the Kubernetes API.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#token-request", + "DefaultValue": "" + } + ] + }, + { + "Id": "5.2.1", + "Description": "Ensure that the cluster has at least one active policy control mechanism in place", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Every Kubernetes cluster should have at least one policy control mechanism in place to enforce the other requirements in this section. This could be the in-built Pod Security Admission controller, or a third party policy control system.", + "RationaleStatement": "Without an active policy control mechanism, it is not possible to limit the use of containers with access to underlying cluster nodes, via mechanisms like privileged containers, or the use of hostPath volume mounts.", + "ImpactStatement": "Where policy control systems are in place, there is a risk that workloads required for the operation of the cluster may be stopped from running. Care is required when implementing admission control policies to ensure that this does not occur.", + "RemediationProcedure": "Ensure that either Pod Security Admission or an external policy control system is in place for every namespace which contains user workloads.", + "AuditProcedure": "Review the workloads deployed to the cluster to understand if Pod Security Admission or external admission control systems are in place.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-admission", + "DefaultValue": "By default, Pod Security Admission is enabled but no policies are in place." + } + ] + }, + { + "Id": "5.2.2", + "Description": "Minimize the admission of privileged containers", + "Checks": [ + "core_minimize_privileged_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `securityContext.privileged` flag set to `true`.", + "RationaleStatement": "Privileged containers have access to all Linux Kernel capabilities and devices. A container running with full privileges can do almost everything that the host can do. This flag exists to allow special use-cases, like manipulating the network stack and accessing devices. There should be at least one admission control policy defined which does not permit privileged containers. If you need to run privileged containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.containers[].securityContext.privileged: true`, `spec.initContainers[].securityContext.privileged: true` and `spec.ephemeralContainers[].securityContext.privileged: true` will not be permitted.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of privileged containers.", + "AuditProcedure": "Run the following command: `kubectl get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@..securityContext}{end}'`It will produce an inventory of all the privileged use on the cluster, if any (please, refer to a sample below). Further grepping can be done to automate each specific violation detection. calico-kube-controllers-57b57c56f-jtmk4: {} << No Elevated Privileges calico-node-c4xv4: {} {privileged:true} {privileged:true} {privileged:true} {privileged:true} << Violates 5.2.2 dashboard-metrics-scraper-7bc864c59-2m2xw: {seccompProfile:{type:RuntimeDefault}} {allowPrivilegeEscalation:false,readOnlyRootFilesystem:true,runAsGroup:2001,runAsUser:1001}", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of privileged containers." + } + ] + }, + { + "Id": "5.2.3", + "Description": "Minimize the admission of containers wishing to share the host process ID namespace", + "Checks": [ + "core_minimize_hostPID_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `hostPID` flag set to true.", + "RationaleStatement": "A container running in the host's PID namespace can inspect processes running outside the container. If the container also has access to ptrace capabilities this can be used to escalate privileges outside of the container. There should be at least one admission control policy defined which does not permit containers to share the host PID namespace. If you need to run containers which require hostPID, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.hostPID: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Configure the Admission Controller to restrict the admission of `hostPID` containers.", + "AuditProcedure": "Fetch hostPID from each pod with `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@.spec.hostPID}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostPID` containers." + } + ] + }, + { + "Id": "5.2.4", + "Description": "Minimize the admission of containers wishing to share the host IPC namespace", + "Checks": [ + "core_minimize_hostIPC_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `hostIPC` flag set to true.", + "RationaleStatement": "A container running in the host's IPC namespace can use IPC to interact with processes outside the container. There should be at least one admission control policy defined which does not permit containers to share the host IPC namespace. If you need to run containers which require hostIPC, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.hostIPC: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of `hostIPC` containers.", + "AuditProcedure": "Fetch hostIPC from each pod with `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@.spec.hostIPC}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostIPC` containers." + } + ] + }, + { + "Id": "5.2.5", + "Description": "Minimize the admission of containers wishing to share the host network namespace", + "Checks": [ + "core_minimize_hostNetwork_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `hostNetwork` flag set to true.", + "RationaleStatement": "A container running in the host's network namespace could access the local loopback device, and could access network traffic to and from other pods. There should be at least one admission control policy defined which does not permit containers to share the host network namespace. If you need to run containers which require access to the host's network namespaces, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `spec.hostNetwork: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of `hostNetwork` containers.", + "AuditProcedure": "Fetch hostNetwork from each pod with `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@.spec.hostNetwork}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostNetwork` containers." + } + ] + }, + { + "Id": "5.2.6", + "Description": "Minimize the admission of containers with allowPrivilegeEscalation", + "Checks": [ + "core_minimize_allowPrivilegeEscalation_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run with the `allowPrivilegeEscalation` flag set to true. Allowing this right can lead to a process running a container getting more rights than it started with. It's important to note that these rights are still constrained by the overall container sandbox, and this setting does not relate to the use of privileged containers.", + "RationaleStatement": "A container running with the `allowPrivilegeEscalation` flag set to `true` may have processes that can gain more privileges than their parent. There should be at least one admission control policy defined which does not permit containers to allow privilege escalation. The option exists (and is defaulted to true) to permit setuid binaries to run. If you have need to run containers which use setuid binaries or require privilege escalation, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `securityContext: allowPrivilegeEscalation: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers with `securityContext: allowPrivilegeEscalation: true`", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of containers which allow privilege escalation. To fetch a list of pods which `allowPrivilegeEscalation` run this command: `get pods -A -o=jsonpath='{range .items[*]}{@.metadata.name}: {@..securityContext}{end}'`", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on contained process ability to escalate privileges, within the context of the container." + } + ] + }, + { + "Id": "5.2.7", + "Description": "Minimize the admission of root containers", + "Checks": [ + "core_minimize_root_containers_admission" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers to be run as the root user.", + "RationaleStatement": "Containers may run as any Linux user. Containers which run as the root user, whilst constrained by Container Runtime security features still have a escalated likelihood of container breakout. Ideally, all containers should run as a defined non-UID 0 user. There should be at least one admission control policy defined which does not permit root containers. If you need to run root containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods with containers which run as the root user will not be permitted.", + "RemediationProcedure": "Create a policy for each namespace in the cluster, ensuring that either `MustRunAsNonRoot` or `MustRunAs` with the range of UIDs not including 0, is set.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy restricts the use of root containers by setting `MustRunAsNonRoot` or `MustRunAs` with the range of UIDs not including 0.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the use of root containers and if a User is not specified in the image, the container will run as root." + } + ] + }, + { + "Id": "5.2.8", + "Description": "Minimize the admission of containers with the NET_RAW capability", + "Checks": [ + "core_minimize_net_raw_capability_admission" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers with the potentially dangerous NET_RAW capability.", + "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. By default this can include potentially dangerous capabilities. With Docker as the container runtime the NET_RAW capability is enabled which may be misused by malicious containers. Ideally, all containers should drop this capability. There should be at least one admission control policy defined which does not permit containers with the NET_RAW capability. If you need to run containers with this capability, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods with containers which run with the NET_RAW capability will not be permitted.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers with the `NET_RAW` capability.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that at least one policy disallows the admission of containers with the `NET_RAW` capability.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/:https://www.nccgroup.trust/uk/our-research/abusing-privileged-and-unprivileged-linux-containers/", + "DefaultValue": "By default, there are no restrictions on the creation of containers with the `NET_RAW` capability." + } + ] + }, + { + "Id": "5.2.9", + "Description": "Minimize the admission of containers with capabilities assigned", + "Checks": [ + "core_minimize_containers_capabilities_assigned" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers with capabilities", + "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities are parts of the rights generally granted on a Linux system to the root user. In many cases applications running in containers do not require any capabilities to operate, so from the perspective of the principal of least privilege use of capabilities should be minimized.", + "ImpactStatement": "Pods with containers require capabilities to operate will not be permitted.", + "RemediationProcedure": "Review the use of capabilities in applications running on your cluster. Where a namespace contains applications which do not require any Linux capabilities to operate consider adding a policy which forbids the admission of containers which do not drop all capabilities.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that at least one policy requires that capabilities are dropped by all containers.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/:https://www.nccgroup.trust/uk/our-research/abusing-privileged-and-unprivileged-linux-containers/", + "DefaultValue": "By default, there are no restrictions on the creation of containers with additional capabilities" + } + ] + }, + { + "Id": "5.2.10", + "Description": "Minimize the admission of Windows HostProcess Containers", + "Checks": [ + "core_minimize_admission_windows_hostprocess_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit Windows containers to be run with the `hostProcess` flag set to true.", + "RationaleStatement": "A Windows container making use of the `hostProcess` flag can interact with the underlying Windows cluster node. As per the Kubernetes documentation, this provides privileged access to the Windows node. Where Windows containers are used inside a Kubernetes cluster, there should be at least one admission control policy which does not permit `hostProcess` Windows containers. If you need to run Windows containers which require `hostProcess`, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `securityContext.windowsOptions.hostProcess: true` will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of `hostProcess` containers.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of `hostProcess` containers", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/:https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostProcess` containers." + } + ] + }, + { + "Id": "5.2.11", + "Description": "Minimize the admission of HostPath volumes", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally admit containers which make use of `hostPath` volumes.", + "RationaleStatement": "A container which mounts a `hostPath` volume as part of its specification will have access to the filesystem of the underlying cluster node. The use of `hostPath` volumes may allow containers access to privileged areas of the node filesystem. There should be at least one admission control policy defined which does not permit containers to mount `hostPath` volumes. If you need to run containers which require `hostPath` volumes, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined which make use of `hostPath` volumes will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers which use `hostPath` volumes.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of containers with `hostPath` volumes.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the creation of `hostPath` volumes." + } + ] + }, + { + "Id": "5.2.12", + "Description": "Minimize the admission of containers which use HostPorts", + "Checks": [ + "core_minimize_admission_hostport_containers" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.2 Pod Security Standards", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Do not generally permit containers which require the use of HostPorts.", + "RationaleStatement": "Host ports connect containers directly to the host's network. This can bypass controls such as network policy. There should be at least one admission control policy defined which does not permit containers which require the use of HostPorts. If you need to run containers which require HostPorts, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", + "ImpactStatement": "Pods defined with `hostPort` settings in either the container, initContainer or ephemeralContainer sections will not be permitted unless they are run under a specific policy.", + "RemediationProcedure": "Add policies to each namespace in the cluster which has user workloads to restrict the admission of containers which use `hostPort` sections.", + "AuditProcedure": "List the policies in use for each namespace in the cluster, ensure that each policy disallows the admission of containers which have `hostPort` sections.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "DefaultValue": "By default, there are no restrictions on the use of HostPorts." + } + ] + }, + { + "Id": "5.3.1", + "Description": "Ensure that the CNI in use supports Network Policies", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.3 Network Policies and CNI", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "There are a variety of CNI plugins available for Kubernetes. If the CNI in use does not support Network Policies it may not be possible to effectively restrict traffic in the cluster.", + "RationaleStatement": "Kubernetes network policies are enforced by the CNI plugin in use. As such it is important to ensure that the CNI plugin supports both Ingress and Egress network policies.", + "ImpactStatement": "None", + "RemediationProcedure": "If the CNI plugin in use does not support network policies, consideration should be given to making use of a different plugin, or finding an alternate mechanism for restricting traffic in the Kubernetes cluster.", + "AuditProcedure": "Review the documentation of CNI plugin in use by the cluster, and confirm that it supports Ingress and Egress network policies.", + "AdditionalInformation": "One example here is Flannel (https://github.com/coreos/flannel) which does not support Network policy unless Calico is also in use.", + "References": "https://kubernetes.io/docs/concepts/extend-kubernetes/compute-storage-net/network-plugins/", + "DefaultValue": "This will depend on the CNI plugin in use." + } + ] + }, + { + "Id": "5.3.2", + "Description": "Ensure that all Namespaces have Network Policies defined", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.3 Network Policies and CNI", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Use network policies to isolate traffic in your cluster network.", + "RationaleStatement": "Running different applications on the same Kubernetes cluster creates a risk of one compromised application attacking a neighboring application. Network segmentation is important to ensure that containers can communicate only with those they are supposed to. A network policy is a specification of how selections of pods are allowed to communicate with each other and other network endpoints. Network Policies are namespace scoped. When a network policy is introduced to a given namespace, all traffic not allowed by the policy is denied. However, if there are no network policies in a namespace all traffic will be allowed into and out of the pods in that namespace.", + "ImpactStatement": "Once network policies are in use within a given namespace, traffic not explicitly allowed by a network policy will be denied. As such it is important to ensure that, when introducing network policies, legitimate traffic is not blocked.", + "RemediationProcedure": "Follow the documentation and create `NetworkPolicy` objects as you need them.", + "AuditProcedure": "Run the below command and review the `NetworkPolicy` objects created in the cluster. ``` kubectl get networkpolicy --all-namespaces ``` Ensure that each namespace defined in the cluster has at least one Network Policy.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/services-networking/networkpolicies/:https://octetz.com/posts/k8s-network-policy-apis:https://kubernetes.io/docs/tasks/configure-pod-container/declare-network-policy/", + "DefaultValue": "By default, network policies are not created." + } + ] + }, + { + "Id": "5.4.1", + "Description": "Prefer using secrets as files over secrets as environment variables", + "Checks": [ + "core_no_secrets_envs" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.4 Secrets Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Kubernetes supports mounting secrets as data volumes or as environment variables. Minimize the use of environment variable secrets.", + "RationaleStatement": "It is reasonably common for application code to log out its environment (particularly in the event of an error). This will include any secret values passed in as environment variables, so secrets can easily be exposed to any user or entity who has access to the logs.", + "ImpactStatement": "Application code which expects to read secrets in the form of environment variables would need modification", + "RemediationProcedure": "If possible, rewrite application code to read secrets from mounted secret files, rather than from environment variables.", + "AuditProcedure": "Run the following command to find references to objects which use environment variables defined from secrets. ``` kubectl get all -o jsonpath='{range .items[?(@..secretKeyRef)]}{.kind}{@.metadata.name}{end}' -A ```", + "AdditionalInformation": "Mounting secrets as volumes has the additional benefit that secret values can be updated without restarting the pod", + "References": "https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets", + "DefaultValue": "By default, secrets are not defined" + } + ] + }, + { + "Id": "5.4.2", + "Description": "Consider external secret storage", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.4 Secrets Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Consider the use of an external secrets storage and management system, instead of using Kubernetes Secrets directly, if you have more complex secret management needs. Ensure the solution requires authentication to access secrets, has auditing of access to and use of secrets, and encrypts secrets. Some solutions also make it easier to rotate secrets.", + "RationaleStatement": "Kubernetes supports secrets as first-class objects, but care needs to be taken to ensure that access to secrets is carefully limited. Using an external secrets provider can ease the management of access to secrets, especially where secrests are used across both Kubernetes and non-Kubernetes environments.", + "ImpactStatement": "None", + "RemediationProcedure": "Refer to the secrets management options offered by your cloud provider or a third-party secrets management solution.", + "AuditProcedure": "Review your secrets management implementation.", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "By default, no external secret management is configured." + } + ] + }, + { + "Id": "5.5.1", + "Description": "Configure Image Provenance using ImagePolicyWebhook admission controller", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.5 Extensible Admission Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure Image Provenance for your deployment.", + "RationaleStatement": "Kubernetes supports plugging in provenance rules to accept or reject the images in your deployments. You could configure such rules to ensure that only approved images are deployed in the cluster.", + "ImpactStatement": "You need to regularly maintain your provenance configuration based on container image updates.", + "RemediationProcedure": "Follow the Kubernetes documentation and setup image provenance.", + "AuditProcedure": "Review the pod definitions in your cluster and verify that image provenance is configured as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/admin/admission-controllers/#imagepolicywebhook:https://github.com/kubernetes/community/blob/master/contributors/design-proposals/image-provenance.md:https://hub.docker.com/r/dnurmi/anchore-toolbox/:https://github.com/kubernetes/kubernetes/issues/22888", + "DefaultValue": "By default, image provenance is not set." + } + ] + }, + { + "Id": "5.6.1", + "Description": "Create administrative boundaries between resources using namespaces", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use namespaces to isolate your Kubernetes objects.", + "RationaleStatement": "Limiting the scope of user permissions can reduce the impact of mistakes or malicious activities. A Kubernetes namespace allows you to partition created resources into logically named groups. Resources created in one namespace can be hidden from other namespaces. By default, each resource created by a user in Kubernetes cluster runs in a default namespace, called `default`. You can create additional namespaces and attach resources and users to them. You can use Kubernetes Authorization plugins to create policies that segregate access to namespace resources between different users.", + "ImpactStatement": "You need to switch between namespaces for administration.", + "RemediationProcedure": "Follow the documentation and create namespaces for objects in your deployment as you need them.", + "AuditProcedure": "Run the below command and review the namespaces created in the cluster. ``` kubectl get namespaces ``` Ensure that these namespaces are the ones you need and are adequately administered as per your requirements.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/#viewing-namespaces:http://blog.kubernetes.io/2016/08/security-best-practices-kubernetes-deployment.html:https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/589-efficient-node-heartbeats", + "DefaultValue": "By default, Kubernetes starts with 4 initial namespaces: 1. `default` - The default namespace for objects with no other namespace 1. `kube-system` - The namespace for objects created by the Kubernetes system 1. `kube-node-lease` - Namespace used for node heartbeats 1. `kube-public` - Namespace used for public information in a cluster" + } + ] + }, + { + "Id": "5.6.2", + "Description": "Ensure that the seccomp profile is set to docker/default in your pod definitions", + "Checks": [ + "core_seccomp_profile_docker_default" + ], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Enable `docker/default` seccomp profile in your pod definitions.", + "RationaleStatement": "Seccomp (secure computing mode) is used to restrict the set of system calls applications can make, allowing cluster administrators greater control over the security of workloads running in the cluster. Kubernetes disables seccomp profiles by default for historical reasons. You should enable it to ensure that the workloads have restricted actions available within the container.", + "ImpactStatement": "If the `docker/default` seccomp profile is too restrictive for you, you would have to create/manage your own seccomp profiles.", + "RemediationProcedure": "Use security context to enable the `docker/default` seccomp profile in your pod definitions. An example is as below: ``` securityContext: seccompProfile: type: RuntimeDefault ```", + "AuditProcedure": "Review the pod definitions in your cluster. It should create a line as below: ``` securityContext: seccompProfile: type: RuntimeDefault ```", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/tutorials/clusters/seccomp/:https://docs.docker.com/engine/security/seccomp/", + "DefaultValue": "By default, seccomp profile is set to `unconfined` which means that no seccomp profiles are enabled." + } + ] + }, + { + "Id": "5.6.3", + "Description": "Apply Security Context to Your Pods and Containers", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Apply Security Context to Your Pods and Containers", + "RationaleStatement": "A security context defines the operating system security settings (uid, gid, capabilities, SELinux role, etc..) applied to a container. When designing your containers and pods, make sure that you configure the security context for your pods, containers, and volumes. A security context is a property defined in the deployment yaml. It controls the security parameters that will be assigned to the pod/container/volume. There are two levels of security context: pod level security context, and container level security context.", + "ImpactStatement": "If you incorrectly apply security contexts, you may have trouble running the pods.", + "RemediationProcedure": "Follow the Kubernetes documentation and apply security contexts to your pods. For a suggested list of security contexts, you may refer to the CIS Security Benchmark for Docker Containers.", + "AuditProcedure": "Review the pod definitions in your cluster and verify that you have security contexts defined as appropriate.", + "AdditionalInformation": "", + "References": "https://kubernetes.io/docs/concepts/policy/security-context/:https://learn.cisecurity.org/benchmarks", + "DefaultValue": "By default, no security contexts are automatically applied to pods." + } + ] + }, + { + "Id": "5.6.4", + "Description": "The default namespace should not be used", + "Checks": [], + "Attributes": [ + { + "Section": "5 Policies", + "SubSection": "5.6 General Policies", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Kubernetes provides a default namespace, where objects are placed if no namespace is specified for them. Placing objects in this namespace makes application of RBAC and other controls more difficult.", + "RationaleStatement": "Resources in a Kubernetes cluster should be segregated by namespace, to allow for security controls to be applied at that level and to make it easier to manage resources.", + "ImpactStatement": "None", + "RemediationProcedure": "Ensure that namespaces are created to allow for appropriate segregation of Kubernetes resources and that all new resources are created in a specific namespace.", + "AuditProcedure": "Run this command to list objects in default namespace ``` kubectl get $(kubectl api-resources --verbs=list --namespaced=true -o name | paste -sd, -) --ignore-not-found -n default ``` The only entries there should be system managed resources such as the `kubernetes` service", + "AdditionalInformation": "", + "References": "", + "DefaultValue": "Unless a namespace is specific on object creation, the `default` namespace will be used" + } + ] + } + ] +} diff --git a/prowler/compliance/kubernetes/pci_4.0_kubernetes.json b/prowler/compliance/kubernetes/pci_4.0_kubernetes.json index 0b301a0645..38d9557d5f 100644 --- a/prowler/compliance/kubernetes/pci_4.0_kubernetes.json +++ b/prowler/compliance/kubernetes/pci_4.0_kubernetes.json @@ -8268,6 +8268,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "10.5.1: Audit log history is retained and available for analysis.", @@ -10054,6 +10062,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.1.3: Sensitive authentication data (SAD) is not stored after authorization.", @@ -10250,6 +10266,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "3.3.3: Sensitive authentication data (SAD) is not stored after authorization.", @@ -13004,6 +13028,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 365 + } + ], "Attributes": [ { "Section": "5.3.4: Anti-malware mechanisms and processes are active, maintained, and monitored.", diff --git a/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json b/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json index 11ffe42485..58ef7c6ddb 100644 --- a/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json +++ b/prowler/compliance/kubernetes/prowler_threatscore_kubernetes.json @@ -1083,6 +1083,23 @@ "LevelOfRisk": 4, "Weight": 100 } + ], + "ConfigRequirements": [ + { + "Check": "kubelet_strong_ciphers_only", + "ConfigKey": "kubelet_strong_ciphers", + "Operator": "subset", + "Value": [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_AES_128_GCM_SHA256" + ] + } ] }, { @@ -1199,6 +1216,14 @@ "Checks": [ "apiserver_audit_log_maxage_set" ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30 + } + ], "Attributes": [ { "Title": "API Server audit log retention configured", @@ -1227,6 +1252,14 @@ "LevelOfRisk": 3, "Weight": 10 } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10 + } ] }, { @@ -1245,6 +1278,14 @@ "LevelOfRisk": 2, "Weight": 8 } + ], + "ConfigRequirements": [ + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100 + } ] }, { diff --git a/prowler/compliance/linode/__init__.py b/prowler/compliance/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 23582fbaac..35d64d358e 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -565,6 +565,68 @@ "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-malware-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/office/compatibility/office-file-format-reference", "DefaultValue": "The following extensions are blocked by default:ace, ani, apk, app, appx, arj, bat, cab, cmd, com, deb, dex, dll, docm, elf, exe, hta, img, iso, jar, jnlp, kext, lha, lib, library, lnk, lzh, macho, msc, msi, msix, msp, mst, pif, ppa, ppam, reg, rev, scf, scr, sct, sys, uif, vb, vbe, vbs, vxd, wsc, wsf, wsh, xll, xz, z" } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } ] }, { @@ -1209,6 +1271,14 @@ "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime", "DefaultValue": "The default configuration for user sign-in frequency is a rolling window of 90 days." } + ], + "ConfigRequirements": [ + { + "Check": "entra_admin_users_sign_in_frequency_enabled", + "ConfigKey": "sign_in_frequency", + "Operator": "lte", + "Value": 4 + } ] }, { diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index f13c323df4..5815c67ac9 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -582,6 +582,68 @@ "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-malware-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/office/compatibility/office-file-format-reference", "DefaultValue": "53 extensions are blocked by default." } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } ] }, { @@ -873,7 +935,9 @@ { "Id": "4.1", "Description": "Compliance policies are sets of rules and conditions that are used to evaluate the configuration of managed devices. These policies can help secure organizational data and resources from devices that don't meet those configuration requirements. The recommended state is Mark devices with no compliance policy assigned as Not compliant.", - "Checks": [], + "Checks": [ + "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + ], "Attributes": [ { "Section": "4 Microsoft Intune admin center", @@ -1947,6 +2011,14 @@ "References": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide", "DefaultValue": "AuditEnabled: True for all mailboxes except Resource Mailboxes, Public Folder Mailboxes, and DiscoverySearch Mailbox" } + ], + "ConfigRequirements": [ + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90 + } ] }, { @@ -2108,6 +2180,14 @@ "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/mailtips/mailtips", "DefaultValue": "MailTipsAllTipsEnabled: True, MailTipsExternalRecipientsTipsEnabled: False, MailTipsGroupMetricsEnabled: True, MailTipsLargeAudienceThreshold: 25" } + ], + "ConfigRequirements": [ + { + "Check": "exchange_organization_mailtips_enabled", + "ConfigKey": "recommended_mailtips_large_audience_threshold", + "Operator": "lte", + "Value": 25 + } ] }, { diff --git a/prowler/compliance/m365/cis_7.0_m365.json b/prowler/compliance/m365/cis_7.0_m365.json new file mode 100644 index 0000000000..a913f339be --- /dev/null +++ b/prowler/compliance/m365/cis_7.0_m365.json @@ -0,0 +1,3614 @@ +{ + "Framework": "CIS", + "Name": "CIS Microsoft 365 Foundations Benchmark v7.0.0", + "Version": "7.0", + "Provider": "M365", + "Description": "The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for establishing a secure configuration posture for Microsoft 365 Cloud offerings running on any OS. This guide includes recommendations for Exchange Online, SharePoint Online, OneDrive for Business, Teams, Power BI (Fabric) and Microsoft Entra ID.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. Regular user accounts should never be utilized for administrative tasks and care should be taken, in the case of a hybrid environment, to keep administrative accounts separate from on-prem accounts. Administrative accounts should not have applications assigned so that they have no access to potentially vulnerable services (EX. email, Teams, SharePoint, etc.) and only access to perform tasks as needed for administrative purposes. Ensure administrative accounts are not On-premises sync enabled.", + "Checks": [ + "entra_admin_users_cloud_only" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. Regular user accounts should never be utilized for administrative tasks and care should be taken, in the case of a hybrid environment, to keep administrative accounts separate from on-prem accounts. Administrative accounts should not have applications assigned so that they have no access to potentially vulnerable services (EX. email, Teams, SharePoint, etc.) and only access to perform tasks as needed for administrative purposes. Ensure administrative accounts are not On-premises sync enabled.", + "RationaleStatement": "In a hybrid environment, having separate accounts will help ensure that in the event of a breach in the cloud, that the breach does not affect the on-prem environment and vice versa.", + "ImpactStatement": "Administrative users will need to utilize login/logout functionality to switch accounts when performing administrative tasks, which means they will not benefit from SSO. This will require a migration process from the 'daily driver' account to a dedicated admin account. Once the new admin account is created, permission sets should be migrated from the 'daily driver' account to the new admin account. This includes both M365 and Azure RBAC roles. Failure to migrate Azure RBAC roles could prevent an admin from seeing their subscriptions/resources while using their admin account.", + "RemediationProcedure": "Remediation will require first identifying the privileged accounts that are synced from on- premises and then creating a new cloud-only account for that user. Once a replacement account is established, the hybrid account should have its role reduced to that of a non- privileged user or removed depending on the need.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Identity > Users and select All users. 3. To the right of the search box click the Add filter button. 4. Add the On-premises sync enabled filter with the value set to Yes and click Apply. 5. Verify that no user accounts in administrative roles are present in the filtered list. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"RoleManagement.Read.Directory\",\"User.Read.All\" 2. Run the following PowerShell script: $DirectoryRoles = Get-MgDirectoryRole # Get privileged role IDs $PrivilegedRoles = $DirectoryRoles | Where-Object { $_.DisplayName -like \"*Administrator*\" -or $_.DisplayName -eq \"Global Reader\" } # Get the members of these various roles $RoleMembers = $PrivilegedRoles | ForEach-Object { Get-MgDirectoryRoleMember -DirectoryRoleId $_.Id } | Select-Object Id -Unique # Retrieve details about the members in these roles $PrivilegedUsers = $RoleMembers | ForEach-Object { Get-MgUser -UserId $_.Id -Property UserPrincipalName, DisplayName, Id, OnPremisesSyncEnabled } $PrivilegedUsers | Where-Object { $_.OnPremisesSyncEnabled -eq $true } | ft DisplayName,UserPrincipalName,OnPremisesSyncEnabled 3. The script will output any hybrid users that are also members of privileged roles. If nothing returns, then no users with that criteria exist.", + "AdditionalInformation": "", + "DefaultValue": "N/A", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/add-users?view=o365-worldwide:https://learn.microsoft.com/en-us/microsoft-365/enterprise/protect-your-global-administrator-accounts?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#9-use-cloud-native-accounts-for-microsoft-entra-roles:https://learn.microsoft.com/en-us/entra/fundamentals/whatis:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference" + } + ] + }, + { + "Id": "1.1.2", + "Description": "Emergency access or \"break glass\" accounts are limited for emergency scenarios where normal administrative accounts are unavailable. They are not assigned to a specific user and will have a combination of physical and technical controls to prevent them from being accessed outside a true emergency. These emergencies could be due to several things, including: - Technical failures of a cellular provider or Microsoft related service such as MFA. - The last remaining Global Administrator account is inaccessible. Ensure two Emergency Access accounts have been defined. Note: Microsoft provides several recommendations for these accounts and how to configure them. For more information on this, please refer to the references section. The CIS Benchmark outlines the more critical things to consider.", + "Checks": [ + "entra_break_glass_account_fido2_security_key_registered", + "entra_emergency_access_exclusion" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Emergency access or \"break glass\" accounts are limited for emergency scenarios where normal administrative accounts are unavailable. They are not assigned to a specific user and will have a combination of physical and technical controls to prevent them from being accessed outside a true emergency. These emergencies could be due to several things, including: - Technical failures of a cellular provider or Microsoft related service such as MFA. - The last remaining Global Administrator account is inaccessible. Ensure two Emergency Access accounts have been defined. Note: Microsoft provides several recommendations for these accounts and how to configure them. For more information on this, please refer to the references section. The CIS Benchmark outlines the more critical things to consider.", + "RationaleStatement": "In various situations, an organization may require the use of a break glass account to gain emergency access. In the event of losing access to administrative functions, an organization may experience a significant loss in its ability to provide support, lose insight into its security posture, and potentially suffer financial losses.", + "ImpactStatement": "Failure to properly implement emergency access accounts can weaken the security posture. Microsoft recommends excluding at least one of the two emergency access accounts from all conditional access rules, necessitating passwords with sufficient entropy and length to protect against random guesses. For a secure passwordless solution, FIDO2 security keys may be used instead of passwords.", + "RemediationProcedure": "To remediate using the UI: Step 1 - Create two emergency access accounts: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Users > Active Users 3. Click Add user and create a new user with this criteria: o Name the account in a way that does NOT identify it with a particular person. o Assign the account to the default .onmicrosoft.com domain and not the organization's. o The password must be at least 16 characters and generated randomly. o Do not assign a license. o Assign the user the Global Administrator role. 4. Repeat the above steps for the second account. Step 2 - Exclude at least one account from conditional access policies: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protection > Conditional Access. 3. Inspect the conditional access policies. 4. For each rule add an exclusion for at least one of the emergency access accounts. 5. Users > Exclude > Users and groups and select one emergency access account. Step 3 - Ensure the necessary procedures and policies are in place: - In order for accounts to be effectively used in a break glass situation the proper policies and procedures must be authorized and distributed by senior management. - FIDO2 Security Keys should be locked in a secure separate fireproof location. - Passwords should be at least 16 characters, randomly generated and MAY be separated in multiple pieces to be joined in case of an emergency. Warning: As of 10/15/2024 MFA is required for all users including Break Glass Accounts. It is recommended to update these accounts to use passkey (FIDO2) or configure certificate-based authentication for MFA. Both methods satisfy the MFA requirement. Additional suggestions for emergency account management: - Create access reviews for these users. - Exclude users from conditional access rules. - Add the account to a restricted management administrative unit. Warning: If CA (conditional access) exclusion is managed by a group, this group should be added to PIM for groups (licensing required) or be created as a role-assignable group. If it is a regular security group, then users with the Group Administrators role are able to bypass CA entirely.", + "AuditProcedure": "To audit using the UI: Step 1 - Ensure a policy and procedure is in place at the organization: - In order for accounts to be effectively used in a break-glass situation the proper policies and procedures must be authorized and distributed by senior management. - FIDO2 Security Keys should be locked in a secure separate fireproof location. - Passwords should be at least 16 characters, randomly generated and MAY be separated in multiple pieces to be joined in case of an emergency. Step 2 - Ensure two emergency access accounts are defined: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Users > Active Users 3. Inspect the designated emergency access accounts and ensure the following: o The accounts are named correctly, and do NOT identify with a particular person. o The accounts use the default .onmicrosoft.com domain and not the organization's. o The accounts are cloud-only. o The accounts are unlicensed. o The accounts are not disabled or Sign-in blocked. o The accounts are assigned the Global Administrator directory role. Step 3 - Ensure at least one account is excluded from all conditional access rules: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protection > Conditional Access. 3. Inspect the conditional access rules. 4. Ensure one of the emergency access accounts is excluded from all rules. Warning: As of 10/15/2024 MFA is required for all users including Break Glass Accounts. It is recommended to update these accounts to use passkey (FIDO2) or configure certificate-based authentication for MFA. Both methods satisfy the MFA requirement.", + "AdditionalInformation": "Microsoft has additional instructions regarding using Azure Monitor to capture events in the Log Analytics workspace, and then generate alerts for Emergency Access accounts. This requires an Azure subscription but should be strongly considered as a method of monitoring activity on these accounts: https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security- emergency-access#monitor-sign-in-and-audit-logs", + "DefaultValue": "Not defined.", + "References": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-planning#stage-1-critical-items-to-do-right-now:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/admin-units-restricted-management:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication#accounts" + } + ] + }, + { + "Id": "1.1.3", + "Description": "Between two and four global administrators should be designated in the tenant. Ideally, these accounts will not have licenses assigned to them which supports additional controls found in this benchmark.", + "Checks": [ + "admincenter_users_between_two_and_four_global_admins" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Between two and four global administrators should be designated in the tenant. Ideally, these accounts will not have licenses assigned to them which supports additional controls found in this benchmark.", + "RationaleStatement": "The Global Administrator role grants unrestricted access across all services in Microsoft Entra ID and should never be used for routine daily activities. Limiting the number of Global Administrators reduces the attack surface of the tenant and aligns with the principle of least privilege. Fewer than two Global Administrators creates a single point of failure and removes the peer oversight needed to detect unauthorized actions. More than four increases the likelihood of account compromise by an external attacker. Maintaining between two and four Global Administrators balances operational redundancy against privileged access risk. For any accounts assigned the Global Administrator role, at least one strong authentication method such as a FIDO2 key or certificate is strongly advised.", + "ImpactStatement": "The potential impact associated with ensuring compliance with this requirement is dependent upon the current number of global administrators configured in the tenant. If there is only one global administrator in a tenant, an additional global administrator will need to be identified and configured. If there are more than four global administrators, a review of role requirements for current global administrators will be required to identify which of the users require global administrator access.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com 2. Select Users > Active Users. 3. In the Search field enter the name of the user to be made a Global Administrator. 4. To create a new Global Admin: 1. Select the user's name. 2. A window will appear to the right. 3. Select Manage roles. 4. Select Admin center access. 5. Check Global Administrator. 6. Click Save changes. 5. To remove a Global Admin: 1. In the Search field, enter the name of the user to be removed. 2. Select the user's name. 3. A window will appear to the right. 4. Under Roles, select Manage roles. 5. Uncheck Global Administrator. 6. Click Save changes.", + "AuditProcedure": "Note: If an organization's tenant is using a third-party identity provider, the audit and remediation procedures presented here may not be relevant. The principle of the recommendation is still relevant, and compensating controls that are relevant to the third-party identity provider should be implemented. To audit using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com 2. Select Roles > Role assignments. 3. Select the Global Administrator role from the list and click on Assigned. 4. Review the list of Global Administrators. o If there are groups present, then inspect each group and its members. o Take note of the total number of Global Administrators in and outside of groups. 5. Verify the number of Global Administrators is between two and four. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes Directory.Read.All 2. Run the following PowerShell script: # Determine Id of GA role using the immutable RoleTemplateId value. $GlobalAdminRole = Get-MgDirectoryRole -Filter \"RoleTemplateId eq '62e90394- 69f5-4237-9190-012177145e10'\" $RoleMembers = Get-MgDirectoryRoleMember -DirectoryRoleId $GlobalAdminRole.Id $GlobalAdmins = [System.Collections.Generic.List[Object]]::new() foreach ($object in $RoleMembers) { $Type = $object.AdditionalProperties.'@odata.type' # Check for and process role assigned groups if ($Type -eq '#microsoft.graph.group') { $GroupId = $object.Id $GroupMembers = (Get-MgGroupMember -GroupId $GroupId).AdditionalProperties foreach ($member in $GroupMembers) { if ($member.'@odata.type' -eq '#microsoft.graph.user') { $GlobalAdmins.Add([PSCustomObject][Ordered]@{ DisplayName = $member.displayName UserPrincipalName = $member.userPrincipalName }) } } } elseif ($Type -eq '#microsoft.graph.user') { $DisplayName = $object.AdditionalProperties.displayName $UPN = $object.AdditionalProperties.userPrincipalName $GlobalAdmins.Add([PSCustomObject][Ordered]@{ DisplayName = $DisplayName UserPrincipalName = $UPN }) } } $GlobalAdmins = $GlobalAdmins | select DisplayName,UserPrincipalName -Unique Write-Host \"*** There are\" $GlobalAdmins.Count \"Global Administrators in the organization.\" 3. Review the output and ensure there are between 2 and 4 Global Administrators. Note: When tallying the number of Global Administrators, the above does not account for Partner relationships. Those are located under Settings > Partner Relationships and should be reviewed on a recurring basis.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement/get-mgdirectoryrole?view=graph-powershell-1.0:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#all-roles:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#5-limit-the-number-of-global-administrators-to-less-than-5:https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/about-admin-roles?view=o365-worldwide#security-guidelines-for-assigning-roles" + } + ] + }, + { + "Id": "1.1.4", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. A license can enable an account to gain access to a variety of different applications, depending on the license assigned. The recommended state is to not license a privileged account or use licenses without associated applications such as Microsoft Entra ID P1 or Microsoft Entra ID P2.", + "Checks": [ + "admincenter_users_admins_reduced_license_footprint" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.1 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. A license can enable an account to gain access to a variety of different applications, depending on the license assigned. The recommended state is to not license a privileged account or use licenses without associated applications such as Microsoft Entra ID P1 or Microsoft Entra ID P2.", + "RationaleStatement": "Ensuring administrative accounts do not use licenses with applications assigned to them will reduce the attack surface of high privileged identities in the organization's environment. Granting access to a mailbox or other collaborative tools increases the likelihood that privileged users might interact with these applications, raising the risk of exposure to social engineering attacks or malicious content. These activities should be restricted to an unprivileged 'daily driver' account. Note: In order to participate in Microsoft 365 security services such as Identity Protection, PIM and Conditional Access an administrative account will need a license attached to it. Ensure that the license used does not include any applications with potentially vulnerable services by using either Microsoft Entra ID P1 or Microsoft Entra ID P2 for the cloud-only account with administrator roles.", + "ImpactStatement": "Administrative users will be required to switch accounts and use manual login/logout procedures when performing privileged tasks. This change also means they will not benefit from Single Sign-On (SSO), potentially impacting workflow efficiency and user experience. Note: Alerts will be sent to TenantAdmins, including Global Administrators, by default. To ensure proper receipt, configure alerts to be sent to security or operations staff with valid email addresses or a security operations center. Otherwise, after adoption of this recommendation, alerts sent to TenantAdmins may go unreceived due to the lack of an application-based license assigned to the Global Administrator accounts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Users > Active users. 3. Click Add a user. 4. Fill out the appropriate fields for Name, user, etc. 5. When prompted to assign licenses select as needed Microsoft Entra ID P1 or Microsoft Entra ID P2, then click Next. 6. Under the Option settings screen you may choose from several types of privileged roles. Choose Admin center access followed by the appropriate role then click Next. 7. Select Finish adding. Note: Utilizing PIM to best practices will satisfy this control. CIS and Microsoft recommend an organization keep zero permanently active assignments for roles other than emergency access accounts.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Users > Active users. 3. Sort by the Licenses column. 4. For each user account in an administrative role verify the account is assigned a license that is not associated with applications i.e. (Microsoft Entra ID P1, Microsoft Entra ID P2). o If an organization uses PIM to elevate a daily driver account to privileged levels, this control and licensing requirement can be considered satisfied. Note: The final step assumes PIM is properly configured to best practices. Accounts eligible for the Global Administrator role should require approval to activate. Using the PIM blade to permanently assign accounts to privileged roles would not satisfy this audit procedure. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"RoleManagement.Read.Directory\",\"User.Read.All\" 2. Run the following PowerShell script: $DirectoryRoles = Get-MgDirectoryRole # Get privileged role IDs $PrivilegedRoles = $DirectoryRoles | Where-Object { $_.DisplayName -like \"*Administrator*\" -or $_.DisplayName -eq \"Global Reader\" } # Get the members of these various roles $RoleMembers = $PrivilegedRoles | ForEach-Object { Get-MgDirectoryRoleMember -DirectoryRoleId $_.Id } | Select-Object Id -Unique # Retrieve details about the members in these roles $PrivilegedUsers = $RoleMembers | ForEach-Object { Get-MgUser -UserId $_.Id -Property UserPrincipalName, DisplayName, Id } $Report = [System.Collections.Generic.List[Object]]::new() foreach ($Admin in $PrivilegedUsers) { $License = $null $License = (Get-MgUserLicenseDetail -UserId $Admin.id).SkuPartNumber - join \", \" $Object = [pscustomobject][ordered]@{ DisplayName = $Admin.DisplayName UserPrincipalName = $Admin.UserPrincipalName License = $License } $Report.Add($Object) } $Report 3. The output will display users assigned privileged roles alongside their assigned licenses. Additional manual assessment is required to determine if the licensing is appropriate for the user.", + "AdditionalInformation": "", + "DefaultValue": "N/A", + "References": "https://learn.microsoft.com/en-us/microsoft-365/enterprise/protect-your-global-administrator-accounts?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/fundamentals/whatis#what-are-the-microsoft-entra-id-licenses:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference:https://learn.microsoft.com/en-us/microsoft-365/business-premium/m365bp-protect-admin-accounts?view=o365-worldwide:https://learn.microsoft.com/en-us/microsoft-365/enterprise/subscriptions-licenses-accounts-and-tenants-for-microsoft-cloud-offerings?view=o365-worldwide#licenses:https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-deployment-plan#principle-of-least-privilege" + } + ] + }, + { + "Id": "1.2.1", + "Description": "Microsoft 365 Groups is the foundational membership service that drives all teamwork across Microsoft 365. With Microsoft 365 Groups, you can give a group of people access to a collection of shared resources. When a new group is created in the Administration panel, the default privacy value of the group is \"Public\". (In this case, 'public' means accessible to the identities within the organization without requiring group owner authorization to join.) The recommended state is Microsoft 365 Groups are set to Private in the Administration panel. Note: Although there are several different group types, this recommendation concerns Microsoft 365 Groups specifically.", + "Checks": [ + "admincenter_groups_not_public_visibility" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.2 Teams & groups", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft 365 Groups is the foundational membership service that drives all teamwork across Microsoft 365. With Microsoft 365 Groups, you can give a group of people access to a collection of shared resources. When a new group is created in the Administration panel, the default privacy value of the group is \"Public\". (In this case, 'public' means accessible to the identities within the organization without requiring group owner authorization to join.) The recommended state is Microsoft 365 Groups are set to Private in the Administration panel. Note: Although there are several different group types, this recommendation concerns Microsoft 365 Groups specifically.", + "RationaleStatement": "If group privacy is not controlled, any user may access sensitive information, depending on the group they try to access. When the privacy value of a group is set to \"Public,\" users may access data related to this group (e.g. SharePoint) via three methods: 1. The Azure Portal: Users can add themselves to the public group via the Azure Portal; however, administrators are notified when users access the Portal. 2. Access Requests: Users can request to join the group via the Groups application in the Access Panel. This provides the user with immediate access to the group, even though they are required to send a message to the group owner when requesting to join. 3. SharePoint URL: Users can directly access a group via its SharePoint URL, which is usually guessable and can be found in the Groups application within the Access Panel.", + "ImpactStatement": "If the recommendation is applied, group owners could receive more access requests than usual, especially regarding groups originally meant to be public.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Teams & groups > Active teams & groups. 3. On the Active teams and groups page, select the group's name that is public. 4. On the popup groups name page, Select Settings. 5. Under Privacy, select Private.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Teams & groups > Active teams & groups. 3. On the Active teams and groups page, check that no groups have the status 'Public' in the privacy column. To audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"Group.Read.All\". 2. Run the following Microsoft Graph PowerShell command: $Groups = Get-MgGroup -All -Filter \"groupTypes/any(c:c eq 'Unified')\" ` -Property Id,DisplayName,Visibility,GroupTypes # Displays the groups to the console for review $Groups | ft Id,DisplayName,Visibility 3. Verify that Visibility is Private for each group.", + "AdditionalInformation": "", + "DefaultValue": "Public when created from the Administration portal; private otherwise.", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management:https://learn.microsoft.com/en-us/microsoft-365/admin/create-groups/compare-groups?view=o365-worldwide" + } + ] + }, + { + "Id": "1.2.2", + "Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people. Users with permissions to the group mailbox can send as or send on behalf of the mailbox email address if the administrator has given that user permissions to do that. This is particularly useful for help and support mailboxes because users can send emails from \"Contoso Support\" or \"Building A Reception Desk.\" Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation. The recommended state is Sign in blocked for Shared mailboxes.", + "Checks": [ + "exchange_shared_mailbox_sign_in_disabled" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.2 Teams & groups", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people. Users with permissions to the group mailbox can send as or send on behalf of the mailbox email address if the administrator has given that user permissions to do that. This is particularly useful for help and support mailboxes because users can send emails from \"Contoso Support\" or \"Building A Reception Desk.\" Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation. The recommended state is Sign in blocked for Shared mailboxes.", + "RationaleStatement": "The intent of the shared mailbox is to only allow delegated access from other mailboxes. An admin could reset the password, or an attacker could potentially gain access to the shared mailbox allowing the direct sign-in to the shared mailbox and subsequently the sending of email from a sender that does not have a unique identity. To prevent this, block sign-in for the account that is associated with the shared mailbox.", + "ImpactStatement": "Blocking sign-in to shared mailboxes prevents direct authentication to these accounts. Authorized users can still access shared mailbox content through their own accounts using Outlook delegation or by being granted Send As/Send on Behalf permissions. This change strengthens security by ensuring shared mailboxes cannot serve as entry points for unauthorized access.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com/ 2. Click to expand Teams & groups and select Shared mailboxes. 3. Take note of all shared mailboxes. 4. Click to expand Users and select Active users. 5. Select a shared mailbox account to open its properties pane and then select Block sign-in. 6. Check the box for Block this user from signing in. 7. Repeat for any additional shared mailboxes. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"User.ReadWrite.All\" 2. Connect to Exchange Online using Connect-ExchangeOnline. 3. To disable sign-in for a single account: $MBX = Get-EXOMailbox -Identity TestUser@example.com Update-MgUser -UserId $MBX.ExternalDirectoryObjectId -AccountEnabled:$false The following can be used block sign-in to all Shared Mailboxes: $MBX = Get-EXOMailbox -RecipientTypeDetails SharedMailbox $MBX | ForEach-Object { Update-MgUser -UserId $_.ExternalDirectoryObjectId - AccountEnabled:$false }", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com/ 2. Expand Teams & groups and select Shared mailboxes. 3. Take note of all shared mailboxes. 4. Expand Users and select Active users. 5. Select a shared mailbox account to open its properties pane, and review. 6. Verify that the text under the name reads Sign-in blocked. 7. Repeat for any additional shared mailboxes. Note: If sign-in is not blocked there will be an option to Block sign-in. This means the shared mailbox is out of compliance with this recommendation. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline 2. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"User.Read.All\" 3. Run the following PowerShell commands: $MBX = Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited $MBX | ForEach-Object { Get-MgUser -UserId $_.ExternalDirectoryObjectId ` -Property DisplayName, UserPrincipalName, AccountEnabled } | Format-Table DisplayName, UserPrincipalName, AccountEnabled 4. Ensure AccountEnabled is set to False for all Shared Mailboxes.", + "AdditionalInformation": "", + "DefaultValue": "AccountEnabled: True", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/email/about-shared-mailboxes?view=o365-worldwide:https://learn.microsoft.com/en-us/microsoft-365/admin/email/create-a-shared-mailbox?view=o365-worldwide#block-sign-in-for-the-shared-mailbox-account:https://learn.microsoft.com/en-us/microsoft-365/enterprise/block-user-accounts-with-microsoft-365-powershell?view=o365-worldwide#block-individual-user-accounts" + } + ] + }, + { + "Id": "1.3.1", + "Description": "Microsoft cloud-only accounts have a pre-defined password policy that cannot be changed. The only items that can change are the number of days until a password expires and whether or not passwords expire at all.", + "Checks": [ + "admincenter_settings_password_never_expire" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft cloud-only accounts have a pre-defined password policy that cannot be changed. The only items that can change are the number of days until a password expires and whether or not passwords expire at all.", + "RationaleStatement": "Organizations such as NIST and Microsoft recommend against arbitrarily requiring users to change their passwords after a set period, unless there is evidence of compromise or the user has forgotten the password. This guidance applies even to single-factor (password-only) scenarios, as forced, periodic changes often lead to weaker passwords and reduced security. Additionally, this Benchmark advises implementing multi-factor authentication (MFA) for all accounts, which further diminishes the value of password expiration policies. Long-lived passwords can be further strengthened by enabling additional password protection features in Entra ID.", + "ImpactStatement": "When setting passwords not to expire it is important to have other controls in place to supplement this setting. See below for related recommendations and user guidance. - Ban common passwords. - Educate users to not reuse organization passwords anywhere else. - Enforce Multi-Factor Authentication registration for all users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org Settings. 3. Click on Security & privacy. 4. Check the Set passwords to never expire (recommended) box. 5. Click Save. To remediate using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"Domain.ReadWrite.All\". 2. Run the following Microsoft Graph PowerShell command: Update-MgDomain -DomainId -PasswordValidityPeriodInDays 2147483647", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org Settings. 3. Click on Security & privacy. 4. Select Password expiration policy and verify that Set passwords to never expire (recommended) has been checked. To audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"Domain.Read.All\". 2. Run the following Microsoft Online PowerShell command: Get-MgDomain | ft id,PasswordValidityPeriodInDays 3. Verify the value returned for valid domains is 2147483647", + "AdditionalInformation": "", + "DefaultValue": "If the property is not set, a default value of 90 days will be used", + "References": "https://pages.nist.gov/800-63-3/sp800-63b.html:https://www.cisecurity.org/white-papers/cis-password-policy-guide/:https://learn.microsoft.com/en-us/microsoft-365/admin/misc/password-policy-recommendations?view=o365-worldwide" + } + ] + }, + { + "Id": "1.3.2", + "Description": "Idle session timeout allows the configuration of a setting which will timeout inactive users after a pre-determined amount of time. When a user reaches the set idle timeout session, they'll get a notification that they're about to be signed out. They must choose to stay signed in or they'll be automatically signed out of all Microsoft 365 web apps. Combined with a Conditional Access rule this will only impact unmanaged devices. A managed device is considered a device managed by Intune MDM or joined to a domain (Entra ID or Hybrid joined). The following Microsoft 365 web apps are supported. - Outlook Web App - OneDrive - SharePoint - Microsoft Fabric - Microsoft365.com and other start pages - Microsoft 365 web apps (Word, Excel, PowerPoint) - Microsoft 365 Admin Center - M365 Defender Portal - Microsoft Purview Compliance Portal The recommended setting is 3 hours (or less) for unmanaged devices. Note: Idle session timeout doesn't affect Microsoft 365 desktop and mobile apps.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Idle session timeout allows the configuration of a setting which will timeout inactive users after a pre-determined amount of time. When a user reaches the set idle timeout session, they'll get a notification that they're about to be signed out. They must choose to stay signed in or they'll be automatically signed out of all Microsoft 365 web apps. Combined with a Conditional Access rule this will only impact unmanaged devices. A managed device is considered a device managed by Intune MDM or joined to a domain (Entra ID or Hybrid joined). The following Microsoft 365 web apps are supported. - Outlook Web App - OneDrive - SharePoint - Microsoft Fabric - Microsoft365.com and other start pages - Microsoft 365 web apps (Word, Excel, PowerPoint) - Microsoft 365 Admin Center - M365 Defender Portal - Microsoft Purview Compliance Portal The recommended setting is 3 hours (or less) for unmanaged devices. Note: Idle session timeout doesn't affect Microsoft 365 desktop and mobile apps.", + "RationaleStatement": "Ending idle sessions through an automatic process can help protect sensitive company data and will add another layer of security for end users who work on unmanaged devices that can potentially be accessed by the public. Unauthorized individuals onsite or remotely can take advantage of systems left unattended over time. Automatic timing out of sessions makes this more difficult.", + "ImpactStatement": "If step 2 in the Audit/Remediation procedure is left out, then there is no issue with this from a security standpoint. However, it will require users on trusted devices to sign in more frequently which could result in credential prompt fatigue. Users don't get signed out in these cases: - If they get single sign-on (SSO) into the web app from the device joined account. - If they selected Stay signed in at the time of sign-in. For more info on hiding this option for your organization, see Add branding to your organization's sign-in page. - If they're on a managed device, that is compliant or joined to a domain and using a supported browser, like Microsoft Edge, or Google Chrome with the Microsoft Single Sign On extension. Note: Idle session timeout also affects the Azure Portal idle timeout if this is not explicitly set to a different timeout. The Azure Portal idle timeout applies to all kinds of devices, not just unmanaged. See: change the directory timeout setting admin", + "RemediationProcedure": "Step 1 - Configure Idle session timeout: To remediate using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com/. 2. Expand Settings > Org settings. 3. Click Security & Privacy tab. 4. Select Idle session timeout. 5. Check the box Turn on to set the period of inactivity for users to be signed off of Microsoft 365 web apps 6. Set a maximum value of 3 hours. 7. Click save. Step 2 - Ensure the Conditional Access policy is in place: To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protect > Conditional Access. 3. Click New policy and give the policy a name. o Select Users > All users. o Select Cloud apps or actions > Select apps and select Office 365 o Select Conditions > Client apps > Yes check only Browser unchecking all other boxes. o Select Sessions and check Use app enforced restrictions. 4. Set Enable policy to On and click Create. Note: To ensure that idle timeouts affect only unmanaged devices, both steps 1 and 2 must be completed. Otherwise managed devices will also be impacted by the timeout policy.", + "AuditProcedure": "Step 1 - Ensure Idle session timeout is configured: To audit using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com/. 2. Expand Settings > Org settings. 3. Click Security & Privacy tab. 4. Select Idle session timeout. 5. Verify that Turn on to set the period of inactivity for users to be signed off of Microsoft 365 web apps is set to 3 hours (or less). To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\": 2. Run the following script: $TimeoutPolicy = Get-MgPolicyActivityBasedTimeoutPolicy $BenchmarkTimeSpan = [TimeSpan]::Parse('03:00:00') # 3 hours if ($TimeoutPolicy) { $PolicyDefinition = $TimeoutPolicy.Definition | ConvertFrom-Json $Timeout = $PolicyDefinition.ActivityBasedTimeoutPolicy.ApplicationPolicies[0].WebSessio nIdleTimeout $TimeSpan = [TimeSpan]::Parse($Timeout) $TimeoutReadable = \"{0} days, {1} hours, {2} minutes\" ` -f $TimeSpan.Days, $TimeSpan.Hours, $TimeSpan.Minutes if ($TimeSpan -le $BenchmarkTimeSpan) { Write-Host \"** PASS ** Timeout is set to $TimeoutReadable.\" } else { Write-Host \"** FAIL ** Timeout is too long. It is set to $TimeoutReadable.\" } } else { Write-Host \"** FAIL **: Idle session timeout is not configured.\" } 3. Verify the policy exists and is 3 hours or less. Step 2 - Ensure the Conditional Access policy is in place: To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Protect > Conditional Access. 3. Inspect existing conditional access rules for one that meets the below conditions: o Users or agents (Preview) is set to include All users. o Cloud apps or actions > Select apps is set to Office 365. o Conditions > Client apps is Browser and nothing else. o Session is set to Use app enforced restrictions. o Enable Policy is set to On To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\": 2. Run the following script: $Caps = Get-MgIdentityConditionalAccessPolicy -All | Where-Object { $_.SessionControls.ApplicationEnforcedRestrictions.IsEnabled } $CapReport = [System.Collections.Generic.List[Object]]::new() # Filter to policies with \"Use app enforced restrictions\" enabled # Loop through policies and generate a per policy report. foreach ($policy in $Caps) { $Name = $policy.DisplayName $Users = $policy.Conditions.Users.IncludeUsers $Targets = $policy.Conditions.Applications.IncludeApplications $ClientApps = $policy.Conditions.ClientAppTypes $Restrictions = $policy.SessionControls.ApplicationEnforcedRestrictions.IsEnabled $State = $policy.State $CountPass = $Targets.count -eq 1 -and $ClientApps.count -eq 1 $Pass = $Targets -eq 'Office365' -and $ClientApps -eq 'browser' -and $Restrictions -and $CountPass -and $State -eq 'enabled' $obj = [PSCustomObject]@{ DisplayName = $Name AuditState = if ($Pass) { \"PASS\" } else { \"FAIL\" } IncludeUsers = $Users IncludeApplications = $Targets ClientAppTypes = $ClientApps AppEnforcedRestrictions = $Restrictions State = $State } $CapReport.Add($obj) } if ($Caps) { $CapReport } else { Write-Host \"** FAIL **: There are no qualifying conditional access policies.\" } 3. The script will output qualifying Conditional Access Policies. If one policy passes, then the recommendation passes. A passing policy will have the following properties: DisplayName : (CIS) Idle timeout for unmanaged AuditState : PASS IncludeUsers : {All} # IncludeUsers not currently scored IncludeApplications : {Office365} ClientAppTypes : {browser} AppEnforcedRestrictions : True State : enabled Note: Both steps 1 and 2 must pass audit checks in order for the recommendation to pass as a whole.", + "AdditionalInformation": "According to Microsoft idle session timeout isn't supported when third party cookies are disabled in the browser. Users won't see any sign-out prompts.", + "DefaultValue": "Not configured. (Idle sessions will not timeout.)", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/idle-session-timeout-web-apps?view=o365-worldwide" + } + ] + }, + { + "Id": "1.3.3", + "Description": "External calendar sharing allows an administrator to enable the ability for users to share calendars with anyone outside of the organization. Outside users will be sent a URL that can be used to view the calendar.", + "Checks": [ + "admincenter_external_calendar_sharing_disabled" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "External calendar sharing allows an administrator to enable the ability for users to share calendars with anyone outside of the organization. Outside users will be sent a URL that can be used to view the calendar.", + "RationaleStatement": "Attackers often spend time learning about organizations before launching an attack. Publicly available calendars can help attackers understand organizational relationships and determine when specific users may be more vulnerable to an attack, such as when they are traveling.", + "ImpactStatement": "This functionality is not widely used. As a result, it is unlikely that implementation of this setting will cause an impact to most users. Users that do utilize this functionality are likely to experience a minor inconvenience when scheduling meetings or synchronizing calendars with people outside the tenant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings select Org settings. 3. In the Services section click Calendar. 4. Uncheck Let your users share their calendars with people outside of your organization who have Office 365 or Exchange. 5. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell command: Set-SharingPolicy -Identity \"Default Sharing Policy\" -Enabled $False", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. In the Services section click Calendar. 4. Verify that Let your users share their calendars with people outside of your organization who have Office 365 or Exchange is unchecked. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell command: Get-SharingPolicy -Identity \"Default Sharing Policy\" | ft Name,Enabled 3. Verify that Enabled is set to False", + "AdditionalInformation": "The following script can be used to audit any mailboxes that might be sharing calendars prior to disabling the feature globally: $mailboxes = Get-Mailbox -ResultSize Unlimited foreach ($mailbox in $mailboxes) { # Get the name of the default calendar folder (depends on the mailbox's language) $calendarFolder = [string](Get-ExoMailboxFolderStatistics $mailbox.PrimarySmtpAddress -FolderScope Calendar| Where-Object { $_.FolderType -eq 'Calendar' }).Name # Get users calendar folder settings for their default Calendar folder # calendar has the format identity:\\ $calendar = Get-MailboxCalendarFolder -Identity \"$($mailbox.PrimarySmtpAddress):\\$calendarFolder\" if ($calendar.PublishEnabled) { Write-Host -ForegroundColor Yellow \"Calendar publishing is enabled for $($mailbox.PrimarySmtpAddress) on $($calendar.PublishedCalendarUrl)\" } }", + "DefaultValue": "Enabled (True)", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/share-calendars-with-external-users?view=o365-worldwide" + } + ] + }, + { + "Id": "1.3.4", + "Description": "By default, users can install add-ins in their Microsoft Word, Excel, and PowerPoint applications, allowing data access within the application. Do not allow users to install add-ins in Word, Excel, or PowerPoint.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "By default, users can install add-ins in their Microsoft Word, Excel, and PowerPoint applications, allowing data access within the application. Do not allow users to install add-ins in Word, Excel, or PowerPoint.", + "RationaleStatement": "Attackers commonly use vulnerable and custom-built add-ins to access data in user applications. While allowing users to install add-ins by themselves does allow them to easily acquire useful add-ins that integrate with Microsoft applications, it can represent a risk if not used and monitored carefully. Disabling future users' ability to install add-ins in Microsoft Word, Excel, or PowerPoint helps reduce your threat-surface and mitigate this risk.", + "ImpactStatement": "Implementation of this change will impact both end users and administrators. End users will not be able to install add-ins that they may want to install.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. In Services select User owned apps and services. 4. Uncheck Let users access the Office Store and Let users start trials on behalf of your organization. 5. Click Save. To remediate using PowerShell 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-AppsAndServices.ReadWrite.All\". 2. Run the following Microsoft Graph PowerShell commands: $uri = \"https://graph.microsoft.com/beta/admin/appsAndServices\" $body = @{ \"Settings\" = @{ \"isAppAndServicesTrialEnabled\" = $false \"isOfficeStoreEnabled\" = $false } } | ConvertTo-Json Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. In Services select User owned apps and services. 4. Verify that Let users access the Office Store and Let users start trials on behalf of your organization are not checked. To Audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-AppsAndServices.Read.All\". 2. Run the following Microsoft Graph PowerShell command: $Uri = \"https://graph.microsoft.com/beta/admin/appsAndServices/settings\" Invoke-MgGraphRequest -Uri $Uri 3. Verify both isOfficeStoreEnabled and isAppAndServicesTrialEnabled are False.", + "AdditionalInformation": "", + "DefaultValue": "Let users access the Office Store is Checked Let users start trials on behalf of your organization is Checked", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/manage-addins-in-the-admin-center?view=o365-worldwide#manage-add-in-downloads-by-turning-onoff-the-office-store-across-all-apps-except-outlook" + } + ] + }, + { + "Id": "1.3.5", + "Description": "Microsoft Forms can be used for phishing attacks by asking personal or sensitive information and collecting the results. Microsoft 365 has built-in protection that will proactively scan for phishing attempt in forms such personal information request.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Forms can be used for phishing attacks by asking personal or sensitive information and collecting the results. Microsoft 365 has built-in protection that will proactively scan for phishing attempt in forms such personal information request.", + "RationaleStatement": "Enabling internal phishing protection for Microsoft Forms will prevent attackers using forms for phishing attacks by asking personal or other sensitive information and URLs.", + "ImpactStatement": "If potential phishing was detected, the form will be temporarily blocked and cannot be distributed, and response collection will not happen until it is unblocked by the administrator or keywords were removed by the creator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Microsoft Forms. 4. Click the checkbox labeled Add internal phishing protection under Phishing protection. 5. Click Save. To remediate using PowerShell 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-AppsAndServices.ReadWrite.All\". 2. Run the following Microsoft Graph PowerShell commands: $uri = 'https://graph.microsoft.com/beta/admin/forms/settings' $body = @{ \"isInOrgFormsPhishingScanEnabled\" = $true } | ConvertTo-Json Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Microsoft Forms. 4. Verify the checkbox labeled Add internal phishing protection is checked under Phishing protection. To Audit using PowerShell: 1. Connect to the Microsoft Graph service using Connect-MgGraph -Scopes \"OrgSettings-Forms.Read.All\". 2. Run the following Microsoft Graph PowerShell commands: $uri = 'https://graph.microsoft.com/beta/admin/forms/settings' Invoke-MgGraphRequest -Uri $uri | select isInOrgFormsPhishingScanEnabled 3. Verify that isInOrgFormsPhishingScanEnabled is 'True'.", + "AdditionalInformation": "", + "DefaultValue": "Internal Phishing Protection is enabled.", + "References": "https://learn.microsoft.com/en-US/microsoft-forms/administrator-settings-microsoft-forms:https://learn.microsoft.com/en-US/microsoft-forms/review-unblock-forms-users-detected-blocked-potential-phishing" + } + ] + }, + { + "Id": "1.3.6", + "Description": "Customer Lockbox is a security feature that provides an additional layer of control and transparency to customer data in Microsoft 365. It offers an approval process for Microsoft support personnel to access organization data and creates an audited trail to meet compliance requirements.", + "Checks": [ + "admincenter_organization_customer_lockbox_enabled" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Customer Lockbox is a security feature that provides an additional layer of control and transparency to customer data in Microsoft 365. It offers an approval process for Microsoft support personnel to access organization data and creates an audited trail to meet compliance requirements.", + "RationaleStatement": "Enabling this feature protects organizational data against data spillage and exfiltration.", + "ImpactStatement": "Administrators will need to grant Microsoft access to the tenant environment prior to a Microsoft engineer accessing the environment for support or troubleshooting.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Select Security & privacy tab. 4. Click Customer lockbox. 5. Check the box Require approval for all data access requests. 6. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OrganizationConfig -CustomerLockBoxEnabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Select Security & privacy tab. 4. Click Customer lockbox. 5. Verify the box labeled Require approval for all data access requests is checked. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | Select-Object CustomerLockBoxEnabled 3. Verify the value is set to True.", + "AdditionalInformation": "", + "DefaultValue": "Require approval for all data access requests - Unchecked CustomerLockboxEnabled - False", + "References": "https://learn.microsoft.com/en-us/purview/customer-lockbox-requests#turn-customer-lockbox-requests-on-or-off" + } + ] + }, + { + "Id": "1.3.7", + "Description": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. Ensure Microsoft 365 on the web third-party storage services are restricted.", + "Checks": [ + "exchange_mailbox_policy_additional_storage_restricted" + ], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. Ensure Microsoft 365 on the web third-party storage services are restricted.", + "RationaleStatement": "By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security.", + "ImpactStatement": "Impact associated with this change is highly dependent upon current practices in the tenant. If users do not use other storage providers, then minimal impact is likely. However, if users do regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Go to Settings > Org Settings > Services > Microsoft 365 on the web 3. Uncheck Let users open files stored in third-party storage services in Microsoft 365 on the web To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Application.ReadWrite.All\" 2. Run the following script: $SP = Get-MgServicePrincipal -Filter \"appId eq 'c1f33bc0-bdb4-4248-ba9b- 096807ddb43e'\" # If the service principal doesn't exist then create it first. if (-not $SP) { $SP = New-MgServicePrincipal -AppId \"c1f33bc0-bdb4-4248-ba9b- 096807ddb43e\" } Update-MgServicePrincipal -ServicePrincipalId $SP.Id -AccountEnabled:$false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Go to Settings > Org Settings > Services > Microsoft 365 on the web 3. Verify that Let users open files stored in third-party storage services in Microsoft 365 on the web is not checked. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Application.Read.All\". 2. Run the following script: $SP = Get-MgServicePrincipal -Filter \"appId eq 'c1f33bc0-bdb4-4248-ba9b- 096807ddb43e'\" if ((-not $SP) -or $SP.AccountEnabled) { Write-Host \"Audit Result: ** FAIL **\" } else { Write-Host \"Audit Result: ** PASS **\" } 3. Verify that AccountEnabled is False. Note: The check will also fail if the Service Principal does not exist as users will still be able to open files stored in third-party storage services in Microsoft 365 on the web.", + "AdditionalInformation": "", + "DefaultValue": "Enabled - Users are able to open files stored in third-party storage services", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/set-up-file-storage-and-sharing?view=o365-worldwide#enable-or-disable-third-party-storage-services" + } + ] + }, + { + "Id": "1.3.8", + "Description": "Sway is a Microsoft 365 app that lets organizations create interactive, web-based presentations using images, text, videos and other media. Its design engine simplifies the process, allowing for quick customization. Presentations can then be shared via a link. This setting controls user Sway sharing capability, both within and outside of the organization. By default, Sway is enabled for everyone in the organization.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "Sway is a Microsoft 365 app that lets organizations create interactive, web-based presentations using images, text, videos and other media. Its design engine simplifies the process, allowing for quick customization. Presentations can then be shared via a link. This setting controls user Sway sharing capability, both within and outside of the organization. By default, Sway is enabled for everyone in the organization.", + "RationaleStatement": "Disable external sharing of Sway documents that can contain sensitive information to prevent accidental or arbitrary data leaks.", + "ImpactStatement": "Interactive reports, presentations, newsletters, and other items created in Sway will not be shared outside the organization by users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Sway o Uncheck: Let people in your organization share their sways with people outside your organization. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings > Org settings. 3. Under Services select Sway. 4. Verify that under Sharing, the following is not checked: o Let people in your organization share their sways with people outside your organization.", + "AdditionalInformation": "", + "DefaultValue": "Let people in your organization share their sways with people outside your organization - Enabled", + "References": "https://support.microsoft.com/en-us/office/administrator-settings-for-sway-d298e79b-b6ab-44c6-9239-aa312f5784d4:https://learn.microsoft.com/en-us/office365/servicedescriptions/microsoft-sway-service-description" + } + ] + }, + { + "Id": "1.3.9", + "Description": "Shared Bookings allows you to invite your team members and create booking pages and let your customers book time with you and your team. It contains various settings to define services, manage staff members, configure schedules and availability, business hours and customize how appointments are scheduled. These pages can be customized to fit the diverse needs of your organization. It is an extension of Person Bookings. The recommended state is to restrict the OwaMailboxPolicy-Default policy or disable at the organization level.", + "Checks": [], + "Attributes": [ + { + "Section": "1 Microsoft 365 admin center", + "SubSection": "1.3 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Shared Bookings allows you to invite your team members and create booking pages and let your customers book time with you and your team. It contains various settings to define services, manage staff members, configure schedules and availability, business hours and customize how appointments are scheduled. These pages can be customized to fit the diverse needs of your organization. It is an extension of Person Bookings. The recommended state is to restrict the OwaMailboxPolicy-Default policy or disable at the organization level.", + "RationaleStatement": "Shared Bookings pages can be exploited by threat actors to impersonate legitimate users using convincing internal email addresses. A compromised low-privilege account could be used to mimic high-profile identities (e.g., the CEO) and bypass impersonation filters to initiate fraudulent actions like fund transfers. Additionally, attackers may create authoritative-looking addresses (e.g., admin@, hostmaster@) to conduct social engineering attacks on external parties aimed at the transfer of infrastructure control. To reduce this risk, access to Shared Bookings should be limited to users with a clear business need and subject to monitoring and governance.", + "ImpactStatement": "Disabling Shared Bookings will limit users' ability to create self-service scheduling pages, which may reduce convenience for teams that rely on automated meeting coordination. Approved users will need to be added to a separate OWA Policy which will increase administrative overhead. Note: Before modifying the default owa policy, ensure that any users who rely on Shared Bookings are assigned a separate policy that explicitly allows its use. This will help prevent unintended service disruptions.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OwaMailboxPolicy \"OwaMailboxPolicy-Default\" - BookingsMailboxCreationEnabled $false Optionally: For a more restrictive state Bookings can be disabled at the organization level 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Set-OrganizationConfig -BookingsEnabled $false Note: Disabling Bookings at the tenant (organization) level will be more impactful to end users and is not required for compliance.", + "AuditProcedure": "Ensure Shared Bookings is turned off in the OWA Default policy. If booking is disabled at the tenant (OrganizationConfig) level this is also a compliant state. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Get-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default | fl BookingsMailboxCreationEnabled 3. Verify that BookingsMailboxCreationEnabled is set to False. Optionally: If Bookings is disabled at the organization level, this is also considered a compliant state. 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Get-OrganizationConfig | fl BookingsEnabled 3. If BookingsEnabled is set to False, the organization is using a more restrictive and compliant configuration. In this case changing the default OWA policy would not be required for compliance.", + "AdditionalInformation": "", + "DefaultValue": "BookingsMailboxCreationEnabled : True (OwaMailboxPolicy-Default) BookingsEnabled : True", + "References": "https://learn.microsoft.com/en-us/microsoft-365/bookings/turn-bookings-on-or-off?view=o365-worldwide:https://techcommunity.microsoft.com/blog/office365businessappsblog/enhancing-security-in-microsoft-bookings-best-practices-for-admins/4382447:https://learn.microsoft.com/en-us/microsoft-365/bookings/best-practices-shared-bookings?view=o365-worldwide&source=recommendations:https://www.cyberis.com/article/microsoft-bookings-facilitating-impersonation" + } + ] + }, + { + "Id": "2.1.1", + "Description": "Enabling Safe Links policy for Office applications allows URL's that exist inside of Office documents and email applications opened by Office, Office Online and Office mobile to be processed against Defender for Office time-of-click verification and rewritten if required. Note: E5 Licensing includes a number of Built-in Protection policies. When auditing policies note which policy you are viewing, and keep in mind CIS recommendations often extend the Default or Built-in Policies provided by MS. In order to Pass the highest priority policy must match all settings recommended.", + "Checks": [ + "defender_safelinks_policy_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Enabling Safe Links policy for Office applications allows URL's that exist inside of Office documents and email applications opened by Office, Office Online and Office mobile to be processed against Defender for Office time-of-click verification and rewritten if required. Note: E5 Licensing includes a number of Built-in Protection policies. When auditing policies note which policy you are viewing, and keep in mind CIS recommendations often extend the Default or Built-in Policies provided by MS. In order to Pass the highest priority policy must match all settings recommended.", + "RationaleStatement": "Safe Links for Office applications extends phishing protection to documents and emails that contain hyperlinks, even after they have been delivered to a user.", + "ImpactStatement": "User impact associated with this change is minor - users may experience a very short delay when clicking on URLs in Office documents before being directed to the requested site. Users should be informed of the change as, in the event a link is unsafe and blocked, they will receive a message that it has been blocked.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under Email & collaboration select Policies & rules 3. Select Threat policies then Safe Links 4. Click on +Create 5. Name the policy then click Next 6. In Domains select all valid domains for the organization and Next 7. Ensure the following URL & click protection settings are defined: Email o Checked On: Safe Links checks a list of known, malicious links when users click links in email. URLs are rewritten by default o Checked Apply Safe Links to email messages sent within the organization o Checked Apply real-time URL scanning for suspicious links and links that point to files o Checked Wait for URL scanning to complete before delivering the message o Unchecked Do not rewrite URLs, do checks via Safe Links API only. Teams o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Teams. URLs are not rewritten Office 365 Apps o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Office apps. URLs are not rewritten Click protection settings o Checked Track user clicks o Unchecked Let users click through the original URL o There is no recommendation for organization branding. 8. Click Next twice and finally Submit To remediate using PowerShell: 1. Connect using Connect-ExchangeOnline. 2. Run the following PowerShell script to create a policy at highest priority that will apply to all valid domains on the tenant: # Create the Policy $params = @{ Name = \"CIS SafeLinks Policy\" EnableSafeLinksForEmail = $true EnableSafeLinksForTeams = $true EnableSafeLinksForOffice = $true TrackClicks = $true AllowClickThrough = $false ScanUrls = $true EnableForInternalSenders = $true DeliverMessageAfterScan = $true DisableUrlRewrite = $false } New-SafeLinksPolicy @params # Create the rule for all users in all valid domains and associate with Policy New-SafeLinksRule -Name \"CIS SafeLinks\" -SafeLinksPolicy \"CIS SafeLinks Policy\" -RecipientDomainIs (Get-AcceptedDomain).Name -Priority 0", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under Email & collaboration select Policies & rules 3. Select Threat policies then Safe Links 4. Inspect each policy and attempt to identify one that matches the parameters outlined below. 5. Scroll down the pane and click on Edit Protection settings (Global Readers will look for on or off values) 6. Verify that the following protection settings are set as outlined: Email o Checked On: Safe Links checks a list of known, malicious links when users click links in email. URLs are rewritten by default o Checked Apply Safe Links to email messages sent within the organization o Checked Apply real-time URL scanning for suspicious links and links that point to files o Checked Wait for URL scanning to complete before delivering the message o Unchecked Do not rewrite URLs, do checks via Safe Links API only. Teams o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Teams. URLs are not rewritten Office 365 Apps o Checked On: Safe Links checks a list of known, malicious links when users click links in Microsoft Office apps. URLs are not rewritten Click protection settings oChecked Track user clicks oUnchecked Let users click through the original URL 7. There is no recommendation for organization branding. 8. Click close To audit using PowerShell: 1. Connect using Connect-ExchangeOnline. 2. Run the following to output properties from all Safe Links policies: $params = @( 'Identity', 'EnableSafeLinksForEmail', 'EnableSafeLinksForTeams', 'EnableSafeLinksForOffice', 'TrackClicks', 'AllowClickThrough', 'ScanUrls', 'EnableForInternalSenders', 'DeliverMessageAfterScan', 'DisableUrlRewrite' ) Get-SafeLinksPolicy | Select-Object -Property $Params 3. Verify there is at least one policy that matches the properties and values below: Identity : EnableSafeLinksForEmail : True EnableSafeLinksForTeams : True EnableSafeLinksForOffice : True TrackClicks : True AllowClickThrough : False ScanUrls : True EnableForInternalSenders : True DeliverMessageAfterScan : True DisableUrlRewrite : False", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-links-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/powershell/module/exchange/set-safelinkspolicy?view=exchange-ps:https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies?view=o365-worldwide" + } + ] + }, + { + "Id": "2.1.2", + "Description": "The Common Attachment Types Filter is a setting within Exchange Online Protection's anti-malware policy that blocks inbound and outbound email messages containing attachments of specified file types. When enabled, messages with attachments matching the blocked extensions are quarantined before delivery. Microsoft maintains a default set of file types considered high risk; organizations may also add custom extensions to the list. The recommended state is Enable the common attachments filter set to On, on the default anti-malware policy, with the default list of blocked file types.", + "Checks": [ + "defender_malware_policy_common_attachments_filter_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The Common Attachment Types Filter is a setting within Exchange Online Protection's anti-malware policy that blocks inbound and outbound email messages containing attachments of specified file types. When enabled, messages with attachments matching the blocked extensions are quarantined before delivery. Microsoft maintains a default set of file types considered high risk; organizations may also add custom extensions to the list. The recommended state is Enable the common attachments filter set to On, on the default anti-malware policy, with the default list of blocked file types.", + "RationaleStatement": "Email is a primary delivery vector for malware, including ransomware, trojans, and remote access tools distributed via executable, script, and installer file formats. The Common Attachment Types Filter blocks delivery of file types that have no legitimate business use in email but are routinely weaponized (such as .exe, .vbs, .bat, .msi), and similar formats. Enforcing this filter at the gateway reduces the attack surface before any client-side or endpoint control has the opportunity to respond.", + "ImpactStatement": "Emails containing attachments with blocked extensions, including those sent by trusted internal senders, will be quarantined and not delivered. Some file types in the default block list may be used legitimately in some IT workflows. Administrators who need to permit specific extensions for specific users or groups should create a scoped custom anti-malware policy with a higher priority than the Default policy rather than modifying the Default policy's file type list.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under polices select Anti-malware and click on the Default (Default) policy. 5. On the Policy page that appears on the right hand pane scroll to the bottom and click on Edit protection settings, check the Enable the common attachments filter. o If any of the default file types are missing click Select file types and add the missing file types in. o Reference the Default Value section of this document for the list of extensions that should be blocked. 6. Click Save to save the changes. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following to enable the common attachment filter: Set-MalwareFilterPolicy -Identity Default -EnableFileFilter $true 3. Use Set-MalwareFilterPolicy -Identity Default with the -FileTypes parameter to add any missing file types from the default list. o FileTypes accepts an array of strings. o To avoid using it destructively, first retrieve the existing list of file types using Get-MalwareFilterPolicy and append any missing file types to the list before using Set-MalwareFilterPolicy to update the policy.", + "AuditProcedure": "Note: The following procedures audit only the Default anti-malware policy. Auditing custom policies is not required for compliance and is discretionary based on the organization's needs. To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Anti-malware and click on the Default (Default) policy. 5. On the policy page that appears on the righthand pane, under Protection settings, verify that the Enable the common attachments filter has the value of On. 6. Click on Edit protection settings to view the list of file types that are blocked by the common attachment filter. Verify that the list of file types contains at least the 53 file types found in the Default Value section of this document. Note: Verifying the complete file type list via the UI requires manual comparison against the default extensions listed in the Default Value section of this document. Auditors who require a programmatic comparison should use the PowerShell method. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell command: Get-MalwareFilterPolicy -Identity Default 3. Verify that the EnableFileFilter property has the value of True. 4. Verify that the FileTypes property contains at least default list of 53 file types found in the Default Value section of this document.", + "AdditionalInformation": "", + "DefaultValue": "EnableFileFilter : True Default extensions: [ \"ani\", \"apk\", \"app\", \"appx\", \"arj\", \"bat\", \"cab\", \"cmd\", \"com\", \"deb\", \"dex\", \"dll\", \"docm\", \"elf\", \"exe\", \"hta\", \"img\", \"iso\", \"jar\", \"jnlp\", \"kext\", \"lha\", \"lib\", \"library\", \"lnk\", \"lzh\", \"macho\", \"msc\", \"msi\", \"msix\", \"msp\", \"mst\", \"pif\", \"ppa\", \"ppam\", \"reg\", \"rev\", \"scf\", \"scr\", \"sct\", \"sys\", \"uif\", \"vb\", \"vbe\", \"vbs\", \"vxd\", \"wsc\", \"wsf\", \"wsh\", \"xll\", \"xz\", \"z\", \"ace\" ]", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/defender-office-365/anti-malware-policies-configure?view=o365-worldwide" + } + ] + }, + { + "Id": "2.1.3", + "Description": "Exchange Online Protection (EOP) is Microsoft's cloud-based filtering service that protects organizations against spam, malware, and other email threats. EOP is included in all Microsoft 365 organizations with Exchange Online mailboxes. EOP uses flexible anti-malware policies for malware protection settings. These policies can be set to notify Admins of malicious activity.", + "Checks": [ + "defender_malware_policy_notifications_internal_users_malware_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Exchange Online Protection (EOP) is Microsoft's cloud-based filtering service that protects organizations against spam, malware, and other email threats. EOP is included in all Microsoft 365 organizations with Exchange Online mailboxes. EOP uses flexible anti-malware policies for malware protection settings. These policies can be set to notify Admins of malicious activity.", + "RationaleStatement": "This setting alerts administrators that an internal user sent a message that contained malware. This may indicate an account or machine compromise that would need to be investigated.", + "ImpactStatement": "Notification of account with potential issues should not have an impact on the user.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Anti-malware. 5. Click on the Default (Default) policy. 6. Click on Edit protection settings and change the settings for Notify an admin about undelivered messages from internal senders to On and enter the email address of the administrator who should be notified under Administrator email address. 7. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Set-MalwareFilterPolicy -Identity '{Identity Name}' - EnableInternalSenderAdminNotifications $True -InternalSenderAdminAddress {admin@domain1.com} Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Anti-malware. 5. Click on the Default (Default) policy. 6. Verify that Notify an admin about undelivered messages from internal senders is set to On and that there is at least one email address under Administrator email address. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following command: Get-MalwareFilterPolicy | fl Identity, EnableInternalSenderAdminNotifications, InternalSenderAdminAddress 3. Verify that EnableInternalSenderAdminNotifications is set to True and a InternalSenderAdminAddress address is defined. Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AdditionalInformation": "", + "DefaultValue": "EnableInternalSenderAdminNotifications : False InternalSenderAdminAddress : $null", + "References": "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-about:https://learn.microsoft.com/en-us/defender-office-365/anti-malware-policies-configure" + } + ] + }, + { + "Id": "2.1.4", + "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", + "Checks": [ + "defender_safe_attachments_policy_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", + "RationaleStatement": "Enabling Safe Attachments policy helps protect against malware threats in email attachments by analyzing suspicious attachments in a secure, cloud-based environment before they are delivered to the user's inbox. This provides an additional layer of security and can prevent new or unseen types of malware from infiltrating the organization's network.", + "ImpactStatement": "Delivery of email with attachments may be delayed while scanning is occurring.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Safe Attachments. 5. Click + Create. 6. Create a Policy Name and Description, and then click Next. 7. Select all valid domains and click Next. 8. Select Block. 9. Quarantine policy is AdminOnlyAccessPolicy. 10. Leave Enable redirect unchecked. 11. Click Next and finally Submit. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. To change an existing policy modify the example below and run the following PowerShell command: Set-SafeAttachmentPolicy -Identity 'Example policy' -Action 'Block' - QuarantineTag 'AdminOnlyAccessPolicy' -Enable $true 3. Or, edit and run the below example to create a new safe attachments policy. New-SafeAttachmentPolicy -Name \"CIS 2.1.4\" -Enable $true -Action 'Block' - QuarantineTag 'AdminOnlyAccessPolicy' New-SafeAttachmentRule -Name \"CIS 2.1.4 Rule\" -SafeAttachmentPolicy \"CIS 2.1.4\" -RecipientDomainIs 'exampledomain[.]com' Note: Policy targets such as users and domains should include domains, or groups that provide coverage for a majority of users in the organization. Different inclusion and exclusion use cases are not covered in the benchmark.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand E-mail & Collaboration > Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies select Safe Attachments. 5. Inspect the highest priority policy. 6. Verify that Users and domains and Included recipient domains are in scope for the organization. 7. Verify that Safe Attachments detection response: is set to Block - Block current and future messages and attachments with detected malware. 8. Verify that Quarantine Policy is set to AdminOnlyAccessPolicy. 9. Verify that the policy is not disabled. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-SafeAttachmentPolicy | ft Identity,Enable,Action,QuarantineTag 3. Inspect the highest priority safe attachments policy and ensure the properties and values match the below: Enable : True Action : Block QuarantineTag : AdminOnlyAccessPolicy Note: To view the priority for a policy the Get-SafeAttachmentRule must be used. Built-in policies will always have a priority of lowest while presets like strict and standard can be viewed with Get-ATPProtectionPolicyRule. Strict and standard presets always operate at a higher priority than custom policies.", + "AdditionalInformation": "", + "DefaultValue": "Identity : Built-In Protection Policy Enable : True Action : Block QuarantineTag : AdminOnlyAccessPolicy Priority : (lowest)", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-about:https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-policies-configure" + } + ] + }, + { + "Id": "2.1.5", + "Description": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams scans these services for malicious files.", + "Checks": [ + "defender_atp_safe_attachments_and_docs_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams scans these services for malicious files.", + "RationaleStatement": "Safe Attachments for SharePoint, OneDrive, and Microsoft Teams protect organizations from inadvertently sharing malicious files. When a malicious file is detected that file is blocked so that no one can open, copy, move, or share it until further actions are taken by the organization's security team.", + "ImpactStatement": "Impact associated with Safe Attachments is minimal, and equivalent to impact associated with anti-virus scanners in an environment.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under Email & collaboration select Policies & rules 3. Select Threat policies then Safe Attachments. 4. Click on Global settings 5. Click to Enable Turn on Defender for Office 365 for SharePoint, OneDrive, and Microsoft Teams 6. Click to Enable Turn on Safe Documents for Office clients 7. Click to Disable Allow people to click through Protected View even if Safe Documents identified the file as malicious. 8. Click Save To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-AtpPolicyForO365 -EnableATPForSPOTeamsODB $true -EnableSafeDocs $true - AllowSafeDocsOpen $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Under Email & collaboration select Policies & rules. 3. Select Threat policies then Safe Attachments. 4. Click on Global settings. 5. Verify that the Turn on Defender for Office 365 for SharePoint, OneDrive, and Microsoft Teams toggle is set to Enabled. 6. Verify that the Turn on Safe Documents for Office clients toggle is set to Enabled. 7. Verify that the Allow people to click through Protected View even if Safe Documents identified the file as malicious toggle is set to Disabled. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-AtpPolicyForO365 | fl Name,EnableATPForSPOTeamsODB,EnableSafeDocs,AllowSafeDocsOpen Verify the values for each parameter as below: EnableATPForSPOTeamsODB : True EnableSafeDocs : True AllowSafeDocsOpen : False", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-for-spo-odfb-teams-about" + } + ] + }, + { + "Id": "2.1.6", + "Description": "In Microsoft 365 organizations with mailboxes in Exchange Online or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, email messages are automatically protected against spam (junk email) by EOP. Configure Exchange Online Spam Policies to copy emails and notify someone when a sender in the organization has been blocked for sending spam emails.", + "Checks": [ + "defender_antispam_outbound_policy_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft 365 organizations with mailboxes in Exchange Online or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, email messages are automatically protected against spam (junk email) by EOP. Configure Exchange Online Spam Policies to copy emails and notify someone when a sender in the organization has been blocked for sending spam emails.", + "RationaleStatement": "A blocked account is a good indication that the account in question has been breached, and an attacker is using it to send spam emails to other people.", + "ImpactStatement": "Notification of users that have been blocked should not cause an impact to the user.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Anti-spam outbound policy (default). 5. Select Edit protection settings then under Notifications: 6. Check Send a copy of suspicious outbound messages or message that exceed these limits to these users and groups then enter the desired email addresses. 7. Check Notify these users and groups if a sender is blocked due to sending outbound spam then enter the desired email addresses. 8. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: $BccEmailAddress = @(\"\") $NotifyEmailAddress = @(\"\") Set-HostedOutboundSpamFilterPolicy -Identity Default - BccSuspiciousOutboundAdditionalRecipients $BccEmailAddress - BccSuspiciousOutboundMail $true -NotifyOutboundSpam $true - NotifyOutboundSpamRecipients $NotifyEmailAddress Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Anti-spam outbound policy (default). 5. Verify that Send a copy of suspicious outbound messages or message that exceed these limits to these users and groups is set to On, ensure the email address is correct. 6. Verify that Notify these users and groups if a sender is blocked due to sending outbound spam is set to On, ensure the email address is correct. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedOutboundSpamFilterPolicy | Select-Object Bcc*, Notify* 3. Verify both BccSuspiciousOutboundMail and NotifyOutboundSpam are set to True and the email addresses to be notified are correct. Note: Audit and Remediation guidance may focus on the Default policy however, if a Custom Policy exists in the organization's tenant, then ensure the setting is set as outlined in the highest priority policy listed.", + "AdditionalInformation": "", + "DefaultValue": "BccSuspiciousOutboundAdditionalRecipients : {} BccSuspiciousOutboundMail : False NotifyOutboundSpamRecipients : {} NotifyOutboundSpam : False", + "References": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about" + } + ] + }, + { + "Id": "2.1.7", + "Description": "By default, Office 365 includes built-in features that help protect users from phishing attacks. Set up anti-phishing polices to increase this protection, for example by refining settings to better detect and prevent impersonation and spoofing attacks. The default policy applies to all users within the organization and is a single view to fine-tune anti- phishing protection. Custom policies can be created and configured for specific users, groups or domains within the organization and will take precedence over the default policy for the scoped users.", + "Checks": [ + "defender_antiphishing_policy_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "By default, Office 365 includes built-in features that help protect users from phishing attacks. Set up anti-phishing polices to increase this protection, for example by refining settings to better detect and prevent impersonation and spoofing attacks. The default policy applies to all users within the organization and is a single view to fine-tune anti- phishing protection. Custom policies can be created and configured for specific users, groups or domains within the organization and will take precedence over the default policy for the scoped users.", + "RationaleStatement": "Protects users from phishing attacks (like impersonation and spoofing) and uses safety tips to warn users about potentially harmful messages.", + "ImpactStatement": "Mailboxes that are used for support systems such as helpdesk and billing systems send mail to internal users and are often not suitable candidates for impersonation protection. Care should be taken to ensure that these systems are excluded from Impersonation Protection.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules 3. Select Threat policies. 4. Under Policies select Anti-phishing and click Create. 5. Name the policy, continuing and clicking Next as needed: o Add Groups and/or Domains that contain a majority of the organization. o Set Phishing email threshold to 3 - More Aggressive o Check Enable users to protect and add up to 350 users o Check Enable domains to protect and check Include domains I own o Check Enable mailbox intelligence (Recommended) o Check Enable Intelligence for impersonation protection (Recommended) o Check Enable spoof intelligence (Recommended) 6. Under Actions configure the following: o Set If a message is detected as user impersonation to Quarantine the message o Set If a message is detected as domain impersonation to Quarantine the message o Set If Mailbox Intelligence detects an impersonated user to Quarantine the message o Leave Honor DMARC record policy when the message is detected as spoof checked. o Check Show first contact safety tip (Recommended) o Check Show user impersonation safety tip o Check Show domain impersonation safety tip o Check Show user impersonation unusual characters safety tip 7. Finally, click Next and Submit the policy. Note: DefaultFullAccessWithNotificationPolicy is suggested but not required. Users will be notified that impersonation emails are in the Quarantine. To remediate using PowerShell: 1. Connect to Exchange Online service using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell script to create an AntiPhish policy: # Create the Policy $params = @{ Name = \"CIS AntiPhish Policy\" PhishThresholdLevel = 3 EnableTargetedUserProtection = $true EnableOrganizationDomainsProtection = $true EnableMailboxIntelligence = $true EnableMailboxIntelligenceProtection = $true EnableSpoofIntelligence = $true TargetedUserProtectionAction = 'Quarantine' TargetedDomainProtectionAction = 'Quarantine' MailboxIntelligenceProtectionAction = 'Quarantine' TargetedUserQuarantineTag = 'DefaultFullAccessWithNotificationPolicy' MailboxIntelligenceQuarantineTag = 'DefaultFullAccessWithNotificationPolicy' TargetedDomainQuarantineTag = 'DefaultFullAccessWithNotificationPolicy' EnableFirstContactSafetyTips = $true EnableSimilarUsersSafetyTips = $true EnableSimilarDomainsSafetyTips = $true EnableUnusualCharactersSafetyTips = $true HonorDmarcPolicy = $true } New-AntiPhishPolicy @params # Create the rule for all users in all valid domains and associate with Policy New-AntiPhishRule -Name $params.Name -AntiPhishPolicy $params.Name - RecipientDomainIs (Get-AcceptedDomain).Name -Priority 0 3. The new policy can be edited in the UI or via PowerShell. Note: Remediation guidance is intended to help create a qualifying AntiPhish policy that meets the recommended criteria while protecting the majority of the organization. It's understood some individual user exceptions may exist or exceptions for the entire policy if another product acts as a similar control.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules 3. Select Threat policies. 4. Under Policies select Anti-phishing. 5. Verify an AntiPhish policy exists that is On and meets the following criteria: 6. Under Users, groups, and domains o Verify that the included domains and groups includes a majority of the organization. 7. Under Phishing threshold & protection verify the following: o Phishing email threshold is at least 3 - More Aggressive. o User impersonation protection is On and contains a subset of users. o Domain impersonation protection is On for owned domains. o Mailbox intelligence and Mailbox intelligence for impersonations and Spoof intelligence are On. 8. Under Actions verify the following: o If a message is detected as user impersonation is set to Quarantine the message. o If a message is detected as domain impersonation is set to Quarantine the message. o If Mailbox Intelligence detects an impersonated user is set to Quarantine the message. o First contact safety tip is On. o User impersonation safety tip is On. o Domain impersonation safety tip is On. o Unusual characters safety tip is On. o Honor DMARC record policy when the message is detected as spoof is On. Note: DefaultFullAccessWithNotificationPolicy is suggested but not required. Users will be notified that impersonation emails are in the Quarantine. To audit using PowerShell: 1. Connect to Exchange Online service using Connect-ExchangeOnline. 2. Run the following Exchange Online PowerShell commands: $params = @( \"name\",\"Enabled\",\"PhishThresholdLevel\",\"EnableTargetedUserProtection\" \"EnableOrganizationDomainsProtection\",\"EnableMailboxIntelligence\" \"EnableMailboxIntelligenceProtection\",\"EnableSpoofIntelligence\" \"TargetedUserProtectionAction\",\"TargetedDomainProtectionAction\" \"MailboxIntelligenceProtectionAction\",\"EnableFirstContactSafetyTips\" \"EnableSimilarUsersSafetyTips\",\"EnableSimilarDomainsSafetyTips\" \"EnableUnusualCharactersSafetyTips\",\"TargetedUsersToProtect\" \"HonorDmarcPolicy\" ) Get-AntiPhishPolicy | fl $params 3. Verify there is a policy created that has matching values for the following parameters: Enabled : True PhishThresholdLevel : 3 EnableTargetedUserProtection : True EnableOrganizationDomainsProtection : True EnableMailboxIntelligence : True EnableMailboxIntelligenceProtection : True EnableSpoofIntelligence : True TargetedUserProtectionAction : Quarantine TargetedDomainProtectionAction : Quarantine MailboxIntelligenceProtectionAction : Quarantine EnableFirstContactSafetyTips : True EnableSimilarUsersSafetyTips : True EnableSimilarDomainsSafetyTips : True EnableUnusualCharactersSafetyTips : True TargetedUsersToProtect : {} HonorDmarcPolicy : True 4. Verify that TargetedUsersToProtect contains a subset of the organization, up to 350 users, for targeted Impersonation Protection. 5. Use PowerShell to verify the AntiPhishRule is configured and enabled. Get-AntiPhishRule | ft AntiPhishPolicy,Priority,State,SentToMemberOf,RecipientDomainIs 6. Identity correct rule from the matching AntiPhishPolicy name in step 3. Ensure the rule defines groups or domains that include the majority of the organization by inspecting SentToMemberOf or RecipientDomainIs. Note: Audit guidance is intended to help identify a qualifying AntiPhish policy+rule that meets the recommended criteria while protecting the majority of the organization. It's understood some individual user exceptions may exist or exceptions for the entire policy if another product stands in as an equivalent control.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/anti-phishing-protection-about:https://learn.microsoft.com/en-us/defender-office-365/anti-phishing-policies-eop-configure" + } + ] + }, + { + "Id": "2.1.8", + "Description": "For each domain that is configured in Exchange, a corresponding Sender Policy Framework (SPF) record should be created.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "For each domain that is configured in Exchange, a corresponding Sender Policy Framework (SPF) record should be created.", + "RationaleStatement": "SPF records enable Exchange Online Protection and other mail systems to verify which servers are authorized to send email for a domain. This helps those systems determine whether a message is legitimate or potentially spoofed. Enforcing a -all or ~all ensures that any undocumented or unauthorized networks attempting to send on behalf of the organization are immediately rejected, reducing the risk of impersonation. If an organization does not have full visibility into where its email originates, this represents a significant security gap. For example, if an email server is sending mail from an unexpected location across the country without your knowledge, that is a serious issue. Addressing this requires a deliberate discovery process to identify all legitimate sending sources, rather than allowing unknown systems to continue sending email unchecked.", + "ImpactStatement": "Setting up SPF records typically has minimal operational impact. However, organizations must ensure proper configuration, as misconfigured SPF records can cause legitimate email to be flagged as spam or fail authentication checks. Additionally, identifying all legitimate senders outside of the default Microsoft 365 IP ranges may require extra time and coordination during the discovery phase.", + "RemediationProcedure": "To remediate using a DNS provider: For each domain identified as non-compliant during the audit, make the necessary updates in your DNS provider or through your third-party SPF management service. Missing SPF Record: If a domain does not currently have an SPF record, create one similar to the example below, assuming all email is routed through Exchange Online (Microsoft 365 or Microsoft 365 GCC): # Hard fail v=spf1 include:spf.protection.outlook.com -all # Soft fail v=spf1 include:spf.protection.outlook.com ~all Additional Senders: If other authorized email services are used, add their SPF entries using the include: mechanism as needed. For example: v=spf1 include:spf.protection.outlook.com include:exampledomain.net -all", + "AuditProcedure": "STEP 1: Determine the list of domains to audit Using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Settings > Domains. 3. Take note of all custom domains and subdomains defined. o Exclude the (MOERA) domain *.onmicrosoft.com o Exclude the coexistence domain *.mail.onmicrosoft.com (if shown). 4. Use this list of domains in the audit procedure. Using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: Get-AcceptedDomain | Where-Object {$_.IsCoExistenceDomain -eq $false -and $_.InitialDomain -eq $false} 3. Use this list in the audit procedure. Note: Microsoft owns the tenant's organizational level *.onmicrosoft.com domains, so it is not necessary to create SPF records for the initial (MOERA) or the coexistence (hybrid) domain. STEP 2: Perform the Audit using PowerShell: 1. Open a PowerShell prompt. 2. Run the following for each custom domain identified in Step 1 that sends email from Exchange Online: Resolve-DnsName domain1.com -Type TXT | fl Ensure the following criteria are met: 1. A valid TXT record must begin with v=spf1, which indicates it is SPF v1 (the current standard). 2. The record must be either directly or indirectly managed: o An indirectly managed record uses a modifier like redirect=[domain], which allows centralized SPF management by a trusted vendor. 3. The record must end with a hard or soft fail policy: o The record must end with: -all or ~all, allowing for a hard fail or soft fail. o If the SPF record uses the redirect= modifier, the redirected SPF record must terminate with a compliant qualifier (not the parent). This will require repeating the DNS lookup against the redirected domain. o Other qualifiers not listed are not compliant as an end state. Parked domains: Ensure that any domain not used for sending email has an SPF record explicitly indicating that no mail is authorized from that domain, using v=spf1 - all. Below are examples of SPF records that are compliant. The audit does not evaluate specific include domains for compliant states. v=spf1 include:spf.protection.outlook.com -all v=spf1 include:spf.protection.outlook.com ~all # GCC High or DoD example v=spf1 include:exampledomain.net include:spf.protection.office365.us ~all # 21Vianet v=spf1 include:spf.protection.partner.outlook.cn ~all # Parked domains v=spf1 -all Note: Resolve-DnsName is not available on versions of Windows earlier than Windows 8 and Windows Server 2012. Use alternatives such as nslookup when needed.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-spf-configure?view=o365-worldwide:https://datatracker.ietf.org/doc/html/rfc7208:https://learn.microsoft.com/en-us/defender-office-365/email-authentication-spf-configure?view=o365-worldwide#scenario-parked-domains" + } + ] + }, + { + "Id": "2.1.9", + "Description": "DKIM is one of the trio of Authentication methods (SPF, DKIM and DMARC) that help prevent attackers from sending messages that look like they come from your domain. DKIM lets an organization add a digital signature to outbound email messages in the message header. When DKIM is configured, the organization authorizes it's domain to associate, or sign, it's name to an email message using cryptographic authentication. Email systems that get email from this domain can use a digital signature to help verify whether incoming email is legitimate. Use of DKIM in addition to SPF and DMARC to help prevent malicious actors using spoofing techniques from sending messages that look like they are coming from your domain.", + "Checks": [ + "defender_domain_dkim_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "DKIM is one of the trio of Authentication methods (SPF, DKIM and DMARC) that help prevent attackers from sending messages that look like they come from your domain. DKIM lets an organization add a digital signature to outbound email messages in the message header. When DKIM is configured, the organization authorizes it's domain to associate, or sign, it's name to an email message using cryptographic authentication. Email systems that get email from this domain can use a digital signature to help verify whether incoming email is legitimate. Use of DKIM in addition to SPF and DMARC to help prevent malicious actors using spoofing techniques from sending messages that look like they are coming from your domain.", + "RationaleStatement": "By enabling DKIM with Office 365, messages that are sent from Exchange Online will be cryptographically signed. This will allow the receiving email system to validate that the messages were generated by a server that the organization authorized and not being spoofed.", + "ImpactStatement": "There should be no impact of setting up DKIM however, organizations should ensure appropriate setup to ensure continuous mail-flow.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Rules section click Email authentication settings. 4. Select DKIM 5. Select the domain to remediate. 6. Click Create DKIM keys. 7. Microsoft provides the properly formatted CNAME records, copy these for later use. 8. In another browser tab or window, go to the domain registrar for the domain, and then create the two CNAME records using the information from the previous step. 9. Return the domain properties flyout and toggle Sign messages for this domain with DKIM signatures to Enabled. If successful the status will show Signing DKIM signatures for this domain.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Rules section click Email authentication settings. 4. Select DKIM. 5. For each Accepted domain that is configured to send email: o Skip any *.onmicrosoft.com domain (MOERA or Coexistence), these outbound messages are automatically signed by Microsoft. o Click on the domain name. o Confirm Sign messages for this domain with DKIM signatures is Enabled. o Ensure Status reads Signing DKIM signatures for this domain. 6. A status of Not signing DKIM signatures for this domain or No DKIM keys saved for this domain is out of compliance. Note: For step 5 these can also be audited the overview showing all domains. In this case a passing audit procedure will display the Toggle set as Enabled and Status as Valid. To audit using PowerShell: 1. Connect to Exchange Online service using Connect-ExchangeOnline. 2. Run the following: # Get-DkimSigningConfig does not display unconfigured domains, # so we first get all accepted domains and then match them against # the DKIM signing configurations. $Domains = Get-AcceptedDomain | Where-Object {$_.IsCoExistenceDomain -eq $false -and $_.InitialDomain -eq $false} $DKIMCfg = Get-DkimSigningConfig # Generate the report $Report = foreach ($Domain in $Domains) { $DKIM = $DKIMCfg | Where-Object { $_.Name -eq $Domain.Name } [PSCustomObject]@{ DomainName = $Domain.Name Enabled = [bool]$DKIM.Enabled Status = if ($DKIM) { $DKIM.Status } else { \"Not Configured\" } IsCISCompliant = ($DKIM.Enabled -and $DKIM.Status -eq \"Valid\") } } # Output the report $Report | Format-Table -AutoSize # Optionally, export the report to a CSV file # $Report | Export-Csv -Path \"2_1_9.csv\" -NoTypeInformation 3. For each domain that is configured to send email verify: o Enabled is True o Status is Valid. o Note: The property IsCISCompliant will also validate whether the state is compliant. Note: If you own registered domains that aren't used for email or anything at all (also known as parked domains), don't publish DKIM records for those domains. The lack of a DKIM record (hence, the lack of a public key in DNS to validate the message signature) prevents DKIM validation of forged domains.", + "AdditionalInformation": "", + "DefaultValue": "Custom domains: Not configured by default MOERA onmicrosoft.com domain: Outbound email is automatically signed", + "References": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-dkim-configure?view=o365-worldwide" + } + ] + }, + { + "Id": "2.1.10", + "Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.", + "RationaleStatement": "DMARC strengthens the trustworthiness of messages sent from an organization's domain to destination email systems. When combined with SPF (Sender Policy Framework) and DKIM (DomainKeys Identified Mail), DMARC significantly enhances defenses against email spoofing and phishing attempts. This includes the MOERA domain (e.g., contoso.onmicrosoft.com), which is provisioned with every Microsoft 365 tenant and is capable of originating email. Because it is often overlooked in favor of custom domains, it represents a common gap in email authentication coverage if left unprotected. Leaving a DMARC policy set to p=none can result in the mail system taking no action when a spear-phishing email fails DMARC but passes SPF and DKIM checks. Having DMARC fully configured is a critical part of preventing business email compromise.", + "ImpactStatement": "The remediation portion can take time to implement and involves a multi-staged approach over time. First, a baseline of the current state of email will be established with p=none and rua and ruf. Once the environment is better understood and reports have been analyzed, an organization will move to the final state with DMARC record values as outlined in the audit section.", + "RemediationProcedure": "To remediate using a DNS provider: 1. For any out of compliance domain sending email, add the following record to DNS: Record: _dmarc.domain1.com Type: TXT Value: v=DMARC1; p=none; rua=mailto:; ruf=mailto: 2. This will create a basic DMARC policy that will allow the organization to start monitoring message statistics. 3. One week is enough time for data generated by the reports to be useful in understanding email trends and traffic. The final step requires implementing a policy of p=reject OR p=quarantine and pct=100 with the necessary rua and ruf email addresses defined: Record: _dmarc.domain1.com Type: TXT Value: v=DMARC1; p=reject; pct=100; rua=mailto:; ruf=mailto: Parked Domains: For any domain not used for sending email, add the following record to DNS: Record: _dmarc.domain1.com Type: TXT Value: v=DMARC1; p=reject; To remediate the MOERA domain using the UI: 1. Navigate to the Microsoft 365 admin center https://admin.microsoft.com/ 2. Expand Settings and select Domains. 3. Select your tenant domain (for example, contoso.onmicrosoft.com). 4. Select DNS records and click + Add record. 5. Add a new record with the TXT name of _dmarc with the appropriate values outlined above.", + "AuditProcedure": "STEP 1: Determine the list of domains to audit Using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com 2. Expand Settings > Domains. 3. Take note of all custom domains and subdomains defined. o Exclude the coexistence domain *.mail.onmicrosoft.com (if shown). 4. Include the MOERA domain: [tenant].onmicrosoft.com 5. Use this list of domains in the audit procedure. Using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: Get-AcceptedDomain | Where-Object {$_.IsCoExistenceDomain -eq $false} 3. Use this list in the audit procedure. STEP 2: Perform the Audit using PowerShell: Note: Resolve-DnsName is not available on versions of Windows earlier than Windows 8 and Windows Server 2012. Use alternatives such as nslookup when needed. 1. Open a PowerShell prompt. 2. Run the following for each domain identified in Step 1: Resolve-DnsName _dmarc.domain1.com -Type TXT 3. Ensure the following criteria are met: 1. The record must be either directly or indirectly managed: - An indirectly managed record would use a CNAME to point to another record. - If the record is managed indirectly then that record must meet all criteria. This would require an additional DNS lookup. 2. The version v tag value must be v=DMARC1, which identifies it as a DMARC record. 3. The policy p tag value is p=quarantine OR p=reject. 4. The sampling rate pct tag value is pct=100 or does not exist. (A non- existent pct tag indicates sampling is 100 percent) 5. The aggregate data rua tag value is configured, i.e. rua=mailto:. 6. The failure data ruf tag value is configured, i.e. ruf=mailto:. 4. Subdomain considerations o When subdomains of the organizational domain are in scope, DMARC policy is determined using a two-step record discovery process (RFC 7489, Section 6.6.3): 1. If the subdomain has its own valid DMARC record (i.e., a record that includes the required p= tag), only that record is used. Nothing is inherited from the organizational domain. Any tags not explicitly defined in the subdomain's record fall back to their RFC- defined default values. 2. If the subdomain does not have a valid DMARC record - either because no record exists or because the record is malformed (e.g., missing the required p= tag) - the organizational domain's record is used. When determining policy disposition, the sp= (subdomain policy) tag is applied if present; otherwise, the p= tag is used. o This fallback is record discovery, not per-tag inheritance. The lookup always falls back to the organizational domain directly. Intermediate parent subdomains are never consulted. o The sp= tag is only meaningful when set on the organizational domain's record and is ignored on subdomain records. 5. Compliance is met when each domain and subdomain meets the requirements in steps 3 and 4. Parked Domains: Ensure that any domain not used for sending email has a DMARC record explicitly indicating that no mail is authorized from that domain. 1. The version v tag value must be v=DMARC1, which identifies it as a DMARC record. 2. The policy p tag value is p=reject. The following example records would pass as they contain a policy that would either quarantine or reject messages failing DMARC, and the policy affects 100% of mail pct=100 as well as containing valid reporting and aggregate addresses: v=DMARC1; p=reject; pct=100; rua=mailto:rua@example.com; ruf=mailto:ruf@example.com; fo=1 v=DMARC1; p=reject; rua=mailto:rua@example.com; ruf=mailto:ruf@example.com; fo=1 v=DMARC1; p=quarantine; pct=100; sp=none; fo=1; ri=3600; rua=mailto:rua@example.com; ruf=mailto:ruf@example.com; # Parked domains v=DMARC1; p=reject; Note: The third example includes sp=none, which sets the subdomain policy to monitor- only. While the organizational domain itself would be compliant, any subdomains would not meet the audit criteria if they exist. Auditors must evaluate subdomains separately to confirm compliance.", + "AdditionalInformation": "Microsoft has a list of best practices for implementing DMARC that cover these steps in detail.", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/defender-office-365/email-authentication-dmarc-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/how-to-enable-dmarc-reporting-for-microsoft-online-email-routing-address-moera-and-parked-domains?view=o365-worldwide:https://media.defense.gov/2024/May/02/2003455483/-1/-1/0/CSA-NORTH-KOREAN-ACTORS-EXPLOIT-WEAK-DMARC.PDF:https://www.rfc-editor.org/rfc/rfc7489" + } + ] + }, + { + "Id": "2.1.11", + "Description": "The Common Attachment Types Filter lets a user block known and custom malicious file types from being attached to emails. The policy provided by Microsoft covers 53 extensions, and an additional custom list of extensions can be defined. The list of 186 extensions provided in this recommendation is comprehensive but not exhaustive.", + "Checks": [ + "defender_malware_policy_comprehensive_attachments_filter_applied" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "The Common Attachment Types Filter lets a user block known and custom malicious file types from being attached to emails. The policy provided by Microsoft covers 53 extensions, and an additional custom list of extensions can be defined. The list of 186 extensions provided in this recommendation is comprehensive but not exhaustive.", + "RationaleStatement": "Blocking known malicious file types can help prevent malware-infested files from infecting a host or performing other malicious attacks such as phishing and data extraction. Defining a comprehensive list of attachments can help protect against additional unknown and known threats. Many legacy file formats, binary files and compressed files have been used as delivery mechanisms for malicious software. Organizations can protect themselves from Business E-mail Compromise (BEC) by allow-listing only the file types relevant to their line of business and blocking all others.", + "ImpactStatement": "For file types that are business necessary users will need to use other organizationally approved methods to transfer blocked extension types between business partners.", + "RemediationProcedure": "To Remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script after editing InternalSenderAdminAddress: # Create an attachment policy and associated rule. The rule is # intentionally disabled allowing the org to enable it when ready $Policy = @{ Name = \"CIS L2 Attachment Policy\" EnableFileFilter = $true ZapEnabled = $true EnableInternalSenderAdminNotifications = $true InternalSenderAdminAddress = 'admin@contoso.com' # Change this. } $L2Extensions = @( \"7z\", \"a3x\", \"ace\", \"ade\", \"adp\", \"ani\", \"app\", \"appinstaller\", \"applescript\", \"application\", \"appref-ms\", \"appx\", \"appxbundle\", \"arj\", \"asd\", \"asx\", \"bas\", \"bat\", \"bgi\", \"bz2\", \"cab\", \"chm\", \"cmd\", \"com\", \"cpl\", \"crt\", \"cs\", \"csh\", \"daa\", \"dbf\", \"dcr\", \"deb\", \"desktopthemepackfile\", \"dex\", \"diagcab\", \"dif\", \"dir\", \"dll\", \"dmg\", \"doc\", \"docm\", \"dot\", \"dotm\", \"elf\", \"eml\", \"exe\", \"fxp\", \"gadget\", \"gz\", \"hlp\", \"hta\", \"htc\", \"htm\", \"html\", \"hwpx\", \"ics\", \"img\", \"inf\", \"ins\", \"iqy\", \"iso\", \"isp\", \"jar\", \"jnlp\", \"js\", \"jse\", \"kext\", \"ksh\", \"lha\", \"lib\", \"library-ms\", \"lnk\", \"lzh\", \"macho\", \"mam\", \"mda\", \"mdb\", \"mde\", \"mdt\", \"mdw\", \"mdz\", \"mht\", \"mhtml\", \"mof\", \"msc\", \"msi\", \"msix\", \"msp\", \"msrcincident\", \"mst\", \"ocx\", \"odt\", \"ops\", \"oxps\", \"pcd\", \"pif\", \"plg\", \"pot\", \"potm\", \"ppa\", \"ppam\", \"ppkg\", \"pps\", \"ppsm\", \"ppt\", \"pptm\", \"prf\", \"prg\", \"ps1\", \"ps11\", \"ps11xml\", \"ps1xml\", \"ps2\", \"ps2xml\", \"psc1\", \"psc2\", \"pub\", \"py\", \"pyc\", \"pyo\", \"pyw\", \"pyz\", \"pyzw\", \"rar\", \"reg\", \"rev\", \"rtf\", \"scf\", \"scpt\", \"scr\", \"sct\", \"searchConnector-ms\", \"service\", \"settingcontent-ms\", \"sh\", \"shb\", \"shs\", \"shtm\", \"shtml\", \"sldm\", \"slk\", \"so\", \"spl\", \"stm\", \"svg\", \"swf\", \"sys\", \"tar\", \"theme\", \"themepack\", \"timer\", \"uif\", \"url\", \"uue\", \"vb\", \"vbe\", \"vbs\", \"vhd\", \"vhdx\", \"vxd\", \"wbk\", \"website\", \"wim\", \"wiz\", \"ws\", \"wsc\", \"wsf\", \"wsh\", \"xla\", \"xlam\", \"xlc\", \"xll\", \"xlm\", \"xls\", \"xlsb\", \"xlsm\", \"xlt\", \"xltm\", \"xlw\", \"xnk\", \"xps\", \"xsl\", \"xz\", \"z\" ) # Create the policy New-MalwareFilterPolicy @Policy -FileTypes $L2Extensions # Create the rule for all accepted domains $Rule = @{ Name = $Policy.Name Enabled = $false MalwareFilterPolicy = $Policy.Name RecipientDomainIs = (Get-AcceptedDomain).Name Priority = 0 } New-MalwareFilterRule @Rule 3. When prepared enable the rule either through the UI or PowerShell. Note: Due to the number of extensions the UI method is not covered. The objects can however be edited in the UI or manually added using the list from the script. 1. Navigate to Microsoft Defender at https://security.microsoft.com/ 2. Browse to Policies & rules > Threat policies > Anti-malware.", + "AuditProcedure": "For this control, a Level 2 comprehensive attachment policy is defined as one that includes at least 120 extensions. The 184 extensions included are a known vector for malicious activity. To pass, organizations must demonstrate at least a 90% adoption rate of the extension list referenced in the script below, with allowances for justified exceptions. Since individual extensions are not assigned specific risk weights, exceptions should be based on documented business needs. Note: Utilizing the UI for auditing Anti-malware policies can be very time consuming so it is recommended to use a script like the one supplied below. To Audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script: $AttachExts = @( '7z', 'a3x', 'ace', 'ade', 'adp', 'ani', 'apk', 'app', 'appinstaller', 'applescript', 'application', 'appref-ms', 'appx', 'appxbundle', 'arj', 'asd', 'asx', 'bas', 'bat', 'bgi', 'bz2', 'cab', 'chm', 'cmd', 'com', 'cpl', 'crt', 'cs', 'csh', 'daa', 'dbf', 'dcr', 'deb', 'desktopthemepackfile', 'dex', 'diagcab', 'dif', 'dir', 'dll', 'dmg', 'doc', 'docm', 'dot', 'dotm', 'elf', 'eml', 'exe', 'fxp', 'gadget', 'gz', 'hlp', 'hta', 'htc', 'htm', 'html', 'hwpx', 'ics', 'img', 'inf', 'ins', 'iqy', 'iso', 'isp', 'jar', 'jnlp', 'js', 'jse', 'kext', 'ksh', 'lha', 'lib', 'library', 'library-ms', 'lnk', 'lzh', 'macho', 'mam', 'mda', 'mdb', 'mde', 'mdt', 'mdw', 'mdz', 'mht', 'mhtml', 'mof', 'msc', 'msi', 'msix', 'msp', 'msrcincident', 'mst', 'ocx', 'odt', 'ops', 'oxps', 'pcd', 'pif', 'plg', 'pot', 'potm', 'ppa', 'ppam', 'ppkg', 'pps', 'ppsm', 'ppt', 'pptm', 'prf', 'prg', 'ps1', 'ps11', 'ps11xml', 'ps1xml', 'ps2', 'ps2xml', 'psc1', 'psc2', 'pub', 'py', 'pyc', 'pyo', 'pyw', 'pyz', 'pyzw', 'rar', 'reg', 'rev', 'rtf', 'scf', 'scpt', 'scr', 'sct', 'searchConnector-ms', 'service', 'settingcontent-ms', 'sh', 'shb', 'shs', 'shtm', 'shtml', 'sldm', 'slk', 'so', 'spl', 'stm', 'svg', 'swf', 'sys', 'tar', 'theme', 'themepack', 'timer', 'uif', 'url', 'uue', 'vb', 'vbe', 'vbs', 'vhd', 'vhdx', 'vxd', 'wbk', 'website', 'wim', 'wiz', 'ws', 'wsc', 'wsf', 'wsh', 'xla', 'xlam', 'xlc', 'xll', 'xlm', 'xls', 'xlsb', 'xlsm', 'xlt', 'xltm', 'xlw', 'xnk', 'xps', 'xsl', 'xz', 'z' ) $MalwareFilterPolicies = Get-MalwareFilterPolicy $MalwareFilterRules = Get-MalwareFilterRule # A policy must have at least 90% of the extensions in the reference list to pass. # This allows for some flexibility with exceptions. $PassingValue = .90 # 90% $FailThreshold = [int]($AttachExts.count * (1 - $PassingValue)) # Only evaluate policies that have more than 120 extensions defined # so we don't output failures on policies that aren't specific to # extension filtering. $CompPolicies = $MalwareFilterPolicies | Where-Object { $_.FileTypes.Count - gt 120 } if (-not $CompPolicies) { Write-Output \"## FAIL ## No comprehensive policies found to evaluate.\" return } $ExtensionReport = foreach ($policy in $CompPolicies) { $Missing = Compare-Object -ReferenceObject $AttachExts ` -DifferenceObject $policy.FileTypes ` -PassThru | Where-Object { $_.SideIndicator -eq '<=' } $FoundRule = $MalwareFilterRules | Where-Object { $_.MalwareFilterPolicy -eq $policy.Id } # Define passing conditions to determine if this policy passes all checks. $Pass = ($Missing.Count -lt $FailThreshold) -and ($FoundRule.State -eq 'Enabled') -and ($policy.EnableFileFilter -eq $true) [PSCustomObject]@{ PolicyName = $policy.Identity IsCISCompliant = $Pass EnableFileFilter = $policy.EnableFileFilter State = $FoundRule.State MissingCount = $Missing.count MissingExtensions = $Missing -join \", \" ExtensionCount = $policy.FileTypes.count } } # Output results in various formats $ExtensionReport | Format-Table -AutoSize <# Optional: Export methods $ExtensionReport | Out-GridView -Title \"Attachment Filter results\" $ExtensionReport | Export-Csv -Path \"2.1.11.csv\" -NoTypeInformation $ExtensionReport | ConvertTo-Json | Out-File -FilePath \"2.1.11.json\" #> 3. Review the results, only policies with over 120 extensions defined will be evaluated. At the end of the script examples of different output formats are given. 4. A pass is given for the following conditions: o A single active policy exists that covers all file extensions listed except those defined as an exception by the organization. o The policy has a state of Enabled. o The EnableFileFilter property is set to True. 5. The report includes a IsCISCompliant property, where True indicates in compliance, allowing for up to 10% of the listed extensions to be missing as documented exceptions. Note: Organizations should evaluate any extensions missing from the report to determine if they are valid exceptions. Note: The audit procedure intentionally does not include the action taken for matched extensions, e.g. Reject with NDR or Quarantine the message. These are considered organization specific and are not scored. When FileTypeAction is not specified the action will default to Reject the message with a non-delivery receipt (NDR). The Quarantine Policy is also considered organization specific.", + "AdditionalInformation": "", + "DefaultValue": "The following extensions are blocked by default: ace, ani, apk, app, appx, arj, bat, cab, cmd, com, deb, dex, dll, docm, elf, exe, hta, img, iso, jar, jnlp, kext, lha, lib, library, lnk, lzh, macho, msc, msi, msix, msp, mst, pif, ppa, ppam, reg, rev, scf, scr, sct, sys, uif, vb, vbe, vbs, vxd, wsc, wsf, wsh, xll, xz, z", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-malwarefilterpolicy?view=exchange-ps:https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-malware-policies-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/office/compatibility/office-file-format-reference" + } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } + ] + }, + { + "Id": "2.1.12", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The recommended state is IP Allow List empty or undefined.", + "Checks": [ + "defender_antispam_connection_filter_policy_empty_ip_allowlist" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The recommended state is IP Allow List empty or undefined.", + "RationaleStatement": "Without additional verification like mail flow rules, email from sources in the IP Allow List skips spam filtering and sender authentication (SPF, DKIM, DMARC) checks. This method creates a high risk of attackers successfully delivering email to the Inbox that would otherwise be filtered. Messages that are determined to be malware or high confidence phishing are filtered.", + "ImpactStatement": "This is the default behavior. IP Allow lists may reduce false positives, however, this benefit is outweighed by the importance of a policy which scans all messages regardless of the origin. This supports the principle of zero trust.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Click Edit connection filter policy. 6. Remove any IP entries from Always allow messages from the following IP addresses or address range:. 7. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @{}", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Verify that IP Allow list contains no entries. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedConnectionFilterPolicy -Identity Default | fl IPAllowList 3. Verify that IPAllowList is empty or {}", + "AdditionalInformation": "", + "DefaultValue": "IPAllowList : {}", + "References": "https://learn.microsoft.com/en-us/defender-office-365/connection-filter-policies-configure:https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365#use-the-ip-allow-list:https://learn.microsoft.com/en-us/defender-office-365/how-policies-and-protections-are-combined#user-and-tenant-settings-conflict" + } + ] + }, + { + "Id": "2.1.13", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The safe list is a pre-configured allow list that is dynamically updated by Microsoft. The recommended safe list state is: Off or False", + "Checks": [ + "defender_antispam_connection_filter_policy_safe_list_off" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations without Exchange Online mailboxes, connection filtering and the default connection filter policy identify good or bad source email servers by IP addresses. The key components of the default connection filter policy are IP Allow List, IP Block List and Safe list. The safe list is a pre-configured allow list that is dynamically updated by Microsoft. The recommended safe list state is: Off or False", + "RationaleStatement": "Without additional verification like mail flow rules, email from sources in the IP Allow List skips spam filtering and sender authentication (SPF, DKIM, DMARC) checks. This method creates a high risk of attackers successfully delivering email to the Inbox that would otherwise be filtered. Messages that are determined to be malware or high confidence phishing are filtered. The safe list is managed dynamically by Microsoft, and administrators do not have visibility into which senders are included. Incoming messages from email servers on the safe list bypass spam filtering.", + "ImpactStatement": "This is the default behavior. IP Allow lists may reduce false positives, however, this benefit is outweighed by the importance of a policy which scans all messages regardless of the origin. This supports the principle of zero trust.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Click Edit connection filter policy. 6. Uncheck Turn on safe list. 7. Click Save. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedConnectionFilterPolicy -Identity Default -EnableSafeList $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Click on the Connection filter policy (Default). 5. Verify that Safe list is Off. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedConnectionFilterPolicy -Identity Default | fl EnableSafeList 3. Verify that EnableSafeList is False", + "AdditionalInformation": "", + "DefaultValue": "EnableSafeList : False", + "References": "https://learn.microsoft.com/en-us/defender-office-365/connection-filter-policies-configure:https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365#use-the-ip-allow-list:https://learn.microsoft.com/en-us/defender-office-365/how-policies-and-protections-are-combined#user-and-tenant-settings-conflict" + } + ] + }, + { + "Id": "2.1.14", + "Description": "Anti-spam protection is a feature of Exchange Online that utilizes policies to help to reduce the amount of junk email, bulk and phishing emails a mailbox receives. These policies contain lists to allow or block specific senders or domains. - The allowed senders list - The allowed domains list - The blocked senders list - The blocked domains list The recommended state is: Do not define any Allowed domains", + "Checks": [ + "defender_antispam_policy_inbound_no_allowed_domains" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Anti-spam protection is a feature of Exchange Online that utilizes policies to help to reduce the amount of junk email, bulk and phishing emails a mailbox receives. These policies contain lists to allow or block specific senders or domains. - The allowed senders list - The allowed domains list - The blocked senders list - The blocked domains list The recommended state is: Do not define any Allowed domains", + "RationaleStatement": "Messages from entries in the allowed senders list or the allowed domains list bypass most email protection (except malware and high confidence phishing) and email authentication checks (SPF, DKIM and DMARC). Entries in the allowed senders list or the allowed domains list create a high risk of attackers successfully delivering email to the Inbox that would otherwise be filtered. The risk is increased even more when allowing common domain names as these can be easily spoofed by attackers. Microsoft specifies in its documentation that allowed domains should be used for testing purposes only.", + "ImpactStatement": "This is the default behavior. Allowed domains may reduce false positives, however, this benefit is outweighed by the importance of having a policy which scans all messages regardless of the origin. As an alternative consider sender based lists. This supports the principle of zero trust.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam. 4. Open each out of compliance inbound anti-spam policy by clicking on it. 5. Click Edit allowed and blocked senders and domains. 6. Select Allow domains. 7. Delete each domain from the domains list. 8. Click Done > Save. 9. Repeat as needed. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedContentFilterPolicy -Identity -AllowedSenderDomains @{} Or, run this to remove allowed domains from all inbound anti-spam policies: $AllowedDomains = Get-HostedContentFilterPolicy | Where-Object {$_.AllowedSenderDomains} $AllowedDomains | Set-HostedContentFilterPolicy -AllowedSenderDomains @{}", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam. 4. Inspect each inbound anti-spam policy 5. Verify that Allowed domains does not contain any domain names. 6. Repeat as needed for any additional inbound anti-spam policy. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-HostedContentFilterPolicy | ft Identity,AllowedSenderDomains 3. Verify that AllowedSenderDomains is undefined for each inbound policy. Note: Each inbound policy must pass for this recommendation to be considered to be in a passing state.", + "AdditionalInformation": "", + "DefaultValue": "AllowedSenderDomains : {}", + "References": "https://learn.microsoft.com/en-us/defender-office-365/anti-spam-protection-about#allow-and-block-lists-in-anti-spam-policies" + } + ] + }, + { + "Id": "2.1.15", + "Description": "The default outbound anti-spam policy in Microsoft Defender automatically applies to all users and is designed to detect and limit suspicious email-sending behavior. The policy enforces limits based on both volume and spam detection. If a user sends too many emails too quickly or if a high percentage of their messages are flagged as spam, their ability to send email can be temporarily restricted. This helps prevent abuse from compromised accounts or inadvertent spam campaigns. When these limits are exceeded, Microsoft routes the messages through a high-risk delivery pool to protect its IP reputation and notifies administrators through built-in alert policies. The recommended state is: - External: Restrict sending to external recipients (per hour) - 500 - Internal: Restrict sending to internal recipients (per hour) - 1000 - Daily: Maximum recipient limit per day - 1000 - Action: Over limit action - Restrict the user from sending mail", + "Checks": [ + "defender_antispam_outbound_policy_configured" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.1 Email & collaboration", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The default outbound anti-spam policy in Microsoft Defender automatically applies to all users and is designed to detect and limit suspicious email-sending behavior. The policy enforces limits based on both volume and spam detection. If a user sends too many emails too quickly or if a high percentage of their messages are flagged as spam, their ability to send email can be temporarily restricted. This helps prevent abuse from compromised accounts or inadvertent spam campaigns. When these limits are exceeded, Microsoft routes the messages through a high-risk delivery pool to protect its IP reputation and notifies administrators through built-in alert policies. The recommended state is: - External: Restrict sending to external recipients (per hour) - 500 - Internal: Restrict sending to internal recipients (per hour) - 1000 - Daily: Maximum recipient limit per day - 1000 - Action: Over limit action - Restrict the user from sending mail", + "RationaleStatement": "Message limit settings help lessen the impact of a Business Email Compromise (BEC) by automatically restricting accounts that send unusually high volumes of email. This containment prevents compromised accounts from launching large-scale attacks and helps ensure the organization's email remains trusted and deliverable. Without these limits, excessive or suspicious outbound traffic could result in Microsoft blocking the organization's email, disrupting communication and damaging reputation.", + "ImpactStatement": "Enforcing message limits may result in legitimate users being temporarily blocked from sending email if their bulk messaging activity resembles spam or exceeds volume thresholds. This can disrupt business operations, delay communication, and require administrative effort to investigate and restore access. However, these adverse effects typically stem from a lack of planning around mass mailings. To avoid triggering these limits, Microsoft recommends sending bulk email through custom subdomains or third- party bulk email providers.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules> Threat policies. 3. Under Policies select Anti-spam and click to open Anti-spam outbound policy (Default). 4. Select Edit protection settings. 5. Set the following settings to the recommended values, or more restrictive values. Message limit values of 0 are not compliant, as it represents the service default o External: Set an external message limit - 500 o Internal: Set an internal message limit - 1000 o Daily: Set a daily message limit - 1000 o Action: Restriction placed on users who reach the message limit - Restrict the user from sending mail 6. Verify that Notify these users and groups if a sender is blocked due to sending outbound spam contains a monitored mailbox. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Change the example email addresses below and run the following PowerShell commands: $params = @{ RecipientLimitExternalPerHour = 500 RecipientLimitInternalPerHour = 1000 RecipientLimitPerDay = 1000 ActionWhenThresholdReached = 'BlockUser' NotifyOutboundSpamRecipients = @('admin@example.com','security@example.com') } Set-HostedOutboundSpamFilterPolicy -Identity 'Default' @params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com. 2. Expand Email & collaboration > Policies & rules > Threat policies. 3. Under Policies select Anti-spam and click to open Anti-spam outbound policy (Default). 4. Verify the following settings are configured to the recommended level or a more restrictive value. Message limit values of 0 are not compliant, as it represents the service default: o External: Restrict sending to external recipients (per hour) - 500 o Internal: Restrict sending to internal recipients (per hour) - 1000 o Daily: Maximum recipient limit per day - 1000 o Action: Over limit action - Restrict the user from sending mail 5. Verify that a monitored mailbox is configured as a recipient under Notify these users and groups if a sender is blocked due to sending outbound spam. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: $params = @( 'RecipientLimitExternalPerHour' 'RecipientLimitInternalPerHour' 'RecipientLimitPerDay' 'ActionWhenThresholdReached' 'NotifyOutboundSpamRecipients' ) Get-HostedOutboundSpamFilterPolicy -Identity Default | fl $params 3. Verify that each of the following properties is configured as specified: o RecipientLimitExternalPerHour is 500 or less, but not 0 o RecipientLimitInternalPerHour is 1000 or less, but not 0 o RecipientLimitPerDay is 1000 or less, but not 0 o ActionWhenThresholdReached is BlockUser o NotifyOutboundSpamRecipients contains a monitored mailbox. Note: Microsoft's Recommended Strict values represent a more restrictive and also compliant configuration. These values 400, 800, and 800 align with the values above. For further details on Standard and Strict settings, refer to the references section.", + "AdditionalInformation": "", + "DefaultValue": "RecipientLimitExternalPerHour : 0 RecipientLimitInternalPerHour : 0 RecipientLimitPerDay : 0 ActionWhenThresholdReached : BlockUserForToday The value of 0 means the service defaults are being used. More information on sending limits is here: https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange- online-service-description/exchange-online-limits#sending-limits-1", + "References": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about:https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#outbound-spam-policy-settings:https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits#sending-limits-1" + } + ] + }, + { + "Id": "2.2.1", + "Description": "Organizations should monitor sign-in and audit log activity from the emergency accounts and trigger notifications to other administrators. When you monitor the activity for emergency access accounts, you can verify these accounts are only used for testing or actual emergencies. You can use Azure Monitor, Microsoft Sentinel, Defender for Cloud Apps or other tools to monitor the sign-in logs and trigger email and SMS alerts to your administrators whenever emergency access accounts sign in. This recommendation uses Defender for Cloud Apps Policies to alert on emergency access account activity. The recommended state is to monitor Activity type Log on on break-glass or emergency access accounts.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.2 Cloud apps", + "Profile": "E5 Level 1", + "AssessmentStatus": "Manual", + "Description": "Organizations should monitor sign-in and audit log activity from the emergency accounts and trigger notifications to other administrators. When you monitor the activity for emergency access accounts, you can verify these accounts are only used for testing or actual emergencies. You can use Azure Monitor, Microsoft Sentinel, Defender for Cloud Apps or other tools to monitor the sign-in logs and trigger email and SMS alerts to your administrators whenever emergency access accounts sign in. This recommendation uses Defender for Cloud Apps Policies to alert on emergency access account activity. The recommended state is to monitor Activity type Log on on break-glass or emergency access accounts.", + "RationaleStatement": "Emergency access accounts should be used in very few scenarios, for example, the last Global Administrator has left the organization and the account is inaccessible. All activity on an emergency access account should be reviewed at the time of the event to ensure the sign on is legitimate and authorized.", + "ImpactStatement": "There is no real world impact to monitoring these accounts beyond allocating staff. The frequency of emergency account sign on should be so low that any activity raises a red flag that is treated with the highest priority.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under the Cloud Apps section select Policies -> Policy management. 3. Click on All policies and then Create policy -> Activity policy. 4. Give the policy a name and set the following: o Policy severity to High severity. o Category to Privileged accounts. o Act on Single activity. o Click Select a filter -> Activity type equals Log on. o Click Add a filter -> User Name equals as Any role. o Ensure all emergency access accounts are added to this policy or another. o Select an alert method such as Send alert as email. Note: Multiple accounts can be monitored by a single policy or by separate policies.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com 2. Under the Cloud Apps section select Policies -> Policy management. 3. Locate a privileged accounts policy that meets the following criteria o Policy severity is High severity. o Category is Privileged accounts. o Act on Single activity is selected. o Under Activities matching all of the following verify: o Filter1: Activity type equals Log on o Filter2: User Name equals as Any role o Verify all additional emergency access accounts are accounted for. o Under Alerts, verify alerting is configured. 4. Repeat this process for any additional emergency access or break-glass accounts in the organization. If matching policies do not exist, then the audit procedure is considered a fail. Note: Multiple accounts can be monitored by a single policy or by separate policies. Note: Emergency access account activity can be monitored in various ways. The audit procedure passes as long as all emergency access account activity is monitored.", + "AdditionalInformation": "", + "DefaultValue": "A policy to monitor emergency access accounts does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access#monitor-sign-in-and-audit-logs:https://learn.microsoft.com/en-us/defender-cloud-apps/control-cloud-apps-with-policies" + } + ] + }, + { + "Id": "2.4.1", + "Description": "Identify priority accounts to utilize Microsoft 365's advanced custom security features. This is an essential tool to bolster protection for users who are frequently targeted due to their critical positions, such as executives, leaders, managers, or others who have access to sensitive, confidential, financial, or high-priority information. Once these accounts are identified, several services and features can be enabled, including threat policies, enhanced sign-in protection through conditional access policies, and alert policies, enabling faster response times for incident response teams.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Identify priority accounts to utilize Microsoft 365's advanced custom security features. This is an essential tool to bolster protection for users who are frequently targeted due to their critical positions, such as executives, leaders, managers, or others who have access to sensitive, confidential, financial, or high-priority information. Once these accounts are identified, several services and features can be enabled, including threat policies, enhanced sign-in protection through conditional access policies, and alert policies, enabling faster response times for incident response teams.", + "RationaleStatement": "Enabling priority account protection for users in Microsoft 365 is necessary to enhance security for accounts with access to sensitive data and high privileges, such as CEOs, CISOs, CFOs, and IT admins. These priority accounts are often targeted by spear phishing or whaling attacks and require stronger protection to prevent account compromise. To address this, Microsoft 365 and Microsoft Defender for Office 365 offer several key features that provide extra security, including the identification of incidents and alerts involving priority accounts and the use of built-in custom protections designed specifically for them.", + "ImpactStatement": "", + "RemediationProcedure": "To remediate using the UI: Remediate with a 3-step process Step 1: Enable Priority account protection in Microsoft 365 Defender: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Select System > Settings near the bottom of the left most panel. 3. Select E-mail & Collaboration > Priority account protection 4. Ensure Priority account protection is set to On Step 2: Tag priority accounts: 5. Select User tags 6. Select the PRIORITY ACCOUNT tag and click Edit 7. Select Add members to add users, or groups. Groups are recommended. 8. Repeat the previous 2 steps for any additional tags needed, such as Finance or HR. 9. Next and Submit. Step 3: Configure E-mail alerts for Priority Accounts: 10. Expand E-mail & Collaboration on the left column. 11. Select Policies & rules > Alert policy 12. Select New Alert Policy 13. Enter a valid policy Name & Description. Set Severity to High and Category to Threat management. 14. Set Activity is to Detected malware in an e-mail message 15. Mail direction is Inbound 16. Select Add Condition and User: recipient tags are 17. In the Selection option field add chosen priority tags such as Priority account. 18. Select Every time an activity matches the rule. 19. Next and verify valid recipient(s) are selected. 20. Next and select Yes, turn it on right away. Click Submit to save the alert. 21. Repeat steps 12 - 18 to create a 2nd alert for the Activity field Activity is: Phishing email detected at time of delivery Note: Any additional activity types may be added as needed. Above are the minimum recommended.", + "AuditProcedure": "To audit using the UI: Audit with a 3-step process Step 1: Verify Priority account protection is enabled: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Select System > Settings near the bottom of the left most panel. 3. Select E-mail & collaboration > Priority account protection 4. Verify that Priority account protection is set to On Step 2: Verify that priority accounts are identified and tagged accordingly: 5. Select User tags 6. Select the PRIORITY ACCOUNT tag and click Edit 7. Verify the assigned members match the organization's defined priority accounts or groups. 8. Repeat the previous 2 steps for any additional tags identified, such as Finance or HR. Step 3: Ensure alerts are configured: 9. Expand E-mail & Collaboration on the left column. 10. Select Policies & rules > Alert policy 11. Verify that at least two alert policies are configured to monitor priority accounts for the activities Detected malware in an email message and Phishing email detected at time of delivery. These alerts should meet the following criteria: o Severity: High o Category: Threat management o Mail Direction: Inbound o Recipient Tags: Includes Priority account To audit using PowerShell: 1. Connect to Exchange using Connect-ExchangeOnline 2. Retrieve the Priority Account protection state: Get-EmailTenantSettings | select EnablePriorityAccountProtection - Ensure EnablePriorityAccountProtection is true. 3. Connect to Security & Compliance PowerShell using Connect-IPPSSession 4. Retrieve alert policies targeting priority accounts: Get-ProtectionAlert | Where-Object { $_.RecipientTags -Match 'Priority account' } 5. For each returned policy, verify all of the following criteria: o Severity is High o Filter matches the pattern Mail.Direction -eq 'Inbound' o RecipientTags matches the pattern Priority account o NotificationEnabled is true o NotifyUser contains a valid email recipient o Disabled is false 6. The control is compliant when the results include: o One passing phishing policy (ThreatType = Phish) o One passing malware policy (ThreatType = Malware). o EnablePriorityAccountProtection is true from step 2.", + "AdditionalInformation": "", + "DefaultValue": "By default, priority accounts are undefined.", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/priority-accounts:https://learn.microsoft.com/en-us/defender-office-365/priority-accounts-security-recommendations" + } + ] + }, + { + "Id": "2.4.2", + "Description": "Preset security policies have been established by Microsoft, utilizing observations and experiences within datacenters to strike a balance between the exclusion of malicious content from users and limiting unwarranted disruptions. These policies can apply to all, or select users and encompass recommendations for addressing spam, malware, and phishing threats. The policy parameters are pre-determined and non-adjustable. Strict protection has the most aggressive protection of the 3 presets. - EOP: Anti-spam, Anti-malware and Anti-phishing - Defender: Spoof protection, Impersonation protection and Advanced phishing - Defender: Safe Links and Safe Attachments NOTE: The preset security polices cannot target Priority account TAGS currently, groups should be used instead.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Preset security policies have been established by Microsoft, utilizing observations and experiences within datacenters to strike a balance between the exclusion of malicious content from users and limiting unwarranted disruptions. These policies can apply to all, or select users and encompass recommendations for addressing spam, malware, and phishing threats. The policy parameters are pre-determined and non-adjustable. Strict protection has the most aggressive protection of the 3 presets. - EOP: Anti-spam, Anti-malware and Anti-phishing - Defender: Spoof protection, Impersonation protection and Advanced phishing - Defender: Safe Links and Safe Attachments NOTE: The preset security polices cannot target Priority account TAGS currently, groups should be used instead.", + "RationaleStatement": "Enabling priority account protection for users in Microsoft 365 is necessary to enhance security for accounts with access to sensitive data and high privileges, such as CEOs, CISOs, CFOs, and IT admins. These priority accounts are often targeted by spear phishing or whaling attacks and require stronger protection to prevent account compromise. The implementation of stringent, pre-defined policies may result in instances of false positive, however, the benefit of requiring the end-user to preview junk email before accessing their inbox outweighs the potential risk of mistakenly perceiving a malicious email as safe due to its placement in the inbox.", + "ImpactStatement": "Strict policies are more likely to cause false positives in anti-spam, phishing, impersonation, spoofing and intelligence responses.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration > Policies & rules > Threat policies. 3. Select Preset security policies. 4. Click to Manage protection settings for Strict protection preset. 5. For Apply Exchange Online Protection select at minimum Specific recipients and include the Accounts/Groups identified as Priority Accounts. 6. For Apply Defender for Office 365 Protection select at minimum Specific recipients and include the Accounts/Groups identified as Priority Accounts. 7. For Impersonation protection click Next and add valid e-mails or priority accounts both internal and external that may be subject to impersonation. 8. For Protected custom domains add the organization's domain name, along side other key partners. 9. Click Next and finally Confirm", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration > Policies & rules > Threat policies. 3. From here visit each section in turn: Anti-phishing Anti-spam Anti-malware Safe Attachments Safe Links 4. Verify that each contains a policy named Strict Preset Security Policy that includes the organization's priority accounts and groups. To audit using PowerShell: 1. Connect to Exchange using Connect-ExchangeOnline 2. Retrieve the ATP strict presets rule for Defender: Get-ATPProtectionPolicyRule | Where-Object { $_.Identity -eq 'Strict Preset Security Policy' } 3. Retrieve the EOP strict preset rule for Defender: Get-EOPProtectionPolicyRule | Where-Object { $_.Identity -eq 'Strict Preset Security Policy' } 4. Verify the following criteria for both the EOP Rule and ATP Rule: o State is Enabled o At least one recipient target is populated with a VIP, i.e.: - SentTo, SentToMemberOf or RecipientDomainIs is not null 5. The control is compliant when both the ATP rule and the EOP rule exist and pass the criteria in step 4.", + "AdditionalInformation": "", + "DefaultValue": "By default, presets are not applied to any users or groups.", + "References": "https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies?view=o365-worldwide:https://learn.microsoft.com/en-us/defender-office-365/priority-accounts-security-recommendations:https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365?view=o365-worldwide#impersonation-settings-in-anti-phishing-policies-in-microsoft-defender-for-office-365" + } + ] + }, + { + "Id": "2.4.3", + "Description": "Microsoft Defender for Cloud Apps is a Cloud Access Security Broker (CASB). It provides visibility into suspicious activity in Microsoft 365, enabling investigation into potential security issues and facilitating the implementation of remediation measures if necessary. Some risk detection methods provided by Entra Identity Protection also require Microsoft Defender for Cloud Apps: - Suspicious manipulation of inbox rules - Suspicious inbox forwarding - New country detection - Impossible travel detection - Activity from anonymous IP addresses - Mass access to sensitive files", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 2", + "AssessmentStatus": "Manual", + "Description": "Microsoft Defender for Cloud Apps is a Cloud Access Security Broker (CASB). It provides visibility into suspicious activity in Microsoft 365, enabling investigation into potential security issues and facilitating the implementation of remediation measures if necessary. Some risk detection methods provided by Entra Identity Protection also require Microsoft Defender for Cloud Apps: - Suspicious manipulation of inbox rules - Suspicious inbox forwarding - New country detection - Impossible travel detection - Activity from anonymous IP addresses - Mass access to sensitive files", + "RationaleStatement": "Security teams can receive notifications of triggered alerts for atypical or suspicious activities, see how the organization's data in Microsoft 365 is accessed and used, suspend user accounts exhibiting suspicious activity, and require users to log back in to Microsoft 365 apps after an alert has been triggered.", + "ImpactStatement": "", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Cloud apps. 3. Scroll to Information Protection and select Files. 4. Check Enable file monitoring. 5. Scroll up to Cloud Discovery and select Microsoft Defender for Endpoint. 6. Check Enforce app access, configure a Notification URL and Save. Note: Defender for Endpoint requires a Defender for Endpoint license. Configure App Connectors: 1. Scroll to Connected apps and select App connectors. 2. Click on Connect an app and select Microsoft 365. 3. Check all Azure and Office 365 boxes then click Connect Office 365. 4. Repeat for the Microsoft Azure application.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Cloud apps. 3. Scroll to Connected apps and select App connectors. 4. Verify that Microsoft 365 and Microsoft Azure both show in the list as Connected. 5. Go to Cloud Discovery > Microsoft Defender for Endpoint and verify that the integration is enabled. 6. Go to Information Protection > Files and verify Enable file monitoring is checked.", + "AdditionalInformation": "Additional Microsoft 365 Defender features include: - The option to use Defender for cloud apps as a reverse proxy, allowing for the application of access or session controls through the definition of a conditional access policy. - The purchase and implementation of the \"App Governance\" add-on, which provides more precise control over OAuth app permissions and includes additional built-in policies. A list of Defender for Cloud Apps built-in policies for Office 365 can be found at https://learn.microsoft.com/en-us/defender-cloud-apps/protect-office-365.", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/defender-cloud-apps/protect-office-365#connect-microsoft-365-to-microsoft-defender-for-cloud-apps:https://learn.microsoft.com/en-us/defender-cloud-apps/protect-azure#connect-azure-to-microsoft-defender-for-cloud-apps:https://learn.microsoft.com/en-us/defender-cloud-apps/best-practices:https://learn.microsoft.com/en-us/defender-cloud-apps/get-started:https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks" + } + ] + }, + { + "Id": "2.4.4", + "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.", + "Checks": [ + "defender_zap_for_teams_enabled" + ], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.", + "RationaleStatement": "ZAP is intended to protect users that have received zero-day malware messages or content that is weaponized after being delivered to users. It does this by continually monitoring spam and malware signatures taking automated retroactive action on messages that have already been delivered.", + "ImpactStatement": "As with any anti-malware or anti-phishing product, false positives may occur.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > Microsoft Teams protection. 3. Set Zero-hour auto purge (ZAP) to On (Default) To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following cmdlet: Set-TeamsProtectionPolicy -Identity \"Teams Protection Policy\" -ZapEnabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > Microsoft Teams protection. 3. Verify that Zero-hour auto purge (ZAP) is set to On (Default) 4. Under Exclude these participants review the list of exclusions and verify they are justified and within tolerance for the organization. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following cmdlets: Get-TeamsProtectionPolicy | fl ZapEnabled Get-TeamsProtectionPolicyRule | fl ExceptIf* 3. Verify that ZapEnabled is True. 4. Review the list of exclusions and ensure they are justified and within tolerance for the organization. If nothing returns from the 2nd cmdlet then there are no exclusions defined.", + "AdditionalInformation": "", + "DefaultValue": "On (Default)", + "References": "https://learn.microsoft.com/en-us/defender-office-365/zero-hour-auto-purge?view=o365-worldwide#zero-hour-auto-purge-zap-in-microsoft-teams:https://learn.microsoft.com/en-us/defender-office-365/mdo-support-teams-about?view=o365-worldwide#configure-zap-for-teams-protection-in-defender-for-office-365-plan-2" + } + ] + }, + { + "Id": "2.4.5", + "Description": "Automated Investigation and Response (AIR) investigates alerts and correlates related signals into investigations. When AIR identifies malicious email messages, it groups them into clusters based on shared characteristics like common malicious file, URL, or sending attributes and produces remediation actions for each cluster. This setting controls whether AIR-identified malicious message clusters are remediated automatically, without requiring SecOps approval. Administrators can enable automatic remediation for one or more cluster types: - Similar files: Clusters sharing a similar malicious file - Similar URLs: Clusters sharing a similar malicious URL - Multiple similar attributes: Clusters grouped by multiple shared attributes such as sender IP address, sender domain, or message subject When enabled, the configured remediation action is applied immediately upon cluster identification. The recommended state is to automatically remediate message clusters.", + "Checks": [], + "Attributes": [ + { + "Section": "2 Microsoft Defender", + "SubSection": "2.4 System", + "Profile": "E5 Level 1", + "AssessmentStatus": "Manual", + "Description": "Automated Investigation and Response (AIR) investigates alerts and correlates related signals into investigations. When AIR identifies malicious email messages, it groups them into clusters based on shared characteristics like common malicious file, URL, or sending attributes and produces remediation actions for each cluster. This setting controls whether AIR-identified malicious message clusters are remediated automatically, without requiring SecOps approval. Administrators can enable automatic remediation for one or more cluster types: - Similar files: Clusters sharing a similar malicious file - Similar URLs: Clusters sharing a similar malicious URL - Multiple similar attributes: Clusters grouped by multiple shared attributes such as sender IP address, sender domain, or message subject When enabled, the configured remediation action is applied immediately upon cluster identification. The recommended state is to automatically remediate message clusters.", + "RationaleStatement": "When automatic remediation is disabled, malicious message clusters identified by AIR remain in users' mailboxes as pending actions until a security analyst manually approves each remediation. During this approval window, users may interact with, open, or forward malicious messages, increasing the risk of a successful compromise. Enabling automatic remediation ensures identified threats are contained immediately upon detection, minimizing the exposure window and reducing the operational burden on security teams to manually review and approve routine threat clusters.", + "ImpactStatement": "Automatic remediation removes the manual SecOps approval step for qualifying message cluster actions. If AIR incorrectly classifies a legitimate message cluster as malicious, affected messages will be soft deleted without prior review. Soft deleted messages are moved to the Recoverable Items folder and can be restored through the Action center, Threat Explorer, or Advanced Hunting, subject to each mailbox's deleted item retention period (14 days by default). Organizations should verify retention policies and legal obligations before enabling this setting, as retention configuration affects whether soft deleted messages remain recoverable. Clusters exceeding 10,000 messages are always excluded from automatic remediation and will continue to require manual approval. License Requirement: This setting requires Defender for Office 365 Plan 2 which is included in Microsoft 365 E5.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > MDO automation settings. 3. Under Message clusters check the following: o Similar files o Similar URLs o Multiple similar attributes 4. Click Save to apply the changes.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Defender https://security.microsoft.com/ 2. Expand System > Settings > Email & collaboration > MDO automation settings. 3. Verify that under Message clusters the following are checked: o Similar files o Similar URLs o Multiple similar attributes Note: At the time of publication, Soft delete is the only available remediation action and is selected by default. Because the action cannot be changed, verifying the selected remediation action is not part of the audit criteria for this recommendation.", + "AdditionalInformation": "", + "DefaultValue": "By default automatic remediation for message clusters is disabled.", + "References": "https://learn.microsoft.com/en-us/defender-office-365/air-auto-remediation:https://learn.microsoft.com/en-us/defender-office-365/air-about:https://learn.microsoft.com/en-us/exchange/security-and-compliance/recoverable-items-folder/recoverable-items-folder:https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/change-deleted-item-retention" + } + ] + }, + { + "Id": "3.1.1", + "Description": "When audit log search is enabled in the Microsoft Purview compliance portal, user and admin activity within the organization is recorded in the audit log and retained for 180 days by default. However, some organizations may prefer to use a third-party security information and event management (SIEM) application to access their auditing data. In this scenario, a global admin can choose to turn off audit log search in Microsoft 365.", + "Checks": [ + "purview_audit_log_search_enabled" + ], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "When audit log search is enabled in the Microsoft Purview compliance portal, user and admin activity within the organization is recorded in the audit log and retained for 180 days by default. However, some organizations may prefer to use a third-party security information and event management (SIEM) application to access their auditing data. In this scenario, a global admin can choose to turn off audit log search in Microsoft 365.", + "RationaleStatement": "Enabling audit log search in the Microsoft Purview compliance portal can help organizations improve their security posture, meet regulatory compliance requirements, respond to security incidents, and gain valuable operational insights.", + "ImpactStatement": "", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Select Solutions and then Audit to open the audit search. 3. Click blue bar Start recording user and admin activity. 4. Click Yes on the dialog box to confirm. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Select Solutions and then Audit to open the audit search. 3. Choose a date and time frame in the past 30 days. 4. Verify search capabilities (e.g. try searching for Activities as Accessed file and results should be displayed). To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled 3. Ensure UnifiedAuditLogIngestionEnabled is set to True. Note: If the Get-AdminAuditLogConfig cmdlet is executed while connected to both Security & Compliance PowerShell as well as Exchange Online PowerShell then UnifiedAuditLogIngestionEnabled will always display False. This depends on the orders the module were imported. If Security & Compliance is needed in the same session be sure to connect to it first, and then Exchange PowerShell second.", + "AdditionalInformation": "", + "DefaultValue": "180 days", + "References": "https://learn.microsoft.com/en-us/purview/audit-log-enable-disable?view=o365-worldwide&tabs=microsoft-purview-portal:https://learn.microsoft.com/en-us/powershell/module/exchange/set-adminauditlogconfig?view=exchange-ps:https://learn.microsoft.com/en-us/purview/audit-log-enable-disable?view=o365-worldwide&tabs=microsoft-purview-portal#verify-the-auditing-status-for-your-organization" + } + ] + }, + { + "Id": "3.2.1", + "Description": "Data Loss Prevention (DLP) policies allow Exchange Online and SharePoint Online content to be scanned for specific types of data like social security numbers, credit card numbers, or passwords.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.2 Data Loss Protection", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Data Loss Prevention (DLP) policies allow Exchange Online and SharePoint Online content to be scanned for specific types of data like social security numbers, credit card numbers, or passwords.", + "RationaleStatement": "Enabling DLP policies alerts users and administrators that specific types of data should not be exposed, helping to protect the data from accidental exposure.", + "ImpactStatement": "Enabling a Teams DLP policy will allow sensitive data in Exchange Online and SharePoint Online to be detected or blocked. Always ensure to follow appropriate procedures during testing and implementation of DLP policies based on organizational standards.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Click Solutions > Data loss prevention then Policies. 3. Click Create policy. 4. Create a policy that is specific to the types of data the organization wishes to protect.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview https://purview.microsoft.com/ 2. Click Solutions > Data loss prevention and then Policies. 3. Inspect the list of policies and verify the following criteria: o A policy exists that meets the organizations DLP needs o Mode is On 4. Open the policy and verify there is at least one of the following locations defined: o Exchange email o SharePoint sites o OneDrive accounts o Teams chat and channel messages 5. Compliance is met when there is at least one policy the meets the above criteria. To audit using the PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Execute the following cmdlet to get a list of DLP Policies: Get-DlpCompliancePolicy 3. For each policy returned verify the following criteria: o Mode is Enable o At least one of the following locations is defined: o ExchangeLocation o SharePointLocation o OneDriveLocation o TeamsLocation 4. Compliance is met when there is at least one policy the meets the above criteria. Note: The types of policies an organization should implement to protect information are specific to their industry. However, certain types of information, such as credit card numbers, social security numbers, and certain personally identifiable information (PII), are universally important to safeguard across all industries.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/purview/dlp-learn-about-dlp?view=o365-worldwide" + } + ] + }, + { + "Id": "3.2.2", + "Description": "The default Teams Data Loss Prevention (DLP) policy rule in Microsoft 365 is a preconfigured rule that is automatically applied to all Teams conversations and channels. The default rule helps prevent accidental sharing of sensitive information by detecting and blocking certain types of content that are deemed sensitive or inappropriate by the organization. By default, the rule includes a check for the sensitive info type Credit Card Number which is pre-defined by Microsoft.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.2 Data Loss Protection", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "The default Teams Data Loss Prevention (DLP) policy rule in Microsoft 365 is a preconfigured rule that is automatically applied to all Teams conversations and channels. The default rule helps prevent accidental sharing of sensitive information by detecting and blocking certain types of content that are deemed sensitive or inappropriate by the organization. By default, the rule includes a check for the sensitive info type Credit Card Number which is pre-defined by Microsoft.", + "RationaleStatement": "Enabling the default Teams DLP policy rule in Microsoft 365 helps protect an organization's sensitive information by preventing accidental sharing or leakage of Credit Card information in Teams conversations and channels. DLP rules are not one size fits all, but at a minimum something should be defined. The organization should identify sensitive information important to them and seek to intercept it using DLP.", + "ImpactStatement": "End-users may be prevented from sharing certain types of content, which may require them to adjust their behavior or seek permission from administrators to share specific content. Administrators may receive requests from end-users for permission to share certain types of content or to modify the policy to better fit the needs of their teams.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Click Policies tab. 4. Check Default policy for Teams then click Edit policy. 5. The edit policy window will appear click Next 6. At the Choose locations to apply the policy page, turn the status toggle to On for Teams chat and channel messages location and then click Next. 7. On Customized advanced DLP rules page, ensure the Default Teams DLP policy rule Status is On and click Next. 8. On the Policy mode page, select the radial for Turn it on right away and click Next. 9. Review all the settings for the created policy on the Review your policy and create it page, and then click submit. 10. Once the policy has been successfully submitted click Done. Note: Some tenants may not have a default policy for teams as Microsoft started creating these by default at a particular point in time. In this case a new policy will have to be created that includes a rule to protect data important to the organization such as credit cards and PII.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Locate the Default policy for Teams. 4. Verify the Status is On. 5. Verify Locations include Teams chat and channel messages - All accounts. 6. Verify Policy settings includes the Default Teams DLP policy rule or one specific to the organization. Note: If there is not a default policy for teams inspect existing policies starting with step 4. DLP rules are specific to the organization and each organization should take steps to protect the data that matters to them. The default teams DLP rule will only alert on Credit Card matches. To audit using PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Run the following to return policies that include Teams chat and channel messages: $DlpPolicy = Get-DlpCompliancePolicy $DlpPolicy | Where-Object {$_.Workload -match \"Teams\"} | ft Name,Mode,TeamsLocation* 3. If nothing returns, then there are no policies that include Teams and remediation is required. 4. For any returned policy verify Mode is set to Enable. 5. Verify TeamsLocation includes All. 6. Verify TeamsLocationException includes only permitted exceptions. Note: Some tenants may not have a default policy for teams as Microsoft started creating these by default at a particular point in time. In this case a new policy will have to be created that includes a rule to protect data important to the organization such as credit cards and PII.", + "AdditionalInformation": "", + "DefaultValue": "Enabled (On)", + "References": "https://learn.microsoft.com/en-us/powershell/exchange/connect-to-scc-powershell?view=exchange-ps:https://learn.microsoft.com/en-us/purview/dlp-teams-default-policy:https://learn.microsoft.com/en-us/powershell/module/exchange/connect-ippssession?view=exchange-ps" + } + ] + }, + { + "Id": "3.2.3", + "Description": "Microsoft Purview Data Loss Prevention (DLP) policies can be scoped to Microsoft 365 Copilot and Copilot Chat interactions. When active, these policies can restrict Copilot from processing or surfacing content that matches configured sensitive information types. Organizations must define the sensitive data categories relevant to their environment and configure at least one DLP policy that covers Copilot interactions in enforcement mode. The recommended state is to configure at least one DLP policy that includes Microsoft 365 Copilot and Copilot Chat - All accounts as a location with rules specific to the organization's needs.", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.2 Data Loss Protection", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Purview Data Loss Prevention (DLP) policies can be scoped to Microsoft 365 Copilot and Copilot Chat interactions. When active, these policies can restrict Copilot from processing or surfacing content that matches configured sensitive information types. Organizations must define the sensitive data categories relevant to their environment and configure at least one DLP policy that covers Copilot interactions in enforcement mode. The recommended state is to configure at least one DLP policy that includes Microsoft 365 Copilot and Copilot Chat - All accounts as a location with rules specific to the organization's needs.", + "RationaleStatement": "Microsoft 365 Copilot can retrieve, summarize, and generate content based on data the authenticated user has access to across M365 workloads, including SharePoint, OneDrive, Teams, and Exchange. Without a DLP policy scoped to Copilot interactions, no technical control exists to prevent sensitive information such as PII, financial data, or health records from being incorporated into Copilot-generated responses and potentially exposed to users who would not otherwise have direct access to the source content. Enforcing DLP policies for Copilot ensures that sensitive data categories defined by the organization are intercepted before they are processed or surfaced by AI-generated responses.", + "ImpactStatement": "Users may find that Copilot declines to process or respond to prompts that involve content matching the organization's configured sensitive information types. In these cases, Copilot will notify the user that the request was blocked by policy. Users who rely on Copilot to summarize, draft, or retrieve content containing sensitive data such as documents with PII, financial records, or health information may need to rephrase their prompts or work with the content directly outside of Copilot. Administrators should communicate the scope of active DLP policies to affected users prior to enforcement.", + "RemediationProcedure": "To remediate using the UI: Note: Microsoft provides a guided wizard to create the Default DLP policy - Protect sensitive M365 Copilot interactions policy, which can be used when no Copilot DLP policy exists in the tenant. The steps below describe how to create a custom policy from scratch with Copilot included as a location. 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Click Policies tab. 4. Click + Create Policy. 5. Click on Enterprise applications & devices. 6. Under Categories select Custom and then Custom policy for the regulation. 7. Name the policy, and if appropriate, select an Admin Unit. 8. In Locations select Microsoft 365 Copilot and Copilot Chat. 9. Click on Next to proceed to the Advanced DLP rules page. 10. Click on + Create Rule 1. Name the rule and give a brief description of the data that is being targeted. 2. Click on + Add condition and select Content Contains 3. Click on Add and select Sensitive info types 4. Select the sensitive information types the organization wants to protect from being processed in Copilot interactions and click Add. 5. Click on + Add an action and select Restrict Copilot from processing content 6. Check the box for a relevant restriction. 7. Click on Save. 11. Repeat step 10 to create as many rules as the organization requires 12. Click Next. 13. On the Policy mode page, select the radial for Turn it on right away and click Next. 14. Click Submit to create the policy once it has been reviewed. 15. Finally, click Done. Note: Compliance with this recommendation is not achieved until the policy is in enforcement mode.", + "AuditProcedure": "Note: Some tenants may have a default policy called Default DLP policy - Protect sensitive M365 Copilot interactions that was automatically created. If not present, it can also be created using a guided process in the Policies blade. If this policy exists, it may be used to satisfy the requirements of this control provided it meets the compliance criteria below. To audit using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Under Solutions select Data loss prevention then Policies. 3. Inspect the list of policies and verify the following criteria: o Locations includes Microsoft 365 Copilot and Copilot Chat - All accounts. o Mode is On. o The policy includes Rules that restrict sensitive data from being shared in Copilot interactions based on the organization's needs. 4. Compliance is met when there is at least one policy that meets the above criteria. To audit using PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Run the following to return policies that include Teams chat and channel messages: $DlpPolicy = Get-DlpCompliancePolicy $DlpPolicy | Where-Object {$_.EnforcementPlanes -match \"CopilotExperiences\"} | FT Name,Mode,LocationInclusions,LocationExclusions 3. If nothing returns, then there are no policies that include Copilot and remediation is required. 4. For any returned policy verify Mode is set to Enable. 5. Verify LocationInclusions includes All. 6. Verify LocationExclusions includes only permitted exceptions. Note: DLP rules are specific to the organization and each organization should take steps to protect the data that matters to them. At a minimum, organizations should consider protecting personally identifiable information (PII) specific to their locale.", + "AdditionalInformation": "", + "DefaultValue": "No Copilot DLP policy exists by default for most tenants. Some tenants may have had a Default DLP policy - Protect sensitive M365 Copilot interactions policy automatically provisioned by Microsoft; if present, it may satisfy this recommendation if it meets the audit criteria.", + "References": "https://learn.microsoft.com/en-us/purview/dlp-microsoft365-copilot-location-learn-about:https://learn.microsoft.com/en-us/purview/dlp-microsoft365-copilot-location-default-policy:https://learn.microsoft.com/en-us/powershell/exchange/connect-to-scc-powershell?view=exchange-ps" + } + ] + }, + { + "Id": "3.3.1", + "Description": "Sensitivity labels enable organizations to classify and label content across Microsoft 365 based on its sensitivity and business impact. These labels can be applied manually by users or automatically based on the content. When applied, labels can automatically encrypt content, provide \"Confidential\" watermarks, restrict access, and offer various data protection features. Labels can be scoped to data assets and containers: - Files & other data assets in Microsoft 365, Fabric, Azure, AWS and other platforms - Email messages sent from all versions of Outlook - Meeting calendar events and schedules in Outlook and Teams - Teams, Microsoft 365 Groups and SharePoint sites", + "Checks": [], + "Attributes": [ + { + "Section": "3 Microsoft Purview", + "SubSection": "3.3 Information Protection", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Sensitivity labels enable organizations to classify and label content across Microsoft 365 based on its sensitivity and business impact. These labels can be applied manually by users or automatically based on the content. When applied, labels can automatically encrypt content, provide \"Confidential\" watermarks, restrict access, and offer various data protection features. Labels can be scoped to data assets and containers: - Files & other data assets in Microsoft 365, Fabric, Azure, AWS and other platforms - Email messages sent from all versions of Outlook - Meeting calendar events and schedules in Outlook and Teams - Teams, Microsoft 365 Groups and SharePoint sites", + "RationaleStatement": "Consistent usage of sensitivity labels can help reduce the risk of data loss or exposure and enable more effective incident response if a breach does occur. They can also help organizations comply with regulatory requirements and provide visibility and control over sensitive information.", + "ImpactStatement": "Encryption configurations (control access, DKE, BYOK) in the individual labels may impact users' ability to access site documents and information. Careful consideration of the individual sensitivity label configurations should be exercised prior to applying an auto labeling policy, publishing policy, sensitivity label configuration, or PowerShell based label settings to SharePoint sites. Additionally, when updating or deleting Sensitivity Labels, an assessment of the potential impacts should be conducted to avoid unintended consequences. If tenants are configured for sharing with guests or external domains and Sensitivity Labels have encryption applied, this can affect the ability to share documents via email stored in SharePoint. Some recipients may be unable to open the document depending on their email client, which could trigger Purview Advanced Encryptions and OME flows based on the recipient type and the cloud license from which the email is sent (e.g., government clouds vs. commercial clouds).", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Select Information protection > Sensitivity labels. 3. Click Create a label to create a label. 4. Click Publish labels and select any newly created labels to publish according to the organization's information protection needs.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Purview compliance portal https://purview.microsoft.com/ 2. Select Information protection > Policies > Label publishing policies. 3. Ensure that a Label policy exists and is published according to the organization's information protection needs. To audit using PowerShell: 1. Connect to the Security & Compliance PowerShell using Connect-IPPSSession. 2. Run the following script: $Policies = Get-LabelPolicy -WarningAction Ignore | Where-Object { $_.Type -eq \"PublishedSensitivityLabel\" } if ($Policies) { $Policies | Format-List -Property Name, *Location* Write-Host \"$($Policies.Count) Sensitivity Label policies found.\" } else { Write-Host \"No Sensitivity Label policies found\" } 3. Ensure there is at least one sensitivity label policy published. 4. Review the locations defined to ensure they're in scope with the organization's needs. Note: These policies are specific to the information protection needs of each organization. Whether an organization passes the audit is open to interpretation by the auditor and depends largely on how effectively it implements information protection features to safeguard data.", + "AdditionalInformation": "", + "DefaultValue": "The \"Global sensitivity label policy\" exists by default.", + "References": "https://learn.microsoft.com/en-us/purview/sensitivity-labels:https://learn.microsoft.com/en-us/purview/create-sensitivity-labels" + } + ] + }, + { + "Id": "4.1", + "Description": "Compliance policies are sets of rules and conditions that are used to evaluate the configuration of managed devices. These policies can help secure organizational data and resources from devices that don't meet those configuration requirements. Managed devices must satisfy the conditions you set in your policies to be considered compliant by Intune. When combined with conditional access, this allows more control over how non-compliant devices are treated. The recommended state is Mark devices with no compliance policy assigned as as Not compliant", + "Checks": [ + "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + ], + "Attributes": [ + { + "Section": "4 Microsoft Intune admin center", + "SubSection": "", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Compliance policies are sets of rules and conditions that are used to evaluate the configuration of managed devices. These policies can help secure organizational data and resources from devices that don't meet those configuration requirements. Managed devices must satisfy the conditions you set in your policies to be considered compliant by Intune. When combined with conditional access, this allows more control over how non-compliant devices are treated. The recommended state is Mark devices with no compliance policy assigned as as Not compliant", + "RationaleStatement": "Implementing this setting is a first step in adopting compliance policies for devices. When used together with Conditional Access policies the attack surface can be reduced by forcing an action to be taken for non-compliant devices. Note: This section does not focus on which compliance policies to use, only that an organization should adopt and enforce them to their needs.", + "ImpactStatement": "Any devices without a compliance policy will be marked not compliant. Care should be taken to first deploy any new compliance policies with a Conditional Access (CA) policy that is in the Report-only state. After the environment's device compliance is better understood it is then appropriate to finally align with Mark devices with no compliance policy assigned as and enable any CA policies that enforce actions based on device compliance. If a mature environment already has an existing device compliance CA policy and a large number of devices without an assigned compliance policy, this could cause disruption as those devices would then be suddenly considered not compliant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Manage devices click Compliance 3. Click Compliance settings. 4. Set Mark devices with no compliance policy assigned as to Not compliant. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"DeviceManagementConfiguration.ReadWrite.All\" 2. Run the following commands: $Uri = 'https://graph.microsoft.com/v1.0/deviceManagement' $Body = @{ settings = @{ secureByDefault = $true } } | ConvertTo-Json Invoke-MgGraphRequest -Uri $Uri -Method PATCH -Body $Body", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Manage devices click Compliance 3. Click Compliance settings. 4. Verify that Mark devices with no compliance policy assigned as is set to Not compliant. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"DeviceManagementConfiguration.Read.All\" 2. Run the following commands: $Uri = 'https://graph.microsoft.com/v1.0/deviceManagement/settings' Invoke-MgGraphRequest -Uri $Uri -Method GET 3. Verify that secureByDefault is set to True.", + "AdditionalInformation": "", + "DefaultValue": "UI: \"Compliant\" Graph: secureByDefault = $false", + "References": "https://learn.microsoft.com/en-us/mem/intune/protect/device-compliance-get-started" + } + ] + }, + { + "Id": "4.2", + "Description": "Device enrollment restrictions let you restrict devices from enrolling in Intune based on certain device attributes such as device limit, device platform, OS Version, manufacturer or device ownership (Personally owned devices). The recommended state is to Block personally owned devices from enrollment.", + "Checks": [], + "Attributes": [ + { + "Section": "4 Microsoft Intune admin center", + "SubSection": "", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Device enrollment restrictions let you restrict devices from enrolling in Intune based on certain device attributes such as device limit, device platform, OS Version, manufacturer or device ownership (Personally owned devices). The recommended state is to Block personally owned devices from enrollment.", + "RationaleStatement": "Restricting the enrollment of personally owned devices prevents attackers who have bypassed other controls from registering a new device to gain an additional foothold, further hiding or obscuring their activities. An attack path could be: 1. Account Compromise via Phishing and AiTM 2. Conditional Access Bypass 3. Reconnaissance using e.g. ROADrecon, GraphRunner or AADInternals 4. Lateral Movement, Privilege Escalation or Persistence through a newly registered device enrolled in Intune", + "ImpactStatement": "Per platform personally owned device enrollment impacts are listed below. It is important to test the changes to the defaults prior to moving into production and implementing this control. Windows Devices The following enrollment methods are authorized for corporate enrollment for Windows devices, any other enrollment method will be considered \"Personal\" and blocked: - The device enrolls through Windows Autopilot. - The device enrolls through GPO, or automatic enrollment from Configuration Manager for co-management. - The device enrolls through a bulk provisioning package. - The enrolling user is using a device enrollment manager account. MacOS By default, Intune classifies macOS devices as personally owned. To be classified as corporate-owned, a Mac must fulfill one of the following conditions: - Registered with a serial number. - Enrolled via Apple Automated Device Enrollment (ADE). iOS/IPadOS devices By default, Intune classifies iOS/iPadOS devices as personally owned. To be classified as corporate-owned, an iOS/iPadOS device must fulfill one of the following conditions: - Registered with a serial number or IMEI. - Enrolled by using Automated Device Enrollment (formerly Device Enrollment Program). Android devices By default, until you manually make changes in the admin center, your Android Enterprise work profile device settings and Android device administrator device settings are the same. If you block Android Enterprise work profile enrollment on personal devices, only corporate-owned devices can enroll with personally owned work profiles.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Device onboarding click Enrollment 3. Under Enrollment options select Device platform restriction. 4. Inspect the policies listed under Device type restrictions o For the Default priority policy, click All Users. o Select Properties. 5. Click Edit to change Platform settings. 6. In the Personally owned column set each platform to Block. Note: Blocking platforms that are not used in the organization is a more restrictive best practice and will also effectively block enrollment of personally owned devices for the selected platform, ensuring compliance for this recommendation.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Intune admin center https://intune.microsoft.com/ 2. Select Devices and then under Device onboarding click Enrollment 3. Under Enrollment options select Device platform restriction. 4. Inspect the policies listed under Device type restrictions o For the Default priority policy, click All Users. o Select Properties. 5. Verify that all platforms are set to Block in the Personally owned column. 6. If the Platform itself is set to Block for any of the platforms shown this is also a passing state for that platform. Note: Blocking platforms that are not used in the organization is a more restrictive best practice and will also effectively block enrollment of personally owned devices for the selected platform, ensuring compliance for this recommendation. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"DeviceManagementConfiguration.Read.All\" 2. Run the following script: $Uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurat ions' $Config = (Invoke-MgGraphRequest -Uri $Uri -Method GET).value | Where-Object { $_.id -match 'DefaultPlatformRestrictions' -and $_.priority - eq 0 } $Result = [PSCustomObject]@{ WindowsPersonalDeviceEnrollmentBlocked = $Config.windowsRestriction.personalDeviceEnrollmentBlocked iOSPersonalDeviceEnrollmentBlocked = $Config.iosRestriction.personalDeviceEnrollmentBlocked AndroidForWorkPersonalDeviceEnrollmentBlocked = $Config.androidForWorkRestriction.personalDeviceEnrollmentBlocked MacOPersonalDeviceEnrollmentBlocked = $Config.macOSRestriction.personalDeviceEnrollmentBlocked AndroidPersonalDeviceEnrollmentBlocked = $Config.androidRestriction.personalDeviceEnrollmentBlocked } $Result 3. Inspect the output, ensure each platform displays True next to its property. A passing output will look like the below: WindowsPersonalDeviceEnrollmentBlocked : True iOSPersonalDeviceEnrollmentBlocked : True AndroidForWorkPersonalDeviceEnrollmentBlocked : True MacOPersonalDeviceEnrollmentBlocked : True AndroidPersonalDeviceEnrollmentBlocked : True Note: If platformBlocked is true then that platform is also in compliance as the platform is blocked from enrollment entirely. This is not currently reflected in the audit script but can be queried from the same API call.", + "AdditionalInformation": "", + "DefaultValue": "Allow", + "References": "https://learn.microsoft.com/en-us/mem/intune/enrollment/enrollment-restrictions-set:https://www.glueckkanja.com/blog/security/2025/01/compliant-device-bypass-en/" + } + ] + }, + { + "Id": "5.1.2.1", + "Description": "Legacy per-user Multi-Factor Authentication (MFA) can be configured to require individual users to provide multiple authentication factors, such as passwords and additional verification codes, to access their accounts. It was introduced in earlier versions of Office 365, prior to the more comprehensive implementation of Conditional Access (CA).", + "Checks": [ + "entra_users_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Legacy per-user Multi-Factor Authentication (MFA) can be configured to require individual users to provide multiple authentication factors, such as passwords and additional verification codes, to access their accounts. It was introduced in earlier versions of Office 365, prior to the more comprehensive implementation of Conditional Access (CA).", + "RationaleStatement": "Both security defaults and conditional access with security defaults turned off are not compatible with per-user multi-factor authentication (MFA), which can lead to undesirable user authentication states. The CIS Microsoft 365 Benchmark explicitly employs Conditional Access for MFA as an enhancement over security defaults and as a replacement for the outdated per-user MFA. To ensure a consistent authentication state disable per-user MFA on all accounts.", + "ImpactStatement": "Accounts using per-user MFA will need to be migrated to use CA. Prior to disabling per-user MFA the organization must be prepared to implement conditional access MFA to avoid security gaps and allow for a smooth transition. This will help ensure relevant accounts are covered by MFA during the change phase from disabling per-user MFA to enabling CA MFA. Section 5.2.2 in this document covers the creation of a CA rule for both administrators and all users in the tenant. Microsoft has documentation on migrating from per-user MFA Convert users from per- user MFA to Conditional Access based MFA", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select All users. 3. Click on Per-user MFA on the top row. 4. Click the empty box next to Display Name to select all accounts. 5. On the far right under quick steps click Disable.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select All users. 3. Click on Per-user MFA on the top row. 4. Verify that the Multi-factor Auth Status column shows Disabled for each account. To audit using Microsoft Graph 1. Determine the id or userPrincipalName of the user being audited. 2. Execute a GET request to the following relative URI: beta/users/{id | userPrincipalName}/authentication/requirements # Example https://graph.microsoft.com/beta/users/071cc716-8147-4397-a5ba- b2105951cc0b/authentication/requirements 3. Verify that the perUserMfaState property is set to disabled. 4. Repeat this process for all users within the tenant. Note: This API is in beta and does not support a list operation. To prevent server-side throttling, clients should implement batching and client-side rate limiting when auditing medium to large sized environments.", + "AdditionalInformation": "", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-userstates#convert-users-from-per-user-mfa-to-conditional-access:https://learn.microsoft.com/en-us/microsoft-365/admin/security-and-compliance/set-up-multi-factor-authentication?view=o365-worldwide#use-conditional-access-policies:https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-userstates#convert-per-user-mfa-enabled-and-enforced-users-to-disabled:https://learn.microsoft.com/en-us/graph/api/authentication-get?view=graph-rest-beta" + } + ] + }, + { + "Id": "5.1.2.2", + "Description": "This setting controls whether standard users can register applications in the Microsoft Entra ID directory. When enabled, any user can create app registrations, which function as identity objects for applications.", + "Checks": [ + "entra_thirdparty_integrated_apps_not_allowed" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls whether standard users can register applications in the Microsoft Entra ID directory. When enabled, any user can create app registrations, which function as identity objects for applications.", + "RationaleStatement": "Allowing standard users to create app registrations expands the tenant's attack surface. A compromised account or malicious insider could create a rogue app registration to establish a persistent OAuth client, facilitate token theft, or impersonate a legitimate application. Restricting app registration to privileged roles ensures that new application identities in the directory are subject to administrative review and approval before they can be granted permissions to organizational resources.", + "ImpactStatement": "End users will no longer be able to register applications independently, including both third-party integrations and custom applications. Developers and IT staff who create app registrations as part of normal workflows will be affected and will need to submit registration requests to a privileged administrator (e.g., Application Administrator or Cloud Application Administrator). Organizations should establish a formal request and approval process before implementing this change to avoid workflow disruption.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select Users settings. 3. Set Users can register applications to No. 4. Click Save. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following commands: $param = @{ AllowedToCreateApps = $false } Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions $param", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select Users settings. 3. Verify that Users can register applications is set to No. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | fl AllowedToCreateApps 3. Verify the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "Yes (Users can register applications.)", + "References": "https://learn.microsoft.com/en-us/entra/identity-platform/how-applications-are-added:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/delegate-app-roles#restrict-who-can-create-applications" + } + ] + }, + { + "Id": "5.1.2.3", + "Description": "Non-privileged users can create tenants in the Microsoft Entra ID and Microsoft Entra administration portal under \"Manage tenant\". The creation of a tenant is recorded in the Audit log as category \"DirectoryManagement\" and activity \"Create Company\". By default, the user who creates a Microsoft Entra tenant is automatically assigned the Global Administrator role. The newly created tenant doesn't inherit any settings or configurations.", + "Checks": [ + "entra_policy_ensure_default_user_cannot_create_tenants" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Non-privileged users can create tenants in the Microsoft Entra ID and Microsoft Entra administration portal under \"Manage tenant\". The creation of a tenant is recorded in the Audit log as category \"DirectoryManagement\" and activity \"Create Company\". By default, the user who creates a Microsoft Entra tenant is automatically assigned the Global Administrator role. The newly created tenant doesn't inherit any settings or configurations.", + "RationaleStatement": "Restricting tenant creation prevents unauthorized or uncontrolled deployment of resources and ensures that the organization retains control over its infrastructure. User generation of shadow IT could lead to multiple, disjointed environments that can make it difficult for IT to manage and secure the organization's data, especially if other users in the organization began using these tenants for business purposes under the misunderstanding that they were secured by the organization's security team.", + "ImpactStatement": "Non-admin users will need to contact I.T. if they have a valid reason to create a tenant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Set Restrict non-admin users from creating tenants to Yes then Save. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following commands: # Create hashtable and update the auth policy $params = @{ AllowedToCreateTenants = $false } Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Verify that Restrict non-admin users from creating tenants is set to Yes To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following commands: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | Select-Object AllowedToCreateTenants 3. Verify the returned value is False", + "AdditionalInformation": "", + "DefaultValue": "No - Non-administrators can create tenants. AllowedToCreateTenants is True", + "References": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#restrict-member-users-default-permissions:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator" + } + ] + }, + { + "Id": "5.1.2.4", + "Description": "This setting restricts non-administrators from loading a set of frequently visited pages in the Microsoft Entra admin center and Azure portal, including home, tenant overview, and the users list. What does it not do? - It does not block programmatic access to Microsoft Entra data via PowerShell, Microsoft Graph API, or other tools like Visual Studio. - It does not apply to users with an administrative role, including custom roles. - It does not prevent all access to the admin center. Many areas are still reachable through alternate paths.", + "Checks": [ + "entra_admin_portals_access_restriction" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This setting restricts non-administrators from loading a set of frequently visited pages in the Microsoft Entra admin center and Azure portal, including home, tenant overview, and the users list. What does it not do? - It does not block programmatic access to Microsoft Entra data via PowerShell, Microsoft Graph API, or other tools like Visual Studio. - It does not apply to users with an administrative role, including custom roles. - It does not prevent all access to the admin center. Many areas are still reachable through alternate paths.", + "RationaleStatement": "The Microsoft Entra admin center contains sensitive data and permission settings, which are still enforced based on the user's role. However, an end user may inadvertently change properties or account settings on their own account. This could result in increased administrative overhead. Additionally, a compromised end-user account could be used to enumerate tenant structure, users, and group memberships to support privilege escalation or lateral movement.", + "ImpactStatement": "Non-administrators who own groups will be unable to reach group management pages through the standard admin center navigation. Self-service access to other portal-facing features may also be affected depending on the navigation path used. Because the restriction targets specific frequently accessed pages rather than all portal content, users with direct (deep) links to other admin center sections may still be able to access them. Note: Users will still be able to sign into Microsoft Entra admin center but will be unable to see directory information.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Set Restrict access to Microsoft Entra admin center to Yes then Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Users and select User settings. 3. Verify under the Administration center section that Restrict access to Microsoft Entra admin center is set to Yes.", + "AdditionalInformation": "", + "DefaultValue": "No - Non-administrators can access the Microsoft Entra admin center.", + "References": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#restrict-member-users-default-permissions" + } + ] + }, + { + "Id": "5.1.2.5", + "Description": "The option for the user to Stay signed in, or the Keep me signed in option, will prompt a user after a successful login. When the user selects this option, a persistent refresh token is created. The refresh token lasts for 90 days by default and does not prompt for sign-in or multifactor.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "The option for the user to Stay signed in, or the Keep me signed in option, will prompt a user after a successful login. When the user selects this option, a persistent refresh token is created. The refresh token lasts for 90 days by default and does not prompt for sign-in or multifactor.", + "RationaleStatement": "Allowing users to select this option presents risk, especially if the user signs into their account on a publicly accessible computer/web browser. In this case it would be trivial for an unauthorized person to gain access to any associated cloud data from that account.", + "ImpactStatement": "Once this setting is hidden users will no longer be prompted upon sign-in with the message Stay signed in?. This may mean users will be forced to sign in more frequently. Important: some features of SharePoint Online and Office 2010 have a dependency on users remaining signed in. If you hide this option, users may get additional and unexpected sign in prompts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Set Show keep user signed in to No. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Verify that Show keep user signed in is highlighted No.", + "AdditionalInformation": "", + "DefaultValue": "Users may select stay signed in", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concepts-azure-multi-factor-authentication-prompts-session-lifetime:https://learn.microsoft.com/en-us/entra/fundamentals/how-to-manage-stay-signed-in-prompt" + } + ] + }, + { + "Id": "5.1.2.6", + "Description": "LinkedIn account connections allow users to connect their Microsoft work or school account with LinkedIn. After a user connects their accounts, information and highlights from LinkedIn are available in some Microsoft apps and services.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.2 Users", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "LinkedIn account connections allow users to connect their Microsoft work or school account with LinkedIn. After a user connects their accounts, information and highlights from LinkedIn are available in some Microsoft apps and services.", + "RationaleStatement": "Disabling LinkedIn integration prevents potential phishing attacks and risk scenarios where an external party could accidentally disclose sensitive information.", + "ImpactStatement": "Users will not be able to sync contacts or use LinkedIn integration.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Under LinkedIn account connections select No. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Users and select User settings. 3. Under LinkedIn account connections, verify that No is selected.", + "AdditionalInformation": "", + "DefaultValue": "LinkedIn integration is enabled by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/linkedin-integration:https://learn.microsoft.com/en-us/entra/identity/users/linkedin-user-consent" + } + ] + }, + { + "Id": "5.1.3.1", + "Description": "This setting allows users in the organization to create new security groups and add members to these groups in the Azure portal, API, or PowerShell. These new groups also show up in the Access Panel for all other users. If the policy setting on the group allows it, other users can create requests to join these groups. The recommended state is Users can create security groups in Azure portals, API or PowerShell set to No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting allows users in the organization to create new security groups and add members to these groups in the Azure portal, API, or PowerShell. These new groups also show up in the Access Panel for all other users. If the policy setting on the group allows it, other users can create requests to join these groups. The recommended state is Users can create security groups in Azure portals, API or PowerShell set to No.", + "RationaleStatement": "Allowing end users to create security groups without oversight can lead to uncontrolled group sprawl, increasing the risk of inappropriate access to sensitive data. The default assignment of group ownership to the creator introduces a potential for privilege escalation, especially if IT teams overlook how these groups are later used to manage access. A more malicious scenario arises when a compromised non-privileged user creates deceptively named security groups such as \"Accounting\" or \"Break-glass\", or uses homograph techniques to mimic legitimate group names. Third-party IT teams may be particularly susceptible, as they might not be familiar with the environment or lack consistent naming conventions. An unsuspecting administrator could then mistakenly assign elevated privileges, grant access to sensitive data, or exclude these groups from Conditional Access policies, inadvertently creating a serious security gap.", + "ImpactStatement": "Restrictions may introduce some operational friction, particularly in fast-paced or decentralized environments where teams rely on self-service capabilities for collaboration and access management. This can increase reliance on IT teams for routine tasks, potentially causing delays. However, these impacts can be minimized through automated approval workflows and clear governance processes.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Set Users can create security groups in Azure portals, API or PowerShell to No. 4. Click Save. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following commands: $params = @{ defaultUserRolePermissions = @{ AllowedToCreateSecurityGroups = $false } } Update-MgPolicyAuthorizationPolicy -BodyParameter $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Verify that Users can create security groups in Azure portals, API or PowerShell is set to No. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | fl 3. Verify that AllowedToCreateSecurityGroups is False.", + "AdditionalInformation": "", + "DefaultValue": "AllowedToCreateSecurityGroups : True", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management?WT.mc_id=Portal-Microsoft_AAD_IAM#group-settings:https://learn.microsoft.com/en-us/graph/api/authorizationpolicy-get?view=graph-rest-1.0&tabs=http:https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management#making-a-group-available-for-end-user-self-service" + } + ] + }, + { + "Id": "5.1.3.2", + "Description": "This setting restricts standard users from accessing the My Groups web interface in the My Account portal (https://myaccount.microsoft.com/groups). When set to Yes, this web interface access is removed for standard users. The recommended state is Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "This setting restricts standard users from accessing the My Groups web interface in the My Account portal (https://myaccount.microsoft.com/groups). When set to Yes, this web interface access is removed for standard users. The recommended state is Yes.", + "RationaleStatement": "By default, any authenticated user can access the My Groups portal and enumerate group memberships, SharePoint site URLs, group email addresses, Teams URLs, and Yammer URLs across the tenant. This information enables reconnaissance, where a user could identify high-value or privileged groups, map resource URLs, and use that data to plan further attacks or lateral movement. Restricting the web interface limits passive enumeration by users who do not require group browsing as part of their duties, reducing the available attack surface without impacting core productivity. Note: This setting applies only to the My Groups web interface. API-based enumeration remains possible for users with appropriate permissions or tooling, and this control should not be treated as a complete enumeration defense.", + "ImpactStatement": "Setting this to Yes creates administrative overhead for users who need to look up group memberships and must now request that information from an administrator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Under Self Service Group Management, set Restrict user ability to access groups features in My Groups. Group and User Admin will have read-only access when the value of this setting is 'Yes' to Yes. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Under Self Service Group Management, verify that Restrict user ability to access groups features in My Groups. Group and User Admin will have read-only access when the value of this setting is 'Yes' is set to Yes.", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management" + } + ] + }, + { + "Id": "5.1.3.3", + "Description": "Microsoft Entra ID provides self-service group management features that enable users to create and manage their own security groups or Microsoft 365 groups. The owner of the group can approve or deny membership requests and delegate control of group membership. Self-service group management features aren't available for mail-enabled security groups or distribution lists. The recommended state is No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Microsoft Entra ID provides self-service group management features that enable users to create and manage their own security groups or Microsoft 365 groups. The owner of the group can approve or deny membership requests and delegate control of group membership. Self-service group management features aren't available for mail-enabled security groups or distribution lists. The recommended state is No.", + "RationaleStatement": "Group owners are standard users who may not have visibility into access governance requirements for a given group. Allowing owners to approve membership requests through My Groups means additions to security groups or Microsoft 365 groups can occur without administrator review, bypassing formal access provisioning controls. Unauthorized or excessive group membership can expand a user's effective permissions and increase the blast radius of a compromised account.", + "ImpactStatement": "Administrators will be responsible for managing group membership requests instead of group owners, which is the default behavior. Administrative overhead will only increase if this setting was previously changed to Yes.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups select General. 3. Set Owners can manage group membership requests in My Groups to No. 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Verify that Owners can manage group membership requests in My Groups is set to No", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management#making-a-group-available-for-end-user-self-service" + } + ] + }, + { + "Id": "5.1.3.4", + "Description": "All users within a Microsoft Entra organization are permitted to create new Microsoft 365 groups and add members to those groups through the Azure portal, API, or PowerShell. Newly created groups also appear in the Access Panel for all other users. When the applicable group policy settings allow it, users can submit requests to join these groups. The recommended state is No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.3 Groups", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "All users within a Microsoft Entra organization are permitted to create new Microsoft 365 groups and add members to those groups through the Azure portal, API, or PowerShell. Newly created groups also appear in the Access Panel for all other users. When the applicable group policy settings allow it, users can submit requests to join these groups. The recommended state is No.", + "RationaleStatement": "Restricting Microsoft 365 group creation to administrators only ensures that creation of Microsoft 365 groups is controlled by the administrator. Appropriate groups should be created and managed by the administrator and group creation rights should not be delegated to any other user.", + "ImpactStatement": "Enabling this setting could create a number of requests that would need to be managed by an administrator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Set Users can create Microsoft 365 groups in Azure portals, API or PowerShell to No 4. Click Save. To remediate using the Microsoft Graph API: 1. Execute a PATCH request to the following relative URI: v1.0/groupSettings 2. Target the object with the templateId of 62375ab9-6b52-47ed-826b- 58e47e0e304b 3. Update EnableGroupCreation to false. Note: If a group with the above templateId doesn't exist this means the defaults are present and it would be advisable to use the UI to remediate, as this will automatically create the Group.Unified object with its defaults. Microsoft's documentation does cover using a POST request to build this using the API, however.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Groups and select General. 3. Verify that Users can create Microsoft 365 groups in Azure portals, API or PowerShell is set to No To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/groupSettings 2. Filter to groups with the templateId of 62375ab9-6b52-47ed-826b- 58e47e0e304b 3. Verify that EnableGroupCreation is false. 4. If the group with the above templateId does not exist, then it means the setting is in its default state and is not compliant.", + "AdditionalInformation": "", + "DefaultValue": "Yes", + "References": "https://learn.microsoft.com/en-us/microsoft-365/solutions/manage-creation-of-groups:https://learn.microsoft.com/en-us/graph/api/group-list-settings?view=graph-rest-0&tabs=http:https://learn.microsoft.com/en-us/graph/api/groupsetting-update?view=graph-rest-1.0&tabs=http" + } + ] + }, + { + "Id": "5.1.4.1", + "Description": "This setting enables you to select the users who can register their devices as Microsoft Entra joined devices. The recommended state is Selected or None. Note: This setting is applicable only to Microsoft Entra join on Windows 10 or newer. This setting doesn't apply to Microsoft Entra hybrid joined devices, Microsoft Entra joined VMs in Azure, or Microsoft Entra joined devices that use Windows Autopilot self- deployment mode because these methods work in a userless context.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting enables you to select the users who can register their devices as Microsoft Entra joined devices. The recommended state is Selected or None. Note: This setting is applicable only to Microsoft Entra join on Windows 10 or newer. This setting doesn't apply to Microsoft Entra hybrid joined devices, Microsoft Entra joined VMs in Azure, or Microsoft Entra joined devices that use Windows Autopilot self- deployment mode because these methods work in a userless context.", + "RationaleStatement": "If a threat actor compromises a standard user account, they can enroll a rogue device under that user's identity. This device may inherit MDM policies and appear compliant, giving attackers persistent access to cloud resources without triggering MFA. In a 2023 blog, Microsoft IR reports that it has detected threat actors registering their own devices to the Microsoft Entra tenant, giving them a platform to escalate the cyberattack. While simply joining a device to a Microsoft Entra tenant may present limited immediate risk, it could allow a threat actor to establish a foothold in the environment.", + "ImpactStatement": "Restricting the setting requires IT teams to assign enrollment permissions to specific staff, such as helpdesk or provisioning personnel, which may impact user-driven Autopilot scenarios and increase administrative overhead for device onboarding and support.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Users may join devices to Microsoft Entra to Selected (and add members) or None.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Users may join devices to Microsoft Entra is set to Selected or None. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/deviceRegistrationPolicy 3. Verify that azureADJoin.allowedToJoin.@odata.type is one of the following: o #microsoft.graph.enumeratedDeviceRegistrationMembership (Selected) o #microsoft.graph.noDeviceRegistrationMembership (None). Note: When set to Selected, users and groups will also appear in the output of the Graph Request.", + "AdditionalInformation": "", + "DefaultValue": "All", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://www.microsoft.com/en-us/security/blog/2023/12/05/microsoft-incident-response-lessons-on-preventing-cloud-identity-compromise/#poor-device:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta" + } + ] + }, + { + "Id": "5.1.4.2", + "Description": "This setting defines the maximum number of Microsoft Entra joined or registered devices that a user can have in Microsoft Entra ID. Once this limit is reached, no additional devices can be added until existing ones are removed. Values above 100 are automatically capped at 100. The recommended state is 10 or less.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting defines the maximum number of Microsoft Entra joined or registered devices that a user can have in Microsoft Entra ID. Once this limit is reached, no additional devices can be added until existing ones are removed. Values above 100 are automatically capped at 100. The recommended state is 10 or less.", + "RationaleStatement": "Microsoft incident response teams have observed threat actors enrolling their own devices to establish persistence after a non-privileged user has been compromised. High device quotas can exacerbate this risk by enabling attackers to register multiple devices that appear legitimate, while also contributing to unmanaged or personal devices cluttering the environment, driving up licensing costs and complicating compliance efforts. Enforcing a reasonable device limit per user supports good governance, reduces the attack surface, and encourages administrators to reassess and clean up legacy or unused device enrollments.", + "ImpactStatement": "IT staff who need to enroll more than 10 devices on behalf of the organization must be assigned the role of Device Enrollment Manager in the Intune admin center. Device Enrollment Managers are non-administrator accounts that can enroll and manage up to 1,000 devices. It is recommended to use dedicated service accounts for this role rather than assigning it to users' primary or daily-use accounts. Warning: Do not delete accounts assigned as a Device enrollment manager if any devices were enrolled using the account. Doing so will lead to issues with these devices.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Maximum number of devices per user to 10 or less.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Maximum number of devices per user is set to 10 or less. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/policies/deviceRegistrationPolicy 2. Verify that userDeviceQuota is 10 or less.", + "AdditionalInformation": "", + "DefaultValue": "50", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/intune/intune-service/enrollment/device-enrollment-manager-enroll:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta" + } + ] + }, + { + "Id": "5.1.4.3", + "Description": "This setting controls whether the Global Administrator role is automatically added to the local administrators group on a device during the Microsoft Entra join process. The recommended state is No.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls whether the Global Administrator role is automatically added to the local administrators group on a device during the Microsoft Entra join process. The recommended state is No.", + "RationaleStatement": "System administrators may be inclined to use over-privileged accounts for convenience when managing devices. Enforcing this control helps discourage that behavior by requiring administrative actions to be performed using accounts specifically designated for local administration. This promotes adherence to the principle of least privilege and reduces the risk associated with using high-level roles for routine tasks. For example, using a Global Administrator account to authenticate to a compromised endpoint and continue performing tasks significantly increases the risk of broader organizational compromise.", + "ImpactStatement": "Restricting the default behavior and requiring manual assignment to least privilege roles introduces minor administrative overhead. During the Microsoft Entra join process, the Microsoft Entra Joined Device Local Administrator role is automatically added to the device's local administrators group and should be used instead.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Global administrator role is added as local administrator on the device during Microsoft Entra join (Preview) to No.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Global administrator role is added as local administrator on the device during Microsoft Entra join (Preview) is set to No. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/deviceRegistrationPolicy 2. Verify that azureADJoin.localAdmins.enableGlobalAdmins is False.", + "AdditionalInformation": "", + "DefaultValue": "Yes", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta:https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin" + } + ] + }, + { + "Id": "5.1.4.4", + "Description": "This setting determines if the Microsoft Entra user registering their device as Microsoft Entra join will be added to the local administrators group. This setting applies only once during the actual registration of the device as Microsoft Entra join. The recommended state is Selected or None.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting determines if the Microsoft Entra user registering their device as Microsoft Entra join will be added to the local administrators group. This setting applies only once during the actual registration of the device as Microsoft Entra join. The recommended state is Selected or None.", + "RationaleStatement": "To uphold the principle of least privilege, the assignment of local administrator rights during Microsoft Entra join should be centrally managed using appropriate built-in roles through Intune. This approach minimizes the number of disparate users with elevated privileges, reducing the attack surface and potential for misuse. Centralized management also streamlines the deprovisioning process, ensuring that administrative access can be revoked efficiently and consistently across all devices, rather than requiring manual intervention on each individual endpoint.", + "ImpactStatement": "Restricting the default behavior and requiring manual assignment to built-in roles introduces minor administrative overhead. During the Microsoft Entra join process, the Microsoft Entra Joined Device Local Administrator role is automatically added to the device's local administrators group and should be used instead.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Registering user is added as local administrator on the device during Microsoft Entra join (Preview) to Selected (and add members) or None.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Registering user is added as local administrator on the device during Microsoft Entra join (Preview) is set to Selected or None. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/deviceRegistrationPolicy 2. Verify that azureADJoin.localAdmins.registeringUsers.@odata.type is one of the following: o #microsoft.graph.enumeratedDeviceRegistrationMembership (Selected) o #microsoft.graph.noDeviceRegistrationMembership (None). Note: When set to Selected, users and groups will also appear in the output of the Graph Request.", + "AdditionalInformation": "", + "DefaultValue": "All", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta:https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin" + } + ] + }, + { + "Id": "5.1.4.5", + "Description": "Local Administrator Password Solution (LAPS) is the management of local account passwords on Windows devices. LAPS provides a solution to securely manage and retrieve the built-in local admin password. With cloud version of LAPS, customers can enable storing and rotation of local admin passwords for both Microsoft Entra and Microsoft Entra hybrid join devices The recommended state is Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Local Administrator Password Solution (LAPS) is the management of local account passwords on Windows devices. LAPS provides a solution to securely manage and retrieve the built-in local admin password. With cloud version of LAPS, customers can enable storing and rotation of local admin passwords for both Microsoft Entra and Microsoft Entra hybrid join devices The recommended state is Yes.", + "RationaleStatement": "Managing local Administrator passwords across multiple systems can be challenging. As a result, many organizations opt to configure the same password on all workstations and/or member servers during deployment. However, this practice introduces a significant security risk: if an attacker compromises one system and obtains the local Administrator password, they can potentially gain administrative access to every other system using that same password. Additionally, enabling LAPS at the tenant level is a prerequisite for implementing LAPS- related recommendations outlined in the CIS Microsoft Intune for Windows Workstation Benchmarks. Note: Enabling LAPS at the tenant level does not automatically enforce password rotation for built-in Administrator accounts. To activate LAPS functionality, appropriate policies must be configured in Intune Settings Catalog or under the Endpoint security > Account protection blade. The CIS Microsoft 365 Foundations Benchmark focuses on hardening at the tenant level, while the CIS Intune Benchmarks focus on endpoint-specific configurations.", + "ImpactStatement": "Enabling LAPS requires some additional operational overhead. Although unlikely if a password is rotated and not retrieved or backed up before the device becomes unreachable (e.g., due to hardware failure, network isolation, or being decommissioned), administrators may be locked out.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Enable Microsoft Entra Local Administrator Password Solution (LAPS) to Yes.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Enable Microsoft Entra Local Administrator Password Solution (LAPS) is set to Yes. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/policies/deviceRegistrationPolicy 2. Verify that localAdminPassword.isEnabled is True.", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/resources/deviceregistrationpolicy?view=graph-rest-beta:https://learn.microsoft.com/en-us/entra/identity/devices/howto-manage-local-admin-passwords" + } + ] + }, + { + "Id": "5.1.4.6", + "Description": "This setting determines if users can self-service recover their BitLocker key(s). 'Yes' restricts non-admin users from being able to see the BitLocker key(s) for their owned devices if there are any. 'No' allows all users to recover their BitLocker key(s). The recommended state is Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.4 Devices", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting determines if users can self-service recover their BitLocker key(s). 'Yes' restricts non-admin users from being able to see the BitLocker key(s) for their owned devices if there are any. 'No' allows all users to recover their BitLocker key(s). The recommended state is Yes.", + "RationaleStatement": "Restricting user access to the self-service BitLocker recovery key portal helps mitigate the risk of recovery key exposure in the event of a compromised user account. If an attacker gains access to both the user's credentials and the physical device, they could potentially retrieve the recovery key and decrypt sensitive data. The recovery key itself is also considered sensitive information.", + "ImpactStatement": "Restricting this setting will increase administrative overhead and may introduce friction between end users and the helpdesk, as users will no longer be able to retrieve BitLocker recovery keys through the self-service portal. This portal was originally designed to streamline recovery and reduce support burden. During the CrowdStrike Falcon Sensor outage in July 2024, many endpoints entered recovery mode, and delays in accessing recovery keys contributed to prolonged downtime. Limiting self-service access could exacerbate such delays in future incidents, especially in large or distributed environments.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Set Restrict users from recovering the BitLocker key(s) for their owned devices to Yes. To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following: $params = @{ defaultUserRolePermissions = @{ AllowedToReadBitlockerKeysForOwnedDevice = $false } } Update-MgPolicyAuthorizationPolicy -BodyParameter $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Devices and select Device settings. 3. Verify that Restrict users from recovering the BitLocker key(s) for their owned devices is set to Yes. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | fl 3. Verify that AllowedToReadBitlockerKeysForOwnedDevice is False.", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/devices/manage-device-identities#configure-device-settings:https://learn.microsoft.com/en-us/graph/api/authorizationpolicy-get?view=graph-rest-1.0:https://techcommunity.microsoft.com/blog/intunecustomersuccess/user-self-service-bitlocker-recovery-key-access-with-intune-company-portal-websi/4150458:https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/recovery-process#self-recovery" + } + ] + }, + { + "Id": "5.1.5.1", + "Description": "Control when end users and group owners are allowed to grant consent to applications, and when they will be required to request administrator review and approval. Allowing users to grant apps access to data helps them acquire useful applications and be productive but can represent a risk in some situations if it's not monitored and controlled carefully.", + "Checks": [ + "entra_policy_restricts_user_consent_for_apps" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Control when end users and group owners are allowed to grant consent to applications, and when they will be required to request administrator review and approval. Allowing users to grant apps access to data helps them acquire useful applications and be productive but can represent a risk in some situations if it's not monitored and controlled carefully.", + "RationaleStatement": "Attackers commonly use custom applications to trick users into granting them access to company data. Restricting user consent mitigates this risk and helps to reduce the threat-surface.", + "ImpactStatement": "If user consent is disabled, previous consent grants will still be honored but all future consent operations must be performed by an administrator. Tenant-wide admin consent can be requested by users through an integrated administrator consent request workflow or through organizational support processes.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions > User consent settings. 4. Under User consent for applications select Do not allow user consent. 5. Click the Save option at the top of the window.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions > User consent settings. 4. Verify that User consent for applications is set to Do not allow user consent. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: (Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions | Select-Object -ExpandProperty PermissionGrantPoliciesAssigned 3. Verify that the returned array does not contain either ManagePermissionGrantsForSelf.microsoft-user-default-low or ManagePermissionGrantsForSelf.microsoft-user-default-legacy. If either of these strings is present, the audit fails.", + "AdditionalInformation": "", + "DefaultValue": "UI - Allow user consent for apps", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent?pivots=portal:https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.signins/get-mgpolicyauthorizationpolicy?view=graph-powershell-1.0" + } + ] + }, + { + "Id": "5.1.5.2", + "Description": "The admin consent workflow gives admins a secure way to grant access to applications that require admin approval. When a user tries to access an application but is unable to provide consent, they can send a request for admin approval. The request is sent via email to admins who have been designated as reviewers. A reviewer takes action on the request, and the user is notified of the action.", + "Checks": [ + "entra_admin_consent_workflow_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The admin consent workflow gives admins a secure way to grant access to applications that require admin approval. When a user tries to access an application but is unable to provide consent, they can send a request for admin approval. The request is sent via email to admins who have been designated as reviewers. A reviewer takes action on the request, and the user is notified of the action.", + "RationaleStatement": "The admin consent workflow (Preview) gives admins a secure way to grant access to applications that require admin approval. When a user tries to access an application but is unable to provide consent, they can send a request for admin approval. The request is sent via email to admins who have been designated as reviewers. A reviewer acts on the request, and the user is notified of the action.", + "ImpactStatement": "To approve requests, a reviewer must be a global administrator, cloud application administrator, or application administrator. The reviewer must already have one of these admin roles assigned; simply designating them as a reviewer doesn't elevate their privileges.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions. 4. Under Manage select Admin consent settings. 5. Set Users can request admin consent to apps they are unable to consent to to Yes under Admin consent requests. 6. Under the Reviewers choose the Roles and Groups that will review user generated app consent requests. 7. Set Selected users will receive email notifications for requests to Yes 8. Select Save at the top of the window.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Consent and permissions. 4. Under Manage select Admin consent settings. 5. Verify that Users can request admin consent to apps they are unable to consent to is set to Yes. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: Get-MgPolicyAdminConsentRequestPolicy | fl IsEnabled,NotifyReviewers,RemindersEnabled 3. Verify that IsEnabled is True.", + "AdditionalInformation": "", + "DefaultValue": "- Users can request admin consent to apps they are unable to consent to: No - Selected users to review admin consent requests: None - Selected users will receive email notifications for requests: Yes - Selected users will receive request expiration reminders: Yes - Consent request expires after (days): 30", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow" + } + ] + }, + { + "Id": "5.1.5.3", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using either certificate credentials or password credentials (also referred to as client secrets). This setting enforces a tenant-wide restriction that prevents new password credentials from being added to any application registration or service principal. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not revoke or invalidate existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block password addition set to On.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using either certificate credentials or password credentials (also referred to as client secrets). This setting enforces a tenant-wide restriction that prevents new password credentials from being added to any application registration or service principal. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not revoke or invalidate existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block password addition set to On.", + "RationaleStatement": "Password credentials (client secrets) used for application authentication are static string values that offer weaker security guarantees than certificate or federated credentials. Unlike certificates, client secrets carry no built-in proof of possession and are frequently stored in plaintext in source code, configuration files, CI/CD pipelines, and shell history. A leaked client secret grants any holder the ability to authenticate as the application to Microsoft Entra ID, potentially accessing any resource or permission scope assigned to that application. Blocking the addition of new password credentials eliminates this attack surface for applications created going forward and forces adoption of stronger credential types such as certificates.", + "ImpactStatement": "This policy applies to new password credential additions only. Existing client secrets remain valid until they expire or are explicitly revoked; this recommendation does not retroactively invalidate credentials created before the policy was enabled. Any automated process, pipeline, or script that programmatically adds client secrets to application registrations or service principals will be blocked once the policy is enabled, unless an exception is configured. Applications that have not yet migrated to certificate- based authentication or workload identity federation will require changes before new credentials can be added.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block password addition. 5. Set Status to On. 6. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 7. Set Only apply to apps created after to a desired date or leave it unconfigured. 8. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: Important: The PATCH request replaces the passwordCredentials array in full. Retrieve the current policy first and include all existing entries in the request body to avoid overwriting other configured restrictions or exclusions. 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: - Set isEnabled to true. - Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition and set the following: o state to enabled o restrictForAppsCreatedAfterDateTime to 0001-01-01T00:00:00Z or a desired date. - Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition and set the following: o state to enabled o restrictForAppsCreatedAfterDateTime to 0001-01-01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block password addition. 5. Verify that Status is set to On. 6. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 7. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 8. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure password addition is properly blocked, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition. 2. Verify the following conditions are met for applicationRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordAddition. 2. Verify the following conditions are met for servicePrincipalRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.5.4", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). This setting enforces a tenant- wide maximum lifetime for new password credentials added to any application registration or service principal. When enabled, any client secret created must have an expiration date that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing password credentials; secrets created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict max password lifetime set to On: 180 days or less.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). This setting enforces a tenant- wide maximum lifetime for new password credentials added to any application registration or service principal. When enabled, any client secret created must have an expiration date that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing password credentials; secrets created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict max password lifetime set to On: 180 days or less.", + "RationaleStatement": "Long-lived client secrets extend the window of exploitation if a credential is compromised. A secret valid for multiple years that is never rotated remains usable even if it was leaked in source code, a build log, or a security breach long after the initial exposure. Enforcing a maximum lifetime of 180 days ensures that client secrets expire on a regular basis, limiting the period during which a stolen credential remains valid and reducing the blast radius of a compromise. This control also encourages teams to establish automated rotation practices, which further reduces reliance on static, long- lived credentials.", + "ImpactStatement": "Any automated process, pipeline, or script that creates client secrets with a lifetime exceeding the configured maximum will fail once the policy is enabled, unless an exception is configured. Organizations will need to update secret creation workflows to specify expiration dates within the allowed range and establish rotation processes for secrets approaching expiry.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max password lifetime. 5. Set Status to On. 6. Set the maximum lifetime to 180 days or less. 7. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 8. Set Only apply to apps created after to a desired date or leave it unconfigured. 9. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: Important: The PATCH request replaces the passwordCredentials array in full. Retrieve the current policy first and include all existing entries in the request body to avoid overwriting other configured restrictions or exclusions. 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: o Set isEnabled to true. o Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. o Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max password lifetime. 5. Verify that Status is set to On. 6. Verify that the configured maximum lifetime is 180 days or less. 7. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 8. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 9. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure the password lifetime is properly restricted, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime. 2. Verify the following conditions are met for applicationRestrictions.passwordCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is passwordLifetime. 2. Verify the following conditions are met for servicePrincipalRestrictions.passwordCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.5.5", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). By default, when adding a new password credential, the caller may supply a custom password value or allow the system to generate one. This setting enforces a tenant-wide restriction that blocks the use of custom password values, requiring all new password credentials to be system- generated. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not affect existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block custom passwords set to On.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using password credentials (also referred to as client secrets). By default, when adding a new password credential, the caller may supply a custom password value or allow the system to generate one. This setting enforces a tenant-wide restriction that blocks the use of custom password values, requiring all new password credentials to be system- generated. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not affect existing password credentials; credentials created before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Block custom passwords set to On.", + "RationaleStatement": "Custom password values are chosen by the caller and are susceptible to low entropy, predictable patterns, and reuse across multiple applications. A weak or reused client secret that is compromised through source code exposure, logging, or a supply-chain breach can be trivially exploited by an attacker to authenticate as the application. System-generated passwords use random values of sufficient length and complexity, making them resistant to brute-force and dictionary attacks. Blocking custom passwords removes the weakest credential creation path and ensures that all new client secrets meet a consistent entropy baseline.", + "ImpactStatement": "Any automated process, pipeline, or script that programmatically creates a client secret by supplying a custom password value will be blocked once the policy is enabled, unless an exception is configured. Most tooling, including the Microsoft Entra admin center, Azure CLI, and Azure PowerShell, already defaults to system-generated values, so the operational impact for typical workflows is minimal. Organizations that rely on custom password values in their automation will need to update those workflows to omit the custom value and accept the system-generated secret. Organizations that have policies or regulatory requirements that mandate specific password formats may need to maintain exclusions for certain applications. Exceptions should be scoped narrowly and reviewed regularly to minimize risk.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block custom passwords. 5. Set Status to On. 6. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 7. Set Only apply to apps created after to a desired date or leave it unconfigured. 8. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: Important: The PATCH request replaces the passwordCredentials array in full. Retrieve the current policy first and include all existing entries in the request body to avoid overwriting other configured restrictions or exclusions. 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: o Set isEnabled to true. o Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition and set the following: - state to enabled - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. o Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition and set the following: - state to enabled - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Block custom passwords. 5. Verify that Status is set to On. 6. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 7. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 8. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure custom password addition is properly blocked, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition. 2. Verify the following conditions are met for applicationRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.passwordCredentials, locate the entry where restrictionType is customPasswordAddition. 2. Verify the following conditions are met for servicePrincipalRestrictions.passwordCredentials: o state is enabled o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.5.6", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using certificate credentials. This setting enforces a tenant-wide maximum lifetime for new certificate credentials added to any application registration or service principal. When enabled, any certificate uploaded must have a validity period that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing certificate credentials; certificates uploaded before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict maximum certificate lifetime set to On: 180 days or less.", + "Checks": [ + "entra_default_app_management_policy_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.5 Enterprise apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In Microsoft Entra ID, applications and service principals can authenticate using certificate credentials. This setting enforces a tenant-wide maximum lifetime for new certificate credentials added to any application registration or service principal. When enabled, any certificate uploaded must have a validity period that falls within the configured maximum, which for this recommendation is 180 days or less. The policy is implemented through the default app management policy and applies to all applications unless scoped exceptions are configured. The setting does not retroactively shorten or invalidate existing certificate credentials; certificates uploaded before the policy was enabled remain valid until they expire or are explicitly removed. The recommended state is Restrict maximum certificate lifetime set to On: 180 days or less.", + "RationaleStatement": "Long-lived certificates extend the window of exploitation if a credential is compromised. A certificate valid for multiple years that is never rotated remains usable even if the private key was exposed through a server breach, misconfigured storage, or supply- chain compromise long after the initial exposure. Enforcing a maximum lifetime of 180 days ensures that certificates expire on a regular basis, limiting the period during which a stolen credential remains valid and reducing the blast radius of a compromise. This control also encourages teams to establish automated certificate rotation practices, which further reduces reliance on static, long-lived credentials.", + "ImpactStatement": "Any automated process, pipeline, or script that uploads certificates with a validity period exceeding the configured maximum will be blocked once the policy is enabled, unless an exception is configured. Organizations will need to update certificate issuance workflows to generate certificates with expiration dates within the allowed range and establish rotation processes for certificates approaching expiry.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max certificate lifetime. 5. Set Status to On. 6. Set Applies to to one of the following: o All applications o All applications with exclusions (if using exclusions, ensure they are reviewed annually). 7. Set Only apply to apps created after to a desired date or leave it unconfigured. 8. Set Maximum lifetime (in days) to 180 days or less. 9. Select Save and close to apply the changes. To remediate using the Microsoft Graph API: 1. Execute a GET request to retrieve the current policy: v1.0/policies/defaultAppManagementPolicy 2. Modify the returned JSON to reflect the following changes: o Set isEnabled to true. o Under applicationRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. o Under servicePrincipalRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime and set the following: - state to enabled - maxLifetime to P180D or a shorter duration. - restrictForAppsCreatedAfterDateTime to 0001-01- 01T00:00:00Z or a desired date. 3. Execute a PATCH request to the same URI with the modified JSON in the request body to apply the changes. Note: The References section includes a link to the API documentation with full remediation examples in multiple languages including HTTP, PowerShell, and Python.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID and select Enterprise apps. 3. Under Security select Application policies. 4. Select Restrict max certificate lifetime. 5. Verify that Status is set to On. 6. Verify that Applies to is set to one of the following: o All applications o All applications with exclusions (if using exclusions, verify they are reviewed annually). 7. Verify that Only apply to apps created after is one of the following states: o Not configured (Field will show Select a date with no date selected) o A date that is on or before the date of the assessment. 8. Verify that Maximum lifetime (in days) is 180 days or less. 9. Compliance is met when all of the above conditions are satisfied. To audit using the Microsoft Graph API: Note: Both the application restrictions and service principal restrictions must be audited to ensure the certificate lifetime is properly restricted, as they can be independently configured in Graph or PowerShell. The UI only surfaces the combined state of both settings. 1. Execute a GET request to the following relative URI to get the default app management policy: v1.0/policies/defaultAppManagementPolicy 2. Verify that isEnabled is true, this indicates that the default app management policy is enabled and being applied to the tenant. Part 1: Audit application restrictions 1. Under applicationRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime. 2. Verify the following conditions are met for applicationRestrictions.keyCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Part 2: Audit service principal restrictions 1. Under servicePrincipalRestrictions.keyCredentials, locate the entry where restrictionType is asymmetricKeyLifetime. 2. Verify the following conditions are met for servicePrincipalRestrictions.keyCredentials: o state is enabled o maxLifetime is P180D or a shorter duration (e.g., P90D, P30D, etc.) o restrictForAppsCreatedAfterDateTime is one of the following states: - 0001-01-01T00:00:00Z (not configured) - A date that is on or before the date of the assessment. 3. Compliance is met when all of the above conditions are satisfied. Note: Presently the API does not surface application exclusions, only excluded callers, so it is not necessary to audit them for compliance.", + "AdditionalInformation": "", + "DefaultValue": "Off", + "References": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-app-management-policies?tabs=portal:https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/tutorial-enforce-secret-standards?pivots=ms-graph:https://learn.microsoft.com/en-us/graph/api/resources/tenantappmanagementpolicy?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#enforce-standards-for-app-secrets-and-certificates" + } + ] + }, + { + "Id": "5.1.6.1", + "Description": "B2B collaboration is a feature within Microsoft Entra External ID that allows for guest invitations to an organization. Ensure users can only send invitations to specified domains. Note: This list works independently from OneDrive for Business and SharePoint Online allow/block lists. To restrict individual file sharing in SharePoint Online, set up an allow or blocklist for OneDrive for Business and SharePoint Online. For instance, in SharePoint or OneDrive users can still share with external users from prohibited domains by using Anyone links if they haven't been disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.6 External Identities", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "B2B collaboration is a feature within Microsoft Entra External ID that allows for guest invitations to an organization. Ensure users can only send invitations to specified domains. Note: This list works independently from OneDrive for Business and SharePoint Online allow/block lists. To restrict individual file sharing in SharePoint Online, set up an allow or blocklist for OneDrive for Business and SharePoint Online. For instance, in SharePoint or OneDrive users can still share with external users from prohibited domains by using Anyone links if they haven't been disabled.", + "RationaleStatement": "By specifying allowed domains for collaborations, external user's companies are explicitly identified. Also, this prevents internal users from inviting unknown external users such as personal accounts and granting them access to resources.", + "ImpactStatement": "This could make collaboration more difficult if the setting is not quickly updated when a new domain is identified as \"allowed\".", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Collaboration restrictions, select Allow invitations only to the specified domains (most restrictive) is selected. Then specify the allowed domains under Target domains.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Collaboration restrictions, verify that Allow invitations only to the specified domains (most restrictive) is selected. Then verify allowed domains are specified under Target domains. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: $Uri = \"https://graph.microsoft.com/beta/legacy/policies\" $Response = (Invoke-MgGraphRequest -Uri $Uri).value | Where-Object { $_.type -eq 'B2BManagementPolicy' } if ($Response) { $Definition = $Response.definition | ConvertFrom-Json $DomainsPolicy = $Definition.B2BManagementPolicy.InvitationsAllowedAndBlockedDomainsPolicy } else { Write-Output \"No policy found.\" return } $DomainsPolicy 3. Verify that the output includes an AllowedDomains property that either contains no domains or lists only organizationally approved domains. If a BlockedDomains property is present, the configuration is considered non-compliant. Example of a compliant output with AllowedDomains defined: AllowedDomains -------------- {cisecurity.org, contoso.com, example.com} Allowed with no domains allowed (also compliant): AllowedDomains -------------- {}", + "AdditionalInformation": "", + "DefaultValue": "Allow invitations to be sent to any domain (most inclusive)", + "References": "https://learn.microsoft.com/en-us/entra/external-id/allow-deny-list:https://learn.microsoft.com/en-us/entra/external-id/what-is-b2b" + } + ] + }, + { + "Id": "5.1.6.2", + "Description": "Microsoft Entra ID, part of Microsoft Entra, allows you to restrict what external guest users can see in their organization in Microsoft Entra ID. Guest users are set to a limited permission level by default in Microsoft Entra ID, while the default for member users is the full set of user permissions. These directory level permissions are enforced across Microsoft Entra services including Microsoft Graph, PowerShell v2, the Azure portal, and My Apps portal. Microsoft 365 services leveraging Microsoft 365 groups for collaboration scenarios are also affected, specifically Outlook, Microsoft Teams, and SharePoint. They do not override the SharePoint or Microsoft Teams guest settings. The recommended state is at least Guest users have limited access to properties and memberships of directory objects or more restrictive.", + "Checks": [ + "entra_policy_guest_users_access_restrictions" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.6 External Identities", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID, part of Microsoft Entra, allows you to restrict what external guest users can see in their organization in Microsoft Entra ID. Guest users are set to a limited permission level by default in Microsoft Entra ID, while the default for member users is the full set of user permissions. These directory level permissions are enforced across Microsoft Entra services including Microsoft Graph, PowerShell v2, the Azure portal, and My Apps portal. Microsoft 365 services leveraging Microsoft 365 groups for collaboration scenarios are also affected, specifically Outlook, Microsoft Teams, and SharePoint. They do not override the SharePoint or Microsoft Teams guest settings. The recommended state is at least Guest users have limited access to properties and memberships of directory objects or more restrictive.", + "RationaleStatement": "By limiting guest access to the most restrictive state this helps prevent malicious group and user object enumeration in the Microsoft 365 environment. This first step, known as reconnaissance in The Cyber Kill Chain, is often conducted by attackers prior to more advanced targeted attacks.", + "ImpactStatement": "The default is Guest users have limited access to properties and memberships of directory objects. When using the 'most restrictive' setting, guests will only be able to access their own profiles and will not be allowed to see other users' profiles, groups, or group memberships. There are some known issues with Yammer that will prevent guests that are signed in from leaving the group.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest user access set Guest user access restrictions to one of the following: o Guest users have limited access to properties and memberships of directory objects o Guest user access is restricted to properties and memberships of their own directory objects (most restrictive) To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run the following command to set the guest user access restrictions to default: # Guest users have limited access to properties and memberships of directory objects Update-MgPolicyAuthorizationPolicy -GuestUserRoleId '10dae51f-b6af-4016-8d66- 8c2a99b929b3' 3. Or, run the following command to set it to the \"most restrictive\": # Guest user access is restricted to properties and memberships of their own directory objects (most restrictive) Update-MgPolicyAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc- daa82404023b' Note: Either setting allows for a passing state.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest user access verify that Guest user access restrictions is set to one of the following: o Guest users have limited access to properties and memberships of directory objects o Guest user access is restricted to properties and memberships of their own directory objects (most restrictive) To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: Get-MgPolicyAuthorizationPolicy | fl GuestUserRoleId 3. Verify that the value returned is 10dae51f-b6af-4016-8d66-8c2a99b929b3 or 2af84b1e-32c8-42b7-82bc-daa82404023b (most restrictive) Note: Either setting allows for a passing state. Note 2: The value of a0b1b346-4d3e-4e8b-98f8-753987be4970 is equal to Guest users have the same access as members (most inclusive) and should not be used.", + "AdditionalInformation": "", + "DefaultValue": "- UI: Guest users have limited access to properties and memberships of directory objects - PowerShell: 10dae51f-b6af-4016-8d66-8c2a99b929b3", + "References": "https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions:https://www.lockheedmartin.com/en-us/capabilities/cyber/cyber-kill-chain.html" + } + ] + }, + { + "Id": "5.1.6.3", + "Description": "By default, all users in the organization, including B2B collaboration guest users, can invite external users to B2B collaboration. The ability to send invitations can be limited by turning it on or off for everyone, or by restricting invitations to certain roles. The recommended state is Only users assigned to specific admin roles can invite guest users or No one in the organization can invite guest users including admins (most restrictive).", + "Checks": [ + "entra_policy_guest_invite_only_for_admin_roles" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.6 External Identities", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "By default, all users in the organization, including B2B collaboration guest users, can invite external users to B2B collaboration. The ability to send invitations can be limited by turning it on or off for everyone, or by restricting invitations to certain roles. The recommended state is Only users assigned to specific admin roles can invite guest users or No one in the organization can invite guest users including admins (most restrictive).", + "RationaleStatement": "Restricting who can invite guests limits the exposure the organization might face from unauthorized accounts. The default behavior allows anyone within the organization to invite guests and non-admins to the tenant, posing a security risk.", + "ImpactStatement": "This introduces an obstacle to collaboration by restricting who can invite guest users to the organization. Designated Guest Inviters must be assigned, and an approval process established and clearly communicated to all users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest invite settings set Guest invite restrictions to one of the desired compliant states: o Only users assigned to specific admin roles can invite guest users o No one in the organization can invite guest users including admins (most restrictive) To remediate using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\" 2. Run one of the following PowerShell commands depending on the desired compliant state: To set to Only users assigned to specific admin roles can invite guest users: Update-MgPolicyAuthorizationPolicy -AllowInvitesFrom 'adminsAndGuestInviters' To set to No one in the organization can invite guest users including admins (most restrictive): Update-MgPolicyAuthorizationPolicy -AllowInvitesFrom \"none\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > External Identities and select External collaboration settings. 3. Under Guest invite settings verify that Guest invite restrictions is set to one of the following: o Only users assigned to specific admin roles can invite guest users o No one in the organization can invite guest users including admins (most restrictive) To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following command: Get-MgPolicyAuthorizationPolicy | fl AllowInvitesFrom 3. Verify the value returned is adminsAndGuestInviters or none.", + "AdditionalInformation": "", + "DefaultValue": "- UI: Anyone in the organization can invite guest users including guests and non-admins (most inclusive) - PowerShell: everyone", + "References": "https://learn.microsoft.com/en-us/entra/external-id/external-collaboration-settings-configure:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#guest-inviter" + } + ] + }, + { + "Id": "5.1.8.1", + "Description": "Password Hash Synchronization is one of the sign-in methods used to enable hybrid identity authentication. With this method, Microsoft Entra Connect synchronizes a cryptographically derived representation of a user's on-premises Active Directory password to Microsoft Entra ID. The original NT password hash (MD4) is never transmitted to Entra ID. Instead, Entra Connect computes a SHA-256 hash of the original MD4 hash and synchronizes that value. Because only this secondary hash is stored in the cloud, the credential material in Entra ID cannot be reused for on-premises pass-the-hash attacks, even if compromised. Note: The audit and remediation procedures described in this recommendation are applicable only to Microsoft 365 tenants operating in a hybrid identity configuration using Microsoft Entra Connect. They do not apply to federated or cloud-only deployments.", + "Checks": [ + "entra_password_hash_sync_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.1.8 Hybrid management", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Password Hash Synchronization is one of the sign-in methods used to enable hybrid identity authentication. With this method, Microsoft Entra Connect synchronizes a cryptographically derived representation of a user's on-premises Active Directory password to Microsoft Entra ID. The original NT password hash (MD4) is never transmitted to Entra ID. Instead, Entra Connect computes a SHA-256 hash of the original MD4 hash and synchronizes that value. Because only this secondary hash is stored in the cloud, the credential material in Entra ID cannot be reused for on-premises pass-the-hash attacks, even if compromised. Note: The audit and remediation procedures described in this recommendation are applicable only to Microsoft 365 tenants operating in a hybrid identity configuration using Microsoft Entra Connect. They do not apply to federated or cloud-only deployments.", + "RationaleStatement": "Password hash synchronization helps by reducing the number of passwords your users need to maintain to just one and enables leaked credential detection for your hybrid accounts. Leaked credential protection is leveraged through Entra ID Protection and is a subset of that feature which can help identify if an organization's user account passwords have appeared on the dark web or public spaces. Using other options for your directory synchronization may be less resilient as Microsoft can still process sign-ins to 365 with Hash Sync even if a network connection to your on-premises environment is not available. This minimizes downtime and ensures business continuity.", + "ImpactStatement": "Compliance or regulatory restrictions may exist, depending on the organization's business sector, that preclude hashed versions of passwords from being securely transmitted to cloud data centers.", + "RemediationProcedure": "To remediate using the on-prem Microsoft Entra Connect tool: 1. Log in to the on premises server that hosts the Microsoft Entra Connect tool 2. Double-click the Azure AD Connect icon that was created on the desktop 3. Click Configure. 4. On the Additional tasks page, select Customize synchronization options and click Next. 5. Enter the username and password for your global administrator. 6. On the Connect your directories screen, click Next. 7. On the Domain and OU filtering screen, click Next. 8. On the Optional features screen, check Password hash synchronization and click Next. 9. On the Ready to configure screen click Configure. 10. Once the configuration completes, click Exit.", + "AuditProcedure": "To audit using the UI: Only Global Admin and Hybrid Identity Administrator roles have access to view the actual Password Hash Sync status message. Inadequate role access will result in a default message stating: \"Unable to retrieve your tenant's password hash sync information.\" 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Entra Connect. 3. Select Connect Sync. 4. Under Microsoft Entra Connect sync, verify the Password Hash Sync status message indicates that synchronization is occurring and no errors are present, with one of the following messages: o Password hash synchronization is enabled o Password hash synchronization cloud configuration is enabled o Password hash synchronization heartbeat detected To audit using the Microsoft Graph API: Permission required: OnPremDirectorySynchronization.Read.All 1. Execute a GET request to the following relative URI: v1.0/directory/onPremisesSynchronization 2. Verify that features.passwordSyncEnabled is true. To audit for the on-prem tool: 1. Log in to the server that hosts the Microsoft Entra Connect tool. 2. Run Azure AD Connect, and then click Configure and View or export current configuration. 3. Verify that PASSWORD HASH SYNCHRONIZATION is enabled on your tenant. To audit using PowerShell: 1. Open PowerShell on the on-premises server running Microsoft Entra Connect. 2. Run the following cmdlet: Get-ADSyncAADCompanyFeature 3. Verify that PasswordHashSync is True.", + "AdditionalInformation": "", + "DefaultValue": "- Microsoft Entra Connect sync disabled by default - Password Hash Sync is Microsoft's recommended setting for new deployments", + "References": "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/whatis-phs:https://www.microsoft.com/en-us/download/details.aspx?id=47594:https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-sync-staging-server:https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-password-hash-synchronization:https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronizationfeature?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.2.1", + "Description": "Multifactor authentication is a process that requires an additional form of identification during the sign-in process, such as a code from a mobile device or a fingerprint scan, to enhance security. Ensure users in administrator roles have MFA capabilities enabled.", + "Checks": [ + "entra_admin_users_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Multifactor authentication is a process that requires an additional form of identification during the sign-in process, such as a code from a mobile device or a fingerprint scan, to enhance security. Ensure users in administrator roles have MFA capabilities enabled.", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multifactor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multifactor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk. Note: To ensure that accounts cannot be easily used to enumerate resources (reconnaissance) through Microsoft Admin Portals or through Microsoft Azure Service Management API, both MFA conditional access policies must target All Resources: - \"Ensure multifactor authentication is enabled for all users\" and - \"Ensure multifactor authentication is enabled for all users in administrative roles\" (this recommendation)", + "ImpactStatement": "Implementation of multifactor authentication for all users in administrative roles will necessitate a change to user routine. All users in administrative roles will be required to enroll in multifactor authentication using phone, SMS, or an authentication application. After enrollment, use of multifactor authentication will be required for future access to the environment.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include Select users and groups and check Directory roles. o At a minimum, include the directory roles listed below in this section of the document. o Under Target resources include All resources (formerly 'All cloud apps') and do not create any exclusions. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant Access and check either Require multifactor authentication or Require authentication strength. o Click Select at the bottom of the pane. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. At minimum these directory roles should be included for MFA: - Application administrator - Authentication administrator - Billing administrator - Cloud application administrator - Conditional Access administrator - Exchange administrator - Global administrator - Global reader - Helpdesk administrator - Password administrator - Privileged authentication administrator - Privileged role administrator - Security administrator - SharePoint administrator - User administrator", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify Directory roles specific to administrators are included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Grant verify Grant Access is on and either Require multifactor authentication or Require authentication strength is checked. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. For each policy returned, verify the following criteria: o Conditions.Users.IncludeRoles contains the directory roles specific to administrators* o Conditions.Applications.IncludeApplications is All o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is defined o State is enabled 3. Compliance is met when at least one policy is found to meet all the criteria listed above. 4. Verify that any exclusions are documented and reviewed annually. Note: The Authentication Strength requirement is satisfied when any valid GUID is present in the authenticationStrength property of a matching policy. Because Authentication Strength configurations are inherently stronger than the built-in Require multifactor authentication control, the presence of a valid Authentication Strength also fulfills the MFA requirement for all users. Note: A list of Directory roles can be found in the Remediation section.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa:https://learn.microsoft.com/en-us/graph/api/conditionalaccessroot-list-policies?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.2.2", + "Description": "Enable multifactor authentication for all users in the Microsoft 365 tenant. Users will be prompted to authenticate with a second factor upon logging in to Microsoft 365 services. The second factor is most commonly a text message to a registered mobile phone number where they type in an authorization code, or with a mobile application like Microsoft Authenticator. Note: Since 2024, Microsoft has been rolling out mandatory multifactor authentication.", + "Checks": [ + "entra_users_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Enable multifactor authentication for all users in the Microsoft 365 tenant. Users will be prompted to authenticate with a second factor upon logging in to Microsoft 365 services. The second factor is most commonly a text message to a registered mobile phone number where they type in an authorization code, or with a mobile application like Microsoft Authenticator. Note: Since 2024, Microsoft has been rolling out mandatory multifactor authentication.", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multifactor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multifactor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk. Note: To ensure that accounts cannot be easily used to enumerate resources (reconnaissance) through Microsoft Admin Portals or through Microsoft Azure Service Management API, both MFA conditional access policies must target All Resources: - \"Ensure multifactor authentication is enabled for all users in administrative roles\" and - \"Ensure multifactor authentication is enabled for all users\" (this recommendation)", + "ImpactStatement": "Implementation of multifactor authentication for all users will necessitate a change to user routine. All users will be required to enroll in multifactor authentication using phone, SMS, or an authentication application. After enrollment, use of multifactor authentication will be required for future authentication to the environment. External identities that attempt to access documents that utilize Purview Information Protection (Sensitivity Labels) will find their access disrupted. In order to mitigate this create an exclusion for Microsoft Rights Management Services ID: 00000012- 0000-0000-c000-000000000000 Note: Organizations that struggle to enforce MFA globally due to budget constraints preventing the provision of company-owned mobile devices to every user, or due to regulations, unions, or policies that prevent forcing end users to use their personal devices, have another option. FIDO2 security keys can be used as an alternative. They are more secure, phishing-resistant, and affordable for organizations to issue to every end user.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps') and do not create any exclusions. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant Access and check either Require multifactor authentication or Require authentication strength. o Click Select at the bottom of the pane. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Grant verify Grant Access and either Require multifactor authentication or Require authentication strength is checked. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is defined o State is enabled 3. Compliance is met when at least one policy is found to meet all the criteria listed above. 4. Verify that any exclusions are documented and reviewed annually. Note: The Authentication Strength requirement is satisfied when any valid GUID is present in the authenticationStrength property of a matching policy. Because Authentication Strength configurations are inherently stronger than the built-in Require multifactor authentication control, the presence of a valid Authentication Strength also fulfills the MFA requirement for all users.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be present as a Microsoft-managed policy or created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa:https://learn.microsoft.com/en-us/graph/api/conditionalaccessroot-list-policies?view=graph-rest-1.0:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mandatory-multifactor-authentication" + } + ] + }, + { + "Id": "5.2.2.3", + "Description": "Entra ID supports the most widely used authentication and authorization protocols including legacy authentication. This authentication pattern includes basic authentication, a widely used industry-standard method for collecting username and password information. The following messaging protocols support legacy authentication: - Authenticated SMTP - Used to send authenticated email messages. - Autodiscover - Used by Outlook and EAS clients to find and connect to mailboxes in Exchange Online. - Exchange ActiveSync (EAS) - Used to connect to mailboxes in Exchange Online. - Exchange Online PowerShell - Used to connect to Exchange Online with remote PowerShell. If you block Basic authentication for Exchange Online PowerShell, you need to use the Exchange Online PowerShell Module to connect. For instructions, see Connect to Exchange Online PowerShell using multifactor authentication. - Exchange Web Services (EWS) - A programming interface that's used by Outlook, Outlook for Mac, and third-party apps. - IMAP4 - Used by IMAP email clients. - MAPI over HTTP (MAPI/HTTP) - Primary mailbox access protocol used by Outlook 2010 SP2 and later. - Offline Address Book (OAB) - A copy of address list collections that are downloaded and used by Outlook. - Outlook Anywhere (RPC over HTTP) - Legacy mailbox access protocol supported by all current Outlook versions. - POP3 - Used by POP email clients. - Reporting Web Services - Used to retrieve report data in Exchange Online. - Universal Outlook - Used by the Mail and Calendar app for Windows 10. - Other clients - Other protocols identified as utilizing legacy authentication.", + "Checks": [ + "entra_legacy_authentication_blocked" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Entra ID supports the most widely used authentication and authorization protocols including legacy authentication. This authentication pattern includes basic authentication, a widely used industry-standard method for collecting username and password information. The following messaging protocols support legacy authentication: - Authenticated SMTP - Used to send authenticated email messages. - Autodiscover - Used by Outlook and EAS clients to find and connect to mailboxes in Exchange Online. - Exchange ActiveSync (EAS) - Used to connect to mailboxes in Exchange Online. - Exchange Online PowerShell - Used to connect to Exchange Online with remote PowerShell. If you block Basic authentication for Exchange Online PowerShell, you need to use the Exchange Online PowerShell Module to connect. For instructions, see Connect to Exchange Online PowerShell using multifactor authentication. - Exchange Web Services (EWS) - A programming interface that's used by Outlook, Outlook for Mac, and third-party apps. - IMAP4 - Used by IMAP email clients. - MAPI over HTTP (MAPI/HTTP) - Primary mailbox access protocol used by Outlook 2010 SP2 and later. - Offline Address Book (OAB) - A copy of address list collections that are downloaded and used by Outlook. - Outlook Anywhere (RPC over HTTP) - Legacy mailbox access protocol supported by all current Outlook versions. - POP3 - Used by POP email clients. - Reporting Web Services - Used to retrieve report data in Exchange Online. - Universal Outlook - Used by the Mail and Calendar app for Windows 10. - Other clients - Other protocols identified as utilizing legacy authentication.", + "RationaleStatement": "Legacy authentication protocols do not support multi-factor authentication. These protocols are often used by attackers because of this deficiency. Blocking legacy authentication makes it harder for attackers to gain access. Note: Basic authentication is now disabled in all tenants. Before December 31 2022, you could re-enable the affected protocols if users and apps in your tenant couldn't connect. Now no one (you or Microsoft support) can re-enable Basic authentication in your tenant.", + "ImpactStatement": "Enabling this setting will block legacy authentication, preventing access from older versions of Microsoft Office, Exchange ActiveSync, and protocols such as IMAP, POP, and SMTP. As a result, some users may need to upgrade to newer Office versions or use email clients that support modern authentication. This change may also affect multifunction devices (MFPs), such as printers using legacy authentication for scan-to-email. Microsoft provides mail flow best practices (linked below) to configure MFPs without relying on legacy authentication. https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a- multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions select Client apps and check the boxes for Exchange ActiveSync clients and Other clients. o Under Grant select Block Access. o Click Select. 4. Set the policy On and click Create.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Verify that only documented resource exclusions exist and that they are reviewed annually. o Under Conditions select Client apps then verify Exchange ActiveSync clients and Other clients is checked. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.ClientAppTypes contains exchangeActiveSync OR other. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.ClientAppTypes contains exchangeActiveSync o Conditions.ClientAppTypes contains other o GrantControls.BuiltInControls is block o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "Basic authentication is disabled by default as of January 2023.", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/disable-basic-authentication-in-exchange-online:https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365:https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online" + } + ] + }, + { + "Id": "5.2.2.4", + "Description": "In complex deployments, organizations might have a need to restrict authentication sessions. Conditional Access policies allow for the targeting of specific user accounts. Some scenarios might include: - Resource access from an unmanaged or shared device - Access to sensitive information from an external network - High-privileged users - Business-critical applications Note: This CA policy can be added to the previous CA policy in this benchmark \"Ensure multifactor authentication is enabled for all users in administrative roles\"", + "Checks": [ + "entra_admin_users_sign_in_frequency_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "In complex deployments, organizations might have a need to restrict authentication sessions. Conditional Access policies allow for the targeting of specific user accounts. Some scenarios might include: - Resource access from an unmanaged or shared device - Access to sensitive information from an external network - High-privileged users - Business-critical applications Note: This CA policy can be added to the previous CA policy in this benchmark \"Ensure multifactor authentication is enabled for all users in administrative roles\"", + "RationaleStatement": "Forcing a time out for MFA will help ensure that sessions are not kept alive for an indefinite period of time, ensuring that browser sessions are not persistent will help in prevention of drive-by attacks in web browsers, this also prevents creation and saving of session cookies leaving nothing for an attacker to take.", + "ImpactStatement": "Users with Administrative roles will be prompted at the frequency set for MFA.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include Select users and groups and check Directory roles. o At a minimum, include the directory roles listed below in this section of the document. o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Grant select Grant Access and check Require multifactor authentication. o Under Session select Sign-in frequency select Periodic reauthentication and set it to 4 hours (or less). o Check Persistent browser session then select Never persistent in the drop-down menu. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. At minimum these directory roles should be included in the policy: - Application administrator - Authentication administrator - Billing administrator - Cloud application administrator - Conditional Access administrator - Exchange administrator - Global administrator - Global reader - Helpdesk administrator - Password administrator - Privileged authentication administrator - Privileged role administrator - Security administrator - SharePoint administrator - User administrator", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify Directory roles specific to administrators are included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Verify that only documented resource exclusions exist and that they are reviewed annually. o Under Session verify Sign-in frequency is checked and set to Periodic reauthentication. o Verify the timeframe is set to the time determined by the organization. o Verify that Periodic reauthentication does not exceed 4 hours (or less). o Verify that Persistent browser session is set to Never persistent. 4. Verify that Enable policy is set to On To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where SessionControls.PersistentBrowser.IsEnabled is true. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeRoles contains the directory roles specific to administrators* o Conditions.Applications.IncludeApplications is All o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime OR is timeBased AND does not exceed 4 hours. o SessionControls.PersistentBrowser.IsEnabled is true o SessionControls.PersistentBrowser.Mode is never o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: A list of directory roles applying to Administrators can be found in the remediation section.", + "AdditionalInformation": "", + "DefaultValue": "The default configuration for user sign-in frequency is a rolling window of 90 days.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime" + } + ] + }, + { + "Id": "5.2.2.5", + "Description": "Authentication strength is a Conditional Access control that allows administrators to specify which combination of authentication methods can be used to access a resource. For example, they can make only phishing-resistant authentication methods available to access a sensitive resource. But to access a non-sensitive resource, they can allow less secure multifactor authentication (MFA) combinations, such as password + SMS. Microsoft has 3 built-in authentication strengths. MFA strength, Passwordless MFA strength, and Phishing-resistant MFA strength. Ensure administrator roles are using a CA policy with Phishing-resistant MFA strength. Administrators can then enroll using one of 3 methods: - FIDO2 Security Key - Windows Hello for Business - Certificate-based authentication (Multi-Factor) Note: Additional steps to configure methods such as FIDO2 keys are not covered here but can be found in related MS articles in the references section. The Conditional Access policy only ensures 1 of the 3 methods is used. Warning: Administrators should be pre-registered for a strong authentication mechanism before this Conditional Access Policy is enforced. Additionally, as stated elsewhere in the CIS Benchmark a break-glass administrator account should be excluded from this policy to ensure unfettered access in the case of an emergency.", + "Checks": [ + "entra_admin_users_phishing_resistant_mfa_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Authentication strength is a Conditional Access control that allows administrators to specify which combination of authentication methods can be used to access a resource. For example, they can make only phishing-resistant authentication methods available to access a sensitive resource. But to access a non-sensitive resource, they can allow less secure multifactor authentication (MFA) combinations, such as password + SMS. Microsoft has 3 built-in authentication strengths. MFA strength, Passwordless MFA strength, and Phishing-resistant MFA strength. Ensure administrator roles are using a CA policy with Phishing-resistant MFA strength. Administrators can then enroll using one of 3 methods: - FIDO2 Security Key - Windows Hello for Business - Certificate-based authentication (Multi-Factor) Note: Additional steps to configure methods such as FIDO2 keys are not covered here but can be found in related MS articles in the references section. The Conditional Access policy only ensures 1 of the 3 methods is used. Warning: Administrators should be pre-registered for a strong authentication mechanism before this Conditional Access Policy is enforced. Additionally, as stated elsewhere in the CIS Benchmark a break-glass administrator account should be excluded from this policy to ensure unfettered access in the case of an emergency.", + "RationaleStatement": "Sophisticated attacks targeting MFA are more prevalent as the use of it becomes more widespread. These 3 methods are considered phishing-resistant as they remove passwords from the login workflow. It also ensures that public/private key exchange can only happen between the devices and a registered provider which prevents login to fake or phishing websites.", + "ImpactStatement": "If administrators aren't pre-registered for a strong authentication method prior to a conditional access policy being created, then a condition could occur where a user can't register for strong authentication because they don't meet the conditional access policy requirements and therefore are prevented from signing in. Additionally, Internet Explorer based credential prompts in PowerShell do not support prompting for a security key. Implementing phishing-resistant MFA with a security key may prevent admins from running their existing sets of PowerShell scripts. Device Authorization Grant Flow can be used as a workaround in some instances.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Click New policy. o Under Users or agents (Preview) include Select users and groups and check Directory roles. o At a minimum, include the directory roles listed below in this section of the document. o Under Target resources include All resources (formerly 'All cloud apps') and do not create any exclusions other than break-glass accounts. o Under Grant select Grant Access and check Require authentication strength and set Phishing-resistant MFA in the dropdown box. o Click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. Warning: Ensure administrators are pre-registered with strong authentication before enforcing the policy. After which the policy must be set to On. At minimum these directory roles should be included for the policy: - Application administrator - Authentication administrator - Billing administrator - Cloud application administrator - Conditional Access administrator - Exchange administrator - Global administrator - Global reader - Helpdesk administrator - Password administrator - Privileged authentication administrator - Privileged role administrator - Security administrator - SharePoint administrator - User administrator", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify Directory roles specific to administrators are included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Directory Roles should include at minimum the roles listed in the remediation section. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Grant verify Grant Access is selected and Require authentication strength is checked with Phishing-resistant MFA set as the value. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where GrantControls.AuthenticationStrength.Id contains any valid id. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeRoles contains the directory roles specific to administrators* o Conditions.Applications.IncludeApplications is All o GrantControls.AuthenticationStrength.AllowedCombinations only contains windowsHelloForBusiness OR fido2 OR x509CertificateMultiFactor o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It can be created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-passwordless#fido2-security-keys:https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-enable-passkey-fido2:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-strengths:https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-mfa-policy" + } + ] + }, + { + "Id": "5.2.2.6", + "Description": "Microsoft Entra ID Protection user risk policies detect the probability that a user account has been compromised. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "Checks": [ + "entra_identity_protection_user_risk_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Protection user risk policies detect the probability that a user account has been compromised. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "RationaleStatement": "With the user risk policy turned on, Entra ID protection detects the probability that a user account has been compromised. Administrators can configure a user risk conditional access policy to automatically respond to a specific user risk level.", + "ImpactStatement": "Upon policy activation, account access will be either blocked or the user will be required to use multi-factor authentication (MFA) and change their password. Users without registered MFA will be denied access, necessitating an admin to recover the account. To avoid inconvenience, it is advised to configure the MFA registration policy for all users under the User Risk policy. Additionally, users identified in the Risky Users section will be affected by this policy. To gain a better understanding of the impact on the organization's environment, the list of Risky Users should be reviewed before enforcing the policy.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) choose All users o Under Target resources choose All resources (formerly 'All cloud apps') - Under Exclude exclude any break-glass accounts. o Under Conditions choose User risk then Yes and select the user risk level High. o Under Grant select Grant access then check Require multifactor authentication or Require authentication strength. Finally check Require password change. o Under Session set Sign-in frequency to Every time. o Click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Under Conditions verify User risk is set to High. o Under Grant verify Grant access is selected and either Require multifactor authentication or Require authentication strength are checked. Then verify Require password change is checked. o Under Session ensure Sign-in frequency is set to Every time. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.UserRiskLevels contains any string. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.UserRiskLevels contains high o GrantControls.BuiltInControls contains passwordChange o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is any id o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: If the UserRiskLevels array includes medium or low, this still qualifies as compliant, as these enforcement levels are considered more strict. However, it must always include high; omitting this level would exclude users who are classified as high risk.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-risk-feedback:https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks" + } + ] + }, + { + "Id": "5.2.2.7", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "Checks": [ + "entra_identity_protection_sign_in_risk_enabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "RationaleStatement": "Turning on the sign-in risk policy ensures that suspicious sign-ins are challenged for multi-factor authentication.", + "ImpactStatement": "When the policy triggers, the user will need MFA to access the account. In the case of a user who hasn't registered MFA on their account, they would be blocked from accessing their account. It is therefore recommended that the MFA registration policy be configured for all users who are a part of the Sign-in Risk policy.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) choose All users. o Under Target resources choose All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions choose Sign-in risk then Yes and check the risk level boxes High and Medium. o Under Grant click Grant access then select Require multifactor authentication. o Under Session select Sign-in Frequency and set to Every time. o Click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Under Conditions verify Sign-in risk is set to Yes ensuring High and Medium are selected. o Under Grant verify grant Grant access is selected and Require multifactor authentication checked. o Under Session verify Sign-in Frequency is set to Every time. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.SignInRiskLevels contains any string. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.SignInRiskLevels contains high o Conditions.SignInRiskLevels contains medium o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is any id o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: If the SignInRiskLevels array includes low, this still qualifies as compliant, as these enforcement levels are considered more strict. However, it must always include high and medium; omitting these levels would exclude users who are classified as such. Note 2: If GrantControls.BuiltInControls is block then the Grant and Session controls are considered satisfied, as this is considered a more strict enforcement of sign-in risk control.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be created from a Conditional Access template.", + "References": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-risk-feedback:https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks" + } + ] + }, + { + "Id": "5.2.2.8", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", + "RationaleStatement": "Sign-in risk is determined at the time of sign-in and includes criteria across both real- time and offline detections for risk. Blocking sign-in to accounts that have risk can prevent undesired access from potentially compromised devices or unauthorized users.", + "ImpactStatement": "Sign-in risk is heavily dependent on detecting risk based on atypical behaviors. Due to this it is important to run this policy in a report-only mode to better understand how the organization's environment and user activity may influence sign-in risk before turning the policy on. Once it's understood what actions may trigger a medium or high sign-in risk event I.T. can then work to create an environment to reduce false positives. For example, employees might be required to notify security personnel when they intend to travel with intent to access work resources.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps') and do not set any exclusions. - Under Exclude exclude any break-glass accounts. o Under Conditions choose Sign-in risk values of High and Medium and click Done. o Under Grant choose Block access and click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected with no exclusions. o Under Conditions verify Sign-in risk values of High and Medium are selected. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.SignInRiskLevels contains any string. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.Applications.ExcludeApplications is null or empty o Conditions.SignInRiskLevels contains high o Conditions.SignInRiskLevels contains medium o GrantControls.BuiltInControls is block o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually. Note: If the SignInRiskLevels array includes low, this still qualifies as compliant, as these enforcement levels are considered more strict. However, it must always include high and medium; omitting these levels would exclude users who are classified as such.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks#risk-detections-mapped-to-riskeventtype" + } + ] + }, + { + "Id": "5.2.2.9", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively this allows CA to classify devices as managed or unmanaged, providing more granular control over authentication policies. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify for authentication. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for authentication is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "Checks": [ + "entra_managed_device_required_for_authentication" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively this allows CA to classify devices as managed or unmanaged, providing more granular control over authentication policies. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify for authentication. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for authentication is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "RationaleStatement": "\"Managed\" devices are considered more secure because they often have additional configuration hardening enforced through centralized management such as Intune or Group Policy. These devices are also typically equipped with MDR/EDR, managed patching and alerting systems. As a result, they provide a safer environment for users to authenticate and operate from. This policy also ensures that attackers must first gain access to a compliant or trusted device before authentication is permitted, reducing the risk posed by compromised account credentials. When combined with other distinct Conditional Access (CA) policies, such as requiring multi-factor authentication, this adds one additional factor before authentication is permitted. Note: Avoid combining these two settings with other Grant settings in the same policy. In a single policy you can only choose between Require all the selected controls or Require one of the selected controls, which limits the ability to integrate this recommendation with others in this benchmark. CA policies function as an \"AND\" operator across multiple policies. The goal here is to both (Require MFA for all users) AND (Require device to be marked as compliant OR Require Microsoft Entra hybrid joined device).", + "ImpactStatement": "Unmanaged devices will not be permitted as a valid authenticator. As a result this may require the organization to mature their device enrollment and management. The following devices can be considered managed: - Entra hybrid joined from Active Directory - Entra joined and enrolled in Intune, with compliance policies - Entra registered and enrolled in Intune, with compliance policies If Guest or external users are collaborating with the organization, they must either be excluded or onboarded with a compliant device to authenticate. Failure to adequately survey the environment and test the Conditional Access (CA) policy in the Report-only state could result in access disruptions for these guest users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Grant select Grant access. o Select the checkbox Require device to be marked as compliant. o Optionally, also select Require Microsoft Entra hybrid joined device if the organization uses hybrid joined devices. o Choose Require one of the selected controls. o Click Select at the bottom. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On. Note: Guest user accounts, if collaborating with the organization, should be considered when testing this policy.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify All resources (formerly 'All cloud apps') is selected. o Verify that only documented resource exclusions exist and that they are reviewed annually. o Under Grant verify that Require device to be marked as compliant is checked. o Under Grant verify that no other controls are checked except, optionally, Require Microsoft Entra hybrid joined device. o Verify Require one of the selected controls is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where GrantControls.BuiltInControls contains compliantDevice or domainJoinedDevice. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o GrantControls.BuiltInControls contains compliantDevice o GrantControls.BuiltInControls does not contain any values other than compliantDevice and domainJoinedDevice o GrantControls.Operator is OR o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant#require-device-to-be-marked-as-compliant:https://learn.microsoft.com/en-us/entra/identity/devices/concept-hybrid-join:https://learn.microsoft.com/en-us/mem/intune/fundamentals/deployment-guide-enrollment" + } + ] + }, + { + "Id": "5.2.2.10", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively, this allows CA to classify devices as managed or unmanaged, providing more granular control over whether a user can register security information from a device. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for registering security information is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "Checks": [ + "entra_managed_device_required_for_mfa_registration" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Conditional Access (CA) can be configured to enforce access based on the device's compliance status or whether it is Entra hybrid joined. Collectively, this allows CA to classify devices as managed or unmanaged, providing more granular control over whether a user can register security information from a device. When using Require device to be marked as compliant, the device must pass checks configured in compliance policies defined within Microsoft Intune. Before these checks can be applied, the device must first be enrolled in Intune MDM. By selecting Require Microsoft Entra hybrid joined device this means the device must first be synchronized from an on-premises Active Directory to qualify. When configured to the recommended state below only one condition needs to be met for the user to authenticate from the device. This functions as an \"OR\" operator. The recommended state for registering security information is: - Require device to be marked as compliant - Require Microsoft Entra hybrid joined device (optional for hybrid environments) - Require one of the selected controls", + "RationaleStatement": "Requiring registration on a managed device significantly reduces the risk of bad actors using stolen credentials to register security information. Accounts that are created but never registered with an MFA method are particularly vulnerable to this type of attack. Enforcing this requirement will both reduce the attack surface for fake registrations and ensure that legitimate users register using trusted devices which typically have additional security measures in place already.", + "ImpactStatement": "The organization will be required to have a mature device management process. New devices provided to users will need to be pre-enrolled in Intune, auto-enrolled, or be Entra hybrid joined. Otherwise, the user will be unable to complete registration, requiring additional resources from I.T. This could be more disruptive in remote worker environments where the MDM maturity is low. Users who do not yet have access to a managed device, such as new hires, users with lost or replaced devices, or users registering MFA methods on personal mobile devices - will be unable to satisfy the device compliance grant control. To address this, organizations should configure a Temporary Access Pass (TAP) policy and issue a one- time TAP to these users. A one-time TAP satisfies multifactor authentication requirements and allows the user to register security information from any device or location. B2B collaboration users (guest accounts) will also be blocked by this policy, as their devices are not managed in the resource tenant. Organizations should consider excluding All guest and external users from this policy. Alternatively, organizations that trust partner device compliance claims can configure this through cross-tenant access settings.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources select User actions and check Register security information. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant access. o Select the checkbox Require device to be marked as compliant. o Optionally, also select Require Microsoft Entra hybrid joined device if the organization uses hybrid joined devices. o Choose Require one of the selected controls. o Click Select at the bottom. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify User actions is selected with Register security information checked. o Under Grant verify that Require device to be marked as compliant is checked. o Under Grant verify that no other controls are checked except, optionally, Require Microsoft Entra hybrid joined device. o Verify Require one of the selected controls is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.Applications.IncludeUserActions contains urn:user:registersecurityinfo. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeUserActions is urn:user:registersecurityinfo o GrantControls.BuiltInControls contains compliantDevice o GrantControls.BuiltInControls does not contain any values other than compliantDevice and domainJoinedDevice o GrantControls.Operator is OR o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant#require-device-to-be-marked-as-compliant:https://learn.microsoft.com/en-us/entra/identity/devices/concept-hybrid-join:https://learn.microsoft.com/en-us/mem/intune/fundamentals/deployment-guide-enrollment:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps#user-actions:https://docs.azure.cn/en-us/entra/identity/authentication/concept-authentication-strength-how-it-works#how-multiple-authentication-strength-policies-are-evaluated-for-registering-security-info" + } + ] + }, + { + "Id": "5.2.2.11", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state is a Sign-in frequency of Every time for Microsoft Intune Enrollment Note: Microsoft accounts for a five-minute clock skew when 'every time' is selected in a conditional access policy, ensuring that users are not prompted more frequently than once every five minutes.", + "Checks": [ + "entra_intune_enrollment_sign_in_frequency_every_time" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state is a Sign-in frequency of Every time for Microsoft Intune Enrollment Note: Microsoft accounts for a five-minute clock skew when 'every time' is selected in a conditional access policy, ensuring that users are not prompted more frequently than once every five minutes.", + "RationaleStatement": "Intune Enrollment is considered a sensitive action and should be safeguarded. An attack path exists that allows for a bypass of device compliance Conditional Access rule. This could allow compromised credentials to be used through a newly registered device enrolled in Intune, enabling persistence and privilege escalation. Setting sign-in frequency to every time limits the timespan an attacker could use fresh credentials to enroll a new device to Intune.", + "ImpactStatement": "New users enrolling into Intune through an automated process may need to sign-in again if the enrollment process goes on for too long.", + "RemediationProcedure": "Note: If the Microsoft Intune Enrollment cloud app isn't available then it must be created. To add the app for new tenants, a Microsoft Entra administrator must create a service principal object, with app ID d4ebce55-015a-49b5-a083-c84d1797ae8c, in PowerShell or Microsoft Graph. To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources select Resources (formerly cloud apps), choose Select resources and add Microsoft Intune Enrollment to the list. - Under Exclude exclude any break-glass accounts. o Under Grant select Grant access. o Check either Require multifactor authentication or Require authentication strength. o Under Session check Sign-in frequency and select Every time. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes Microsoft Intune Enrollment. o Under Grant verify Require multifactor authentication or Require authentication strength is checked. o Under Session verify Sign-in frequency is set to Every time. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: Note: The Authentication Strength requirement is satisfied when any valid GUID is present in the authenticationStrength property of a matching policy. Because Authentication Strength configurations are inherently stronger than the built-in Require multifactor authentication control, the presence of a valid Authentication Strength also fulfills the MFA requirement for all users. 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.Applications.IncludeApplications contains d4ebce55-015a-49b5-a083-c84d1797ae8c. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications contains d4ebce55- 015a-49b5-a083-c84d1797ae8c o GrantControls.BuiltInControls contains mfa OR GrantControls.AuthenticationStrength.id is defined o SessionControls.SignInFrequency.IsEnabled is true o SessionControls.SignInFrequency.FrequencyInterval is everyTime o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. Sign-in frequency defaults to 90 days.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime#require-reauthentication-every-time:https://www.blackhat.com/eu-24/briefings/schedule/#unveiling-the-power-of-intune-leveraging-intune-for-breaking-into-your-cloud-and-on-premise-42176:https://www.glueckkanja.com/blog/security/2025/01/compliant-device-bypass-en/" + } + ] + }, + { + "Id": "5.2.2.12", + "Description": "The Microsoft identity platform supports the device authorization grant, which allows users to sign in to input-constrained devices such as a smart TV, IoT device, or a printer. To enable this flow, the device has the user visit a webpage in a browser on another device to sign in. Once the user signs in, the device is able to get access tokens and refresh tokens as needed. The recommended state is to Block access for Device code flow in Conditional Access.", + "Checks": [ + "entra_conditional_access_policy_device_code_flow_blocked" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The Microsoft identity platform supports the device authorization grant, which allows users to sign in to input-constrained devices such as a smart TV, IoT device, or a printer. To enable this flow, the device has the user visit a webpage in a browser on another device to sign in. Once the user signs in, the device is able to get access tokens and refresh tokens as needed. The recommended state is to Block access for Device code flow in Conditional Access.", + "RationaleStatement": "Since August 2024, Microsoft has observed threat actors, such as Storm-2372, employing \"device code phishing\" attacks. These attacks deceive users into logging into productivity applications, capturing authentication tokens to gain further access to compromised accounts. To mitigate this specific attack, block authentication code flows and permit only those from devices within trusted environments, identified by specific IP addresses.", + "ImpactStatement": "Some administrative overhead will be required for stricter management of these devices. Since exclusions do not violate compliance, this feature can still be utilized effectively within a controlled environment.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources > Resources (formerly cloud apps) include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions > Authentication flows set Configure to Yes and check Device code flow. o Click Save. o Under Grant select Block access and click Select. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to `On", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes All resources (formerly 'All cloud apps') o Under Conditions > Authentication flows verify Configure is set to Yes and Device code flow is checked. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where Conditions.AuthenticationFlows.TransferMethods contains deviceCodeFlow. 3. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o Conditions.AuthenticationFlows.TransferMethods contains deviceCodeFlow o GrantControls.BuiltInControls is block o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default. It may be present as a Microsoft-managed policy.", + "References": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-authentication-flows:https://www.microsoft.com/en-us/security/blog/2025/02/13/storm-2372-conducts-device-code-phishing-campaign/:https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-authentication-flows#device-code-flow-policies" + } + ] + }, + { + "Id": "5.2.2.13", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state for all users is to enforce periodic reauthentication for 7 days or less.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state for all users is to enforce periodic reauthentication for 7 days or less.", + "RationaleStatement": "A 7-day interval balances security and user experience by reducing the maximum lifespan of compromised credentials or stolen tokens without introducing excessive reauthentication prompts that can increase phishing susceptibility and user fatigue.", + "ImpactStatement": "Most users will not find weekly reauthentication requirements disruptive. Organizations with legacy applications, custom authentication workflows, or users relying on long-running sessions (such as shared or kiosk devices) may need to evaluate compatibility and apply appropriate exclusions to prevent user disruption.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources verify Resources (formerly cloud apps) include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Session select Sign-in frequency and set Periodic reauthentication to 7 days or less. 4. Under Enable policy set it to Report-only. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria and is set to On: o Under Users or agents (Preview) verify All users is included. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes All resources (formerly 'All cloud apps') o Under Session ensure Sign-in frequency is checked, and Periodic reauthentication is set to 7 days or less. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where SessionControls.SignInFrequency.IsEnabled is true 3. Filter out policies where Conditions.signInRiskLevels and Conditions.userRiskLevels have values defined, excluding these from the assessment. 4. For each policy returned, verify the following criteria: o Conditions.Users.IncludeUsers is All o Conditions.Applications.IncludeApplications is All o SessionControls.SignInFrequency.frequencyInterval is timeBased o SessionControls.SignInFrequency.type is days* o SessionControls.SignInFrequency.value is 7 (or less)* o State is enabled 5. Compliance is met when at least one policy is found to meet all the criteria listed above. 6. Verify that any exclusions are documented and reviewed annually. Note: Any SignInFrequency type and value combination is considered compliant as long as the reauthentication interval is less than or equal to 7 days. For example, a policy applied to all users that requires reauthentication every 20 hours would still meet the requirement.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime" + } + ] + }, + { + "Id": "5.2.2.14", + "Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization. The recommended state is to define at least one trusted, IP range named location.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization. The recommended state is to define at least one trusted, IP range named location.", + "RationaleStatement": "Defining trusted source IP addresses or ranges enables organizations to better tailor and enforce Conditional Access policies based on whether authentication attempts originate from trusted or untrusted network locations. Users signing in from trusted IP ranges can be granted reduced access requirements or fewer authentication prompts, while users coming from untrusted or unknown locations may face stricter controls. Additionally, marking named locations as trusted improves the accuracy of Microsoft Entra ID Protection's risk evaluations. When a user authenticates from a trusted location, their sign-in risk is appropriately lowered, helping reduce false positives and ensuring that risk-based policies trigger only when truly necessary.", + "ImpactStatement": "Configuring named locations by country cannot designate those locations as trusted, which means Conditional Access policies cannot use the \"All trusted locations\" option and must instead rely on explicitly selecting locations. This increases the administrative effort needed to configure and maintain these policies and requires more thorough testing to prevent unintended authentication blocks. Because Conditional Access policies can fully prevent users from signing in to Entra ID if misconfigured, organizations should maintain a dedicated break-glass Global Administrator account that is excluded from all Conditional Access policies and secured with a strong passphrase and hardware-based authentication. This account exists solely to recover access if all other administrators are locked out.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Under Manage, click Named locations. 4. Click on IP ranges location to add a new location. 5. Enter a name for this location setting in the Name field. 6. Click on the + icon. 7. Add only a trusted IP Address Range in CIDR notation inside the text box that appears. 8. Click on the Add button. 9. Repeat steps 6 through 8 for each additional IP range. 10. Select the Mark as trusted location checkbox. 11. Once finished, click on Create. Note: There is no single prescribed method for applying a named location to a Conditional Access policy, as the correct configuration depends on the specific access control requirements. Implementers should have a clear understanding of how named locations function before applying them to production policies.", + "AuditProcedure": "To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Under Manage, click Named locations. 4. For each named location with a Location type of IP ranges, verify there is one with the following criteria: o Trusted is set to Yes. o At least one IP Range is defined 5. Compliance is met when at least one named location is found to meet all the criteria listed above. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/namedLocations?$filter=isof('microsoft.graph. ipNamedLocation') 2. For each named location returned, verify the following criteria: o isTrusted is true o ipRanges contains at least one ipv4 or ipv6 range 3. Compliance is met when at least one named location is found to meet all the criteria listed above.", + "AdditionalInformation": "", + "DefaultValue": "Named locations are not configured by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-assignment-network:https://learn.microsoft.com/en-us/entra/id-protection/concept-risk-detection-types#locations:https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.2.15", + "Description": "Conditional Access Policies can be used to block access from geographic locations that are deemed out-of-scope for your organization or application. The scope and variables for this policy should be carefully examined and defined. The recommended state is to configure at least one policy to block access from untrusted locations.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Conditional Access Policies can be used to block access from geographic locations that are deemed out-of-scope for your organization or application. The scope and variables for this policy should be carefully examined and defined. The recommended state is to configure at least one policy to block access from untrusted locations.", + "RationaleStatement": "Using Conditional Access as a deny list at the tenant or subscription level enables an organization to block inbound and outbound traffic from geographic locations that fall outside its operational scope (e.g., customers, suppliers) or legal jurisdiction. Restricting access to only required regions significantly reduces unnecessary exposure to international threat actors, including advanced persistent threats (APTs), and helps maintain a more controlled and defensible security posture. Note: Because the selection of allowed or blocked locations is unique to each organization, this control does not prescribe specific countries or regions. Each organization should determine its geographic access requirements based on operational needs, regulatory obligations, and risk tolerance.", + "ImpactStatement": "Limiting access geographically will deny access to users that are traveling or working remotely in a different part of the world. A point-to-site or site to site tunnel such as a VPN is recommended to address exceptions to geographic access policies. CAUTION: If these policies are created without first auditing and testing the result, misconfiguration can potentially lock out administrators or create undesired access issues.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users o Under Target resources include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Network set Configure to Yes: - Select Include, then add entries for untrusted locations that should be blocked - Select Exclude, then add entries for trusted locations that should be allowed 4. Under Access Controls, select Grant select Block Access. 5. Under Enable policy set it to Report-only. 6. Click Create. 7. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria: o Under Users or agents (Preview) verify All users is included o Verify that only documented user exclusions exist and that they are reviewed annually o Under Target resources verify All resources (formerly 'All cloud apps') is selected o Under Network verify Include> Selected networks and locations contains at least one untrusted location o Under Network verify Exclude contains trusted locations through either All trusted networks and locations or Selected networks and locations o Under Grant verify Block access is selected 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. For each policy returned, verify the following criteria: o conditions.users.includeUsers is All o conditions.applications.includeApplications is All o conditions.locations.includeLocations contains at least one GUID of at least one untrusted location. o conditions.locations.excludeLocations is AllTrusted OR contains at least one GUID of at least one trusted location. o grantControls.builtInControls is block o state is enabled 3. Compliance is met when at least one policy is found to meet all the criteria listed above. 4. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "These policies should be tested by using the What If tool in the References. Setting these can and will create issues with logging in for users until they use an MFA device linked to their accounts. Further testing can also be done via the insights and reporting resource in References which monitors Azure sign ins.", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-by-location:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-report-only" + } + ] + }, + { + "Id": "5.2.2.16", + "Description": "Token Protection is a Conditional Access session control that attempts to reduce token replay attacks by ensuring only device bound sign-in session tokens, like Primary Refresh Tokens (PRTs), are accepted by Microsoft Entra ID when applications request access to protected resources. When a user registers a supported device with Microsoft Entra, a PRT is issued and cryptographically bound to that device. This binding ensures that even if a threat actor steals the token, it can't be used from another device. With Token Protection enforced, Microsoft Entra validates that only these bound sign-in session tokens are used by supported applications. The recommended state is to enforce Token Protection for Office 365 Exchange Online, Office 365 SharePoint Online and Microsoft Teams Services.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Token Protection is a Conditional Access session control that attempts to reduce token replay attacks by ensuring only device bound sign-in session tokens, like Primary Refresh Tokens (PRTs), are accepted by Microsoft Entra ID when applications request access to protected resources. When a user registers a supported device with Microsoft Entra, a PRT is issued and cryptographically bound to that device. This binding ensures that even if a threat actor steals the token, it can't be used from another device. With Token Protection enforced, Microsoft Entra validates that only these bound sign-in session tokens are used by supported applications. The recommended state is to enforce Token Protection for Office 365 Exchange Online, Office 365 SharePoint Online and Microsoft Teams Services.", + "RationaleStatement": "When properly configured, Conditional Access can aid in preventing attacks involving token theft, via hijacking or replay, as part of the attack flow. Although currently considered a rare event, the impact from token impersonation can be severe.", + "ImpactStatement": "Token Protection currently supports native applications only. Browser-based applications are not supported. There are also many other known limitations documented in the link below: https://learn.microsoft.com/en-us/entra/identity/conditional-access/deployment-guide- token-protection-windows#known-limitations", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Select New policy. 4. Select Users or agents (Preview): 1. Under Include, select the users or groups to apply this policy. 2. Under Exclude exclude any break-glass accounts. 5. Select Target resources > Resources > Include > Select resources 1. Under Select specific resources, select the following applications: 1. Office 365 Exchange Online 2. Office 365 SharePoint Online 3. Microsoft Teams Services 2. Choose Select 6. Select Conditions: 1. Under Device platforms 1. Set Configure to Yes. 2. Include > Select device platforms > Windows. 3. Select Done. 2. Under Client apps: 1. Set Configure to Yes 2. Under Modern authentication clients, only select Mobile apps and desktop clients. 3. Select Done 7. Under Access controls > Session, select Require token protection for sign-in sessions (Generally available for Windows. Preview for MacOS, iOS) and click Select. 8. Under Enable policy set it to Report-only. 9. Click Create. 10. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access and select Policies. 3. Verify that a policy exists with the following criteria: o Under Users or agents (Preview) verify that None is not selected. o Verify that only documented exclusions exist and that they are reviewed annually o Under Target resources select Select resources and verify at minimum the following are checked: - Office 365 Exchange Online - Office 365 SharePoint Online - Microsoft Teams Services 4. Under Conditions > Device Platforms: verify that Configure is set to Yes and Include indicates Windows platforms. 5. Under Conditions > Client Apps: verify that Configure is set to Yes and only Mobile Apps and Desktop Clients is selected. 6. Under Access controls > Session, verify that Require token protection for sign-in sessions (Generally available for Windows. Preview for MacOS, iOS) is selected. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where sessionControls.secureSignInSession.isEnabled is true. 3. For each policy returned, verify the following criteria: o conditions.users.includeUsers is not None o conditions.applications.includeApplications contains at least the following GUIDs: - 00000002-0000-0ff1-ce00-000000000000 (Office 365 Exchange Online) - 00000003-0000-0ff1-ce00-000000000000 (Office 365 SharePoint Online) - cc15fd57-2c6c-4117-a88c-83b1d56b4bbe (Microsoft Teams Services) o conditions.platforms.includePlatforms contains windows o conditions.clientAppTypes is mobileAppsAndDesktopClients o sessionControls.secureSignInSession.isEnabled is true o State is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-token-protection:https://learn.microsoft.com/en-us/entra/identity/conditional-access/deployment-guide-token-protection-windows:https://learn.microsoft.com/en-us/entra/identity/devices/protecting-tokens-microsoft-entra-id" + } + ] + }, + { + "Id": "5.2.2.17", + "Description": "Authentication transfer is a flow that lets users seamlessly transfer authenticated state from one device to another. For example, users might see a QR code in the desktop version of Outlook that, when scanned on their mobile device, transfers their authenticated state to the mobile device. The recommended state is to block Authentication transfer.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.2 Conditional Access", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Authentication transfer is a flow that lets users seamlessly transfer authenticated state from one device to another. For example, users might see a QR code in the desktop version of Outlook that, when scanned on their mobile device, transfers their authenticated state to the mobile device. The recommended state is to block Authentication transfer.", + "RationaleStatement": "Blocking authentication transfer helps protect against token theft and replay attacks by preventing the use of device tokens to silently authenticate on other devices or browsers. When authentication transfer is enabled, a threat actor who gains access to one device can access resources to unapproved devices, bypassing standard authentication and device compliance checks. When administrators block this flow, organizations can ensure that each authentication request must originate from the original device, maintaining the integrity of the device compliance and user session context.", + "ImpactStatement": "Users will no longer be able to use authentication transfer to sign into mobile versions of Microsoft apps (e.g., scanning a QR code in Outlook desktop to sign into Outlook mobile). Each device will require independent, interactive sign-in subject to applicable Conditional Access policies.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access. 3. Create a new policy by selecting New policy. o Under Users or agents (Preview) include All users. o Under Target resources > Resources (formerly cloud apps) include All resources (formerly 'All cloud apps'). - Under Exclude exclude any break-glass accounts. o Under Conditions > Authentication flows set Configure to Yes and check Authentication transfer. - Click Save. o Under Grant select Block access and click Select. 4. Under Enable policy set it to Report-only until the organization is ready to enable it. 5. Click Create. 6. After allowing the policy to run in Report-only mode for at least one week, review the Sign-in logs for any unexpected impact, then return to the policy and set Enable policy to On.", + "AuditProcedure": "Note: Break-glass accounts should be excluded from all Conditional Access policies. To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Conditional Access. 3. Verify that a policy exists with the following criteria: o Under Users or agents (Preview) verify All users is selected. o Verify that only documented user exclusions exist and that they are reviewed annually. o Under Target resources verify Resources (formerly cloud apps) includes All resources (formerly 'All cloud apps') o Under Conditions > Authentication flows verify Configure is set to Yes and Authentication transfer is checked. o Under Grant verify Block access is selected. 4. Verify that Enable policy is set to On. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identity/conditionalAccess/policies 2. Filter to policies where conditions.authenticationFlows.transferMethods contains authenticationTransfer. 3. For each policy returned, verify the following criteria: o conditions.users.includeUsers is All o conditions.applications.includeApplications is All o conditions.authenticationFlows.transferMethods contains authenticationTransfer o grantControls.builtInControls is block o state is enabled 4. Compliance is met when at least one policy is found to meet all the criteria listed above. 5. Verify that any exclusions are documented and reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "This policy does not exist by default.", + "References": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-authentication-flows#authentication-transfer-policies:https://learn.microsoft.com/en-us/entra/fundamentals/zero-trust-protect-identities#authentication-transfer-is-blocked:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-authentication-flows:https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-authentication-transfer" + } + ] + }, + { + "Id": "5.2.3.1", + "Description": "Microsoft provides supporting settings to enhance the configuration of the Microsoft Authenticator application. These settings provide users with additional information and context when they receive MFA passwordless and push requests, including the geographic location of the request, the requesting application, and a requirement for number matching. The recommended state is Enabled for the following: - Show application name in push and passwordless notifications - Show geographic location in push and passwordless notifications Note: On February 27, 2023 Microsoft started enforcing number matching tenant-wide for all users using Microsoft Authenticator.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft provides supporting settings to enhance the configuration of the Microsoft Authenticator application. These settings provide users with additional information and context when they receive MFA passwordless and push requests, including the geographic location of the request, the requesting application, and a requirement for number matching. The recommended state is Enabled for the following: - Show application name in push and passwordless notifications - Show geographic location in push and passwordless notifications Note: On February 27, 2023 Microsoft started enforcing number matching tenant-wide for all users using Microsoft Authenticator.", + "RationaleStatement": "As the use of strong authentication has become more widespread, attackers have started to exploit the tendency of users to experience \"MFA fatigue.\" This occurs when users are repeatedly asked to provide additional forms of identification, leading them to eventually approve requests without fully verifying the source. To counteract this, number matching can be employed to ensure the security of the authentication process. With this method, users are prompted to confirm a number displayed on their original device and enter it into the device being used for MFA. Additionally, other information such as geolocation and application details are displayed to enhance the end user's awareness. Among these 3 options, number matching provides the strongest net security gain.", + "ImpactStatement": "Additional interaction will be required by end users using number matching as opposed to simply pressing \"Approve\" for login attempts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click to expand Entra ID > Authentication methods and select Policies. 3. Select Microsoft Authenticator 4. Under Enable and Target ensure the setting is set to Enable. 5. Select Configure 6. Set the following Microsoft Authenticator settings: o Show application name in push and passwordless notifications Status is set to Enabled, Target All users o Show geographic location in push and passwordless notifications Status is set to Enabled, Target All users Note: Valid groups such as break glass accounts can be excluded per organization policy.", + "AuditProcedure": "To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Authentication methods and select Policies. 3. Under Method select Microsoft Authenticator. 4. Under Enable and Target verify the setting is set to Enable. 5. In the Include tab verify that All users is selected. 6. In the Exclude tab verify only valid groups are present (i.e. Break Glass accounts). 7. Select Configure 8. Verify the following Microsoft Authenticator settings: o Show application name in push and passwordless notifications Status is set to Enabled, Target All users o Show geographic location in push and passwordless notifications Status is set to Enabled, Target All users 9. In each setting select Exclude and verify only valid groups are present (i.e. Break Glass accounts). To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/ microsoftAuthenticator 2. Verify that the state is enabled. 3. Verify that includeTargets.id is all_users. 4. Verify that the excludeTargets only includes valid groups (i.e. Break Glass accounts). 5. Under featureSettings verify the following settings: o displayAppInformationRequiredState.state is enabled o displayAppInformationRequiredState.includeTarget.id is all_users o displayLocationInformationRequiredState.state is enabled o displayLocationInformationRequiredState.includeTarget.id is all_users 6. In each setting excludeTarget only includes a valid group (i.e. Break Glass accounts) or a target id of 00000000-0000-0000-0000-000000000000 Note: Compliance cannot be easily validated for exclusions so adding these to a report for human manual review is recommended. These should be reviewed annually.", + "AdditionalInformation": "", + "DefaultValue": "Microsoft-managed", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-default-enablement:https://techcommunity.microsoft.com/t5/microsoft-entra-blog/defend-your-users-from-mfa-fatigue-attacks/ba-p/2365677:https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-number-match:https://learn.microsoft.com/en-us/graph/api/authenticationmethodspolicy-get?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.2.3.2", + "Description": "With Entra Password Protection, default global banned password lists are automatically applied to all users in an Entra ID tenant. To support business and security needs, custom banned password lists can be defined. When users change or reset their passwords, these banned password lists are checked to enforce the use of strong passwords. A custom banned password list should include some of the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "With Entra Password Protection, default global banned password lists are automatically applied to all users in an Entra ID tenant. To support business and security needs, custom banned password lists can be defined. When users change or reset their passwords, these banned password lists are checked to enforce the use of strong passwords. A custom banned password list should include some of the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning", + "RationaleStatement": "Creating a new password can be difficult regardless of one's technical background. It is common to look around one's environment for suggestions when building a password, however, this may include picking words specific to the organization as inspiration for a password. An adversary may employ what is called a 'mangler' to create permutations of these specific words in an attempt to crack passwords or hashes making it easier to reach their goal.", + "ImpactStatement": "If a custom banned password list includes too many common dictionary words, or short words that are part of compound words, then perfectly secure passwords may be blocked. The organization should consider a balance between security and usability when creating a list.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set Enforce custom list to Yes 4. In Custom banned password list create a list using suggestions outlined in this document. 5. Click Save Note: Below is a list of examples that can be used as a starting place. The references section contains more suggestions. - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Enforce custom list is set to Yes 4. Verify that Custom banned password list contains entries specific to the organization or matches a pre-determined list. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Directory.Read.All\" 2. Run the following commands: $PwRuleSettings = '5cf42378-d67d-4f36-ba46-e8b86229381d' Get-MgGroupSetting | Where-Object TemplateId -eq $PwRuleSettings | Select-Object -ExpandProperty Values 3. Verify that EnableBannedPasswordCheck is True and BannedPasswordList is populated.", + "AdditionalInformation": "Organization-specific terms can be added to the custom banned password list, such as the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific terms - Abbreviations that have specific company meaning - Months and weekdays with your company's local languages The default global banned password list is already applied to your resources which applies the following basic requirements: Characters allowed: - Uppercase characters (A - Z) - Lowercase characters (a - z) - Numbers (0 - 9) - Symbols: - @#$%^&*-_!+=[]{}|\\:',.?/`~\"();<> - blank space Characters not allowed: - Unicode characters Password length: Passwords require: - A minimum of eight characters - A maximum of 256 characters Password complexity: Passwords require three out of four of the following categories: - Uppercase characters - Lowercase characters - Numbers - Symbols Note: Password complexity check isn't required for Education tenants. Password not recently used: - When a user changes or resets their password, the new password can't be the same as the current or recently used passwords. - Password isn't banned by Entra ID Password Protection. - The password can't be on the global list of banned passwords for Azure AD Password Protection, or on the customizable list of banned passwords specific to your organization. Evaluation New passwords are evaluated for strength and complexity by validating against the combined list of terms from the global and custom banned password lists. Even if a user's password contains a banned password, the password may be accepted if the overall password is otherwise strong enough.", + "DefaultValue": "By default the custom banned password list is not 'Enabled'.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad#custom-banned-password-list:https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-configure-custom-password-protection:https://www.microsoft.com/en-us/research/publication/password-guidance/:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-6-use-strong-authentication-controls" + } + ] + }, + { + "Id": "5.2.3.3", + "Description": "Microsoft Entra Password Protection provides a global and custom banned password list. A password change request fails if there's a match in these banned password list. To protect on-premises Active Directory Domain Services (AD DS) environment, install and configure Entra Password Protection. Note: This recommendation applies to Hybrid deployments only and will have no impact unless working with on-premises Active Directory.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Password Protection provides a global and custom banned password list. A password change request fails if there's a match in these banned password list. To protect on-premises Active Directory Domain Services (AD DS) environment, install and configure Entra Password Protection. Note: This recommendation applies to Hybrid deployments only and will have no impact unless working with on-premises Active Directory.", + "RationaleStatement": "This feature protects an organization by prohibiting the use of weak or leaked passwords. In addition, organizations can create custom banned password lists to prevent their users from using easily guessed passwords that are specific to their industry. Deploying this feature to Active Directory will strengthen the passwords that are used in the environment.", + "ImpactStatement": "The potential impact associated with implementation of this setting is dependent upon the existing password policies in place in the environment. For environments that have strong password policies in place, the impact will be minimal. For organizations that do not have strong password policies in place, implementation of Microsoft Entra Password Protection may require users to change passwords and adhere to more stringent requirements than they have been accustomed to.", + "RemediationProcedure": "To remediate using the UI: - Download and install the Azure AD Password Proxies and DC Agents from the following location: https://www.microsoft.com/download/details.aspx?id=57071 After installed follow the steps below. 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set Enable password protection on Windows Server Active Directory to Yes and Mode to Enforced.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Enable password protection on Windows Server Active Directory is set to Yes and that Mode is set to Enforced. To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Directory.Read.All\" 2. Run the following command: (Get-MgGroupSetting | ? { $_.TemplateId -eq '5cf42378-d67d-4f36-ba46- e8b86229381d' }).Values 3. Verify that EnableBannedPasswordCheckOnPremises is set to True and BannedPasswordCheckOnPremisesMode is set to Enforce.", + "AdditionalInformation": "", + "DefaultValue": "Enable - Yes Mode - Audit", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-ban-bad-on-premises-operations" + } + ] + }, + { + "Id": "5.2.3.4", + "Description": "Microsoft defines Multifactor authentication capable as being registered and enabled for a strong authentication method. The method must also be allowed by the authentication methods policy. Ensure all member users are MFA capable.", + "Checks": [ + "entra_users_mfa_capable" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft defines Multifactor authentication capable as being registered and enabled for a strong authentication method. The method must also be allowed by the authentication methods policy. Ensure all member users are MFA capable.", + "RationaleStatement": "Multifactor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Users who are not MFA Capable have never registered a strong authentication method for multifactor authentication that is within policy and may not be using MFA. This could be a result of having never signed in, exclusion from a Conditional Access (CA) policy requiring MFA, or a CA policy does not exist. Reviewing this list of users will help identify possible lapses in policy or procedure.", + "ImpactStatement": "When using the UI audit method guest users will appear in the report and unless the organization is applying MFA rules to guests then they will need to be manually filtered. Accounts that provide on-premises directory synchronization also appear in these reports.", + "RemediationProcedure": "Remediation steps will depend on the status of the personnel in question or configuration of Conditional Access policies and will not be covered in detail. Administrators should review each user identified on a case-by-case basis using the conditions below. User has never signed on: - Employment status should be reviewed, and appropriate action taken on the user account's roles, licensing and enablement. Conditional Access policy applicability: - Ensure a CA policy is in place requiring all users to use MFA. - Ensure the user is not excluded from the CA MFA policy. - Ensure the policy's state is set to On. - Use What if to determine applicable CA policies. (Protection > Conditional Access > Policies) - Review the user account in Sign-in logs. Under the Activity Details pane click the Conditional Access tab to view applied policies. Note: Conditional Access is covered step by step in section 5.2.2", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select User registration details. 3. Set the filter option Multifactor authentication capable to Not Capable. 4. Review the non-guest users in this list. 5. Excluding any exceptions users found in this report may require remediation. To audit using PowerShell: 1. Connect to Graph using Connect-MgGraph -Scopes \"UserAuthenticationMethod.Read.All,AuditLog.Read.All\" 2. Run the following: Get-MgReportAuthenticationMethodUserRegistrationDetail ` -Filter \"IsMfaCapable eq false and UserType eq 'Member'\" | ft UserPrincipalName,IsMfaCapable,IsAdmin 3. Verify that IsMfaCapable is set to True. 4. Excluding any exceptions users found in this report may require remediation. Note: The CA rule must be in place for a successful deployment of Multifactor Authentication. This policy is outlined in the conditional access section 5.2.2 Note 2: Possible exceptions include on-premises synchronization accounts.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.reports/update-mgreportauthenticationmethoduserregistrationdetail?view=graph-powershell-0#-ismfacapable:https://learn.microsoft.com/en-us/entra/identity/monitoring-health/how-to-view-applied-conditional-access-policies:https://learn.microsoft.com/en-us/entra/identity/conditional-access/what-if-tool:https://learn.microsoft.com/en-us/entra/identity/authentication/howto-authentication-methods-activity" + } + ] + }, + { + "Id": "5.2.3.5", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. SMS and Voice Call rely on telephony carrier communication methods to deliver the authenticating factor. The recommended state is to Disable these methods: - SMS - Voice Call", + "Checks": [ + "entra_authentication_method_sms_voice_disabled" + ], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. SMS and Voice Call rely on telephony carrier communication methods to deliver the authenticating factor. The recommended state is to Disable these methods: - SMS - Voice Call", + "RationaleStatement": "Traditional MFA methods such as SMS codes, email-based OTPs, and push notifications are becoming less effective against today's attackers. Sophisticated phishing campaigns have demonstrated that second factors can be intercepted or spoofed. Attackers now exploit social engineering, man-in-the-middle tactics, and user fatigue (e.g., MFA bombing) to bypass these mechanisms. These risks are amplified in distributed, cloud-first organizations with hybrid workforces and varied device ecosystems. The SMS and Voice call methods are vulnerable to SIM swapping which could allow an attacker to gain access to your Microsoft 365 account.", + "ImpactStatement": "There may be increased administrative overhead in adopting more secure authentication methods depending on the maturity of the organization.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Inspect each method that is out of compliance and remediate: o Click on the method to open it. o Change the Enable toggle to the off position. o Click Save. Note: If the save button remains greyed out after toggling a method off, then first turn it back on and then change the position of the Target selection (all users or select groups). Turn the method off again and save. This was observed to be a bug in the UI at the time this document was published. To remediate using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.AuthenticationMethod\" 2. Run the following to disable both authentication methods: $params = @( @{ Id = \"Sms\"; State = \"disabled\" }, @{ Id = \"Voice\"; State = \"disabled\" } ) Update-MgPolicyAuthenticationMethodPolicy -AuthenticationMethodConfigurations $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Verify that the following methods in the Enabled column are set to No. o Method: SMS o Method: Voice call To audit using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: (Get-MgPolicyAuthenticationMethodPolicy).AuthenticationMethodConfigurations 3. Verify that Sms and Voice are disabled.", + "AdditionalInformation": "", + "DefaultValue": "- SMS : Disabled - Voice Call : Disabled", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage:https://learn.microsoft.com/en-us/security/zero-trust/sfi/phishing-resistant-mfa#context-and-problem:https://www.microsoft.com/en-us/microsoft-365-life-hacks/privacy-and-safety/what-is-sim-swapping" + } + ] + }, + { + "Id": "5.2.3.6", + "Description": "System-preferred multifactor authentication (MFA) prompts users to sign in by using the most secure method they registered. The user is prompted to sign-in with the most secure method according to the below order. The order of authentication methods is dynamic. It's updated by Microsoft as the security landscape changes, and as better authentication methods emerge. 1. Temporary Access Pass 2. Passkey (FIDO2) 3. Microsoft Authenticator notifications 4. External authentication methods 5. Time-based one-time password (TOTP) 6. Telephony 7. Certificate-based authentication The recommended state is Enabled.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "System-preferred multifactor authentication (MFA) prompts users to sign in by using the most secure method they registered. The user is prompted to sign-in with the most secure method according to the below order. The order of authentication methods is dynamic. It's updated by Microsoft as the security landscape changes, and as better authentication methods emerge. 1. Temporary Access Pass 2. Passkey (FIDO2) 3. Microsoft Authenticator notifications 4. External authentication methods 5. Time-based one-time password (TOTP) 6. Telephony 7. Certificate-based authentication The recommended state is Enabled.", + "RationaleStatement": "Regardless of the authentication method enabled by an administrator or set as preferred by the user, the system will dynamically select the most secure option available at the time of authentication. This approach acts as an additional safeguard to prevent the use of weaker methods, such as voice calls, SMS, and email OTPs, which may have been inadvertently left enabled due to misconfiguration or lack of configuration hardening. Enforcing the default behavior also ensures the feature is not disabled.", + "ImpactStatement": "The Microsoft managed value of system-preferred MFA is Enabled and as such enforces the default behavior. No additional impact is expected. Note: Due to known issues with certificate-based authentication (CBA) and system- preferred MFA, Microsoft moved CBA to the bottom of the list. It is still considered a strong authentication method.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Settings. 3. Set the System-preferred multifactor authentication State to Enabled and include All users. 4. Any users exclusions should be documented and reviewed annually.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Settings. 3. Verify the System-preferred multifactor authentication State is set to Enabled and All users are included. 4. Verify that only documented exclusions exist and that they are reviewed annually To audit using PowerShell: 1. Connect to Microsoft Graph using Connect-MgGraph -Scopes \"Policy.Read.AuthenticationMethod\" 2. Run the following commands: $Uri = 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' (Invoke-MgGraphRequest -Method GET -Uri $Uri).systemCredentialPreferences 3. Verify that includeTargets is set to all_users and state is set to enabled.", + "AdditionalInformation": "", + "DefaultValue": "Microsoft Managed (Enabled)", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-system-preferred-multifactor-authentication:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-system-preferred-multifactor-authentication#how-does-system-preferred-mfa-determine-the-most-secure-method" + } + ] + }, + { + "Id": "5.2.3.7", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. The email one-time passcode feature is a way to authenticate B2B collaboration users when they can't be authenticated through other means, such as Microsoft Entra ID, Microsoft account (MSA), or social identity providers. When a B2B guest user tries to redeem your invitation or sign in to your shared resources, they can request a temporary passcode, which is sent to their email address. Then they enter this passcode to continue signing in. The recommended state is to Disable email OTP.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. The email one-time passcode feature is a way to authenticate B2B collaboration users when they can't be authenticated through other means, such as Microsoft Entra ID, Microsoft account (MSA), or social identity providers. When a B2B guest user tries to redeem your invitation or sign in to your shared resources, they can request a temporary passcode, which is sent to their email address. Then they enter this passcode to continue signing in. The recommended state is to Disable email OTP.", + "RationaleStatement": "Traditional MFA methods such as SMS codes, email-based OTPs, and push notifications are becoming less effective against today's attackers. Sophisticated phishing campaigns have demonstrated that second factors can be intercepted or spoofed. Attackers now exploit social engineering, man-in-the-middle tactics, and user fatigue (e.g., MFA bombing) to bypass these mechanisms. These risks are amplified in distributed, cloud-first organizations with hybrid workforces and varied device ecosystems.", + "ImpactStatement": "Disabling Email OTP will prevent one-time pass codes from being sent to unverified guest users accessing Microsoft 365 resources on the tenant such as \"@yahoo.com\". They will be required to use a personal Microsoft account, a managed Microsoft Entra account, be part of a federation or be configured as a guest in the host tenant's Microsoft Entra ID.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Click on Email OTP. 4. Change the Enable toggle to the off position\\ 5. Click Save. Note: If the save button remains greyed out after toggling a method off, then first turn it back on and then change the position of the Target selection (all users or select groups). Turn the method off again and save. This was observed to be a bug in the UI at the time this document was published. To remediate using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.ReadWrite.AuthenticationMethod\" 2. Run the following: $params = @( @{ Id = \"Email\"; State = \"disabled\" } ) Update-MgPolicyAuthenticationMethodPolicy -AuthenticationMethodConfigurations $params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Policies. 3. Verify that Email OTP is set to No in the Enabled column. To audit using Powershell: 1. Connect to Graph using Connect-MgGraph -Scopes \"Policy.Read.All\" 2. Run the following: (Get-MgPolicyAuthenticationMethodPolicy).AuthenticationMethodConfigurations 3. Verify the id type Email is set to disabled.", + "AdditionalInformation": "", + "DefaultValue": "- Email OTP : Enabled", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage:https://learn.microsoft.com/en-us/entra/external-id/one-time-passcode:https://learn.microsoft.com/en-us/security/zero-trust/sfi/phishing-resistant-mfa#context-and-problem" + } + ] + }, + { + "Id": "5.2.3.8", + "Description": "The account lockout threshold determines how many failed login attempts are permitted prior to placing the account in a locked-out state and initiating a variable lockout duration. The recommended Lockout threshold is 10 or less.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The account lockout threshold determines how many failed login attempts are permitted prior to placing the account in a locked-out state and initiating a variable lockout duration. The recommended Lockout threshold is 10 or less.", + "RationaleStatement": "Account lockout is a method of protecting against brute-force and password spray attacks. Once the lockout threshold has been exceeded, the account enters a locked- out state which prevents all login attempts for a variable duration. The lockout in combination with a reasonable duration reduces the total number of failed login attempts that a malicious actor can execute in a given period of time.", + "ImpactStatement": "If account lockout threshold is set too low (less than 3), users may experience frequent lockout events and the resulting security alerts may contribute to alert fatigue. If account lockout threshold is set too high (more than 10), malicious actors can programmatically execute more password attempts in a given period of time. In hybrid environments using pass-through authentication (PTA), the Microsoft Entra lockout threshold must be set lower than the AD DS account lockout threshold. If the AD DS threshold is equal to or lower than the Entra threshold, AD DS will lock the account before Entra smart lockout activates, bypassing the cloud-side protection and resulting in on-premises account lockouts that require manual administrator intervention to clear. Microsoft recommends configuring the AD DS lockout threshold to be at least two to three times greater than the Entra lockout threshold.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set Lockout threshold to 10 or less. Note: In hybrid environments using pass-through authentication (PTA), Microsoft recommends to configure the AD DS account lockout threshold to be at least two to three times greater than the value set here to ensure Entra smart lockout activates before AD DS lockout.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Lockout threshold is set to 10 or less. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/groupSettings 2. Filter to Password Rule settings, where templateId is 5cf42378-d67d-4f36- ba46-e8b86229381d 3. Verify that LockoutThreshold is 10 or less.", + "AdditionalInformation": "NOTE: The variable number for failed login attempts allowed before lockout is prescribed by many security and compliance frameworks. The appropriate setting for this variable should be determined by the most restrictive security or compliance framework that your organization follows.", + "DefaultValue": "By default, Lockout threshold is set to 10.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-smart-lockout#manage-microsoft-entra-smart-lockout-values:https://learn.microsoft.com/en-us/graph/api/group-list-settings?view=graph-rest-0" + } + ] + }, + { + "Id": "5.2.3.9", + "Description": "The account lockout duration value determines how long an account retains the status of lockout, and therefore how long before a user can continue to attempt to login after passing the lockout threshold. The recommended state is Lockout duration in seconds is at least 60.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The account lockout duration value determines how long an account retains the status of lockout, and therefore how long before a user can continue to attempt to login after passing the lockout threshold. The recommended state is Lockout duration in seconds is at least 60.", + "RationaleStatement": "Account lockout is a method of protecting against brute-force and password spray attacks. Once the lockout threshold has been exceeded, the account enters a locked- out state which prevents all login attempts for a variable duration. The lockout in combination with a reasonable duration reduces the total number of failed login attempts that a malicious actor can execute in a given period of time.", + "ImpactStatement": "If account lockout duration is set too low (less than 60 seconds), malicious actors can perform more password spray and brute-force attempts over a given period of time. If the account lockout duration is set too high (more than 300 seconds) users may experience inconvenient delays during lockout. In hybrid environments using pass-through authentication (PTA), the Microsoft Entra lockout duration must be set longer than the AD DS account lockout duration. Note that Entra lockout duration is configured in seconds while AD DS lockout duration is configured in minutes; verify the units when comparing the two values to ensure Entra smart lockout expires after AD DS lockout.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Set the Lockout duration in seconds to 60 or higher. 4. Click Save. Note: In hybrid environments using pass-through authentication (PTA), ensure the AD DS account lockout duration is shorter than the value set here. The Entra lockout duration is configured in seconds while AD DS lockout duration is configured in minutes; account for the unit difference when comparing the two values.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Authentication methods and select Password protection. 3. Verify that Lockout duration in seconds is set to 60 or higher. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/groupSettings 2. Filter to Password Rule settings, where templateId is 5cf42378-d67d-4f36- ba46-e8b86229381d 3. Verify that LockoutDurationInSeconds is greater than or equal to 60.", + "AdditionalInformation": "", + "DefaultValue": "By default, Lockout duration in seconds is set to 60.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-smart-lockout#manage-microsoft-entra-smart-lockout-values:https://learn.microsoft.com/en-us/graph/api/group-list-settings?view=graph-rest-0" + } + ] + }, + { + "Id": "5.2.3.10", + "Description": "Microsoft Entra ID includes a feature called Authenticator Lite, which embeds a subset of Microsoft Authenticator functionality into companion applications such as Outlook mobile. When enabled, users can satisfy MFA requirements using push notifications or time-based one-time passcodes (TOTP) directly from the companion application without installing the standalone Microsoft Authenticator app. The recommended state is Microsoft Authenticator on companion applications set to Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.3 Authentication Methods", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra ID includes a feature called Authenticator Lite, which embeds a subset of Microsoft Authenticator functionality into companion applications such as Outlook mobile. When enabled, users can satisfy MFA requirements using push notifications or time-based one-time passcodes (TOTP) directly from the companion application without installing the standalone Microsoft Authenticator app. The recommended state is Microsoft Authenticator on companion applications set to Disabled.", + "RationaleStatement": "Authenticator Lite does not support application name or geographic location context in push notifications, regardless of tenant-wide Authenticator feature settings. These are key defenses against MFA fatigue attacks that are only available in the full Microsoft Authenticator app. Authenticator Lite also does not satisfy Conditional Access authentication strength requirements, does not support passwordless authentication, and does not support SSPR via push notifications. Disabling this feature ensures users authenticate through the full Microsoft Authenticator app where all available security protections are active.", + "ImpactStatement": "Users who have registered Authenticator Lite as their only MFA method will be unable to complete MFA until they install and register the standalone Microsoft Authenticator app. Administrators should communicate this change in advance and verify that affected users have registered an alternative MFA method before disabling this feature to avoid authentication lockouts.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Authentication methods and select Policies. 3. Under Method select Microsoft Authenticator. 4. Select Configure. 5. Set Microsoft Authenticator on companion applications: Status to Disabled. 6. Select Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Entra ID > Authentication methods and select Policies. 3. Under Method select Microsoft Authenticator. 4. Select Configure. 5. Verify that Microsoft Authenticator on companion applications: Status is set to Disabled. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/ microsoftAuthenticator 2. Verify that featureSettings.companionAppAllowedState.state is disabled.", + "AdditionalInformation": "", + "DefaultValue": "Microsoft managed (enabled as of June 26, 2023)", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-authenticator-lite:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-default-enablement:https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-additional-context" + } + ] + }, + { + "Id": "5.2.4.1", + "Description": "Enabling self-service password reset allows users to reset their own passwords in Entra ID. When users sign in to Microsoft 365, they will be prompted to enter additional contact information that will help them reset their password in the future. If combined registration is enabled additional information, outside of multi-factor, will not be needed. The recommended state is All. Note: Effective Oct. 1st, 2022, Microsoft will begin to enable combined registration for all users in Entra ID tenants created before August 15th, 2020. Tenants created after this date are enabled with combined registration by default.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "Enabling self-service password reset allows users to reset their own passwords in Entra ID. When users sign in to Microsoft 365, they will be prompted to enter additional contact information that will help them reset their password in the future. If combined registration is enabled additional information, outside of multi-factor, will not be needed. The recommended state is All. Note: Effective Oct. 1st, 2022, Microsoft will begin to enable combined registration for all users in Entra ID tenants created before August 15th, 2020. Tenants created after this date are enabled with combined registration by default.", + "RationaleStatement": "Enabling Self-Service Password Reset (SSPR) significantly reduces helpdesk interactions, streamlining support operations and improving user experience. Traditional methods involving temporary passwords pose notable security risks-they are often weak, predictable, and susceptible to interception. This creates a window of opportunity for threat actors to compromise accounts before users can update their credentials. SSPR minimizes credential exposure and strengthens overall identity protection.", + "ImpactStatement": "Users will be required to provide additional contact information in order to enroll in SSPR. Some light user education may be necessary, particularly for individuals who are accustomed to contacting the help desk for password reset assistance. In hybrid environments, SSPR writeback must be enabled before users are able to reset their passwords through self-service.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Properties. 3. Set Self service password reset enabled to All", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Properties. 3. Verify that Self service password reset enabled is set to All", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/let-users-reset-passwords?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr:https://learn.microsoft.com/en-us/entra/identity/authentication/howto-registration-mfa-sspr-combined:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-writeback" + } + ] + }, + { + "Id": "5.2.4.2", + "Description": "Ensures that two alternate forms of identification are provided before allowing a password reset. The recommended state is Number of methods required to reset set to 2.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 2", + "AssessmentStatus": "Manual", + "Description": "Ensures that two alternate forms of identification are provided before allowing a password reset. The recommended state is Number of methods required to reset set to 2.", + "RationaleStatement": "Requiring Multi-factor Authentication (MFA) for Self-service Password Reset (SSPR) strengthens the password reset process by confirming the user's identity with two separate methods of authentication. With multiple methods required for password reset, an attacker would have to compromise multiple authentication methods before resetting a user's password.", + "ImpactStatement": "If multiple methods are required for password reset and a user has lost access to other authentication methods, the resetting user will need an administrator with permissions to remove the lost authentication method. Policy and training are recommended to teach administrators to verify the identity of the requesting user so that social engineering is not an effective vector of compromise. If multifactor authentication is not currently enabled for all users, users with only one registered form of authentication will not be able to reset their passwords through SSPR until another form of authentication is registered. If multifactor authentication is already enabled for all users, the impact of this recommendation should be minimal.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Authentication methods. 3. Set the Number of methods required to reset to 2 4. Click Save", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Authentication methods. 3. Verify that Number of methods required to reset is set to 2", + "AdditionalInformation": "", + "DefaultValue": "By default, the Number of methods required to reset is 1.", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-registration-mfa-sspr-combined:https://support.microsoft.com/en-us/account-billing/reset-your-work-or-school-password-using-security-info-23dde81f-08bb-4776-ba72-e6b72b9dda9e:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods" + } + ] + }, + { + "Id": "5.2.4.3", + "Description": "The Require users to register when signing in? setting controls whether users are prompted to register their self-service password reset (SSPR) authentication methods at their next sign-in. When set to Yes, users who have not yet registered are prompted to do so upon signing in. The Number of days before users are asked to re-confirm their authentication information setting designates the period of time before registered users are prompted to re-confirm their existing authentication information is still valid, up to a maximum of 730 days. If set to 0 days, registered users will never be prompted to re-confirm their authentication information.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "The Require users to register when signing in? setting controls whether users are prompted to register their self-service password reset (SSPR) authentication methods at their next sign-in. When set to Yes, users who have not yet registered are prompted to do so upon signing in. The Number of days before users are asked to re-confirm their authentication information setting designates the period of time before registered users are prompted to re-confirm their existing authentication information is still valid, up to a maximum of 730 days. If set to 0 days, registered users will never be prompted to re-confirm their authentication information.", + "RationaleStatement": "Without requiring users to register, users may never establish SSPR authentication methods, rendering the re-confirmation setting ineffective regardless of the value it is set to. When users do register authentication methods for self-service password reset (SSPR), those methods may become stale over time as phone numbers, email addresses, or other contact information changes. If re-confirmation is disabled, outdated recovery information persists indefinitely. An attacker who gains access to a former phone number or email address associated with a user's account can exploit that stale recovery information to reset the user's password and take over the account. Requiring registration and periodic re-confirmation ensures that the authentication methods on record remain accurate and under the user's control.", + "ImpactStatement": "Because both settings default to the compliant state, organizations that have not altered them will experience no impact. Re-enabling registration prompts unregistered users to register at their next sign-in; re-enabling re-confirmation prompts registered users to verify their information on the configured interval. Organizations with large user populations and short re-confirmation intervals should expect increased SSPR support volume.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Registration. 3. Set Require users to register when signing in? to Yes. 4. Set Number of days before users are asked to re-confirm their authentication information to any organization-approved value other than 0. 5. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Registration. 3. Verify that Require users to register when signing in? is set to Yes. 4. Verify that Number of days before users are asked to re-confirm their authentication information is not set to 0.", + "AdditionalInformation": "", + "DefaultValue": "- Require users to register when signing in?: Yes - Number of days before users are asked to re-confirm their authentication information: 180", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-howitworks#registration:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods" + } + ] + }, + { + "Id": "5.2.4.4", + "Description": "This setting determines whether or not users receive an email to their primary and alternate email addresses notifying them when their own password has been reset via the Self-Service Password Reset portal. The recommended state is Notify users on password resets? is set to Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This setting determines whether or not users receive an email to their primary and alternate email addresses notifying them when their own password has been reset via the Self-Service Password Reset portal. The recommended state is Notify users on password resets? is set to Yes.", + "RationaleStatement": "User notification on password reset is a proactive way of confirming password reset activity. It helps the user to recognize unauthorized password reset activities.", + "ImpactStatement": "Users will receive emails alerting them to password changes to both their primary and alternate emails.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Set Notify users on password resets? to Yes 4. Click Save.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Verify that Notify users on password resets? is set to Yes.", + "AdditionalInformation": "", + "DefaultValue": "Yes", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr#set-up-notifications-and-customizations:https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-howitworks#notifications:https://support.microsoft.com/en-us/account-billing/reset-your-work-or-school-password-using-security-info-23dde81f-08bb-4776-ba72-e6b72b9dda9e" + } + ] + }, + { + "Id": "5.2.4.5", + "Description": "This setting determines whether or not all global administrators receive an email to their primary email address when other administrators reset their own passwords via the Self-Service Password Reset Portal. The recommended state is Notify all admins when other admins reset their password? is set to Yes.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.2.4 Password reset", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This setting determines whether or not all global administrators receive an email to their primary email address when other administrators reset their own passwords via the Self-Service Password Reset Portal. The recommended state is Notify all admins when other admins reset their password? is set to Yes.", + "RationaleStatement": "Administrator accounts are sensitive. Any password reset activity notification, when sent to all Administrators, ensures that all Global Administrators can passively confirm if such a reset is a common pattern within their group. For example, if all Administrators change their password every 30 days, any password reset activity before that may require administrator(s) to evaluate any unusual activity and confirm its origin.", + "ImpactStatement": "All Global Administrators will receive a notification from Azure every time a password is reset. This is useful for auditing procedures to confirm that there are no out of the ordinary password resets for Administrators. There is additional overhead, however, in the time required for Global Administrators to audit the notifications. This setting is only useful if all Global Administrators pay attention to the notifications and audit each one.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Set Notify all admins when other admins reset their password? to Yes 4. Click Save", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Password reset and select Notifications. 3. Verify that Notify all admins when other admins reset their password? is set to Yes", + "AdditionalInformation": "", + "DefaultValue": "No", + "References": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-howitworks#notifications:https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-sspr#set-up-notifications-and-customizations" + } + ] + }, + { + "Id": "5.3.1", + "Description": "Microsoft Entra Privileged Identity Management (PIM) provides just-in-time (JIT) activation workflows for privileged Entra ID and Microsoft 365 roles, enabling time- bound access with approval and justification requirements. Rather than holding permanent role assignments, users are made eligible for a role and must explicitly activate it when needed. PIM supports requiring multi-factor authentication at activation, mandatory justification, approval workflows, and configurable activation durations.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Privileged Identity Management (PIM) provides just-in-time (JIT) activation workflows for privileged Entra ID and Microsoft 365 roles, enabling time- bound access with approval and justification requirements. Rather than holding permanent role assignments, users are made eligible for a role and must explicitly activate it when needed. PIM supports requiring multi-factor authentication at activation, mandatory justification, approval workflows, and configurable activation durations.", + "RationaleStatement": "Permanent active role assignments expose privileged access continuously, regardless of whether a user is actively performing administrative tasks. If a permanently privileged account is compromised, an attacker immediately holds full role permissions with no time boundary. PIM eliminates standing privilege by requiring users to explicitly activate role assignments, scoping elevated access to a defined duration and requiring justification and, optionally, approval. This reduces the window of opportunity for both external attackers and insider threats to exploit privileged access.", + "ImpactStatement": "The implementation of Just in Time privileged access is likely to necessitate changes to administrator routine. Administrators will only be granted access to administrative roles when required. When administrators request role activation, they will need to document the reason for requiring role access, anticipated time required to have the access, and to reauthenticate to enable role access. Note: If all global admins become eligible then there will be no global admin to receive notifications, by default. Alerts are sent to TenantAdmins, including Global Administrators, by default. To ensure proper receipt, configure alerts to be sent to security or operations staff with valid email addresses or a security operations center. Otherwise, after adoption of this recommendation, alerts sent to TenantAdmins may go unreceived due to the lack of a licensed permanently active Global Administrator.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Roles & admins and select All roles. 3. For each user or group role assignment that is out of compliance: o Click on the role to open it in PIM. o Select the Active assignments tab. o Under action click Update or Remove. - If Update is selected, set the Assignment type to Eligible and click Save. - If Remove is selected, the assignment will be removed and the principal will no longer hold the role. 4. For each privileged role with a non-compliant service principal active assignment: 1. Open the Active assignments tab. 2. Click Update to modify the service principal assignment. 3. Uncheck Permanently assigned and set an appropriate end time to create a time-bound assignment based on business needs. 4. Click Save to apply the changes. 5. Repeat for any other privileged role assignments that are out of compliance. Note: CIS Safeguard 6.8, Define and Maintain Role-Based Access Control, recommends reviewing access on a recurring schedule, at least annually and more frequently as needed. This practice is strongly encouraged for service principals when defining time-bound assignments.", + "AuditProcedure": "Note: There is no programmatic way to reliably determine whether a principal is a designated break-glass account. Global Administrator assignments require manual review and judgment to confirm that any permanent assignments belong exclusively to the organization's two approved break-glass accounts. To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Entra ID > Roles & admins and select All roles. 3. Select Add filters and apply the Privileged filter to scope the review to built- in and custom privileged roles. 4. For each PRIVILEGED role that has one or more assignments, perform the following review of active assignments: 1. Select the role to open it. 2. Open the Active assignments tab. 3. For each assignment where Type is User or Group, verify that State is Activated. - A State of Assigned is permitted only for approved break-glass accounts assigned to the Global Administrator role, and no more than 2 such accounts may hold this permanent assignment. 4. For each assignment where Type is Service principal, verify that State is Assigned and an End time is designated. 5. Compliance is met when all privileged role assignments meet the verification requirements in step 4. Usage of PIM for management of other administrator roles is recommended but not required for compliance. To audit using Microsoft Graph API: 1. Execute a GET request to the following relative URI to retrieve privileged roles (custom or built-in): beta/roleManagement/directory/roleDefinitions?$filter=isPrivileged eq true&$select=id,displayName,isPrivileged,isBuiltIn,isEnabled 2. Execute a GET request to the following relative URI to retrieve all instances of active role assignments: v1.0/roleManagement/directory/roleAssignmentScheduleInstances?$expand=princip al 3. Correlate by matching each assignment instance's roleDefinitionId to the privileged role list's id to produce a list of privileged role assignment instances. 4. For each privileged role assignment instance, verify the appropriate condition for the principal type: o For users and groups (principal@odata.type is #microsoft.graph.user or #microsoft.graph.group), verify that assignmentType is Activated. An assignmentType of Assigned is permitted only for approved break-glass accounts assigned to the Global Administrator role, and no more than 2 such accounts may hold this permanent assignment. o For service principals (principal@odata.type is #microsoft.graph.servicePrincipal), verify that assignmentType is Assigned and endDateTime is defined (time-bound assignment). 5. Compliance is met when all privileged role assignments meet the verification requirements in step 4. Usage of PIM for management of other administrator roles is recommended but not required for compliance.", + "AdditionalInformation": "In addition to enforcing just-in-time activation for active privileged role assignments, organizations are encouraged to periodically review eligible PIM role assignments to confirm ongoing business justification and remove stale entries. Annual review at minimum is recommended. This is a governance process that requires manual judgment and is outside the scope of the automated compliance check for this recommendation.", + "DefaultValue": "Without Privileged Identity Management configured, all privileged role assignments are permanent active assignments with no expiration or activation requirement.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure" + } + ] + }, + { + "Id": "5.3.2", + "Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization. When configured for guest users, access reviews can automatically remove access if no reviewer responds within the review period, enforcing a fail-closed posture for external identities.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization. When configured for guest users, access reviews can automatically remove access if no reviewer responds within the review period, enforcing a fail-closed posture for external identities.", + "RationaleStatement": "Access to groups and applications for guests can change over time. If a guest user's access to a particular resource goes unnoticed, they may unintentionally gain access to sensitive data if a member adds new files or data to the resource. Access reviews can help reduce the risks associated with outdated assignments by requiring a member of the organization to conduct the reviews. Furthermore, these reviews can enable a fail- closed mechanism to remove access to the subject if the reviewer does not respond to the review.", + "ImpactStatement": "Legitimate guest users may lose access to resources if designated reviewers fail to respond within the review window, requiring re-invitation and re-provisioning of access. Organizations with a large number of Microsoft 365 groups may face significant reviewer workload from monthly review cycles, which can lead to approval fatigue. - Microsoft Entra ID Governance licensing (included in Microsoft 365 E5) is required to configure access reviews. - As of January 15, 2026, a linked Azure subscription is required to use Entra ID Governance features for guest users.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance and select Access reviews 3. Click New access review. 4. In the Resource review box click Select. 5. In Review Type set the following: o Select what to review choose Teams + Groups. o Review Scope to All Microsoft 365 groups with guest users. o Scope to Guest users only then click Next: Reviews. 6. In Reviews set the following: o Select reviewers to Group members or Selected users and groups, ensuring that at least one reviewer is assigned and that the guest is not performing a self-review. o Duration (in days) to 14 days or less. o Review recurrence to Monthly or more frequent. o Start date for the review, ensuring the review becomes active before the next audit date. o End to Never, then click Next: Settings. 7. In Settings set the following: o Auto apply results to resource is checked. o If reviewers don't respond to Remove access o Justification required is checked. o E-mail notifications is checked. o Reminders is checked. o Click Next: Review + Create 8. Click Create. To remediate using the Microsoft Graph API: To create a new access review, execute a POST request to the following relative URI. To update an existing review, execute a PATCH request to the same URI appended with the review's id: v1.0/identityGovernance/accessReviews/definitions The request body must include properties that satisfy the audit criteria above. The Graph API documentation provides complete sample request bodies in multiple languages including HTTP, PowerShell, and Python. See Reference 3 for details.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance and select Access reviews 3. Inspect the access reviews, and verify an access review is created with the following criteria: o Overview: Scope is set to Guest users only and Review status is Active o Reviewers: At least one reviewer is assigned, and the reviewer is not a guest user. o Settings > General: - Mail notifications is set to Enable - Reminders is set to Enable o Settings > Reviewers: - Require reason on approval is set to Enable o Settings > Scheduling: - Frequency is set to Monthly or more frequent - Duration (in days) does not exceed 14 days - End is set to Never o Settings > When completed: - Auto apply results to resource is set to Enable - If reviewers don't respond is set to Remove access 4. The control is compliant if there is at least one access review for guests that meets all criteria above. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identityGovernance/accessReviews/definitions 2. Apply the following filters to identify access reviews targeting guest users: o scope.resourceScopes.query matches the pattern userType eq 'Guest' OR o scope.query matches the pattern userType eq 'Guest' 3. For each review that passes the filters above, verify the following criteria: o Recurrence is configured as monthly or more frequent: - recurrence.pattern.type is absoluteMonthly OR - recurrence.pattern.type is weekly o reviewers is not empty o settings.mailNotificationsEnabled is true o settings.instanceDurationInDays is 14 days or less o settings.reminderNotificationsEnabled is true o settings.justificationRequiredOnApproval is true o settings.autoApplyDecisionsEnabled is true o settings.defaultDecision is Deny o settings.recurrence.range.type is noEnd 4. The control is compliant if there is at least one access review for guests that meets all criteria above.", + "AdditionalInformation": "", + "DefaultValue": "By default access reviews are not configured.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/access-reviews-overview:https://learn.microsoft.com/en-us/entra/id-governance/create-access-review:https://learn.microsoft.com/en-us/graph/api/resources/accessreviewscheduledefinition?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.3.3", + "Description": "Access reviews in Microsoft Entra Privileged Identity Management (PIM) enable administrators to periodically validate whether users still require their privileged role assignments. These reviews can be scheduled to recur on a regular cadence and can be delegated to reviewers other than the role holders themselves, such as security auditors.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Access reviews in Microsoft Entra Privileged Identity Management (PIM) enable administrators to periodically validate whether users still require their privileged role assignments. These reviews can be scheduled to recur on a regular cadence and can be delegated to reviewers other than the role holders themselves, such as security auditors.", + "RationaleStatement": "Regular review of critical high privileged roles in Entra ID will help identify role drift, or potential malicious activity. This will enable the practice and application of \"separation of duties\" where even non-privileged users like security auditors can be assigned to review assigned roles in an organization. These reviews can optionally be configured to automatically remove access if a reviewer does not respond within the review window, though this recommendation conservatively sets non-response to result in no change to avoid inadvertent removal of privileged accounts including break-glass accounts.", + "ImpactStatement": "In order to avoid disruption reviewers who have the authority to revoke roles should be trusted individuals who understand the significance of access reviews. Additionally, the principle of separation of duties should be applied to ensure that no administrator is responsible for reviewing their own access levels. This will cause additional administrative overhead. If the reviews are configured to automatically revoke highly privileged roles like the Global Administrator role, then this could result in removing all Global Administrators from the organization. Care should be taken when configuring this setting especially in the case of break-glass accounts which would be included in the scope. - Microsoft Entra ID Governance licensing (included in Microsoft 365 E5) is required to configure access reviews.", + "RemediationProcedure": "Note: An access review is created for each role selected after completing the process. To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance > Privileged Identity Management. 3. Select Microsoft Entra Roles under Manage. 4. Select Access reviews and click New. o Provide a name and description. o Set Frequency to Monthly or more frequently. o Set Duration (in days) to at most 14. o Set End to Never. o Set Users scope to All users and groups. o In Role select the directory roles outlined in the Additional Information section. o Set Assignment type to All active and eligible assignments. o Set Reviewers to member(s) responsible for this type of review, other than self. 5. In Upon completion settings set the following: o Auto apply results to resource to Enable. o If reviewers don't respond to No change. 6. In Advanced settings set the following: o Require reason on approval to Enable o Mail notifications to Enable o Reminders to Enable 7. Click Start to save and begin the review series. Warning: Care should be taken when configuring the If reviewers don't respond setting for Global Administrator reviews, if misconfigured break-glass accounts could automatically have roles revoked. Additionally, reviewers should be educated on the purpose of break-glass accounts to prevent accidental manual removal of roles. To remediate using the Microsoft Graph API: To create a new access review, execute a POST request to the following relative URI. To update an existing review, execute a PATCH request to the same URI appended with the review's id: v1.0/identityGovernance/accessReviews/definitions The request body must include properties that satisfy the audit criteria above. The Graph API documentation provides complete sample request bodies in multiple languages including HTTP, PowerShell, and Python. See Reference 3 for details", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/ 2. Expand ID Governance > Privileged Identity Management. 3. Select Microsoft Entra Roles under Manage. 4. Select Access reviews 5. For each privileged role listed in the Additional Information section, verify an access review exists that meets the following criteria: o Overview: - Role assignment type is set to Eligible and Active - Scope is set to Everyone - Review status is Active o Reviewers: At least one reviewer is assigned, and the reviewer is not self- reviewing. o Settings > General: - Mail notifications is set to Enable - Reminders is set to Enable o Settings > Reviewers: - Require reason on approval is set to Enable o Settings > Scheduling: - Frequency is set to Monthly or more frequently - Duration (in days) does not exceed 14 days - End is set to Never o Settings > When completed: - Auto apply results to resource is set to Enable - If reviewers don't respond is set to No change To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI: v1.0/identityGovernance/accessReviews/definitions 2. Apply the following filters to identify relevant access review definitions: o scope.resourceScopes.query matches the pattern /directory/roleDefinitions/ o scope.resourceScopes.query matches the GUID of one of the 6 privileged directory roles outlined in the Additional Information section. 3. For each review that passes the filters above, verify the following criteria: o Scoped to Eligible and Active role assignments: - scope.principalScopes.query contains /v1.0/users AND - scope.principalScopes.query contains /v1.0/groups o Recurrence is configured as monthly or more frequent: - recurrence.pattern.type is absoluteMonthly OR - recurrence.pattern.type is weekly - recurrence.range.startDate is in the past relative to the audit date o reviewers is not empty o settings.mailNotificationsEnabled is true o settings.reminderNotificationsEnabled is true o settings.justificationRequiredOnApproval is true o settings.instanceDurationInDays is less than or equal to 14 o settings.autoApplyDecisionsEnabled is true o settings.defaultDecision is None 4. The control is compliant when all 6 privileged directory roles have an associated access review definition that meets all the criteria listed above. Note: The 6 roles referenced and their associated GUIDs can be found in the Additional Information section.", + "AdditionalInformation": "The 6 privileged directory roles referenced in the audit and remediation procedures and their associated GUIDs are as follows: Role Name Role Definition GUID Global Administrator 62e90394-69f5-4237-9190-012177145e10 Privileged Role Administrator e8611ab8-c189-46e8-94e1-60213ab1f814 Exchange Administrator 29232cdf-9323-42fd-ade2-1d097af3e4de SharePoint Administrator f28a1f50-f6e7-4571-818b-6a12f2af6b6c Teams Administrator 69091246-20e8-4a56-aa4d-066075b2a7a8 Security Administrator 194ae4cb-b126-40b2-bd5b-6091b380977d", + "DefaultValue": "By default access reviews are not configured.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-create-roles-and-resource-roles-review:https://learn.microsoft.com/en-us/entra/id-governance/access-reviews-overview:https://learn.microsoft.com/en-us/graph/api/resources/accessreviewscheduledefinition?view=graph-rest-1.0" + } + ] + }, + { + "Id": "5.3.4", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Global Administrator role.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Global Administrator role.", + "RationaleStatement": "Requiring approval for Global Administrator role activation enhances visibility and accountability every time this highly privileged role is used. This process reduces the risk of an attacker elevating a compromised account to the highest privilege level, as any activation must first be reviewed and approved by a trusted party. Note: This only acts as protection for eligible users that are activating a role. Directly assigning a role does not require an approval workflow so therefore it is important to implement and use PIM correctly.", + "ImpactStatement": "Approvers do not need to be assigned the same role or be members of the same group. It's important to have at least two approvers and an emergency access (break-glass) account to prevent a scenario where no Global Administrators are available. For example, if the last active Global Administrator leaves the organization, and only eligible but inactive Global Administrators remain, a trusted approver without the Global Administrator role or an emergency access account would be essential to avoid delays in critical administrative tasks.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Global Administrator in the list. 6. Select Role settings and click Edit. 7. Check the Require approval to activate box. 8. Add at least one approver. 9. Click Update.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Global Administrator in the list. 6. Select Role settings. 7. Verify that Require approval to activate is set to Yes. 8. Verify there is at least 1 approvers in the list. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI to retrieve the policyID for the Global Administrator-role: v1.0/policies/roleManagementPolicyAssignments?$filter=scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '62e90394-69f5-4237- 9190-012177145e10'&$select=policyId 2. Execute a GET request to the following relative URI to retrieve the policy's Approval setting using the policyId from the previous call: v1.0/policies/roleManagementPolicies/{policyID}/rules/Approval_EndUser_Assign ment # Example https://graph.microsoft.com/v1.0/policies/roleManagementPolicies/DirectoryRol e_9c4d49a8-1f7a-4256-b1a2-b7cb0e7180f4_86522f3f-cfd0-4634-95a0- 38083127ca00/rules/Approval_EndUser_Assignment 3. Verify that isApprovalRequired is true. 4. Verify that approvalStages.primaryApprovers contains one or more valid users. Note: Approvers should be reviewed on a regular basis to ensure the members are active and valid.", + "AdditionalInformation": "", + "DefaultValue": "Require approval to activate : No.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure:https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/groups-role-settings#require-approval-to-activate" + } + ] + }, + { + "Id": "5.3.5", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Privileged Role Administrator role.", + "Checks": [], + "Attributes": [ + { + "Section": "5 Microsoft Entra admin center", + "SubSection": "5.3 ID Governance", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Privileged Role Administrator role.", + "RationaleStatement": "This role grants the ability to manage assignments for all Microsoft Entra roles including the Global Administrator role. This role does not include any other privileged abilities in Microsoft Entra ID like creating or updating users. However, users assigned to this role can grant themselves or others additional privilege by assigning additional roles. Requiring approval for activation enhances visibility and accountability every time this highly privileged role is used. This process reduces the risk of an attacker elevating a compromised account to the highest privilege level, as any activation must first be reviewed and approved by a trusted party. Note: This only acts as protection for eligible users that are activating a role. Directly assigning a role does not require an approval workflow so therefore it is important to implement and use PIM correctly.", + "ImpactStatement": "Requiring approvers for automatic role assignment can slightly increase administrative overhead and add delays to tasks.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Privileged Role Administrator in the list. 6. Select Role settings and click Edit. 7. Check the Require approval to activate box. 8. Add at least one approver. 9. Click Update.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand ID Governance > Privileged Identity Management. 3. Under Manage select Microsoft Entra Roles. 4. Under Manage select Roles. 5. Select Privileged Role Administrator in the list. 6. Select Role settings. 7. Verify Require approval to activate is set to Yes. 8. Verify there is at least one approvers in the list. To audit using the Microsoft Graph API: 1. Execute a GET request to the following relative URI to retrieve the policyID for the Privileged Role Administrator-role: v1.0/policies/roleManagementPolicyAssignments?$filter=scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq 'e8611ab8-c189-46e8- 94e1-60213ab1f814'&$select=policyId 2. Execute a GET request to the following relative URI to retrieve the policy's Approval setting using the policyId from the previous call: v1.0/policies/roleManagementPolicies/{policyID}/rules/Approval_EndUser_Assign ment # Example https://graph.microsoft.com/v1.0/policies/roleManagementPolicies/DirectoryRol e_d1fdbf46-5729-4c53-a951-7ab677be380f_3679e0d0-412a-444d-b517- ab23973d6067/rules/Approval_EndUser_Assignment 3. Verify that isApprovalRequired is true. 4. Verify that approvalStages.primaryApprovers contains one or more valid users. Note: Approvers should be reviewed on a regular basis to ensure the members are active and valid.", + "AdditionalInformation": "", + "DefaultValue": "Require approval to activate : No.", + "References": "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure:https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/groups-role-settings#require-approval-to-activate" + } + ] + }, + { + "Id": "6.1.1", + "Description": "The value False indicates that mailbox auditing on by default is turned on for the organization. Mailbox auditing on by default in the organization overrides the mailbox auditing settings on individual mailboxes. For example, if mailbox auditing is turned off for a mailbox (the AuditEnabled property on the mailbox is False), the default mailbox actions are still audited for the mailbox, because mailbox auditing on by default is turned on for the organization. Turning off mailbox auditing on by default ($true) has the following results: - Mailbox auditing is turned off for your organization. - From the time you turn off mailbox auditing on by default, no mailbox actions are audited, even if mailbox auditing is enabled on a mailbox (the AuditEnabled property on the mailbox is True). - Mailbox auditing isn't turned on for new mailboxes and setting the AuditEnabled property on a new or existing mailbox to True is ignored. - Any mailbox audit bypass association settings (configured by using the Set- MailboxAuditBypassAssociation cmdlet) are ignored. - Existing mailbox audit records are retained until the audit log age limit for the record expires. The recommended state for this setting is False at the organization level. This will enable auditing and enforce the default.", + "Checks": [ + "exchange_organization_mailbox_auditing_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The value False indicates that mailbox auditing on by default is turned on for the organization. Mailbox auditing on by default in the organization overrides the mailbox auditing settings on individual mailboxes. For example, if mailbox auditing is turned off for a mailbox (the AuditEnabled property on the mailbox is False), the default mailbox actions are still audited for the mailbox, because mailbox auditing on by default is turned on for the organization. Turning off mailbox auditing on by default ($true) has the following results: - Mailbox auditing is turned off for your organization. - From the time you turn off mailbox auditing on by default, no mailbox actions are audited, even if mailbox auditing is enabled on a mailbox (the AuditEnabled property on the mailbox is True). - Mailbox auditing isn't turned on for new mailboxes and setting the AuditEnabled property on a new or existing mailbox to True is ignored. - Any mailbox audit bypass association settings (configured by using the Set- MailboxAuditBypassAssociation cmdlet) are ignored. - Existing mailbox audit records are retained until the audit log age limit for the record expires. The recommended state for this setting is False at the organization level. This will enable auditing and enforce the default.", + "RationaleStatement": "Enforcing the default ensures auditing was not turned off intentionally or accidentally. Auditing mailbox actions will allow forensics and IR teams to trace various malicious activities that can generate TTPs caused by inbox access and tampering.", + "ImpactStatement": "None - this is the default behavior as of 2019.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OrganizationConfig -AuditDisabled $false", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | Format-List AuditDisabled 3. Verify that AuditDisabled is set to False.", + "AdditionalInformation": "", + "DefaultValue": "False", + "References": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide:https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps#-auditdisabled" + } + ] + }, + { + "Id": "6.1.2", + "Description": "Mailbox audit logging is turned on by default in all organizations. This effort started in January 2019, and means that certain actions performed by mailbox owners, delegates, and admins are automatically logged. The corresponding mailbox audit records are available for admins to search in the mailbox audit log. Mailboxes and shared mailboxes have actions assigned to them individually in order to audit the data the organization determines valuable at the mailbox level. The recommended state per mailbox is AuditEnabled to True including all default audit actions with additional actions outlined below in the audit and remediation sections. Note: Audit (Standard) licensing allows for up to 180 days log retention as of October 2023.", + "Checks": [ + "exchange_user_mailbox_auditing_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Mailbox audit logging is turned on by default in all organizations. This effort started in January 2019, and means that certain actions performed by mailbox owners, delegates, and admins are automatically logged. The corresponding mailbox audit records are available for admins to search in the mailbox audit log. Mailboxes and shared mailboxes have actions assigned to them individually in order to audit the data the organization determines valuable at the mailbox level. The recommended state per mailbox is AuditEnabled to True including all default audit actions with additional actions outlined below in the audit and remediation sections. Note: Audit (Standard) licensing allows for up to 180 days log retention as of October 2023.", + "RationaleStatement": "Whether it is for regulatory compliance or for tracking unauthorized configuration changes in Microsoft 365, enabling mailbox auditing and ensuring the proper mailbox actions are accounted for allows for Microsoft 365 teams to run security operations, forensics or general investigations on mailbox activities. The following mailbox types ignore the organizational default and must have AuditEnabled set to True at the mailbox level in order to capture relevant audit data. - Resource Mailboxes - Public Folder Mailboxes - DiscoverySearch Mailbox", + "ImpactStatement": "Adding additional audit action types and increasing the AuditLogAgeLimit from 90 to 180 days will have a limited impact on mailbox storage. Mailbox audit log records are stored in a subfolder (named Audits) in the Recoverable Items folder in each user's mailbox. - Mailbox audit records count against the storage quota of the Recoverable Items folder. - Mailbox audit records also count against the folder limit for the Recoverable Items folder. A maximum of 3 million items (audit records) can be stored in the Audits subfolder. The following cmdlet in Exchange Online PowerShell can be run to display the size and number of items in the Audits subfolder in the Recoverable Items folder: Get-MailboxFolderStatistics -Identity -FolderScope RecoverableItems | Where-Object {$_.Name -eq 'Audits'} | Format-List FolderPath,FolderSize,ItemsInFolder Note: It's unlikely that mailbox auditing on by default impacts the storage quota or the folder limit for the Recoverable Items folder.", + "RemediationProcedure": "For each UserMailbox ensure AuditEnabled is True and the following audit actions are included in addition to default actions of each sign-in type. - Admin actions: Copy, FolderBind and Move. - Delegate actions: FolderBind and Move. - Owner actions: Create, MailboxLogin and Move. Note: The defaults can be found in the Default Value section and the combined total can be found in the scripts of the Audit/Remediation sections. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell script to remediate every 'UserMailbox' in the organization: $AuditAdmin = @( \"ApplyRecord\", \"Copy\", \"Create\", \"FolderBind\", \"HardDelete\", \"MailItemsAccessed\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $AuditDelegate = @( \"ApplyRecord\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $AuditOwner = @( \"ApplyRecord\", \"Create\", \"HardDelete\", \"MailboxLogin\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $MBX = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.RecipientTypeDetails -eq \"UserMailbox\" } $MBX | Set-Mailbox -AuditEnabled $true ` -AuditLogAgeLimit 180 -AuditAdmin $AuditAdmin -AuditDelegate $AuditDelegate ` -AuditOwner $AuditOwner 3. The script will apply the prescribed Audit Actions for each sign-in type (Owner, Delegate, Admin) and the AuditLogAgeLimit to each UserMailbox in the organization. Note: Mailboxes with Audit (Premium) licenses, which is included with E5, can retain audit logs beyond 180 days.", + "AuditProcedure": "Inspect each UserMailbox and ensure AuditEnabled is True and the following audit actions are included in addition to default actions of each sign-in type. - Admin actions: Copy, FolderBind and Move. - Delegate actions: FolderBind and Move. - Owner actions: Create, MailboxLogin and Move. Note: The defaults can be found in the Default Value section and the combined total can be found in the scripts of the Audit/Remediation sections. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell script: $AdminActions = @( \"ApplyRecord\", \"Copy\", \"Create\", \"FolderBind\", \"HardDelete\", \"MailItemsAccessed\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $DelegateActions = @( \"ApplyRecord\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) $OwnerActions = @( \"ApplyRecord\", \"Create\", \"HardDelete\", \"MailboxLogin\", \"Move\", \"MailItemsAccessed\", \"MoveToDeletedItems\", \"Send\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\" ) function VerifyActions { param ( [array]$ExpectedActions, [array]$ActualActions ) $Missing = $ExpectedActions | Where-Object { $_ -notin $ActualActions } return $Missing } $MBX = Get-EXOMailbox -PropertySets Audit, Minimum -ResultSize Unlimited | Where-Object { $_.RecipientTypeDetails -eq \"UserMailbox\" } $Results = foreach ($mailbox in $MBX) { $AdminMissing = VerifyActions -ExpectedActions $AdminActions ` -ActualActions $mailbox.AuditAdmin $DelegateMissing = VerifyActions -ExpectedActions $DelegateActions ` -ActualActions $mailbox.AuditDelegate $OwnerMissing = VerifyActions -ExpectedActions $OwnerActions ` -ActualActions $mailbox.AuditOwner $IsCompliant = $AdminMissing.Count -eq 0 -and $DelegateMissing.Count -eq 0 -and $OwnerMissing.Count -eq 0 -and $mailbox.AuditEnabled [PSCustomObject]@{ Mailbox = $mailbox.UserPrincipalName AuditEnabled = $mailbox.AuditEnabled AdminMissing = if ($AdminMissing.Count -gt 0) { $AdminMissing -join \", \" } else { \"None\" } DelegateMissing = if ($DelegateMissing.Count -gt 0) { $DelegateMissing -join \", \" } else { \"None\" } OwnerMissing = if ($OwnerMissing.Count -gt 0) { $OwnerMissing -join \", \" } else { \"None\" } ComplianceState = if ($IsCompliant) { \"Compliant\" } else { \"Non-Compliant\" } } } # Display results in table format $Results | Format-Table -AutoSize <# Optional: Export methods $Results | Out-GridView -Title \"Mailbox Audit Results\" $Results | Export-Csv -Path \"6.1.2.csv\" -NoTypeInformation $Results | ConvertTo-Json | Out-File -FilePath \"6.1.2.json\" #> 3. Inspect the results. Mailboxes will be labeled as either Compliant or Non- compliant, accompanied by supporting details that outline the missing actions for each type and the current state of AuditEnabled. Optional methods for exporting the data to CSV, JSON, or GridView are also shown at the end of the script. Note: Mailboxes with Audit (Premium) licenses, which is included with E5, can retain audit logs beyond 180 days.", + "AdditionalInformation": "", + "DefaultValue": "AuditEnabled: True for all mailboxes except below: - Resource Mailboxes - Public Folder Mailboxes - DiscoverySearch Mailbox AuditAdmin: ApplyRecord, Create, HardDelete, MailItemsAccessed, MoveToDeletedItems, Send, SendAs, SendOnBehalf, SoftDelete, Update, UpdateCalendarDelegation, UpdateFolderPermissions, UpdateInboxRules AuditDelegate: ApplyRecord, Create, HardDelete, MailItemsAccessed, MoveToDeletedItems, SendAs, SendOnBehalf, SoftDelete, Update, UpdateFolderPermissions, UpdateInboxRules AuditOwner: ApplyRecord, HardDelete, MailItemsAccessed, MoveToDeletedItems, Send, SoftDelete, Update, UpdateCalendarDelegation, UpdateFolderPermissions, UpdateInboxRules", + "References": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide" + } + ], + "ConfigRequirements": [ + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90 + } + ] + }, + { + "Id": "6.1.3", + "Description": "When configuring a user or computer account to bypass mailbox audit logging, the system will not record any access, or actions performed by the said user or computer account on any mailbox. Administratively this was introduced to reduce the volume of entries in the mailbox audit logs on trusted user or computer accounts. Ensure AuditBypassEnabled is not enabled on accounts without a written exception.", + "Checks": [ + "exchange_mailbox_audit_bypass_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.1 Audit", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "When configuring a user or computer account to bypass mailbox audit logging, the system will not record any access, or actions performed by the said user or computer account on any mailbox. Administratively this was introduced to reduce the volume of entries in the mailbox audit logs on trusted user or computer accounts. Ensure AuditBypassEnabled is not enabled on accounts without a written exception.", + "RationaleStatement": "If a mailbox audit bypass association is added for an account, the account can access any mailbox in the organization to which it has been assigned access permissions, without generating any mailbox audit logging entries for such access or recording any actions taken, such as message deletions. Enabling this parameter, whether intentionally or unintentionally, could allow insiders or malicious actors to conceal their activity on specific mailboxes. Ensuring proper logging of user actions and mailbox operations in the audit log will enable comprehensive incident response and forensics.", + "ImpactStatement": "None - this is the default behavior.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. The following example PowerShell script will disable AuditBypass for all mailboxes which currently have it enabled: # Get mailboxes with AuditBypassEnabled set to $true $MBXAudit = Get-MailboxAuditBypassAssociation -ResultSize unlimited | Where- Object { $_.AuditBypassEnabled -eq $true } foreach ($mailbox in $MBXAudit) { $mailboxName = $mailbox.Name Set-MailboxAuditBypassAssociation -Identity $mailboxName - AuditBypassEnabled $false Write-Host \"Audit Bypass disabled for mailbox Identity: $mailboxName\" - ForegroundColor Green }", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: $MBXData = Get-MailboxAuditBypassAssociation -ResultSize unlimited $Report = $MBXData | ? {$_.AuditBypassEnabled -eq $true} | select Name,AuditBypassEnabled $Report <# Optional: Export methods $Report | Out-GridView -Title \"Mailbox Audit Bypass Association\" $Report | Export-Csv -Path \"6.1.3.csv\" -NoTypeInformation #> 3. If nothing is returned, then there are no accounts with Audit Bypass enabled. Note: The cmdlet Get-MailboxAuditBypassAssociation may display a WARNING on system objects that begin with \"Asc-2X1\", this is not part of the Audit procedure and can be ignored.", + "AdditionalInformation": "", + "DefaultValue": "AuditBypassEnabled False", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailboxauditbypassassociation?view=exchange-ps" + } + ] + }, + { + "Id": "6.2.1", + "Description": "Exchange Online offers several methods of managing the flow of email messages. These are Remote domain, Transport Rules, and Anti-spam outbound policies. These methods work together to provide comprehensive coverage for potential automatic forwarding channels: - Outlook forwarding using inbox rules. - Outlook forwarding configured using OOF rule. - OWA forwarding setting (ForwardingSmtpAddress). - Forwarding set by the admin using EAC (ForwardingAddress). - Forwarding using Power Automate / Flow. Ensure a Transport rule and Anti-spam outbound policy are used to block mail forwarding. NOTE: Any exclusions should be implemented based on organizational policy.", + "Checks": [ + "exchange_transport_rules_mail_forwarding_disabled", + "defender_antispam_outbound_policy_forwarding_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.2 Mail flow", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Exchange Online offers several methods of managing the flow of email messages. These are Remote domain, Transport Rules, and Anti-spam outbound policies. These methods work together to provide comprehensive coverage for potential automatic forwarding channels: - Outlook forwarding using inbox rules. - Outlook forwarding configured using OOF rule. - OWA forwarding setting (ForwardingSmtpAddress). - Forwarding set by the admin using EAC (ForwardingAddress). - Forwarding using Power Automate / Flow. Ensure a Transport rule and Anti-spam outbound policy are used to block mail forwarding. NOTE: Any exclusions should be implemented based on organizational policy.", + "RationaleStatement": "Attackers often create these rules to exfiltrate data from your tenancy, this could be accomplished via access to an end-user account or otherwise. An insider could also use one of these methods as a secondary channel to exfiltrate sensitive data.", + "ImpactStatement": "Care should be taken before implementation to ensure there is no business need for case-by-case auto-forwarding. Disabling auto-forwarding to remote domains will affect all users in an organization. Any exclusions should be implemented based on organizational policy.", + "RemediationProcedure": "Note: Remediation is a two step procedure as follows: STEP 1: Transport rules To remediate using the UI: 1. Select Exchange to open the Exchange admin center. 2. Select Mail Flow then Rules. 3. For each rule that redirects email to external domains, select the rule and click the 'Delete' icon. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Remove-TransportRule {RuleName} STEP 2: Anti-spam outbound policy To remediate using the UI: 1. Navigate to Microsoft 365 Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration then select Policies & rules. 3. Select Threat policies > Anti-spam. 4. Select Anti-spam outbound policy (default) 5. Click Edit protection settings 6. Set Automatic forwarding rules dropdown to Off - Forwarding is disabled and click Save 7. Repeat steps 4-6 for any additional higher priority, custom policies. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-HostedOutboundSpamFilterPolicy -Identity {policyName} -AutoForwardingMode Off 3. To remove AutoForwarding from all outbound policies you can also run: Get-HostedOutboundSpamFilterPolicy | Set-HostedOutboundSpamFilterPolicy - AutoForwardingMode Off", + "AuditProcedure": "Note: Audit is a two step procedure as follows: STEP 1: Transport rules To audit using the UI: 1. Select Exchange to open the Exchange admin center. 2. Select Mail Flow then Rules. 3. Review the rules and verify that none of them are forwards or redirects e-mail to external domains. To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command to review the Transport Rules that are redirecting email: Get-TransportRule | Where-Object {$_.RedirectMessageTo -ne $null} | ft Name,RedirectMessageTo 3. Verify that none of the addresses listed belong to external domains outside of the organization. If nothing returns then there are no transport rules set to redirect messages. STEP 2: Anti-spam outbound policy To audit using the UI: 1. Navigate to Microsoft 365 Defender https://security.microsoft.com/ 2. Expand E-mail & collaboration then select Policies & rules. 3. Select Threat policies > Anti-spam. 4. Inspect Anti-spam outbound policy (default) and ensure Automatic forwarding is set to Off - Forwarding is disabled 5. Inspect any additional custom outbound policies and ensure Automatic forwarding is set to Off - Forwarding is disabled, in accordance with the organization's exclusion policies. To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell cmdlet: Get-HostedOutboundSpamFilterPolicy | ft Name, AutoForwardingMode 3. In each outbound policy verify AutoForwardingMode is Off. Note: According to Microsoft if a recipient is defined in multiple policies of the same type (anti-spam, anti-phishing, etc.), only the policy with the highest priority is applied to the recipient. Any remaining policies of that type are not evaluated for the recipient (including the default policy). However, it is our recommendation to audit the default policy as well in the case a higher priority custom policy is removed. This will keep the organization's security posture strong.", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules:https://techcommunity.microsoft.com/t5/exchange-team-blog/all-you-need-to-know-about-automatic-email-forwarding-in/ba-p/2074888#:~:text=%20%20%20Automatic%20forwarding%20option%20%20,%:https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-policies-external-email-forwarding?view=o365-worldwide" + } + ] + }, + { + "Id": "6.2.2", + "Description": "Mail flow rules (transport rules) in Exchange Online can be configured to set the spam confidence level (SCL) of a message to -1, which bypasses spam and phishing filtering. When a rule applies this action to messages based on the sender's domain, all mail from that domain is treated as trusted and skips anti-malware and anti-phishing evaluation regardless of message content.", + "Checks": [ + "exchange_transport_rules_whitelist_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.2 Mail flow", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Mail flow rules (transport rules) in Exchange Online can be configured to set the spam confidence level (SCL) of a message to -1, which bypasses spam and phishing filtering. When a rule applies this action to messages based on the sender's domain, all mail from that domain is treated as trusted and skips anti-malware and anti-phishing evaluation regardless of message content.", + "RationaleStatement": "Whitelisting domains in transport rules bypasses regular malware and phishing scanning, which can enable an attacker to launch attacks against your users from a safe haven domain. Note: If an organization identifies a business need for an exception, the domain should only be whitelisted if inbound emails from that domain originate from a specific IP address. These exceptions should be documented and regularly reviewed.", + "ImpactStatement": "Removing SCL bypass rules will subject previously whitelisted domains to standard spam and phishing filtering. Mail from those domains that does not pass filtering may be quarantined or rejected, which could disrupt established business communications. Prior to removal, identify any rules in scope and coordinate with affected business owners. If a legitimate need exists, consider replacing domain-based whitelisting with approved sender lists at the connection level.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com 2. Click to expand Mail Flow and then select Rules. 3. For each rule that sets the spam confidence level to -1 for a specific domain, select the rule and click Delete. To remediate using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. To remove a specific non-compliant rule: Remove-TransportRule -Identity \"RuleName\" Note: If the rule serves a legitimate purpose beyond domain whitelisting, consider modifying it to remove the SenderDomainIs condition or the SetSCL -1 action rather than deleting it entirely.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com 2. Click to expand Mail Flow and then select Rules. 3. Review each rule and ensure that a single rule does not contain both of these properties together: o Under Apply this rule if: Sender's address domain portion belongs to any of these domains: '' o Under Do the following: Set the spam confidence level (SCL) to '-1' Note: Setting the spam confidence level to -1 indicates the message is from a trusted sender, so the message bypasses spam filtering. The recommendation fails if any external domain has a SCL of -1. To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-TransportRule | Where-Object { $_.setscl -eq -1 -and $_.SenderDomainIs - ne $null } | ft Name,SenderDomainIs,SetSCL 3. Transport rules that fail the audit will be shown. If no output is shown, the recommendation passes. To pass, all rules with SetSCL set to -1 must not include any domains in the SenderDomainIs property.", + "AdditionalInformation": "", + "DefaultValue": "No mail flow rules that set the SCL to -1 based on sender domain exist by default.", + "References": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/configuration-best-practices:https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules" + } + ] + }, + { + "Id": "6.2.3", + "Description": "External callouts provide a native experience to identify emails from senders outside the organization. This is achieved by presenting a new tag on emails called \"External\" (the string is localized based on the client language setting) and exposing related user interface at the top of the message reading view to see and verify the real sender's email address. The recommended state is ExternalInOutlook set to Enabled True", + "Checks": [ + "exchange_external_email_tagging_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.2 Mail flow", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "External callouts provide a native experience to identify emails from senders outside the organization. This is achieved by presenting a new tag on emails called \"External\" (the string is localized based on the client language setting) and exposing related user interface at the top of the message reading view to see and verify the real sender's email address. The recommended state is ExternalInOutlook set to Enabled True", + "RationaleStatement": "Tagging emails from external senders helps to inform end users about the origin of the email. This can allow them to proceed with more caution and make informed decisions when it comes to identifying spam or phishing emails. Mail flow rules are often used by Exchange administrators to accomplish the External email tagging by appending a tag to the front of a subject line. There are limitations to this outlined here. The preferred method in the CIS Benchmark is to use the native experience. Note: Existing emails in a user's inbox from external senders are not tagged retroactively.", + "ImpactStatement": "Mail flow rules using external tagging must be disabled, along with third-party mail filtering tools that offer similar features, to avoid duplicate [External] tags. External tags can consume additional screen space on systems with limited real estate, such as thin clients or mobile devices. After enabling this feature via PowerShell, it may take 24-48 hours for users to see the External sender tag in emails from outside your organization. Rolling back the feature takes the same amount of time. Note: Third-party tools that provide similar functionality will also meet compliance requirements, although Microsoft recommends using the native experience for better interoperability.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-ExternalInOutlook -Enabled $true", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-ExternalInOutlook 3. For each identity verify Enabled is set to True and the AllowList only contains email addresses the organization has permitted to bypass external tagging.", + "AdditionalInformation": "", + "DefaultValue": "Disabled (False)", + "References": "https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098:https://learn.microsoft.com/en-us/powershell/module/exchange/set-externalinoutlook?view=exchange-ps" + } + ] + }, + { + "Id": "6.3.1", + "Description": "Role assignment policies in Exchange Online control whether users can install and manage add-ins for Outlook. Three management roles govern this capability: My Custom Apps allows users to sideload custom add-ins, My Marketplace Apps allows users to install add-ins from the marketplace, and My ReadWriteMailbox Apps allows users to install add-ins that request read/write mailbox permissions. When these roles are assigned to a user's role assignment policy, users can self-install add-ins in both Outlook desktop and Outlook on the web, granting those add-ins access to mailbox data. This recommendation applies to the default role assignment policy, which is automatically assigned to new mailboxes unless a custom policy is specified.", + "Checks": [ + "exchange_roles_assignment_policy_addins_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.3 Roles", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Role assignment policies in Exchange Online control whether users can install and manage add-ins for Outlook. Three management roles govern this capability: My Custom Apps allows users to sideload custom add-ins, My Marketplace Apps allows users to install add-ins from the marketplace, and My ReadWriteMailbox Apps allows users to install add-ins that request read/write mailbox permissions. When these roles are assigned to a user's role assignment policy, users can self-install add-ins in both Outlook desktop and Outlook on the web, granting those add-ins access to mailbox data. This recommendation applies to the default role assignment policy, which is automatically assigned to new mailboxes unless a custom policy is specified.", + "RationaleStatement": "Attackers exploit vulnerable or malicious add-ins to read, exfiltrate, or modify mailbox content including email, calendar items, and contacts. Restricting user-installed add-ins reduces this attack surface and centralizes add-in approval with administrators.", + "ImpactStatement": "End users will be unable to self-install third-party Outlook add-ins. Administrators may receive requests to evaluate and deploy add-ins on behalf of users. Organizations that rely on user-deployed add-ins for business workflows should inventory those add-ins and deploy them centrally via Centralized Deployment before implementing this recommendation.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Click to expand Roles and select User roles. 3. Select Default Role Assignment Policy. 4. In the properties pane on the right click on Manage permissions. 5. Under Other roles uncheck any non-compliant roles: My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps. 6. Click Save changes. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script: $TargetRoles = @( \"My Custom Apps\", \"My Marketplace Apps\", \"My ReadWriteMailbox Apps\" ) $DefaultPolicy = Get-RoleAssignmentPolicy | Where-Object { $_.IsDefault -eq $true } $Assignments = Get-ManagementRoleAssignment -RoleAssignee $DefaultPolicy.Identity | Where-Object { $_.Role -in $TargetRoles } foreach ($Assignment in $Assignments) { Remove-ManagementRoleAssignment -Identity $Assignment.Identity - Confirm:$false }", + "AuditProcedure": "To audit using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Expand Roles and select User roles. 3. Select Default Role Assignment Policy. 4. In the properties pane on the right click on Manage permissions. 5. Under Other roles verify that My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps are not checked. Note: As of this release of the Benchmark the manage permissions link no longer displays anything when a user assigned the Global Reader role clicks on it. As an alternative, users assigned the Global Reader directory role can inspect the Roles column or use the PowerShell method to perform the audit. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following script: $RoleList = @( \"My Custom Apps\", \"My Marketplace Apps\", \"My ReadWriteMailbox Apps\" ) $DefaultPolicy = Get-RoleAssignmentPolicy | Where-Object { $_.IsDefault -eq $true } $NonCompliantRoles = $DefaultPolicy.AssignedRoles | Where-Object { $_ -in $RoleList } Write-Host \"Checking Default Role Assignment Policy: $($DefaultPolicy.Name)\" if ($NonCompliantRoles) { \"Non-compliant - the following roles are assigned: \" + ($NonCompliantRoles -join \", \") } else { \"Compliant - no add-in roles are assigned to the default policy.\" } 3. Verify that the output indicates compliance. If My Custom Apps, My Marketplace Apps, or My ReadWriteMailbox Apps are listed, the default policy is non-compliant.", + "AdditionalInformation": "", + "DefaultValue": "UI - My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps are checked. PowerShell - My Custom Apps, My Marketplace Apps, and My ReadWriteMailbox Apps are assigned.", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/add-ins-for-outlook/specify-who-can-install-and-manage-add-ins?source=recommendations:https://learn.microsoft.com/en-us/exchange/permissions-exo/role-assignment-policies" + } + ] + }, + { + "Id": "6.3.2", + "Description": "Outlook on the web (OWA) mailbox policies include two settings that control personal account integration in Outlook. PersonalAccountsEnabled controls whether users can add personal email accounts (e.g., Outlook.com, Gmail, Yahoo) in the new Outlook for Windows. PersonalAccountCalendarsEnabled controls whether users can connect personal Outlook.com or Google calendars in Outlook on the web. Neither setting applies to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. The recommended state for the default OWA Mailbox Policy is: - PersonalAccountsEnabled is set to False - PersonalAccountCalendarsEnabled is set to False", + "Checks": [], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.3 Roles", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Outlook on the web (OWA) mailbox policies include two settings that control personal account integration in Outlook. PersonalAccountsEnabled controls whether users can add personal email accounts (e.g., Outlook.com, Gmail, Yahoo) in the new Outlook for Windows. PersonalAccountCalendarsEnabled controls whether users can connect personal Outlook.com or Google calendars in Outlook on the web. Neither setting applies to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. The recommended state for the default OWA Mailbox Policy is: - PersonalAccountsEnabled is set to False - PersonalAccountCalendarsEnabled is set to False", + "RationaleStatement": "Personal email accounts are not subject to corporate security controls such as anti- malware scanning, data loss prevention (DLP), Safe Links, or audit logging. Allowing personal accounts alongside the corporate mailbox enables side-channel data exfiltration (e.g., forwarding sensitive content to a personal inbox) and creates an ingress path for malware and phishing payloads that bypass tenant mail-flow protections.", + "ImpactStatement": "This control does not apply to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. Organizations requiring broader coverage should evaluate additional controls like application management policies to restrict personal account usage on those clients. This also does not block users from accessing personal accounts via other email clients or web browsers. Changes to OWA mailbox policies may take up to 60 minutes to take effect. If users previously added personal accounts before this policy was applied, those accounts will be disabled once the policy is detected, and affected users will see a message advising them to remove the personal account from Outlook, which may generate helpdesk inquiries. The audit only applies to the default OWA mailbox policy. Users assigned to a non- default OWA mailbox policy are not covered; optionally, custom policies can be reviewed separately to ensure a level of enforcement beyond the compliance requirements of this control.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: $DefaultPolicy = Get-OwaMailboxPolicy | Where-Object { $_.IsDefault } Set-OwaMailboxPolicy -Identity $DefaultPolicy.Identity - PersonalAccountsEnabled $false -PersonalAccountCalendarsEnabled $false", + "AuditProcedure": "Note: The default OWA Mailbox Policy is the only policy required for compliance with this control. Other mailbox policies are discretionary and left up to the organization to audit as needed. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following: Get-OwaMailboxPolicy | Where-Object { $_.IsDefault } | Format-List PersonalAccountsEnabled, PersonalAccountCalendarsEnabled 3. Verify the output matches the following: PersonalAccountsEnabled : False PersonalAccountCalendarsEnabled : False", + "AdditionalInformation": "", + "DefaultValue": "- PersonalAccountsEnabled is True. - PersonalAccountCalendarsEnabled is True.", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-owamailboxpolicy?view=exchange-ps#-personalaccountsenabled:https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/get-started/supported-account-types#prevent-adding-personal-accounts:https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/manage/policy-management" + } + ] + }, + { + "Id": "6.5.1", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers. When you enable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use modern authentication to log in to Microsoft 365 mailboxes. When you disable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use basic authentication to log in to Microsoft 365 mailboxes. When users initially configure certain email clients, like Outlook 2013 and Outlook 2016, they may be required to authenticate using enhanced authentication mechanisms, such as multifactor authentication. Other Outlook clients that are available in Microsoft 365 (for example, Outlook Mobile and Outlook for Mac 2016) always use modern authentication to log in to Microsoft 365 mailboxes.", + "Checks": [ + "exchange_organization_modern_authentication_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers. When you enable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use modern authentication to log in to Microsoft 365 mailboxes. When you disable modern authentication in Exchange Online, Outlook 2016 and Outlook 2013 use basic authentication to log in to Microsoft 365 mailboxes. When users initially configure certain email clients, like Outlook 2013 and Outlook 2016, they may be required to authenticate using enhanced authentication mechanisms, such as multifactor authentication. Other Outlook clients that are available in Microsoft 365 (for example, Outlook Mobile and Outlook for Mac 2016) always use modern authentication to log in to Microsoft 365 mailboxes.", + "RationaleStatement": "Strong authentication controls, such as the use of multifactor authentication, may be circumvented if basic authentication is used by Exchange Online email clients such as Outlook 2016 and Outlook 2013. Enabling modern authentication for Exchange Online ensures strong authentication mechanisms are used when establishing sessions between email clients and Exchange Online.", + "ImpactStatement": "Users of older email clients, such as Outlook 2013 and Outlook 2016, will no longer be able to authenticate to Exchange using Basic Authentication, which will necessitate migration to modern authentication practices.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings and select Org Settings. 3. Select Modern authentication. 4. Check Turn on modern authentication for Outlook 2013 for Windows and later (recommended) to enable modern authentication. To remediate using PowerShell: 1. Run the Microsoft Exchange Online PowerShell Module. 2. Connect to Exchange Online using Connect-ExchangeOnline. 3. Run the following PowerShell command: Set-OrganizationConfig -OAuth2ClientProfileEnabled $True", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Expand Settings and select Org Settings. 3. Select Modern authentication. 4. Verify that Turn on modern authentication for Outlook 2013 for Windows and later (recommended) is checked. To audit using PowerShell: 1. Run the Microsoft Exchange Online PowerShell Module. 2. Connect to Exchange Online using Connect-ExchangeOnline. 3. Run the following PowerShell command: Get-OrganizationConfig | Format-Table -Auto Name, OAuth* 4. Verify that OAuth2ClientProfileEnabled is True.", + "AdditionalInformation": "", + "DefaultValue": "True", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/enable-or-disable-modern-authentication-in-exchange-online" + } + ] + }, + { + "Id": "6.5.2", + "Description": "MailTips are informative messages displayed to users while they're composing a message. While a new message is open and being composed, Exchange analyzes the message (including recipients). If a potential problem is detected, the user is notified with a MailTip prior to sending the message. Using the information in the MailTip, the user can adjust the message to avoid undesirable situations or non-delivery reports (also known as NDRs or bounce messages).", + "Checks": [ + "exchange_organization_mailtips_enabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "MailTips are informative messages displayed to users while they're composing a message. While a new message is open and being composed, Exchange analyzes the message (including recipients). If a potential problem is detected, the user is notified with a MailTip prior to sending the message. Using the information in the MailTip, the user can adjust the message to avoid undesirable situations or non-delivery reports (also known as NDRs or bounce messages).", + "RationaleStatement": "Setting up MailTips gives a visual aid to users when they send emails to large groups of recipients or send emails to recipients not within the tenant.", + "ImpactStatement": "Not applicable.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: $TipsParams = @{ MailTipsAllTipsEnabled = $true MailTipsExternalRecipientsTipsEnabled = $true MailTipsGroupMetricsEnabled = $true MailTipsLargeAudienceThreshold = '25' } Set-OrganizationConfig @TipsParams", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | fl MailTips* 3. Verify the values for MailTipsAllTipsEnabled, MailTipsExternalRecipientsTipsEnabled, and MailTipsGroupMetricsEnabled are set to True and MailTipsLargeAudienceThreshold is set to an acceptable value; 25 is the default value.", + "AdditionalInformation": "", + "DefaultValue": "MailTipsAllTipsEnabled: True MailTipsExternalRecipientsTipsEnabled: False MailTipsGroupMetricsEnabled: True MailTipsLargeAudienceThreshold: 25", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/mailtips/mailtips:https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps" + } + ], + "ConfigRequirements": [ + { + "Check": "exchange_organization_mailtips_enabled", + "ConfigKey": "recommended_mailtips_large_audience_threshold", + "Operator": "lte", + "Value": 25 + } + ] + }, + { + "Id": "6.5.3", + "Description": "This setting allows users to open certain external files while working in Outlook on the web. If allowed, keep in mind that Microsoft doesn't control the use terms or privacy policies of those third-party services. Ensure AdditionalStorageProvidersAvailable is restricted on the default OWA policy.", + "Checks": [ + "exchange_mailbox_policy_additional_storage_restricted" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting allows users to open certain external files while working in Outlook on the web. If allowed, keep in mind that Microsoft doesn't control the use terms or privacy policies of those third-party services. Ensure AdditionalStorageProvidersAvailable is restricted on the default OWA policy.", + "RationaleStatement": "By default, additional storage providers are allowed in Office on the Web (such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.). This could lead to information leakage and additional risk of infection from organizational non-trusted storage providers. Restricting this will inherently reduce risk as it will narrow opportunities for infection and data leakage.", + "ImpactStatement": "The impact associated with this change is highly dependent upon current practices in the tenant. If users do not use other storage providers, then minimal impact is likely. However, if users do regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default - AdditionalStorageProvidersAvailable $false", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command to audit the default OWA policy: Get-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default | fl AdditionalStorageProvidersAvailable 3. Verify that AdditionalStorageProvidersAvailable is False.", + "AdditionalInformation": "", + "DefaultValue": "AdditionalStorageProvidersAvailable : True", + "References": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy?view=exchange-ps:https://support.microsoft.com/en-us/topic/3rd-party-cloud-storage-services-supported-by-office-apps-fce12782-eccc-4cf5-8f4b-d1ebec513f72" + } + ] + }, + { + "Id": "6.5.4", + "Description": "This setting enables or disables authenticated client SMTP submission (SMTP AUTH) at an organization level in Exchange Online. The recommended state is Turn off SMTP AUTH protocol for your organization (checked).", + "Checks": [ + "exchange_transport_config_smtp_auth_disabled" + ], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting enables or disables authenticated client SMTP submission (SMTP AUTH) at an organization level in Exchange Online. The recommended state is Turn off SMTP AUTH protocol for your organization (checked).", + "RationaleStatement": "SMTP AUTH is a legacy protocol. Disabling it at the organization level supports the principle of least functionality and serves to further back additional controls that block legacy protocols, such as in Conditional Access. Virtually all modern email clients that connect to Exchange Online mailboxes in Microsoft 365 can do so without using SMTP AUTH.", + "ImpactStatement": "This enforces the default behavior, so no impact is expected unless the organization is using it globally. A per-mailbox setting exists that overrides the tenant-wide setting, allowing an individual mailbox SMTP AUTH capability for special cases.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Expand Settings and select Mail flow. 3. Check Turn off SMTP AUTH protocol for your organization to disable the protocol. To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-TransportConfig -SmtpClientAuthenticationDisabled $true", + "AuditProcedure": "To audit using the UI: 1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Expand Settings and select Mail flow. 3. Ensure Turn off SMTP AUTH protocol for your organization is checked. To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-TransportConfig | Format-List SmtpClientAuthenticationDisabled 3. Verify that the value returned is True.", + "AdditionalInformation": "", + "DefaultValue": "SmtpClientAuthenticationDisabled : True", + "References": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission" + } + ] + }, + { + "Id": "6.5.5", + "Description": "Direct Send is a method used to send emails directly to an Exchange Online customer's hosted mailboxes from on-premises devices, applications, or third-party cloud services using the customer's own accepted domain. This method does not require any form of authentication because, by its nature, it mimics incoming anonymous emails from the internet, apart from the sender domain. The recommended state is to configure RejectDirectSend to True.", + "Checks": [], + "Attributes": [ + { + "Section": "6 Exchange admin center", + "SubSection": "6.5 Settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Direct Send is a method used to send emails directly to an Exchange Online customer's hosted mailboxes from on-premises devices, applications, or third-party cloud services using the customer's own accepted domain. This method does not require any form of authentication because, by its nature, it mimics incoming anonymous emails from the internet, apart from the sender domain. The recommended state is to configure RejectDirectSend to True.", + "RationaleStatement": "Direct Send allows devices and applications to transmit unauthenticated email directly to Exchange Online. While this method may support legacy systems such as printers or scanners, it introduces significant security risks: - Unauthenticated Email Delivery: Direct Send does not require authentication, making it an attractive vector for threat actors to deliver spoofed or malicious emails that appear to originate from trusted internal sources. - Phishing and Spoofing Risks: Because these emails bypass standard authentication mechanisms, they can easily impersonate internal users or services, increasing the likelihood of successful phishing attacks. - Lack of Visibility and Control: Emails sent via Direct Send may not be subject to the same security policies, logging, or filtering as authenticated traffic, reducing the organization's ability to monitor and respond to threats effectively. Threat research from Varonis has shown that attackers are actively exploiting Direct Send to impersonate internal accounts and distribute malicious content without needing to compromise any credentials. These campaigns have successfully targeted organizations by leveraging predictable infrastructure and public user data to craft convincing phishing emails. Because these messages originate from outside the tenant but appear internal, they often evade detection and filtering mechanisms.", + "ImpactStatement": "Per Microsoft, there is a forwarding scenario that could be affected by this feature. It is possible that someone in your organization sends a message to a 3rd party and they in turn forward it to another mailbox in your organization. If the 3rd party's email provider does not support Sender Rewriting Scheme (SRS), the message will return with the original sender's address. Prior to this feature being enabled, those messages will already be punished by SPF failing but could still end up in inboxes. Enabling the Reject Direct Send feature without a partner mail flow connector being set up will lead to these messages being rejected outright.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Set-OrganizationConfig -RejectDirectSend $true", + "AuditProcedure": "To audit using PowerShell: 1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Run the following PowerShell command: Get-OrganizationConfig | fl RejectDirectSend 3. Verify that the value returned for RejectDirectSend is True.", + "AdditionalInformation": "", + "DefaultValue": "RejectDirectSend : False", + "References": "https://techcommunity.microsoft.com/blog/exchange/introducing-more-control-over-direct-send-in-exchange-online/4408790?WT.mc_id=M365-MVP-9501:https://techcommunity.microsoft.com/blog/exchange/direct-send-vs-sending-directly-to-an-exchange-online-tenant/4439865:https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-organizationconfig?view=exchange-ps:https://www.varonis.com/blog/direct-send-exploit:https://techcommunity.microsoft.com/discussions/microsoft-365/disable-direct-send-in-exchange-online-to-mitigate-ongoing-phishing-threats/4434649" + } + ] + }, + { + "Id": "7.2.1", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers.", + "Checks": [ + "sharepoint_modern_authentication_required" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Modern authentication in Microsoft 365 enables authentication features like multifactor authentication (MFA) using smart cards, certificate-based authentication (CBA), and third-party SAML identity providers.", + "RationaleStatement": "Strong authentication controls, such as the use of multifactor authentication, may be circumvented if basic authentication is used by SharePoint applications. Requiring modern authentication for SharePoint applications ensures strong authentication mechanisms are used when establishing sessions between these applications, SharePoint, and connecting users.", + "ImpactStatement": "Implementation of modern authentication for SharePoint will require users to authenticate to SharePoint using modern authentication. This may cause a minor impact to typical user behavior. This may also prevent third-party apps from accessing SharePoint Online resources. Also, this will also block apps using the SharePointOnlineCredentials class to access SharePoint Online resources.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies and select Access control. 3. Select Apps that don't use modern authentication. 4. Select the radio button for Block access. 5. Click Save. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com replacing tenant with your value. 2. Run the following SharePoint Online PowerShell command: Set-SPOTenant -LegacyAuthProtocolsEnabled $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies and select Access control. 3. Select Apps that don't use modern authentication and ensure that it is set to Block access. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com replacing tenant with your value. 2. Run the following SharePoint Online PowerShell command: Get-SPOTenant | ft LegacyAuthProtocolsEnabled 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "True (Apps that don't use modern authentication are allowed)", + "References": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.2", + "Description": "Entra ID B2B provides authentication and management of guests. Authentication happens via one-time passcode when they don't already have a work or school account or a Microsoft account. Integration with SharePoint and OneDrive allows for more granular control of how guest user accounts are managed in the organization's AAD, unifying a similar guest experience already deployed in other Microsoft 365 services such as Teams. Note: Global Reader role currently can't access SharePoint using PowerShell.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Entra ID B2B provides authentication and management of guests. Authentication happens via one-time passcode when they don't already have a work or school account or a Microsoft account. Integration with SharePoint and OneDrive allows for more granular control of how guest user accounts are managed in the organization's AAD, unifying a similar guest experience already deployed in other Microsoft 365 services such as Teams. Note: Global Reader role currently can't access SharePoint using PowerShell.", + "RationaleStatement": "External users assigned guest accounts will be subject to Entra ID access policies, such as multi-factor authentication. This provides a way to manage guest identities and control access to SharePoint and OneDrive resources. Without this integration, files can be shared without account registration, making it more challenging to audit and manage who has access to the organization's data.", + "ImpactStatement": "After enabling Microsoft Entra B2B integration, external users attempting to access previously shared links (One Time Passcode) will encounter access issues. They receive error 'This organization has updated its guest access settings'. To restore access, your users need to reshare files/folders/sites to external users.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService 2. Run the following command: Set-SPOTenant -EnableAzureADB2BIntegration $true", + "AuditProcedure": "To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService 2. Run the following command: Get-SPOTenant | ft EnableAzureADB2BIntegration 3. Verify that the returned value is True.", + "AdditionalInformation": "", + "DefaultValue": "False", + "References": "https://learn.microsoft.com/en-us/sharepoint/sharepoint-azureb2b-integration#enabling-the-integration:https://learn.microsoft.com/en-us/entra/external-id/what-is-b2b:https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.3", + "Description": "The external sharing settings govern sharing for the organization overall. Each site has its own sharing setting that can be set independently, though it must be at the same or more restrictive setting as the organization. The new and existing guests option requires people who have received invitations to sign in with their work or school account (if their organization uses Microsoft 365) or a Microsoft account, or to provide a code to verify their identity. Users can share with guests already in your organization's directory, and they can send invitations to people who will be added to the directory if they sign in. The recommended state is New and existing guests or less permissive.", + "Checks": [ + "sharepoint_external_sharing_restricted" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "The external sharing settings govern sharing for the organization overall. Each site has its own sharing setting that can be set independently, though it must be at the same or more restrictive setting as the organization. The new and existing guests option requires people who have received invitations to sign in with their work or school account (if their organization uses Microsoft 365) or a Microsoft account, or to provide a code to verify their identity. Users can share with guests already in your organization's directory, and they can send invitations to people who will be added to the directory if they sign in. The recommended state is New and existing guests or less permissive.", + "RationaleStatement": "Forcing guest authentication on the organization's tenant enables the implementation of controls and oversight over external file sharing. When a guest is registered with the organization, they now have an identity which can be accounted for. This identity can also have other restrictions applied to it through group membership and conditional access rules.", + "ImpactStatement": "When using B2B integration, Entra ID external collaboration settings, such as guest invite settings and collaboration restrictions apply.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under SharePoint, move the slider bar to New and existing guests or a less permissive level. o OneDrive will also be moved to the same level and can never be more permissive than SharePoint. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet to establish the minimum recommended state: Set-SPOTenant -SharingCapability ExternalUserSharingOnly Note: Other acceptable values for this parameter that are more restrictive include: Disabled and ExistingExternalUserSharingOnly.", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under SharePoint, verify that the slider bar is set to New and existing guests or a less permissive level. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl SharingCapability 3. Verify that SharingCapability is set to one of the following values: o ExternalUserSharingOnly o ExistingExternalUserSharingOnly o Disabled", + "AdditionalInformation": "", + "DefaultValue": "Anyone (ExternalUserAndGuestSharing)", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off:https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.4", + "Description": "This setting governs the global permissiveness of OneDrive content sharing in the organization. OneDrive content sharing can be restricted independent of SharePoint but can never be more permissive than the level established with SharePoint. The recommended state is Only people in your organization.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting governs the global permissiveness of OneDrive content sharing in the organization. OneDrive content sharing can be restricted independent of SharePoint but can never be more permissive than the level established with SharePoint. The recommended state is Only people in your organization.", + "RationaleStatement": "OneDrive, designed for end-user cloud storage, inherently provides less oversight and control compared to SharePoint, which often involves additional content overseers or site administrators. This autonomy can lead to potential risks such as inadvertent sharing of privileged information by end users. Restricting external OneDrive sharing will require users to transfer content to SharePoint folders first which have those tighter controls.", + "ImpactStatement": "Users will be required to take additional steps to share OneDrive content or use other official channels.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under OneDrive, set the slider bar to Only people in your organization. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -OneDriveSharingCapability Disabled Alternative remediation method using PowerShell: 1. Connect to SharePoint Online. 2. Run one of the following: # Replace [tenant] with your tenant id Set-SPOSite -Identity https://[tenant]-my.sharepoint.com/ -SharingCapability Disabled # Or run this to filter to the specific site without supplying the tenant name. $OneDriveSite = Get-SPOSite -Filter { Url -like \"*-my.sharepoint.com/\" } Set-SPOSite -Identity $OneDriveSite -SharingCapability Disabled", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. Under OneDrive, verify that the slider bar is set to Only people in your organization. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl OneDriveSharingCapability 3. Verify that the returned value is Disabled. Alternative audit method using PowerShell: 1. Connect to SharePoint Online. 2. Use one of the following methods: # Replace [tenant] with your tenant id Get-SPOSite -Identity https://[tenant]-my.sharepoint.com/ | fl Url,SharingCapability # Or run this to filter to the specific site without supplying the tenant name. $OneDriveSite = Get-SPOSite -Filter { Url -like \"*-my.sharepoint.com/\" } Get-SPOSite -Identity $OneDriveSite | fl Url,SharingCapability 2. Verify that the returned value for SharingCapability is Disabled Note: As of March 2024, using Get-SPOSite with Where-Object or filtering against the entire site and then returning the SharingCapability parameter can result in a different value as opposed to running the cmdlet specifically against the OneDrive specific site using the -Identity switch as shown in the example. Note 2: The parameter OneDriveSharingCapability may not be yet fully available in all tenants. It is demonstrated in official Microsoft documentation as linked in the references section but not in the Set-SPOTenant cmdlet itself. If the parameter is unavailable, then either use the UI method or alternative PowerShell audit method.", + "AdditionalInformation": "", + "DefaultValue": "Anyone (ExternalUserAndGuestSharing)", + "References": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps#-onedrivesharingcapability" + } + ] + }, + { + "Id": "7.2.5", + "Description": "SharePoint gives users the ability to share files, folders, and site collections. Internal users can share with external collaborators, and with the right permissions could share to other external parties.", + "Checks": [ + "sharepoint_guest_sharing_restricted" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "SharePoint gives users the ability to share files, folders, and site collections. Internal users can share with external collaborators, and with the right permissions could share to other external parties.", + "RationaleStatement": "Sharing and collaboration are key; however, file, folder, or site collection owners should have the authority over what external users get shared with to prevent unauthorized disclosures of information.", + "ImpactStatement": "The impact associated with this change is highly dependent upon current practices. If users do not regularly share with external parties, then minimal impact is likely. However, if users do regularly share with guests/externally, minimum impacts could occur as those external users will be unable to 're-share' content.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Expand More external sharing settings, uncheck Allow guests to share items they don't own. 4. Click Save. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following SharePoint Online PowerShell command: Set-SPOTenant -PreventExternalUsersFromResharing $True", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Expand More external sharing settings, verify that Allow guests to share items they don't own is unchecked. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following SharePoint Online PowerShell command: Get-SPOTenant | ft PreventExternalUsersFromResharing 3. Verify that the returned value is True.", + "AdditionalInformation": "", + "DefaultValue": "Checked (False)", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off:https://learn.microsoft.com/en-us/sharepoint/external-sharing-overview" + } + ] + }, + { + "Id": "7.2.6", + "Description": "The external sharing features of SharePoint and OneDrive let users in the organization share content with people outside the organization (such as partners, vendors, clients, or customers). It can also be used to share between licensed users on multiple Microsoft 365 subscriptions if your organization has more than one subscription. The recommended state is Limit external sharing by domain > Allow only specific domains", + "Checks": [ + "sharepoint_external_sharing_managed" + ], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "The external sharing features of SharePoint and OneDrive let users in the organization share content with people outside the organization (such as partners, vendors, clients, or customers). It can also be used to share between licensed users on multiple Microsoft 365 subscriptions if your organization has more than one subscription. The recommended state is Limit external sharing by domain > Allow only specific domains", + "RationaleStatement": "Attackers will often attempt to expose sensitive information to external entities through sharing, and restricting the domains that users can share documents with will reduce that surface area.", + "ImpactStatement": "Users will be unable to initiate new shares with parties whose domains are not on the approved allowlist, which may require administrative action before collaboration with new partners or vendors can begin. Administrators must keep the allowlist current to reflect active business relationships; an outdated list can block legitimate sharing and generate support requests. Note that existing shares to domains not on the allowlist are not revoked when this setting is configured.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies > Sharing. 3. Expand More external sharing settings and check Limit external sharing by domain. 4. Select Add domains, choose Allow only specific domains, enter the list of approved domain names, and select Done. 5. Click Save at the bottom of the page. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Set-SPOTenant -SharingDomainRestrictionMode AllowList - SharingAllowedDomainList \"domain1.com domain2.com\"", + "AuditProcedure": "Note: If the SharePoint external sharing slider is set to Only people in your organization, this recommendation is compliant regardless of the configured value for Limit external sharing by domain. To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. If the SharePoint slider is set to Only people in your organization, this recommendation is compliant. 5. Otherwise, expand More external sharing settings and confirm that Limit external sharing by domain is checked. 6. Verify that Allow only specific domains is selected and that domains listed are approved by the organization. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Get-SPOTenant | fl SharingCapability,SharingDomainRestrictionMode,SharingAllowedDomainList 3. Verify that one of the following conditions is true: o SharingCapability is Disabled, OR o SharingDomainRestrictionMode is AllowList and SharingAllowedDomainList contains domains trusted by the organization for external sharing.", + "AdditionalInformation": "", + "DefaultValue": "Limit external sharing by domain is unchecked SharingDomainRestrictionMode: None SharingAllowedDomainList: (empty)", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off?WT.mc_id=365AdminCSH_spo#more-external-sharing-settings" + } + ] + }, + { + "Id": "7.2.7", + "Description": "This setting sets the default link type that a user will see when sharing content in OneDrive or SharePoint. It does not restrict or exclude any other options. The recommended state is Specific people (only the people the user specifies) or Only people in your organization.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting sets the default link type that a user will see when sharing content in OneDrive or SharePoint. It does not restrict or exclude any other options. The recommended state is Specific people (only the people the user specifies) or Only people in your organization.", + "RationaleStatement": "By defaulting to specific people, the user will first need to consider whether or not the content being shared should be accessible by the entire organization versus select individuals. This aids in reinforcing the concept of least privilege.", + "ImpactStatement": "Changing the default sharing link type influences the user experience when sharing files and folders in SharePoint and OneDrive. The configured default option will appear pre- selected in the sharing dialog, guiding users toward the organization's preferred sharing method.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Set Choose the type of link that's selected by default when users share files and folders in SharePoint and OneDrive to Specific people (only the people the user specifies) or Only people in your organization. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run one of the following PowerShell commands depending on the desired compliant state: To set the default sharing link to specific people: Set-SPOTenant -DefaultSharingLinkType Direct To set the default sharing link to people in the organization: Set-SPOTenant -DefaultSharingLinkType Internal", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Verify that the setting Choose the type of link that's selected by default when users share files and folders in SharePoint and OneDrive is set to Specific people (only the people the user specifies) or Only people in your organization. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Get-SPOTenant | fl DefaultSharingLinkType 3. Verify that the returned value is Direct or Internal.", + "AdditionalInformation": "", + "DefaultValue": "Only people in your organization (Internal)", + "References": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.8", + "Description": "External sharing of content can be restricted to specific security groups. This setting is global, applies to sharing in both SharePoint and OneDrive and cannot be set at the site level in SharePoint.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "External sharing of content can be restricted to specific security groups. This setting is global, applies to sharing in both SharePoint and OneDrive and cannot be set at the site level in SharePoint.", + "RationaleStatement": "Without restricting external sharing to designated security groups, any user in the organization can share SharePoint or OneDrive content with external recipients. A compromised or insider-threat account can exfiltrate sensitive data by sharing files externally without additional authorization controls. Limiting external sharing to members of specific Entra ID security groups ensures that only reviewed and authorized users have this capability, reducing the attack surface for data exfiltration through sharing.", + "ImpactStatement": "Users who are not members of the designated security groups will lose the ability to create new external shares or invite new external guests. Existing sharing links they previously established will remain active for current recipients. Organizations should ensure the security groups are populated with appropriate members before enabling this setting to avoid inadvertently blocking all external sharing. Helpdesk volume may increase as users in non-designated groups encounter sharing restrictions.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Set the following: o Check Allow only users in specific security groups to share externally o Click Manage security groups, then add at least one security group authorized for external sharing. To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following command, replacing with the GUID of the security group to be authorized for external sharing: Set-SPOTenant -WhoCanShareAuthenticatedGuestAllowList \"\" Note: To authorize multiple security groups, provide a comma-delimited list of Object IDs: \"\",\"\". Note: Users in the designated security groups must also be permitted to invite guests in Microsoft Entra. Verify this at Identity > External Identities > External collaboration settings.", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Locate the External sharing section. 4. If the SharePoint slider is set to Only people in your organization, this recommendation is compliant. 5. Otherwise, scroll to and expand More external sharing settings. 6. Verify the following: o Allow only users in specific security groups to share externally is checked o Manage security groups contains at least one security group. To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService. 2. Run the following PowerShell command: Get-SPOTenant | fl SharingCapability, WhoCanShareAuthenticatedGuestAllowList 3. Verify the output using the following logic: o If SharingCapability is Disabled, the recommendation is compliant regardless of the value of WhoCanShareAuthenticatedGuestAllowList. o Otherwise, verify that WhoCanShareAuthenticatedGuestAllowList contains at least one security group GUID. If the value is empty or $null, the recommendation is not compliant.", + "AdditionalInformation": "", + "DefaultValue": "By default, this restriction is not in place, allowing any user in the organization to share content externally, subject only to the top-level sharing slider.", + "References": "https://learn.microsoft.com/en-us/sharepoint/manage-security-groups:https://learn.microsoft.com/en-us/powershell/module/microsoft.online.sharepoint.powershell/set-spotenant?view=sharepoint-ps" + } + ] + }, + { + "Id": "7.2.9", + "Description": "This policy setting configures the expiration time for each guest that is invited to the SharePoint site or with whom users share individual files and folders with. Expiration can be a number 30 to 730. The recommended state is 30.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting configures the expiration time for each guest that is invited to the SharePoint site or with whom users share individual files and folders with. Expiration can be a number 30 to 730. The recommended state is 30.", + "RationaleStatement": "This setting ensures that guests who no longer need access to the site or link no longer have access after a set period of time. Allowing guest access for an indefinite amount of time could lead to loss of data confidentiality and oversight. Note: Guest membership applies at the Microsoft 365 group level. Guests who have permission to view a SharePoint site or use a sharing link may also have access to a Microsoft Teams team or security group.", + "ImpactStatement": "Site collection administrators will have to renew access to guests who still need access after 30 days. They will receive an e-mail notification once per week about guest access that is about to expire. Note: The guest expiration policy only applies to guests who use sharing links or guests who have direct permissions to a SharePoint site after the guest policy is enabled. The guest policy does not apply to guest users that have pre-existing permissions or access through a sharing link before the guest expiration policy is applied.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Set Guest access to a site or OneDrive will expire automatically after this many days to 30 To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -ExternalUserExpireInDays 30 -ExternalUserExpirationRequired $True", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Verify that Guest access to a site or OneDrive will expire automatically after this many days is checked and set to 30. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl ExternalUserExpirationRequired,ExternalUserExpireInDays 3. Verify the following values are returned: o ExternalUserExpirationRequired is True. o ExternalUserExpireInDays is 30.", + "AdditionalInformation": "", + "DefaultValue": "ExternalUserExpirationRequired $false ExternalUserExpireInDays 60 days", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off#change-the-organization-level-external-sharing-setting:https://learn.microsoft.com/en-us/microsoft-365/community/sharepoint-security-a-team-effort" + } + ] + }, + { + "Id": "7.2.10", + "Description": "This setting configures if guests who use a verification code to access the site or links are required to reauthenticate after a set number of days. The recommended state is 15 or less.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting configures if guests who use a verification code to access the site or links are required to reauthenticate after a set number of days. The recommended state is 15 or less.", + "RationaleStatement": "By increasing the frequency of times guests need to reauthenticate this ensures guest user access to data is not prolonged beyond an acceptable amount of time.", + "ImpactStatement": "Guests who use Microsoft 365 in their organization can sign in using their work or school account to access the site or document. After the one-time passcode for verification has been entered for the first time, guests will authenticate with their work or school account and have a guest account created in the host's organization. Note: If OneDrive and SharePoint integration with Entra ID B2B is enabled as per the CIS Benchmark the one-time-passcode experience will be replaced. Please visit Secure external sharing in SharePoint - SharePoint in Microsoft 365 | Microsoft Learn for more information.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Set People who use a verification code must reauthenticate after this many days to 15 or less. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to and expand More external sharing settings. 4. Verify that People who use a verification code must reauthenticate after this many days is set to 15 or less. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl EmailAttestationRequired,EmailAttestationReAuthDays 3. Verify that the following values are returned: o EmailAttestationRequired True o EmailAttestationReAuthDays 15 or less days.", + "AdditionalInformation": "", + "DefaultValue": "EmailAttestationRequired : False EmailAttestationReAuthDays : 30", + "References": "https://learn.microsoft.com/en-us/sharepoint/what-s-new-in-sharing-in-targeted-release:https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off#change-the-organization-level-external-sharing-setting:https://learn.microsoft.com/en-us/entra/external-id/one-time-passcode" + } + ] + }, + { + "Id": "7.2.11", + "Description": "This setting configures the permission that is selected by default for sharing link from a SharePoint site. The recommended state is View.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.2 Policies", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting configures the permission that is selected by default for sharing link from a SharePoint site. The recommended state is View.", + "RationaleStatement": "Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link. This approach reduces the risk of unintentionally granting edit privileges to a resource that only requires read access, supporting the principle of least privilege.", + "ImpactStatement": "Not applicable.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Set Choose the permission that's selected by default for sharing links to View. To remediate using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Set-SPOTenant -DefaultLinkPermission View", + "AuditProcedure": "To audit using the UI: 1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Expand Policies > Sharing. 3. Scroll to File and folder links. 4. Verify that Choose the permission that's selected by default for sharing links is set to View. To audit using PowerShell: 1. Connect to SharePoint Online service using Connect-SPOService. 2. Run the following cmdlet: Get-SPOTenant | fl DefaultLinkPermission 3. Verify that the returned value is View.", + "AdditionalInformation": "", + "DefaultValue": "DefaultLinkPermission : Edit", + "References": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off#file-and-folder-links" + } + ] + }, + { + "Id": "7.3.1", + "Description": "By default, SharePoint online allows files that Defender for Office 365 has detected as infected to be downloaded.", + "Checks": [], + "Attributes": [ + { + "Section": "7 SharePoint admin center", + "SubSection": "7.3 Settings", + "Profile": "E5 Level 2", + "AssessmentStatus": "Automated", + "Description": "By default, SharePoint online allows files that Defender for Office 365 has detected as infected to be downloaded.", + "RationaleStatement": "Defender for Office 365 for SharePoint, OneDrive, and Microsoft Teams protects your organization from inadvertently sharing malicious files. When an infected file is detected that file is blocked so that no one can open, copy, move, or share it until further actions are taken by the organization's security team.", + "ImpactStatement": "The only potential impact associated with implementation of this setting is potential inconvenience associated with the small percentage of false positive detections that may occur.", + "RemediationProcedure": "To remediate using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com, replacing \"tenant\" with the appropriate value. 2. Run the following PowerShell command to set the recommended value: Set-SPOTenant -DisallowInfectedFileDownload $true Note: The Global Reader role cannot access SharePoint using PowerShell according to Microsoft. See the reference section for more information.", + "AuditProcedure": "To audit using PowerShell: 1. Connect to SharePoint Online using Connect-SPOService -Url https://tenant-admin.sharepoint.com, replacing \"tenant\" with the appropriate value. 2. Run the following PowerShell command: Get-SPOTenant | Select-Object DisallowInfectedFileDownload 3. Ensure that the DisallowInfectedFileDownload is set to True. Note: According to Microsoft, SharePoint cannot be accessed through PowerShell by users with the Global Reader role. For further information, please refer to the reference section.", + "AdditionalInformation": "", + "DefaultValue": "False", + "References": "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-for-spo-odfb-teams-configure?view=o365-worldwide:https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-for-spo-odfb-teams-about?view=o365-worldwide:https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#global-reader" + } + ] + }, + { + "Id": "8.1.1", + "Description": "Microsoft Teams enables collaboration via file sharing. This file sharing is conducted within Teams, using SharePoint Online, by default; however, third-party cloud services are allowed as well. Note: Skype for business is deprecated as of July 31, 2021 although these settings may still be valid for a period of time. See the link in the references section for more information.", + "Checks": [ + "teams_external_file_sharing_restricted" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.1 Teams", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Microsoft Teams enables collaboration via file sharing. This file sharing is conducted within Teams, using SharePoint Online, by default; however, third-party cloud services are allowed as well. Note: Skype for business is deprecated as of July 31, 2021 although these settings may still be valid for a period of time. See the link in the references section for more information.", + "RationaleStatement": "Ensuring that only authorized cloud storage providers are accessible from Teams will help to dissuade the use of non-approved storage providers.", + "ImpactStatement": "The impact associated with this change is highly dependent upon current practices in the tenant. If users do not use other storage providers, then minimal impact is likely. However, if users do regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under files set storages providers to Off unless they have first been authorized by the organization. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following PowerShell command to disable external providers that are not authorized. (the example disables Citrix Files, DropBox, Box, Google Drive and Egnyte) $Params = @{ Identity = 'Global' AllowGoogleDrive = $false AllowShareFile = $false AllowBox = $false AllowDropBox = $false AllowEgnyte = $false } Set-CsTeamsClientConfiguration @Params", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under files verify that only organizationally authorized cloud storage options are set to On and all others Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following to verify the recommended state: $Params = @( 'AllowDropbox' 'AllowBox' 'AllowGoogleDrive' 'AllowShareFile' 'AllowEgnyte' ) Get-CsTeamsClientConfiguration -Identity Global | fl $Params 3. Verify that only authorized providers are set to True and all others False.", + "AdditionalInformation": "", + "DefaultValue": "AllowDropBox : True AllowBox : True AllowGoogleDrive : True AllowShareFile : True AllowEgnyte : True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/teams-powershell-managing-teams" + } + ] + }, + { + "Id": "8.1.2", + "Description": "This setting controls whether Teams channels are allowed to receive emails sent to their unique email addresses. When enabled, emails sent to a channel's address will be delivered and appear in the channel's conversation thread; when disabled, the channel will reject incoming emails, preventing them from being posted. The recommended state is Off.", + "Checks": [ + "teams_email_sending_to_channel_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.1 Teams", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls whether Teams channels are allowed to receive emails sent to their unique email addresses. When enabled, emails sent to a channel's address will be delivered and appear in the channel's conversation thread; when disabled, the channel will reject incoming emails, preventing them from being posted. The recommended state is Off.", + "RationaleStatement": "Channel email addresses are not under the tenant's domain and organizations do not have control over the security settings for this email address. An attacker could email channels directly if they discover the channel email address.", + "ImpactStatement": "Depending on the organization's adoption, disabling this may disrupt workflows that rely on email-to-channel communication, particularly in environments where email is used to bridge external systems or vendors into Teams. This could include reduced visibility of important updates or alerts that were previously routed into Teams channels via email.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under email integration set Users can send emails to a channel email address to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsClientConfiguration -Identity Global -AllowEmailIntoChannel $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Select Settings & policies > Global (Org-wide default) settings. 3. Click Teams to open the Teams settings section. 4. Under email integration verify that Users can send emails to a channel email address is Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsClientConfiguration -Identity Global | fl AllowEmailIntoChannel 3. Ensure the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On (True)", + "References": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#restricting-channel-email-messages-to-approved-domains:https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference#email-integration:https://support.microsoft.com/en-us/office/send-an-email-to-a-channel-in-microsoft-teams-d91db004-d9d7-4a47-82e6-fb1b16dfd51e" + } + ] + }, + { + "Id": "8.2.1", + "Description": "This policy controls whether external domains are allowed, blocked or permitted based on an allowlist or denylist. When external domains are allowed, users in your organization can chat, add users to meetings, and use audio video conferencing with users in external organizations. The recommended state is Off on the Global (Org-wide default) policy.", + "Checks": [ + "teams_external_domains_restricted" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This policy controls whether external domains are allowed, blocked or permitted based on an allowlist or denylist. When external domains are allowed, users in your organization can chat, add users to meetings, and use audio video conferencing with users in external organizations. The recommended state is Off on the Global (Org-wide default) policy.", + "RationaleStatement": "Unrestricted external federation allows any Teams user from any organization to initiate contact with your users, making them susceptible to social engineering, phishing, and malware delivery via Teams chat. Restricting external domains to an allowlist or blocking them entirely eliminates this unsolicited contact vector. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Restricting external domains will limit users' ability to collaborate with individuals outside the organization unless their domain is explicitly allowlisted or they are invited as a guest in Microsoft Entra ID. Administrators choosing an allowlist approach will incur ongoing overhead to manage approved domains as external collaboration needs evolve. Note: Organizations may create custom external access policies with federation enabled and assign them to specific users or groups requiring external access, while keeping the Global (Org-wide default) policy restrictive.", + "RemediationProcedure": "Note: Configuring this setting at the organization level in Organization settings to either Off, Block all external domains or Allow only specific external domains is also a compliant remediation for this control. To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Set Manage external domains for this policy to Off. 6. Click Save. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command to configure the Global (Org-wide default) policy. Set-CsExternalAccessPolicy -Identity Global -EnableFederationAccess $false", + "AuditProcedure": "Note: The focus of this control at a minimum is the Global (Org-wide default) policy. If the organization-wide setting is configured to Allow only specific external domains or Block all external domains, then this is also considered a passing state due to its increased restrictiveness. To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Verify that Manage external domains for this policy is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsExternalAccessPolicy -Identity Global 3. Verify that EnableFederationAccess is False. Organization settings: Optional passing state 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Organization settings tab. 4. Verify that Manage external domains for this organization is set to one of the following: o Off o On with Allow or block external domains set to Allow only specific external domains o On with Allow or block external domains set to Block all external domains To audit using PowerShell: 1. Run the following command: Get-CsTenantFederationConfiguration | fl AllowFederatedUsers,AllowedDomains 2. Verify the output meets one of the following compliant conditions: o Off: AllowFederatedUsers is False o Block all external domains: AllowFederatedUsers is True and AllowedDomains is empty o Allow only specific external domains: AllowFederatedUsers is True and AllowedDomains contains only authorized domain names", + "AdditionalInformation": "", + "DefaultValue": "EnableFederationAccess - $True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs/" + } + ] + }, + { + "Id": "8.2.2", + "Description": "This policy setting controls chats and meetings initiated through the external access channel with unmanaged Teams users (those not managed by an organization, such as Microsoft Teams (free)). This does not govern anonymous meeting join via shared link, which is controlled separately. The recommended state is: People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts set to Off.", + "Checks": [ + "teams_unmanaged_communication_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls chats and meetings initiated through the external access channel with unmanaged Teams users (those not managed by an organization, such as Microsoft Teams (free)). This does not govern anonymous meeting join via shared link, which is controlled separately. The recommended state is: People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts set to Off.", + "RationaleStatement": "Allowing users to communicate with unmanaged Teams users presents a potential security threat as little effort is required by threat actors to gain access to a trial or free Microsoft Teams account. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / Phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Users will be unable to communicate with Teams users who are not managed by an organization. Organizations may choose to create additional policies for specific groups needing to communicate with unmanaged external users. Note: The settings that govern chats and meetings with external unmanaged Teams users aren't available in GCC, GCC High, or DOD deployments, or in private cloud environments.", + "RemediationProcedure": "Note: Configuring this setting at the organization level in Organization settings to Off is also a compliant remediation for this control. To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Set People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts to Off. 6. Click Save. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Set-CsExternalAccessPolicy -Identity Global -EnableTeamsConsumerAccess $false", + "AuditProcedure": "Note: The focus of this control at a minimum is the Global (Org-wide default) policy. If the organization-wide setting is configured to Off, then this is also considered a passing state due to its increased restrictiveness. To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Verify that People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsExternalAccessPolicy -Identity Global Verify that EnableTeamsConsumerAccess is set to False. Organization settings: Optional passing state 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Organization settings tab. 4. Verify that People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts is set to Off. To audit using PowerShell: 1. Run the following command: Get-CsTenantFederationConfiguration | fl AllowTeamsConsumer 2. Verify that AllowTeamsConsumer is False.", + "AdditionalInformation": "", + "DefaultValue": "- EnableTeamsConsumerAccess (Global policy): True - AllowTeamsConsumer (Organization settings): True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs/" + } + ] + }, + { + "Id": "8.2.3", + "Description": "This setting prevents external users who are not managed by an organization from initiating contact with users in the protected organization. The recommended state is to uncheck People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. Note: Disabling this setting is used as an additional stop gap for the parent setting which disables communication with unmanaged Teams users entirely. If an organization chooses to have an exception to Ensure communication with unmanaged Teams users is disabled they can do so while also disabling the ability for the same group of users to initiate contact. Disabling communication entirely will also disable the ability for unmanaged users to initiate contact.", + "Checks": [ + "teams_external_users_cannot_start_conversations" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting prevents external users who are not managed by an organization from initiating contact with users in the protected organization. The recommended state is to uncheck People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. Note: Disabling this setting is used as an additional stop gap for the parent setting which disables communication with unmanaged Teams users entirely. If an organization chooses to have an exception to Ensure communication with unmanaged Teams users is disabled they can do so while also disabling the ability for the same group of users to initiate contact. Disabling communication entirely will also disable the ability for unmanaged users to initiate contact.", + "RationaleStatement": "Allowing users to communicate with unmanaged Teams users presents a potential security threat as little effort is required by threat actors to gain access to a trial or free Microsoft Teams account. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / Phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Unmanaged Teams users (those using personal Microsoft accounts or free Teams) will be unable to initiate new chats or meeting invitations with members of the organization. Organization members may still be able to join externally-initiated meetings depending on the configuration of the parent setting. Organizations that need to allow inbound contact from specific external users can assign a custom external access policy to those users that has EnableTeamsConsumerInbound enabled. Note: Chats and meetings with external unmanaged Teams users isn't available in GCC, GCC High, or DOD deployments, or in private cloud environments.", + "RemediationProcedure": "Note: Configuring this setting at the organization level in Organization settings to Off is also a compliant remediation for this control. To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Locate the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. 6. Uncheck People in my org can join external meetings and receive new chats from users who have unmanaged Microsoft accounts. 7. Click Save. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Set-CsExternalAccessPolicy -Identity Global -EnableTeamsConsumerInbound $false", + "AuditProcedure": "Note: The focus of this control at a minimum is the Global (Org-wide default) policy. If the equivalent organization-wide setting is disabled, then this is also considered a passing state due to its increased restrictiveness. To audit using the UI: Note: If the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts is already set to Off then this setting will not be visible in the UI. 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Open the Policies tab. 4. Click on the Global (Org-wide default) settings policy. 5. Locate the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. 6. Verify that People in my org can join external meetings and receive new chats from users who have unmanaged Microsoft accounts is not checked. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsExternalAccessPolicy -Identity Global Verify that EnableTeamsConsumerInbound is False Organization settings: Optional passing state 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Select the Organization settings tab. 4. Locate the parent setting People in my org can chat and have meetings with external users who have unmanaged Microsoft accounts. 5. Verify that People in my org can join external meetings and receive new chats from users who have unmanaged Microsoft accounts is not checked. To audit using PowerShell: 1. Run the following command: Get-CsTenantFederationConfiguration | fl AllowTeamsConsumerInbound Verify that AllowTeamsConsumerInbound is False", + "AdditionalInformation": "", + "DefaultValue": "- EnableTeamsConsumerInbound (Global policy) : True - AllowTeamsConsumerInbound (Organization settings) : True", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs/" + } + ] + }, + { + "Id": "8.2.4", + "Description": "This setting controls the organization's external access with Teams \"trial-only\" tenants. These are tenants that don't have any purchased seats. When set to Blocked, users from these trial-only tenants aren't able to search and contact your users via chats, Teams calls, and meetings (using the users' authenticated identities) and your users aren't able to reach users in these trial-only tenants. Users from the trial-only tenant are also removed from existing chats. The recommended state for People in my organization can communicate with accounts in trial Teams tenant is Off.", + "Checks": [], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.2 Users", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting controls the organization's external access with Teams \"trial-only\" tenants. These are tenants that don't have any purchased seats. When set to Blocked, users from these trial-only tenants aren't able to search and contact your users via chats, Teams calls, and meetings (using the users' authenticated identities) and your users aren't able to reach users in these trial-only tenants. Users from the trial-only tenant are also removed from existing chats. The recommended state for People in my organization can communicate with accounts in trial Teams tenant is Off.", + "RationaleStatement": "Microsoft introduced this setting as Off by default on July 29, 2024 in order to block attack vectors being exploited by threat actors who have abused trial tenants. Enforcing the default ensures the setting is not reenabled for any reason. Allowing users to communicate with unmanaged Teams users presents a potential security threat as little effort is required by threat actors to gain access to a trial or free Microsoft Teams account. Real-world attacks and exploits delivered via Teams over external access channels include: - DarkGate malware - Social engineering / Phishing attacks by \"Midnight Blizzard\" - GIFShell - Username enumeration", + "ImpactStatement": "Users currently in chat conversations with accounts from trial tenants will be removed from those existing chats when this setting is disabled. Organizations that have established communication with external contacts who are using trial tenants will need to use alternative channels (such as email) until those contacts migrate to a licensed tenant.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Select the Organization settings tab. 4. Set People in my organization can communicate with accounts in trial Teams tenant to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Set-CsTenantFederationConfiguration -ExternalAccessWithTrialTenants \"Blocked\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Expand External collaboration and select External access. 3. Select the Organization settings tab. 4. Verify that People in my organization can communicate with accounts in trial Teams tenant is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command: Get-CsTenantFederationConfiguration Verify that ExternalAccessWithTrialTenants is set to Blocked.", + "AdditionalInformation": "", + "DefaultValue": "- Off (UI) - Blocked (PowerShell)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat?tabs=organization-settings#block-federation-with-teams-trial-only-tenants:https://www.microsoft.com/en-us/security/blog/2023/08/02/midnight-blizzard-conducts-targeted-social-engineering-over-microsoft-teams/:https://www.bitdefender.com/en-us/blog/hotforsecurity/gifshell-attack-lets-hackers-create-reverse-shell-through-microsoft-teams-gifs" + } + ] + }, + { + "Id": "8.4.1", + "Description": "This policy setting controls which class of apps are available for users to install.", + "Checks": [], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.4 Teams apps", + "Profile": "E3 Level 1", + "AssessmentStatus": "Manual", + "Description": "This policy setting controls which class of apps are available for users to install.", + "RationaleStatement": "Allowing users to install third-party or unverified apps poses a potential risk of introducing malicious software to the environment.", + "ImpactStatement": "Users will only be able to install approved classes of apps.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Expand Teams apps and select Manage apps. 3. In the upper right click Actions > Org-wide app settings. 4. For Third-party apps set Let users install and use available apps by default to Off. 5. For Custom apps set Let users install and use available apps by default to Off. 6. For Custom apps set Let users interact with custom apps in preview to Off.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Expand Teams apps and select Manage apps. 3. In the upper right click Actions > Org-wide app settings. 4. For Third-party apps verify Let users install and use available apps by default is Off. 5. For Custom apps verify Let users install and use available apps by default is Off. 6. For Custom apps verify Let users interact with custom apps in preview is Off.", + "AdditionalInformation": "", + "DefaultValue": "- Third-party apps: On - Custom apps: On", + "References": "https://learn.microsoft.com/en-us/microsoftteams/app-centric-management:https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#disabling-third-party--custom-apps" + } + ] + }, + { + "Id": "8.5.1", + "Description": "Anonymous users are users whose identity can't be verified. They may be logged in to an organization without a mutual trust relationship or they may not have an account (guest or user). Anonymous participants appear with \"(Unverified)\" appended to their name in meetings. These users could include: - Users who aren't logged in to Teams with a work or school account. - Users from non-trusted organizations (as configured in external access) and from organizations that you trust but which don't trust your organization. When defining trusted organizations for external meetings and chat, ensure both organizations allow each other's domains. Meeting organizers and participants should have user policies that allow external access. These settings prevent attendees from being considered anonymous due to external access settings. For details, see IT Admins - Manage external meetings and chat with people and organizations using Microsoft identities The recommended state is Anonymous users can join a meeting unverified set to Off.", + "Checks": [ + "teams_meeting_anonymous_user_join_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Anonymous users are users whose identity can't be verified. They may be logged in to an organization without a mutual trust relationship or they may not have an account (guest or user). Anonymous participants appear with \"(Unverified)\" appended to their name in meetings. These users could include: - Users who aren't logged in to Teams with a work or school account. - Users from non-trusted organizations (as configured in external access) and from organizations that you trust but which don't trust your organization. When defining trusted organizations for external meetings and chat, ensure both organizations allow each other's domains. Meeting organizers and participants should have user policies that allow external access. These settings prevent attendees from being considered anonymous due to external access settings. For details, see IT Admins - Manage external meetings and chat with people and organizations using Microsoft identities The recommended state is Anonymous users can join a meeting unverified set to Off.", + "RationaleStatement": "For meetings that could contain sensitive information, it is best to allow the meeting organizer to vet anyone not directly sent an invite before admitting them to the meeting. This will also prevent the anonymous user from using the meeting link to have meetings at unscheduled times. Note: Those companies that don't normally operate at a Level 2 environment, but do deal with sensitive information, may want to consider this policy setting.", + "ImpactStatement": "Individuals who were not sent or forwarded a meeting invite will not be able to join the meeting automatically.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set Anonymous users can join a meeting unverified to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify that Anonymous users can join a meeting unverified is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowAnonymousUsersToJoinMeeting 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On (True)", + "References": "https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#configure-meeting-settings:https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference?WT.mc_id=TeamsAdminCenterCSH#meeting-join--lobby:https://learn.microsoft.com/en-us/MicrosoftTeams/configure-meetings-sensitive-protection:https://learn.microsoft.com/en-us/microsoftteams/anonymous-users-in-meetings:https://learn.microsoft.com/en-us/microsoftteams/plan-meetings-external-participants" + } + ] + }, + { + "Id": "8.5.2", + "Description": "This policy setting controls if an anonymous participant can start a Microsoft Teams meeting without someone in attendance. Anonymous users and dial-in callers must wait in the lobby until the meeting is started by someone in the organization or an external user from a trusted organization. Anonymous participants are classified as: - Participants who are not logged in to Teams with a work or school account. - Participants from non-trusted organizations (as configured in external access). - Participants from organizations where there is not mutual trust. Note: This setting only applies when Who can bypass the lobby is set to Everyone. If the anonymous users can join a meeting organization-level setting or meeting policy is Off, this setting only applies to dial-in callers.", + "Checks": [ + "teams_meeting_anonymous_user_start_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls if an anonymous participant can start a Microsoft Teams meeting without someone in attendance. Anonymous users and dial-in callers must wait in the lobby until the meeting is started by someone in the organization or an external user from a trusted organization. Anonymous participants are classified as: - Participants who are not logged in to Teams with a work or school account. - Participants from non-trusted organizations (as configured in external access). - Participants from organizations where there is not mutual trust. Note: This setting only applies when Who can bypass the lobby is set to Everyone. If the anonymous users can join a meeting organization-level setting or meeting policy is Off, this setting only applies to dial-in callers.", + "RationaleStatement": "Not allowing anonymous participants to automatically join a meeting reduces the risk of meeting spamming.", + "ImpactStatement": "Anonymous participants will not be able to start a Microsoft Teams meeting.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set Anonymous users and dial-in callers can start a meeting to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToStartMeeting $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify that Anonymous users and dial-in callers can start a meeting is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowAnonymousUsersToStartMeeting 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "Off (False)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/anonymous-users-in-meetings:https://learn.microsoft.com/en-us/microsoftteams/who-can-bypass-meeting-lobby#overview-of-lobby-settings-and-policies" + } + ] + }, + { + "Id": "8.5.3", + "Description": "This policy setting controls who can join a meeting directly and who must wait in the lobby until they're admitted by an organizer, co-organizer, or presenter of the meeting. The recommended state is People who were invited, People in my org or Only organizers and co-organizers.", + "Checks": [ + "teams_meeting_external_lobby_bypass_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls who can join a meeting directly and who must wait in the lobby until they're admitted by an organizer, co-organizer, or presenter of the meeting. The recommended state is People who were invited, People in my org or Only organizers and co-organizers.", + "RationaleStatement": "For meetings that could contain sensitive information, it is best to allow the meeting organizer to vet anyone not directly sent an invite before admitting them to the meeting. This will also prevent the anonymous user from using the meeting link to have meetings at unscheduled times.", + "ImpactStatement": "Individuals who are not part of the organization will have to wait in the lobby until they're admitted by an organizer, co-organizer, or presenter of the meeting. Any individual who dials into the meeting regardless of status will also have to wait in the lobby. This includes internal users who are considered unauthenticated when dialing in.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set Who can bypass the lobby to one of the following: o People who were invited o People in my org o Only organizers and co-organizers To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run one of the following PowerShell commands depending on the desired compliant state: To set to People who were invited: Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers \"InvitedUsers\" To set to People in my org: Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers \"EveryoneInCompanyExcludingGuests\" To set to Only organizers and co-organizers: Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers \"OrganizerOnly\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify Who can bypass the lobby is set to one of the following: o People who were invited o People in my org o Only organizers and co-organizers To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AutoAdmittedUsers 3. Verify that the returned value is one of the following strings: o InvitedUsers o EveryoneInCompanyExcludingGuests o OrganizerOnly", + "AdditionalInformation": "", + "DefaultValue": "People in my org and guests (EveryoneInCompany)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/who-can-bypass-meeting-lobby#overview-of-lobby-settings-and-policies:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.4", + "Description": "This policy setting controls if users who dial in by phone can join the meeting directly or must wait in the lobby. Admittance to the meeting from the lobby is authorized by the meeting organizer, co-organizer, or presenter of the meeting.", + "Checks": [ + "teams_meeting_dial_in_lobby_bypass_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls if users who dial in by phone can join the meeting directly or must wait in the lobby. Admittance to the meeting from the lobby is authorized by the meeting organizer, co-organizer, or presenter of the meeting.", + "RationaleStatement": "For meetings that could contain sensitive information, it is best to allow the meeting organizer to vet anyone not directly from the organization.", + "ImpactStatement": "Individuals who are dialing in to the meeting must wait in the lobby until a meeting organizer, co-organizer, or presenter admits them.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby set People dialing in can bypass the lobby to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowPSTNUsersToBypassLobby $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting join & lobby verify that People dialing in can bypass the lobby is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowPSTNUsersToBypassLobby 3. Verify that the value is False.", + "AdditionalInformation": "", + "DefaultValue": "Off (False)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/who-can-bypass-meeting-lobby#overview-of-lobby-settings-and-policies:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.5", + "Description": "This policy setting controls who has access to read and write chat messages during a meeting.", + "Checks": [ + "teams_meeting_chat_anonymous_users_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls who has access to read and write chat messages during a meeting.", + "RationaleStatement": "Ensuring that only authorized individuals can read and write chat messages during a meeting reduces the risk that a malicious user can inadvertently show content that is not appropriate or view sensitive information.", + "ImpactStatement": "Only authorized individuals will be able to read and write chat messages during a meeting.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement set Meeting chat to On for everyone but anonymous users. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the minimum recommended state: Set-CsTeamsMeetingPolicy -Identity Global -MeetingChatEnabledType \"EnabledExceptAnonymous\" Note: The audit section outlines additional compliant states which are more restrictive than the recommended state.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement verify that Meeting chat is set to On for everyone but anonymous users or a more restrictive value: In-meeting only except anonymous or Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl MeetingChatEnabledType 3. Verify that the returned value is EnabledExceptAnonymous or a more restrictive value EnabledInMeetingOnlyForAllExceptAnonymous or Disabled.", + "AdditionalInformation": "", + "DefaultValue": "On for everyone (Enabled)", + "References": "https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps#-meetingchatenabledtype" + } + ] + }, + { + "Id": "8.5.6", + "Description": "This policy setting controls who can present in a Teams meeting. Note: Organizers and co-organizers can change this setting when the meeting is set up.", + "Checks": [ + "teams_meeting_presenters_restricted" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This policy setting controls who can present in a Teams meeting. Note: Organizers and co-organizers can change this setting when the meeting is set up.", + "RationaleStatement": "Ensuring that only authorized individuals are able to present reduces the risk that a malicious user can inadvertently show content that is not appropriate.", + "ImpactStatement": "Only organizers and co-organizers will be able to present without being granted permission.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing set Who can present to Only organizers and co- organizers. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -DesignatedPresenterRoleMode \"OrganizerOnlyUserOverride\"", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing verify Who can present is set to Only organizers and co-organizers. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl DesignatedPresenterRoleMode 3. Verify that the returned value is OrganizerOnlyUserOverride.", + "AdditionalInformation": "", + "DefaultValue": "Everyone (EveryoneUserOverride)", + "References": "https://learn.microsoft.com/en-US/microsoftteams/meeting-who-present-request-control:https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control#manage-who-can-present:https://learn.microsoft.com/en-us/defender-office-365/step-by-step-guides/reducing-attack-surface-in-microsoft-teams?view=o365-worldwide#configure-meeting-settings-restrict-presenters:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.7", + "Description": "This policy setting allows control of who can present in meetings and who can request control of the presentation while a meeting is underway.", + "Checks": [ + "teams_meeting_external_control_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This policy setting allows control of who can present in meetings and who can request control of the presentation while a meeting is underway.", + "RationaleStatement": "Ensuring that only authorized individuals and not external participants are able to present and request control reduces the risk that a malicious user can inadvertently show content that is not appropriate. External participants are categorized as follows: external users, guests, and anonymous users.", + "ImpactStatement": "External participants will not be able to present or request control during the meeting. Warning: This setting also affects webinars. Note: At this time, to give and take control of shared content during a meeting, both parties must be using the Teams desktop client. Control isn't supported when either party is running Teams in a browser.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing set External participants can give or request control to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global - AllowExternalParticipantGiveRequestControl $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under content sharing verify that External participants can give or request control is Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowExternalParticipantGiveRequestControl 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "Off (False)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control:https://learn.microsoft.com/en-us/powershell/module/skype/set-csteamsmeetingpolicy?view=skype-ps" + } + ] + }, + { + "Id": "8.5.8", + "Description": "This meeting policy setting controls whether users can read or write messages in external meeting chats with untrusted organizations. If an external organization is on the list of trusted organizations this setting will be ignored.", + "Checks": [ + "teams_meeting_external_chat_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This meeting policy setting controls whether users can read or write messages in external meeting chats with untrusted organizations. If an external organization is on the list of trusted organizations this setting will be ignored.", + "RationaleStatement": "Restricting access to chat in meetings hosted by external organizations limits the opportunity for an exploit like GIFShell or DarkGate malware from being delivered to users.", + "ImpactStatement": "When joining external meetings users will be unable to read or write chat messages in Teams meetings with organizations that they don't have a trust relationship with. This will completely remove the chat functionality in meetings. From an I.T. perspective both the upkeep of adding new organizations to the trusted list and the decision-making process behind whether to trust or not trust an external partner will increase time expenditure.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab.. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement set External meeting chat to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalNonTrustedMeetingChat $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under meeting engagement verify that External meeting chat is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowExternalNonTrustedMeetingChat 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On(True)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference#meeting-engagement" + } + ] + }, + { + "Id": "8.5.9", + "Description": "This setting controls the ability for a user to initiate a recording of a meeting in progress. The recommended state is Off for the Global (Org-wide default) meeting policy.", + "Checks": [ + "teams_meeting_recording_disabled" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.5 Meetings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "This setting controls the ability for a user to initiate a recording of a meeting in progress. The recommended state is Off for the Global (Org-wide default) meeting policy.", + "RationaleStatement": "Disabling meeting recordings in the Global meeting policy ensures that only authorized users, such as organizers, co-organizers, and leads, can initiate a recording. This measure helps safeguard sensitive information by preventing unauthorized individuals from capturing and potentially sharing meeting content. Restricting recording capabilities to specific roles allows organizations to exercise greater control over what is recorded, aligning it with the meeting's confidentiality requirements. Note: Creating a separate policy for users or groups who are allowed to record is expected and in compliance. This control is only for the default meeting policy.", + "ImpactStatement": "If there are no additional policies allowing anyone to record, then recording will effectively be disabled.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under Recording & transcription set Meeting recording to Off. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to set the recommended state: Set-CsTeamsMeetingPolicy -Identity Global -AllowCloudRecording $false", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Meetings to open the meeting settings section. 4. Under Recording & transcription verify that Meeting recording is set to Off. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following command to verify the recommended state: Get-CsTeamsMeetingPolicy -Identity Global | fl AllowCloudRecording 3. Verify that the returned value is False.", + "AdditionalInformation": "", + "DefaultValue": "On (True)", + "References": "https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference#recording--transcription" + } + ] + }, + { + "Id": "8.6.1", + "Description": "User reporting settings allow a user to report a message as malicious for further analysis. This recommendation is composed of 3 different settings and all must be configured for compliance: - In the Teams admin center: On by default and controls whether users are able to report messages from Teams. When this setting is turned off, users can't report messages within Teams, so the corresponding setting in the Microsoft 365 Defender portal is irrelevant. - In the Microsoft 365 Defender portal: On by default for new tenants. Existing tenants need to enable it. If user reporting of messages is turned on in the Teams admin center, it also needs to be turned on the Defender portal for user reported messages to show up correctly on the User reported tab on the Submissions page. - Defender - Report message destinations: This applies to more than just Microsoft Teams and allows for an organization to keep their reports contained. Due to how the parameters are configured on the backend it is included in this assessment as a requirement.", + "Checks": [ + "teams_security_reporting_enabled", + "defender_chat_report_policy_configured" + ], + "Attributes": [ + { + "Section": "8 Microsoft Teams admin center", + "SubSection": "8.6 Messaging", + "Profile": "E5 Level 1", + "AssessmentStatus": "Automated", + "Description": "User reporting settings allow a user to report a message as malicious for further analysis. This recommendation is composed of 3 different settings and all must be configured for compliance: - In the Teams admin center: On by default and controls whether users are able to report messages from Teams. When this setting is turned off, users can't report messages within Teams, so the corresponding setting in the Microsoft 365 Defender portal is irrelevant. - In the Microsoft 365 Defender portal: On by default for new tenants. Existing tenants need to enable it. If user reporting of messages is turned on in the Teams admin center, it also needs to be turned on the Defender portal for user reported messages to show up correctly on the User reported tab on the Submissions page. - Defender - Report message destinations: This applies to more than just Microsoft Teams and allows for an organization to keep their reports contained. Due to how the parameters are configured on the backend it is included in this assessment as a requirement.", + "RationaleStatement": "Users will be able to more quickly and systematically alert administrators of suspicious malicious messages within Teams. The content of these messages may be sensitive in nature and therefore should be kept within the organization and not shared with Microsoft without first consulting company policy. Note: - The reported message remains visible to the user in the Teams client. - Users can report the same message multiple times. - The message sender isn't notified that messages were reported.", + "ImpactStatement": "Enabling message reporting has an impact beyond just addressing security concerns. When users of the platform report a message, the content could include messages that are threatening or harassing in nature, possibly stemming from colleagues. Due to this the security staff responsible for reviewing and acting on these reports should be equipped with the skills to discern and appropriately direct such messages to the relevant departments, such as Human Resources (HR).", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Messaging to open the messaging settings section. 4. Set Report a security concern to On. 5. Next, navigate to Microsoft 365 Defender https://security.microsoft.com/ 6. Expand System > Settings and select Email & collaboration. 7. Click on User reported settings. 8. Under Microsoft Teams check the box for Monitor reported items in Microsoft Teams and click Save. 9. Set Send reported messages to: to My reporting mailbox only with reports configured to be sent to authorized staff. To remediate using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Connect to Exchange Online PowerShell using Connect-ExchangeOnline. 3. Run the following cmdlet: Set-CsTeamsMessagingPolicy -Identity Global -AllowSecurityEndUserReporting $true 4. To configure the Defender reporting policies, edit and run this script: $usersub = \"userreportedmessages@fabrikam.com\" # Change this. $params = @{ Identity = \"DefaultReportSubmissionPolicy\" EnableReportToMicrosoft = $false ReportChatMessageEnabled = $false ReportChatMessageToCustomizedAddressEnabled = $true ReportJunkToCustomizedAddress = $true ReportNotJunkToCustomizedAddress = $true ReportPhishToCustomizedAddress = $true ReportJunkAddresses = $usersub ReportNotJunkAddresses = $usersub ReportPhishAddresses = $usersub } Set-ReportSubmissionPolicy @params New-ReportSubmissionRule -Name DefaultReportSubmissionRule - ReportSubmissionPolicy DefaultReportSubmissionPolicy -SentTo $usersub", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click Settings & policies and select the Global (Org-wide default) settings tab. 3. Select Messaging to open the messaging settings section. 4. Verify that Report a security concern is On. 5. Next, navigate to Microsoft 365 Defender https://security.microsoft.com/ 6. Expand System > Settings and select Email & collaboration. 7. Click on User reported settings. 8. Under Microsoft Teams verify that Monitor reported items in Microsoft Teams is checked. 9. Verify that Send reported messages to: is set to My reporting mailbox only with report email addresses defined for authorized staff. To audit using PowerShell: 1. Connect to Teams PowerShell using Connect-MicrosoftTeams. 2. Run the following cmdlet for to assess Teams: Get-CsTeamsMessagingPolicy -Identity Global | fl AllowSecurityEndUserReporting 3. Verify that the value returned is True. 4. Connect to Exchange Online PowerShell using Connect-ExchangeOnline. 5. Run this cmdlet to assess Defender: Get-ReportSubmissionPolicy | fl Report* 6. Verify that the output matches the following values with organization specific email addresses: ReportJunkToCustomizedAddress : True ReportNotJunkToCustomizedAddress : True ReportPhishToCustomizedAddress : True ReportJunkAddresses : {SOC@contoso.com} ReportNotJunkAddresses : {SOC@contoso.com} ReportPhishAddresses : {SOC@contoso.com} ReportChatMessageEnabled : False ReportChatMessageToCustomizedAddressEnabled : True", + "AdditionalInformation": "", + "DefaultValue": "On (True) Report message destination: Microsoft Only", + "References": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide" + } + ] + }, + { + "Id": "9.1.1", + "Description": "This setting allows business-to-business (B2B) guests access to Microsoft Fabric, and contents that they have permissions to. With the setting turned off, B2B guest users receive an error when trying to access Power BI. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting allows business-to-business (B2B) guests access to Microsoft Fabric, and contents that they have permissions to. With the setting turned off, B2B guest users receive an error when trying to access Power BI. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Azure that are new or assigned guest status from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Security groups will need to be more closely tended to and monitored.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Guest users can access Microsoft Fabric to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Guest users can access Microsoft Fabric is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName AllowGuestUserToAccessSharedContent in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.2", + "Description": "This setting helps organizations choose whether new external users can be invited to the organization through Power BI sharing, permissions, and subscription experiences. This setting only controls the ability to invite through Power BI. The recommended state is Enabled for a subset of the organization or Disabled. Note: To invite external users to the organization, the user must also have the Microsoft Entra Guest Inviter role.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting helps organizations choose whether new external users can be invited to the organization through Power BI sharing, permissions, and subscription experiences. This setting only controls the ability to invite through Power BI. The recommended state is Enabled for a subset of the organization or Disabled. Note: To invite external users to the organization, the user must also have the Microsoft Entra Guest Inviter role.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Azure that are new or assigned guest status from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Guest user invitations will be limited to only specific employees.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Users can invite guest users to collaborate through item sharing and permissions to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Users can invite guest users to collaborate through item sharing and permissions is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ExternalSharingV2 in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing:https://learn.microsoft.com/en-us/power-bi/enterprise/service-admin-azure-ad-b2b#invite-guest-users" + } + ] + }, + { + "Id": "9.1.3", + "Description": "This setting allows Microsoft Entra B2B guest users to have full access to the browsing experience using the left-hand navigation pane in the organization. Guest users who have been assigned workspace roles or specific item permissions will continue to have those roles and/or permissions, even if this setting is disabled. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting allows Microsoft Entra B2B guest users to have full access to the browsing experience using the left-hand navigation pane in the organization. Guest users who have been assigned workspace roles or specific item permissions will continue to have those roles and/or permissions, even if this setting is disabled. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Entra that are new or assigned guest status from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Security groups will need to be more closely tended to and monitored.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Guest users can browse and access Fabric content to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Guest users can browse and access Fabric content is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ElevatedGuestsTenant in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.4", + "Description": "Power BI enables users to share reports and materials directly on the internet from both the application's desktop version and its web user interface. This functionality generates a publicly reachable web link that doesn't necessitate authentication or the need to be an Entra ID user in order to access and view it. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Power BI enables users to share reports and materials directly on the internet from both the application's desktop version and its web user interface. This functionality generates a publicly reachable web link that doesn't necessitate authentication or the need to be an Entra ID user in order to access and view it. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "When using Publish to Web anyone on the Internet can view a published report or visual. Viewing requires no authentication. It includes viewing detail-level data that your reports aggregate. By disabling the feature, restricting access to certain users and allowing existing embed codes organizations can mitigate the exposure of confidential or proprietary information.", + "ImpactStatement": "Depending on the organization's utilization administrators may experience more overhead managing embed codes, and requests.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Publish to web to one of these states: o Disabled o Enabled with Choose how embed codes work set to Only allow existing codes AND Specific security groups selected and defined Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Publish to web is set to one of the following: o Disabled o Enabled with Choose how embed codes work set to Only allow existing codes AND Specific security groups selected and defined Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName PublishToWeb in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is false. o enabled is true AND createP2w is false AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: The createP2w property can be found nested under properties.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization Only allow existing codes", + "References": "https://learn.microsoft.com/en-us/power-bi/collaborate-share/service-publish-to-web:https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing#publish-to-web" + } + ] + }, + { + "Id": "9.1.5", + "Description": "Power BI allows the integration of R and Python scripts directly into visuals. This feature allows data visualizations by incorporating custom calculations, statistical analyses, machine learning models, and more using R or Python scripts. Custom visuals can be created by embedding them directly into Power BI reports. Users can then interact with these visuals and see the results of the custom code within the Power BI interface.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 2", + "AssessmentStatus": "Automated", + "Description": "Power BI allows the integration of R and Python scripts directly into visuals. This feature allows data visualizations by incorporating custom calculations, statistical analyses, machine learning models, and more using R or Python scripts. Custom visuals can be created by embedding them directly into Power BI reports. Users can then interact with these visuals and see the results of the custom code within the Power BI interface.", + "RationaleStatement": "Disabling this feature can reduce the attack surface by preventing potential malicious code execution leading to data breaches, or unauthorized access. The potential for sensitive or confidential data being leaked to unintended users is also increased with the use of scripts.", + "ImpactStatement": "Use of R and Python scripting will require exceptions for developers, along with more stringent code review.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to R and Python visuals settings. 4. Set Interact with and share R and Python visuals to Disabled", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to R and Python visuals settings. 4. Verify that Interact with and share R and Python visuals is set to Disabled To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName RScriptVisual in the output. 3. Verify that enabled is false.", + "AdditionalInformation": "", + "DefaultValue": "Enabled", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-r-python-visuals:https://learn.microsoft.com/en-us/power-bi/visuals/service-r-visuals:https://www.r-project.org/" + } + ] + }, + { + "Id": "9.1.6", + "Description": "Information protection tenant settings help to protect sensitive information in the Power BI tenant. Allowing and applying sensitivity labels to content ensures that information is only seen and accessed by the appropriate users. The recommended state is Enabled or Enabled for a subset of the organization. Note: Sensitivity labels and protection are only applied to files exported to Excel, PowerPoint, or PDF files, that are controlled by \"Export to Excel\" and \"Export reports as PowerPoint presentation or PDF documents\" settings. All other export and sharing options do not support the application of sensitivity labels and protection. Note 2: There are some prerequisite steps that need to be completed in order to fully utilize labeling. See here.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Information protection tenant settings help to protect sensitive information in the Power BI tenant. Allowing and applying sensitivity labels to content ensures that information is only seen and accessed by the appropriate users. The recommended state is Enabled or Enabled for a subset of the organization. Note: Sensitivity labels and protection are only applied to files exported to Excel, PowerPoint, or PDF files, that are controlled by \"Export to Excel\" and \"Export reports as PowerPoint presentation or PDF documents\" settings. All other export and sharing options do not support the application of sensitivity labels and protection. Note 2: There are some prerequisite steps that need to be completed in order to fully utilize labeling. See here.", + "RationaleStatement": "Establishing data classifications and affixing labels to data at creation enables organizations to discern the data's criticality, sensitivity, and value. This initial identification enables the implementation of appropriate protective measures, utilizing technologies like Data Loss Prevention (DLP) to avert inadvertent exposure and enforcing access controls to safeguard against unauthorized access. This practice can also promote user awareness and responsibility in regard to the nature of the data they interact with. Which in turn can foster awareness in other areas of data management across the organization.", + "ImpactStatement": "Additional license requirements like Power BI Pro are required, as outlined in the Licensed and requirements page linked in the description and references sections.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Information protection. 4. Set Allow users to apply sensitivity labels for content to one of these states: o Enabled o Enabled with Specific security groups selected and defined.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Information protection. 4. Verify that Allow users to apply sensitivity labels for content is set to one of the following: o Enabled o Enabled with Specific security groups selected and defined. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName EimInformationProtectionEdit in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is true. o enabled is true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled", + "References": "https://learn.microsoft.com/en-us/power-bi/enterprise/service-security-enable-data-sensitivity-labels:https://learn.microsoft.com/en-us/fabric/governance/data-loss-prevention-overview:https://learn.microsoft.com/en-us/power-bi/enterprise/service-security-enable-data-sensitivity-labels#licensing-and-requirements" + } + ] + }, + { + "Id": "9.1.7", + "Description": "Creating a shareable link allows a user to create a link to a report or dashboard, then add that link to an email or another messaging application. There are 3 options that can be selected when creating a shareable link: - People in your organization - People with existing access - Specific people This setting solely deals with restrictions to People in the organization. External users by default are not included in any of these categories, and therefore cannot use any of these links regardless of the state of this setting. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Creating a shareable link allows a user to create a link to a report or dashboard, then add that link to an email or another messaging application. There are 3 options that can be selected when creating a shareable link: - People in your organization - People with existing access - Specific people This setting solely deals with restrictions to People in the organization. External users by default are not included in any of these categories, and therefore cannot use any of these links regardless of the state of this setting. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "While external users are unable to utilize shareable links, disabling or restricting this feature ensures that a user cannot generate a link accessible by individuals within the same organization who lack the necessary clearance to the shared data. For example, a member of Human Resources intends to share sensitive information with a particular employee or another colleague within their department. The owner would be prompted to specify either People with existing access or Specific people when generating the link requiring the person clicking the link to pass a first layer access control list. This measure along with proper file and folder permissions can help prevent unintended access and potential information leakage.", + "ImpactStatement": "If the setting is Enabled then only specific people in the organization would be allowed to create general links viewable by the entire organization.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Allow shareable links to grant access to everyone in your organization to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Allow shareable links to grant access to everyone in your organization is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ShareLinkToEntireOrg in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/power-bi/collaborate-share/service-share-dashboards?wt.mc_id=powerbi_inproduct_sharedialog#link-settings:https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.8", + "Description": "Power BI admins can specify which users or user groups can share datasets externally with guests from a different tenant through the in-place mechanism. Disabling this setting prevents any user from sharing datasets externally by restricting the ability of users to turn on external sharing for datasets they own or manage. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Power BI admins can specify which users or user groups can share datasets externally with guests from a different tenant through the in-place mechanism. Disabling this setting prevents any user from sharing datasets externally by restricting the ability of users to turn on external sharing for datasets they own or manage. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Establishing and enforcing a dedicated security group prevents unauthorized access to Microsoft Fabric for guests collaborating in Azure that are new or from other applications. This upholds the principle of least privilege and uses role-based access control (RBAC). These security groups can also be used for tasks like conditional access, enhancing risk management and user accountability across the organization.", + "ImpactStatement": "Security groups will need to be more closely tended to and monitored.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Set Allow specific users to turn on external data sharing to one of these states: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Export and Sharing settings. 4. Verify that Allow specific users to turn on external data sharing is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName EnableDatasetInPlaceSharing in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-export-sharing" + } + ] + }, + { + "Id": "9.1.9", + "Description": "This setting blocks the use of resource key based authentication. The Block ResourceKey Authentication setting applies to streaming and PUSH datasets. If blocked users will not be allowed to send data to streaming and PUSH datasets using the API with a resource key. The recommended state is Enabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "This setting blocks the use of resource key based authentication. The Block ResourceKey Authentication setting applies to streaming and PUSH datasets. If blocked users will not be allowed to send data to streaming and PUSH datasets using the API with a resource key. The recommended state is Enabled.", + "RationaleStatement": "Resource keys are a form of authentication that allows users to access Power BI resources (such as reports, dashboards, and datasets) without requiring individual user accounts. While convenient, this method bypasses the organization's centralized identity and access management controls. Enabling ensures that access to Power BI resources is tied to the organization's authentication mechanisms, providing a more secure and controlled environment.", + "ImpactStatement": "Developers will need to request a special exception in order to use this feature.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Block ResourceKey Authentication to Enabled", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Block ResourceKey Authentication is set to Enabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName BlockResourceKeyAuthentication in the output. 3. Verify that enabled is set to true. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer:https://learn.microsoft.com/en-us/power-bi/connect-data/service-real-time-streaming" + } + ] + }, + { + "Id": "9.1.10", + "Description": "Use a service principal to access Fabric public APIs that include create, read, update, and delete (CRUD) operations, and are protected by a Fabric permission model. To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Use a service principal to access Fabric public APIs that include create, read, update, and delete (CRUD) operations, and are protected by a Fabric permission model. To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Leaving API access unrestricted increases the attack surface in the event an adversary gains access to a Service Principal. APIs are a feature-rich method for programmatic access to many areas of Power Bi and should be guarded closely.", + "ImpactStatement": "Service principals will need to be members of specific security groups in order to perform public API calls.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Service principals can call Fabric public APIs to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Service principals can call Fabric public APIs is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ServicePrincipalAccessPermissionAPIs in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Enabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer" + } + ] + }, + { + "Id": "9.1.11", + "Description": "Service principal profiles provide a flexible solution for apps used in a multitenancy deployment. The profiles enable customer data isolation and tighter security boundaries between customers that are utilizing the app. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Service principal profiles provide a flexible solution for apps used in a multitenancy deployment. The profiles enable customer data isolation and tighter security boundaries between customers that are utilizing the app. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Service Principals should be restricted to a security group to limit which Service Principals can interact with profiles. This supports the principle of least privilege.", + "ImpactStatement": "Disabled is the default behavior.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Allow service principals to create and use profiles to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Allow service principals to create and use profiles is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName AllowServicePrincipalsCreateAndUseProfiles in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer:https://learn.microsoft.com/en-us/power-bi/developer/embedded/embed-multi-tenancy" + } + ] + }, + { + "Id": "9.1.12", + "Description": "Use a service principal to access these Fabric APIs that aren't protected by a Fabric permission model. - Create Workspace - Create Connection - Create Deployment Pipeline To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "Checks": [], + "Attributes": [ + { + "Section": "9 Microsoft Fabric", + "SubSection": "9.1 Tenant settings", + "Profile": "E3 Level 1", + "AssessmentStatus": "Automated", + "Description": "Use a service principal to access these Fabric APIs that aren't protected by a Fabric permission model. - Create Workspace - Create Connection - Create Deployment Pipeline To allow an app to use service principal authentication, its service principal must be included in an allowed security group. You can control who can access service principals by creating dedicated security groups and using these groups in other tenant settings. The recommended state is Enabled for a subset of the organization or Disabled.", + "RationaleStatement": "Leaving API access unrestricted increases the attack surface in the event an adversary gains access to a Service Principal. APIs are a feature-rich method for programmatic access to many areas of Power Bi and should be guarded closely.", + "ImpactStatement": "Service principals will need to be members of specific security groups in order to perform public API calls.", + "RemediationProcedure": "To remediate using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Set Service principals can create workspaces, connections, and deployment pipelines to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled.", + "AuditProcedure": "To audit using the UI: 1. Navigate to Microsoft Fabric https://app.powerbi.com/admin-portal 2. Select Tenant settings. 3. Scroll to Developer settings. 4. Verify that Service principals can create workspaces, connections, and deployment pipelines is set to one of the following: o Disabled o Enabled with Specific security groups selected and defined. Important: If the organization doesn't actively use this feature it is recommended to keep it Disabled. To audit using PowerShell: 1. Inspect the results of the Get-CISFabricTenantSettings function from the section overview. 2. Locate the settingName ServicePrincipalAccessGlobalAPIs in the output. 3. Verify that the properties adhere to one of the following compliant configurations: o enabled is set to false. o enabled is set to true AND enabledSecurityGroups contains at least one security group. 4. If neither condition is met, the setting is non-compliant. Note: If the Specific security groups setting is not enabled then the enabledSecurityGroups property does not appear in the output.", + "AdditionalInformation": "", + "DefaultValue": "Disabled for the entire organization", + "References": "https://learn.microsoft.com/en-us/fabric/admin/service-admin-portal-developer" + } + ] + } + ] +} \ No newline at end of file diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index ede6727166..238002d0b6 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -206,7 +206,11 @@ "admincenter_users_admins_reduced_license_footprint", "entra_admin_portals_access_restriction", "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "entra_conditional_access_policy_block_unknown_device_platforms", + "entra_conditional_access_policy_directory_sync_account_excluded", + "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" ] @@ -245,14 +249,19 @@ "entra_admin_users_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", "entra_break_glass_account_fido2_security_key_registered", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_default_app_management_policy_enabled", + "entra_emergency_access_exclusion", "entra_all_apps_conditional_access_coverage", "entra_conditional_access_policy_device_registration_mfa_required", "entra_intune_enrollment_sign_in_frequency_every_time", + "entra_conditional_access_policy_block_unknown_device_platforms", "entra_conditional_access_policy_device_code_flow_blocked", + "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", "entra_legacy_authentication_blocked", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", + "entra_service_principal_no_secrets_for_permanent_tier0_roles", "entra_users_mfa_enabled", "exchange_organization_modern_authentication_enabled", "exchange_transport_config_smtp_auth_disabled", @@ -275,6 +284,7 @@ "entra_admin_portals_access_restriction", "entra_app_registration_no_unused_privileged_permissions", "entra_policy_guest_users_access_restrictions", + "entra_service_principal_no_secrets_for_permanent_tier0_roles", "sharepoint_external_sharing_managed", "sharepoint_external_sharing_restricted", "sharepoint_guest_sharing_restricted" @@ -425,6 +435,7 @@ ], "Checks": [ "admincenter_groups_not_public_visibility", + "exchange_organization_delicensing_resiliency_enabled", "teams_meeting_recording_disabled" ] }, @@ -626,6 +637,7 @@ "entra_admin_users_phishing_resistant_mfa_enabled", "entra_conditional_access_policy_approved_client_app_required_for_mobile", "entra_conditional_access_policy_app_enforced_restrictions", + "entra_conditional_access_policy_block_unknown_device_platforms", "entra_conditional_access_policy_device_registration_mfa_required", "entra_intune_enrollment_sign_in_frequency_every_time", "entra_managed_device_required_for_authentication", @@ -662,10 +674,12 @@ "entra_admin_users_phishing_resistant_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", "entra_break_glass_account_fido2_security_key_registered", + "entra_emergency_access_exclusion", "entra_app_registration_no_unused_privileged_permissions", "entra_policy_ensure_default_user_cannot_create_tenants", "entra_policy_guest_invite_only_for_admin_roles", - "entra_seamless_sso_disabled" + "entra_seamless_sso_disabled", + "entra_service_principal_no_secrets_for_permanent_tier0_roles" ] }, { @@ -685,7 +699,10 @@ "entra_conditional_access_policy_approved_client_app_required_for_mobile", "entra_conditional_access_policy_app_enforced_restrictions", "entra_conditional_access_policy_block_elevated_insider_risk", + "entra_conditional_access_policy_directory_sync_account_excluded", "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "entra_conditional_access_policy_block_unknown_device_platforms", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_policy_guest_users_access_restrictions", "sharepoint_external_sharing_restricted" ] @@ -707,13 +724,19 @@ "entra_admin_users_sign_in_frequency_enabled", "entra_all_apps_conditional_access_coverage", "entra_conditional_access_policy_device_registration_mfa_required", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_intune_enrollment_sign_in_frequency_every_time", "entra_break_glass_account_fido2_security_key_registered", "entra_conditional_access_policy_approved_client_app_required_for_mobile", + "entra_conditional_access_policy_block_unknown_device_platforms", "entra_conditional_access_policy_device_code_flow_blocked", + "entra_conditional_access_policy_directory_sync_account_excluded", + "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", + "entra_emergency_access_exclusion", "entra_identity_protection_sign_in_risk_enabled", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", + "entra_service_principal_no_secrets_for_permanent_tier0_roles", "entra_users_mfa_enabled" ] }, diff --git a/prowler/compliance/m365/prowler_threatscore_m365.json b/prowler/compliance/m365/prowler_threatscore_m365.json index 286a4fff39..f5a6cd1cd8 100644 --- a/prowler/compliance/m365/prowler_threatscore_m365.json +++ b/prowler/compliance/m365/prowler_threatscore_m365.json @@ -819,6 +819,14 @@ "LevelOfRisk": 4, "Weight": 100 } + ], + "ConfigRequirements": [ + { + "Check": "entra_admin_users_sign_in_frequency_enabled", + "ConfigKey": "sign_in_frequency", + "Operator": "lte", + "Value": 4 + } ] }, { @@ -964,6 +972,68 @@ "LevelOfRisk": 2, "Weight": 8 } + ], + "ConfigRequirements": [ + { + "Check": "defender_malware_policy_comprehensive_attachments_filter_applied", + "ConfigKey": "recommended_blocked_file_types", + "Operator": "superset", + "Value": [ + "ace", + "ani", + "apk", + "app", + "appx", + "arj", + "bat", + "cab", + "cmd", + "com", + "deb", + "dex", + "dll", + "docm", + "elf", + "exe", + "hta", + "img", + "iso", + "jar", + "jnlp", + "kext", + "lha", + "lib", + "library", + "lnk", + "lzh", + "macho", + "msc", + "msi", + "msix", + "msp", + "mst", + "pif", + "ppa", + "ppam", + "reg", + "rev", + "scf", + "scr", + "sct", + "sys", + "uif", + "vb", + "vbe", + "vbs", + "vxd", + "wsc", + "wsf", + "wsh", + "xll", + "xz", + "z" + ] + } ] }, { diff --git a/prowler/compliance/okta/__init__.py b/prowler/compliance/okta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json new file mode 100644 index 0000000000..66747cdba7 --- /dev/null +++ b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json @@ -0,0 +1,670 @@ +{ + "Framework": "Okta-IDaaS-STIG", + "Name": "DISA Okta Identity as a Service (IDaaS) STIG V1R2", + "Version": "1R2", + "Provider": "Okta", + "Description": "Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS), Version 1 Release 2 (Benchmark Date: 05 Jan 2026).", + "Requirements": [ + { + "Id": "OKTA-APP-000020", + "Name": "Okta must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead. Satisfies: SRG-APP-000003, SRG-APP-000190", + "Checks": [ + "signon_global_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273186r1098825_rule", + "StigID": "OKTA-APP-000020", + "CCI": [ + "CCI-000057", + "CCI-001133" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the edit icon next to the Priority 1 rule. 4. Verify the \"Maximum Okta global session idle time\" is set to 15 minutes. If \"Maximum Okta global session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session idle time\" to 15 minutes." + } + ], + "ConfigRequirements": [ + { + "Check": "signon_global_session_idle_timeout_15min", + "ConfigKey": "okta_max_session_idle_minutes", + "Operator": "lte", + "Value": 15 + } + ] + }, + { + "Id": "OKTA-APP-000025", + "Name": "The Okta Admin Console must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead.", + "Checks": [ + "application_admin_console_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273187r1098828_rule", + "StigID": "OKTA-APP-000025", + "CCI": [ + "CCI-000057" + ], + "CheckText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", verify the \"Maximum app session idle time\" is set to 15 minutes. If the \"Maximum app session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", set the \"Maximum app session idle time\" to 15 minutes." + } + ], + "ConfigRequirements": [ + { + "Check": "application_admin_console_session_idle_timeout_15min", + "ConfigKey": "okta_admin_console_idle_timeout_max_minutes", + "Operator": "lte", + "Value": 15 + } + ] + }, + { + "Id": "OKTA-APP-000090", + "Name": "Okta must automatically disable accounts after a 35-day period of account inactivity.", + "Description": "Attackers that are able to exploit an inactive account can potentially obtain and maintain undetected access to an application. Owners of inactive accounts will not notice if unauthorized access to their user account has been obtained. Applications must track periods of user inactivity and disable accounts after 35 days of inactivity. Such a process greatly reduces the risk that accounts will be hijacked, leading to a data compromise. To address access requirements, many application developers choose to integrate their applications with enterprise-level authentication/access mechanisms that meet or exceed access control policy requirements. Such integration allows the application developer to off-load those access control functions and focus on core application features and functionality. This policy does not apply to emergency accounts or infrequently used accounts. Infrequently used accounts are local login administrator accounts used by system administrators when network or normal login/access is not available. Emergency accounts are administrator accounts created in response to crisis situations. Satisfies: SRG-APP-000025, SRG-APP-000163, SRG-APP-000700", + "Checks": [ + "user_inactivity_automation_35d_enabled" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273188r1098831_rule", + "StigID": "OKTA-APP-000090", + "CCI": [ + "CCI-000017", + "CCI-000795", + "CCI-003627" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this is not applicable, and the connected directory services must perform this function. Go to Workflows >> Automations and verify that an Automation has been created to disable accounts after 35 days of inactivity. If the Okta configuration does not automatically disable accounts after a 35-day period of account inactivity, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Workflow >> Automations and select \"Add Automation\". 2. Create a name for the Automation (e.g., \"User Inactivity\"). 3. Click \"Add Condition\" and select \"User Inactivity in Okta\". 4. In the duration field, enter 35 days and click \"Save\". 5 Click the edit button next to \"Select Schedule\". 6. Configure the \"Schedule\" field for \"Run Daily\" and set the \"Time\" field to an organizationally defined time to run this automation. Click \"Save\". 7. Click the edit button next to \"Select group membership\". 8. In the \"Applies to\" field, select the group \"Everyone\" by typing it into the field. Click \"Save\". 9. Click \"Add Action\" and select \"Change User lifecycle state in Okta\". 10. In the \"Change user state to\" field, select \"Suspended\" and click \"Save\". 11. Click the \"Inactive\" button near the top of the section screen and select \"Activate\"." + } + ], + "ConfigRequirements": [ + { + "Check": "user_inactivity_automation_35d_enabled", + "ConfigKey": "okta_user_inactivity_max_days", + "Operator": "lte", + "Value": 35 + } + ] + }, + { + "Id": "OKTA-APP-000170", + "Name": "Okta must enforce the limit of three consecutive invalid login attempts by a user during a 15-minute time period.", + "Description": "By limiting the number of failed login attempts, the risk of unauthorized system access via user password guessing, otherwise known as brute forcing, is reduced. Limits are imposed by locking the account. Satisfies: SRG-APP-000065, SRG-APP-000345", + "Checks": [ + "authenticator_password_lockout_threshold_3" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273189r1098834_rule", + "StigID": "OKTA-APP-000170", + "CCI": [ + "CCI-000044", + "CCI-002238" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this check is not applicable, and the connected directory services must perform this function. From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, verify the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\". If Okta Services are not configured to automatically lock user accounts after three consecutive invalid login attempts, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, ensure the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\"." + } + ] + }, + { + "Id": "OKTA-APP-000180", + "Name": "The Okta Dashboard application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_dashboard_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273190r1099763_rule", + "StigID": "OKTA-APP-000180", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000190", + "Name": "The Okta Admin Console application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_admin_console_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273191r1099764_rule", + "StigID": "OKTA-APP-000190", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000200", + "Name": "Okta must display the Standard Mandatory DOD Notice and Consent Banner before granting access to the application.", + "Description": "Display of the DOD-approved use notification before granting access to the application ensures that privacy and security notification verbiage used is consistent with applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance. System use notifications are required only for access via login interfaces with human users and are not required when such human interfaces do not exist. The banner must be formatted in accordance with DTM-08-060. Use the following verbiage for applications that can accommodate banners of 1300 characters: \"You are accessing a U.S. Government (USG) Information System (IS) that is provided for USG-authorized use only. By using this IS (which includes any device attached to this IS), you consent to the following conditions: -The USG routinely intercepts and monitors communications on this IS for purposes including, but not limited to, penetration testing, COMSEC monitoring, network operations and defense, personnel misconduct (PM), law enforcement (LE), and counterintelligence (CI) investigations. -At any time, the USG may inspect and seize data stored on this IS. -Communications using, or data stored on, this IS are not private, are subject to routine monitoring, interception, and search, and may be disclosed or used for any USG-authorized purpose. -This IS includes security measures (e.g., authentication and access controls) to protect USG interests--not for your personal benefit or privacy. -Notwithstanding the above, using this IS does not constitute consent to PM, LE or CI investigative searching or monitoring of the content of privileged communications, or work product, related to personal representation or services by attorneys, psychotherapists, or clergy, and their assistants. Such communications and work product are private and confidential. See User Agreement for details.\" Use the following verbiage for operating systems that have severe limitations on the number of characters that can be displayed in the banner: \"I've read & consent to terms in IS user agreem't.\" Satisfies: SRG-APP-000068, SRG-APP-000069, SRG-APP-000070", + "Checks": [ + "signon_dod_warning_banner_configured" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273192r1098843_rule", + "StigID": "OKTA-APP-000200", + "CCI": [ + "CCI-000048", + "CCI-000050", + "CCI-001384", + "CCI-001385", + "CCI-001386", + "CCI-001387", + "CCI-001388" + ], + "CheckText": "Attempt to log in to the Okta tenant and verify the DOD-approved warning banner is in place. If the required warning banner is not present and complete, this is a finding.", + "FixText": "Follow the supplemental instructions in the \"Okta DOD Warning Banner Configuration Guide\" provided with this STIG package." + } + ] + }, + { + "Id": "OKTA-APP-000560", + "Name": "The Okta Admin Console application must be configured to use multifactor authentication.", + "Description": "Without the use of multifactor authentication, the ease of access to privileged functions is greatly increased. Multifactor authentication requires using two or more factors to achieve authentication. Factors include: (i) something a user knows (e.g., password/PIN); (ii) something a user has (e.g., cryptographic identification device, token); or (iii) something a user is (e.g., biometric). A privileged account is defined as an information system account with authorizations of a privileged user. Network access is defined as access to an information system by a user (or a process acting on behalf of a user) communicating through a network (e.g., local area network, wide area network, or the internet). Satisfies: SRG-APP-000149, SRG-APP-000154", + "Checks": [ + "application_admin_console_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273193r1098846_rule", + "StigID": "OKTA-APP-000560", + "CCI": [ + "CCI-000765", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000570", + "Name": "The Okta Dashboard application must be configured to use multifactor authentication.", + "Description": "To ensure accountability and prevent unauthenticated access, nonprivileged users must use multifactor authentication to prevent potential misuse and compromise of the system. Multifactor authentication uses two or more factors to achieve authentication. Factors include: (i) Something you know (e.g., password/PIN); (ii) Something you have (e.g., cryptographic identification device, token); or (iii) Something you are (e.g., biometric). A nonprivileged account is any information system account with authorizations of a nonprivileged user. Network access is any access to an application by a user (or process acting on behalf of a user) where the access is obtained through a network connection. Applications integrating with the DOD Active Directory and using the DOD CAC are examples of compliant multifactor authentication solutions. Satisfies: SRG-APP-000150, SRG-APP-000155", + "Checks": [ + "application_dashboard_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273194r1098849_rule", + "StigID": "OKTA-APP-000570", + "CCI": [ + "CCI-000766", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000650", + "Name": "Okta must enforce a minimum 15-character password length.", + "Description": "Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password length is one factor of several that helps to determine strength and how long it takes to crack a password. The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised. Use of more characters in a password helps to exponentially increase the time and/or resources required to compromise the password.", + "Checks": [ + "authenticator_password_minimum_length_15" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273195r1098852_rule", + "StigID": "OKTA-APP-000650", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify the \"Minimum Length\" field is set to at least \"15\" characters. If any policy is not set to at least \"15\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set the \"Minimum Length\" field to at least \"15\" characters." + } + ] + }, + { + "Id": "OKTA-APP-000670", + "Name": "Okta must enforce password complexity by requiring that at least one uppercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password is, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_uppercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273196r1098855_rule", + "StigID": "OKTA-APP-000670", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Upper case letter\" is checked. For each policy, if \"Upper case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Upper case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000680", + "Name": "Okta must enforce password complexity by requiring that at least one lowercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_lowercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273197r1098858_rule", + "StigID": "OKTA-APP-000680", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Lower case letter\" is checked. For each policy, if \"Lower case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Lower case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000690", + "Name": "Okta must enforce password complexity by requiring that at least one numeric character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_number" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273198r1098861_rule", + "StigID": "OKTA-APP-000690", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Number (0-9)\" is checked. For each policy, if \"Number (0-9)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Number (0-9)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000700", + "Name": "Okta must enforce password complexity by requiring that at least one special character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor in determining how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised. Special characters are not alphanumeric. Examples include: ~ ! @ # $ % ^ *.", + "Checks": [ + "authenticator_password_complexity_symbol" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273199r1098864_rule", + "StigID": "OKTA-APP-000700", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Symbol (e.g., !@#$%^&*)\" is checked. For each policy, if \"Symbol (e.g., !@#$%^&*)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Symbol (e.g., !@#$%^&*)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000740", + "Name": "Okta must enforce 24 hours/one day as the minimum password lifetime.", + "Description": "Enforcing a minimum password lifetime helps prevent repeated password changes to defeat the password reuse or history enforcement requirement. Restricting this setting limits the user's ability to change their password. Passwords must be changed at specific policy-based intervals; however, if the application allows the user to immediately and continually change their password, it could be changed repeatedly in a short period of time to defeat the organization's policy regarding password reuse. Satisfies: SRG-APP-000173, SRG-APP-000870", + "Checks": [ + "authenticator_password_minimum_age_24h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273200r1098867_rule", + "StigID": "OKTA-APP-000740", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Minimum password age is XX hours\" is set to at least \"24\". For each policy, if \"Minimum password age is XX hours\" is not set to at least \"24\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Minimum password age is XX hours\" to at least \"24\"." + } + ] + }, + { + "Id": "OKTA-APP-000745", + "Name": "Okta must enforce a 60-day maximum password lifetime restriction.", + "Description": "Any password, no matter how complex, can eventually be cracked. Therefore, passwords must be changed at specific intervals. One method of minimizing this risk is to use complex passwords and periodically change them. If the application does not limit the lifetime of passwords and force users to change their passwords, there is the risk that the system and/or application passwords could be compromised. This requirement does not include emergency administration accounts, which are meant for access to the application in case of failure. These accounts are not required to have maximum password lifetime restrictions.", + "Checks": [ + "authenticator_password_maximum_age_60d" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273201r1098870_rule", + "StigID": "OKTA-APP-000745", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Password expires after XX days\" is set to \"60\". For each policy, if \"Password expires after XX days\" is not set to \"60\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Password expires after XX days\" to \"60\"." + } + ] + }, + { + "Id": "OKTA-APP-001430", + "Name": "Okta must off-load audit records onto a central log server.", + "Description": "Information stored in one location is vulnerable to accidental or incidental deletion or alteration. Off-loading is a common process in information systems with limited audit storage capacity. Satisfies: SRG-APP-000358, SRG-APP-000080, SRG-APP-000125", + "Checks": [ + "systemlog_streaming_enabled" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273202r1099766_rule", + "StigID": "OKTA-APP-001430", + "CCI": [ + "CCI-001851", + "CCI-000166", + "CCI-001348" + ], + "CheckText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Verify that a Log Stream connection is configured and active. Alternately, interview the information system security manager (ISSM) and verify that an external Security Information and Event Management (SIEM) system is pulling Okta logs via an Application Programming Interface (API). If either of these is not configured, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Select either \"AWS EventBridge\" or \"Splunk Cloud\" and click \"Next\". 3. Complete the necessary fields and click \"Save\". If Log Streaming is not an option because the SIEM required is not an option, customers can use the Okta Log API to export system logs in real time." + } + ] + }, + { + "Id": "OKTA-APP-001665", + "Name": "Okta must be configured to limit the global session lifetime to 18 hours.", + "Description": "Without reauthentication, users may access resources or perform tasks for which they do not have authorization. When applications provide the capability to change security roles or escalate the functional capability of the application, it is critical the user reauthenticate. In addition to the reauthentication requirements associated with session locks, organizations may require reauthentication of individuals and/or devices in other situations, including (but not limited to) the following circumstances. (i) When authenticators change; (ii) When roles change; (iii) When security categories of information systems change; (iv) When the execution of privileged functions occurs; (v) After a fixed period of time; or (vi) Periodically. Within the DOD, the minimum circumstances requiring reauthentication are privilege escalation and role changes.", + "Checks": [ + "signon_global_session_lifetime_18h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273203r1099958_rule", + "StigID": "OKTA-APP-001665", + "CCI": [ + "CCI-002038" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Maximum Okta global session lifetime\" is set to 18 hours. If the above is not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session lifetime\" to 18 hours." + } + ], + "ConfigRequirements": [ + { + "Check": "signon_global_session_lifetime_18h", + "ConfigKey": "okta_max_session_lifetime_minutes", + "Operator": "lte", + "Value": 1080 + } + ] + }, + { + "Id": "OKTA-APP-001670", + "Name": "Okta must be configured to accept Personal Identity Verification (PIV) credentials.", + "Description": "The use of PIV credentials facilitates standardization and reduces the risk of unauthorized access. DOD has mandated the use of the common access card (CAC) to support identity management and personal authentication for systems covered under HSPD 12, as well as a primary component of layered protection for national security systems. Satisfies: SRG-APP-000391, SRG-APP-000402, SRG-APP-000403", + "Checks": [ + "authenticator_smart_card_active" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273204r1098879_rule", + "StigID": "OKTA-APP-001670", + "CCI": [ + "CCI-001953", + "CCI-002009", + "CCI-002010" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Verify that \"Smart Card Authenticator\" is listed and has \"Status\" listed as \"Active\". If \"Smart Card Authenticator\" is not listed or is not listed as \"Active\", this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. In the \"Setup\" tab, click \"Add authenticator\". 3. Select the configured Smart Card Identity Provider and finish configuration." + } + ] + }, + { + "Id": "OKTA-APP-001700", + "Name": "The Okta Verify application must be configured to connect only to FIPS-compliant devices.", + "Description": "Without device-to-device authentication, communications with malicious devices may be established. Bidirectional authentication provides stronger safeguards to validate the identity of other devices for connections that are of greater risk. Currently, DOD requires the use of AES for bidirectional authentication because it is the only FIPS-validated AES cipher block algorithm. For distributed architectures (e.g., service-oriented architectures), the decisions regarding the validation of authentication claims may be made by services separate from the services acting on those decisions. In such situations, it is necessary to provide authentication decisions (as opposed to the actual authenticators) to the services that need to act on those decisions. A local connection is any connection with a device communicating without the use of a network. A network connection is any connection with a device that communicates through a network (e.g., local area or wide area network; the internet). A remote connection is any connection with a device communicating through an external network (e.g., the internet). Because of the challenges of applying this requirement on a large scale, organizations are encouraged to apply the requirement only to those limited number (and type) of devices that truly need to support this capability.", + "Checks": [ + "authenticator_okta_verify_fips_compliant" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273205r1098882_rule", + "StigID": "OKTA-APP-001700", + "CCI": [ + "CCI-001967" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. Review the \"FIPS Compliance\" field. If FIPS-compliant authentication is not enabled, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. In the \"FIPS Compliance\" field, choose whether users enrolling in Okta Verify can use FIPS-compliant devices only or any device. 4. Click \"Save\" after making any changes." + } + ] + }, + { + "Id": "OKTA-APP-001710", + "Name": "Okta must be configured to disable persistent global session cookies.", + "Description": "If cached authentication information is out of date, the validity of the authentication information may be questionable. Satisfies: SRG-APP-000400, SRG-APP-000157", + "Checks": [ + "signon_global_session_cookies_not_persistent" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273206r1098885_rule", + "StigID": "OKTA-APP-001710", + "CCI": [ + "CCI-002007", + "CCI-001942" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Okta global session cookies persist across browser sessions\" is set to \"Disabled\". If the above it not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the \"Rules\" table, make these updates: - Click \"Add rule\". - Set \"Okta global session cookies persist across browser sessions\" to Disable." + } + ] + }, + { + "Id": "OKTA-APP-001920", + "Name": "Okta must be configured to use only DOD-approved certificate authorities.", + "Description": "Untrusted Certificate Authorities (CA) can issue certificates, but they may be issued by organizations or individuals that seek to compromise DOD systems or by organizations with insufficient security controls. If the CA used for verifying the certificate is not DOD approved, trust of this CA has not been established. The DOD will accept only PKI certificates obtained from a DOD-approved internal or external CA. Reliance on CAs for the establishment of secure sessions includes, for example, the use of Transport Layer Security (TLS) certificates. This requirement focuses on communications protection for the application session rather than for the network packet. This requirement applies to applications that use communications sessions. This includes, but is not limited to, web-based applications and Service-Oriented Architectures (SOA). Satisfies: SRG-APP-000427, SRG-APP-000910", + "Checks": [ + "idp_smart_card_dod_approved_ca" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273207r1098888_rule", + "StigID": "OKTA-APP-001920", + "CCI": [ + "CCI-002470", + "CCI-004909" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Identity Providers (IdPs). 2. Review the list of IdPs with \"Type\" as \"Smart Card\". If the IdP is not listed as \"Active\", this is a finding. 3. Select Actions >> Configure. 4. Under \"Certificate chain\", verify the certificate is from a DOD-approved CA. If the certificate is not from a DOD-approved CA, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Identity Providers. 2. Click \"Add identity provider.\" 3. Click \"Smart Card IdP\". Click \"Next\". 4. Enter the name of the identity provider. 5. Build a certificate chain: - Click \"Browse\" to open a file explorer. Select the certificate file to add and click \"Open\". - To add another certificate, click \"Add Another\" and repeat step 1. - Click \"Build certificate chain\". On success, the chain and its certificates are shown. If the build failed, correct any issues and try again. - Click \"Reset certificate chain\" if replacing the current chain with a new one. 6. In \"IdP username\", select the \"idpuser.subjectAltNameUpn\" attribute. This is the attribute that stores the Electronic Data Interchange Personnel Identifier (EDIPI) on the CAC. 7. In the \"Match Against\" field, select the Okta Profile Attribute in which the EDIPI is to be stored." + } + ] + }, + { + "Id": "OKTA-APP-002980", + "Name": "Okta must validate passwords against a list of commonly used, expected, or compromised passwords.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_common_password_check" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273208r1099769_rule", + "StigID": "OKTA-APP-002980", + "CCI": [ + "CCI-004058" + ], + "CheckText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, verify the \"Common Password Check\" box is checked. If \"Common Password Check\" is not selected, this is a finding.", + "FixText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, check the \"Common Password Check\" box." + } + ] + }, + { + "Id": "OKTA-APP-003010", + "Name": "Okta must prohibit password reuse for a minimum of five generations.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_history_5" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273209r1098894_rule", + "StigID": "OKTA-APP-003010", + "CCI": [ + "CCI-004061" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password row\" and select \"Edit\". 3. For each listed policy, verify \"Enforce password history for last XX passwords\" is set to \"5\". If any policy is not set to at least \"5\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Enforce password history for last XX passwords\" to \"5\"." + } + ] + }, + { + "Id": "OKTA-APP-003240", + "Name": "Okta API tokens must be configured with Network Zones to restrict authorization from known networks.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. API tokens have the potential to be replicated or stolen (just like a password). Because of this, it is important to only allow API tokens to authenticate from known IP ranges as this limits an adversary's ability to use a token to gain access.", + "Checks": [ + "apitoken_restricted_to_network_zone" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279689r1155066_rule", + "StigID": "OKTA-APP-003240", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, verify the \"Token can be used from\" setting is mapped to a known network zone for the application calling the API. If a network zone for each API access token is not defined, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, click \"Edit\". 5. Set the \"Token can be used from\" setting to the known network zone for the application calling the API. 6. Click \"Save\"." + } + ] + }, + { + "Id": "OKTA-APP-003241", + "Name": "Okta API tokens must be created under new dedicated user accounts.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. When API tokens are created, they inherit the permissions of the user that created them. Therefore, API tokens should only be created from dedicated accounts and permissions must be constrained to least privilege for that dedicated user account and token. No API tokens should be created using a Super Admin account.", + "Checks": [ + "apitoken_not_super_admin" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279690r1155069_rule", + "StigID": "OKTA-APP-003241", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, verify that the Role listed is not \"Super Admin\", and that the account has been specifically created for that token. 4. Click the account name to be token to the user profile for that user. 5. Verify the user only has an administrator role (standard or customer) applied that is correctly scoped as required and documented in the Okta Access Control policy. If the token is using a Super Administrator account, or one that is not properly scoped per the Access Control policy, this is a finding. Note: If a Super Admin token is required for system operation, then this permanent finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed that has \"Super Admin\" or an improperly scoped Admin account, delete the token and create a new one with the appropriately scoped permissions. 4. Verify the application performing the API calls with the new token has been updated." + } + ] + }, + { + "Id": "OKTA-APP-003242", + "Name": "The Okta Global Session policy must be configured to allow or deny IP based access in accordance with the Access Control policy for Okta.", + "Description": "To mitigate the risk of unauthorized access to sensitive information by entities that have been issued certificates by DOD-approved PKIs, all DOD systems (e.g., networks, web servers, and web portals) must be properly configured to incorporate access control methods that do not rely solely on the possession of a certificate for access. Successful authentication must not automatically give an entity access to an asset or security boundary. Authorization procedures and controls must be implemented to ensure each authenticated entity also has a validated and current authorization. Authorization is the process of determining whether an entity, once authenticated, is permitted to access a specific asset. Information systems use access control policies and enforcement mechanisms to implement this requirement. Access Control policies include identity-based policies, role-based policies, and attribute-based policies. Access enforcement mechanisms include access control lists, access control matrices, and cryptography. These policies and mechanisms must be employed by the application to control access between users (or processes acting on behalf of users) and objects (e.g., devices, files, records, processes, programs, and domains) in the information system. The Okta Global Session Policy is applied at the organization level and before any application-specific authentication policies are processed. The Okta authorization package should contain an access control policy that defines IP ranges from which to either allow or deny access. This list (either as an explicit allow or explicit deny) can be implemented in the Global Session Policy.", + "Checks": [ + "signon_global_session_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279691r1155072_rule", + "StigID": "OKTA-APP-003242", + "CCI": [ + "CCI-000213" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the \"Policy Settings\" section, verify the \"IF User's IP is\" setting is correctly set to either allow or deny based on the organization defined policy. If the Okta Global Session Policy is not configured to restrict access to specific IP ranges, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the Policy Settings section, configure the \"IF User's IP is\" setting to correctly set the appropriate network to either allow or deny based on the Access Control Policy." + } + ] + }, + { + "Id": "OKTA-APP-003243", + "Name": "Okta must be configured with Network Zones defined to block anonymized proxies according to organizationally defined policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Working with the organizational CSSP, the ISSM should obtain a list of known anonymizer proxies that exist on the commercial internet. If this is not available from the CSSP, then the Okta-provided \"Enhanced dynamic zone blocklist\" should be activated.", + "Checks": [ + "network_zone_block_anonymized_proxies" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279692r1155075_rule", + "StigID": "OKTA-APP-003243", + "CCI": [ + "CCI-001414" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks' item. 2. If the CSSP has provided a list of anonymizers to block, verify the \"IP Block list\" is configured with them. a. Click the pencil icon next to IP Block list. b. Verify the \"Gateway IPs\" section contains all of the IP ranges in the provided list. 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Verify the \"Enhanced dynamic zone blocklist\" is set to \"Active\". If Network Zones are not configured to block anonymous proxies, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks\" item. 2. If the CSSP has provided a list of anonymizers to block, add the IP ranges to the \"IP Block list\". a. Click the pencil icon next to IP Block list. b. Add the IP ranges to the \"Gateway IPs\" section and click \"Save\". 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Set the \"Enhanced dynamic zone blocklist\" to \"Active\"." + } + ] + }, + { + "Id": "OKTA-APP-003244", + "Name": "For each application integrated with Okta, network zones must be defined in its authentication policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Each application in Okta should have a well defined access control policy that takes into account the end user network. This should be documented in the Access Control policy for each application. As an example, access to an application may be restricted to a specific location by policy. In this case, a network defining that specific location should be created.", + "Checks": [ + "application_authentication_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279693r1155078_rule", + "StigID": "OKTA-APP-003244", + "CCI": [ + "CCI-001414" + ], + "CheckText": "For each application integrated into Okta: 1. From the Admin console, open the \"Security\" menu, and then select \"Networks\". 2. Verify the list of networks includes all necessary allow or block lists. If any application is not configured with network zones, this is a finding.", + "FixText": "For each application, starting at the admin console: 1. Open the \"Applications\" group from the Menu, and then click the \"Applications\" menu item. 2. Click the application name. 3. Click the \"Sign On\" tab. 4. Scroll to the \"User Authentication\" section, and then click \"Edit\". 5. Select the appropriate Authentication policy from the pull down, and then click \"Save\". 6. Click \"View Policy Details\". 7. For each nondefault rule: a. Select \"Edit\" from the Actions menu. b. In the \"IF\" section, verify the \"User is\" setting has the appropriate allow or deny range has been selected based on the Access Control policy for the application. c. Scroll down to the bottom and click \"Save\". 8. For the Catch-All rule: a. Select \"Edit\" from the Actions menu. b. Scroll down to the \"Then\" section. c. For the \"Access is\" setting, select \"Denied\", and then click \"Save\"." + } + ] + } + ] +} diff --git a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json index ceca68d124..8140dca6cc 100644 --- a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json +++ b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json @@ -302,7 +302,9 @@ { "Id": "1.15", "Description": "Ensure storage service-level admins cannot delete resources they manage", - "Checks": [], + "Checks": [ + "identity_storage_service_level_admins_scoped" + ], "Attributes": [ { "Section": "1. Identity and Access Management", diff --git a/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json b/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json deleted file mode 100644 index 300e32788d..0000000000 --- a/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json +++ /dev/null @@ -1,7307 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "OracleCloud", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_versioning_enabled" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "events_rule_iam_group_changes", - "events_rule_iam_policy_changes", - "events_rule_user_changes", - "events_rule_vcn_changes" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_rule_network_gateway_changes", - "events_rule_network_security_group_changes", - "events_rule_route_table_changes", - "events_rule_security_list_changes", - "events_rule_vcn_changes" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "compute_instance_in_transit_encryption_enabled", - "filestorage_file_system_encrypted_with_cmk", - "objectstorage_bucket_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "filestorage_file_system_encrypted_with_cmk", - "objectstorage_bucket_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_db_passwords_rotated_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_not_publicly_accessible", - "database_autonomous_database_access_restricted", - "analytics_instance_access_restricted", - "integration_instance_access_restricted" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_not_publicly_accessible", - "objectstorage_bucket_encrypted_with_cmk", - "database_autonomous_database_access_restricted", - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "identity_password_policy_minimum_length_14", - "identity_password_policy_expires_within_365_days", - "identity_password_policy_prevents_reuse" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_valid_email_address" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "identity_service_level_admins_exist", - "identity_iam_admins_cannot_update_tenancy_admins", - "identity_tenancy_admin_permissions_limited" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_service_level_admins_exist", - "identity_no_resources_in_root_compartment", - "identity_non_root_compartment_exists" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_db_passwords_rotated_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_iam_admins_cannot_update_tenancy_admins", - "identity_tenancy_admin_users_no_api_keys" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_tenancy_admin_users_no_api_keys", - "identity_iam_admins_cannot_update_tenancy_admins" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "identity_user_mfa_enabled_console_access", - "identity_user_valid_email_address" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "identity_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "identity_password_policy_minimum_length_14", - "identity_password_policy_expires_within_365_days", - "identity_password_policy_prevents_reuse" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_service_level_admins_exist", - "database_autonomous_database_access_restricted", - "analytics_instance_access_restricted", - "integration_instance_access_restricted" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "network_vcn_subnet_flow_logs_enabled", - "network_default_security_list_restricts_traffic", - "network_security_group_ingress_from_internet_to_ssh_port", - "network_security_group_ingress_from_internet_to_rdp_port", - "network_security_list_ingress_from_internet_to_ssh_port", - "network_security_list_ingress_from_internet_to_rdp_port" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_legacy_metadata_endpoint_disabled", - "compute_instance_secure_boot_enabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "network_default_security_list_restricts_traffic", - "identity_non_root_compartment_exists", - "identity_no_resources_in_root_compartment" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_notification_topic_and_subscription_exists", - "events_rule_local_user_authentication" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "events_rule_iam_group_changes", - "events_rule_iam_policy_changes", - "events_rule_identity_provider_changes", - "events_rule_idp_group_mapping_changes", - "events_rule_local_user_authentication", - "events_rule_network_gateway_changes", - "events_rule_network_security_group_changes", - "events_rule_route_table_changes", - "events_rule_security_list_changes", - "events_rule_user_changes", - "events_rule_vcn_changes", - "events_rule_cloudguard_problems" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days", - "network_vcn_subnet_flow_logs_enabled", - "objectstorage_bucket_logging_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days", - "network_vcn_subnet_flow_logs_enabled", - "objectstorage_bucket_logging_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_notification_topic_and_subscription_exists" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "filestorage_file_system_encrypted_with_cmk" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/compliance/stackit/__init__.py b/prowler/compliance/stackit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/config/config.py b/prowler/config/config.py index 3339a0d1ea..c664745000 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -1,3 +1,4 @@ +import importlib.metadata import os import pathlib from datetime import datetime, timezone @@ -9,6 +10,16 @@ import requests 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 @@ -38,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.23.0" +prowler_version = "5.32.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" @@ -65,24 +76,56 @@ class Provider(str, Enum): ALIBABACLOUD = "alibabacloud" OPENSTACK = "openstack" IMAGE = "image" + SCALEWAY = "scaleway" VERCEL = "vercel" + OKTA = "okta" + STACKIT = "stackit" + LINODE = "linode" -# 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__))) +def _get_ep_compliance_dirs() -> dict: + """Discover compliance directories from entry points. Returns {provider: [paths]}. + + A provider may be contributed by several packages, so accumulate every + directory instead of overwriting. + """ + dirs = {} + for ep in importlib.metadata.entry_points(group="prowler.compliance"): + try: + module = ep.load() + if hasattr(module, "__path__"): + path = module.__path__[0] + elif hasattr(module, "__file__"): + path = os.path.dirname(module.__file__) + else: + continue + dirs.setdefault(ep.name, []).append(path) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return dirs + + def get_available_compliance_frameworks(provider=None): available_compliance_frameworks = [] - providers = [p.value for p in Provider] + # Built-in compliance + compliance_base = f"{actual_directory}/../compliance" if provider: providers = [provider] - for provider in providers: - compliance_dir = f"{actual_directory}/../compliance/{provider}" + else: + # Scan compliance directory for all provider subdirectories + providers = [] + if os.path.isdir(compliance_base): + for entry in os.scandir(compliance_base): + if entry.is_dir(): + providers.append(entry.name) + for prov in providers: + compliance_dir = f"{compliance_base}/{prov}" if not os.path.isdir(compliance_dir): continue with os.scandir(compliance_dir) as files: @@ -91,6 +134,64 @@ def get_available_compliance_frameworks(provider=None): available_compliance_frameworks.append( file.name.removesuffix(".json") ) + # Built-in multi-provider frameworks at top-level compliance/ directory. + # Placed before external entry points so built-ins win on name collisions. + # When a specific provider was requested, only include the framework if it + # declares support for that provider; otherwise include all universal frameworks. + compliance_root = f"{actual_directory}/../compliance" + if os.path.isdir(compliance_root): + with os.scandir(compliance_root) as files: + for file in files: + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if provider: + framework = load_compliance_framework_universal(file.path) + if framework is None or not framework.supports_provider( + provider + ): + continue + if name not in available_compliance_frameworks: + available_compliance_frameworks.append(name) + # External compliance via entry points; a provider may be served by + # several packages, so iterate every directory it contributes. + ep_dirs = _get_ep_compliance_dirs() + for prov, paths in ep_dirs.items(): + if provider and prov != provider: + continue + for path in paths: + if not os.path.isdir(path): + continue + for file in os.scandir(path): + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if name not in available_compliance_frameworks: + available_compliance_frameworks.append(name) + # External multi-provider frameworks via the dedicated universal group; + # filtered by supports_provider when a provider is given. + for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + try: + module = ep.load() + path = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not os.path.isdir(path): + continue + for file in os.scandir(path): + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if provider: + framework = load_compliance_framework_universal(file.path) + if framework is None or not framework.supports_provider(provider): + continue + if name not in available_compliance_frameworks: + available_compliance_frameworks.append(name) return available_compliance_frameworks @@ -110,6 +211,7 @@ json_file_suffix = ".json" json_asff_file_suffix = ".asff.json" json_ocsf_file_suffix = ".ocsf.json" html_file_suffix = ".html" +sarif_file_suffix = ".sarif" default_config_file_path = ( f"{pathlib.Path(os.path.dirname(os.path.realpath(__file__)))}/config.yaml" ) @@ -120,7 +222,7 @@ default_redteam_config_file_path = ( f"{pathlib.Path(os.path.dirname(os.path.realpath(__file__)))}/llm_config.yaml" ) encoding_format_utf_8 = "utf-8" -available_output_formats = ["csv", "json-asff", "json-ocsf", "html"] +available_output_formats = ["csv", "json-asff", "json-ocsf", "html", "sarif"] # Prowler Cloud API settings cloud_api_base_url = os.getenv("PROWLER_CLOUD_API_BASE_URL", "https://api.prowler.com") @@ -135,7 +237,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, @@ -197,24 +299,41 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict: Returns: dict: The configuration dictionary for the specified provider. """ + # Imported lazily to avoid an import cycle: schemas may eventually want to + # import from prowler.config.config (e.g. for shared constants). + from prowler.config.schema.registry import SCHEMAS + from prowler.config.schema.validator import validate_provider_config + try: with open(config_file_path, "r", encoding=encoding_format_utf_8) as f: config_file = yaml.safe_load(f) - # Not to introduce a breaking change, allow the old format config file without any provider keys - # and a new format with a key for each provider to include their configuration values within. - if any( - key in config_file - for key in ["aws", "gcp", "azure", "kubernetes", "m365"] + # Namespaced format: each provider has its own top-level key. + # Works for every built-in and every external plugin without a hardcoded list. + # Flat legacy format is AWS-only (historical, pre-multicloud). We identify it + # by the absence of nested-dict top-level values (namespaced files always + # have dict values; the legacy AWS format only has primitives/lists). + if ( + isinstance(config_file, dict) + and provider in config_file + and isinstance(config_file[provider], dict) ): - config = config_file.get(provider, {}) + config = config_file.get(provider, {}) or {} + elif ( + isinstance(config_file, dict) + and config_file + and provider == "aws" + and not any(isinstance(v, dict) for v in config_file.values()) + ): + config = config_file else: - config = config_file if config_file else {} - # Not to break Azure, K8s and GCP does not support or use the old config format - if provider in ["azure", "gcp", "kubernetes", "m365"]: - config = {} + config = {} - return config + return validate_provider_config( + provider=provider, + raw=config, + schema_cls=SCHEMAS.get(provider), + ) except FileNotFoundError as error: logger.error( diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index c645d6e25b..dae81a92d3 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -3,6 +3,12 @@ aws: # AWS Global Configuration # aws.mute_non_default_regions --> Set to True to muted failed findings in non-default regions for AccessAnalyzer, GuardDuty, SecurityHub, DRS and Config mute_non_default_regions: False + # aws.disallowed_regions --> List of AWS regions to exclude from the scan. + # Also settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable or + # the --excluded-region CLI flag. Precedence: CLI > env var > config file. + disallowed_regions: + - me-south-1 + - me-central-1 # If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`: # Mutelist: # Accounts: @@ -20,6 +26,8 @@ aws: max_unused_access_keys_days: 45 # aws.iam_user_console_access_unused --> CIS recommends 45 days max_console_access_days: 45 + # aws.iam_user_access_not_stale_to_sagemaker --> default 90 days + max_unused_sagemaker_access_days: 90 # AWS EC2 Configuration # aws.ec2_elastic_ip_shodan @@ -135,6 +143,7 @@ aws: # ] organizations_enabled_regions: [] organizations_trusted_delegated_administrators: [] + organizations_trusted_ids: [] # AWS ECR # aws.ecr_repositories_scan_vulnerabilities_in_latest_image @@ -371,6 +380,39 @@ aws: # Minimum number of Availability Zones that an ELBv2 must be in elbv2_min_azs: 2 + # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + # Allowed post-quantum key algorithms for AWS Private CA certificate authorities + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + # aws.cloudfront_distributions_pqc_tls_enabled + # Allowed CloudFront MinimumProtocolVersion values that enable post-quantum hybrid key exchange + cloudfront_pqc_min_protocol_versions: + - "TLSv1.3_2025" + # aws.apigateway_domain_name_pqc_tls_enabled + # Allowed post-quantum TLS security policies for API Gateway custom domain names + apigateway_pqc_tls_allowed_policies: + - "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PQ_2025_09" + + # aws.rolesanywhere_trust_anchor_pqc_pki + # Allowed post-quantum key algorithms for AWS Private CAs backing IAM Roles Anywhere trust anchors + rolesanywhere_pqc_pca_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + + # AWS Post-Quantum SSH Key Exchange Configuration + # aws.transfer_server_pqc_ssh_kex_enabled + # Allowed AWS Transfer Family security policies with post-quantum SSH key exchange + transfer_pqc_ssh_allowed_policies: + - "TransferSecurityPolicy-2025-03" + - "TransferSecurityPolicy-FIPS-2025-03" + - "TransferSecurityPolicy-AS2Restricted-2025-07" + # AWS Elasticache Configuration # aws.elasticache_redis_cluster_backup_enabled # Minimum number of days that a Redis cluster must have backups retention period @@ -381,6 +423,13 @@ aws: # Patterns to ignore in the secrets checks secrets_ignore_patterns: [] + # Validate discovered secrets by checking whether they are live against the + # provider APIs. WARNING: this makes outbound network calls that authenticate + # with the discovered secret itself; the credential is exercised against the + # provider and the call will appear in the audited account's logs (and may + # trigger its monitoring). Disabled by default (scans stay fully offline). + secrets_validate: False + # AWS Secrets Manager Configuration # aws.secretsmanager_secret_unused # Maximum number of days a secret can be unused @@ -394,37 +443,6 @@ aws: # Minimum retention period in hours for Kinesis streams min_kinesis_stream_retention_hours: 168 # 7 days - # Detect Secrets plugin configuration - detect_secrets_plugins: [ - {"name": "ArtifactoryDetector"}, - {"name": "AWSKeyDetector"}, - {"name": "AzureStorageKeyDetector"}, - {"name": "BasicAuthDetector"}, - {"name": "CloudantDetector"}, - {"name": "DiscordBotTokenDetector"}, - {"name": "GitHubTokenDetector"}, - {"name": "GitLabTokenDetector"}, - {"name": "Base64HighEntropyString", "limit": 6.0}, - {"name": "HexHighEntropyString", "limit": 3.0}, - {"name": "IbmCloudIamDetector"}, - {"name": "IbmCosHmacDetector"}, - # {"name": "IPPublicDetector"}, https://github.com/Yelp/detect-secrets/pull/885 - {"name": "JwtTokenDetector"}, - {"name": "KeywordDetector"}, - {"name": "MailchimpDetector"}, - {"name": "NpmDetector"}, - {"name": "OpenAIDetector"}, - {"name": "PrivateKeyDetector"}, - {"name": "PypiTokenDetector"}, - {"name": "SendGridDetector"}, - {"name": "SlackDetector"}, - {"name": "SoftlayerDetector"}, - {"name": "SquareOAuthDetector"}, - {"name": "StripeDetector"}, - # {"name": "TelegramBotTokenDetector"}, https://github.com/Yelp/detect-secrets/pull/878 - {"name": "TwilioKeyDetector"}, - ] - # AWS CodeBuild Configuration # aws.codebuild_project_uses_allowed_github_organizations codebuild_github_allowed_organizations: @@ -458,6 +476,18 @@ azure: "1.3", ] + # Azure Storage + # azure.storage_smb_channel_encryption_with_secure_algorithm + # List of SMB channel encryption algorithms allowed on file shares. A storage + # account passes only if every enabled algorithm is in this list. Defaults to + # the value required by CIS (AES-256-GCM only, excluding weaker AES-128 ciphers). + recommended_smb_channel_encryption_algorithms: + [ + "AES-256-GCM", + # "AES-128-CCM", + # "AES-128-GCM", + ] + # Azure Virtual Machines # azure.vm_desired_sku_size # List of desired VM SKU sizes that are allowed in the organization @@ -528,6 +558,9 @@ gcp: # GCP Storage Sufficient Retention Period # gcp.cloudstorage_bucket_sufficient_retention_period storage_min_retention_days: 90 + # GCP Secret Manager Rotation Period + # gcp.secretmanager_secret_rotation_enabled + secretmanager_max_rotation_days: 90 # Kubernetes Configuration kubernetes: @@ -640,3 +673,58 @@ vercel: - "_PASSWORD" - "_API_KEY" - "_PRIVATE_KEY" + +okta: + # Okta Sign-On Policies + # okta.signon_global_session_idle_timeout_15min + # Maximum acceptable Global Session idle timeout, in minutes. Defaults to + # 15 per DISA STIG V-273186 (OKTA-APP-000020); raise it only with an + # explicit risk acceptance. + okta_max_session_idle_minutes: 15 + # okta.signon_global_session_lifetime_18h + # Maximum acceptable Global Session lifetime, in minutes. Defaults to + # 18h (1080); raise it only with an explicit risk acceptance. + okta_max_session_lifetime_minutes: 1080 + # Okta Applications + # okta.application_admin_console_session_idle_timeout_15min + # Maximum acceptable Okta Admin Console app idle timeout, in minutes. + # Defaults to 15 per DISA STIG V-273187 (OKTA-APP-000025); raise it only + # with an explicit risk acceptance. + okta_admin_console_idle_timeout_max_minutes: 15 + # Okta Users + # okta.user_inactivity_automation_35d_enabled + # Maximum number of days a user can stay inactive before the + # inactivity-automation check flags the org. Defaults to 35. + okta_user_inactivity_max_days: 35 + # Okta Identity Providers + # okta.idp_smart_card_dod_approved_ca + # Extra regex patterns matched against a Smart Card IdP certificate issuer + # DN to recognise a DOD-approved CA, on top of the built-in `OU=DoD` / + # `OU=ECA` patterns. + okta_dod_approved_ca_issuer_patterns: [] + +alibabacloud: + # alibabacloud.cs_kubernetes_cluster_check_recent / cs_kubernetes_cluster_check_weekly + # Maximum number of days an ACK cluster can go without a security check + # before being flagged. Defaults to 7. + max_cluster_check_days: 7 + # alibabacloud.ram_user_console_access_unused + # Days a RAM user's console access can stay unused before being flagged. + # Defaults to 90. + max_console_access_days: 90 + # alibabacloud.sls_logstore_retention_period + # Minimum required SLS log store retention, in days. Defaults to 365. + min_log_retention_days: 365 + # alibabacloud.rds_instance_sql_audit_retention + # Minimum required RDS SQL audit log retention, in days. Defaults to 180. + min_rds_audit_retention_days: 180 + +openstack: + # openstack.image_not_shared_with_multiple_projects + # Maximum number of accepted project members a shared image may have before + # being flagged. Defaults to 5. + image_sharing_threshold: 5 + # openstack._*_metadata_sensitive_data + # Regex patterns whose matches are excluded from secret scanning of + # resource metadata. + secrets_ignore_patterns: [] diff --git a/prowler/config/linode_mutelist_example.yaml b/prowler/config/linode_mutelist_example.yaml new file mode 100644 index 0000000000..1b08ab6fda --- /dev/null +++ b/prowler/config/linode_mutelist_example.yaml @@ -0,0 +1,18 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### Region == * (Linode is non-regional) +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Accounts, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "example-account-uuid": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "example-user@example.com" + - "another-user@example.com" diff --git a/prowler/config/okta_mutelist_example.yaml b/prowler/config/okta_mutelist_example.yaml new file mode 100644 index 0000000000..1c6ff2ca8c --- /dev/null +++ b/prowler/config/okta_mutelist_example.yaml @@ -0,0 +1,19 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### Bare domain only — no scheme, no path, no trailing slash. +### Region is always "*" — Okta has no regional concept. +### Resources matches against the policy name (e.g. "Default Policy"), not the id. +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Accounts, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "acme.okta.com": + Checks: + "signon_global_session_idle_timeout_15min": + Regions: + - "*" + Resources: + - "Default Policy" diff --git a/prowler/config/oraclecloud_mutelist_example.yaml b/prowler/config/oraclecloud_mutelist_example.yaml index 3e40310ea8..2f56167b6a 100644 --- a/prowler/config/oraclecloud_mutelist_example.yaml +++ b/prowler/config/oraclecloud_mutelist_example.yaml @@ -1,12 +1,12 @@ -### Tenancy, Check and/or Region can be * to apply for all the cases. -### Tenancy == OCI Tenancy OCID and Region == OCI Region +### Account, Check and/or Region can be * to apply for all the cases. +### Account == OCI Tenancy OCID and Region == OCI Region ### Resources and tags are lists that can have either Regex or Keywords. ### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. ### Use an alternation Regex to match one of multiple tags with "ORed" logic. -### For each check you can except Tenancies, Regions, Resources and/or Tags. +### For each check you can except Accounts, Regions, Resources and/or Tags. ########################### MUTELIST EXAMPLE ########################### Mutelist: - Tenancies: + Accounts: "ocid1.tenancy.oc1..aaaaaaaexample": Checks: "iam_user_mfa_enabled": diff --git a/prowler/config/scan_config_schema.py b/prowler/config/scan_config_schema.py new file mode 100644 index 0000000000..ac00250c78 --- /dev/null +++ b/prowler/config/scan_config_schema.py @@ -0,0 +1,116 @@ +"""Bridge between the Pydantic-based provider schemas in +`prowler.config.schema` and the Prowler App backend (Django) + UI. + +The SDK runtime is intentionally LENIENT: invalid keys are dropped with a +warning and downstream checks fall back to their defaults +(`prowler.config.schema.validator.validate_provider_config`). + +The Prowler App, however, needs to surface those errors to the user when +they save a Scan Config from the UI, and to expose the schema as JSON so +the UI can validate live with `ajv`. This module provides: + +- `validate_scan_config(payload)` — STRICT: returns a list of + `{path, message}` errors without silently dropping anything. The DRF + serializer (`api/.../v1/serializers.py:validate_scan_config_payload`) + turns each entry into a `ValidationError`. + +- `SCAN_CONFIG_SCHEMA` — aggregated JSON Schema derived from the Pydantic + models via `model_json_schema()`. Served by the `/scan-configs/schema` + endpoint and consumed by the UI editor for in-editor live validation. +""" + +from typing import Any + +from pydantic import ValidationError + +from prowler.config.schema.registry import SCHEMAS + + +def _format_loc(loc: tuple) -> str: + """Render a Pydantic error location as a dot-separated path. + + Integer elements (array indices) are formatted as `[idx]` appended to the + previous component. String elements are joined with dots. An empty location + is rendered as ``. + + Examples: + ("aws", "regions", 0) -> "aws.regions[0]" + ("aws", "threshold") -> "aws.threshold" + () -> "" + """ + parts: list[str] = [] + for piece in loc: + if isinstance(piece, int): + if parts: + parts[-1] = f"{parts[-1]}[{piece}]" + else: + parts.append(f"[{piece}]") + else: + parts.append(str(piece)) + return ".".join(parts) if parts else "" + + +def validate_scan_config(payload: Any) -> list[dict]: + """Validate a scan config payload against the registered provider schemas. + + Strict by design: every Pydantic violation surfaces as a `{path, message}` + entry so the caller can decide how to present it. Unknown provider + sections are accepted (consistent with `additionalProperties: True` at + the top level — the SDK simply has no opinion on them). + """ + if not isinstance(payload, dict): + return [ + { + "path": "", + "message": "Scan config must be a mapping with provider sections.", + } + ] + + errors: list[dict] = [] + for provider, section in payload.items(): + schema_cls = SCHEMAS.get(provider) + if schema_cls is None: + # Unknown provider type: tolerated. The SDK will simply ignore it. + continue + if not isinstance(section, dict): + errors.append( + { + "path": str(provider), + "message": "section must be a mapping.", + } + ) + continue + try: + schema_cls.model_validate(section) + except ValidationError as exc: + for err in exc.errors(): + loc = err.get("loc") or () + path = _format_loc((str(provider), *loc)) + errors.append( + { + "path": path, + "message": err.get("msg", "validation error"), + } + ) + return errors + + +def _build_aggregated_schema() -> dict: + """Compose one JSON Schema per provider into a single top-level schema. + + The output mirrors the layout of `prowler/config/config.yaml` (a mapping + keyed by provider type) and is what the UI consumes via `ajv`. + """ + properties: dict[str, dict] = {} + for provider, schema_cls in SCHEMAS.items(): + properties[provider] = schema_cls.model_json_schema() + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Prowler Scan Config", + "type": "object", + "additionalProperties": True, + "properties": properties, + } + + +SCAN_CONFIG_SCHEMA: dict = _build_aggregated_schema() diff --git a/prowler/config/schema/__init__.py b/prowler/config/schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/config/schema/alibabacloud.py b/prowler/config/schema/alibabacloud.py new file mode 100644 index 0000000000..104f8a3556 --- /dev/null +++ b/prowler/config/schema/alibabacloud.py @@ -0,0 +1,54 @@ +"""Alibaba Cloud provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class AlibabaCloudProviderConfig(ProviderConfigBase): + """Alibaba Cloud provider configuration schema. + + Bounds the retention and staleness thresholds consumed by the Alibaba + Cloud checks. Every field is optional: when omitted (or dropped for being + out of range) the check falls back to its own default via + ``audit_config.get(key, default)``. + """ + + max_cluster_check_days: Optional[int] = Field( + default=None, + ge=1, + le=365, + description=( + "Maximum number of days an ACK cluster can go without a security " + "check before being flagged. Range: 1..365 (defaults to 7)." + ), + ) + max_console_access_days: Optional[int] = Field( + default=None, + ge=30, + le=180, + description=( + "Days a RAM user's console access can stay unused before being " + "flagged. Range: 30..180 (defaults to 90)." + ), + ) + min_log_retention_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description=( + "Minimum required SLS log store retention, in days. Range: " + "1..3650 (defaults to 365)." + ), + ) + min_rds_audit_retention_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description=( + "Minimum required RDS SQL audit log retention, in days. Range: " + "1..3650 (defaults to 180)." + ), + ) diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py new file mode 100644 index 0000000000..d15fc276c5 --- /dev/null +++ b/prowler/config/schema/aws.py @@ -0,0 +1,404 @@ +"""AWS provider config schema. + +Bounds on every field are intentionally conservative: they are not the +absolute service maxima but the values that produce a useful security +check. A user is free to keep the built-in default by omitting the key — +out-of-range values are dropped with a warning at SDK runtime, and +rejected at the Prowler App backend. + +Whenever an upper bound is uncertain, the cap is set to a value that +still keeps the check meaningful (e.g. a 10-year window for date-based +thresholds) and avoids ints that obviously break downstream maths +(`min_kinesis_stream_retention_hours = 99999`). +""" + +from typing import Annotated, Literal, Optional + +from pydantic import AfterValidator, Field + +from prowler.config.schema.base import ProviderConfigBase +from prowler.config.schema.validators import ( + make_dotted_version_validator, + validate_ip_networks, + validate_port_range, +) + +# ---- Reusable constants ----------------------------------------------------- + +# CloudWatch Logs only accepts these retention values (in days). Anything else +# is silently coerced to the next valid value by the API — we reject upfront. +_CLOUDWATCH_RETENTION_DAYS = ( + 1, + 3, + 5, + 7, + 14, + 30, + 60, + 90, + 120, + 150, + 180, + 365, + 400, + 545, + 731, + 1096, + 1827, + 2192, + 2557, + 2922, + 3288, + 3653, +) + +_VALID_CW_RETENTION_LITERAL = Literal[ + 1, + 3, + 5, + 7, + 14, + 30, + 60, + 90, + 120, + 150, + 180, + 365, + 400, + 545, + 731, + 1096, + 1827, + 2192, + 2557, + 2922, + 3288, + 3653, +] + + +# ---- Custom validators ------------------------------------------------------ + + +# Reusable validators shared across providers (see schema/validators.py). +_validate_port_range = validate_port_range +_validate_trusted_ips = validate_ip_networks +# "1.4.0" style strings (used by Fargate platform versions). +_validate_semver = make_dotted_version_validator(3, 3) +# "1.28" style strings (EKS minor versions). +_validate_eks_minor = make_dotted_version_validator(2, 2) + + +def _validate_account_ids(v: Optional[list[str]]) -> Optional[list[str]]: + if v is None: + return v + for account_id in v: + if not (account_id.isdigit() and len(account_id) == 12): + raise ValueError( + f"trusted_account_ids entry {account_id!r} is not a 12-digit AWS account id" + ) + return v + + +# ---- Main schema ------------------------------------------------------------ + + +class AWSProviderConfig(ProviderConfigBase): + # --- IAM --------------------------------------------------------------- + mute_non_default_regions: Optional[bool] = None + disallowed_regions: Optional[list[str]] = None + max_unused_access_keys_days: Optional[int] = Field( + default=None, + ge=30, + le=180, + description=( + "Days an IAM user access key can stay unused before being flagged. " + "Range: 30..180 days (CIS AWS 1.13 recommends 45; NIST IA-5 ≤90)." + ), + ) + max_console_access_days: Optional[int] = Field( + default=None, + ge=30, + le=180, + description=( + "Days an IAM console password can stay unused before being flagged. " + "Range: 30..180 days (CIS AWS 1.12 recommends 45)." + ), + ) + max_unused_sagemaker_access_days: Optional[int] = Field( + default=None, + ge=7, + le=180, + description=( + "Days a SageMaker user access key can stay unused. Range: 7..180 " + "(SageMaker tokens are usually high-privilege over S3/KMS)." + ), + ) + + # --- EC2 --------------------------------------------------------------- + shodan_api_key: Optional[str] = Field( + default=None, + max_length=512, + description="API key for Shodan lookups on EC2 public IPs.", + ) + max_security_group_rules: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description="Max ingress+egress rules per security group. AWS hard limit is 1000.", + ) + max_ec2_instance_age_in_days: Optional[int] = Field( + default=None, + ge=1, + le=1095, + description=( + "Days an EC2 instance can run before being flagged as old. " + "Range: 1..1095 (3 years; instances should be refreshed for patching " + "per NIST CM-3 — anything older is a security smell)." + ), + ) + ec2_allowed_interface_types: Optional[list[str]] = None + ec2_allowed_instance_owners: Optional[list[str]] = None + ec2_high_risk_ports: Annotated[ + Optional[list[int]], AfterValidator(_validate_port_range) + ] = Field( + default=None, + description="TCP/UDP ports considered high-risk when reachable from the Internet (1..65535; port 0 is reserved).", + ) + + # --- ECS --------------------------------------------------------------- + fargate_linux_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_semver) + ] = Field(default=None, description="Fargate Linux platform version (X.Y.Z).") + fargate_windows_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_semver) + ] = Field(default=None, description="Fargate Windows platform version (X.Y.Z).") + + # --- Cross-account trust ---------------------------------------------- + trusted_account_ids: Annotated[ + Optional[list[str]], AfterValidator(_validate_account_ids) + ] = Field( + default=None, + description="Additional 12-digit AWS account IDs trusted by cross-account checks.", + ) + trusted_ips: Annotated[ + Optional[list[str]], AfterValidator(_validate_trusted_ips) + ] = Field( + default=None, + description="IPv4/IPv6 addresses or CIDR ranges that are NOT considered public.", + ) + + # --- CloudWatch / CloudFormation -------------------------------------- + log_group_retention_days: Optional[_VALID_CW_RETENTION_LITERAL] = Field( + default=None, + description=( + "Required CloudWatch Logs retention in days. Must match one of the " + f"values accepted by the AWS API: {list(_CLOUDWATCH_RETENTION_DAYS)}." + ), + ) + recommended_cdk_bootstrap_version: Optional[int] = Field( + default=None, + ge=1, + le=100, + description="Min CDK bootstrap version expected on the account.", + ) + + # --- AppStream -------------------------------------------------------- + max_idle_disconnect_timeout_in_seconds: Optional[int] = Field( + default=None, + ge=60, + le=1800, + description=( + "AppStream idle disconnect timeout (seconds). Range: 60..1800 " + "(NIST AC-12: sensitive sessions ≤15 min — cap at 30 min)." + ), + ) + max_disconnect_timeout_in_seconds: Optional[int] = Field( + default=None, + ge=60, + le=3600, + description="AppStream disconnect timeout (seconds). Range: 60..3600.", + ) + max_session_duration_seconds: Optional[int] = Field( + default=None, + ge=600, + le=86400, + description=( + "AppStream max session duration (seconds). Range: 600..86400 " + "(10 min .. 24 h — AWS AppStream hard limit per session)." + ), + ) + + # --- Lambda ----------------------------------------------------------- + obsolete_lambda_runtimes: Optional[list[str]] = None + lambda_min_azs: Optional[int] = Field( + default=None, + ge=1, + le=6, + description="Min number of AZs a VPC-bound Lambda must span. Range: 1..6.", + ) + + # --- Organizations ---------------------------------------------------- + organizations_enabled_regions: Optional[list[str]] = None + organizations_trusted_delegated_administrators: Annotated[ + Optional[list[str]], AfterValidator(_validate_account_ids) + ] = None + organizations_trusted_ids: Optional[list[str]] = None + + # --- ECR -------------------------------------------------------------- + ecr_repository_vulnerability_minimum_severity: Optional[ + Literal["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"] + ] = Field( + default=None, + description="Highest severity tolerated for ECR images.", + ) + + # --- Trusted Advisor -------------------------------------------------- + verify_premium_support_plans: Optional[bool] = None + + # --- CloudTrail threat detection: privilege escalation ---------------- + threat_detection_privilege_escalation_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the priv-esc detection.", + ) + threat_detection_privilege_escalation_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description=( + "Lookback window (minutes) for priv-esc detection. Range: 5..43200 " + "(under 5 min the signal is dominated by false positives)." + ), + ) + threat_detection_privilege_escalation_actions: Optional[list[str]] = None + + # --- CloudTrail threat detection: enumeration ------------------------- + threat_detection_enumeration_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the enumeration detection.", + ) + threat_detection_enumeration_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description="Lookback window (minutes) for enumeration detection. Range: 5..43200.", + ) + threat_detection_enumeration_actions: Optional[list[str]] = None + + # --- CloudTrail threat detection: LLM jacking ------------------------- + threat_detection_llm_jacking_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the LLM-jacking detection.", + ) + threat_detection_llm_jacking_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description="Lookback window (minutes) for LLM-jacking detection. Range: 5..43200.", + ) + threat_detection_llm_jacking_actions: Optional[list[str]] = None + + # --- RDS -------------------------------------------------------------- + check_rds_instance_replicas: Optional[bool] = None + + # --- ACM -------------------------------------------------------------- + days_to_expire_threshold: Optional[int] = Field( + default=None, + ge=7, + le=365, + description=( + "Days before certificate expiration to flag. Range: 7..365 " + "(PCI-DSS 4.2.1.1: alert ≥30 days before expiry; <7 days is too " + "tight to actually act on)." + ), + ) + insecure_key_algorithms: Optional[list[str]] = None + + # --- EKS -------------------------------------------------------------- + eks_required_log_types: Optional[ + list[ + Literal[ + "api", + "audit", + "authenticator", + "controllerManager", + "scheduler", + ] + ] + ] = Field( + default=None, + description="EKS control plane log types that must be enabled.", + ) + eks_cluster_oldest_version_supported: Annotated[ + Optional[str], AfterValidator(_validate_eks_minor) + ] = Field( + default=None, + description='Minimum supported EKS minor version, expected as "X.Y".', + ) + + # --- CodeBuild -------------------------------------------------------- + excluded_sensitive_environment_variables: Optional[list[str]] = None + codebuild_github_allowed_organizations: Optional[list[str]] = None + + # --- ELB / ELBv2 ------------------------------------------------------ + elb_min_azs: Optional[int] = Field( + default=None, + ge=1, + le=6, + description="Min AZs a Classic ELB must span. Range: 1..6.", + ) + elbv2_min_azs: Optional[int] = Field( + default=None, + ge=1, + le=6, + description="Min AZs an Application/Network LB must span. Range: 1..6.", + ) + + # --- ElastiCache ----------------------------------------------------- + minimum_snapshot_retention_period: Optional[int] = Field( + default=None, + ge=1, + le=35, + description="Days an ElastiCache backup must be retained. Range: 1..35 (service hard limit).", + ) + + # --- Secrets --------------------------------------------------------- + secrets_ignore_patterns: Optional[list[str]] = None + secrets_validate: Optional[bool] = Field( + default=None, + description=( + "Validate discovered secrets against the provider APIs (live check). " + "Makes outbound network calls that authenticate with the discovered " + "secret. Disabled by default." + ), + ) + max_days_secret_unused: Optional[int] = Field( + default=None, + ge=7, + le=365, + description="Days a Secrets Manager secret can stay unused. Range: 7..365.", + ) + max_days_secret_unrotated: Optional[int] = Field( + default=None, + ge=1, + le=180, + description=( + "Days a Secrets Manager secret can go without rotation. Range: 1..180 " + "(NIST IA-5: rotate quarterly; CIS recommends ≤90)." + ), + ) + + # --- Kinesis --------------------------------------------------------- + min_kinesis_stream_retention_hours: Optional[int] = Field( + default=None, + ge=24, + le=8760, + description="Hours of Kinesis stream retention. Range: 24..8760 (1 day .. 1 year).", + ) diff --git a/prowler/config/schema/azure.py b/prowler/config/schema/azure.py new file mode 100644 index 0000000000..75954c602b --- /dev/null +++ b/prowler/config/schema/azure.py @@ -0,0 +1,83 @@ +"""Azure provider config schema with safety bounds. + +Bounds aim for values that produce a meaningful security check; out-of-range +values are dropped (SDK runtime) or rejected (Prowler App backend). +""" + +from typing import Annotated, Literal, Optional + +from pydantic import AfterValidator, Field + +from prowler.config.schema.base import ProviderConfigBase +from prowler.config.schema.validators import make_dotted_version_validator + +# Accept "8.2", "3.12", "17" style version strings. Used by App Service +# language version fields where the upstream APIs accept either MAJOR or +# MAJOR.MINOR notation. +_validate_dotted_version = make_dotted_version_validator(1, 2) + + +class AzureProviderConfig(ProviderConfigBase): + # --- Network --------------------------------------------------------- + shodan_api_key: Optional[str] = Field( + default=None, + max_length=512, + description="API key for Shodan lookups on Azure public IPs.", + ) + + # --- Defender -------------------------------------------------------- + defender_attack_path_minimal_risk_level: Optional[ + Literal["Low", "Medium", "High", "Critical"] + ] = Field( + default=None, + description="Minimum attack-path risk level worth a notification.", + ) + + # --- App Service ---------------------------------------------------- + php_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_dotted_version) + ] = Field(default=None, description='PHP minimum acceptable version, e.g. "8.2".') + python_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_dotted_version) + ] = Field( + default=None, description='Python minimum acceptable version, e.g. "3.12".' + ) + java_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_dotted_version) + ] = Field(default=None, description='Java minimum acceptable version, e.g. "17".') + + # --- SQL ------------------------------------------------------------ + recommended_minimal_tls_versions: Optional[list[Literal["1.2", "1.3"]]] = Field( + default=None, + description="TLS versions accepted on Azure SQL Server.", + ) + + # --- Virtual Machines ----------------------------------------------- + desired_vm_sku_sizes: Optional[list[str]] = None + vm_backup_min_daily_retention_days: Optional[int] = Field( + default=None, + ge=7, + le=9999, + description=( + "Min daily backup retention days. Range: 7..9999 " + "(Azure Backup hard limit; <7 days defeats DR/ransomware recovery)." + ), + ) + + # --- API Management threat detection (LLM jacking) ----------------- + apim_threat_detection_llm_jacking_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the detection.", + ) + apim_threat_detection_llm_jacking_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description=( + "Lookback window (minutes) for LLM-jacking detection. Range: 5..43200 " + "(under 5 min the signal is dominated by false positives)." + ), + ) + apim_threat_detection_llm_jacking_actions: Optional[list[str]] = None diff --git a/prowler/config/schema/base.py b/prowler/config/schema/base.py new file mode 100644 index 0000000000..cc473a4545 --- /dev/null +++ b/prowler/config/schema/base.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, ConfigDict + + +class ProviderConfigBase(BaseModel): + """Base for every provider config schema. + + ``extra="allow"`` is REQUIRED for backwards compatibility: third-party + check plugins frequently introduce config keys we do not know about, + and pre-existing user configs may carry deprecated keys. Validation + must never reject these. + """ + + model_config = ConfigDict( + extra="allow", + str_strip_whitespace=True, + validate_assignment=False, + ) diff --git a/prowler/config/schema/cloudflare.py b/prowler/config/schema/cloudflare.py new file mode 100644 index 0000000000..417092d6df --- /dev/null +++ b/prowler/config/schema/cloudflare.py @@ -0,0 +1,24 @@ +"""Cloudflare provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class CloudflareProviderConfig(ProviderConfigBase): + """Cloudflare provider configuration schema. + + Defines optional configuration parameters for Cloudflare security checks, + including API retry behavior. + """ + + max_retries: Optional[int] = Field( + default=None, + ge=0, + le=10, + description=( + "Max retries for Cloudflare API requests. Range: 0..10 (0 disables retries)." + ), + ) diff --git a/prowler/config/schema/gcp.py b/prowler/config/schema/gcp.py new file mode 100644 index 0000000000..59e1a162fa --- /dev/null +++ b/prowler/config/schema/gcp.py @@ -0,0 +1,45 @@ +"""GCP provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class GCPProviderConfig(ProviderConfigBase): + shodan_api_key: Optional[str] = Field( + default=None, + max_length=512, + description="API key for Shodan lookups on GCP public IPs.", + ) + mig_min_zones: Optional[int] = Field( + default=None, + ge=1, + le=5, + description="Min zones a Managed Instance Group must span. Range: 1..5.", + ) + max_snapshot_age_days: Optional[int] = Field( + default=None, + ge=1, + le=1095, + description=( + "Days a disk snapshot can age before being flagged. Range: 1..1095 " + "(3 years; older snapshots typically miss data-class compliance)." + ), + ) + max_unused_account_days: Optional[int] = Field( + default=None, + ge=30, + le=365, + description=( + "Days a service account or user-managed key can stay unused. " + "Range: 30..365." + ), + ) + storage_min_retention_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description="Min retention period on Cloud Storage buckets. Range: 1..3650.", + ) diff --git a/prowler/config/schema/github.py b/prowler/config/schema/github.py new file mode 100644 index 0000000000..30b5a13b85 --- /dev/null +++ b/prowler/config/schema/github.py @@ -0,0 +1,20 @@ +"""GitHub provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class GitHubProviderConfig(ProviderConfigBase): + inactive_not_archived_days_threshold: Optional[int] = Field( + default=None, + ge=30, + le=3650, + description=( + "Days a repository can stay inactive without being archived before " + "being flagged. Range: 30..3650 (CIS GitHub recommends 180; " + "<30 days produces false positives on seasonal projects)." + ), + ) diff --git a/prowler/config/schema/kubernetes.py b/prowler/config/schema/kubernetes.py new file mode 100644 index 0000000000..3235971500 --- /dev/null +++ b/prowler/config/schema/kubernetes.py @@ -0,0 +1,45 @@ +"""Kubernetes provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class KubernetesProviderConfig(ProviderConfigBase): + audit_log_maxbackup: Optional[int] = Field( + default=None, + ge=2, + le=1000, + description=( + "API server audit log file rotations to keep. Range: 2..1000 " + "(CIS Kubernetes 1.2.18 recommends ≥10)." + ), + ) + audit_log_maxsize: Optional[int] = Field( + default=None, + ge=10, + le=10000, + description=( + "Max MB per audit log file before rotation. Range: 10..10000 MB " + "(CIS Kubernetes 1.2.19 recommends ≥100 MB)." + ), + ) + audit_log_maxage: Optional[int] = Field( + default=None, + ge=7, + le=3650, + description=( + "Days an audit log file is retained. Range: 7..3650 " + "(CIS Kubernetes 1.2.17 recommends ≥30 days)." + ), + ) + apiserver_strong_ciphers: Optional[list[str]] = Field( + default=None, + description="Whitelist of strong TLS cipher suites required on the API server.", + ) + kubelet_strong_ciphers: Optional[list[str]] = Field( + default=None, + description="Whitelist of strong TLS cipher suites required on kubelet.", + ) diff --git a/prowler/config/schema/m365.py b/prowler/config/schema/m365.py new file mode 100644 index 0000000000..ff1ff98285 --- /dev/null +++ b/prowler/config/schema/m365.py @@ -0,0 +1,54 @@ +"""M365 provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class M365ProviderConfig(ProviderConfigBase): + # --- Entra (sign-in policy) ---------------------------------------- + sign_in_frequency: Optional[int] = Field( + default=None, + ge=1, + le=168, + description=( + "Hours between forced sign-ins for admin users. Range: 1..168 (1 h .. 7 days). " + "Microsoft Conditional Access baseline for admin roles is ≤24 h." + ), + ) + + # --- Teams --------------------------------------------------------- + allowed_cloud_storage_services: Optional[list[str]] = Field( + default=None, + description="External cloud storage services allowed in Teams.", + ) + + # --- Exchange ------------------------------------------------------ + recommended_mailtips_large_audience_threshold: Optional[int] = Field( + default=None, + ge=5, + le=10000, + description=( + "Recipient count that should trigger a 'large audience' MailTip. " + "Range: 5..10000 (Microsoft default 25)." + ), + ) + + # --- Defender malware policy -------------------------------------- + default_recommended_extensions: Optional[list[str]] = Field( + default=None, + description="File extensions blocked by the malware policy.", + ) + + # --- Mailbox auditing --------------------------------------------- + audit_log_age: Optional[int] = Field( + default=None, + ge=30, + le=3650, + description=( + "Days mailbox audit logs must be retained. Range: 30..3650 " + "(M365 E3 default is 90 days; SEC/FINRA require ≥7 years)." + ), + ) diff --git a/prowler/config/schema/mongodbatlas.py b/prowler/config/schema/mongodbatlas.py new file mode 100644 index 0000000000..552e0a7bbd --- /dev/null +++ b/prowler/config/schema/mongodbatlas.py @@ -0,0 +1,25 @@ +"""MongoDB Atlas provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class MongoDBAtlasProviderConfig(ProviderConfigBase): + """MongoDB Atlas provider configuration schema. + + Defines optional configuration parameters for MongoDB Atlas security checks, + including service account secret validity constraints. + """ + + max_service_account_secret_validity_hours: Optional[int] = Field( + default=None, + ge=1, + le=720, + description=( + "Max hours a service account secret can stay valid. " + "Range: 1..720 (1 h .. 30 days)." + ), + ) diff --git a/prowler/config/schema/okta.py b/prowler/config/schema/okta.py new file mode 100644 index 0000000000..d70794756c --- /dev/null +++ b/prowler/config/schema/okta.py @@ -0,0 +1,65 @@ +"""Okta provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class OktaProviderConfig(ProviderConfigBase): + """Okta provider configuration schema. + + Bounds the session, idle-timeout and inactivity thresholds consumed by + the Okta checks. Every field is optional: when omitted (or dropped for + being out of range) the check falls back to its own DISA STIG-derived + default via ``audit_config.get(key, default)``. + """ + + okta_max_session_idle_minutes: Optional[int] = Field( + default=None, + ge=1, + le=1440, + description=( + "Maximum acceptable Global Session idle timeout, in minutes. " + "Range: 1..1440 (DISA STIG V-273186 recommends 15; raising it " + "weakens the idle-timeout control)." + ), + ) + okta_max_session_lifetime_minutes: Optional[int] = Field( + default=None, + ge=1, + le=43200, + description=( + "Maximum acceptable Global Session lifetime, in minutes. " + "Range: 1..43200 i.e. up to 30 days (DISA STIG recommends 18h = " + "1080; raising it weakens the session-lifetime control)." + ), + ) + okta_admin_console_idle_timeout_max_minutes: Optional[int] = Field( + default=None, + ge=1, + le=1440, + description=( + "Maximum acceptable Okta Admin Console app idle timeout, in " + "minutes. Range: 1..1440 (DISA STIG V-273187 recommends 15)." + ), + ) + okta_user_inactivity_max_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description=( + "Maximum number of days a user can stay inactive before the " + "inactivity-automation check flags the org. Range: 1..3650 " + "(defaults to 35)." + ), + ) + okta_dod_approved_ca_issuer_patterns: Optional[list[str]] = Field( + default=None, + description=( + "Additional regex patterns matched against a Smart Card IdP " + "certificate issuer DN to recognise a DOD-approved CA. Extends " + "the built-in `OU=DoD` / `OU=ECA` patterns." + ), + ) diff --git a/prowler/config/schema/openstack.py b/prowler/config/schema/openstack.py new file mode 100644 index 0000000000..02b94136ef --- /dev/null +++ b/prowler/config/schema/openstack.py @@ -0,0 +1,34 @@ +"""OpenStack provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class OpenStackProviderConfig(ProviderConfigBase): + """OpenStack provider configuration schema. + + Bounds the image-sharing threshold and reuses the ``secrets_ignore_patterns`` + config consumed by the metadata sensitive-data checks. Every field is + optional: when omitted (or dropped for being out of range) the check falls + back to its own default via ``audit_config.get(key, default)``. + """ + + image_sharing_threshold: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description=( + "Maximum number of accepted project members a shared image may " + "have before being flagged. Range: 1..1000 (defaults to 5)." + ), + ) + secrets_ignore_patterns: Optional[list[str]] = Field( + default=None, + description=( + "Regex patterns whose matches are excluded from secret " + "scanning of resource metadata." + ), + ) diff --git a/prowler/config/schema/registry.py b/prowler/config/schema/registry.py new file mode 100644 index 0000000000..d34a9b866a --- /dev/null +++ b/prowler/config/schema/registry.py @@ -0,0 +1,34 @@ +"""Mapping of provider name to its Pydantic schema class. + +Kept in its own module so the validator stays free of provider-schema imports +and callers pay the import cost only when they actually need the registry. +""" + +from prowler.config.schema.alibabacloud import AlibabaCloudProviderConfig +from prowler.config.schema.aws import AWSProviderConfig +from prowler.config.schema.azure import AzureProviderConfig +from prowler.config.schema.base import ProviderConfigBase +from prowler.config.schema.cloudflare import CloudflareProviderConfig +from prowler.config.schema.gcp import GCPProviderConfig +from prowler.config.schema.github import GitHubProviderConfig +from prowler.config.schema.kubernetes import KubernetesProviderConfig +from prowler.config.schema.m365 import M365ProviderConfig +from prowler.config.schema.mongodbatlas import MongoDBAtlasProviderConfig +from prowler.config.schema.okta import OktaProviderConfig +from prowler.config.schema.openstack import OpenStackProviderConfig +from prowler.config.schema.vercel import VercelProviderConfig + +SCHEMAS: dict[str, type[ProviderConfigBase]] = { + "aws": AWSProviderConfig, + "azure": AzureProviderConfig, + "gcp": GCPProviderConfig, + "kubernetes": KubernetesProviderConfig, + "m365": M365ProviderConfig, + "github": GitHubProviderConfig, + "mongodbatlas": MongoDBAtlasProviderConfig, + "cloudflare": CloudflareProviderConfig, + "vercel": VercelProviderConfig, + "okta": OktaProviderConfig, + "alibabacloud": AlibabaCloudProviderConfig, + "openstack": OpenStackProviderConfig, +} diff --git a/prowler/config/schema/validator.py b/prowler/config/schema/validator.py new file mode 100644 index 0000000000..8113302855 --- /dev/null +++ b/prowler/config/schema/validator.py @@ -0,0 +1,66 @@ +from typing import Any + +from pydantic import ValidationError + +from prowler.config.schema.base import ProviderConfigBase +from prowler.lib.logger import logger + + +def validate_provider_config( + provider: str, + raw: Any, + schema_cls: type[ProviderConfigBase] | None, +) -> dict: + """Validate a provider's config dict against its Pydantic schema. + + Behavior is intentionally lenient to preserve backwards compatibility: + + - If ``raw`` is not a dict, return an empty dict (mirrors prior loader). + - If no schema is registered for ``provider``, return ``raw`` untouched. + - On validation errors, log one WARNING per offending field, DROP those + keys from the result, and continue. Consumers fall back to their own + hard-coded defaults via ``audit_config.get(key, default)``. + - Coerced values (e.g. ``"180"`` -> ``180``) replace the user's input + so that downstream checks never receive a wrongly-typed value. + """ + if not isinstance(raw, dict): + return {} + + if schema_cls is None: + return raw + + try: + model = schema_cls.model_validate(raw) + return model.model_dump(exclude_unset=True) + except ValidationError as exc: + bad_keys: set[str] = set() + for err in exc.errors(): + loc = err.get("loc") or () + if not loc: + continue + key = loc[0] + if not isinstance(key, str): + continue + bad_keys.add(key) + logger.warning( + f"prowler.config[{provider}.{key}] = {raw.get(key)!r} is invalid " + f"({err.get('msg', 'validation error')}); the value will be ignored " + f"and the built-in default will be used." + ) + + cleaned = {k: v for k, v in raw.items() if k not in bad_keys} + # Retry validation with the cleaned dict. Dropping invalid keys handles + # common field-level mismatches, but revalidation can still fail due to + # higher-level structural constraints (e.g. nested validation errors not + # captured in the top-level bad_keys). In that case, log and return the + # cleaned dict so consumers fall back to their own defaults. + try: + model = schema_cls.model_validate(cleaned) + return model.model_dump(exclude_unset=True) + except ValidationError as exc2: + logger.error( + f"prowler.config[{provider}] could not be revalidated after dropping " + f"invalid keys ({bad_keys}); passing through the cleaned dict as-is. " + f"Underlying errors: {exc2.errors()}" + ) + return cleaned diff --git a/prowler/config/schema/validators.py b/prowler/config/schema/validators.py new file mode 100644 index 0000000000..3c87f532c7 --- /dev/null +++ b/prowler/config/schema/validators.py @@ -0,0 +1,71 @@ +"""Reusable field validators shared across provider config schemas. + +These are factored out so multiple providers can reuse the same validation +logic (version strings, port ranges, IP/CIDR entries) instead of duplicating +it per schema. Each validator accepts ``None`` so optional fields stay valid +when the key is absent. +""" + +from ipaddress import ip_network +from typing import Callable, Optional + +_VERSION_PART_LABELS = ("X", "Y", "Z", "W") + + +def make_dotted_version_validator( + min_parts: int, max_parts: int +) -> Callable[[Optional[str]], Optional[str]]: + """Build a validator for dotted numeric version strings. + + The returned validator accepts ``None`` and strings made of between + ``min_parts`` and ``max_parts`` dot-separated numeric components. Anything + else raises ``ValueError``. + + Examples: + ``make_dotted_version_validator(3, 3)`` accepts ``"1.4.0"`` (semver). + ``make_dotted_version_validator(2, 2)`` accepts ``"1.28"`` (EKS minor). + ``make_dotted_version_validator(1, 2)`` accepts ``"17"`` or ``"8.2"``. + """ + if min_parts == max_parts: + expected = ".".join(_VERSION_PART_LABELS[:min_parts]) + else: + expected = " or ".join( + f"'{'.'.join(_VERSION_PART_LABELS[:n])}'" + for n in range(min_parts, max_parts + 1) + ) + + def _validate(v: Optional[str]) -> Optional[str]: + if v is None: + return v + parts = v.split(".") + if not (min_parts <= len(parts) <= max_parts) or not all( + p.isdigit() for p in parts + ): + raise ValueError(f"{v!r} is not a valid version (expected {expected})") + return v + + return _validate + + +def validate_port_range(v: Optional[list[int]]) -> Optional[list[int]]: + """Reject ports outside the valid ``1..65535`` range.""" + if v is None: + return v + for port in v: + if not 1 <= port <= 65535: + raise ValueError(f"port {port} is outside the valid range 1..65535") + return v + + +def validate_ip_networks(v: Optional[list[str]]) -> Optional[list[str]]: + """Reject entries that are not a valid IP address or CIDR network.""" + if v is None: + return v + for entry in v: + try: + ip_network(entry, strict=False) + except ValueError as exc: + raise ValueError( + f"entry {entry!r} is not a valid IP or CIDR ({exc})" + ) from exc + return v diff --git a/prowler/config/schema/vercel.py b/prowler/config/schema/vercel.py new file mode 100644 index 0000000000..2e466e2049 --- /dev/null +++ b/prowler/config/schema/vercel.py @@ -0,0 +1,68 @@ +"""Vercel provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class VercelProviderConfig(ProviderConfigBase): + """Vercel provider configuration schema. + + Defines optional configuration parameters for Vercel security checks, + including deployment branch policies, credential staleness thresholds, + RBAC ownership limits, and secret detection patterns. + """ + + stable_branches: Optional[list[str]] = Field( + default=None, + description="Branches considered stable for production deployments.", + ) + days_to_expire_threshold: Optional[int] = Field( + default=None, + ge=7, + le=365, + description=( + "Days before token/certificate expiration to flag. Range: 7..365 " + "(PCI-DSS 4.2.1.1: alert ≥30 days before expiry)." + ), + ) + stale_token_threshold_days: Optional[int] = Field( + default=None, + ge=30, + le=3650, + description=( + "Days of inactivity before a token is considered stale. Range: 30..3650 " + "(NIST AC-2(3) typical window 30..90 days)." + ), + ) + stale_invitation_threshold_days: Optional[int] = Field( + default=None, + ge=7, + le=365, + description=( + "Days a pending invitation can stay open. Range: 7..365 " + "(OWASP ASVS 2.7.1 recommends short-lived invitations)." + ), + ) + max_owner_percentage: Optional[int] = Field( + default=None, + ge=1, + le=50, + description=( + "Max percentage of team members that can have the OWNER role. " + "Range: 1..50 (PoLP — having >50% of a team as OWNER defeats RBAC; " + "industry guidance recommends ≤25%)." + ), + ) + max_owners: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description="Absolute max owners (overrides percentage for large teams). Range: 1..1000.", + ) + secret_suffixes: Optional[list[str]] = Field( + default=None, + description="Suffixes that mark a project env var as secret-like.", + ) diff --git a/prowler/config/stackit_mutelist_example.yaml b/prowler/config/stackit_mutelist_example.yaml new file mode 100644 index 0000000000..40b04a2731 --- /dev/null +++ b/prowler/config/stackit_mutelist_example.yaml @@ -0,0 +1,26 @@ +### Project, Check and/or Region can be * to apply for all the cases. +### Project == +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Projects, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "project_id_1": + Checks: + "iaas_security_group_ssh_unrestricted": + Regions: + - "*" + Resources: + - "sg-production-ssh" + - "sg-development-rdp" + Tags: + - "environment=dev" + "project_id_2": + Checks: + "*": + Regions: + - "eu01" + Resources: + - ".*-test$" diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index 72031805e2..8115983bc6 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -3,12 +3,13 @@ from colorama import Fore, Style from prowler.config.config import banner_color, orange_color, prowler_version, timestamp -def print_banner(legend: bool = False): +def print_banner(legend: bool = False, provider: str = None): """ Prints the banner with optional legend for color codes. Parameters: - legend (bool): Flag to indicate whether to print the color legend or not. Default is False. + - provider (str): The provider being scanned, used to tailor the Prowler Cloud banner. Returns: - None @@ -20,20 +21,50 @@ def print_banner(legend: bool = False): | .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version} |_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL} -{Fore.GREEN}New! Send findings from Prowler CLI to Prowler Cloud{Style.RESET_ALL} -{Fore.GREEN}More details here: goto.prowler.com/import-findings{Style.RESET_ALL} - {Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL} """ print(banner) + print_prowler_cloud_banner(provider) + if legend: - print( - f""" + print(f""" {Style.BRIGHT}Color code for results:{Style.RESET_ALL} - {Fore.YELLOW}MANUAL (Manual check){Style.RESET_ALL} - {Fore.GREEN}PASS (Recommended value){Style.RESET_ALL} - {orange_color}MUTED (Muted by muted list){Style.RESET_ALL} - {Fore.RED}FAIL (Fix required){Style.RESET_ALL} - """ - ) + """) + + +def print_prowler_cloud_banner(provider: str = None): + """ + Prints a promotional banner highlighting what Prowler Cloud adds on top of + the open-source CLI. + + Shown at the start and end of a scan to let users know about the managed + platform capabilities they are missing (attack paths, AI, organizations, + continuous scanning, integrations and live compliance dashboards). + + Parameters: + - provider (str): The provider that was scanned, used to tailor the message. + + Returns: + - None + """ + check = f"{Fore.GREEN}✓{Style.RESET_ALL}" + bar = f"{banner_color}│{Style.RESET_ALL}" + print(f""" +{bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL} +{bar} +{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - scheduled scans with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation. +{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening. +{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date. +{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI. +{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels. +{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds. +{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC. +{bar} +{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL} +""") diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 0041d4fde2..c0c6a02e5d 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -1,4 +1,6 @@ import importlib +import importlib.metadata +import importlib.util import json import os import re @@ -19,6 +21,7 @@ from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger from prowler.lib.outputs.outputs import report from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes +from prowler.providers.common.builtin import is_builtin_provider from prowler.providers.common.models import Audit_Metadata @@ -299,12 +302,22 @@ def print_compliance_frameworks( def print_compliance_requirements( bulk_compliance_frameworks: dict, compliance_frameworks: list ): + from prowler.lib.check.compliance_models import ComplianceFramework + for compliance_framework in compliance_frameworks: for key in bulk_compliance_frameworks.keys(): - framework = bulk_compliance_frameworks[key].Framework - provider = bulk_compliance_frameworks[key].Provider - version = bulk_compliance_frameworks[key].Version - requirements = bulk_compliance_frameworks[key].Requirements + entry = bulk_compliance_frameworks[key] + is_universal = isinstance(entry, ComplianceFramework) + if is_universal: + framework = entry.framework + provider = entry.provider or "Multi-provider" + version = entry.version + requirements = entry.requirements + else: + framework = entry.Framework + provider = entry.Provider or "Multi-provider" + version = entry.Version + requirements = entry.Requirements # We can list the compliance requirements for a given framework using the # bulk_compliance_frameworks keys since they are the compliance specification file name if compliance_framework == key: @@ -313,10 +326,23 @@ def print_compliance_requirements( ) for requirement in requirements: checks = "" - for check in requirement.Checks: - checks += f" {Fore.YELLOW}\t\t{check}\n{Style.RESET_ALL}" + if is_universal: + req_checks = requirement.checks + req_id = requirement.id + req_description = requirement.description + else: + req_checks = requirement.Checks + req_id = requirement.Id + req_description = requirement.Description + if isinstance(req_checks, dict): + for prov, check_list in req_checks.items(): + for check in check_list: + checks += f" {Fore.YELLOW}\t\t[{prov}] {check}\n{Style.RESET_ALL}" + else: + for check in req_checks: + checks += f" {Fore.YELLOW}\t\t{check}\n{Style.RESET_ALL}" print( - f"Requirement Id: {Fore.MAGENTA}{requirement.Id}{Style.RESET_ALL}\n\t- Description: {requirement.Description}\n\t- Checks:\n{checks}" + f"Requirement Id: {Fore.MAGENTA}{req_id}{Style.RESET_ALL}\n\t- Description: {req_description}\n\t- Checks:\n{checks}" ) @@ -362,6 +388,45 @@ def import_check(check_path: str) -> ModuleType: return lib +def _resolve_check_module( + provider_type: str, service: str, check_name: str +) -> ModuleType: + """Resolve and import a check module. + + Built-in wins on CheckID collision. Plug-ins are first-class extenders + (they can add new checks under new CheckIDs) but cannot override + existing built-ins — a security tool prefers fail-loud predictability + over silent overrides. CheckMetadata.get_bulk() applies the same + precedence on the metadata side (first-write-wins) and emits a warning + when a plug-in tries to override, so the user knows their plug-in + duplicate is being ignored and can rename it. + + Gates the built-in branch on `is_builtin_provider(provider_type)` — + calling `find_spec` on `prowler.providers.{provider_type}.services...` + directly would propagate `ModuleNotFoundError` for external providers + (their parent package `prowler.providers.{provider_type}` does not + exist) instead of returning None. The leaf helper encapsulates the + safe lookup, so external providers go straight to entry points. For + built-ins we still use `find_spec` to distinguish "check doesn't + exist" from "check exists but failed to import" (broken transitive + dep, etc.). + """ + # Built-in first — built-in wins on CheckID collision + if is_builtin_provider(provider_type): + builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}" + if importlib.util.find_spec(builtin_path) is not None: + return import_check(builtin_path) + + # Entry point lookup — only consulted when the built-in truly doesn't exist + for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"): + if ep.name == check_name: + return importlib.import_module(ep.value) + + raise ModuleNotFoundError( + f"Check '{check_name}' not found for provider '{provider_type}'" + ) + + def run_fixer(check_findings: list) -> int: """ Run the fixer for the check if it exists and there are any FAIL findings @@ -502,9 +567,10 @@ def execute_checks( service = check_name.split("_")[0] try: try: - # Import check module - check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) + lib = _resolve_check_module( + global_provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() @@ -582,9 +648,10 @@ def execute_checks( ) try: try: - # Import check module - check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) + lib = _resolve_check_module( + global_provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() @@ -718,12 +785,35 @@ def execute( is_finding_muted_args["team_id"] = ( team.id if team else global_provider.identity.user_id ) + elif global_provider.type == "scaleway": + is_finding_muted_args["organization_id"] = ( + global_provider.identity.organization_id + ) + elif global_provider.type == "oraclecloud": + is_finding_muted_args["tenancy_id"] = ( + global_provider.identity.tenancy_id + ) + elif global_provider.type == "okta": + is_finding_muted_args["org_domain"] = ( + global_provider.identity.org_domain + ) + elif global_provider.type == "linode": + is_finding_muted_args["account_id"] = ( + global_provider.identity.account_id + ) + elif not is_builtin_provider(global_provider.type): + # External/custom provider — delegate identity args + is_finding_muted_args = global_provider.get_mutelist_finding_args() + for finding in check_findings: if global_provider.type == "cloudflare": is_finding_muted_args["account_id"] = finding.account_id if global_provider.type == "azure": - is_finding_muted_args["subscription_id"] = ( - global_provider.identity.subscriptions.get(finding.subscription) + is_finding_muted_args["subscription_id"] = finding.subscription + is_finding_muted_args["subscription_name"] = ( + global_provider.identity.subscriptions.get( + finding.subscription, finding.subscription + ) ) is_finding_muted_args["finding"] = finding finding.muted = global_provider.mutelist.is_finding_muted( diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index 56f606ecc6..77840084ff 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -5,6 +5,7 @@ from colorama import Fore, Style 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 +from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider from prowler.lib.logger import logger @@ -20,11 +21,18 @@ def load_checks_to_execute( compliance_frameworks: list = None, categories: set = None, resource_groups: set = None, + list_checks: bool = False, + universal_frameworks: dict = None, ) -> 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 tool-wrapper providers — they delegate + # scanning to an external tool and have no checks to recover. + # Single source of truth across __main__, the CheckMetadata validators, + # check discovery and this loader, covering both built-in tool wrappers + # (iac/llm/image) and external plug-ins that declare + # `is_external_tool_provider = True` via the contract. + if is_tool_wrapper_provider(provider): return set() # Local subsets @@ -153,12 +161,21 @@ def load_checks_to_execute( if not bulk_compliance_frameworks: bulk_compliance_frameworks = Compliance.get_bulk(provider=provider) for compliance_framework in compliance_frameworks: - checks_to_execute.update( - CheckMetadata.list( - bulk_compliance_frameworks=bulk_compliance_frameworks, - compliance_framework=compliance_framework, + # Try universal frameworks first (snake_case dict-keyed checks) + if ( + universal_frameworks + and compliance_framework in universal_frameworks + ): + fw = universal_frameworks[compliance_framework] + for req in fw.requirements: + checks_to_execute.update(req.checks.get(provider.lower(), [])) + elif compliance_framework in bulk_compliance_frameworks: + checks_to_execute.update( + CheckMetadata.list( + bulk_compliance_frameworks=bulk_compliance_frameworks, + compliance_framework=compliance_framework, + ) ) - ) # Handle if there are categories passed using --categories elif categories: @@ -209,7 +226,12 @@ def load_checks_to_execute( ): checks_to_execute.add(check_name) # Only execute threat detection checks if threat-detection category is set - if (not categories or "threat-detection" not in categories) and not check_list: + # Skip this exclusion when listing checks (--list-checks or --list-checks-json) + if ( + (not categories or "threat-detection" not in categories) + and not check_list + and not list_checks + ): for threat_detection_check in check_categories.get("threat-detection", []): checks_to_execute.discard(threat_detection_check) diff --git a/prowler/lib/check/compliance_config_eval.py b/prowler/lib/check/compliance_config_eval.py new file mode 100644 index 0000000000..763e1389c2 --- /dev/null +++ b/prowler/lib/check/compliance_config_eval.py @@ -0,0 +1,404 @@ +"""Shared evaluation of a requirement's configuration constraints. + +Some compliance requirements only hold if the configurable checks they map to +ran with a configuration strict enough for the requirement. For example CIS AWS +6.0 requirement 2.11 ("credentials unused for 45 days or more are disabled") +maps `iam_user_accesskey_unused` (config `max_unused_access_keys_days`); if the +user loosens that to 120 days the check can PASS while the requirement is, in +fact, not satisfied. + +A requirement declares its expectations via ``ConfigRequirements`` (a list of +``{Check, ConfigKey, Operator, Value}``). The configuration a scan applied is a +single, scan-global mapping (the provider's ``audit_config``), so the rules are +evaluated against that mapping directly. This module is consumed by the SDK +compliance outputs (CSV + CLI table) and by the Prowler App backend so the rule +lives in one place. +""" + +from typing import Any, Optional + +# Leading sentence of the message prepended to a finding's ``status_extended`` +# when its requirement's config constraints are not satisfied and the status is +# forced to FAIL. It opens every config-not-valid message, so it doubles as a +# stable marker for detecting the case programmatically. +CONFIG_NOT_VALID_PREFIX = "Configuration not valid for this requirement." + + +def _format_value(value: Any) -> str: + """Render a constraint value for a user-facing message (lists comma-joined).""" + if isinstance(value, (list, tuple, set)): + return ", ".join(str(item) for item in value) + return str(value) + + +def _describe_violation( + check: Any, config_key: Any, applied: Any, operator: str, expected: Any +) -> str: + """Return a product-friendly explanation of why a config violates a constraint. + + The message names the check and config key, the value the scan applied, what + the requirement needs, and how to fix it, in plain language rather than the + operator/value pair. + + Args: + check: the check the requirement maps to (e.g. ``iam_user_accesskey_unused``). + config_key: the config option that was too loose (e.g. ``max_unused_access_keys_days``). + applied: the value the scan actually applied. + operator: the constraint operator (``lte``/``gte``/``eq``/``in``/``subset``/``superset``). + expected: the value the requirement expects. + + Returns: + A full, human-readable message ending with an actionable fix. + """ + applied_str = _format_value(applied) + expected_str = _format_value(expected) + needs, fix = { + "lte": ( + f"a value of {expected_str} or lower", + f"Update it to {expected_str} or lower.", + ), + "gte": ( + f"a value of {expected_str} or higher", + f"Update it to {expected_str} or higher.", + ), + "eq": ( + f"it set to {expected_str}", + f"Update it to {expected_str}.", + ), + "in": ( + f"it set to one of {expected_str}", + f"Update it to one of {expected_str}.", + ), + "subset": ( + f"it limited to {expected_str}", + f"Remove any value that is not in {expected_str}.", + ), + "superset": ( + f"it to include {expected_str}", + f"Make sure it includes {expected_str}.", + ), + }.get(operator, (f"a different value (expected {operator} {expected_str})", "")) + message = ( + f"{CONFIG_NOT_VALID_PREFIX} The check {check} has {config_key} set to " + f"{applied_str}, but the requirement needs {needs}." + ) + return f"{message} {fix}".strip() + + +def _check_operator(applied: Any, operator: str, expected: Any) -> bool: + """Return whether ``applied`` satisfies ``operator`` against ``expected``.""" + try: + if operator == "lte": + return applied <= expected + if operator == "gte": + return applied >= expected + if operator == "eq": + return applied == expected + if operator == "in": + return applied in expected + if operator in ("subset", "superset"): + # Set comparisons for list-valued configs (allowlists / denylists). + # Both sides must be collections; anything else is not satisfiable. + if not isinstance(applied, (list, tuple, set)) or not isinstance( + expected, (list, tuple, set) + ): + return False + applied_set, expected_set = set(applied), set(expected) + if operator == "subset": + return applied_set <= expected_set + return applied_set >= expected_set + except TypeError: + # Mismatched/unhashable types → treat as not satisfied. + return False + # Unknown operator: do not block the requirement on a malformed constraint. + return True + + +def evaluate_config_constraints( + config_requirements: Optional[list[Any]], + audit_config: Optional[dict[str, Any]], + provider_type: Optional[str] = None, +) -> tuple[bool, str]: + """Evaluate a requirement's config constraints against the scan's config. + + Args: + config_requirements: list of constraints, each a mapping (or object with + the same attributes) holding ``Check``, ``ConfigKey``, ``Operator``, + ``Value`` and an optional ``Provider``. ``None``/empty means the + requirement has no config expectations. + audit_config: the scan-global configuration mapping (the provider's + ``audit_config``, i.e. ``{config_key: value}``). The applied config + is identical across every resource and region of a scan. + provider_type: the provider being scanned (e.g. ``aws``). A constraint + tagged with a ``Provider`` is only evaluated when it matches this + value; this scopes universal (multi-provider) framework constraints + to the right provider. ``None`` disables provider scoping (every + constraint is evaluated), which is the correct behaviour for + single-provider frameworks. + + Returns: + ``(is_compliant, reason)``. ``is_compliant`` is ``True`` when there are + no constraints or every explicitly-set value satisfies its constraint. + When a configured value violates a constraint, returns ``(False, reason)`` + describing the first violation. A constraint whose ``ConfigKey`` was not + explicitly set is skipped (the check's default is assumed to match what + the requirement expects). + """ + if not config_requirements: + return True, "" + + audit_config = audit_config or {} + + for constraint in config_requirements: + # Accept both dicts (API template) and objects (Pydantic model). + if isinstance(constraint, dict): + check = constraint.get("Check") + config_key = constraint.get("ConfigKey") + operator = constraint.get("Operator") + expected = constraint.get("Value") + provider = constraint.get("Provider") + else: + check = getattr(constraint, "Check", None) + config_key = getattr(constraint, "ConfigKey", None) + operator = getattr(constraint, "Operator", None) + expected = getattr(constraint, "Value", None) + provider = getattr(constraint, "Provider", None) + + # Constraint scoped to another provider → not applicable to this scan. + # Compared case-insensitively (and trimmed) so a constraint authored as + # e.g. "AWS" still scopes to the "aws" scan instead of being silently + # bypassed by a casing/format mismatch. + if ( + provider + and provider_type + and str(provider).strip().lower() != str(provider_type).strip().lower() + ): + continue + + if config_key not in audit_config: + # Config not explicitly set → default is assumed adequate. + continue + + applied = audit_config[config_key] + if not _check_operator(applied, operator, expected): + reason = _describe_violation(check, config_key, applied, operator, expected) + return False, reason + + return True, "" + + +def get_scan_audit_config() -> dict[str, Any]: + """Return the scan-global applied configuration (the provider's audit_config). + + The applied config is identical across every resource and region of a scan, + so every compliance output evaluates constraints against this single mapping. + Imported lazily to avoid a circular import with the provider package and to + keep this module usable from contexts without a global provider. + + Returns: + The provider's ``audit_config`` mapping, or ``{}`` when no global + provider is set (``AttributeError``) or the provider package cannot be + imported (``ImportError``). + """ + try: + from prowler.providers.common.provider import Provider + + return Provider.get_global_provider().audit_config or {} + except (AttributeError, ImportError): + # No global provider set, or provider package unavailable. + return {} + + +def get_scan_provider_type() -> str: + """Return the provider being scanned (e.g. ``aws``) for constraint scoping. + + Imported lazily to avoid a circular import with the provider package and to + keep this module usable from contexts without a global provider. + + Returns: + The provider's ``type`` (e.g. ``aws``), or ``""`` when no global provider + is set (``AttributeError``) or the provider package cannot be imported + (``ImportError``); an empty string disables provider scoping. + """ + try: + from prowler.providers.common.provider import Provider + + return Provider.get_global_provider().type or "" + except (AttributeError, ImportError): + # No global provider set, or provider package unavailable. + return "" + + +def _requirement_id(requirement: Any) -> Optional[str]: + """Return a requirement's id across the legacy (``Id``) and universal (``id``) models.""" + return getattr(requirement, "Id", None) or getattr(requirement, "id", None) + + +def _requirement_constraints(requirement: Any) -> Optional[list]: + """Return a requirement's config constraints across both model flavours. + + Legacy ``Compliance_Requirement`` exposes ``ConfigRequirements`` (a list of + Pydantic models); ``UniversalComplianceRequirement`` exposes + ``config_requirements`` (a list of dicts). ``evaluate_config_constraints`` + handles both element types. + """ + return getattr(requirement, "ConfigRequirements", None) or getattr( + requirement, "config_requirements", None + ) + + +def build_requirement_config_status( + requirements: list[Any], + audit_config: Optional[dict[str, Any]] = None, + provider_type: Optional[str] = None, +) -> dict[str, tuple[bool, str]]: + """Map every requirement id to its ``(is_compliant, reason)`` config verdict. + + Only requirements that actually declare constraints are included; callers use + ``dict.get(req_id)`` (returning ``None`` → no constraints → no override). + + Args: + requirements: the framework's requirements (legacy or universal models). + audit_config: the applied config; resolved via ``get_scan_audit_config`` + when omitted. + provider_type: the provider being scanned, for constraint scoping; + resolved via ``get_scan_provider_type`` when omitted. + + Returns: + A mapping ``{requirement_id: (is_compliant, reason)}`` containing only the + requirements that declare config constraints. + """ + if audit_config is None: + audit_config = get_scan_audit_config() + if provider_type is None: + provider_type = get_scan_provider_type() + status = {} + for requirement in requirements: + constraints = _requirement_constraints(requirement) + if constraints: + status[_requirement_id(requirement)] = evaluate_config_constraints( + constraints, audit_config, provider_type + ) + return status + + +def resolve_requirement_config_status( + requirement: Any, + audit_config: dict[str, Any], + cache: dict, + provider_type: Optional[str] = None, +) -> tuple[bool, str]: + """Return a requirement's ``(is_compliant, reason)`` verdict, memoised in ``cache``. + + For table generators that iterate findings × compliances and only encounter + each requirement lazily. + + Args: + requirement: the requirement (legacy or universal model). + audit_config: the scan-global applied config. + cache: a dict keyed by requirement id, reused across the whole table + build to memoise verdicts. + provider_type: the provider being scanned, for constraint scoping; + resolved via ``get_scan_provider_type`` when omitted. + + Returns: + The ``(is_compliant, reason)`` verdict; ``(True, "")`` when the + requirement declares no constraints. + """ + req_id = _requirement_id(requirement) + if req_id not in cache: + constraints = _requirement_constraints(requirement) + if constraints: + if provider_type is None: + provider_type = get_scan_provider_type() + cache[req_id] = evaluate_config_constraints( + constraints, audit_config, provider_type + ) + else: + cache[req_id] = (True, "") + return cache[req_id] + + +def apply_config_status( + status: str, + status_extended: str, + config_status: Optional[tuple[bool, str]], +) -> tuple[str, str]: + """Override a finding's ``(status, status_extended)`` when its config is invalid. + + A requirement whose configurable checks ran with a config too loose to trust + is forced to ``FAIL`` regardless of the finding's own status, with the reason + prepended to ``status_extended``. + + Args: + status: the finding's original status (e.g. ``PASS`` / ``FAIL``). + status_extended: the finding's extended status message. + config_status: the ``(is_compliant, reason)`` tuple from + ``build_requirement_config_status``/``resolve_requirement_config_status``, + or ``None`` when the requirement declares no constraints. + + Returns: + The ``(status, status_extended)`` to report: unchanged when the config is + valid (or ``config_status`` is ``None``); otherwise ``FAIL`` with the + reason prepended to ``status_extended``. + """ + if not config_status or config_status[0]: + return status, status_extended + return ( + "FAIL", + f"{config_status[1]} {status_extended}".strip(), + ) + + +def get_effective_status( + status: str, + config_status: Optional[tuple[bool, str]], +) -> str: + """Return the effective status for table aggregation. + + Args: + status: the finding's original status. + config_status: the ``(is_compliant, reason)`` tuple, or ``None`` when the + requirement declares no constraints. + + Returns: + ``FAIL`` when ``config_status`` marks the config invalid; otherwise the + finding's original ``status``. + """ + if not config_status or config_status[0]: + return status + return "FAIL" + + +def accumulate_overview_status( + index: int, + status: str, + pass_indices: set, + fail_indices: set, + muted_indices: set, +) -> None: + """Record a finding in the overview totals once, with FAIL precedence over PASS (sets mutated in place).""" + if status == "Muted": + muted_indices.add(index) + elif status == "FAIL": + fail_indices.add(index) + pass_indices.discard(index) + elif status == "PASS" and index not in fail_indices: + pass_indices.add(index) + + +def accumulate_group_status( + index: int, + status: str, + counts: dict, + seen: dict, +) -> None: + """Count a finding once per group, upgrading a counted PASS to FAIL on conflict (mutates ``counts``/``seen``).""" + previous = seen.get(index) + if previous is None: + seen[index] = status + counts[status] += 1 + elif previous == "PASS" and status == "FAIL": + seen[index] = "FAIL" + counts["PASS"] -= 1 + counts["FAIL"] += 1 diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index d1f3b8c35d..80322a6929 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -1,9 +1,11 @@ +import importlib.metadata +import json import os import sys from enum import Enum -from typing import Optional, Union +from typing import Any, Literal, Optional, Union -from pydantic.v1 import BaseModel, ValidationError, root_validator +from pydantic.v1 import BaseModel, Field, ValidationError, root_validator from prowler.lib.check.utils import list_compliance_modules from prowler.lib.logger import logger @@ -101,6 +103,48 @@ class CIS_Requirement_Attribute(BaseModel): References: str +class ASDEssentialEight_Requirement_Attribute_MaturityLevel(str, Enum): + """ASD Essential Eight Maturity Level""" + + ML1 = "ML1" + ML2 = "ML2" + ML3 = "ML3" + + +class ASDEssentialEight_Requirement_Attribute_AssessmentStatus(str, Enum): + """Essential Eight Requirement Attribute Assessment Status""" + + Manual = "Manual" + Automated = "Automated" + + +class ASDEssentialEight_Requirement_Attribute_CloudApplicability(str, Enum): + """How well the ASD control maps to AWS cloud infrastructure.""" + + Full = "full" + Partial = "partial" + Limited = "limited" + NonApplicable = "non-applicable" + + +# Essential Eight Requirement Attribute +class ASDEssentialEight_Requirement_Attribute(BaseModel): + """ASD Essential Eight Requirement Attribute""" + + Section: str + MaturityLevel: ASDEssentialEight_Requirement_Attribute_MaturityLevel + AssessmentStatus: ASDEssentialEight_Requirement_Attribute_AssessmentStatus + CloudApplicability: ASDEssentialEight_Requirement_Attribute_CloudApplicability + MitigatedThreats: list[str] + Description: str + RationaleStatement: str + ImpactStatement: str + RemediationProcedure: str + AuditProcedure: str + AdditionalInformation: str + References: str + + # Well Architected Requirement Attribute class AWS_Well_Architected_Requirement_Attribute(BaseModel): """AWS Well Architected Requirement Attribute""" @@ -126,6 +170,79 @@ class ISO27001_2013_Requirement_Attribute(BaseModel): Check_Summary: str +# Base Compliance Model +class Compliance_Requirement_ConfigConstraint(BaseModel): + """A constraint a requirement places on a configurable check's config. + + Declares that the configurable check ``Check`` must have run with + ``ConfigKey`` satisfying ``Operator`` ``Value`` for the requirement's + result to be trusted. Example: ``max_unused_access_keys_days <= 45``. + + ``Provider`` scopes the constraint to a single provider. It is required for + universal (multi-provider) frameworks, where the same requirement maps + checks across providers and a constraint must only apply when that provider + is the one being scanned. Single-provider frameworks may omit it (the + framework's provider is already the one being scanned). + + Operators: + - ``lte``/``gte``/``eq``: scalar comparisons (e.g. a max-age or min-retention + threshold, or a boolean toggle). + - ``in``: the applied scalar must be one of ``Value`` (a list). + - ``subset``: the applied list must be a subset of ``Value`` — for allowlist + configs (e.g. ``recommended_minimal_tls_versions``); widening the allowlist + with a weaker value (e.g. TLS ``1.0``) breaks the constraint. + - ``superset``: the applied list must be a superset of ``Value`` — for + denylist configs (e.g. ``insecure_key_algorithms``); removing a forbidden + value from the denylist breaks the constraint. + """ + + Check: str + ConfigKey: str + Operator: Literal["lte", "gte", "eq", "in", "subset", "superset"] + # ``bool`` must precede ``int`` so pydantic v1 keeps booleans (e.g. a + # ``mute_non_default_regions == false`` constraint) instead of coercing + # them to 0/1. + Value: Union[bool, int, float, str, list[Any]] + # Provider this constraint applies to (e.g. ``aws``), matched + # case-insensitively. ``None`` applies whenever the requirement runs + # (single-provider frameworks). + Provider: Optional[str] = None + + @root_validator + @classmethod + def validate_value_matches_operator(cls, values): # noqa: F841 + """Ensure ``Value``'s type is consistent with ``Operator``. + + Without this, a mistyped value (e.g. ``gte`` with a list, or ``subset`` + with a scalar) is not rejected at load time and ``_check_operator`` + silently treats it as *not satisfied*, forcing the requirement to a + spurious config-not-valid FAIL. Validating here turns that into a + clear error when the framework is loaded. + """ + operator = values.get("Operator") + value = values.get("Value") + # If Operator/Value failed their own validation they are absent here. + if operator is None or value is None: + return values + if operator in ("in", "subset", "superset"): + if not isinstance(value, list): + raise ValueError( + f"operator '{operator}' requires a list Value, got {type(value).__name__}" + ) + elif operator in ("lte", "gte"): + # bool is an int subclass but is never a valid numeric threshold. + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError( + f"operator '{operator}' requires a numeric Value, got {value!r}" + ) + elif operator == "eq": + if not isinstance(value, (bool, int, float, str)): + raise ValueError( + f"operator 'eq' requires a scalar Value, got {type(value).__name__}" + ) + return values + + # MITRE Requirement Attribute for AWS class Mitre_Requirement_Attribute_AWS(BaseModel): """MITRE Requirement Attribute""" @@ -173,6 +290,9 @@ class Mitre_Requirement(BaseModel): list[Mitre_Requirement_Attribute_GCP], ] Checks: list[str] + # MITRE checks may also declare config constraints; without this field + # Pydantic silently drops them during parsing. + ConfigRequirements: Optional[list[Compliance_Requirement_ConfigConstraint]] = None # KISA-ISMS-P Requirement Attribute @@ -239,7 +359,26 @@ class CSA_CCM_Requirement_Attribute(BaseModel): ScopeApplicability: list[dict] -# Base Compliance Model +class STIG_Requirement_Attribute_Severity(str, Enum): + """DISA STIG Requirement Attribute Severity (maps to CAT I/II/III)""" + + high = "high" + medium = "medium" + low = "low" + + +class STIG_Requirement_Attribute(BaseModel): + """DISA STIG Requirement Attribute""" + + Section: str + Severity: STIG_Requirement_Attribute_Severity + RuleID: str + StigID: str + CCI: Optional[list[str]] = None + CheckText: Optional[str] = None + FixText: Optional[str] = None + + # TODO: move this to compliance folder class Compliance_Requirement(BaseModel): """Compliance_Requirement holds the base model for every requirement within a compliance framework""" @@ -249,6 +388,7 @@ class Compliance_Requirement(BaseModel): Name: Optional[str] = None Attributes: list[ Union[ + ASDEssentialEight_Requirement_Attribute, CIS_Requirement_Attribute, ENS_Requirement_Attribute, ISO27001_2013_Requirement_Attribute, @@ -258,11 +398,13 @@ class Compliance_Requirement(BaseModel): CCC_Requirement_Attribute, C5Germany_Requirement_Attribute, CSA_CCM_Requirement_Attribute, + STIG_Requirement_Attribute, # Generic_Compliance_Requirement_Attribute must be the last one since it is the fallback for generic compliance framework Generic_Compliance_Requirement_Attribute, ] ] Checks: list[str] + ConfigRequirements: Optional[list[Compliance_Requirement_ConfigConstraint]] = None class Compliance(BaseModel): @@ -390,26 +532,63 @@ class Compliance(BaseModel): """Bulk load all compliance frameworks specification into a dict""" try: bulk_compliance_frameworks = {} + # Built-in compliance from prowler/compliance/{provider}/ available_compliance_framework_modules = list_compliance_modules() for compliance_framework in available_compliance_framework_modules: - if provider in compliance_framework.name: + # Match the provider segment exactly, not as a substring, so + # e.g. `cloud` does not capture `cloudflare`. + if compliance_framework.name.split(".")[-1] == provider: compliance_specification_dir_path = ( f"{compliance_framework.module_finder.path}/{provider}" ) - # for compliance_framework in available_compliance_framework_modules: for filename in os.listdir(compliance_specification_dir_path): file_path = os.path.join( compliance_specification_dir_path, filename ) - # Check if it is a file and ti size is greater than 0 if os.path.isfile(file_path) and os.stat(file_path).st_size > 0: - # Open Compliance file in JSON - # cis_v1.4_aws.json --> cis_v1.4_aws compliance_framework_name = filename.split(".json")[0] - # Store the compliance info bulk_compliance_frameworks[compliance_framework_name] = ( load_compliance_framework(file_path) ) + + # External compliance via entry points + for ep in importlib.metadata.entry_points(group="prowler.compliance"): + if ep.name == provider: + try: + module = ep.load() + compliance_dir = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + for filename in os.listdir(compliance_dir): + if filename.endswith(".json"): + file_path = os.path.join(compliance_dir, filename) + if ( + os.path.isfile(file_path) + and os.stat(file_path).st_size > 0 + ): + compliance_framework_name = filename.split(".json")[ + 0 + ] + if ( + compliance_framework_name + not in bulk_compliance_frameworks + ): + # External JSON: tolerate non-legacy + # schemas (skip + warn) instead of aborting. + framework = load_compliance_framework( + file_path, fatal=False + ) + if framework is not None: + bulk_compliance_frameworks[ + compliance_framework_name + ] = framework + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as e: logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}") @@ -418,15 +597,537 @@ class Compliance(BaseModel): # Testing Pending def load_compliance_framework( - compliance_specification_file: str, -) -> Compliance: - """load_compliance_framework loads and parse a Compliance Framework Specification""" + compliance_specification_file: str, fatal: bool = True +) -> Optional[Compliance]: + """load_compliance_framework loads and parse a Compliance Framework Specification. + + With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with + ``fatal=False`` (external JSONs) it is skipped with a warning and ``None`` + is returned. + """ try: - compliance_framework = Compliance.parse_file(compliance_specification_file) + return Compliance.parse_file(compliance_specification_file) except ValidationError as error: - logger.critical( - f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + if fatal: + logger.critical( + f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + ) + sys.exit(1) + logger.warning( + f"Skipping invalid compliance framework {compliance_specification_file}: {error}" ) - sys.exit(1) - else: - return compliance_framework + return None + + +# ─── Universal Compliance Schema Models (Phase 1-3) ───────────────────────── + + +class OutputFormats(BaseModel): + """Flags indicating in which output formats an attribute should be included.""" + + csv: bool = True + ocsf: bool = True + + +class AttributeMetadata(BaseModel): + """Schema descriptor for a single attribute field in a universal compliance framework.""" + + key: str + label: Optional[str] = None + type: str = "str" # str, int, float, list_str, list_dict, bool + enum: Optional[list] = None + required: bool = False + enum_display: Optional[dict] = None # enum_value -> EnumValueDisplay dict + enum_order: Optional[list] = None # explicit ordering of enum values + chart_label: Optional[str] = None # axis label when used in charts + output_formats: OutputFormats = Field(default_factory=OutputFormats) + + +class SplitByConfig(BaseModel): + """Column-splitting configuration (e.g. CIS Level 1/Level 2).""" + + field: str + values: list + + +class ScoringConfig(BaseModel): + """Weighted scoring configuration (e.g. ThreatScore).""" + + risk_field: str + weight_field: str + + +class TableLabels(BaseModel): + """Custom pass/fail labels for console table rendering.""" + + pass_label: str = "PASS" + fail_label: str = "FAIL" + provider_header: str = "Provider" + group_header: Optional[str] = None + status_header: str = "Status" + title: Optional[str] = None + results_title: Optional[str] = None + footer_note: Optional[str] = None + + +class TableConfig(BaseModel): + """Declarative rendering instructions for the console compliance table.""" + + group_by: str + split_by: Optional[SplitByConfig] = None + scoring: Optional[ScoringConfig] = None + labels: Optional[TableLabels] = None + + +class EnumValueDisplay(BaseModel): + """Per-enum-value visual metadata for PDF rendering. + + Replaces hardcoded DIMENSION_MAPPING, TIPO_ICONS, nivel colors. + """ + + label: Optional[str] = None # "Trazabilidad" + abbreviation: Optional[str] = None # "T" + color: Optional[str] = None # "#4286F4" + icon: Optional[str] = None # emoji + + +class ChartConfig(BaseModel): + """Declarative chart description for PDF reports.""" + + id: str + type: str # vertical_bar | horizontal_bar | radar + group_by: str # attribute key to group by + title: Optional[str] = None + x_label: Optional[str] = None + y_label: Optional[str] = None + value_source: str = "compliance_percent" + color_mode: str = "by_value" # by_value | fixed | by_group + fixed_color: Optional[str] = None + + +class ScoringFormula(BaseModel): + """Weighted scoring formula (e.g. ThreatScore).""" + + risk_field: str # "LevelOfRisk" + weight_field: str # "Weight" + risk_boost_factor: float = 0.25 # rfac = 1 + factor * risk_level + + +class CriticalRequirementsFilter(BaseModel): + """Filter for critical requirements section in PDF reports.""" + + filter_field: str # "LevelOfRisk" + min_value: Optional[int] = None # 4 (int-based filter) + filter_value: Optional[str] = None # "alto" (string-based filter) + status_filter: str = "FAIL" + title: Optional[str] = None # "Critical Failed Requirements" + + +class ReportFilter(BaseModel): + """Default report filtering for PDF generation.""" + + only_failed: bool = True + include_manual: bool = False + + +class I18nLabels(BaseModel): + """Localized labels for PDF report rendering.""" + + report_title: Optional[str] = None + page_label: str = "Page" + powered_by: str = "Powered by Prowler" + framework_label: str = "Framework:" + version_label: str = "Version:" + provider_label: str = "Provider:" + description_label: str = "Description:" + compliance_score_label: str = "Compliance Score by Sections" + requirements_index_label: str = "Requirements Index" + detailed_findings_label: str = "Detailed Findings" + + +class PDFConfig(BaseModel): + """Declarative PDF report configuration. + + Drives the API report generator from JSON data instead of hardcoded + Python config. Colors are hex strings (e.g. '#336699'). + """ + + language: str = "en" + logo_filename: Optional[str] = None + primary_color: Optional[str] = None + secondary_color: Optional[str] = None + bg_color: Optional[str] = None + sections: Optional[list] = None + section_short_names: Optional[dict] = None + group_by_field: Optional[str] = None + sub_group_by_field: Optional[str] = None + section_titles: Optional[dict] = None + charts: Optional[list] = None + scoring: Optional[ScoringFormula] = None + critical_filter: Optional[CriticalRequirementsFilter] = None + filter: Optional[ReportFilter] = None + labels: Optional[I18nLabels] = None + + +class UniversalComplianceRequirement(BaseModel): + """Universal requirement with flat dict-based attributes.""" + + id: str + description: str + name: Optional[str] = None + attributes: dict = Field(default_factory=dict) + checks: dict[str, list[str]] = Field(default_factory=dict) + # Typed with the same constraint model as legacy so the operator/value + # validation also covers universal frameworks. evaluate_config_constraints + # accepts both dicts and model objects, so downstream consumers are unaffected. + config_requirements: Optional[list[Compliance_Requirement_ConfigConstraint]] = None + tactics: Optional[list] = None + sub_techniques: Optional[list] = None + platforms: Optional[list] = None + technique_url: Optional[str] = None + + +class OutputsConfig(BaseModel): + """Container for output-related configuration (table, PDF, etc.).""" + + table_config: Optional[TableConfig] = None + pdf_config: Optional[PDFConfig] = None + + +class ComplianceFramework(BaseModel): + """Universal top-level container for any compliance framework. + + Provider may be explicit (single-provider JSON) or derived from checks + keys across all requirements. + """ + + framework: str + name: str + provider: Optional[str] = None + version: Optional[str] = None + description: str + icon: Optional[str] = None + requirements: list[UniversalComplianceRequirement] + attributes_metadata: Optional[list[AttributeMetadata]] = None + outputs: Optional[OutputsConfig] = None + + @root_validator + # noqa: F841 - since vulture raises unused variable 'cls' + def validate_attributes_against_metadata(cls, values): # noqa: F841 + """Validate every Requirement's attributes dict against attributes_metadata. + + Checks: + - Required keys (required=True) must be present in each Requirement. + - Enum-constrained keys must have a value within the declared enum list. + - Basic type validation (int, float, bool) for non-None values. + """ + metadata = values.get("attributes_metadata") + requirements = values.get("requirements", []) + if not metadata: + return values + + required_keys = {m.key for m in metadata if m.required} + valid_keys = {m.key for m in metadata} + enum_map = {m.key: m.enum for m in metadata if m.enum} + type_map = {m.key: m.type for m in metadata} + + type_checks = { + "int": int, + "float": (int, float), + "bool": bool, + } + + errors = [] + for req in requirements: + attrs = req.attributes + + # Required keys + for key in required_keys: + if key not in attrs or attrs[key] is None: + errors.append( + f"Requirement '{req.id}': missing required attribute '{key}'" + ) + + # Unknown keys — anything outside the declared schema is a typo or drift + unknown_keys = set(attrs) - valid_keys + for key in sorted(unknown_keys): + errors.append( + f"Requirement '{req.id}': unknown attribute '{key}' " + f"(not declared in attributes_metadata)" + ) + + # Enum validation + for key, allowed in enum_map.items(): + if key in attrs and attrs[key] is not None: + if attrs[key] not in allowed: + errors.append( + f"Requirement '{req.id}': attribute '{key}' value " + f"'{attrs[key]}' not in {allowed}" + ) + + # Type validation for non-string types + for key in attrs: + if key not in valid_keys or attrs[key] is None: + continue + expected_type = type_map.get(key, "str") + py_type = type_checks.get(expected_type) + if py_type and not isinstance(attrs[key], py_type): + errors.append( + f"Requirement '{req.id}': attribute '{key}' expected " + f"type {expected_type}, got {type(attrs[key]).__name__}" + ) + + if errors: + detail = "\n ".join(errors) + raise ValueError(f"attributes_metadata validation failed:\n {detail}") + + return values + + def get_providers(self) -> list: + """Derive the set of providers this framework supports. + + Inspects checks keys across all requirements. Falls back to the + explicit provider field for single-provider frameworks with no + requirement-level checks. + """ + providers = set() + for req in self.requirements: + providers.update(k.lower() for k in req.checks.keys()) + if self.provider and not providers: + providers.add(self.provider.lower()) + return sorted(providers) + + def supports_provider(self, provider: str) -> bool: + """Return True if this framework has checks for the given provider.""" + provider_lower = provider.lower() + for req in self.requirements: + if any(k.lower() == provider_lower for k in req.checks.keys()): + return True + return self.provider is not None and self.provider.lower() == provider_lower + + +# ─── Legacy-to-Universal Adapter (Phase 2) ────────────────────────────────── + + +def _infer_attribute_metadata(legacy: Compliance) -> Optional[list[AttributeMetadata]]: + """Introspect the first requirement's attribute model to build attributes_metadata.""" + try: + if not legacy.Requirements: + return None + + first_req = legacy.Requirements[0] + + # MITRE requirements have Tactics at top level, not in Attributes + if isinstance(first_req, Mitre_Requirement): + return None + + if not first_req.Attributes: + return None + + sample_attr = first_req.Attributes[0] + metadata = [] + + for field_name, field_obj in sample_attr.__fields__.items(): + field_type = field_obj.outer_type_ + type_str = "str" + enum_values = None + + origin = getattr(field_type, "__origin__", None) + if field_type is int: + type_str = "int" + elif field_type is float: + type_str = "float" + elif field_type is bool: + type_str = "bool" + elif origin is list: + args = getattr(field_type, "__args__", ()) + if args and args[0] is dict: + type_str = "list_dict" + else: + type_str = "list_str" + elif isinstance(field_type, type) and issubclass(field_type, Enum): + type_str = "str" + enum_values = [e.value for e in field_type] + + metadata.append( + AttributeMetadata( + key=field_name, + type=type_str, + enum=enum_values, + required=field_obj.required, + ) + ) + + return metadata + except Exception: + return None + + +def adapt_legacy_to_universal(legacy: Compliance) -> ComplianceFramework: + """Convert a legacy Compliance object to a ComplianceFramework.""" + universal_requirements = [] + legacy_provider_key = legacy.Provider.lower() + + for req in legacy.Requirements: + req_checks = {legacy_provider_key: list(req.Checks)} if req.Checks else {} + if isinstance(req, Mitre_Requirement): + # For MITRE, promote special fields and store raw attributes + raw_attrs = [attr.dict() for attr in req.Attributes] + attrs = {"_raw_attributes": raw_attrs} + config_requirements = ( + [c.dict() for c in req.ConfigRequirements] + if getattr(req, "ConfigRequirements", None) + else None + ) + universal_requirements.append( + UniversalComplianceRequirement( + id=req.Id, + description=req.Description, + name=req.Name, + attributes=attrs, + checks=req_checks, + config_requirements=config_requirements, + tactics=req.Tactics, + sub_techniques=req.SubTechniques, + platforms=req.Platforms, + technique_url=req.TechniqueURL, + ) + ) + else: + # Standard requirement: flatten first attribute to dict + if req.Attributes: + attrs = req.Attributes[0].dict() + else: + attrs = {} + config_requirements = ( + [c.dict() for c in req.ConfigRequirements] + if getattr(req, "ConfigRequirements", None) + else None + ) + universal_requirements.append( + UniversalComplianceRequirement( + id=req.Id, + description=req.Description, + name=req.Name, + attributes=attrs, + checks=req_checks, + config_requirements=config_requirements, + ) + ) + + inferred_metadata = _infer_attribute_metadata(legacy) + + return ComplianceFramework( + framework=legacy.Framework, + name=legacy.Name, + provider=legacy.Provider, + version=legacy.Version, + description=legacy.Description, + requirements=universal_requirements, + attributes_metadata=inferred_metadata, + ) + + +def load_compliance_framework_universal(path: str) -> ComplianceFramework: + """Load a compliance JSON as a ComplianceFramework, handling both new and legacy formats.""" + try: + with open(path, "r") as f: + data = json.load(f) + + if "attributes_metadata" in data or "requirements" in data: + # New universal format — parse directly + return ComplianceFramework(**data) + else: + # Legacy format — parse as Compliance, then adapt + legacy = Compliance(**data) + return adapt_legacy_to_universal(legacy) + except Exception as e: + logger.error( + f"Failed to load universal compliance framework from {path}: " + f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}" + ) + return None + + +def _load_jsons_from_dir(dir_path: str, provider: str, bulk: dict) -> None: + """Scan *dir_path* for JSON files and add matching frameworks to *bulk*.""" + for filename in os.listdir(dir_path): + file_path = os.path.join(dir_path, filename) + if not ( + os.path.isfile(file_path) + and filename.endswith(".json") + and os.stat(file_path).st_size > 0 + ): + continue + framework_name = filename.split(".json")[0] + if framework_name in bulk: + continue + fw = load_compliance_framework_universal(file_path) + if fw is None: + continue + if fw.provider and fw.provider.lower() == provider.lower(): + bulk[framework_name] = fw + elif fw.supports_provider(provider): + bulk[framework_name] = fw + + +def get_bulk_compliance_frameworks_universal(provider: str) -> dict: + """Bulk load all compliance frameworks relevant to the given provider. + + Scans: + + 1. The **top-level** ``prowler/compliance/`` directory for multi-provider + JSONs (``Checks`` keyed by provider, no ``Provider`` field). + 2. Every **provider sub-directory** (``prowler/compliance/{p}/``) so that + single-provider JSONs are also picked up. + + A framework is included when its explicit ``Provider`` matches + (case-insensitive) **or** any requirement has dict-style ``Checks`` + with a key for *provider*. + """ + bulk = {} + try: + available_modules = list_compliance_modules() + + # Resolve the compliance root once (parent of provider sub-dirs). + compliance_root = None + seen_paths = set() + + for module in available_modules: + dir_path = f"{module.module_finder.path}/{module.name.split('.')[-1]}" + if not os.path.isdir(dir_path) or dir_path in seen_paths: + continue + seen_paths.add(dir_path) + + # Remember the root the first time we see a valid sub-dir. + if compliance_root is None: + compliance_root = module.module_finder.path + + _load_jsons_from_dir(dir_path, provider, bulk) + + # Also scan top-level compliance/ for provider-agnostic JSONs. + if compliance_root and os.path.isdir(compliance_root): + _load_jsons_from_dir(compliance_root, provider, bulk) + + # External multi-provider frameworks via the dedicated universal entry + # point group, kept separate from the per-provider `prowler.compliance` + # group so the legacy loader never parses a universal JSON. Built-ins + # (already in bulk) win on a name collision. + for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + try: + module = ep.load() + ep_dir = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + if os.path.isdir(ep_dir): + _load_jsons_from_dir(ep_dir, provider, bulk) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + except Exception as e: + logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}") + return bulk 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/models.py b/prowler/lib/check/models.py index 43d6018cbf..aa16d7969b 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set from pydantic.v1 import BaseModel, Field, ValidationError, validator from pydantic.v1.error_wrappers import ErrorWrapper -from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger +from prowler.providers.common.provider import Provider as ProviderABC # Valid ResourceGroup values as defined in the RFC VALID_RESOURCE_GROUPS = frozenset( @@ -62,6 +62,9 @@ VALID_CATEGORIES = frozenset( "e5", "privilege-escalation", "ec2-imdsv1", + "vercel-hobby-plan", + "vercel-pro-plan", + "vercel-enterprise-plan", } ) @@ -244,18 +247,19 @@ class CheckMetadata(BaseModel): # store the compliance later if supplied Compliance: Optional[list[Any]] = Field(default_factory=list) + # TODO: Remove noqa and fix cls vulture errors @validator("Categories", each_item=True, pre=True, always=True) - def valid_category(cls, value, values): + def valid_category(cls, value, values): # noqa: F841 if not isinstance(value, str): raise ValueError("Categories must be a list of strings") value_lower = value.lower() if not re.match("^[a-z0-9-]+$", value_lower): raise ValueError( - f"Invalid category: {value}. Categories can only contain lowercase letters, numbers and hyphen '-'" + f"Invalid category: {value}. Categories can only contain lowercase letters, numbers, and hyphen '-'" ) if ( value_lower not in VALID_CATEGORIES - and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS + and not ProviderABC.is_tool_wrapper_provider(values.get("Provider")) ): raise ValueError( f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}." @@ -279,12 +283,14 @@ class CheckMetadata(BaseModel): return resource_type @validator("ServiceName", pre=True, always=True) - def validate_service_name(cls, service_name, values): + def validate_service_name(cls, service_name, values): # noqa: F841 if not service_name: raise ValueError("ServiceName must be a non-empty string") check_id = values.get("CheckID") - if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if check_id and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): service_from_check_id = check_id.split("_")[0] if service_name != service_from_check_id: raise ValueError( @@ -296,11 +302,13 @@ class CheckMetadata(BaseModel): return service_name @validator("CheckID", pre=True, always=True) - def valid_check_id(cls, check_id, values): + def valid_check_id(cls, check_id, values): # noqa: F841 if not check_id: raise ValueError("CheckID must be a non-empty string") - if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if check_id and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): if "-" in check_id: raise ValueError( f"CheckID {check_id} contains a hyphen, which is not allowed" @@ -309,8 +317,9 @@ class CheckMetadata(BaseModel): return check_id @validator("CheckTitle", pre=True, always=True) - def validate_check_title(cls, check_title, values): - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + @classmethod + def validate_check_title(cls, check_title, values): # noqa: F841 + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(check_title) > 150: raise ValueError( f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters" @@ -322,14 +331,18 @@ class CheckMetadata(BaseModel): return check_title @validator("RelatedUrl", pre=True, always=True) - def validate_related_url(cls, related_url, values): - if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + @classmethod + def validate_related_url(cls, related_url, values): # noqa: F841 + if related_url and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): raise ValueError("RelatedUrl must be empty. This field is deprecated.") return related_url @validator("Remediation") - def validate_recommendation_url(cls, remediation, values): - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + @classmethod + def validate_recommendation_url(cls, remediation, values): # noqa: F841 + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): url = remediation.Recommendation.Url if url and not url.startswith("https://hub.prowler.com/"): raise ValueError( @@ -338,11 +351,11 @@ class CheckMetadata(BaseModel): return remediation @validator("CheckType", pre=True, always=True) - def validate_check_type(cls, check_type, values): + def validate_check_type(cls, check_type, values): # noqa: F841 provider = values.get("Provider", "").lower() # Non-AWS providers must have an empty CheckType list - if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS: + if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider): if check_type: raise ValueError( f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'." @@ -367,8 +380,9 @@ class CheckMetadata(BaseModel): return check_type @validator("Description", pre=True, always=True) - def validate_description(cls, description, values): - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + @classmethod + def validate_description(cls, description, values): # noqa: F841 + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(description) > 400: raise ValueError( f"Description must not exceed 400 characters, got {len(description)} characters" @@ -376,8 +390,9 @@ class CheckMetadata(BaseModel): return description @validator("Risk", pre=True, always=True) - def validate_risk(cls, risk, values): - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + @classmethod + def validate_risk(cls, risk, values): # noqa: F841 + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(risk) > 400: raise ValueError( f"Risk must not exceed 400 characters, got {len(risk)} characters" @@ -385,7 +400,7 @@ class CheckMetadata(BaseModel): return risk @validator("ResourceGroup", pre=True, always=True) - def validate_resource_group(cls, resource_group): + def validate_resource_group(cls, resource_group): # noqa: F841 if resource_group and resource_group not in VALID_RESOURCE_GROUPS: raise ValueError( f"Invalid ResourceGroup: '{resource_group}'. Must be one of: {', '.join(sorted(VALID_RESOURCE_GROUPS))} or empty string." @@ -393,7 +408,7 @@ class CheckMetadata(BaseModel): return resource_group @validator("AdditionalURLs", pre=True, always=True) - def validate_additional_urls(cls, additional_urls): + def validate_additional_urls(cls, additional_urls): # noqa: F841 if not isinstance(additional_urls, list): raise ValueError("AdditionalURLs must be a list") @@ -429,6 +444,20 @@ class CheckMetadata(BaseModel): metadata_file = f"{check_path}/{check_name}.metadata.json" # Load metadata check_metadata = load_check_metadata(metadata_file) + # Built-in wins on CheckID collision. Plug-in entry points are + # appended after built-ins by `recover_checks_from_provider`, so + # a duplicate CheckID here means an entry-point check is trying + # to override a built-in. Ignore the override (the built-in + # metadata stays) and surface it via a warning — matching the + # precedence enforced by `_resolve_check_module`. + if check_metadata.CheckID in bulk_check_metadata: + logger.warning( + f"Plug-in check metadata '{check_metadata.CheckID}' " + f"(loaded from '{metadata_file}') is being IGNORED — " + f"a built-in with the same CheckID exists. To use your " + f"plug-in, register it under a different CheckID." + ) + continue bulk_check_metadata[check_metadata.CheckID] = check_metadata return bulk_check_metadata @@ -466,7 +495,7 @@ class CheckMetadata(BaseModel): # If the bulk checks metadata is not provided, get it if not bulk_checks_metadata: bulk_checks_metadata = {} - available_providers = [p.value for p in Provider] + available_providers = ProviderABC.get_available_providers() for provider_name in available_providers: bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name)) if provider: @@ -491,7 +520,7 @@ class CheckMetadata(BaseModel): # Loaded here, as it is not always needed if not bulk_compliance_frameworks: bulk_compliance_frameworks = {} - available_providers = [p.value for p in Provider] + available_providers = ProviderABC.get_available_providers() for provider in available_providers: bulk_compliance_frameworks = Compliance.get_bulk(provider=provider) checks_from_compliance_framework = ( @@ -929,6 +958,41 @@ class CheckReportGithub(Check_Report): ) +@dataclass +class CheckReportOkta(Check_Report): + """Contains the Okta Check's finding information.""" + + resource_name: str + resource_id: str + org_domain: str + region: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str = None, + resource_id: str = None, + org_domain: str = None, + region: str = "global", + ) -> None: + """Initialize the Okta Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. + resource_name: The name of the resource related with the finding. + resource_id: The id of the resource related with the finding. + org_domain: The Okta organization domain related with the finding. + region: Always "global" — Okta has no regional concept. + """ + super().__init__(metadata, resource) + self.resource_name = resource_name or getattr(resource, "name", "") + self.resource_id = resource_id or getattr(resource, "id", "") + self.org_domain = org_domain or getattr(resource, "org_domain", "") + self.region = region + + @dataclass class CheckReportGoogleWorkspace(Check_Report): """Contains the Google Workspace Check's finding information.""" @@ -1042,6 +1106,37 @@ class CheckReportCloudflare(Check_Report): return "global" +@dataclass +class CheckReportLinode(Check_Report): + """Contains the Linode Check's finding information.""" + + resource_name: str + resource_id: str + region: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str, + resource_id: str, + region: str = "global", + ) -> None: + """Initialize the Linode Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. + resource_name: The name of the resource related with the finding. + resource_id: The id of the resource related with the finding. + region: The region of the resource related with the finding. + """ + super().__init__(metadata, resource) + self.resource_name = resource_name + self.resource_id = resource_id + self.region = region + + @dataclass class CheckReportM365(Check_Report): """Contains the M365 Check's finding information.""" @@ -1094,15 +1189,10 @@ class CheckReportIAC(Check_Report): self.resource = finding self.resource_name = file_path - self.resource_line_range = ( - ( - str(finding.get("CauseMetadata", {}).get("StartLine", "")) - + ":" - + str(finding.get("CauseMetadata", {}).get("EndLine", "")) - ) - if finding.get("CauseMetadata", {}).get("StartLine", "") - else "" - ) + cause = finding.get("CauseMetadata", {}) + start = cause.get("StartLine") or finding.get("StartLine") + end = cause.get("EndLine") or finding.get("EndLine") + self.resource_line_range = f"{start}:{end}" if start else "" @dataclass @@ -1196,6 +1286,31 @@ class CheckReportNHN(Check_Report): self.location = getattr(resource, "location", "kr1") +@dataclass +class CheckReportStackIT(Check_Report): + """Contains the StackIT Check's finding information.""" + + resource_name: str + resource_id: str + project_id: str + location: str + + def __init__(self, metadata: Dict, resource: Any) -> None: + """Initialize the StackIT Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. Defaults to None. + """ + super().__init__(metadata, resource) + self.resource_name = getattr( + resource, "name", getattr(resource, "resource_name", "") + ) + self.resource_id = getattr(resource, "id", getattr(resource, "resource_id", "")) + self.project_id = getattr(resource, "project_id", "") + self.location = getattr(resource, "region", getattr(resource, "location", "")) + + @dataclass class CheckReportOpenStack(Check_Report): """Contains the OpenStack Check's finding information.""" @@ -1284,6 +1399,54 @@ class CheckReportVercel(Check_Report): return "global" +@dataclass +class CheckReportScaleway(Check_Report): + """Contains the Scaleway Check's finding information. + + Scaleway scans run at the organization level. Most IAM/account-level + resources are global; regional resources expose a ``region`` attribute + on the underlying object, which we surface as the report ``region``. + """ + + resource_name: str + resource_id: str + organization_id: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str = None, + resource_id: str = None, + organization_id: str = None, + ) -> None: + """Initialize the Scaleway Check's finding information. + + Args: + metadata: Check metadata dictionary. + resource: The Scaleway resource being checked. + resource_name: Override for resource name. + resource_id: Override for resource ID. + organization_id: Override for the organization ID. + """ + super().__init__(metadata, resource) + self.resource_name = resource_name or getattr( + resource, "name", getattr(resource, "resource_name", "") + ) + self.resource_id = resource_id or getattr( + resource, "id", getattr(resource, "resource_id", "") + ) + self.organization_id = organization_id or getattr( + resource, "organization_id", "" + ) + self._region = getattr(resource, "region", None) or "global" + + @property + def region(self) -> str: + """Scaleway regional resources expose their own region; IAM is global.""" + return self._region + + # Testing Pending def load_check_metadata(metadata_file: str) -> CheckMetadata: """ diff --git a/prowler/lib/check/tool_wrapper.py b/prowler/lib/check/tool_wrapper.py new file mode 100644 index 0000000000..a1d606594e --- /dev/null +++ b/prowler/lib/check/tool_wrapper.py @@ -0,0 +1,62 @@ +"""Standalone helper for tool-wrapper provider detection. + +A provider is a "tool wrapper" if it delegates scanning to an external tool +(Trivy, promptfoo, etc.) instead of running checks/services through the +standard Prowler engine. This module is the single source of truth for that +classification across the codebase. + +Kept as a leaf module with no Prowler imports beyond the leaf +`external_tool_providers` so it can be referenced from `prowler.lib.check.*` +and `prowler.providers.common.provider` without forming an import cycle. +""" + +import importlib.metadata + +from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS +from prowler.providers.common.builtin import is_builtin_provider + +# Module-level cache for entry-point classes consulted by this helper. +# Independent of `Provider._ep_providers` to keep this module leaf — the cost +# of a duplicate cache entry is negligible (one class object per external +# provider, loaded lazily on first lookup). +_ep_class_cache: dict = {} + + +def _load_ep_class(provider: str): + """Return the entry-point provider class for `provider`, or None. + + Caches the result in `_ep_class_cache`. Errors during entry-point loading + are swallowed (returning None) so a broken plug-in never crashes the + is-tool-wrapper check; it just falls through to "not a tool wrapper". + """ + if provider in _ep_class_cache: + return _ep_class_cache[provider] + for ep in importlib.metadata.entry_points(group="prowler.providers"): + if ep.name == provider: + try: + cls = ep.load() + except Exception: + cls = None + _ep_class_cache[provider] = cls + return cls + _ep_class_cache[provider] = None + return None + + +def is_tool_wrapper_provider(provider: str) -> bool: + """Return True if the provider delegates scanning to an external tool. + + Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for + iac/llm/image) with the `is_external_tool_provider` class attribute of + external plug-ins registered via entry points. This is the single source + of truth consulted by `__main__`, the `CheckMetadata` validators, the + check-loading utilities, and the checks loader. + """ + if provider in EXTERNAL_TOOL_PROVIDERS: + return True + # Built-in wins: short-circuit before ep.load() so a same-name plug-in + # cannot flip a built-in onto the tool-wrapper path or run its code. + if is_builtin_provider(provider): + return False + cls = _load_ep_class(provider) + return bool(cls and getattr(cls, "is_external_tool_provider", False)) diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py index 45f16cde32..9c9a9c0523 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -1,8 +1,43 @@ import importlib +import importlib.metadata +import importlib.util +import os import sys from pkgutil import walk_packages +from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider from prowler.lib.logger import logger +from prowler.providers.common.builtin import is_builtin_provider + + +def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]: + """Discover external checks registered via entry points for a provider. + + External plugins follow the same layout as built-ins: + `{plugin_root}.services.{service}.{check}.{check}` + + When `service` is provided, only entry points whose dotted path contains + `.services.{service}.` are included — mirroring how built-in discovery + filters by the `prowler.providers.{provider}.services.{service}` package. + + Uses find_spec to locate the check module without importing it, + avoiding service client initialization at discovery time. + """ + checks = [] + for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"): + try: + if service and f".services.{service}." not in ep.value: + continue + + spec = importlib.util.find_spec(ep.value) + if spec and spec.origin: + check_path = os.path.dirname(spec.origin) + checks.append((ep.name, check_path)) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return checks def recover_checks_from_provider( @@ -14,29 +49,55 @@ def recover_checks_from_provider( Returns a list of tuples with the following format (check_name, check_path) """ try: - # Bypass check loading for IAC, LLM, and Image providers since they use external tools directly - if provider in ("iac", "llm", "image"): + # Bypass check loading for tool-wrapper providers — they delegate + # scanning to an external tool and have no checks to recover. + # Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS + # frozenset (built-ins) with the per-provider `is_external_tool_provider` + # class attribute (so external plug-ins opt in via the contract). + if is_tool_wrapper_provider(provider): return [] checks = [] - modules = list_modules(provider, service) - for module_name in modules: - # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}" - check_module_name = module_name.name - # We need to exclude common shared libraries in services - if ( - check_module_name.count(".") == 6 - and ".lib." not in check_module_name - and (not check_module_name.endswith("_fixer") or include_fixers) - ): - check_path = module_name.module_finder.path - # Check name is the last part of the check_module_name - check_name = check_module_name.split(".")[-1] - check_info = (check_name, check_path) - checks.append(check_info) - except ModuleNotFoundError: - logger.critical(f"Service {service} was not found for the {provider} provider.") - sys.exit(1) + # Built-in checks from prowler.providers.{provider}.services. Gate + # the built-in branch on `is_builtin_provider(provider)` — calling + # `find_spec` directly on `prowler.providers.{provider}.services` + # would propagate `ModuleNotFoundError` when the parent package + # `prowler.providers.{provider}` does not exist (i.e. the provider + # is external), instead of returning None. The leaf helper + # encapsulates the safe lookup, so we only run the built-in + # discovery when the provider actually ships with the SDK; for + # external providers we go straight to entry points. + if is_builtin_provider(provider): + modules = list_modules(provider, service) + for module_name in modules: + # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}" + check_module_name = module_name.name + # We need to exclude common shared libraries in services + if ( + check_module_name.count(".") == 6 + and ".lib." not in check_module_name + and (not check_module_name.endswith("_fixer") or include_fixers) + ): + check_path = module_name.module_finder.path + check_name = check_module_name.split(".")[-1] + check_info = (check_name, check_path) + checks.append(check_info) + + # External checks registered via entry points — always consulted, with + # optional service filter. Previously gated by `if not service:`, which + # prevented external providers from being usable with --service. + checks.extend(_recover_ep_checks(provider, service)) + + # A service was requested but nothing matched in either built-ins or + # entry points — surface this as a clear error instead of silently + # returning an empty list. + if service and not checks: + logger.critical( + f"Service '{service}' was not found for the '{provider}' provider " + f"(neither as a built-in nor via external entry points)." + ) + sys.exit(1) + except Exception as e: logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}") sys.exit(1) @@ -63,8 +124,9 @@ def recover_checks_from_service(service_list: list, provider: str) -> set: Returns a set of checks from the given services """ try: - # Bypass check loading for IAC provider since it uses Trivy directly - if provider == "iac": + # Bypass check loading for tool-wrapper providers — symmetric with + # `recover_checks_from_provider` above, using the same source of truth. + if is_tool_wrapper_provider(provider): return set() checks = set() diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 47275b12cf..67a7ea2824 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -12,27 +12,72 @@ from prowler.config.config import ( default_output_directory, ) from prowler.lib.check.models import Severity +from prowler.lib.cli.redact import warn_sensitive_argument_values from prowler.lib.outputs.common import Status from prowler.providers.common.arguments import ( + PROVIDER_ALIASES, + enforce_invoked_provider_loaded, init_providers_parser, validate_asff_usage, validate_provider_arguments, + validate_sarif_usage, ) - -SENSITIVE_ARGUMENTS = frozenset({"--shodan"}) +from prowler.providers.common.provider import Provider class ProwlerArgumentParser: # Set the default parser def __init__(self): + # Discover any providers not in the hardcoded list below + # TODO - First step to support current providers and the new external provider implementation + known_providers = { + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "vercel", + "okta", + "scaleway", + "stackit", + "linode", + } + all_providers = set(Provider.get_available_providers()) + new_providers = sorted(all_providers - known_providers) + + # Build extra strings for dynamically discovered providers + extra_providers_csv = "" + extra_providers_text = "" + if new_providers: + providers_help = Provider.get_providers_help_text() + extra_providers_csv = "," + ",".join(new_providers) + extra_lines = [] + for name in new_providers: + help_text = providers_help.get(name, "") + if help_text: + extra_lines.append(f" {name:<20}{help_text}") + if extra_lines: + extra_providers_text = "\n" + "\n".join(extra_lines) + # CLI Arguments self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ...", - epilog=""" + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,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} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -40,22 +85,27 @@ Available Cloud Providers: m365 Microsoft 365 Provider github GitHub Provider googleworkspace Google Workspace Provider + okta Okta Provider cloudflare Cloudflare Provider oraclecloud Oracle Cloud Infrastructure Provider openstack OpenStack Provider + stackit StackIT 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) + mongodbatlas MongoDB Atlas Provider + scaleway Scaleway Provider vercel Vercel Provider + linode Linode Provider{extra_providers_text} + Available components: dashboard Local dashboard To see the different available options on a specific component, run: - prowler {provider|dashboard} -h|--help + prowler {{provider|dashboard}} -h|--help Detailed documentation at https://docs.prowler.com """, @@ -114,17 +164,23 @@ Detailed documentation at https://docs.prowler.com and (sys.argv[1] not in ("-v", "--version")) ): # Since the provider is always the second argument, we are checking if - # a flag, starting by "-", is supplied - if "-" in sys.argv[1]: + # a flag is supplied. Use startswith("-") instead of "in" to avoid + # matching external provider names that contain hyphens + # (e.g. "local-acme-snowflake"). + if sys.argv[1].startswith("-"): sys.argv = self.__set_default_provider__(sys.argv) - # Provider aliases mapping - # Microsoft 365 - elif sys.argv[1] == "microsoft365": - sys.argv[1] = "m365" - # Oracle Cloud Infrastructure - elif sys.argv[1] == "oci": - sys.argv[1] = "oraclecloud" + # Provider aliases mapping (single source: arguments.PROVIDER_ALIASES) + elif sys.argv[1] in PROVIDER_ALIASES: + sys.argv[1] = PROVIDER_ALIASES[sys.argv[1]] + + # Selective fail-loud here (post argv-normalisation, pre parse_args) + # so the invoked-provider check stays correct under parse(args=...). + enforce_invoked_provider_loaded(self) + + # Warn about sensitive flags passed with explicit values + # Snapshot argv before parse_args() which may exit on errors + warn_sensitive_argument_values(list(sys.argv[1:])) # Parse arguments args = self.parser.parse_args() @@ -150,6 +206,12 @@ Detailed documentation at https://docs.prowler.com if not asff_is_valid: self.parser.error(asff_error) + sarif_is_valid, sarif_error = validate_sarif_usage( + args.provider, getattr(args, "output_formats", None) + ) + if not sarif_is_valid: + self.parser.error(sarif_error) + return args def __set_default_provider__(self, args: list) -> list: @@ -411,6 +473,18 @@ Detailed documentation at https://docs.prowler.com default=default_fixer_config_file_path, help="Set configuration fixer file path", ) + config_parser.add_argument( + "--scan-secrets-validate", + action="store_true", + default=False, + help=( + "Validate secrets discovered by the secrets checks by checking " + "whether they are live against the provider APIs. WARNING: this " + "makes outbound network calls using the discovered secret itself; " + "the credential is exercised against the provider and the call " + "appears in the audited account's logs. Disabled by default." + ), + ) def __init_custom_checks_metadata_parser__(self): # CustomChecksMetadata @@ -434,7 +508,7 @@ Detailed documentation at https://docs.prowler.com nargs="?", default=None, metavar="SHODAN_API_KEY", - help="Check if any public IPs in your Cloud environments are exposed in Shodan.", + help="Check if any public IPs in your Cloud environments are exposed in Shodan. We recommend to use the SHODAN_API_KEY environment variable to provide the API key.", ) third_party_subparser.add_argument( "--slack", diff --git a/prowler/lib/cli/redact.py b/prowler/lib/cli/redact.py index 2dfd9e2bfa..3984139bae 100644 --- a/prowler/lib/cli/redact.py +++ b/prowler/lib/cli/redact.py @@ -1,6 +1,9 @@ from functools import lru_cache from importlib import import_module +from colorama import Fore, Style + +from prowler.lib.cli.sensitive import SENSITIVE_ARGUMENTS as COMMON_SENSITIVE_ARGUMENTS from prowler.lib.logger import logger from prowler.providers.common.provider import Provider, providers_path @@ -13,11 +16,7 @@ def get_sensitive_arguments() -> frozenset: sensitive: set[str] = set() # Common parser sensitive arguments (e.g., --shodan) - try: - parser_module = import_module("prowler.lib.cli.parser") - sensitive.update(getattr(parser_module, "SENSITIVE_ARGUMENTS", frozenset())) - except Exception as error: - logger.debug(f"Could not load SENSITIVE_ARGUMENTS from parser: {error}") + sensitive.update(COMMON_SENSITIVE_ARGUMENTS) # Provider-specific sensitive arguments for provider in Provider.get_available_providers(): @@ -66,3 +65,49 @@ def redact_argv(argv: list[str]) -> str: result.append(arg) return " ".join(result) + + +def warn_sensitive_argument_values(argv: list[str]) -> None: + """Log a warning for each sensitive CLI flag that was passed with an explicit value. + + Scans the raw argv list (not parsed args) to detect when users pass + secret values directly on the command line instead of using environment + variables. Handles both ``--flag value`` and ``--flag=value`` syntax. + + Args: + argv: The argument list to check (typically ``sys.argv[1:]``). + """ + sensitive = get_sensitive_arguments() + if not sensitive: + return + + use_color = "--no-color" not in argv + flags_with_values: list[str] = [] + + for i, arg in enumerate(argv): + # --flag=value syntax + if "=" in arg: + flag = arg.split("=", 1)[0] + if flag in sensitive: + flags_with_values.append(flag) + continue + + # --flag value syntax + if arg in sensitive: + if i + 1 < len(argv) and not argv[i + 1].startswith("-"): + flags_with_values.append(arg) + + for flag in flags_with_values: + if use_color: + logger.warning( + f"{Fore.YELLOW}{Style.BRIGHT}WARNING:{Style.RESET_ALL}{Fore.YELLOW} " + f"Passing a value directly to {flag} is not recommended. " + f"Use the corresponding environment variable instead to avoid " + f"exposing secrets in process listings and shell history.{Style.RESET_ALL}" + ) + else: + logger.warning( + f"Passing a value directly to {flag} is not recommended. " + f"Use the corresponding environment variable instead to avoid " + f"exposing secrets in process listings and shell history." + ) diff --git a/prowler/lib/cli/sensitive.py b/prowler/lib/cli/sensitive.py new file mode 100644 index 0000000000..4f5ad004d7 --- /dev/null +++ b/prowler/lib/cli/sensitive.py @@ -0,0 +1,8 @@ +"""Common parser sensitive arguments. + +This module is kept dependency-free (no prowler-internal imports) so that +``prowler.lib.cli.redact`` and any provider argument module can import it +without circular-import risk. +""" + +SENSITIVE_ARGUMENTS = frozenset({"--shodan"}) diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/__init__.py b/prowler/lib/outputs/compliance/asd_essential_eight/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py new file mode 100644 index 0000000000..074e684f33 --- /dev/null +++ b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py @@ -0,0 +1,114 @@ +from colorama import Fore, Style +from tabulate import tabulate + +from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) + + +def get_asd_essential_eight_table( + findings: list, + bulk_checks_metadata: dict, + compliance_framework: str, + output_filename: str, + output_directory: str, + compliance_overview: bool, +): + sections = {} + asd_essential_eight_compliance_table = { + "Provider": [], + "Section": [], + "Status": [], + "Muted": [], + } + pass_count = set() + fail_count = set() + muted_count = set() + section_seen = {} + provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} + for index, finding in enumerate(findings): + check = bulk_checks_metadata[finding.check_metadata.CheckID] + check_compliances = check.Compliance + for compliance in check_compliances: + if compliance.Framework == "ASD-Essential-Eight": + provider = compliance.Provider + for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) + for attribute in requirement.Attributes: + section = attribute.Section + if section not in sections: + sections[section] = { + "FAIL": 0, + "PASS": 0, + "Muted": 0, + } + section_seen[section] = {} + + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, sections[section], section_seen[section] + ) + + sections = dict(sorted(sections.items())) + for section in sections: + asd_essential_eight_compliance_table["Provider"].append(provider) + asd_essential_eight_compliance_table["Section"].append(section) + if sections[section]["FAIL"] > 0: + asd_essential_eight_compliance_table["Status"].append( + f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}" + ) + elif sections[section]["PASS"] > 0: + asd_essential_eight_compliance_table["Status"].append( + f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}" + ) + else: + asd_essential_eight_compliance_table["Status"].append("-") + asd_essential_eight_compliance_table["Muted"].append( + f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}" + ) + if len(fail_count) + len(pass_count) + len(muted_count) > 1: + print( + f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:" + ) + total_findings_count = len(fail_count) + len(pass_count) + len(muted_count) + overview_table = [ + [ + f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}", + f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}", + f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}", + ] + ] + print(tabulate(overview_table, tablefmt="rounded_grid")) + if not compliance_overview: + print( + f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:" + ) + print( + tabulate( + asd_essential_eight_compliance_table, + headers="keys", + tablefmt="rounded_grid", + ) + ) + print( + f"{Style.BRIGHT}* Only sections containing results appear.{Style.RESET_ALL}" + ) + print(f"\nDetailed results of {compliance_framework.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n" + ) diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws.py b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws.py new file mode 100644 index 0000000000..4bdf9cd851 --- /dev/null +++ b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws.py @@ -0,0 +1,124 @@ +from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.asd_essential_eight.models import ( + ASDEssentialEightAWSModel, +) +from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput +from prowler.lib.outputs.finding import Finding + + +class ASDEssentialEightAWS(ComplianceOutput): + """ + This class represents the AWS ASD Essential Eight compliance output. + + Attributes: + - _data (list): A list to store transformed data from findings. + - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. + + Methods: + - transform: Transforms findings into AWS Essential Eight compliance format. + """ + + def transform( + self, + findings: list[Finding], + compliance: Compliance, + compliance_name: str, + ) -> None: + """ + Transforms a list of findings into AWS Essential Eight compliance format. + + Parameters: + - findings (list): A list of findings. + - compliance (Compliance): A compliance model. + - compliance_name (str): The name of the compliance model. + + Returns: + - None + """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + + for finding in findings: + for requirement in compliance.Requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) + for attribute in requirement.Attributes: + compliance_row = ASDEssentialEightAWSModel( + Provider=finding.provider, + Description=compliance.Description, + AccountId=finding.account_uid, + Region=finding.region, + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_MaturityLevel=attribute.MaturityLevel, + Requirements_Attributes_AssessmentStatus=attribute.AssessmentStatus, + Requirements_Attributes_CloudApplicability=attribute.CloudApplicability, + Requirements_Attributes_MitigatedThreats=", ".join( + attribute.MitigatedThreats + ), + Requirements_Attributes_Description=attribute.Description, + Requirements_Attributes_RationaleStatement=attribute.RationaleStatement, + Requirements_Attributes_ImpactStatement=attribute.ImpactStatement, + Requirements_Attributes_RemediationProcedure=attribute.RemediationProcedure, + Requirements_Attributes_AuditProcedure=attribute.AuditProcedure, + Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, + Requirements_Attributes_References=attribute.References, + Status=row_status, + StatusExtended=row_status_extended, + ResourceId=finding.resource_uid, + ResourceName=finding.resource_name, + CheckId=finding.check_id, + Muted=finding.muted, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) + # Add manual requirements to the compliance output + for requirement in compliance.Requirements: + if not requirement.Checks: + for attribute in requirement.Attributes: + compliance_row = ASDEssentialEightAWSModel( + Provider=compliance.Provider.lower(), + Description=compliance.Description, + AccountId="", + Region="", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_MaturityLevel=attribute.MaturityLevel, + Requirements_Attributes_AssessmentStatus=attribute.AssessmentStatus, + Requirements_Attributes_CloudApplicability=attribute.CloudApplicability, + Requirements_Attributes_MitigatedThreats=", ".join( + attribute.MitigatedThreats + ), + Requirements_Attributes_Description=attribute.Description, + Requirements_Attributes_RationaleStatement=attribute.RationaleStatement, + Requirements_Attributes_ImpactStatement=attribute.ImpactStatement, + Requirements_Attributes_RemediationProcedure=attribute.RemediationProcedure, + Requirements_Attributes_AuditProcedure=attribute.AuditProcedure, + Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, + Requirements_Attributes_References=attribute.References, + Status="MANUAL", + StatusExtended="Manual check", + ResourceId="manual_check", + ResourceName="Manual check", + CheckId="manual", + Muted=False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/models.py b/prowler/lib/outputs/compliance/asd_essential_eight/models.py new file mode 100644 index 0000000000..f0760832e1 --- /dev/null +++ b/prowler/lib/outputs/compliance/asd_essential_eight/models.py @@ -0,0 +1,35 @@ +from pydantic.v1 import BaseModel + + +class ASDEssentialEightAWSModel(BaseModel): + """ + ASDEssentialEightAWSModel generates a finding's output in AWS ASD Essential Eight Compliance format. + """ + + Provider: str + Description: str + AccountId: str + Region: str + AssessmentDate: str + Requirements_Id: str + Requirements_Description: str + Requirements_Attributes_Section: str + Requirements_Attributes_MaturityLevel: str + Requirements_Attributes_AssessmentStatus: str + Requirements_Attributes_CloudApplicability: str + Requirements_Attributes_MitigatedThreats: str + Requirements_Attributes_Description: str + Requirements_Attributes_RationaleStatement: str + Requirements_Attributes_ImpactStatement: str + Requirements_Attributes_RemediationProcedure: str + Requirements_Attributes_AuditProcedure: str + Requirements_Attributes_AdditionalInformation: str + Requirements_Attributes_References: str + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool + Framework: str + Name: str diff --git a/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py b/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py index 38352484c2..69a37949f8 100644 --- a/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py +++ b/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.aws_well_architected.models import ( AWSWellArchitectedModel, @@ -36,11 +40,18 @@ class AWSWellArchitected(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSWellArchitectedModel( Provider=finding.provider, @@ -59,8 +70,8 @@ class AWSWellArchitected(ComplianceOutput): Requirements_Attributes_AssessmentMethod=attribute.AssessmentMethod, Requirements_Attributes_Description=attribute.Description, Requirements_Attributes_ImplementationGuidanceUrl=attribute.ImplementationGuidanceUrl, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/c5/c5.py b/prowler/lib/outputs/compliance/c5/c5.py index 32eb7e0f5a..003b2664cf 100644 --- a/prowler/lib/outputs/compliance/c5/c5.py +++ b/prowler/lib/outputs/compliance/c5/c5.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_c5_table( @@ -18,37 +25,45 @@ def get_c5_table( "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() sections = {} + section_seen = {} + provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "C5": + provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = {} - if finding.muted: - if index not in muted_count: - muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: - fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) - sections[section]["PASS"] += 1 + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, sections[section], section_seen[section] + ) sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/c5/c5_aws.py b/prowler/lib/outputs/compliance/c5/c5_aws.py index 24d5239602..b8a4115482 100644 --- a/prowler/lib/outputs/compliance/c5/c5_aws.py +++ b/prowler/lib/outputs/compliance/c5/c5_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.c5.models import AWSC5Model from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,19 @@ class AWSC5(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSC5Model( Provider=finding.provider, @@ -53,8 +65,8 @@ class AWSC5(ComplianceOutput): Requirements_Attributes_Type=attribute.Type, Requirements_Attributes_AboutCriteria=attribute.AboutCriteria, Requirements_Attributes_ComplementaryCriteria=attribute.ComplementaryCriteria, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/c5/c5_azure.py b/prowler/lib/outputs/compliance/c5/c5_azure.py index 3c050a6581..0899ff6b15 100644 --- a/prowler/lib/outputs/compliance/c5/c5_azure.py +++ b/prowler/lib/outputs/compliance/c5/c5_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.c5.models import AzureC5Model from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,19 @@ class AzureC5(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureC5Model( Provider=finding.provider, @@ -53,8 +65,8 @@ class AzureC5(ComplianceOutput): Requirements_Attributes_Type=attribute.Type, Requirements_Attributes_AboutCriteria=attribute.AboutCriteria, Requirements_Attributes_ComplementaryCriteria=attribute.ComplementaryCriteria, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/c5/c5_gcp.py b/prowler/lib/outputs/compliance/c5/c5_gcp.py index 85ee9c25e9..c8b874f622 100644 --- a/prowler/lib/outputs/compliance/c5/c5_gcp.py +++ b/prowler/lib/outputs/compliance/c5/c5_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.c5.models import GCPC5Model from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,19 @@ class GCPC5(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPC5Model( Provider=finding.provider, @@ -53,8 +65,8 @@ class GCPC5(ComplianceOutput): Requirements_Attributes_Type=attribute.Type, Requirements_Attributes_AboutCriteria=attribute.AboutCriteria, Requirements_Attributes_ComplementaryCriteria=attribute.ComplementaryCriteria, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/csa/csa.py b/prowler/lib/outputs/compliance/ccc/ccc.py similarity index 71% rename from prowler/lib/outputs/compliance/csa/csa.py rename to prowler/lib/outputs/compliance/ccc/ccc.py index ab8a021a70..48b2086e78 100644 --- a/prowler/lib/outputs/compliance/csa/csa.py +++ b/prowler/lib/outputs/compliance/ccc/ccc.py @@ -2,9 +2,16 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) -def get_csa_table( +def get_ccc_table( findings: list, bulk_checks_metadata: dict, compliance_framework: str, @@ -18,40 +25,45 @@ def get_csa_table( "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() sections = {} + section_seen = {} + provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: - if ( - compliance.Framework == "CSA-CCM" - and compliance.Version in compliance_framework - ): + if compliance.Framework == "CCC": + provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = {} - if finding.muted: - if index not in muted_count: - muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: - fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) - sections[section]["PASS"] += 1 + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, sections[section], section_seen[section] + ) sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/ccc/ccc_aws.py b/prowler/lib/outputs/compliance/ccc/ccc_aws.py index a9de0aeca7..20d42b1508 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_aws.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.ccc.models import CCC_AWSModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,19 @@ class CCC_AWS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = CCC_AWSModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class CCC_AWS(ComplianceOutput): Requirements_Attributes_Recommendation=attribute.Recommendation, Requirements_Attributes_SectionThreatMappings=attribute.SectionThreatMappings, Requirements_Attributes_SectionGuidelineMappings=attribute.SectionGuidelineMappings, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ccc/ccc_azure.py b/prowler/lib/outputs/compliance/ccc/ccc_azure.py index 2801b91f97..4dcc8c5ca8 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_azure.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.ccc.models import CCC_AzureModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,19 @@ class CCC_Azure(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = CCC_AzureModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class CCC_Azure(ComplianceOutput): Requirements_Attributes_Recommendation=attribute.Recommendation, Requirements_Attributes_SectionThreatMappings=attribute.SectionThreatMappings, Requirements_Attributes_SectionGuidelineMappings=attribute.SectionGuidelineMappings, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ccc/ccc_gcp.py b/prowler/lib/outputs/compliance/ccc/ccc_gcp.py index 9793834776..ed7c709c24 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_gcp.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.ccc.models import CCC_GCPModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,19 @@ class CCC_GCP(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = CCC_GCPModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class CCC_GCP(ComplianceOutput): Requirements_Attributes_Recommendation=attribute.Recommendation, Requirements_Attributes_SectionThreatMappings=attribute.SectionThreatMappings, Requirements_Attributes_SectionGuidelineMappings=attribute.SectionGuidelineMappings, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis.py b/prowler/lib/outputs/compliance/cis/cis.py index 7f161f34c2..de3120f689 100644 --- a/prowler/lib/outputs/compliance/cis/cis.py +++ b/prowler/lib/outputs/compliance/cis/cis.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_cis_table( @@ -13,6 +20,9 @@ def get_cis_table( compliance_overview: bool, ): sections = {} + section_muted_seen = {} + section_split_seen = {} + provider = "" cis_compliance_table = { "Provider": [], "Section": [], @@ -20,16 +30,25 @@ def get_cis_table( "Level 2": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: version_in_name = compliance_framework.split("_")[1] if compliance.Framework == "CIS" and version_in_name in compliance.Version: + provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section # Check if Section exists @@ -40,32 +59,44 @@ def get_cis_table( "Level 2": {"FAIL": 0, "PASS": 0}, "Muted": 0, } + section_muted_seen[section] = set() + section_split_seen[section] = { + "Level 1": {}, + "Level 2": {}, + } + + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) if finding.muted: - if index not in muted_count: - muted_count.append(index) + # Per-section Muted: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_muted_seen[section]: + section_muted_seen[section].add(index) sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: - fail_count.append(index) - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) + if "Level 1" in attribute.Profile: if not finding.muted: - if finding.status == "FAIL": - sections[section]["Level 1"]["FAIL"] += 1 - else: - sections[section]["Level 1"]["PASS"] += 1 + accumulate_group_status( + index, + effective_status, + sections[section]["Level 1"], + section_split_seen[section]["Level 1"], + ) elif "Level 2" in attribute.Profile: if not finding.muted: - if finding.status == "FAIL": - sections[section]["Level 2"]["FAIL"] += 1 - else: - sections[section]["Level 2"]["PASS"] += 1 + accumulate_group_status( + index, + effective_status, + sections[section]["Level 2"], + section_split_seen[section]["Level 2"], + ) # Add results to table sections = dict(sorted(sections.items())) for section in sections: - cis_compliance_table["Provider"].append(compliance.Provider) + cis_compliance_table["Provider"].append(provider) cis_compliance_table["Section"].append(section) if sections[section]["Level 1"]["FAIL"] > 0: cis_compliance_table["Level 1"].append( diff --git a/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py b/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py index adf8ef2af1..77bf02ee98 100644 --- a/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py +++ b/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AlibabaCloudCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class AlibabaCloudCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AlibabaCloudCISModel( Provider=finding.provider, @@ -60,8 +71,8 @@ class AlibabaCloudCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_aws.py b/prowler/lib/outputs/compliance/cis/cis_aws.py index 749a3b7463..58bf7fbc27 100644 --- a/prowler/lib/outputs/compliance/cis/cis_aws.py +++ b/prowler/lib/outputs/compliance/cis/cis_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AWSCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,19 @@ class AWSCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSCISModel( Provider=finding.provider, @@ -60,8 +72,8 @@ class AWSCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_azure.py b/prowler/lib/outputs/compliance/cis/cis_azure.py index 5c5e4cd486..53b1cbdd32 100644 --- a/prowler/lib/outputs/compliance/cis/cis_azure.py +++ b/prowler/lib/outputs/compliance/cis/cis_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AzureCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class AzureCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureCISModel( Provider=finding.provider, @@ -60,8 +71,8 @@ class AzureCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_gcp.py b/prowler/lib/outputs/compliance/cis/cis_gcp.py index 328726aa9b..c2a11dae77 100644 --- a/prowler/lib/outputs/compliance/cis/cis_gcp.py +++ b/prowler/lib/outputs/compliance/cis/cis_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GCPCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class GCPCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPCISModel( Provider=finding.provider, @@ -59,8 +70,8 @@ class GCPCIS(ComplianceOutput): Requirements_Attributes_AuditProcedure=attribute.AuditProcedure, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_github.py b/prowler/lib/outputs/compliance/cis/cis_github.py index 0082b891e5..039bd1b993 100644 --- a/prowler/lib/outputs/compliance/cis/cis_github.py +++ b/prowler/lib/outputs/compliance/cis/cis_github.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GithubCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class GithubCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GithubCISModel( Provider=finding.provider, @@ -59,8 +70,8 @@ class GithubCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_References=attribute.References, Requirements_Attributes_DefaultValue=attribute.DefaultValue, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py index a4b58bb3b4..cf2d3755c7 100644 --- a/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py +++ b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GoogleWorkspaceCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class GoogleWorkspaceCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GoogleWorkspaceCISModel( Provider=finding.provider, @@ -59,8 +70,8 @@ class GoogleWorkspaceCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py index e8fee839fc..23c482310e 100644 --- a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py +++ b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import KubernetesCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class KubernetesCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = KubernetesCISModel( Provider=finding.provider, @@ -60,8 +71,8 @@ class KubernetesCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_References=attribute.References, Requirements_Attributes_DefaultValue=attribute.DefaultValue, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_m365.py b/prowler/lib/outputs/compliance/cis/cis_m365.py index 3c7de43542..8dc06155e7 100644 --- a/prowler/lib/outputs/compliance/cis/cis_m365.py +++ b/prowler/lib/outputs/compliance/cis/cis_m365.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import M365CISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class M365CIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = M365CISModel( Provider=finding.provider, @@ -60,8 +71,8 @@ class M365CIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py b/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py index 19e6e1d8c8..d8110d72db 100644 --- a/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py +++ b/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import OracleCloudCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -34,11 +38,18 @@ class OracleCloudCIS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = OracleCloudCISModel( Provider=finding.provider, @@ -60,8 +71,8 @@ class OracleCloudCIS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_DefaultValue=attribute.DefaultValue, Requirements_Attributes_References=attribute.References, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py b/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py index 8250d1bcce..d2f6faa212 100644 --- a/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py +++ b/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cisa_scuba.models import ( GoogleWorkspaceCISASCuBAModel, @@ -36,11 +40,18 @@ class GoogleWorkspaceCISASCuBA(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GoogleWorkspaceCISASCuBAModel( Provider=finding.provider, @@ -53,8 +64,8 @@ class GoogleWorkspaceCISASCuBA(ComplianceOutput): Requirements_Attributes_SubSection=attribute.SubSection, Requirements_Attributes_Service=attribute.Service, Requirements_Attributes_Type=attribute.Type, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index bb7fbf1146..4e4bd78232 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -1,10 +1,15 @@ import sys -from prowler.lib.check.models import Check_Report from prowler.lib.logger import logger +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight import ( + get_asd_essential_eight_table, +) from prowler.lib.outputs.compliance.c5.c5 import get_c5_table +from prowler.lib.outputs.compliance.ccc.ccc import get_ccc_table from prowler.lib.outputs.compliance.cis.cis import get_cis_table -from prowler.lib.outputs.compliance.csa.csa import get_csa_table +from prowler.lib.outputs.compliance.compliance_check import ( # noqa: F401 - re-export for backward compatibility + get_check_compliance, +) from prowler.lib.outputs.compliance.ens.ens import get_ens_table from prowler.lib.outputs.compliance.generic.generic_table import ( get_generic_compliance_table, @@ -13,9 +18,120 @@ from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( get_mitre_attack_table, ) +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( get_prowler_threatscore_table, ) +from prowler.lib.outputs.compliance.universal.universal_table import get_universal_table + + +def process_universal_compliance_frameworks( + input_compliance_frameworks: set, + universal_frameworks: dict, + finding_outputs: list, + output_directory: str, + output_filename: str, + provider: str, + generated_outputs: dict, + from_cli: bool = True, + is_last: bool = True, +) -> set: + """Process universal compliance frameworks, generating CSV and OCSF outputs. + + For each framework in *input_compliance_frameworks* that exists in + *universal_frameworks* and has an ``outputs.table_config``, this function + writes both a CSV (``UniversalComplianceOutput``) and an OCSF JSON + (``OCSFComplianceOutput``) file. OCSF is always generated regardless of + the user's ``--output-formats`` flag. + + Streaming-aware: writers are tracked via ``generated_outputs["compliance"]`` + keyed by ``file_path``. On the first call per framework a new writer is + created and emits both findings and manual requirements; subsequent calls + reuse the writer, transform only the new ``finding_outputs`` (manual + requirements are not re-emitted), and append to the open file. Set + ``from_cli=False`` and ``is_last=False`` for intermediate batches; pass + ``is_last=True`` on the final batch to close the file (OCSF is also + finalized as a valid JSON array). + + Returns the set of framework names processed so the caller can subtract + them from the legacy per-provider output loop. + """ + from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, + ) + from prowler.lib.outputs.compliance.universal.universal_output import ( + UniversalComplianceOutput, + ) + + existing_writers = { + getattr(out, "file_path", None): out + for out in generated_outputs.get("compliance", []) + if isinstance(out, (UniversalComplianceOutput, OCSFComplianceOutput)) + } + + def _flush(writer, framework, label, is_new): + if not is_new: + writer._transform(finding_outputs, framework, label, include_manual=False) + writer.close_file = is_last + writer.batch_write_data_to_file() + writer._data.clear() + + processed = set() + for compliance_name in input_compliance_frameworks: + if not ( + compliance_name in universal_frameworks + and universal_frameworks[compliance_name].outputs + and universal_frameworks[compliance_name].outputs.table_config + ): + continue + + fw = universal_frameworks[compliance_name] + compliance_label = ( + fw.framework + "-" + fw.version if fw.version else fw.framework + ) + + # CSV output + csv_path = ( + f"{output_directory}/compliance/" f"{output_filename}_{compliance_name}.csv" + ) + csv_writer = existing_writers.get(csv_path) + csv_is_new = csv_writer is None + if csv_is_new: + csv_writer = UniversalComplianceOutput( + findings=finding_outputs, + framework=fw, + file_path=csv_path, + from_cli=from_cli, + provider=provider, + ) + generated_outputs["compliance"].append(csv_writer) + existing_writers[csv_path] = csv_writer + _flush(csv_writer, fw, compliance_label, csv_is_new) + + # OCSF output (always generated for universal frameworks) + ocsf_path = ( + f"{output_directory}/compliance/" + f"{output_filename}_{compliance_name}.ocsf.json" + ) + ocsf_writer = existing_writers.get(ocsf_path) + ocsf_is_new = ocsf_writer is None + if ocsf_is_new: + ocsf_writer = OCSFComplianceOutput( + findings=finding_outputs, + framework=fw, + file_path=ocsf_path, + from_cli=from_cli, + provider=provider, + ) + generated_outputs["compliance"].append(ocsf_writer) + existing_writers[ocsf_path] = ocsf_writer + _flush(ocsf_writer, fw, compliance_label, ocsf_is_new) + + processed.add(compliance_name) + + return processed def display_compliance_table( @@ -25,6 +141,9 @@ def display_compliance_table( output_filename: str, output_directory: str, compliance_overview: bool, + universal_frameworks: dict = None, + provider: str = None, + output_formats: list = None, ) -> None: """ display_compliance_table generates the compliance table for the given compliance framework. @@ -36,21 +155,35 @@ def display_compliance_table( output_filename (str): The output filename output_directory (str): The output directory compliance_overview (bool): The compliance + universal_frameworks (dict): Optional universal ComplianceFramework objects + provider (str): The current provider (e.g. "aws") for multi-provider filtering + output_formats (list): The output formats to generate 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( - findings, - bulk_checks_metadata, - compliance_framework, - output_filename, - output_directory, - compliance_overview, - ) - elif "cis_" in compliance_framework: + # Universal path: if the framework has TableConfig, use the universal renderer + if universal_frameworks and compliance_framework in universal_frameworks: + fw = universal_frameworks[compliance_framework] + if fw.outputs and fw.outputs.table_config: + get_universal_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + framework=fw, + provider=provider, + output_formats=output_formats, + ) + return + + if compliance_framework.startswith("cis_"): get_cis_table( findings, bulk_checks_metadata, @@ -59,7 +192,16 @@ def display_compliance_table( output_directory, compliance_overview, ) - elif "mitre_attack" in compliance_framework: + elif compliance_framework.startswith("ens_"): + get_ens_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) + elif compliance_framework.startswith("mitre_attack"): get_mitre_attack_table( findings, bulk_checks_metadata, @@ -68,7 +210,7 @@ def display_compliance_table( output_directory, compliance_overview, ) - elif "kisa_isms_" in compliance_framework: + elif compliance_framework.startswith("kisa"): get_kisa_ismsp_table( findings, bulk_checks_metadata, @@ -77,7 +219,7 @@ def display_compliance_table( output_directory, compliance_overview, ) - elif "threatscore_" in compliance_framework: + elif compliance_framework.startswith("prowler_threatscore_"): get_prowler_threatscore_table( findings, bulk_checks_metadata, @@ -86,16 +228,7 @@ def display_compliance_table( output_directory, compliance_overview, ) - elif "csa_ccm_" in compliance_framework: - get_csa_table( - findings, - bulk_checks_metadata, - compliance_framework, - output_filename, - output_directory, - compliance_overview, - ) - elif "c5_" in compliance_framework: + elif compliance_framework.startswith("c5_"): get_c5_table( findings, bulk_checks_metadata, @@ -104,8 +237,8 @@ def display_compliance_table( output_directory, compliance_overview, ) - else: - get_generic_compliance_table( + elif compliance_framework.startswith("ccc_"): + get_ccc_table( findings, bulk_checks_metadata, compliance_framework, @@ -113,54 +246,53 @@ def display_compliance_table( output_directory, compliance_overview, ) + elif "asd_essential_eight" in compliance_framework: + get_asd_essential_eight_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) + elif compliance_framework.startswith("okta_idaas_stig"): + get_okta_idaas_stig_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) + else: + # Try provider-specific table first, fall back to generic + from prowler.providers.common.provider import Provider + + provider = Provider.get_global_provider() + handled = False + if provider is not None: + try: + handled = provider.display_compliance_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) + except NotImplementedError: + handled = False + if not handled: + get_generic_compliance_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) except Exception as error: logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) sys.exit(1) - - -# TODO: this should be in the Check class -def get_check_compliance( - finding: Check_Report, provider_type: str, bulk_checks_metadata: dict -) -> dict: - """get_check_compliance returns a map with the compliance framework as key and the requirements where the finding's check is present. - - Example: - - { - "CIS-1.4": ["2.1.3"], - "CIS-1.5": ["2.1.3"], - } - - Args: - finding (Any): The Check_Report finding - provider_type (str): The provider type - bulk_checks_metadata (dict): The bulk checks metadata - - Returns: - dict: The compliance framework as key and the requirements where the finding's check is present. - """ - try: - check_compliance = {} - # We have to retrieve all the check's compliance requirements - if finding.check_metadata.CheckID in bulk_checks_metadata: - for compliance in bulk_checks_metadata[ - finding.check_metadata.CheckID - ].Compliance: - compliance_fw = compliance.Framework - if compliance.Version: - compliance_fw = f"{compliance_fw}-{compliance.Version}" - # compliance.Provider == "Azure" or "Kubernetes" - # provider_type == "azure" or "kubernetes" - if compliance.Provider.upper() == provider_type.upper(): - if compliance_fw not in check_compliance: - check_compliance[compliance_fw] = [] - for requirement in compliance.Requirements: - check_compliance[compliance_fw].append(requirement.Id) - return check_compliance - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" - ) - return {} diff --git a/prowler/lib/outputs/compliance/compliance_check.py b/prowler/lib/outputs/compliance/compliance_check.py new file mode 100644 index 0000000000..85de5faed0 --- /dev/null +++ b/prowler/lib/outputs/compliance/compliance_check.py @@ -0,0 +1,48 @@ +from prowler.lib.check.models import Check_Report +from prowler.lib.logger import logger + + +# TODO: this should be in the Check class +def get_check_compliance( + finding: Check_Report, provider_type: str, bulk_checks_metadata: dict +) -> dict: + """get_check_compliance returns a map with the compliance framework as key and the requirements where the finding's check is present. + + Example: + + { + "CIS-1.4": ["2.1.3"], + "CIS-1.5": ["2.1.3"], + } + + Args: + finding (Any): The Check_Report finding + provider_type (str): The provider type + bulk_checks_metadata (dict): The bulk checks metadata + + Returns: + dict: The compliance framework as key and the requirements where the finding's check is present. + """ + try: + check_compliance = {} + # We have to retrieve all the check's compliance requirements + if finding.check_metadata.CheckID in bulk_checks_metadata: + for compliance in bulk_checks_metadata[ + finding.check_metadata.CheckID + ].Compliance: + compliance_fw = compliance.Framework + if compliance.Version: + compliance_fw = f"{compliance_fw}-{compliance.Version}" + # compliance.Provider == "Azure" or "Kubernetes" + # provider_type == "azure" or "kubernetes" + if compliance.Provider.upper() == provider_type.upper(): + if compliance_fw not in check_compliance: + check_compliance[compliance_fw] = [] + for requirement in compliance.Requirements: + check_compliance[compliance_fw].append(requirement.Id) + return check_compliance + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return {} diff --git a/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py b/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py deleted file mode 100644 index 084edbaa4b..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py +++ /dev/null @@ -1,96 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AlibabaCloudCSAModel -from prowler.lib.outputs.finding import Finding - - -class AlibabaCloudCSA(ComplianceOutput): - """ - This class represents the Alibaba Cloud CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into Alibaba Cloud CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into Alibaba Cloud CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) - for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: - for attribute in requirement.Attributes: - compliance_row = AlibabaCloudCSAModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AlibabaCloudCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_aws.py b/prowler/lib/outputs/compliance/csa/csa_aws.py deleted file mode 100644 index 0f371bfb3a..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_aws.py +++ /dev/null @@ -1,96 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AWSCSAModel -from prowler.lib.outputs.finding import Finding - - -class AWSCSA(ComplianceOutput): - """ - This class represents the AWS CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into AWS CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into AWS CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) - for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: - for attribute in requirement.Attributes: - compliance_row = AWSCSAModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AWSCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_azure.py b/prowler/lib/outputs/compliance/csa/csa_azure.py deleted file mode 100644 index 9bb16e6f11..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_azure.py +++ /dev/null @@ -1,96 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AzureCSAModel -from prowler.lib.outputs.finding import Finding - - -class AzureCSA(ComplianceOutput): - """ - This class represents the Azure CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into Azure CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into Azure CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) - for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: - for attribute in requirement.Attributes: - compliance_row = AzureCSAModel( - Provider=finding.provider, - Description=compliance.Description, - SubscriptionId=finding.account_uid, - Location=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AzureCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - SubscriptionId="", - Location="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_gcp.py b/prowler/lib/outputs/compliance/csa/csa_gcp.py deleted file mode 100644 index 5c1b400525..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_gcp.py +++ /dev/null @@ -1,96 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import GCPCSAModel -from prowler.lib.outputs.finding import Finding - - -class GCPCSA(ComplianceOutput): - """ - This class represents the GCP CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into GCP CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into GCP CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) - for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: - for attribute in requirement.Attributes: - compliance_row = GCPCSAModel( - Provider=finding.provider, - Description=compliance.Description, - ProjectId=finding.account_uid, - Location=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = GCPCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - ProjectId="", - Location="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/models.py b/prowler/lib/outputs/compliance/csa/models.py deleted file mode 100644 index 78c7384fc6..0000000000 --- a/prowler/lib/outputs/compliance/csa/models.py +++ /dev/null @@ -1,146 +0,0 @@ -from pydantic.v1 import BaseModel - - -class AWSCSAModel(BaseModel): - """ - AWSCSAModel generates a finding's output in CSV CSA format for AWS. - """ - - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class GCPCSAModel(BaseModel): - """ - GCPCSAModel generates a finding's output in CSV CSA format for GCP. - """ - - Provider: str - Description: str - ProjectId: str - Location: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class OracleCloudCSAModel(BaseModel): - """ - OracleCloudCSAModel generates a finding's output in CSV CSA format for OracleCloud. - """ - - Provider: str - Description: str - TenancyId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class AlibabaCloudCSAModel(BaseModel): - """ - AlibabaCloudCSAModel generates a finding's output in CSV CSA format for Alibaba Cloud. - """ - - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class AzureCSAModel(BaseModel): - """ - AzureCSAModel generates a finding's output in CSV CSA format for Azure. - """ - - Provider: str - Description: str - SubscriptionId: str - Location: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str diff --git a/prowler/lib/outputs/compliance/ens/ens.py b/prowler/lib/outputs/compliance/ens/ens.py index a414a0206d..4fcca922c7 100644 --- a/prowler/lib/outputs/compliance/ens/ens.py +++ b/prowler/lib/outputs/compliance/ens/ens.py @@ -2,6 +2,11 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_ens_table( @@ -13,6 +18,8 @@ def get_ens_table( compliance_overview: bool, ): marcos = {} + marco_muted_seen = {} + provider = "" ens_compliance_table = { "Proveedor": [], "Marco/Categoria": [], @@ -23,15 +30,24 @@ def get_ens_table( "Opcional": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ENS": + provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: marco_categoria = f"{attribute.Marco}/{attribute.Categoria}" # Check if Marco/Categoria exists @@ -44,22 +60,27 @@ def get_ens_table( "Bajo": 0, "Muted": 0, } + marco_muted_seen[marco_categoria] = set() if finding.muted: - if index not in muted_count: - muted_count.append(index) + # Overview total: count each finding once per framework + muted_count.add(index) + # Per-marco Muted: count each finding once per marco + # it belongs to (a finding can map to several marcos). + if index not in marco_muted_seen[marco_categoria]: + marco_muted_seen[marco_categoria].add(index) marcos[marco_categoria]["Muted"] += 1 else: - if finding.status == "FAIL": - if ( - attribute.Tipo != "recomendacion" - and index not in fail_count - ): - fail_count.append(index) + if effective_status == "FAIL": + if attribute.Tipo != "recomendacion": + fail_count.add(index) + pass_count.discard(index) + # Mark every marco the finding belongs to as + # NO CUMPLE, not just the first one seen. marcos[marco_categoria][ "Estado" ] = f"{Fore.RED}NO CUMPLE{Style.RESET_ALL}" - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) + elif effective_status == "PASS" and index not in fail_count: + pass_count.add(index) if attribute.Nivel == "opcional": marcos[marco_categoria]["Opcional"] += 1 elif attribute.Nivel == "alto": @@ -71,7 +92,7 @@ def get_ens_table( # Add results to table for marco in sorted(marcos): - ens_compliance_table["Proveedor"].append(compliance.Provider) + ens_compliance_table["Proveedor"].append(provider) ens_compliance_table["Marco/Categoria"].append(marco) ens_compliance_table["Estado"].append(marcos[marco]["Estado"]) ens_compliance_table["Opcional"].append( diff --git a/prowler/lib/outputs/compliance/ens/ens_aws.py b/prowler/lib/outputs/compliance/ens/ens_aws.py index 01a212d9af..543799f7b9 100644 --- a/prowler/lib/outputs/compliance/ens/ens_aws.py +++ b/prowler/lib/outputs/compliance/ens/ens_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import AWSENSModel @@ -34,11 +38,19 @@ class AWSENS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSENSModel( Provider=finding.provider, @@ -61,8 +73,8 @@ class AWSENS(ComplianceOutput): Requirements_Attributes_Dependencias=",".join( attribute.Dependencias ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ens/ens_azure.py b/prowler/lib/outputs/compliance/ens/ens_azure.py index 35385ab673..23055da2a2 100644 --- a/prowler/lib/outputs/compliance/ens/ens_azure.py +++ b/prowler/lib/outputs/compliance/ens/ens_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import AzureENSModel @@ -34,11 +38,19 @@ class AzureENS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureENSModel( Provider=finding.provider, @@ -61,8 +73,8 @@ class AzureENS(ComplianceOutput): Requirements_Attributes_Dependencias=",".join( attribute.Dependencias ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/ens/ens_gcp.py b/prowler/lib/outputs/compliance/ens/ens_gcp.py index 81d6d33b14..f616e48f83 100644 --- a/prowler/lib/outputs/compliance/ens/ens_gcp.py +++ b/prowler/lib/outputs/compliance/ens/ens_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import GCPENSModel @@ -34,11 +38,19 @@ class GCPENS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPENSModel( Provider=finding.provider, @@ -61,8 +73,8 @@ class GCPENS(ComplianceOutput): Requirements_Attributes_Dependencias=",".join( attribute.Dependencias ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py index d9dbe8abe9..b3f1ad7ec9 100644 --- a/prowler/lib/outputs/compliance/generic/generic.py +++ b/prowler/lib/outputs/compliance/generic/generic.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel @@ -34,61 +38,61 @@ class GenericCompliance(ComplianceOutput): Returns: - None """ + + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + + def compliance_row(requirement, attribute, finding=None): + # Read attribute fields defensively: GenericCompliance is the + # last-resort renderer for any framework, and provider-specific + # schemas (e.g. CIS, ENS, ISO27001) do not declare the universal + # Section/SubSection/SubGroup/Service/Type/Comment fields. + status, status_extended = ( + apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) + if finding + else ("MANUAL", "Manual check") + ) + return GenericComplianceModel( + Provider=(finding.provider if finding else compliance.Provider.lower()), + Description=compliance.Description, + AccountId=finding.account_uid if finding else "", + Region=finding.region if finding else "", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=getattr(attribute, "Section", None), + Requirements_Attributes_SubSection=getattr( + attribute, "SubSection", None + ), + Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None), + Requirements_Attributes_Service=getattr(attribute, "Service", None), + Requirements_Attributes_Type=getattr(attribute, "Type", None), + Requirements_Attributes_Comment=getattr(attribute, "Comment", None), + Status=status, + StatusExtended=status_extended, + ResourceId=finding.resource_uid if finding else "manual_check", + ResourceName=finding.resource_name if finding else "Manual check", + CheckId=finding.check_id if finding else "manual", + Muted=finding.muted if finding else False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, + self._data.append( + compliance_row(requirement, attribute, finding) ) - self._data.append(compliance_row) # Add manual requirements to the compliance output for requirement in compliance.Requirements: if not requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) + self._data.append(compliance_row(requirement, attribute)) diff --git a/prowler/lib/outputs/compliance/generic/generic_table.py b/prowler/lib/outputs/compliance/generic/generic_table.py index 9136cf19e2..060acda10a 100644 --- a/prowler/lib/outputs/compliance/generic/generic_table.py +++ b/prowler/lib/outputs/compliance/generic/generic_table.py @@ -2,6 +2,11 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_generic_compliance_table( @@ -15,6 +20,8 @@ def get_generic_compliance_table( pass_count = [] fail_count = [] muted_count = [] + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -25,13 +32,21 @@ def get_generic_compliance_table( and compliance.Version in compliance_framework.upper() and compliance.Provider.upper() in compliance_framework.upper() ): - if finding.muted: - if index not in muted_count: - muted_count.append(index) - else: - if finding.status == "FAIL" and index not in fail_count: + for requirement in compliance.Requirements: + # A configurable check that passed with a too-loose config is + # forced to FAIL (source of truth: framework ConfigRequirements). + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) + if finding.muted: + if index not in muted_count: + muted_count.append(index) + elif effective_status == "FAIL" and index not in fail_count: fail_count.append(index) - elif finding.status == "PASS" and index not in pass_count: + elif effective_status == "PASS" and index not in pass_count: pass_count.append(index) if ( len(fail_count) + len(pass_count) + len(muted_count) > 1 diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py index b6da583af2..3376b8c5ad 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import AWSISO27001Model @@ -34,11 +38,18 @@ class AWSISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSISO27001Model( Provider=finding.provider, @@ -53,8 +64,8 @@ class AWSISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py index 1e36005440..f1f964c726 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import AzureISO27001Model @@ -34,11 +38,18 @@ class AzureISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AzureISO27001Model( Provider=finding.provider, @@ -53,8 +64,8 @@ class AzureISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py index 9f08e3d920..9a5de17bfa 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import GCPISO27001Model @@ -34,11 +38,18 @@ class GCPISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = GCPISO27001Model( Provider=finding.provider, @@ -53,8 +64,8 @@ class GCPISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py index b0e8904ebe..5ca81e82d5 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import KubernetesISO27001Model @@ -34,11 +38,18 @@ class KubernetesISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = KubernetesISO27001Model( Provider=finding.provider, @@ -53,8 +64,8 @@ class KubernetesISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py index cf12bbd841..84c5072002 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import M365ISO27001Model @@ -34,10 +38,18 @@ class M365ISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = M365ISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class M365ISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py index 6c89b7333f..ad8ea6dcab 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import NHNISO27001Model @@ -34,10 +38,18 @@ class NHNISO27001(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = NHNISO27001Model( Provider=finding.provider, @@ -52,8 +64,8 @@ class NHNISO27001(ComplianceOutput): Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, Requirements_Attributes_Check_Summary=attribute.Check_Summary, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, CheckId=finding.check_id, Muted=finding.muted, diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py index 93b925a5ff..dda83342b6 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py @@ -2,6 +2,12 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_kisa_ismsp_table( @@ -13,16 +19,20 @@ def get_kisa_ismsp_table( compliance_overview: bool, ): sections = {} + section_seen = {} sections_status = {} + provider = "" kisa_ismsp_compliance_table = { "Provider": [], "Section": [], "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -31,7 +41,14 @@ def get_kisa_ismsp_table( compliance.Framework.startswith("KISA") and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: section = attribute.Section # Check if Section exists @@ -43,17 +60,27 @@ def get_kisa_ismsp_table( }, "Muted": 0, } - if finding.muted: - if index not in muted_count: - muted_count.append(index) + section_seen[section] = {} + + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + + # FAIL/PASS live under ["Status"], Muted at top level. + previous = section_seen[section].get(index) + if previous is None: + section_seen[section][index] = status + if status == "Muted": sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: - fail_count.append(index) + elif status == "FAIL": sections[section]["Status"]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) + elif status == "PASS": sections[section]["Status"]["PASS"] += 1 + elif previous == "PASS" and status == "FAIL": + section_seen[section][index] = "FAIL" + sections[section]["Status"]["PASS"] -= 1 + sections[section]["Status"]["FAIL"] += 1 # Add results to table sections = dict(sorted(sections.items())) @@ -70,7 +97,7 @@ def get_kisa_ismsp_table( else: sections_status[section] = f"{Fore.GREEN}PASS{Style.RESET_ALL}" for section in sections: - kisa_ismsp_compliance_table["Provider"].append(compliance.Provider) + kisa_ismsp_compliance_table["Provider"].append(provider) kisa_ismsp_compliance_table["Section"].append(section) kisa_ismsp_compliance_table["Status"].append(sections_status[section]) kisa_ismsp_compliance_table["Muted"].append( diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py index f65430b1a3..3d05632d26 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.kisa_ismsp.models import AWSKISAISMSPModel @@ -34,11 +38,19 @@ class AWSKISAISMSP(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = AWSKISAISMSPModel( Provider=finding.provider, @@ -56,8 +68,8 @@ class AWSKISAISMSP(ComplianceOutput): Requirements_Attributes_RelatedRegulations=attribute.RelatedRegulations, Requirements_Attributes_AuditEvidence=attribute.AuditEvidence, Requirements_Attributes_NonComplianceCases=attribute.NonComplianceCases, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py index bab3e4e31a..a352acfa6e 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) def get_mitre_attack_table( @@ -13,15 +20,19 @@ def get_mitre_attack_table( compliance_overview: bool, ): tactics = {} + tactic_seen = {} + provider = "" mitre_compliance_table = { "Provider": [], "Tactic": [], "Status": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -30,27 +41,29 @@ def get_mitre_attack_table( "MITRE-ATTACK" in compliance.Framework and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) + status = "Muted" if finding.muted else effective_status for tactic in requirement.Tactics: if tactic not in tactics: tactics[tactic] = {"FAIL": 0, "PASS": 0, "Muted": 0} - if finding.muted: - if index not in muted_count: - muted_count.append(index) - tactics[tactic]["Muted"] += 1 - else: - if finding.status == "FAIL": - if index not in fail_count: - fail_count.append(index) - tactics[tactic]["FAIL"] += 1 - elif finding.status == "PASS": - if index not in pass_count: - pass_count.append(index) - tactics[tactic]["PASS"] += 1 + tactic_seen[tactic] = {} + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, tactics[tactic], tactic_seen[tactic] + ) # Add results to table tactics = dict(sorted(tactics.items())) for tactic in tactics: - mitre_compliance_table["Provider"].append(compliance.Provider) + mitre_compliance_table["Provider"].append(provider) mitre_compliance_table["Tactic"].append(tactic) if tactics[tactic]["FAIL"] > 0: mitre_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py index 9a4c4fc2c3..92f4ac0e5b 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import AWSMitreAttackModel @@ -35,11 +39,19 @@ class AWSMitreAttack(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) compliance_row = AWSMitreAttackModel( Provider=finding.provider, Description=compliance.Description, @@ -67,8 +79,8 @@ class AWSMitreAttack(ComplianceOutput): Requirements_Attributes_Comments=", ".join( attribute.Comment for attribute in requirement.Attributes ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py index 5787bbfba6..a43e27e775 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import AzureMitreAttackModel @@ -35,11 +39,19 @@ class AzureMitreAttack(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) compliance_row = AzureMitreAttackModel( Provider=finding.provider, Description=compliance.Description, @@ -68,8 +80,8 @@ class AzureMitreAttack(ComplianceOutput): Requirements_Attributes_Comments=", ".join( attribute.Comment for attribute in requirement.Attributes ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py index bae7920e43..b8efdc5250 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import GCPMitreAttackModel @@ -35,11 +39,19 @@ class GCPMitreAttack(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) compliance_row = GCPMitreAttackModel( Provider=finding.provider, Description=compliance.Description, @@ -67,8 +79,8 @@ class GCPMitreAttack(ComplianceOutput): Requirements_Attributes_Comments=", ".join( attribute.Comment for attribute in requirement.Attributes ), - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py b/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/models.py b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py new file mode 100644 index 0000000000..674d9656b7 --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py @@ -0,0 +1,32 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + + +class OktaIDaaSSTIGModel(BaseModel): + """ + OktaIDaaSSTIGModel generates a finding's output in DISA Okta IDaaS STIG Compliance format. + """ + + Provider: str + Description: str + OrganizationDomain: str + AssessmentDate: str + Requirements_Id: str + Requirements_Name: str + Requirements_Description: str + Requirements_Attributes_Section: str + Requirements_Attributes_Severity: str + Requirements_Attributes_RuleID: str + Requirements_Attributes_StigID: str + Requirements_Attributes_CCI: Optional[list[str]] = None + Requirements_Attributes_CheckText: Optional[str] = None + Requirements_Attributes_FixText: Optional[str] = None + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool + Framework: str + Name: str diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py new file mode 100644 index 0000000000..ef6bb2742a --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py @@ -0,0 +1,127 @@ +from colorama import Fore, Style +from tabulate import tabulate + +from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) + + +def get_okta_idaas_stig_table( + findings: list, + bulk_checks_metadata: dict, + compliance_framework: str, + output_filename: str, + output_directory: str, + compliance_overview: bool, +): + section_table = { + "Provider": [], + "Section": [], + "Status": [], + "Muted": [], + } + pass_count = [] + fail_count = [] + muted_count = [] + sections = {} + section_seen = {} + provider = "" + audit_config = get_scan_audit_config() + config_status_cache = {} + for index, finding in enumerate(findings): + check = bulk_checks_metadata[finding.check_metadata.CheckID] + check_compliances = check.Compliance + for compliance in check_compliances: + if compliance.Framework == "Okta-IDaaS-STIG": + provider = compliance.Provider + for requirement in compliance.Requirements: + # A configurable check that passed with a too-loose config is + # forced to FAIL (source of truth: framework ConfigRequirements). + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) + for attribute in requirement.Attributes: + section = attribute.Section + + if section not in sections: + sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + + # Overview totals: count each finding once per framework + if finding.muted: + if index not in muted_count: + muted_count.append(index) + elif effective_status == "FAIL": + if index not in fail_count: + fail_count.append(index) + elif effective_status == "PASS": + if index not in pass_count: + pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif effective_status == "FAIL": + sections[section]["FAIL"] += 1 + elif effective_status == "PASS": + sections[section]["PASS"] += 1 + + sections = dict(sorted(sections.items())) + for section in sections: + section_table["Provider"].append(provider) + section_table["Section"].append(section) + if sections[section]["FAIL"] > 0: + section_table["Status"].append( + f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}" + ) + else: + if sections[section]["PASS"] > 0: + section_table["Status"].append( + f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}" + ) + else: + section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") + section_table["Muted"].append( + f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}" + ) + + if ( + len(fail_count) + len(pass_count) + len(muted_count) > 1 + ): # If there are no resources, don't print the compliance table + print( + f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:" + ) + total_findings_count = len(fail_count) + len(pass_count) + len(muted_count) + overview_table = [ + [ + f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}", + f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}", + f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}", + ] + ] + print(tabulate(overview_table, tablefmt="rounded_grid")) + if not compliance_overview: + if len(fail_count) > 0 and len(section_table["Section"]) > 0: + print( + f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:" + ) + print( + tabulate( + section_table, + tablefmt="rounded_grid", + headers="keys", + ) + ) + print(f"\nDetailed results of {compliance_framework.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n" + ) diff --git a/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py similarity index 55% rename from prowler/lib/outputs/compliance/csa/csa_oraclecloud.py rename to prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py index 9b3d36c168..b8a72f9f95 100644 --- a/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py @@ -1,62 +1,73 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import OracleCloudCSAModel +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel from prowler.lib.outputs.finding import Finding -class OracleCloudCSA(ComplianceOutput): +class OktaIDaaSSTIG(ComplianceOutput): """ - This class represents the OracleCloud CSA compliance output. + This class represents the Okta IDaaS STIG compliance output. Attributes: - _data (list): A list to store transformed data from findings. - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. Methods: - - transform: Transforms findings into OracleCloud CSA compliance format. + - transform: Transforms findings into Okta IDaaS STIG compliance format. """ def transform( self, findings: list[Finding], compliance: Compliance, - compliance_name: str, + _compliance_name: str, ) -> None: """ - Transforms a list of findings into OracleCloud CSA compliance format. + Transforms a list of findings into Okta IDaaS STIG compliance format. Parameters: - findings (list): A list of findings. - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. + - _compliance_name (str): The name of the compliance model (unused). Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: - compliance_row = OracleCloudCSAModel( + compliance_row = OktaIDaaSSTIGModel( Provider=finding.provider, Description=compliance.Description, - TenancyId=finding.account_uid, - Region=finding.region, + OrganizationDomain=finding.account_name, AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, @@ -69,21 +80,21 @@ class OracleCloudCSA(ComplianceOutput): for requirement in compliance.Requirements: if not requirement.Checks: for attribute in requirement.Attributes: - compliance_row = OracleCloudCSAModel( + compliance_row = OktaIDaaSSTIGModel( Provider=compliance.Provider.lower(), Description=compliance.Description, - TenancyId="", - Region="", + OrganizationDomain="", AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, Status="MANUAL", StatusExtended="Manual check", ResourceId="manual_check", diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index cfcd4a006e..cb23d67ffb 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -2,6 +2,13 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance @@ -20,22 +27,33 @@ def get_prowler_threatscore_table( "Score": [], "Muted": [], } - pass_count = [] - fail_count = [] - muted_count = [] + pass_count = set() + fail_count = set() + muted_count = set() pillars = {} + pillar_seen = {} + provider = "" generic_score = 0 max_generic_score = 0 - counted_findings_generic = [] + counted_findings_generic = {} score_per_pillar = {} max_score_per_pillar = {} counted_findings_per_pillar = {} + audit_config = get_scan_audit_config() + config_status_cache = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ProwlerThreatScore": + provider = compliance.Provider for requirement in compliance.Requirements: + config_status = resolve_requirement_config_status( + requirement, audit_config, config_status_cache + ) + effective_status = get_effective_status( + finding.status, config_status + ) for attribute in requirement.Attributes: pillar = attribute.Section @@ -48,60 +66,68 @@ def get_prowler_threatscore_table( ): score_per_pillar[pillar] = 0 max_score_per_pillar[pillar] = 0 - counted_findings_per_pillar[pillar] = [] + counted_findings_per_pillar[pillar] = {} - if ( - index not in counted_findings_per_pillar[pillar] - and not finding.muted - ): - if finding.status == "PASS": - score_per_pillar[pillar] += ( - attribute.LevelOfRisk * attribute.Weight - ) - max_score_per_pillar[pillar] += ( - attribute.LevelOfRisk * attribute.Weight - ) - counted_findings_per_pillar[pillar].append(index) + # Revoke an earlier PASS score if a later requirement FAILs. + if not finding.muted: + contribution = attribute.LevelOfRisk * attribute.Weight + counted = counted_findings_per_pillar[pillar] + if index not in counted: + max_score_per_pillar[pillar] += contribution + if effective_status == "PASS": + score_per_pillar[pillar] += contribution + counted[index] = contribution + else: + counted[index] = 0 + elif effective_status == "FAIL" and counted[index]: + score_per_pillar[pillar] -= counted[index] + counted[index] = 0 if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} + pillar_seen[pillar] = {} - if finding.muted: - if index not in muted_count: - muted_count.append(index) - pillars[pillar]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: - fail_count.append(index) - pillars[pillar]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) - pillars[pillar]["PASS"] += 1 + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, pillars[pillar], pillar_seen[pillar] + ) - # Generic score - if index not in counted_findings_generic and not finding.muted: - if finding.status == "PASS": - generic_score += ( - attribute.LevelOfRisk * attribute.Weight - ) - max_generic_score += ( - attribute.LevelOfRisk * attribute.Weight - ) - counted_findings_generic.append(index) + # Generic score, with the same PASS-revocation on FAIL. + if not finding.muted: + contribution = attribute.LevelOfRisk * attribute.Weight + if index not in counted_findings_generic: + max_generic_score += contribution + if effective_status == "PASS": + generic_score += contribution + counted_findings_generic[index] = contribution + else: + counted_findings_generic[index] = 0 + elif ( + effective_status == "FAIL" + and counted_findings_generic[index] + ): + generic_score -= counted_findings_generic[index] + counted_findings_generic[index] = 0 no_findings_pillars = [] - bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get( - compliance_framework + bulk_compliance = ( + Compliance.get_bulk(provider=provider.lower()).get(compliance_framework) + if provider + else None ) - for requirement in bulk_compliance.Requirements: - for attribute in requirement.Attributes: - pillar = attribute.Section - if pillar not in pillars.keys() and pillar not in no_findings_pillars: - no_findings_pillars.append(pillar) + if bulk_compliance: + for requirement in bulk_compliance.Requirements: + for attribute in requirement.Attributes: + pillar = attribute.Section + if pillar not in pillars.keys() and pillar not in no_findings_pillars: + no_findings_pillars.append(pillar) pillars = dict(sorted(pillars.items())) for pillar in pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) if max_score_per_pillar[pillar] == 0: pillar_score = 100.0 @@ -127,7 +153,7 @@ def get_prowler_threatscore_table( ) for pillar in no_findings_pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) pillar_table["Score"].append(f"{Style.BRIGHT}{Fore.GREEN}100%{Style.RESET_ALL}") pillar_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py index 66fabe51b3..7d682a3e62 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,11 +40,19 @@ class ProwlerThreatScoreAlibaba(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreAlibabaModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class ProwlerThreatScoreAlibaba(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py index b896e55528..f1280808e3 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,11 +40,19 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreAWSModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py index bbc3749843..5118511369 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,11 +40,19 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreAzureModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py index 4b03e269a3..39f9c23850 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,11 +40,19 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreGCPModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py index 7b35dab312..88bf41582a 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,11 +40,19 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreKubernetesModel( Provider=finding.provider, @@ -57,8 +69,8 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py index 6b0cc88a15..b7b533c72a 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py @@ -1,4 +1,8 @@ from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -36,11 +40,19 @@ class ProwlerThreatScoreM365(ComplianceOutput): Returns: - None """ + requirement_config_status = build_requirement_config_status( + compliance.Requirements + ) + for finding in findings: - # Get the compliance requirements for the finding - finding_requirements = finding.compliance.get(compliance_name, []) for requirement in compliance.Requirements: - if requirement.Id in finding_requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + row_status, row_status_extended = apply_config_status( + finding.status, + finding.status_extended, + requirement_config_status.get(requirement.Id), + ) for attribute in requirement.Attributes: compliance_row = ProwlerThreatScoreM365Model( Provider=finding.provider, @@ -57,8 +69,8 @@ class ProwlerThreatScoreM365(ComplianceOutput): Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, Requirements_Attributes_Weight=attribute.Weight, - Status=finding.status, - StatusExtended=finding.status_extended, + Status=row_status, + StatusExtended=row_status_extended, ResourceId=finding.resource_uid, ResourceName=finding.resource_name, CheckId=finding.check_id, diff --git a/prowler/lib/outputs/compliance/universal/__init__.py b/prowler/lib/outputs/compliance/universal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py new file mode 100644 index 0000000000..07c1f67b10 --- /dev/null +++ b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py @@ -0,0 +1,477 @@ +import json +import os +from datetime import datetime +from typing import TYPE_CHECKING, List + +from py_ocsf_models.events.base_event import SeverityID +from py_ocsf_models.events.base_event import StatusID as EventStatusID +from py_ocsf_models.events.findings.compliance_finding import ComplianceFinding +from py_ocsf_models.events.findings.compliance_finding_type_id import ( + ComplianceFindingTypeID, +) +from py_ocsf_models.events.findings.finding import ActivityID, FindingInformation +from py_ocsf_models.objects.check import Check +from py_ocsf_models.objects.compliance import Compliance +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID +from py_ocsf_models.objects.group import Group +from py_ocsf_models.objects.metadata import Metadata +from py_ocsf_models.objects.product import Product +from py_ocsf_models.objects.resource_details import ResourceDetails + +from prowler.config.config import prowler_version +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) +from prowler.lib.check.compliance_models import ComplianceFramework +from prowler.lib.logger import logger +from prowler.lib.outputs.utils import unroll_dict_to_list +from prowler.lib.utils.utils import open_file + +if TYPE_CHECKING: + from prowler.lib.outputs.finding import Finding + +PROWLER_TO_COMPLIANCE_STATUS = { + "PASS": ComplianceStatusID.Pass, + "FAIL": ComplianceStatusID.Fail, + "MANUAL": ComplianceStatusID.Unknown, +} + + +def _sanitize_resource_data(resource_details, resource_metadata) -> dict: + """Ensure resource data is JSON-serializable. + + Service resource_metadata may carry non-serializable objects (e.g. raw + Pydantic models or service classes such as ``Trail`` / ``LifecyclePolicy``). + Convert them to plain dicts and roundtrip through JSON so the resulting + ComplianceFinding can be serialized without errors. + """ + + def _make_serializable(obj): + if hasattr(obj, "model_dump") and callable(obj.model_dump): + return _make_serializable(obj.model_dump()) + if hasattr(obj, "dict") and callable(obj.dict): + return _make_serializable(obj.dict()) + if isinstance(obj, dict): + return {str(k): _make_serializable(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_make_serializable(v) for v in obj] + return obj + + try: + converted = _make_serializable(resource_metadata) + sanitized_metadata = json.loads(json.dumps(converted, default=str)) + except (TypeError, ValueError, RecursionError) as error: + logger.warning( + f"Failed to serialize resource metadata, defaulting to empty: {error}" + ) + sanitized_metadata = {} + return { + "details": resource_details, + "metadata": sanitized_metadata, + } + + +def _to_snake_case(name: str) -> str: + """Convert a PascalCase or camelCase string to snake_case.""" + import re + + # Insert underscore before uppercase letters preceded by lowercase + s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + # Insert underscore between consecutive uppercase and following lowercase + s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s) + return s.lower() + + +def _build_requirement_attrs(requirement, framework): + """Build the requirement attributes payload for the unmapped section. + + Keys are snake_cased and filtered by ``AttributeMetadata.output_formats.ocsf`` + when declared. MITRE-style attrs (``{"_raw_attributes": [...]}``) are + unwrapped into a list of per-entry dicts. + """ + requirement_attributes = requirement.attributes + if not requirement_attributes: + return {} + + metadata = framework.attributes_metadata + allowed_keys = ( + {entry.key for entry in metadata if entry.output_formats.ocsf} + if metadata + else None + ) + + def _to_snake_case_dict(entry: dict) -> dict: + return { + _to_snake_case(key): value + for key, value in entry.items() + if allowed_keys is None or key in allowed_keys + } + + if ( + isinstance(requirement_attributes, dict) + and "_raw_attributes" in requirement_attributes + ): + raw_entries = requirement_attributes.get("_raw_attributes") or [] + return [ + _to_snake_case_dict(entry) + for entry in raw_entries + if isinstance(entry, dict) + ] + + return _to_snake_case_dict(requirement_attributes) + + +class OCSFComplianceOutput: + """Produces OCSF ComplianceFinding (class_uid 2003) events from + universal compliance framework data. + + Each finding × requirement combination produces one ComplianceFinding event + with structured Compliance and Check objects. + """ + + def __init__( + self, + findings: list, + framework: ComplianceFramework, + file_path: str = None, + from_cli: bool = True, + provider: str = None, + ) -> None: + self._data = [] + self._file_descriptor = None + self.file_path = file_path + self._from_cli = from_cli + self._provider = provider + self.close_file = False + + if findings: + compliance_name = ( + framework.framework + "-" + framework.version + if framework.version + else framework.framework + ) + self._transform(findings, framework, compliance_name) + if not self._file_descriptor and file_path: + self._create_file_descriptor(file_path) + + @property + def data(self): + return self._data + + def _transform( + self, + findings: List["Finding"], + framework: ComplianceFramework, + compliance_name: str, + include_manual: bool = True, + ) -> None: + """Transform findings into OCSF ComplianceFinding events. + + Manual requirements are emitted only when ``include_manual=True``. The + caller must pass ``False`` for subsequent streaming batches so manual + events are not duplicated. + """ + # Build check -> requirements map + check_req_map = {} + for req in framework.requirements: + checks = req.checks + if self._provider: + all_checks = checks.get(self._provider.lower(), []) + else: + all_checks = [] + for check_list in checks.values(): + all_checks.extend(check_list) + for check_id in all_checks: + check_req_map.setdefault(check_id, []).append(req) + + # Scope constraints to this output's provider (e.g. an Azure constraint + # must not affect an AWS output). + requirement_config_status = build_requirement_config_status( + framework.requirements, provider_type=self._provider + ) + + for finding in findings: + if finding.check_id in check_req_map: + for req in check_req_map[finding.check_id]: + cf = self._build_compliance_finding( + finding, + framework, + req, + compliance_name, + requirement_config_status.get(req.id, (True, "")), + ) + if cf: + self._data.append(cf) + + if not include_manual: + return + + # Manual requirements (no checks or empty for current provider) + for req in framework.requirements: + checks = req.checks + if self._provider: + has_checks = bool(checks.get(self._provider.lower(), [])) + else: + has_checks = any(checks.values()) + + if not has_checks: + cf = self._build_manual_compliance_finding( + framework, req, compliance_name + ) + if cf: + self._data.append(cf) + + def _build_unmapped(self, finding, requirement, framework) -> dict: + """Build the unmapped dict with cloud info and requirement attributes.""" + unmapped = {} + + # Cloud info (from finding, when available) + if finding and getattr(finding, "provider", None) != "kubernetes": + unmapped["cloud"] = { + "provider": finding.provider, + "region": finding.region, + "account": { + "uid": finding.account_uid, + "name": finding.account_name, + }, + "org": { + "uid": finding.account_organization_uid, + "name": finding.account_organization_name, + }, + } + + # Requirement attributes + req_attrs = _build_requirement_attrs(requirement, framework) + if req_attrs: + unmapped["requirement_attributes"] = req_attrs + + return unmapped or None + + def _build_compliance_finding( + self, + finding: "Finding", + framework: ComplianceFramework, + requirement, + compliance_name: str, + config_status: tuple = (True, ""), + ) -> ComplianceFinding: + try: + effective_status, message = apply_config_status( + finding.status, finding.status_extended, config_status + ) + compliance_status = PROWLER_TO_COMPLIANCE_STATUS.get( + effective_status, ComplianceStatusID.Unknown + ) + check_status = PROWLER_TO_COMPLIANCE_STATUS.get( + finding.status, ComplianceStatusID.Unknown + ) + + finding_severity = getattr( + SeverityID, + finding.metadata.Severity.capitalize(), + SeverityID.Unknown, + ) + event_status = ( + EventStatusID.Suppressed if finding.muted else EventStatusID.New + ) + + time_value = ( + int(finding.timestamp.timestamp()) + if isinstance(finding.timestamp, datetime) + else finding.timestamp + ) + + cf = ComplianceFinding( + activity_id=ActivityID.Create.value, + activity_name=ActivityID.Create.name, + compliance=Compliance( + standards=[compliance_name], + requirements=[requirement.id], + control=requirement.description, + status_id=compliance_status, + # Nested Check preserves the raw check result. + checks=[ + Check( + uid=finding.check_id, + name=finding.metadata.CheckTitle, + desc=finding.metadata.Description, + status=finding.status, + status_id=check_status, + ) + ], + ), + finding_info=FindingInformation( + uid=f"{finding.uid}-{requirement.id}", + title=requirement.id, + desc=requirement.description, + created_time=time_value, + created_time_dt=( + finding.timestamp + if isinstance(finding.timestamp, datetime) + else None + ), + ), + message=message, + metadata=Metadata( + event_code=finding.check_id, + product=Product( + uid="prowler", + name="Prowler", + vendor_name="Prowler", + version=finding.prowler_version, + ), + profiles=( + ["cloud", "datetime"] + if finding.provider != "kubernetes" + else ["container", "datetime"] + ), + tenant_uid=finding.account_organization_uid, + ), + resources=[ + ResourceDetails( + labels=unroll_dict_to_list(finding.resource_tags), + name=finding.resource_name, + uid=finding.resource_uid, + group=Group(name=finding.metadata.ServiceName), + type=finding.metadata.ResourceType, + cloud_partition=( + finding.partition + if finding.provider != "kubernetes" + else None + ), + region=( + finding.region if finding.provider != "kubernetes" else None + ), + namespace=( + finding.region.replace("namespace: ", "") + if finding.provider == "kubernetes" + else None + ), + data=_sanitize_resource_data( + finding.resource_details, + finding.resource_metadata, + ), + ) + ], + severity_id=finding_severity.value, + severity=finding_severity.name, + status_id=event_status.value, + status=event_status.name, + # Effective status, so the top-level never contradicts the + # nested compliance status. + status_code=effective_status, + status_detail=message, + time=time_value, + time_dt=( + finding.timestamp + if isinstance(finding.timestamp, datetime) + else None + ), + type_uid=ComplianceFindingTypeID.Create, + type_name=f"Compliance Finding: {ComplianceFindingTypeID.Create.name}", + unmapped=self._build_unmapped(finding, requirement, framework), + ) + + return cf + except Exception as e: + logger.debug(f"Skipping OCSF compliance finding for {requirement.id}: {e}") + return None + + def _build_manual_compliance_finding( + self, + framework: ComplianceFramework, + requirement, + compliance_name: str, + ) -> ComplianceFinding: + try: + from prowler.config.config import timestamp as config_timestamp + + time_value = int(config_timestamp.timestamp()) + + return ComplianceFinding( + activity_id=ActivityID.Create.value, + activity_name=ActivityID.Create.name, + compliance=Compliance( + standards=[compliance_name], + requirements=[requirement.id], + control=requirement.description, + status_id=ComplianceStatusID.Unknown, + ), + finding_info=FindingInformation( + uid=f"manual-{requirement.id}", + title=requirement.id, + desc=requirement.description, + created_time=time_value, + ), + message="Manual check", + metadata=Metadata( + event_code="manual", + product=Product( + uid="prowler", + name="Prowler", + vendor_name="Prowler", + version=prowler_version, + ), + ), + severity_id=SeverityID.Informational.value, + severity=SeverityID.Informational.name, + status_id=EventStatusID.New.value, + status=EventStatusID.New.name, + status_code="MANUAL", + status_detail="Manual check", + time=time_value, + type_uid=ComplianceFindingTypeID.Create, + type_name=f"Compliance Finding: {ComplianceFindingTypeID.Create.name}", + unmapped=self._build_unmapped(None, requirement, framework), + ) + except Exception as e: + logger.debug( + f"Skipping manual OCSF compliance finding for {requirement.id}: {e}" + ) + return None + + def _create_file_descriptor(self, file_path: str) -> None: + try: + self._file_descriptor = open_file(file_path, "a") + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def batch_write_data_to_file(self) -> None: + """Write ComplianceFinding events to a JSON array file.""" + try: + if ( + getattr(self, "_file_descriptor", None) + and not self._file_descriptor.closed + and self._data + ): + if self._file_descriptor.tell() == 0: + self._file_descriptor.write("[") + for finding in self._data: + try: + if hasattr(finding, "model_dump_json"): + json_output = finding.model_dump_json( + exclude_none=True, indent=4 + ) + else: + json_output = finding.json(exclude_none=True, indent=4) + self._file_descriptor.write(json_output) + self._file_descriptor.write(",") + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if self.close_file or self._from_cli: + if self._file_descriptor.tell() != 1: + self._file_descriptor.seek( + self._file_descriptor.tell() - 1, os.SEEK_SET + ) + self._file_descriptor.truncate() + self._file_descriptor.write("]") + self._file_descriptor.close() + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py new file mode 100644 index 0000000000..59afd1fa31 --- /dev/null +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -0,0 +1,325 @@ +from csv import DictWriter +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from pydantic.v1 import create_model + +from prowler.config.config import timestamp +from prowler.lib.check.compliance_config_eval import ( + apply_config_status, + build_requirement_config_status, +) +from prowler.lib.check.compliance_models import ComplianceFramework +from prowler.lib.logger import logger +from prowler.lib.utils.utils import open_file + +if TYPE_CHECKING: + from prowler.lib.outputs.finding import Finding + +PROVIDER_HEADER_MAP = { + "aws": ("AccountId", "account_uid", "Region", "region"), + "azure": ("SubscriptionId", "account_uid", "Location", "region"), + "gcp": ("ProjectId", "account_uid", "Location", "region"), + "kubernetes": ("Context", "account_name", "Namespace", "region"), + "m365": ("TenantId", "account_uid", "Location", "region"), + "github": ("Account_Name", "account_name", "Account_Id", "account_uid"), + "oraclecloud": ("TenancyId", "account_uid", "Region", "region"), + "alibabacloud": ("AccountId", "account_uid", "Region", "region"), + "nhn": ("AccountId", "account_uid", "Region", "region"), +} +_DEFAULT_HEADERS = ("AccountId", "account_uid", "Region", "region") + + +class UniversalComplianceOutput: + """Universal compliance CSV output driven by ComplianceFramework metadata. + + Dynamically builds a Pydantic row model from attributes_metadata so that + CSV columns match the framework's declared attribute fields. + """ + + def __init__( + self, + findings: list, + framework: ComplianceFramework, + file_path: str = None, + from_cli: bool = True, + provider: str = None, + ) -> None: + self._data = [] + self._file_descriptor = None + self.file_path = file_path + self._from_cli = from_cli + self._provider = provider + self.close_file = False + + if file_path: + path_obj = Path(file_path) + self._file_extension = path_obj.suffix if path_obj.suffix else "" + + if findings: + self._row_model = self._build_row_model(framework) + compliance_name = ( + framework.framework + "-" + framework.version + if framework.version + else framework.framework + ) + self._transform(findings, framework, compliance_name) + if not self._file_descriptor and file_path: + self._create_file_descriptor(file_path) + + @property + def data(self): + return self._data + + def _build_row_model(self, framework: ComplianceFramework): + """Build a dynamic Pydantic model from attributes_metadata.""" + acct_header, acct_field, loc_header, loc_field = PROVIDER_HEADER_MAP.get( + (self._provider or "").lower(), _DEFAULT_HEADERS + ) + self._acct_header = acct_header + self._acct_field = acct_field + self._loc_header = loc_header + self._loc_field = loc_field + + # Base fields present in every compliance CSV + fields = { + "Provider": (str, ...), + "Description": (str, ...), + acct_header: (str, ...), + loc_header: (str, ...), + "AssessmentDate": (str, ...), + "Requirements_Id": (str, ...), + "Requirements_Description": (str, ...), + } + + # Dynamic attribute columns from metadata + if framework.attributes_metadata: + for attr_meta in framework.attributes_metadata: + if not attr_meta.output_formats.csv: + continue + field_name = f"Requirements_Attributes_{attr_meta.key}" + # Map type strings to Python types + type_map = { + "str": Optional[str], + "int": Optional[int], + "float": Optional[float], + "bool": Optional[bool], + "list_str": Optional[str], # Serialized as joined string + "list_dict": Optional[str], # Serialized as string + } + py_type = type_map.get(attr_meta.type, Optional[str]) + fields[field_name] = (py_type, None) + + # Check if any requirement has MITRE fields + has_mitre = any(req.tactics for req in framework.requirements if req.tactics) + if has_mitre: + fields["Requirements_Tactics"] = (Optional[str], None) + fields["Requirements_SubTechniques"] = (Optional[str], None) + fields["Requirements_Platforms"] = (Optional[str], None) + fields["Requirements_TechniqueURL"] = (Optional[str], None) + + # Trailing fields + fields["Status"] = (str, ...) + fields["StatusExtended"] = (str, ...) + fields["ResourceId"] = (str, ...) + fields["ResourceName"] = (str, ...) + fields["CheckId"] = (str, ...) + fields["Muted"] = (bool, ...) + fields["Framework"] = (str, ...) + fields["Name"] = (str, ...) + + return create_model("UniversalComplianceRow", **fields) + + def _serialize_attr_value(self, value): + """Serialize attribute values for CSV.""" + if isinstance(value, list): + if value and isinstance(value[0], dict): + return str(value) + return " | ".join(str(v) for v in value) + return value + + def _build_row( + self, finding, framework, requirement, is_manual=False, config_status=None + ): + """Build a single row dict for a finding + requirement combination.""" + row = { + "Provider": ( + finding.provider + if not is_manual + else (framework.provider or self._provider or "").lower() + ), + "Description": framework.description, + self._acct_header: ( + getattr(finding, self._acct_field, "") if not is_manual else "" + ), + self._loc_header: ( + getattr(finding, self._loc_field, "") if not is_manual else "" + ), + "AssessmentDate": str(timestamp), + "Requirements_Id": requirement.id, + "Requirements_Description": requirement.description, + } + + # Add dynamic attribute columns + if framework.attributes_metadata: + for attr_meta in framework.attributes_metadata: + if not attr_meta.output_formats.csv: + continue + field_name = f"Requirements_Attributes_{attr_meta.key}" + raw_val = requirement.attributes.get(attr_meta.key) + row[field_name] = ( + self._serialize_attr_value(raw_val) if raw_val is not None else None + ) + + # MITRE fields + if requirement.tactics: + row["Requirements_Tactics"] = ( + " | ".join(requirement.tactics) if requirement.tactics else None + ) + row["Requirements_SubTechniques"] = ( + " | ".join(requirement.sub_techniques) + if requirement.sub_techniques + else None + ) + row["Requirements_Platforms"] = ( + " | ".join(requirement.platforms) if requirement.platforms else None + ) + row["Requirements_TechniqueURL"] = requirement.technique_url + + if is_manual: + row["Status"] = "MANUAL" + row["StatusExtended"] = "Manual check" + else: + # Config-invalid PASS reports as FAIL, matching OCSF/table outputs. + row["Status"], row["StatusExtended"] = apply_config_status( + finding.status, finding.status_extended, config_status + ) + row["ResourceId"] = finding.resource_uid if not is_manual else "manual_check" + row["ResourceName"] = finding.resource_name if not is_manual else "Manual check" + row["CheckId"] = finding.check_id if not is_manual else "manual" + row["Muted"] = finding.muted if not is_manual else False + row["Framework"] = framework.framework + row["Name"] = framework.name + + return row + + def _transform( + self, + findings: list["Finding"], + framework: ComplianceFramework, + compliance_name: str, + include_manual: bool = True, + ) -> None: + """Transform findings into universal compliance CSV rows. + + Manual requirements (no checks or empty for current provider) are + emitted only when ``include_manual=True``. When the writer is reused + across streaming batches, the caller should pass ``False`` after the + first batch so manual rows are not duplicated. + """ + # Build check -> requirements map (filtered by provider for dict checks) + check_req_map = {} + for req in framework.requirements: + checks = req.checks + if self._provider: + all_checks = checks.get(self._provider.lower(), []) + else: + all_checks = [] + for check_list in checks.values(): + all_checks.extend(check_list) + for check_id in all_checks: + if check_id not in check_req_map: + check_req_map[check_id] = [] + check_req_map[check_id].append(req) + + # Scope constraints to this output's provider (e.g. an Azure constraint + # must not affect an AWS output). + requirement_config_status = build_requirement_config_status( + framework.requirements, provider_type=self._provider + ) + + # Process findings using the provider-filtered check_req_map. + # This ensures that for multi-provider dict checks, only the checks + # belonging to the current provider produce output rows. + for finding in findings: + check_id = finding.check_id + if check_id in check_req_map: + for req in check_req_map[check_id]: + row = self._build_row( + finding, + framework, + req, + config_status=requirement_config_status.get(req.id), + ) + try: + self._data.append(self._row_model(**row)) + except Exception as e: + logger.debug(f"Skipping row for {req.id}: {e}") + + if not include_manual: + return + + # Manual requirements (no checks or empty dict) + for req in framework.requirements: + checks = req.checks + if self._provider: + has_checks = bool(checks.get(self._provider.lower(), [])) + else: + has_checks = any(checks.values()) + + if not has_checks: + # Use a dummy finding-like namespace for manual rows + row = self._build_row( + _ManualFindingStub(), framework, req, is_manual=True + ) + try: + self._data.append(self._row_model(**row)) + except Exception as e: + logger.debug(f"Skipping manual row for {req.id}: {e}") + + def _create_file_descriptor(self, file_path: str) -> None: + try: + self._file_descriptor = open_file(file_path, "a") + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def batch_write_data_to_file(self) -> None: + """Write findings data to CSV.""" + try: + if ( + getattr(self, "_file_descriptor", None) + and not self._file_descriptor.closed + and self._data + ): + csv_writer = DictWriter( + self._file_descriptor, + fieldnames=[field.upper() for field in self._data[0].dict().keys()], + delimiter=";", + ) + if self._file_descriptor.tell() == 0: + csv_writer.writeheader() + for row in self._data: + csv_writer.writerow({k.upper(): v for k, v in row.dict().items()}) + if self.close_file or self._from_cli: + self._file_descriptor.close() + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class _ManualFindingStub: + """Minimal stub to satisfy _build_row for manual requirements.""" + + provider = "" + account_uid = "" + account_name = "" + region = "" + status = "MANUAL" + status_extended = "Manual check" + resource_uid = "manual_check" + resource_name = "Manual check" + check_id = "manual" + muted = False diff --git a/prowler/lib/outputs/compliance/universal/universal_table.py b/prowler/lib/outputs/compliance/universal/universal_table.py new file mode 100644 index 0000000000..5f4a6cf88b --- /dev/null +++ b/prowler/lib/outputs/compliance/universal/universal_table.py @@ -0,0 +1,544 @@ +from colorama import Fore, Style +from tabulate import tabulate + +from prowler.config.config import orange_color +from prowler.lib.check.compliance_config_eval import ( + accumulate_group_status, + accumulate_overview_status, + get_effective_status, + get_scan_audit_config, + resolve_requirement_config_status, +) +from prowler.lib.check.compliance_models import ComplianceFramework + + +def get_universal_table( + findings: list, + bulk_checks_metadata: dict, + compliance_framework_name: str, + output_filename: str, + output_directory: str, + compliance_overview: bool, + framework: ComplianceFramework = None, + provider: str = None, + output_formats: list = None, +) -> None: + """Render a compliance console table driven by TableConfig. + + Supports 3 modes: + - Grouped: group_by only (generic, C5, CSA, ISO, KISA) + - Split: group_by + split_by (CIS Level 1/2, ENS alto/medio/bajo/opcional) + - Scored: group_by + scoring (ThreatScore weighted risk %) + + When ``provider`` is given and ``checks`` is a multi-provider dict, + only the checks for that provider are matched against findings. + """ + if framework is None or not framework.outputs or not framework.outputs.table_config: + return + + tc = framework.outputs.table_config + labels = tc.labels or _default_labels() + + group_by = tc.group_by + split_by = tc.split_by + scoring = tc.scoring + + if scoring: + _render_scored( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + scoring, + labels, + provider, + output_formats=output_formats, + ) + elif split_by: + _render_split( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + split_by, + labels, + provider, + output_formats=output_formats, + ) + else: + _render_grouped( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + labels, + provider, + output_formats=output_formats, + ) + + +def _default_labels(): + """Return a simple namespace with default labels.""" + from prowler.lib.check.compliance_models import TableLabels + + return TableLabels() + + +def _build_requirement_check_map(framework, provider=None): + """Build a map of check_id -> list of requirements for fast lookup. + + When *provider* is given, only the checks for that provider are included. + """ + check_map = {} + for req in framework.requirements: + checks = req.checks + if provider: + all_checks = checks.get(provider.lower(), []) + else: + all_checks = [] + for check_list in checks.values(): + all_checks.extend(check_list) + for check_id in all_checks: + if check_id not in check_map: + check_map[check_id] = [] + check_map[check_id].append(req) + return check_map + + +def _get_group_key(req, group_by): + """Extract the group key from a requirement.""" + if group_by == "_Tactics": + return req.tactics or [] + return [req.attributes.get(group_by, "Unknown")] + + +def _print_overview(pass_count, fail_count, muted_count, framework_name, labels): + """Print the overview pass/fail/muted summary.""" + total = len(fail_count) + len(pass_count) + len(muted_count) + if total < 2: + return False + + title = ( + labels.title + or f"Compliance Status of {Fore.YELLOW}{framework_name.upper()}{Style.RESET_ALL} Framework:" + ) + print(f"\n{title}") + + fail_pct = round(len(fail_count) / total * 100, 2) + pass_pct = round(len(pass_count) / total * 100, 2) + muted_pct = round(len(muted_count) / total * 100, 2) + + fail_label = labels.fail_label + pass_label = labels.pass_label + + overview_table = [ + [ + f"{Fore.RED}{fail_pct}% ({len(fail_count)}) {fail_label}{Style.RESET_ALL}", + f"{Fore.GREEN}{pass_pct}% ({len(pass_count)}) {pass_label}{Style.RESET_ALL}", + f"{orange_color}{muted_pct}% ({len(muted_count)}) MUTED{Style.RESET_ALL}", + ] + ] + print(tabulate(overview_table, tablefmt="rounded_grid")) + return True + + +def _render_grouped( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + labels, + provider=None, + output_formats=None, +): + """Grouped mode: one row per group with pass/fail counts.""" + check_map = _build_requirement_check_map(framework, provider) + groups = {} + group_seen = {} + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} + + for index, finding in enumerate(findings): + check_id = finding.check_metadata.CheckID + if check_id not in check_map: + continue + + for req in check_map[check_id]: + effective_status = get_effective_status( + finding.status, + resolve_requirement_config_status( + req, audit_config, config_status_cache, provider_type=provider + ), + ) + for group_key in _get_group_key(req, group_by): + if group_key not in groups: + groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = {} + + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, groups[group_key], group_seen[group_key] + ) + + if not _print_overview( + pass_count, fail_count, muted_count, compliance_framework_name, labels + ): + return + + if not compliance_overview: + provider_header = labels.provider_header + group_header = labels.group_header or group_by + table = { + provider_header: [], + group_header: [], + labels.status_header: [], + "Muted": [], + } + for group_key in sorted(groups): + table[provider_header].append( + framework.provider or (provider.upper() if provider else "") + ) + table[group_header].append(group_key) + if groups[group_key]["FAIL"] > 0: + table[labels.status_header].append( + f"{Fore.RED}{labels.fail_label}({groups[group_key]['FAIL']}){Style.RESET_ALL}" + ) + else: + table[labels.status_header].append( + f"{Fore.GREEN}{labels.pass_label}({groups[group_key]['PASS']}){Style.RESET_ALL}" + ) + table["Muted"].append( + f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}" + ) + + results_title = ( + labels.results_title + or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:" + ) + print(f"\n{results_title}") + print(tabulate(table, headers="keys", tablefmt="rounded_grid")) + footer = labels.footer_note or "* Only sections containing results appear." + print(f"{Style.BRIGHT}{footer}{Style.RESET_ALL}") + print(f"\nDetailed results of {compliance_framework_name.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv" + ) + if "json-ocsf" in (output_formats or []): + print( + f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json" + ) + print() + + +def _render_split( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + split_by, + labels, + provider=None, + output_formats=None, +): + """Split mode: one row per group with columns for each split value (e.g. Level 1/Level 2).""" + check_map = _build_requirement_check_map(framework, provider) + split_field = split_by.field + split_values = split_by.values + groups = {} + group_muted_seen = {} + group_split_seen = {} + pass_count = set() + fail_count = set() + muted_count = set() + audit_config = get_scan_audit_config() + config_status_cache = {} + + for index, finding in enumerate(findings): + check_id = finding.check_metadata.CheckID + if check_id not in check_map: + continue + + for req in check_map[check_id]: + effective_status = get_effective_status( + finding.status, + resolve_requirement_config_status( + req, audit_config, config_status_cache, provider_type=provider + ), + ) + for group_key in _get_group_key(req, group_by): + if group_key not in groups: + groups[group_key] = { + sv: {"FAIL": 0, "PASS": 0} for sv in split_values + } + groups[group_key]["Muted"] = 0 + group_muted_seen[group_key] = set() + group_split_seen[group_key] = {sv: {} for sv in split_values} + + split_val = req.attributes.get(split_field, "") + + if finding.muted: + # Overview total: count each finding once per framework + muted_count.add(index) + # Per-group Muted: count each finding once per group it + # belongs to (a finding can map to several groups). + if index not in group_muted_seen[group_key]: + group_muted_seen[group_key].add(index) + groups[group_key]["Muted"] += 1 + else: + if effective_status == "FAIL": + fail_count.add(index) + pass_count.discard(index) + elif effective_status == "PASS" and index not in fail_count: + pass_count.add(index) + + for sv in split_values: + if sv in str(split_val): + accumulate_group_status( + index, + effective_status, + groups[group_key][sv], + group_split_seen[group_key][sv], + ) + + if not _print_overview( + pass_count, fail_count, muted_count, compliance_framework_name, labels + ): + return + + if not compliance_overview: + provider_header = labels.provider_header + group_header = labels.group_header or group_by + table = {provider_header: [], group_header: []} + for sv in split_values: + table[sv] = [] + table["Muted"] = [] + + for group_key in sorted(groups): + table[provider_header].append( + framework.provider or (provider.upper() if provider else "") + ) + table[group_header].append(group_key) + for sv in split_values: + if groups[group_key][sv]["FAIL"] > 0: + table[sv].append( + f"{Fore.RED}{labels.fail_label}({groups[group_key][sv]['FAIL']}){Style.RESET_ALL}" + ) + else: + table[sv].append( + f"{Fore.GREEN}{labels.pass_label}({groups[group_key][sv]['PASS']}){Style.RESET_ALL}" + ) + table["Muted"].append( + f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}" + ) + + results_title = ( + labels.results_title + or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:" + ) + print(f"\n{results_title}") + print(tabulate(table, headers="keys", tablefmt="rounded_grid")) + footer = labels.footer_note or "* Only sections containing results appear." + print(f"{Style.BRIGHT}{footer}{Style.RESET_ALL}") + print(f"\nDetailed results of {compliance_framework_name.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv" + ) + if "json-ocsf" in (output_formats or []): + print( + f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json" + ) + print() + + +def _render_scored( + findings, + bulk_checks_metadata, + compliance_framework_name, + output_filename, + output_directory, + compliance_overview, + framework, + group_by, + scoring, + labels, + provider=None, + output_formats=None, +): + """Scored mode: weighted risk scoring per group (e.g. ThreatScore).""" + check_map = _build_requirement_check_map(framework, provider) + risk_field = scoring.risk_field + weight_field = scoring.weight_field + groups = {} + group_seen = {} + pass_count = set() + fail_count = set() + muted_count = set() + + score_per_group = {} + max_score_per_group = {} + counted_per_group = {} + generic_score = 0 + max_generic_score = 0 + counted_generic = {} + audit_config = get_scan_audit_config() + config_status_cache = {} + + for index, finding in enumerate(findings): + check_id = finding.check_metadata.CheckID + if check_id not in check_map: + continue + + for req in check_map[check_id]: + effective_status = get_effective_status( + finding.status, + resolve_requirement_config_status( + req, audit_config, config_status_cache, provider_type=provider + ), + ) + for group_key in _get_group_key(req, group_by): + attrs = req.attributes + risk = attrs.get(risk_field, 0) + weight = attrs.get(weight_field, 0) + + if group_key not in groups: + groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = {} + score_per_group[group_key] = 0 + max_score_per_group[group_key] = 0 + counted_per_group[group_key] = {} + + # Revoke an earlier PASS score if a later requirement FAILs. + if not finding.muted: + contribution = risk * weight + counted = counted_per_group[group_key] + if index not in counted: + max_score_per_group[group_key] += contribution + if effective_status == "PASS": + score_per_group[group_key] += contribution + counted[index] = contribution + else: + counted[index] = 0 + elif effective_status == "FAIL" and counted[index]: + score_per_group[group_key] -= counted[index] + counted[index] = 0 + + status = "Muted" if finding.muted else effective_status + accumulate_overview_status( + index, status, pass_count, fail_count, muted_count + ) + accumulate_group_status( + index, status, groups[group_key], group_seen[group_key] + ) + + # Generic score, with the same PASS-revocation on FAIL. + if not finding.muted: + contribution = risk * weight + if index not in counted_generic: + max_generic_score += contribution + if effective_status == "PASS": + generic_score += contribution + counted_generic[index] = contribution + else: + counted_generic[index] = 0 + elif effective_status == "FAIL" and counted_generic[index]: + generic_score -= counted_generic[index] + counted_generic[index] = 0 + + if not _print_overview( + pass_count, fail_count, muted_count, compliance_framework_name, labels + ): + return + + if not compliance_overview: + provider_header = labels.provider_header + group_header = labels.group_header or group_by + table = { + provider_header: [], + group_header: [], + labels.status_header: [], + "Score": [], + "Muted": [], + } + + for group_key in sorted(groups): + table[provider_header].append( + framework.provider or (provider.upper() if provider else "") + ) + table[group_header].append(group_key) + if max_score_per_group[group_key] == 0: + group_score = 100.0 + score_color = Fore.GREEN + else: + group_score = ( + score_per_group[group_key] / max_score_per_group[group_key] + ) * 100 + score_color = Fore.RED + table["Score"].append( + f"{Style.BRIGHT}{score_color}{group_score:.2f}%{Style.RESET_ALL}" + ) + if groups[group_key]["FAIL"] > 0: + table[labels.status_header].append( + f"{Fore.RED}{labels.fail_label}({groups[group_key]['FAIL']}){Style.RESET_ALL}" + ) + else: + table[labels.status_header].append( + f"{Fore.GREEN}{labels.pass_label}({groups[group_key]['PASS']}){Style.RESET_ALL}" + ) + table["Muted"].append( + f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}" + ) + + if max_generic_score == 0: + generic_threat_score = 100.0 + else: + generic_threat_score = generic_score / max_generic_score * 100 + + results_title = ( + labels.results_title + or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:" + ) + print(f"\n{results_title}") + print(f"\nGeneric Threat Score: {generic_threat_score:.2f}%") + print(tabulate(table, headers="keys", tablefmt="rounded_grid")) + footer = labels.footer_note or ( + f"{Style.BRIGHT}\n=== Threat Score Guide ===\n" + f"The lower the score, the higher the risk.{Style.RESET_ALL}\n" + f"{Style.BRIGHT}(Only sections containing results appear, the score is calculated as the sum of the " + f"level of risk * weight of the passed findings divided by the sum of the risk * weight of all the findings){Style.RESET_ALL}" + ) + print(footer) + print(f"\nDetailed results of {compliance_framework_name.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv" + ) + if "json-ocsf" in (output_formats or []): + print( + f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json" + ) + print() diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 26fbe327ee..ed8bda4039 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -15,7 +15,7 @@ from prowler.lib.check.models import ( ) from prowler.lib.logger import logger from prowler.lib.outputs.common import Status, fill_common_finding_data -from prowler.lib.outputs.compliance.compliance import get_check_compliance +from prowler.lib.outputs.compliance.compliance_check import get_check_compliance from prowler.lib.outputs.utils import unroll_tags from prowler.lib.utils.utils import dict_to_lowercase, get_nested_attribute from prowler.providers.common.provider import Provider @@ -187,9 +187,11 @@ class Finding(BaseModel): output_data["account_uid"] = ( output_data["account_organization_uid"] if "Tenant:" in check_output.subscription - else provider.identity.subscriptions[check_output.subscription] + else check_output.subscription + ) + output_data["account_name"] = provider.identity.subscriptions.get( + check_output.subscription, check_output.subscription ) - output_data["account_name"] = check_output.subscription output_data["resource_name"] = check_output.resource_name output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.location @@ -245,15 +247,16 @@ class Finding(BaseModel): elif provider.type == "kubernetes": if provider.identity.context == "In-Cluster": output_data["auth_method"] = "in-cluster" + output_data["provider_uid"] = provider.identity.cluster else: output_data["auth_method"] = "kubeconfig" + output_data["provider_uid"] = provider.identity.context output_data["resource_name"] = check_output.resource_name output_data["resource_uid"] = check_output.resource_id output_data["account_name"] = f"context: {provider.identity.context}" output_data["account_uid"] = get_nested_attribute( provider, "identity.cluster" ) - output_data["provider_uid"] = provider.identity.context output_data["region"] = f"namespace: {check_output.namespace}" elif provider.type == "github": @@ -339,6 +342,20 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.location + elif provider.type == "stackit": + output_data["auth_method"] = getattr( + provider, "auth_method", "api_token" + ) + output_data["account_uid"] = get_nested_attribute( + provider, "identity.project_id" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.project_name" + ) + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.location + elif provider.type == "iac": output_data["auth_method"] = provider.auth_method provider_uid = getattr(provider, "provider_uid", None) @@ -354,6 +371,9 @@ class Finding(BaseModel): check_output, "resource_line_range", "" ) output_data["framework"] = check_output.check_metadata.ServiceName + output_data["raw"] = { + "resource_line_range": output_data.get("resource_line_range", ""), + } elif provider.type == "llm": output_data["auth_method"] = provider.auth_method @@ -421,6 +441,51 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = "global" + elif provider.type == "okta": + output_data["auth_method"] = provider.auth_method + output_data["account_uid"] = get_nested_attribute( + provider, "identity.org_domain" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.org_domain" + ) + output_data["account_organization_uid"] = get_nested_attribute( + provider, "identity.client_id" + ) + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = "global" + + elif provider.type == "scaleway": + output_data["auth_method"] = "api_key" + output_data["account_uid"] = get_nested_attribute( + provider, "identity.organization_id" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.bearer_email" + ) or get_nested_attribute(provider, "identity.organization_id") + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.region + + elif provider.type == "linode": + output_data["auth_method"] = "api_token" + # account_uid is a required string, but the account ID may be + # unavailable when the token lacks account:read_only scope. Fall + # back to the username/email so findings are never dropped. + output_data["account_uid"] = ( + get_nested_attribute(provider, "identity.account_id") + or get_nested_attribute(provider, "identity.username") + or get_nested_attribute(provider, "identity.email") + or "linode" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.username" + ) or get_nested_attribute(provider, "identity.email") + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.region + elif provider.type == "alibabacloud": output_data["auth_method"] = get_nested_attribute( provider, "identity.identity_arn" @@ -470,6 +535,11 @@ class Finding(BaseModel): check_output, "fixed_version", "" ) + else: + # Dynamic fallback: any external/custom provider + provider_data = provider.get_finding_output_data(check_output) + output_data.update(provider_data) + # check_output Unique ID # TODO: move this to a function # TODO: in Azure, GCP and K8s there are findings without resource_name @@ -543,6 +613,8 @@ class Finding(BaseModel): finding.subscription = list(provider.identity.subscriptions.keys())[0] elif provider.type == "gcp": finding.project_id = list(provider.projects.keys())[0] + elif provider.type == "stackit": + finding.project_id = provider.identity.project_id elif provider.type == "iac": # For IaC, we don't have resource_line_range in the Finding model # It would need to be extracted from the resource metadata if needed diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index d547ea3b95..18dd4b0f3d 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -73,8 +73,7 @@ class HTML(Output): elif finding.status == "FAIL": row_class = "table-danger" - self._data.append( - f""" + self._data.append(f""" {finding_status} {finding.metadata.Severity.value} @@ -89,8 +88,7 @@ class HTML(Output):

{HTML.process_markdown(finding.metadata.Remediation.Recommendation.Text)}

{parse_html_string(unroll_dict(finding.compliance, separator=": "))}

- """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -143,8 +141,7 @@ class HTML(Output): from_cli (bool): whether the request is from the CLI or not """ try: - file_descriptor.write( - f""" + file_descriptor.write(f""" @@ -253,8 +250,7 @@ class HTML(Output): Compliance - """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -269,8 +265,7 @@ class HTML(Output): file_descriptor (file): the file descriptor to write the footer """ try: - file_descriptor.write( - """ + file_descriptor.write(""" @@ -409,8 +404,7 @@ class HTML(Output): -""" - ) +""") except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -492,8 +486,11 @@ class HTML(Output): """ try: printed_subscriptions = [] - for key, value in provider.identity.subscriptions.items(): - intermediate = f"{key} : {value}" + for ( + subscription_id, + display_name, + ) in provider.identity.subscriptions.items(): + intermediate = f"{display_name} : {subscription_id}" printed_subscriptions.append(intermediate) # check if identity is str(coming from SP) or dict(coming from browser or) @@ -1073,6 +1070,73 @@ class HTML(Output): ) return "" + @staticmethod + def get_stackit_assessment_summary(provider: Provider) -> str: + """ + get_stackit_assessment_summary gets the HTML assessment summary for the StackIT provider + + Args: + provider (Provider): the StackIT provider object + + Returns: + str: HTML assessment summary for the StackIT provider + """ + try: + project_id = getattr(provider.identity, "project_id", "unknown") + project_name = getattr(provider.identity, "project_name", "") + audited_regions = getattr(provider.identity, "audited_regions", set()) + + project_name_item = ( + f""" +
  • + Project Name: {project_name} +
  • """ + if project_name + else "" + ) + + regions_item = ( + f""" +
  • + Regions: {", ".join(sorted(audited_regions))} +
  • """ + if audited_regions + else "" + ) + + return f""" +
    +
    +
    + StackIT Assessment Summary +
    +
      +
    • + Project ID: {project_id} +
    • + {project_name_item} + {regions_item} +
    +
    +
    +
    +
    +
    + StackIT Credentials +
    +
      +
    • + Authentication Type: Service Account Key +
    • +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_cloudflare_assessment_summary(provider: Provider) -> str: """ @@ -1397,6 +1461,184 @@ class HTML(Output): ) return "" + @staticmethod + def get_okta_assessment_summary(provider: Provider) -> str: + """ + get_okta_assessment_summary gets the HTML assessment summary for the Okta provider + + Args: + provider (Provider): the Okta provider object + + Returns: + str: HTML assessment summary for the Okta provider + """ + try: + assessment_items = f""" +
  • + Okta Domain: {provider.identity.org_domain} +
  • """ + + credentials_items = f""" +
  • + Authentication: {provider.auth_method} +
  • +
  • + Client ID: {provider.identity.client_id} +
  • """ + + return f""" +
    +
    +
    + Okta Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Okta Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + + @staticmethod + def get_scaleway_assessment_summary(provider: Provider) -> str: + """ + get_scaleway_assessment_summary gets the HTML assessment summary for the Scaleway provider + + Args: + provider (Provider): the Scaleway provider object + + Returns: + str: HTML assessment summary for the Scaleway provider + """ + try: + assessment_items = f""" +
  • + Organization ID: {provider.identity.organization_id} +
  • """ + + credentials_items = """ +
  • + Authentication: API Key +
  • """ + + access_key = getattr(provider.session, "access_key", None) + if access_key: + credentials_items += f""" +
  • + Access Key: {access_key} +
  • """ + + bearer_type = getattr(provider.identity, "bearer_type", None) + bearer_email = getattr(provider.identity, "bearer_email", None) + bearer_id = getattr(provider.identity, "bearer_id", None) + if bearer_type: + bearer_label = bearer_email or bearer_id or "-" + credentials_items += f""" +
  • + Bearer: {bearer_type} ({bearer_label}) +
  • """ + + region = getattr(provider.session, "default_region", None) + if region: + credentials_items += f""" +
  • + Default Region: {region} +
  • """ + + return f""" +
    +
    +
    + Scaleway Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Scaleway Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + + @staticmethod + def get_linode_assessment_summary(provider: Provider) -> str: + """ + get_linode_assessment_summary gets the HTML assessment summary for the Linode provider + + Args: + provider (Provider): the Linode provider object + + Returns: + str: HTML assessment summary for the Linode provider + """ + try: + username = getattr(provider.identity, "username", None) or "-" + email = getattr(provider.identity, "email", None) or "-" + account_id = getattr(provider.identity, "account_id", None) or "-" + + assessment_items = f""" +
  • + Account ID: {account_id} +
  • +
  • + Username: {username} +
  • +
  • + Email: {email} +
  • """ + + credentials_items = """ +
  • + Authentication: API Token +
  • """ + + return f""" +
    +
    +
    + Linode Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Linode Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_assessment_summary(provider: Provider) -> str: """ @@ -1417,11 +1659,13 @@ class HTML(Output): # Azure_provider --> azure # Kubernetes_provider --> kubernetes - # Dynamically get the Provider quick inventory handler - provider_html_assessment_summary_function = ( - f"get_{provider.type}_assessment_summary" - ) - return getattr(HTML, provider_html_assessment_summary_function)(provider) + # Try static method first, fall back to provider method + method_name = f"get_{provider.type}_assessment_summary" + if hasattr(HTML, method_name): + return getattr(HTML, method_name)(provider) + else: + # Dynamic fallback: any external/custom provider + return provider.get_html_assessment_summary() except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index ed8f7faab0..9005b5274e 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -229,7 +229,9 @@ class MarkdownToADFConverter: return node def _paragraph_with_text(self, text: str) -> Dict: - return {"type": "paragraph", "content": [self._create_text_node(text, None)]} + # ADF forbids empty text nodes; emit an empty paragraph instead. + content = [self._create_text_node(text, None)] if text else [] + return {"type": "paragraph", "content": content} @staticmethod def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None: @@ -339,6 +341,7 @@ class Jira: } TOKEN_URL = "https://auth.atlassian.com/oauth/token" API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" + REQUEST_TIMEOUT = 90 HEADER_TEMPLATE = { "Content-Type": "application/json", "X-Force-Accept-Language": "true", @@ -576,7 +579,12 @@ class Jira: } headers = self.get_headers(content_type_json=True) - response = requests.post(self.TOKEN_URL, json=body, headers=headers) + response = requests.post( + self.TOKEN_URL, + json=body, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: tokens = response.json() @@ -628,12 +636,17 @@ class Jira: response = requests.get( f"https://{domain}.atlassian.net/_edge/tenant_info", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) response = response.json() return response.get("cloudId") else: headers = self.get_headers(access_token) - response = requests.get(self.API_TOKEN_URL, headers=headers) + response = requests.get( + self.API_TOKEN_URL, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: resources = response.json() @@ -715,7 +728,12 @@ class Jira: } headers = self.get_headers(content_type_json=True) - response = requests.post(url, json=body, headers=headers) + response = requests.post( + url, + json=body, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: tokens = response.json() @@ -872,6 +890,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: @@ -939,6 +958,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project_key}&expand=projects.issuetypes.fields", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: @@ -984,6 +1004,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: projects_data = {} @@ -999,6 +1020,7 @@ class Jira: project_response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project['key']}&expand=projects.issuetypes.fields", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if project_response.status_code == 200: project_metadata = project_response.json() @@ -1118,6 +1140,18 @@ class Jira: tenant_info: str = "", ) -> dict: + # ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT. + def _safe(value: str) -> str: + return value if (value and value.strip()) else "-" + + check_id = _safe(check_id) + check_title = _safe(check_title) + status_extended = _safe(status_extended) + provider = _safe(provider) + region = _safe(region) + resource_uid = _safe(resource_uid) + resource_name = _safe(resource_name) + table_rows = [ { "type": "tableRow", @@ -1909,6 +1943,7 @@ class Jira: f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", json=payload, headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code != 201: @@ -2113,6 +2148,7 @@ class Jira: f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", json=payload, headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code != 201: diff --git a/prowler/lib/outputs/ocsf/ocsf.py b/prowler/lib/outputs/ocsf/ocsf.py index 731d029284..53f27d0e1b 100644 --- a/prowler/lib/outputs/ocsf/ocsf.py +++ b/prowler/lib/outputs/ocsf/ocsf.py @@ -227,6 +227,10 @@ class OCSF(Output): json_output = finding.json(exclude_none=True, indent=4) self._file_descriptor.write(json_output) self._file_descriptor.write(",") + except OSError: + # I/O errors (e.g. ENOSPC) are not recoverable per finding: + # fail fast instead of logging once per finding. + raise except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -239,6 +243,10 @@ class OCSF(Output): self._file_descriptor.truncate() self._file_descriptor.write("]") self._file_descriptor.close() + except OSError: + # Propagate unrecoverable I/O errors (e.g. ENOSPC) so the caller can + # fail fast instead of producing a corrupt output file. + raise except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index 1e2f6f2058..40dc4635ba 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -7,39 +7,54 @@ from prowler.lib.outputs.common import Status from prowler.lib.outputs.finding import Finding -def stdout_report(finding, color, verbose, status, fix): +def stdout_report(finding, color, verbose, status, fix, provider=None): if finding.check_metadata.Provider == "aws": details = finding.region - if finding.check_metadata.Provider == "azure": + elif finding.check_metadata.Provider == "azure": details = finding.location - if finding.check_metadata.Provider == "gcp": + elif finding.check_metadata.Provider == "gcp": details = finding.location.lower() - if finding.check_metadata.Provider == "kubernetes": + elif finding.check_metadata.Provider == "kubernetes": details = finding.namespace.lower() - if finding.check_metadata.Provider == "github": + elif finding.check_metadata.Provider == "github": details = finding.owner - if finding.check_metadata.Provider == "m365": + elif finding.check_metadata.Provider == "m365": details = finding.location - if finding.check_metadata.Provider == "mongodbatlas": + elif finding.check_metadata.Provider == "mongodbatlas": details = finding.location - if finding.check_metadata.Provider == "nhn": + elif finding.check_metadata.Provider == "nhn": details = finding.location - if finding.check_metadata.Provider == "llm": + elif finding.check_metadata.Provider == "stackit": + details = finding.location + elif finding.check_metadata.Provider == "llm": details = finding.check_metadata.CheckID - if finding.check_metadata.Provider == "iac": + elif finding.check_metadata.Provider == "iac": details = finding.check_metadata.CheckID - if finding.check_metadata.Provider == "oraclecloud": + elif finding.check_metadata.Provider == "oraclecloud": details = finding.region - if finding.check_metadata.Provider == "alibabacloud": + elif finding.check_metadata.Provider == "alibabacloud": details = finding.region - if finding.check_metadata.Provider == "openstack": + elif finding.check_metadata.Provider == "openstack": details = finding.region - if finding.check_metadata.Provider == "cloudflare": + elif finding.check_metadata.Provider == "cloudflare": details = finding.zone_name - if finding.check_metadata.Provider == "googleworkspace": + elif finding.check_metadata.Provider == "googleworkspace": details = finding.location - if finding.check_metadata.Provider == "vercel": + elif finding.check_metadata.Provider == "vercel": details = finding.region + elif finding.check_metadata.Provider == "okta": + details = finding.region + elif finding.check_metadata.Provider == "scaleway": + details = finding.region + elif finding.check_metadata.Provider == "linode": + details = finding.region + else: + # Dynamic fallback: any external/custom provider + if provider is None: + from prowler.providers.common.provider import Provider + + provider = Provider.get_global_provider() + details = provider.get_stdout_detail(finding) if (verbose or fix) and (not status or finding.status in status): if finding.muted: @@ -59,12 +74,15 @@ def report(check_findings, provider, output_options): if hasattr(output_options, "verbose"): verbose = output_options.verbose if check_findings: - # TO-DO Generic Function if provider.type == "aws": check_findings.sort(key=lambda x: x.region) - - if provider.type == "azure": + elif provider.type == "azure": check_findings.sort(key=lambda x: x.subscription) + else: + # Dynamic fallback: any external/custom provider + sort_key = provider.get_finding_sort_key() + if sort_key and isinstance(sort_key, str): + check_findings.sort(key=lambda x: getattr(x, sort_key, "")) for finding in check_findings: # Print findings by stdout @@ -75,12 +93,16 @@ def report(check_findings, provider, output_options): if hasattr(output_options, "fixer"): fixer = output_options.fixer color = set_report_color(finding.status, finding.muted) + # Pass the local `provider` through so the dynamic else inside + # `stdout_report` does not have to consult the global singleton + # — defeating the whole purpose of the new parameter. stdout_report( finding, color, verbose, status, fixer, + provider=provider, ) else: # No service resources in the whole account diff --git a/prowler/lib/outputs/sarif/__init__.py b/prowler/lib/outputs/sarif/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/sarif/sarif.py b/prowler/lib/outputs/sarif/sarif.py new file mode 100644 index 0000000000..c1798b20f5 --- /dev/null +++ b/prowler/lib/outputs/sarif/sarif.py @@ -0,0 +1,191 @@ +from json import dump +from typing import Optional + +from prowler.config.config import prowler_version +from prowler.lib.logger import logger +from prowler.lib.outputs.finding import Finding +from prowler.lib.outputs.output import Output + +SARIF_SCHEMA_URL = "https://json.schemastore.org/sarif-2.1.0.json" +SARIF_VERSION = "2.1.0" + +SEVERITY_TO_SARIF_LEVEL = { + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", + "informational": "note", +} + +SEVERITY_TO_SECURITY_SEVERITY = { + "critical": "9.0", + "high": "7.0", + "medium": "4.0", + "low": "2.0", + "informational": "0.0", +} + + +class SARIF(Output): + """Generates SARIF 2.1.0 output compatible with GitHub Code Scanning.""" + + def transform(self, findings: list[Finding]) -> None: + """Transform findings into a SARIF 2.1.0 document. + + Only FAIL findings that are not muted are included. Each unique + check ID produces one rule entry; multiple findings for the same + check share the rule via ruleIndex. + + Args: + findings: List of Finding objects to transform. + """ + rules = {} + rule_indices = {} + results = [] + + for finding in findings: + if finding.status != "FAIL" or finding.muted: + continue + + check_id = finding.metadata.CheckID + severity = finding.metadata.Severity.lower() + + if check_id not in rules: + rule_indices[check_id] = len(rules) + rule = { + "id": check_id, + "name": finding.metadata.CheckTitle, + "shortDescription": {"text": finding.metadata.CheckTitle}, + "fullDescription": { + "text": finding.metadata.Description or check_id + }, + "help": { + "text": finding.metadata.Remediation.Recommendation.Text + or finding.metadata.Description + or check_id, + "markdown": self._build_help_markdown(finding, severity), + }, + "defaultConfiguration": { + "level": SEVERITY_TO_SARIF_LEVEL.get(severity, "note"), + }, + "properties": { + "tags": [ + "security", + f"prowler/{finding.metadata.Provider}", + f"severity/{severity}", + ], + "security-severity": SEVERITY_TO_SECURITY_SEVERITY.get( + severity, "0.0" + ), + }, + } + if finding.metadata.RelatedUrl: + rule["helpUri"] = finding.metadata.RelatedUrl + rules[check_id] = rule + + rule_index = rule_indices[check_id] + result = { + "ruleId": check_id, + "ruleIndex": rule_index, + "level": SEVERITY_TO_SARIF_LEVEL.get(severity, "note"), + "message": { + "text": finding.status_extended or finding.metadata.CheckTitle + }, + } + + location = self._build_location(finding) + if location is not None: + result["locations"] = [location] + + results.append(result) + + sarif_document = { + "$schema": SARIF_SCHEMA_URL, + "version": SARIF_VERSION, + "runs": [ + { + "tool": { + "driver": { + "name": "Prowler", + "version": prowler_version, + "informationUri": "https://prowler.com", + "rules": list(rules.values()), + }, + }, + "results": results, + }, + ], + } + + self._data = [sarif_document] + + def batch_write_data_to_file(self) -> None: + """Write the SARIF document to the output file as JSON.""" + try: + if ( + getattr(self, "_file_descriptor", None) + and not self._file_descriptor.closed + and self._data + ): + dump(self._data[0], self._file_descriptor, indent=2) + if self.close_file or self._from_cli: + self._file_descriptor.close() + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + @staticmethod + def _build_help_markdown(finding: Finding, severity: str) -> str: + """Build a markdown help string for a SARIF rule.""" + remediation = ( + finding.metadata.Remediation.Recommendation.Text + or finding.metadata.Description + or finding.metadata.CheckID + ) + lines = [ + f"**{finding.metadata.CheckTitle}**\n", + "| Severity | Remediation |", + "| --- | --- |", + f"| {severity.upper()} | {remediation} |", + ] + if finding.metadata.RelatedUrl: + lines.append(f"\n[More info]({finding.metadata.RelatedUrl})") + return "\n".join(lines) + + @staticmethod + def _build_location(finding: Finding) -> Optional[dict]: + """Build a SARIF physicalLocation from a Finding. + + Uses resource_name as the artifact URI and resource_line_range + (stored in finding.raw for IaC findings) for line range info. + + Returns: + A SARIF location dict, or None if resource_name is empty. + """ + if not finding.resource_name: + return None + + location = { + "physicalLocation": { + "artifactLocation": { + "uri": finding.resource_name, + }, + }, + } + + line_range = finding.raw.get("resource_line_range", "") + if line_range and ":" in line_range: + parts = line_range.split(":") + try: + start_line = int(parts[0]) + end_line = int(parts[1]) + if start_line >= 1 and end_line >= 1: + location["physicalLocation"]["region"] = { + "startLine": start_line, + "endLine": end_line, + } + except (ValueError, IndexError): + pass # Malformed line range — skip region, keep location + + return location diff --git a/prowler/lib/outputs/slack/slack.py b/prowler/lib/outputs/slack/slack.py index 1b1773dc92..d32214cc7c 100644 --- a/prowler/lib/outputs/slack/slack.py +++ b/prowler/lib/outputs/slack/slack.py @@ -82,8 +82,11 @@ class Slack: logo = gcp_logo elif provider.type == "azure": printed_subscriptions = [] - for key, value in provider.identity.subscriptions.items(): - intermediate = f"- *{key}: {value}*\n" + for ( + subscription_id, + display_name, + ) in provider.identity.subscriptions.items(): + intermediate = f"- *{subscription_id}: {display_name}*\n" printed_subscriptions.append(intermediate) identity = f"Azure Subscriptions:\n{''.join(printed_subscriptions)}" logo = azure_logo diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 635139117d..77b4c2725a 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -9,6 +9,7 @@ from prowler.config.config import ( json_asff_file_suffix, json_ocsf_file_suffix, orange_color, + sarif_file_suffix, ) from prowler.lib.logger import logger from prowler.providers.github.models import GithubAppIdentityInfo, GithubIdentityInfo @@ -69,6 +70,13 @@ def display_summary_table( elif provider.type == "nhn": entity_type = "Tenant Domain" audited_entities = provider.identity.tenant_domain + elif provider.type == "stackit": + if provider.identity.project_name: + entity_type = "Project" + audited_entities = provider.identity.project_name + else: + entity_type = "Project ID" + audited_entities = provider.identity.project_id elif provider.type == "iac": if provider.scan_repository_url: entity_type = "Repository" @@ -107,6 +115,20 @@ def display_summary_table( ) else: audited_entities = provider.identity.username or "Personal Account" + elif provider.type == "okta": + entity_type = "Okta Org" + audited_entities = provider.identity.org_domain + elif provider.type == "scaleway": + entity_type = "Organization" + audited_entities = provider.identity.organization_id + elif provider.type == "linode": + entity_type = "Account" + audited_entities = ( + provider.identity.username or provider.identity.email or "linode" + ) + else: + # Dynamic fallback: any external/custom provider + entity_type, audited_entities = provider.get_summary_entity() # Check if there are findings and that they are not all MANUAL if findings and not all(finding.status == "MANUAL" for finding in findings): @@ -184,9 +206,13 @@ def display_summary_table( print( f"\n{entity_type} {Fore.YELLOW}{audited_entities}{Style.RESET_ALL} Scan Results (severity columns are for fails only):" ) - if provider == "azure": + if provider.type == "azure": + scanned_subscriptions = ", ".join( + f"{display_name} ({subscription_id})" + for subscription_id, display_name in provider.identity.subscriptions.items() + ) print( - f"\nSubscriptions scanned: {Fore.YELLOW}{' '.join(provider.identity.subscriptions.keys())}{Style.RESET_ALL}" + f"\nSubscriptions scanned: {Fore.YELLOW}{scanned_subscriptions}{Style.RESET_ALL}" ) print(tabulate(findings_table, headers="keys", tablefmt="rounded_grid")) print( @@ -207,6 +233,10 @@ def display_summary_table( print( f" - HTML: {output_directory}/{output_filename}{html_file_suffix}" ) + if "sarif" in output_options.output_modes: + print( + f" - SARIF: {output_directory}/{output_filename}{sarif_file_suffix}" + ) else: print( diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index 2ce7263e2b..4bef660d33 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -4,8 +4,8 @@ from types import SimpleNamespace from typing import Generator from prowler.lib.check.check import ( + _resolve_check_module, execute, - import_check, list_services, update_audit_metadata, ) @@ -426,9 +426,14 @@ class Scan: # Recover service from check name service = get_service_name_from_check_name(check_name) try: - # Import check module - check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) — + # delegates to `_resolve_check_module` so external + # providers registered via entry points are resolved + # correctly (their checks do not live under + # `prowler.providers.{type}.services...`). + lib = _resolve_check_module( + self._provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() diff --git a/prowler/lib/utils/utils.py b/prowler/lib/utils/utils.py index c4b29f6cc1..62e01d66ef 100644 --- a/prowler/lib/utils/utils.py +++ b/prowler/lib/utils/utils.py @@ -9,52 +9,116 @@ except ImportError: pass import re +import shutil +import subprocess import sys import tempfile from datetime import datetime -from hashlib import sha512 +from functools import lru_cache +from hashlib import sha1, sha512 from io import TextIOWrapper from ipaddress import ip_address from os.path import exists from time import mktime -from typing import Any, Optional +from typing import Any, Iterable, Mapping, Optional, Union from colorama import Style -from detect_secrets import SecretsCollection -from detect_secrets.settings import transient_settings from prowler.config.config import encoding_format_utf_8 from prowler.lib.logger import logger -default_detect_secrets_plugins = [ - {"name": "ArtifactoryDetector"}, - {"name": "AWSKeyDetector"}, - {"name": "AzureStorageKeyDetector"}, - {"name": "BasicAuthDetector"}, - {"name": "CloudantDetector"}, - {"name": "DiscordBotTokenDetector"}, - {"name": "GitHubTokenDetector"}, - {"name": "GitLabTokenDetector"}, - {"name": "Base64HighEntropyString", "limit": 6.0}, - {"name": "HexHighEntropyString", "limit": 3.0}, - {"name": "IbmCloudIamDetector"}, - {"name": "IbmCosHmacDetector"}, - # {"name": "IPPublicDetector"}, https://github.com/Yelp/detect-secrets/pull/885 - {"name": "JwtTokenDetector"}, - {"name": "KeywordDetector"}, - {"name": "MailchimpDetector"}, - {"name": "NpmDetector"}, - {"name": "OpenAIDetector"}, - {"name": "PrivateKeyDetector"}, - {"name": "PypiTokenDetector"}, - {"name": "SendGridDetector"}, - {"name": "SlackDetector"}, - {"name": "SoftlayerDetector"}, - {"name": "SquareOAuthDetector"}, - {"name": "StripeDetector"}, - # {"name": "TelegramBotTokenDetector"}, https://github.com/Yelp/detect-secrets/pull/878 - {"name": "TwilioKeyDetector"}, -] +# Default minimum confidence level for reporting findings. "low" is required to +# enable Kingfisher's built-in generic rules (Generic Password / Secret / API +# Key), which preserve the keyword-based coverage Prowler had with +# detect-secrets' KeywordDetector; at "medium" those generic rules do not fire. +# Possible values: "low", "medium", "high". +default_secrets_confidence = "low" + +# Kingfisher exit codes considered successful: 0 (no findings), 200 (findings), +# 205 (validated findings). +_kingfisher_success_exit_codes = (0, 200, 205) + +# Number of payloads scanned per Kingfisher invocation in batch mode. Bounds +# peak temp-disk and memory while still amortizing the per-process spawn cost +# across many fragments (see detect_secrets_scan_batch). +default_secrets_batch_chunk_size = 500 + +# Wall-clock cap (seconds) for a single Kingfisher subprocess, so a hung binary +# cannot block the audit indefinitely. +default_secrets_scan_timeout = 300 + + +class SecretsScanError(Exception): + """The secret scanner could not produce a trustworthy result. + + Raised when Kingfisher exits with a non-success code, times out, cannot be + located/executed, or returns output that cannot be parsed. This is distinct + from "no secrets found": a security check must never treat a scanner failure + as a clean result, so callers are expected to surface it as ``MANUAL`` + (manual review required) instead of ``PASS``. + """ + + +@lru_cache(maxsize=1) +def get_kingfisher_binary() -> str: + """Return the path to the bundled Kingfisher binary (cached).""" + from kingfisher import get_binary_path + + return get_binary_path() + + +def _build_kingfisher_command( + scan_paths: list, + output_path: str, + confidence: str, + validate: bool, + no_dedup: bool = False, +) -> list: + """Build the Kingfisher ``scan`` command shared by single and batch scans.""" + command = [ + get_kingfisher_binary(), + "scan", + *scan_paths, + "--format", + "json", + "--output", + output_path, + "--no-update-check", + "--confidence", + confidence, + ] + if validate: + # Live-validate discovered secrets against provider APIs. Use + # conservative defaults (short timeout, no retries) to limit the blast + # radius of the outbound calls. + command += ["--validation-timeout", "5", "--validation-retries", "0"] + else: + command.append("--no-validate") + if no_dedup: + # Report every occurrence (one per file) so batched results match + # scanning each payload individually. + command.append("--no-dedup") + return command + + +def _finding_to_dict(entry: dict, fallback_filename: str) -> dict: + """Convert a Kingfisher finding entry into Prowler's finding dict shape.""" + rule = entry.get("rule", {}) + finding = entry.get("finding", {}) + snippet = finding.get("snippet", "") or "" + return { + "filename": finding.get("path", fallback_filename), + "line_number": finding.get("line"), + "type": rule.get("name"), + # Non-security identifier for the matched secret (matches the + # detect-secrets output shape); not used for security. + "hashed_secret": ( + sha1(snippet.encode(), usedforsecurity=False).hexdigest() + if snippet + else None + ), + "is_verified": finding.get("validation", {}).get("status") == "Active", + } def open_file(input_file: str, mode: str = "r") -> TextIOWrapper: @@ -111,77 +175,188 @@ def hash_sha512(string: str) -> str: return sha512(string.encode(encoding_format_utf_8)).hexdigest()[0:9] -def detect_secrets_scan( - data: str = None, - file=None, - excluded_secrets: list[str] = None, - detect_secrets_plugins: dict = None, -) -> list[dict[str, str]]: - """detect_secrets_scan scans the data or file for secrets using the detect-secrets library. - Args: - data (str): The data to scan for secrets. - file (str): The file to scan for secrets. - excluded_secrets (list): A list of regex patterns to exclude from the scan. - detect_secrets_plugins (dict): The settings to use for the scan. - Returns: - dict: The secrets found in the - Raises: - Exception: If an error occurs during the scan. - Examples: - >>> detect_secrets_scan(data="password=password") - [{'filename': 'data', 'hashed_secret': 'f7c3bc1d808e04732adf679965ccc34ca7ae3441', 'is_verified': False, 'line_number': 1, 'type': 'Secret Keyword'}] - >>> detect_secrets_scan(file="file.txt") - {'file.txt': [{'filename': 'file.txt', 'hashed_secret': 'f7c3bc1d808e04732adf679965ccc34ca7ae3441', 'is_verified': False, 'line_number': 1, 'type': 'Secret Keyword'}]} +def _scan_batch_chunk( + chunk: list, + excluded_secrets: list, + confidence: str, + validate: bool, + results: dict, +) -> None: + """Scan one chunk of ``(key, data)`` payloads in a single Kingfisher call. + + Writes each payload to its own file in a temp directory, scans the whole + directory once (``--no-dedup`` so per-file results match individual scans), + maps findings back to their key by file path, and appends them to + ``results``. The temp directory is always removed. """ + if not chunk: + return + tmp_dir = tempfile.mkdtemp() + temp_output_file = None try: - if not file: - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(bytes(data, encoding="raw_unicode_escape")) - temp_data_file.close() + index_to_key = {} + for index, (key, data) in enumerate(chunk): + content = data if data.endswith("\n") else data + "\n" + name = str(index) + with open(os.path.join(tmp_dir, name), "wb") as fh: + fh.write(bytes(content, encoding="raw_unicode_escape")) + index_to_key[name] = key - secrets = SecretsCollection() - - if not detect_secrets_plugins: - detect_secrets_plugins = default_detect_secrets_plugins - - settings = { - "plugins_used": detect_secrets_plugins, - "filters_used": [ - {"path": "detect_secrets.filters.common.is_invalid_file"}, - {"path": "detect_secrets.filters.common.is_known_false_positive"}, - {"path": "detect_secrets.filters.heuristic.is_likely_id_string"}, - {"path": "detect_secrets.filters.heuristic.is_potential_secret"}, - ], - } - - if excluded_secrets and len(excluded_secrets) > 0: - settings["filters_used"].append( - { - "path": "detect_secrets.filters.regex.should_exclude_line", - "pattern": excluded_secrets, - } + temp_output_file = tempfile.NamedTemporaryFile(delete=False, suffix=".json") + temp_output_file.close() + command = _build_kingfisher_command( + [tmp_dir], temp_output_file.name, confidence, validate, no_dedup=True + ) + process = subprocess.run( + command, + capture_output=True, + text=True, + timeout=default_secrets_scan_timeout, + ) + if process.returncode not in _kingfisher_success_exit_codes: + raise SecretsScanError( + f"Kingfisher exited with code {process.returncode}: " + f"{process.stderr.strip()[:500]}" ) - with transient_settings(settings): - if file: - secrets.scan_file(file) - else: - secrets.scan_file(temp_data_file.name) - if not file: - os.remove(temp_data_file.name) + with open(temp_output_file.name, encoding=encoding_format_utf_8) as f: + output = f.read() + kingfisher_output = json.loads(output) if output.strip() else {} - detect_secrets_output = secrets.json() + source_lines_cache = {} - if detect_secrets_output: - if file: - return detect_secrets_output[file] - else: - return detect_secrets_output[temp_data_file.name] - else: - return None - except Exception as e: - logger.error(f"Error scanning for secrets: {e}") - return None + def source_lines(file_name: str) -> list: + if file_name not in source_lines_cache: + with open( + os.path.join(tmp_dir, file_name), + encoding=encoding_format_utf_8, + errors="replace", + ) as f: + source_lines_cache[file_name] = f.read().splitlines() + return source_lines_cache[file_name] + + for entry in kingfisher_output.get("findings", []): + finding = entry.get("finding", {}) + name = os.path.basename(finding.get("path", "")) + key = index_to_key.get(name) + if key is None: + continue + # Validate the line index before any consumer trusts it. Checks use + # ``line_number`` as a 1-based index into their own parallel data + # (e.g. CloudWatch does ``events[line_number - 1]``), so a missing, + # non-integer, or out-of-range line would crash the check or map the + # secret to the wrong resource. Fail closed: surface a malformed + # finding as a scan failure so callers report MANUAL instead of a + # wrong PASS/FAIL. ``bool`` is rejected explicitly because it is a + # subclass of ``int``. + line_number = finding.get("line") + lines = source_lines(name) + if ( + isinstance(line_number, bool) + or not isinstance(line_number, int) + or not 1 <= line_number <= len(lines) + ): + raise SecretsScanError( + f"Kingfisher returned an invalid line number " + f"{line_number!r} for a finding in {name}" + ) + if excluded_secrets and any( + re.search(pattern, lines[line_number - 1]) + for pattern in excluded_secrets + ): + continue + results.setdefault(key, []).append(_finding_to_dict(entry, name)) + except SecretsScanError: + # Already a typed scan failure; propagate so callers report MANUAL. + raise + except subprocess.TimeoutExpired as error: + raise SecretsScanError( + f"Kingfisher timed out after {default_secrets_scan_timeout}s " + "while scanning for secrets" + ) from error + except Exception as error: + # Fail closed: a missing/unexecutable binary, unparseable JSON output or + # any other runtime failure must NOT be silently treated as "no secrets + # found". Surface it so callers can report MANUAL instead of PASS. + raise SecretsScanError(f"Secret scan failed: {error}") from error + finally: + if temp_output_file and os.path.exists(temp_output_file.name): + os.remove(temp_output_file.name) + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def detect_secrets_scan_batch( + payloads: Union[Mapping[Any, str], Iterable[tuple[Any, str]]], + excluded_secrets: Optional[list[str]] = None, + confidence: str = default_secrets_confidence, + validate: bool = False, + chunk_size: int = default_secrets_batch_chunk_size, +) -> dict: + """Scan many payloads with Kingfisher in chunked subprocess invocations. + + This is the scan entry point used by every secret check. Each payload is + written to its own file and scanned with ``--no-dedup`` so per-payload + results match scanning each payload on its own. Payloads are processed in + chunks (writing each to disk and releasing it as it is consumed) to bound + peak temp-disk and memory use while amortizing the per-process spawn cost + across many fragments. + + By default the scan runs fully offline (``--no-validate``, + ``--no-update-check``): no network calls are made, so the scanned data is + never sent anywhere. When ``validate`` is True, Kingfisher additionally + checks whether each discovered secret is live by authenticating with it + against the provider's API (the secret itself is the credential; no extra + permissions are required). That makes outbound network calls, so it must be + explicitly opted in. + + Args: + payloads: a mapping ``{key: data}`` or any iterable of ``(key, data)`` + pairs. ``key`` is any hashable the caller uses to map findings back + to its source (e.g. a variable name or a ``(resource, stream)``). + excluded_secrets (list): regex patterns; a finding whose source line + matches one is excluded. + confidence (str): minimum Kingfisher confidence ("low"/"medium"/"high"). + validate (bool): live-validate discovered secrets (outbound calls). + chunk_size (int): payloads scanned per Kingfisher invocation. + Returns: + dict mapping each key that produced findings to its list of finding + dicts, each with ``filename``, ``line_number``, ``type``, + ``hashed_secret`` and ``is_verified`` keys. Keys with no findings are + omitted. + Raises: + SecretsScanError: if the scanner fails for any chunk (non-success exit + code, timeout, missing/unexecutable binary or unparseable output). + An empty result is therefore always "no secrets found", never a + silent scan failure; callers must report MANUAL on this error. + """ + items = payloads.items() if hasattr(payloads, "items") else payloads + results = {} + chunk = [] + for key, data in items: + chunk.append((key, data)) + if len(chunk) >= chunk_size: + _scan_batch_chunk(chunk, excluded_secrets, confidence, validate, results) + chunk = [] + _scan_batch_chunk(chunk, excluded_secrets, confidence, validate, results) + return results + + +def annotate_verified_secrets(report, secrets: list) -> None: + """Escalate and annotate a finding when any of its secrets is confirmed live. + + When secret validation (``--scan-secrets-validate`` / ``secrets_validate``) + confirms that a discovered secret is live, the finding is more severe than a + potential secret: its severity is raised to critical and a note is appended + to ``status_extended``. No-op when no secret was validated as live, so the + default offline behavior (and existing finding messages) is unchanged. + """ + if secrets and any(secret.get("is_verified") for secret in secrets): + from prowler.lib.check.models import Severity + + report.check_metadata.Severity = Severity.critical + report.status_extended += ( + " One or more of these secrets were confirmed to be live." + ) def validate_ip_address(ip_string): diff --git a/prowler/lib/utils/vulnerability_references.py b/prowler/lib/utils/vulnerability_references.py new file mode 100644 index 0000000000..63afe3c2c6 --- /dev/null +++ b/prowler/lib/utils/vulnerability_references.py @@ -0,0 +1,90 @@ +import re +from urllib.parse import parse_qs, urlparse + +AQUA_REFERENCE_HOST = "avd.aquasec.com" +GITHUB_ADVISORY_URL = "https://github.com/advisories/{advisory_id}" +PROWLER_HUB_CHECK_URL = "https://hub.prowler.com/check/{check_id}" +_CVE_ID_PATTERN = re.compile(r"^CVE-\d{4}-\d+$", re.IGNORECASE) +_GHSA_ID_PATTERN = re.compile(r"^GHSA(?:-[a-z0-9]{4}){3}$", re.IGNORECASE) + + +def _dedupe_preserve_order(urls: list[str]) -> list[str]: + seen: set[str] = set() + ordered_urls: list[str] = [] + + for url in urls: + if not url or not url.strip(): + continue + + normalized_url = url.strip() + if normalized_url in seen: + continue + + seen.add(normalized_url) + ordered_urls.append(normalized_url) + + return ordered_urls + + +def _is_aqua_reference(url: str) -> bool: + return AQUA_REFERENCE_HOST in urlparse(url).netloc.lower() + + +def _build_cve_org_url(vulnerability_id: str) -> str: + return f"https://www.cve.org/CVERecord?id={vulnerability_id.upper()}" + + +def build_finding_reference_url(finding_id: str) -> str: + """Map a Trivy finding ID to a stable, real reference URL. + + - CVE-XXXX-NNNN → cve.org record + - GHSA-… → github.com/advisories + - everything else → hub.prowler.com/check/, stripping a leading + "AVD-" prefix because Prowler Hub indexes Trivy rules by the + non-prefixed ID (e.g., "AWS-0001" not "AVD-AWS-0001"). + """ + normalized = finding_id.strip().upper() + if _CVE_ID_PATTERN.match(normalized): + return _build_cve_org_url(normalized) + if _GHSA_ID_PATTERN.match(normalized): + return GITHUB_ADVISORY_URL.format(advisory_id=normalized) + hub_id = normalized[4:] if normalized.startswith("AVD-") else normalized + return PROWLER_HUB_CHECK_URL.format(check_id=hub_id) + + +def _is_cve_org_url(url: str, vulnerability_id: str) -> bool: + parsed_url = urlparse(url) + if parsed_url.netloc.lower() != "www.cve.org": + return False + + query_value = parse_qs(parsed_url.query).get("id", [""])[0] + return query_value.upper() == vulnerability_id.upper() + + +def resolve_vulnerability_reference_urls( + vulnerability_id: str, + references: list[str] | None = None, + primary_url: str = "", +) -> tuple[str, list[str]]: + """Resolve non-Aqua vulnerability URLs, prioritizing official CVE destinations.""" + + candidate_urls = list(references or []) + if primary_url and primary_url not in candidate_urls: + candidate_urls.append(primary_url) + + filtered_urls = _dedupe_preserve_order( + [url for url in candidate_urls if not _is_aqua_reference(url)] + ) + + if not _CVE_ID_PATTERN.match(vulnerability_id): + return "", filtered_urls + + cve_org_urls = [ + url for url in filtered_urls if _is_cve_org_url(url, vulnerability_id) + ] + + recommendation_url = ( + cve_org_urls[0] if cve_org_urls else _build_cve_org_url(vulnerability_id) + ) + + return recommendation_url, [recommendation_url] diff --git a/prowler/providers/alibabacloud/alibabacloud_provider.py b/prowler/providers/alibabacloud/alibabacloud_provider.py index 82e48e2f14..d7020186a0 100644 --- a/prowler/providers/alibabacloud/alibabacloud_provider.py +++ b/prowler/providers/alibabacloud/alibabacloud_provider.py @@ -53,6 +53,7 @@ class AlibabacloudProvider(Provider): """ _type: str = "alibabacloud" + sdk_only: bool = False _identity: AlibabaCloudIdentityInfo _session: AlibabaCloudSession _audit_resources: list = [] diff --git a/prowler/providers/alibabacloud/lib/service/service.py b/prowler/providers/alibabacloud/lib/service/service.py index b58c06416a..c3f9c936a0 100644 --- a/prowler/providers/alibabacloud/lib/service/service.py +++ b/prowler/providers/alibabacloud/lib/service/service.py @@ -68,6 +68,45 @@ class AlibabaCloudService: return self.regional_clients[region] return self.client + @staticmethod + def _is_retriable_error(error: Exception) -> bool: + """Return True when an Alibaba API error is worth retrying once.""" + error_code = getattr(error, "code", "") + status_code = getattr(error, "statusCode", None) or getattr( + error, "status_code", None + ) + message = str(error) + + retriable_codes = {"ServiceUnavailable", "Throttling", "Throttling.User"} + retriable_substrings = ( + "Connection reset by peer", + "Connection aborted", + "ConnectTimeoutError", + "ReadTimeout", + "timed out", + "temporarily unavailable", + ) + + return ( + error_code in retriable_codes + or status_code in {429, 500, 502, 503, 504} + or any(fragment in message for fragment in retriable_substrings) + ) + + def _call_with_retries(self, func, *args, retries: int = 1, **kwargs): + """Call a function and retry once for transient Alibaba API failures.""" + last_error = None + + for attempt in range(retries + 1): + try: + return func(*args, **kwargs) + except Exception as error: # pragma: no cover - exercised via services + last_error = error + if attempt >= retries or not self._is_retriable_error(error): + raise + + raise last_error + def __threading_call__(self, call, iterator=None): """ Execute a function across multiple regions or items using threads. diff --git a/prowler/providers/alibabacloud/models.py b/prowler/providers/alibabacloud/models.py index cbb57044fc..860ce2a11c 100644 --- a/prowler/providers/alibabacloud/models.py +++ b/prowler/providers/alibabacloud/models.py @@ -150,6 +150,34 @@ class AlibabaCloudSession: ) return self._credentials + @staticmethod + def _get_securitycenter_endpoint(region: str) -> str: + """Return the public Security Center OpenAPI endpoint for a region.""" + securitycenter_region = region or ALIBABACLOUD_DEFAULT_REGION + if securitycenter_region.startswith("cn-"): + return "tds.cn-shanghai.aliyuncs.com" + return "tds.ap-southeast-1.aliyuncs.com" + + @staticmethod + def _get_rds_endpoint(region: str) -> str: + """Return the public RDS OpenAPI endpoint for a region.""" + rds_region = region or ALIBABACLOUD_DEFAULT_REGION + shared_rds_regions = { + "cn-qingdao", + "cn-beijing", + "cn-hangzhou", + "cn-shanghai", + "cn-shenzhen", + "cn-heyuan", + "cn-hongkong", + "cn-beijing-finance-1", + "cn-hangzhou-finance", + "cn-shanghai-finance-1", + } + if rds_region in shared_rds_regions: + return "rds.aliyuncs.com" + return f"rds.{rds_region}.aliyuncs.com" + def client(self, service: str, region: str = None): """ Create a service client for the given service and region. @@ -196,11 +224,8 @@ class AlibabaCloudSession: config.endpoint = f"ecs.{ALIBABACLOUD_DEFAULT_REGION}.aliyuncs.com" return EcsClient(config) elif service == "sas" or service == "securitycenter": - # SAS (Security Center) endpoint is regional: sas.{region}.aliyuncs.com - if region: - config.endpoint = f"sas.{region}.aliyuncs.com" - else: - config.endpoint = f"sas.{ALIBABACLOUD_DEFAULT_REGION}.aliyuncs.com" + # Security Center uses regional groups of shared TDS endpoints. + config.endpoint = self._get_securitycenter_endpoint(region) return SasClient(config) elif service == "oss": if region: @@ -226,10 +251,7 @@ class AlibabaCloudSession: config.endpoint = f"cs.{ALIBABACLOUD_DEFAULT_REGION}.aliyuncs.com" return CSClient(config) elif service == "rds": - if region: - config.endpoint = f"rds.{region}.aliyuncs.com" - else: - config.endpoint = f"rds.{ALIBABACLOUD_DEFAULT_REGION}.aliyuncs.com" + config.endpoint = self._get_rds_endpoint(region) return RdsClient(config) elif service == "sls": if region: diff --git a/prowler/providers/alibabacloud/services/actiontrail/actiontrail_service.py b/prowler/providers/alibabacloud/services/actiontrail/actiontrail_service.py index 2922472502..d6dd52c2d4 100644 --- a/prowler/providers/alibabacloud/services/actiontrail/actiontrail_service.py +++ b/prowler/providers/alibabacloud/services/actiontrail/actiontrail_service.py @@ -33,7 +33,7 @@ class ActionTrail(AlibabaCloudService): try: # Use Tea SDK client (ActionTrail is regional service) request = actiontrail_models.DescribeTrailsRequest() - response = regional_client.describe_trails(request) + response = self._call_with_retries(regional_client.describe_trails, request) if response and response.body and response.body.trail_list: # trail_list is already a list, not an object with a trail attribute diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_weekly/__init__.py b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_weekly/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/alibabacloud/services/cs/cs_service.py b/prowler/providers/alibabacloud/services/cs/cs_service.py index 7645f3edf2..d6ef8de4cd 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_service.py +++ b/prowler/providers/alibabacloud/services/cs/cs_service.py @@ -1,4 +1,6 @@ +import json from datetime import datetime +from threading import Lock from typing import Optional from alibabacloud_cs20151215 import models as cs_models @@ -23,6 +25,8 @@ class CS(AlibabaCloudService): # Fetch CS resources self.clusters = [] + self._cluster_ids_lock = Lock() + self._seen_cluster_ids = set() self.__threading_call__(self._describe_clusters) def _describe_clusters(self, regional_client): @@ -33,18 +37,30 @@ class CS(AlibabaCloudService): try: # DescribeClustersV1 returns cluster list request = cs_models.DescribeClustersV1Request() - response = regional_client.describe_clusters_v1(request) + response = self._call_with_retries( + regional_client.describe_clusters_v1, request + ) if response and response.body and response.body.clusters: for cluster_data in response.body.clusters: cluster_id = getattr(cluster_data, "cluster_id", "") + cluster_region = getattr(cluster_data, "region_id", "") or region + + if ( + cluster_region != region + and cluster_region in self.regional_clients + ): + continue if not self.audit_resources or is_resource_filtered( cluster_id, self.audit_resources ): + cluster_client = self.regional_clients.get( + cluster_region, regional_client + ) # Get detailed information for each cluster cluster_detail = self._get_cluster_detail( - regional_client, cluster_id + cluster_client, cluster_id ) if cluster_detail: @@ -60,12 +76,12 @@ class CS(AlibabaCloudService): # Get node pools to check CloudMonitor cloudmonitor_enabled = self._check_cloudmonitor_enabled( - regional_client, cluster_id + cluster_client, cluster_id ) # Check if cluster checks have been run in the last week last_check_time = self._get_last_cluster_check( - regional_client, cluster_id + cluster_client, cluster_id ) # Check addons for dashboard, network policy, etc. @@ -78,33 +94,33 @@ class CS(AlibabaCloudService): cluster_detail, region ) - self.clusters.append( - Cluster( - id=cluster_id, - name=getattr(cluster_data, "name", cluster_id), - region=region, - cluster_type=getattr( - cluster_data, "cluster_type", "" - ), - state=getattr(cluster_data, "state", ""), - audit_project_name=audit_project_name, - log_service_enabled=bool(audit_project_name), - cloudmonitor_enabled=cloudmonitor_enabled, - rbac_enabled=rbac_enabled, - last_check_time=last_check_time, - dashboard_enabled=addons_status[ - "dashboard_enabled" - ], - network_policy_enabled=addons_status[ - "network_policy_enabled" - ], - eni_multiple_ip_enabled=addons_status[ - "eni_multiple_ip_enabled" - ], - private_cluster_enabled=not public_access_enabled, - ) + cluster = Cluster( + id=cluster_id, + name=getattr(cluster_data, "name", cluster_id), + region=cluster_region, + cluster_type=getattr(cluster_data, "cluster_type", ""), + state=getattr(cluster_data, "state", ""), + audit_project_name=audit_project_name, + log_service_enabled=bool(audit_project_name), + cloudmonitor_enabled=cloudmonitor_enabled, + rbac_enabled=rbac_enabled, + last_check_time=last_check_time, + dashboard_enabled=addons_status["dashboard_enabled"], + network_policy_enabled=addons_status[ + "network_policy_enabled" + ], + eni_multiple_ip_enabled=addons_status[ + "eni_multiple_ip_enabled" + ], + private_cluster_enabled=not public_access_enabled, ) + with self._cluster_ids_lock: + if cluster_id in self._seen_cluster_ids: + continue + self._seen_cluster_ids.add(cluster_id) + self.clusters.append(cluster) + except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -114,19 +130,43 @@ class CS(AlibabaCloudService): """Get detailed information for a specific cluster.""" try: # DescribeClusterDetail returns detailed cluster information - request = cs_models.DescribeClusterDetailRequest() - response = regional_client.describe_cluster_detail(cluster_id, request) + if hasattr(cs_models, "DescribeClusterDetailRequest"): + request = cs_models.DescribeClusterDetailRequest() + response = self._call_with_retries( + regional_client.describe_cluster_detail, + cluster_id, + request, + ) + else: + response = self._call_with_retries( + regional_client.describe_cluster_detail, cluster_id + ) if response and response.body: # Convert response body to dict body = response.body - result = {"meta_data": {}} + result = {"meta_data": {}, "parameters": {}, "master_url": ""} - # Check if meta_data exists in the response + # The ACK SDK exposes meta_data as a JSON string in recent versions. if hasattr(body, "meta_data"): meta_data = body.meta_data if meta_data: - result["meta_data"] = dict(meta_data) + if isinstance(meta_data, dict): + result["meta_data"] = meta_data + elif isinstance(meta_data, str): + try: + parsed_meta_data = json.loads(meta_data) + except (TypeError, ValueError): + parsed_meta_data = {} + + if isinstance(parsed_meta_data, dict): + result["meta_data"] = parsed_meta_data + + if hasattr(body, "parameters") and body.parameters: + result["parameters"] = body.parameters + + if hasattr(body, "master_url") and body.master_url: + result["master_url"] = body.master_url return result @@ -143,7 +183,9 @@ class CS(AlibabaCloudService): try: # DescribeClusterNodePools returns node pool information request = cs_models.DescribeClusterNodePoolsRequest() - response = regional_client.describe_cluster_node_pools(cluster_id, request) + response = self._call_with_retries( + regional_client.describe_cluster_node_pools, cluster_id, request + ) if response and response.body and response.body.nodepools: nodepools = response.body.nodepools @@ -214,9 +256,19 @@ class CS(AlibabaCloudService): or None if no successful checks found. """ try: - # DescribeClusterChecks returns cluster check history - request = cs_models.DescribeClusterChecksRequest() - response = regional_client.describe_cluster_checks(cluster_id, request) + # Newer ACK SDKs expose ListClusterChecks; older ones used DescribeClusterChecks. + if hasattr(cs_models, "ListClusterChecksRequest") and hasattr( + regional_client, "list_cluster_checks" + ): + request = cs_models.ListClusterChecksRequest() + response = self._call_with_retries( + regional_client.list_cluster_checks, cluster_id, request + ) + else: + request = cs_models.DescribeClusterChecksRequest() + response = self._call_with_retries( + regional_client.describe_cluster_checks, cluster_id, request + ) if response and response.body and response.body.checks: checks = response.body.checks @@ -267,18 +319,20 @@ class CS(AlibabaCloudService): # Note: Addons structure from API is typically a string representation of JSON or a list # Based on sample: "Addons": [{"name": "gateway-api", ...}, ...] addons = meta_data.get("Addons", []) + if addons is None: + addons = [] # If addons is string, try to parse it? # The SDK typically handles this conversion, but let's be safe if isinstance(addons, str): - import json - try: addons = json.loads(addons) except Exception: addons = [] for addon in addons: + if not isinstance(addon, dict): + continue name = addon.get("name", "") disabled = addon.get("disabled", False) @@ -317,7 +371,13 @@ class CS(AlibabaCloudService): parameters = cluster_detail.get("parameters", {}) endpoint_public = parameters.get("endpoint_public", "") - if endpoint_public: + if isinstance(endpoint_public, str): + normalized_public = endpoint_public.strip().lower() + if normalized_public in {"true", "1", "yes"}: + return True + if normalized_public in {"false", "0", "no", ""}: + return False + elif endpoint_public: return True # If we can't find explicit indicator, check if master_url is present diff --git a/prowler/providers/alibabacloud/services/oss/oss_service.py b/prowler/providers/alibabacloud/services/oss/oss_service.py index 378807bc5d..42cb40e2ec 100644 --- a/prowler/providers/alibabacloud/services/oss/oss_service.py +++ b/prowler/providers/alibabacloud/services/oss/oss_service.py @@ -29,6 +29,8 @@ class OSS(AlibabaCloudService): # Treat as regional for client generation consistency with other services super().__init__(__class__.__name__, provider, global_service=False) self._buckets_lock = Lock() + self._bucket_inventory_lock = Lock() + self._bucket_inventory_loaded = False # Fetch OSS resources self.buckets = {} @@ -40,6 +42,11 @@ class OSS(AlibabaCloudService): def _list_buckets(self, regional_client=None): region = "unknown" try: + with self._bucket_inventory_lock: + if self._bucket_inventory_loaded: + return + self._bucket_inventory_loaded = True + regional_client = regional_client or self.client region = getattr(regional_client, "region", self.region) endpoint = f"oss-{region}.aliyuncs.com" @@ -75,11 +82,20 @@ class OSS(AlibabaCloudService): headers["Authorization"] = f"OSS {credentials.access_key_id}:{signature}" url = f"https://{endpoint}/" - response = requests.get(url, headers=headers, timeout=10) + response = self._call_with_retries( + requests.get, url, headers=headers, timeout=10 + ) if response.status_code != 200: - logger.error( - f"OSS - HTTP listing {endpoint_label} returned {response.status_code}: {response.text}" - ) + if response.status_code == 403 and "UserDisable" in ( + response.text or "" + ): + logger.info( + f"OSS - HTTP listing {endpoint_label} skipped because OSS is disabled for this account." + ) + else: + logger.error( + f"OSS - HTTP listing {endpoint_label} returned {response.status_code}: {response.text}" + ) return try: diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number.py b/prowler/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number.py new file mode 100644 index 0000000000..305570289c --- /dev/null +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, CheckReportAlibabaCloud +from prowler.providers.alibabacloud.services.ram.ram_client import ram_client + + +class ram_password_policy_number(Check): + """Check if RAM password policy requires at least one number.""" + + def execute(self) -> list[CheckReportAlibabaCloud]: + findings = [] + + if ram_client.password_policy: + report = CheckReportAlibabaCloud( + metadata=self.metadata(), resource=ram_client.password_policy + ) + report.region = ram_client.region + report.resource_id = f"{ram_client.audited_account}-password-policy" + report.resource_arn = ( + f"acs:ram::{ram_client.audited_account}:password-policy" + ) + + if ram_client.password_policy.require_numbers: + report.status = "PASS" + report.status_extended = ( + "RAM password policy requires at least one number." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "RAM password policy does not require at least one number." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/alibabacloud/services/rds/rds_service.py b/prowler/providers/alibabacloud/services/rds/rds_service.py index 3d3f29d0da..8e9680fbf8 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_service.py +++ b/prowler/providers/alibabacloud/services/rds/rds_service.py @@ -22,6 +22,18 @@ class RDS(AlibabaCloudService): self.instances = [] self.__threading_call__(self._describe_instances) + @staticmethod + def _set_region_id(request, regional_client) -> None: + """Populate RegionId on RDS requests when the SDK model exposes it.""" + region = getattr(regional_client, "region", "") + if not region: + return + + if hasattr(request, "region_id"): + request.region_id = region + elif hasattr(request, "RegionId"): + request.RegionId = region + def _describe_instances(self, regional_client): """List all RDS instances and fetch their details in a specific region.""" region = getattr(regional_client, "region", "unknown") @@ -30,7 +42,10 @@ class RDS(AlibabaCloudService): try: # DescribeDBInstances returns instance list request = rds_models.DescribeDBInstancesRequest() - response = regional_client.describe_dbinstances(request) + self._set_region_id(request, regional_client) + response = self._call_with_retries( + regional_client.describe_dbinstances, request + ) if response and response.body and response.body.items: for instance_data in response.body.items.dbinstance: @@ -123,7 +138,10 @@ class RDS(AlibabaCloudService): try: request = rds_models.DescribeDBInstanceAttributeRequest() request.dbinstance_id = instance_id - response = regional_client.describe_dbinstance_attribute(request) + self._set_region_id(request, regional_client) + response = self._call_with_retries( + regional_client.describe_dbinstance_attribute, request + ) if ( response @@ -146,7 +164,10 @@ class RDS(AlibabaCloudService): try: request = rds_models.DescribeDBInstanceSSLRequest() request.dbinstance_id = instance_id - response = regional_client.describe_dbinstance_ssl(request) + self._set_region_id(request, regional_client) + response = self._call_with_retries( + regional_client.describe_dbinstance_ssl, request + ) if response and response.body: # response.body is a DescribeDBInstanceSSLResponseBody model object, use getattr @@ -169,7 +190,10 @@ class RDS(AlibabaCloudService): try: request = rds_models.DescribeDBInstanceTDERequest() request.dbinstance_id = instance_id - response = regional_client.describe_dbinstance_tde(request) + self._set_region_id(request, regional_client) + response = self._call_with_retries( + regional_client.describe_dbinstance_tde, request + ) if response and response.body: return { @@ -187,7 +211,10 @@ class RDS(AlibabaCloudService): try: request = rds_models.DescribeDBInstanceIPArrayListRequest() request.dbinstance_id = instance_id - response = regional_client.describe_dbinstance_iparray_list(request) + self._set_region_id(request, regional_client) + response = self._call_with_retries( + regional_client.describe_dbinstance_iparray_list, request + ) ips = [] if response and response.body and response.body.items: @@ -205,12 +232,12 @@ class RDS(AlibabaCloudService): def _describe_sql_collector_policy(self, regional_client, instance_id: str) -> dict: """Check SQL audit status.""" try: - request = rds_models.DescribeSQLLogRecordsRequest() - request.dbinstance_id = instance_id - policy_request = rds_models.DescribeSQLCollectorPolicyRequest() policy_request.dbinstance_id = instance_id - response = regional_client.describe_sqlcollector_policy(policy_request) + self._set_region_id(policy_request, regional_client) + response = self._call_with_retries( + regional_client.describe_sqlcollector_policy, policy_request + ) if response and response.body: status = getattr(response.body, "sqlcollector_status", "") @@ -232,7 +259,10 @@ class RDS(AlibabaCloudService): try: request = rds_models.DescribeParametersRequest() request.dbinstance_id = instance_id - response = regional_client.describe_parameters(request) + self._set_region_id(request, regional_client) + response = self._call_with_retries( + regional_client.describe_parameters, request + ) params = {} if response and response.body and response.body.running_parameters: diff --git a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_service.py b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_service.py index 6f114a3597..f78ad507ce 100644 --- a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_service.py +++ b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_service.py @@ -50,7 +50,9 @@ class SecurityCenter(AlibabaCloudService): request.page_size = 100 while True: - response = self.client.describe_vul_list(request) + response = self._call_with_retries( + self.client.describe_vul_list, request + ) if response and response.body and response.body.vul_records: vul_records = response.body.vul_records @@ -112,7 +114,9 @@ class SecurityCenter(AlibabaCloudService): request.page_size = 100 while True: - response = self.client.describe_cloud_center_instances(request) + response = self._call_with_retries( + self.client.describe_cloud_center_instances, request + ) if response and response.body and response.body.instances: instances = response.body.instances @@ -174,7 +178,9 @@ class SecurityCenter(AlibabaCloudService): request.page_size = 100 while True: - response = self.client.list_uninstall_aegis_machines(request) + response = self._call_with_retries( + self.client.list_uninstall_aegis_machines, request + ) if response and response.body and response.body.machine_list: machines = response.body.machine_list @@ -221,7 +227,9 @@ class SecurityCenter(AlibabaCloudService): try: # Get notification configurations request = sas_models.DescribeNoticeConfigRequest() - response = self.client.describe_notice_config(request) + response = self._call_with_retries( + self.client.describe_notice_config, request + ) if response and response.body and response.body.notice_config_list: notice_configs = response.body.notice_config_list @@ -253,7 +261,7 @@ class SecurityCenter(AlibabaCloudService): try: # Get vulnerability scan configuration request = sas_models.DescribeVulConfigRequest() - response = self.client.describe_vul_config(request) + response = self._call_with_retries(self.client.describe_vul_config, request) if response and response.body and response.body.target_configs: target_configs = response.body.target_configs @@ -281,7 +289,9 @@ class SecurityCenter(AlibabaCloudService): try: # Get vulnerability scan level priorities request = sas_models.DescribeConcernNecessityRequest() - response = self.client.describe_concern_necessity(request) + response = self._call_with_retries( + self.client.describe_concern_necessity, request + ) if response and response.body: concern_necessity = getattr(response.body, "concern_necessity", []) @@ -314,7 +324,9 @@ class SecurityCenter(AlibabaCloudService): try: # Get Security Center edition request = sas_models.DescribeVersionConfigRequest() - response = self.client.describe_version_config(request) + response = self._call_with_retries( + self.client.describe_version_config, request + ) if response and response.body: # Get Version field from response diff --git a/prowler/providers/alibabacloud/services/sls/sls_service.py b/prowler/providers/alibabacloud/services/sls/sls_service.py index 16df02dfbe..31727658db 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_service.py +++ b/prowler/providers/alibabacloud/services/sls/sls_service.py @@ -39,7 +39,9 @@ class Sls(AlibabaCloudService): try: # List Projects list_project_request = sls_models.ListProjectRequest(offset=0, size=500) - projects_resp = client.list_project(list_project_request) + projects_resp = self._call_with_retries( + client.list_project, list_project_request + ) if projects_resp.body and projects_resp.body.projects: for project in projects_resp.body.projects: @@ -50,8 +52,10 @@ class Sls(AlibabaCloudService): offset=0, size=500 ) try: - alerts_resp = client.list_alerts( - project_name, list_alert_request + alerts_resp = self._call_with_retries( + client.list_alerts, + project_name, + list_alert_request, ) if alerts_resp.body and alerts_resp.body.results: for alert in alerts_resp.body.results: @@ -90,7 +94,9 @@ class Sls(AlibabaCloudService): try: # List Projects list_project_request = sls_models.ListProjectRequest(offset=0, size=500) - projects_resp = client.list_project(list_project_request) + projects_resp = self._call_with_retries( + client.list_project, list_project_request + ) if projects_resp.body and projects_resp.body.projects: for project in projects_resp.body.projects: @@ -101,14 +107,18 @@ class Sls(AlibabaCloudService): offset=0, size=500 ) try: - logstores_resp = client.list_log_stores( - project_name, list_logstores_request + logstores_resp = self._call_with_retries( + client.list_log_stores, + project_name, + list_logstores_request, ) if logstores_resp.body and logstores_resp.body.logstores: for logstore_name in logstores_resp.body.logstores: try: - logstore_resp = client.get_log_store( - project_name, logstore_name + logstore_resp = self._call_with_retries( + client.get_log_store, + project_name, + logstore_name, ) if logstore_resp.body: self.log_stores.append( diff --git a/prowler/providers/alibabacloud/services/vpc/vpc_service.py b/prowler/providers/alibabacloud/services/vpc/vpc_service.py index 9ed00fd9ea..02e9084223 100644 --- a/prowler/providers/alibabacloud/services/vpc/vpc_service.py +++ b/prowler/providers/alibabacloud/services/vpc/vpc_service.py @@ -33,7 +33,7 @@ class VPC(AlibabaCloudService): try: request = vpc_models.DescribeVpcsRequest() - response = regional_client.describe_vpcs(request) + response = self._call_with_retries(regional_client.describe_vpcs, request) if response and response.body and response.body.vpcs: for vpc_data in response.body.vpcs.vpc: @@ -70,7 +70,9 @@ class VPC(AlibabaCloudService): request = vpc_models.DescribeFlowLogsRequest() request.resource_id = vpc_id request.resource_type = "VPC" - response = regional_client.describe_flow_logs(request) + response = self._call_with_retries( + regional_client.describe_flow_logs, request + ) if response and response.body and response.body.flow_logs: flow_logs = response.body.flow_logs.flow_log diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index 9f0afe97f3..b4c9ed3771 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -25,8 +25,8 @@ from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes from prowler.providers.aws.config import ( AWS_REGION_US_EAST_1, AWS_STS_GLOBAL_ENDPOINT_REGION, - BOTO3_USER_AGENT_EXTRA, ROLE_SESSION_NAME, + get_default_session_config, ) from prowler.providers.aws.exceptions.exceptions import ( AWSAccessKeyIDInvalidError, @@ -90,13 +90,14 @@ class AwsProvider(Provider): """ _type: str = "aws" + sdk_only: bool = False _identity: AWSIdentityInfo _session: AWSSession _organizations_metadata: AWSOrganizationsInfo _audit_resources: list = [] _audit_config: dict _scan_unused_services: bool = False - _enabled_regions: set = set() + _enabled_regions: set | None = None _mutelist: AWSMutelist # TODO: this is not optional, enforce for all providers audit_metadata: Audit_Metadata @@ -111,6 +112,7 @@ class AwsProvider(Provider): mfa: bool = False, profile: str = None, regions: set = set(), + excluded_regions: set = None, organizations_role_arn: str = None, scan_unused_services: bool = False, resource_tags: list[str] = [], @@ -136,6 +138,10 @@ class AwsProvider(Provider): - mfa: A boolean indicating whether MFA is enabled. - profile: The name of the AWS CLI profile to use. - regions: A set of regions to audit. + - excluded_regions: A set of regions to skip during the scan. Applied + on top of `regions` and of the account's enabled regions. Also + settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable + or the `disallowed_regions` key in the provider config file. - organizations_role_arn: The ARN of the AWS Organizations IAM role to assume. - scan_unused_services: A boolean indicating whether to scan unused services. False by default. - resource_tags: A list of tags to filter the resources to audit. @@ -190,19 +196,47 @@ class AwsProvider(Provider): logger.info("Initializing AWS provider ...") + # Load provider config early because provider-level settings can affect + # bootstrap region selection before the scan starts. + if config_content is not None: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + excluded_regions = self.resolve_excluded_regions( + excluded_regions, self._audit_config + ) + + # Normalize excluded_regions and prune the include-list up front so + # every downstream consumer (identity, STS region, service/region + # enumeration) sees an already-filtered view. + if excluded_regions and regions: + regions = set(regions) - excluded_regions + if not regions: + raise AWSArgumentTypeValidationError( + message=( + "All requested AWS regions are excluded by the " + "disallowed regions configuration." + ), + file=pathlib.Path(__file__).name, + ) + ######## AWS Session logger.info("Generating original session ...") # TODO: Use AwsSetUpSession ????? # Configure the initial AWS Session using the local credentials: profile or environment variables + session_config = self.set_session_config(retries_max_attempts) aws_session = self.setup_session( mfa=mfa, profile=profile, aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, aws_session_token=aws_session_token, + session_config=session_config, ) - session_config = self.set_session_config(retries_max_attempts) # Current session and the original session points to the same session object until we get a new one, if needed self._session = AWSSession( current_session=aws_session, @@ -215,7 +249,7 @@ class AwsProvider(Provider): # After the session is created, validate it logger.info("Validating credentials ...") sts_region = get_aws_region_for_sts( - self.session.current_session.region_name, regions + self.session.current_session.region_name, regions, excluded_regions ) # Validate the credentials @@ -229,7 +263,9 @@ class AwsProvider(Provider): ######## AWS Provider Identity # Get profile region - profile_region = self.get_profile_region(self._session.current_session) + profile_region = self.get_profile_region( + self._session.current_session, excluded_regions + ) # Set identity self._identity = self.set_identity( @@ -284,8 +320,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 @@ -330,9 +374,50 @@ 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 + ) ######## - # Parse Scan Tags + # Get Enabled Regions + self._enabled_regions = self.get_aws_enabled_regions( + self._session.current_session + ) + + # Apply the exclusion to the account's enabled regions. This is the + # gate used by generate_regional_clients, so skipped regions never get + # a boto3 client created for them and cannot stall the scan. + if excluded_regions: + if self._enabled_regions is not None: + self._enabled_regions = self._enabled_regions - excluded_regions + if self._identity.audited_regions: + self._identity.audited_regions = ( + set(self._identity.audited_regions) - excluded_regions + ) + logger.info(f"Excluding AWS regions from scan: {sorted(excluded_regions)}") + self._excluded_regions = excluded_regions + + # Parse Scan Tags after region exclusions are applied so tag discovery + # also skips disallowed regions. if resource_tags: self._audit_resources = self.get_tagged_resources(resource_tags) @@ -340,22 +425,9 @@ class AwsProvider(Provider): if resource_arn: self._audit_resources = resource_arn - # Get Enabled Regions - self._enabled_regions = self.get_aws_enabled_regions( - self._session.current_session - ) - # Set ignore unused services self._scan_unused_services = scan_unused_services - # Audit Config - if config_content: - self._audit_config = config_content - else: - if not config_path: - config_path = default_config_file_path - self._audit_config = load_and_validate_config_file(self._type, config_path) - # Fixer Config self._fixer_config = fixer_config @@ -468,12 +540,53 @@ class AwsProvider(Provider): ) @staticmethod - def get_profile_region(session: Session): - profile_region = AWS_REGION_US_EAST_1 - if session.region_name: - profile_region = session.region_name + def resolve_excluded_regions( + excluded_regions: set | list | tuple | None, + audit_config: dict | None, + ) -> set[str]: + """Resolve AWS region exclusions with precedence arg > env > config.""" + if excluded_regions is not None: + raw_regions = excluded_regions + else: + raw_regions = Provider.get_excluded_regions_from_env() + if not raw_regions and isinstance(audit_config, dict): + raw_regions = audit_config.get("disallowed_regions") or [] - return profile_region + return {str(region).strip() for region in raw_regions if str(region).strip()} + + @staticmethod + def get_bootstrap_region_candidates(session_region: str | None) -> tuple[str, ...]: + """Return safe fallback regions for bootstrap AWS calls.""" + if session_region: + if session_region.startswith("cn-"): + return ("cn-north-1", "cn-northwest-1") + if session_region.startswith("us-gov-"): + return ("us-gov-east-1", "us-gov-west-1") + if session_region.startswith("eusc-"): + return ("eusc-de-east-1",) + if session_region.startswith("us-iso"): + return (session_region,) + + return (AWS_STS_GLOBAL_ENDPOINT_REGION, "us-east-2", "us-west-2", "eu-west-1") + + @staticmethod + def get_profile_region( + session: Session, excluded_regions: set[str] | None = None + ) -> str: + excluded_regions = set(excluded_regions or ()) + session_region = session.region_name + if session_region and session_region not in excluded_regions: + return session_region + + for region in AwsProvider.get_bootstrap_region_candidates(session_region): + if region not in excluded_regions: + if session_region and session_region != region: + logger.info( + f"Configured AWS profile region {session_region} is excluded; using {region} for bootstrap clients." + ) + return region + + return session_region or AWS_REGION_US_EAST_1 @staticmethod def set_identity( @@ -519,6 +632,7 @@ class AwsProvider(Provider): aws_access_key_id: str = None, aws_secret_access_key: str = None, aws_session_token: Optional[str] = None, + session_config: Optional[Config] = None, ) -> Session: """ setup_session sets up an AWS session using the provided credentials. @@ -529,6 +643,9 @@ class AwsProvider(Provider): - aws_access_key_id: The AWS access key ID. - aws_secret_access_key: The AWS secret access key. - aws_session_token: The AWS session token, optional. + - session_config: Botocore Config applied as the session's default + client config so every client created from the session inherits + the Prowler user agent and retry settings. Returns: - Session: The AWS session. @@ -539,6 +656,9 @@ class AwsProvider(Provider): try: logger.debug("Creating original session ...") + if session_config is None: + session_config = AwsProvider.set_session_config(None) + session_arguments = {} if profile: session_arguments["profile_name"] = profile @@ -550,6 +670,7 @@ class AwsProvider(Provider): if mfa: session = Session(**session_arguments) + session._session.set_default_client_config(session_config) sts_client = session.client("sts") # TODO: pass values from the input @@ -562,7 +683,7 @@ class AwsProvider(Provider): session_credentials = sts_client.get_session_token( **get_session_token_arguments ) - return Session( + mfa_session = Session( aws_access_key_id=session_credentials["Credentials"]["AccessKeyId"], aws_secret_access_key=session_credentials["Credentials"][ "SecretAccessKey" @@ -571,8 +692,12 @@ class AwsProvider(Provider): "SessionToken" ], ) + mfa_session._session.set_default_client_config(session_config) + return mfa_session else: - return Session(**session_arguments) + session = Session(**session_arguments) + session._session.set_default_client_config(session_config) + return session except Exception as error: logger.critical( f"AWSSetUpSessionError[{error.__traceback__.tb_lineno}]: {error}" @@ -587,6 +712,7 @@ class AwsProvider(Provider): identity: AWSIdentityInfo, assumed_role_configuration: AWSAssumeRoleConfiguration, session: AWSSession, + session_config: Optional[Config] = None, ) -> Session: """ Sets up an assumed session using the provided assumed role credentials. @@ -631,6 +757,13 @@ class AwsProvider(Provider): assumed_session = BotocoreSession() assumed_session._credentials = assumed_refreshable_credentials assumed_session.set_config_variable("region", identity.profile_region) + if session_config is None: + session_config = ( + session.session_config + if session is not None + else AwsProvider.set_session_config(None) + ) + assumed_session.set_default_client_config(session_config) return Session( profile_name=identity.profile, botocore_session=assumed_session, @@ -701,12 +834,15 @@ class AwsProvider(Provider): Caller Identity ARN: arn:aws:iam::123456789012:user/prowler ``` """ - # Beautify audited regions, set "all" if there is no filter region - regions = ( - ", ".join(self._identity.audited_regions) - if self._identity.audited_regions is not None - else "all" - ) + # Beautify audited regions. If the scan includes all regions but some + # are explicitly excluded, reflect that in the banner instead of + # showing the misleading "all" label. + if self._identity.audited_regions: + regions = ", ".join(sorted(self._identity.audited_regions)) + elif getattr(self, "_excluded_regions", None): + regions = f"all except {', '.join(sorted(self._excluded_regions))}" + else: + regions = "all" # Beautify audited profile, set "default" if there is no profile set profile = ( self._identity.profile if self._identity.profile is not None else "default" @@ -745,16 +881,18 @@ class AwsProvider(Provider): service_regions = AwsProvider.get_available_aws_service_regions( service, self._identity.partition, self._identity.audited_regions ) + if getattr(self, "_excluded_regions", None): + service_regions = service_regions - self._excluded_regions # Get the regions enabled for the account and get the intersection with the service available regions - if self._enabled_regions: + if self._enabled_regions is not None: enabled_regions = service_regions.intersection(self._enabled_regions) else: enabled_regions = service_regions for region in enabled_regions: regional_client = self._session.current_session.client( - service, region_name=region, config=self._session.session_config + service, region_name=region ) regional_client.region = region regional_clients[region] = regional_client @@ -962,6 +1100,8 @@ class AwsProvider(Provider): service_regions = AwsProvider.get_available_aws_service_regions( service, self._identity.partition, self._identity.audited_regions ) + if getattr(self, "_excluded_regions", None): + service_regions = service_regions - self._excluded_regions default_region = self.get_global_region() # global region of the partition when all regions are audited and there is no profile region if self._identity.profile_region in service_regions: @@ -1022,21 +1162,16 @@ class AwsProvider(Provider): Returns: - Config: The botocore Config object """ - # Set the maximum retries for the standard retrier config - default_session_config = Config( - retries={"max_attempts": 3, "mode": "standard"}, - user_agent_extra=BOTO3_USER_AGENT_EXTRA, - ) + default_session_config = get_default_session_config() if retries_max_attempts: - # Create the new config - config = Config( - retries={ - "max_attempts": retries_max_attempts, - "mode": "standard", - }, + default_session_config = default_session_config.merge( + Config( + retries={ + "max_attempts": retries_max_attempts, + "mode": "standard", + }, + ) ) - # Merge the new configuration - default_session_config = default_session_config.merge(config) return default_session_config @@ -1104,14 +1239,14 @@ class AwsProvider(Provider): file=pathlib.Path(__file__).name, ) - def get_aws_enabled_regions(self, current_session: Session) -> set: - """get_aws_enabled_regions returns a set of enabled AWS regions + def get_aws_enabled_regions(self, current_session: Session) -> set | None: + """get_aws_enabled_regions returns a set of enabled AWS regions, or None on failure. Args: - current_session: The AWS session object Returns: - - set: set of strings representing the enabled AWS regions + - set | None: set of enabled AWS region strings, or None if regions could not be determined """ try: # EC2 Client to check enabled regions @@ -1131,7 +1266,7 @@ class AwsProvider(Provider): logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - return set() + return None # TODO: review this function # Maybe this should be done within the AwsProvider and not in __main__.py @@ -1307,6 +1442,9 @@ class AwsProvider(Provider): region_name=aws_region, profile_name=profile, ) + session._session.set_default_client_config( + AwsProvider.set_session_config(None) + ) caller_identity = AwsProvider.validate_credentials(session, aws_region) # Do an extra validation if the AWS account ID is provided @@ -1565,13 +1703,19 @@ def read_aws_regions_file() -> dict: # TODO: This can be moved to another class since it doesn't need self -def get_aws_region_for_sts(session_region: str, regions: set[str]) -> str: +def get_aws_region_for_sts( + session_region: str, + regions: set[str], + excluded_regions: set[str] | None = None, +) -> str: """ Get the AWS region for the STS Assume Role operation. Args: - session_region (str): The region configured in the AWS session. - regions (set[str]): The regions passed with the -f/--region/--filter-region option. + - excluded_regions (set[str] | None): Regions that should be avoided for + bootstrap calls when possible. Returns: str: The AWS region for the STS Assume Role operation @@ -1579,20 +1723,21 @@ def get_aws_region_for_sts(session_region: str, regions: set[str]) -> str: Example: aws_region = get_aws_region_for_sts(session_region, regions) """ - # If there is no region passed with -f/--region/--filter-region - if regions is None or len(regions) == 0: - # If you have a region configured in your AWS config or credentials file - if session_region is not None: - aws_region = session_region - else: - # If there is no region set passed with -f/--region - # we use the Global STS Endpoint Region, us-east-1 - aws_region = AWS_STS_GLOBAL_ENDPOINT_REGION - else: - # Get the first region passed to the -f/--region - aws_region = list(regions)[0] + excluded_regions = set(excluded_regions or ()) - return aws_region + if regions: + for region in regions: + if region not in excluded_regions: + return region + + if session_region and session_region not in excluded_regions: + return session_region + + for region in AwsProvider.get_bootstrap_region_candidates(session_region): + if region not in excluded_regions: + return region + + return session_region or AWS_STS_GLOBAL_ENDPOINT_REGION # TODO: this duplicates the provider arguments validation library diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 297921370d..68e164b13e 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -482,6 +482,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -506,7 +507,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1231,6 +1234,21 @@ "aws-us-gov": [] } }, + "aws-devops-agent": { + "regions": { + "aws": [ + "ap-northeast-1", + "ap-southeast-2", + "eu-central-1", + "eu-west-1", + "us-east-1", + "us-west-2" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-us-gov": [] + } + }, "awshealthdashboard": { "regions": { "aws": [ @@ -1572,6 +1590,7 @@ "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -1589,6 +1608,7 @@ "ap-southeast-2", "ca-central-1", "eu-central-1", + "eu-south-2", "eu-west-1", "eu-west-2", "us-east-1", @@ -2192,6 +2212,8 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -2253,9 +2275,12 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", + "eu-central-2", "eu-north-1", "eu-south-1", "eu-south-2", @@ -2441,6 +2466,9 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-central-2", @@ -2554,6 +2582,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2563,6 +2592,9 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -2576,6 +2608,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -2586,7 +2619,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2793,6 +2828,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2803,6 +2839,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -3145,6 +3182,17 @@ "aws-us-gov": [] } }, + "connecthealth": { + "regions": { + "aws": [ + "us-east-1", + "us-west-2" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-us-gov": [] + } + }, "connectparticipant": { "regions": { "aws": [ @@ -3753,6 +3801,7 @@ "ap-southeast-5", "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", @@ -3815,7 +3864,9 @@ "us-west-2" ], "aws-cn": [], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3876,17 +3927,22 @@ "dsql": { "regions": { "aws": [ + "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", + "ap-south-1", + "ap-southeast-1", "ap-southeast-2", "ap-southeast-4", "ca-central-1", "ca-west-1", "eu-central-1", + "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -4561,6 +4617,14 @@ ] } }, + "elementalinference": { + "regions": { + "aws": [], + "aws-cn": [], + "aws-eusc": [], + "aws-us-gov": [] + } + }, "emr": { "regions": { "aws": [ @@ -4659,15 +4723,19 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -4681,6 +4749,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -5238,6 +5307,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5288,6 +5358,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5338,6 +5409,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5386,7 +5458,9 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5437,6 +5511,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5559,6 +5634,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5870,7 +5946,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6008,6 +6086,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6055,6 +6134,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6146,10 +6226,12 @@ "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "me-central-1", "me-south-1", "sa-east-1", @@ -6182,10 +6264,12 @@ "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "me-central-1", "me-south-1", "sa-east-1", @@ -6314,10 +6398,12 @@ "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "me-central-1", "me-south-1", "sa-east-1", @@ -6412,10 +6498,12 @@ "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "me-central-1", "me-south-1", "sa-east-1", @@ -7261,18 +7349,22 @@ "lightsail": { "regions": { "aws": [ + "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -7646,6 +7738,8 @@ "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-north-1", @@ -8030,6 +8124,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8040,8 +8135,10 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", @@ -8053,6 +8150,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -8179,7 +8277,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8244,21 +8344,31 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -8279,6 +8389,7 @@ "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", @@ -8524,6 +8635,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -8537,6 +8649,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -8553,7 +8666,6 @@ "notificationscontacts": { "regions": { "aws": [ - "ap-southeast-5", "us-east-1" ], "aws-cn": [], @@ -8669,10 +8781,20 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ap-south-2", "ap-southeast-2", + "ap-southeast-4", "ca-central-1", "eu-central-1", + "eu-central-2", + "eu-south-1", + "eu-south-2", "eu-west-1", + "eu-west-2", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -8994,6 +9116,7 @@ "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -9053,6 +9176,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9094,15 +9218,21 @@ "pcs": { "regions": { "aws": [ + "af-south-1", "ap-northeast-1", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", "eu-central-1", "eu-north-1", + "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -9336,6 +9466,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-central-2", @@ -9354,7 +9485,9 @@ "aws-cn": [ "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-west-1" ] @@ -9824,10 +9957,12 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", "ap-southeast-5", "ap-southeast-6", "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", @@ -9864,6 +9999,8 @@ "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-south-2", @@ -9971,7 +10108,10 @@ ], "aws-cn": [], "aws-eusc": [], - "aws-us-gov": [] + "aws-us-gov": [ + "us-gov-east-1", + "us-gov-west-1" + ] } }, "resource-groups": { @@ -10620,22 +10760,42 @@ "s3vectors": { "regions": { "aws": [ + "af-south-1", + "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "mx-central-1", + "sa-east-1", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], - "aws-cn": [], + "aws-cn": [ + "cn-north-1", + "cn-northwest-1" + ], "aws-eusc": [], "aws-us-gov": [] } @@ -11257,7 +11417,9 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-6", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", @@ -11589,26 +11751,6 @@ ] } }, - "simspaceweaver": { - "regions": { - "aws": [ - "ap-southeast-1", - "ap-southeast-2", - "eu-central-1", - "eu-north-1", - "eu-west-1", - "us-east-1", - "us-east-2", - "us-west-2" - ], - "aws-cn": [], - "aws-eusc": [], - "aws-us-gov": [ - "us-gov-east-1", - "us-gov-west-1" - ] - } - }, "sms": { "regions": { "aws": [ @@ -11778,26 +11920,38 @@ "regions": { "aws": [ "af-south-1", + "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", "ap-south-2", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", + "eu-west-3", + "il-central-1", "me-central-1", "me-south-1", "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], "aws-cn": [], @@ -12061,7 +12215,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12284,6 +12440,17 @@ "aws-us-gov": [] } }, + "sustainability": { + "regions": { + "aws": [ + "us-east-1", + "us-west-2" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-us-gov": [] + } + }, "swf": { "regions": { "aws": [ @@ -12461,6 +12628,7 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", @@ -12474,6 +12642,7 @@ "eu-west-2", "eu-west-3", "me-central-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -12759,6 +12928,16 @@ ] } }, + "uxc": { + "regions": { + "aws": [ + "us-east-1" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-us-gov": [] + } + }, "verified-access": { "regions": { "aws": [ @@ -12968,6 +13147,7 @@ "eu-west-3", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -13319,6 +13499,7 @@ "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-west-1", @@ -13327,6 +13508,7 @@ "il-central-1", "sa-east-1", "us-east-1", + "us-east-2", "us-west-2" ], "aws-cn": [ diff --git a/prowler/providers/aws/config.py b/prowler/providers/aws/config.py index 0384900fdc..ea55d1a314 100644 --- a/prowler/providers/aws/config.py +++ b/prowler/providers/aws/config.py @@ -1,4 +1,15 @@ +import os + +from botocore.config import Config + 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" + + +def get_default_session_config() -> Config: + return Config( + user_agent_extra=BOTO3_USER_AGENT_EXTRA, + retries={"max_attempts": 3, "mode": "standard"}, + ) diff --git a/prowler/providers/aws/lib/arguments/arguments.py b/prowler/providers/aws/lib/arguments/arguments.py index eb611c0d04..50f4665b2d 100644 --- a/prowler/providers/aws/lib/arguments/arguments.py +++ b/prowler/providers/aws/lib/arguments/arguments.py @@ -66,6 +66,16 @@ def init_parser(self): help="AWS region names to run Prowler against", choices=AwsProvider.get_regions(partition=None), ) + aws_regions_subparser.add_argument( + "--excluded-region", + "--excluded-regions", + nargs="+", + help=( + "AWS region names to exclude from the scan. Overrides the " + "PROWLER_AWS_DISALLOWED_REGIONS environment variable when set." + ), + choices=AwsProvider.get_regions(partition=None), + ) # AWS Organizations aws_orgs_subparser = aws_parser.add_argument_group("AWS Organizations") aws_orgs_subparser.add_argument( diff --git a/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py index 45dff270d0..2f03dd8c84 100644 --- a/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py +++ b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py @@ -135,25 +135,54 @@ class CloudTrailTimeline(TimelineService): ) -> List[Dict[str, Any]]: """Query CloudTrail for events related to a specific resource. - Uses MaxResults to limit the number of events returned, preparing - for API-level pagination. Currently returns up to max_results events - from the first page only. + CloudTrail's ResourceName attribute is populated per-service by AWS + and is not consistent: KMS and SNS store full ARNs, while S3, IAM, + EC2, Lambda, RDS and others store only the resource name or ID. We + first look up using the identifier as-is, and if no events come back + we retry with the last segment extracted from the ARN. """ client = self._get_client(region) start_time = datetime.now(timezone.utc) - timedelta(days=self._lookback_days) - # Use direct API call with MaxResults instead of paginator - # This limits CloudTrail to return only max_results events + events = self._lookup_events_by_name(client, resource_identifier, start_time) + + if not events and resource_identifier.startswith("arn:"): + short_name = self._extract_short_name(resource_identifier) + if short_name and short_name != resource_identifier: + logger.debug( + f"CloudTrail timeline: no events for '{resource_identifier}', " + f"retrying lookup with short name '{short_name}'" + ) + events = self._lookup_events_by_name(client, short_name, start_time) + + return events + + def _lookup_events_by_name( + self, client, resource_name: str, start_time: datetime + ) -> List[Dict[str, Any]]: response = client.lookup_events( LookupAttributes=[ - {"AttributeKey": "ResourceName", "AttributeValue": resource_identifier} + {"AttributeKey": "ResourceName", "AttributeValue": resource_name} ], StartTime=start_time, MaxResults=self._max_results, ) - return response.get("Events", []) + @staticmethod + def _extract_short_name(identifier: str) -> str: + """Return the last segment of an ARN or identifier. + + ARNs take the form `arn:partition:service:region:account:resource-info` + where resource-info is one of `name`, `type/name`, or `type:name`. + Splitting on the final `/` and then the final `:` yields the value + CloudTrail stores for most services: S3 bucket name, IAM user/role + name, EC2 resource ID, Lambda function name, RDS DB identifier, etc. + """ + if not identifier: + return identifier + return identifier.rsplit("/", 1)[-1].rsplit(":", 1)[-1] + def _parse_event(self, raw_event: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Parse a raw CloudTrail event into a TimelineEvent dictionary.""" try: @@ -192,27 +221,12 @@ class CloudTrailTimeline(TimelineService): @staticmethod def _extract_actor(user_identity: Dict[str, Any]) -> str: - """Extract a human-readable actor name from CloudTrail userIdentity.""" - # Try ARN first - most reliable + """Return a compact actor name from CloudTrail userIdentity. + + For ARNs, returns the resource portion (everything after the last + `:`) — e.g. `user/alice`, `assumed-role/MyRole/session-name`, + `root`. The full ARN is preserved separately in `actor_uid`. + """ if arn := user_identity.get("arn"): - if "/" in arn: - parts = arn.split("/") - # For assumed-role, return the role name (second-to-last part) - if "assumed-role" in arn and len(parts) >= 2: - return parts[-2] - return parts[-1] - return arn.split(":")[-1] - - # Fall back to userName - if username := user_identity.get("userName"): - return username - - # Fall back to principalId - if principal_id := user_identity.get("principalId"): - return principal_id - - # For service-invoked actions - if invoking_service := user_identity.get("invokedBy"): - return invoking_service - - return "Unknown" + return arn.rsplit(":", 1)[-1] + return user_identity.get("invokedBy") or "Unknown" diff --git a/prowler/providers/aws/lib/ip_ranges/__init__.py b/prowler/providers/aws/lib/ip_ranges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/lib/ip_ranges/ip_ranges.py b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py new file mode 100644 index 0000000000..3dbb9d1b17 --- /dev/null +++ b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py @@ -0,0 +1,51 @@ +import json +import urllib.error +import urllib.request +from ipaddress import ip_network + +from prowler.lib.logger import logger + +AWS_IP_RANGES_URL = "https://ip-ranges.amazonaws.com/ip-ranges.json" +AWS_IP_RANGES_TIMEOUT = 10 + + +def get_public_ip_networks() -> list: + """Fetch the AWS public IP prefixes as a list of ip_network objects. + + The request verifies the server certificate against the system trust store, + matching urllib's default behaviour. This replaces the unmaintained + awsipranges package, whose latest release (0.3.3) calls + urllib.request.urlopen() with the cafile/capath arguments that Python 3.13 + removed. + + Returns an empty list when the feed cannot be fetched or parsed, and skips + individual malformed prefixes, so a transient or corrupt feed never aborts + the calling check. + """ + try: + with urllib.request.urlopen( + AWS_IP_RANGES_URL, timeout=AWS_IP_RANGES_TIMEOUT + ) as response: + ranges = json.loads(response.read()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [] + + networks = [] + for key, prefixes in ( + ("ip_prefix", ranges.get("prefixes", [])), + ("ipv6_prefix", ranges.get("ipv6_prefixes", [])), + ): + for prefix in prefixes: + cidr = prefix.get(key) + if not cidr: + continue + try: + networks.append(ip_network(cidr)) + except ValueError as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return networks diff --git a/prowler/providers/aws/lib/quick_inventory/quick_inventory.py b/prowler/providers/aws/lib/quick_inventory/quick_inventory.py index 8e7bc6e450..8ffdd80e7b 100644 --- a/prowler/providers/aws/lib/quick_inventory/quick_inventory.py +++ b/prowler/providers/aws/lib/quick_inventory/quick_inventory.py @@ -30,10 +30,12 @@ def quick_inventory(provider: AwsProvider, args): ec2_client = provider.session.current_session.client( "ec2", region_name=provider.identity.profile_region ) + excluded_regions = getattr(provider, "_excluded_regions", set()) # Get all the available regions provider.identity.audited_regions = [ region["RegionName"] for region in ec2_client.describe_regions()["Regions"] + if region["RegionName"] not in excluded_regions ] with alive_bar( @@ -54,9 +56,7 @@ def quick_inventory(provider: AwsProvider, args): try: # Scan IAM only once if not iam_was_scanned: - global_resources.extend( - get_iam_resources(provider.session.current_session) - ) + global_resources.extend(get_iam_resources(provider)) iam_was_scanned = True # Get regional S3 buckets since none-tagged buckets are not supported by the resourcegroupstaggingapi @@ -310,8 +310,8 @@ def create_output(resources: list, provider: AwsProvider, args): if args.output_bucket: output_bucket = args.output_bucket bucket_session = provider.session.current_session - # Check if -D was input - elif args.output_bucket_no_assume: + # The outer condition guarantees -D was input when -B was not + else: output_bucket = args.output_bucket_no_assume bucket_session = provider.session.original_session @@ -373,9 +373,9 @@ def get_regional_buckets(provider: AwsProvider, region: str) -> list: return regional_buckets -def get_iam_resources(session) -> list: +def get_iam_resources(provider: AwsProvider) -> list: iam_resources = [] - iam_client = session.client("iam") + iam_client = provider.session.current_session.client("iam") try: get_roles_paginator = iam_client.get_paginator("list_roles") for page in get_roles_paginator.paginate(): diff --git a/prowler/providers/aws/lib/s3/s3.py b/prowler/providers/aws/lib/s3/s3.py index ae609e67c6..a4bbb42cc7 100644 --- a/prowler/providers/aws/lib/s3/s3.py +++ b/prowler/providers/aws/lib/s3/s3.py @@ -111,6 +111,13 @@ class S3: - None """ if session: + # Preserve the caller's existing default config (and the + # retries_max_attempts already baked into it) instead of clobbering + # it with a freshly built one. + if session._session.get_default_client_config() is None: + session._session.set_default_client_config( + AwsProvider.set_session_config(retries_max_attempts) + ) self._session = session.client(__class__.__name__.lower()) else: aws_setup_session = AwsSetUpSession( @@ -127,8 +134,7 @@ class S3: regions=regions, ) self._session = aws_setup_session._session.current_session.client( - __class__.__name__.lower(), - config=aws_setup_session._session.session_config, + __class__.__name__.lower() ) self._bucket_name = bucket_name @@ -313,6 +319,9 @@ class S3: region_name=aws_region, profile_name=profile, ) + session._session.set_default_client_config( + AwsProvider.set_session_config(None) + ) s3_client = session.client(__class__.__name__.lower()) if "s3://" in bucket_name: bucket_name = bucket_name.removeprefix("s3://") diff --git a/prowler/providers/aws/lib/security_hub/security_hub.py b/prowler/providers/aws/lib/security_hub/security_hub.py index a45d36c2fd..bc372d1ddd 100644 --- a/prowler/providers/aws/lib/security_hub/security_hub.py +++ b/prowler/providers/aws/lib/security_hub/security_hub.py @@ -148,6 +148,13 @@ class SecurityHub: regions=regions, ) self._session = aws_setup_session._session.current_session + # Only install the Prowler default config when the caller-supplied + # session does not already carry one — overwriting would drop the + # provider's retries_max_attempts value. + if aws_session and self._session._session.get_default_client_config() is None: + self._session._session.set_default_client_config( + AwsProvider.set_session_config(retries_max_attempts) + ) self._aws_account_id = aws_account_id if not aws_partition: aws_partition = AwsProvider.validate_credentials( @@ -235,7 +242,7 @@ class SecurityHub: Args: region (str): AWS region to check. - session (Session): AWS session object. + session (Session): AWS session object. Expected to carry the Prowler default client config. aws_account_id (str): AWS account ID. aws_partition (str): AWS partition. @@ -540,6 +547,9 @@ class SecurityHub: region_name=aws_region, profile_name=profile, ) + session._session.set_default_client_config( + AwsProvider.set_session_config(None) + ) all_regions = AwsProvider.get_available_aws_service_regions( service="securityhub", partition=aws_partition diff --git a/prowler/providers/aws/lib/service/service.py b/prowler/providers/aws/lib/service/service.py index 1044a21881..ac241c64a2 100644 --- a/prowler/providers/aws/lib/service/service.py +++ b/prowler/providers/aws/lib/service/service.py @@ -32,7 +32,13 @@ class AWSService: def is_failed_check(cls, check_id, arn): return (check_id.split(".")[-1], arn) in cls.failed_checks - def __init__(self, service: str, provider: AwsProvider, global_service=False): + def __init__( + self, + service: str, + provider: AwsProvider, + global_service=False, + region: str = None, + ): # Audit Information # Do we need to store the whole provider? self.provider = provider @@ -61,7 +67,7 @@ class AWSService: # Get a single region and client if the service needs it (e.g. AWS Global Service) # We cannot include this within an else because some services needs both the regional_clients # and a single client like S3 - self.region = provider.get_default_region( + self.region = region or provider.get_default_region( self.service, global_service=global_service ) self.client = self.session.client(self.service, self.region) diff --git a/prowler/providers/aws/lib/session/aws_set_up_session.py b/prowler/providers/aws/lib/session/aws_set_up_session.py index 9246c0a9eb..3189400040 100644 --- a/prowler/providers/aws/lib/session/aws_set_up_session.py +++ b/prowler/providers/aws/lib/session/aws_set_up_session.py @@ -73,15 +73,15 @@ class AwsSetUpSession: aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, ) - # Setup the AWS session + session_config = AwsProvider.set_session_config(retries_max_attempts) aws_session = AwsProvider.setup_session( mfa=mfa, profile=profile, aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, aws_session_token=aws_session_token, + session_config=session_config, ) - session_config = AwsProvider.set_session_config(retries_max_attempts) self._session = AWSSession( current_session=aws_session, session_config=session_config, diff --git a/prowler/providers/aws/services/acmpca/__init__.py b/prowler/providers/aws/services/acmpca/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json new file mode 100644 index 0000000000..6915540539 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "acmpca_certificate_authority_pqc_key_algorithm", + "CheckTitle": "AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "acmpca", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsAcmPcaCertificateAuthority", + "ResourceGroup": "security", + "Description": "**AWS Private Certificate Authorities (Private CAs)** are assessed for use of a **post-quantum digital signature key algorithm** (`ML_DSA_44`, `ML_DSA_65`, `ML_DSA_87`). CAs that still issue certificates with RSA or ECC algorithms produce signatures vulnerable to forgery once a cryptographically relevant quantum computer is available.", + "Risk": "RSA and ECC signatures can be broken by Shor's algorithm on a sufficiently large quantum computer. A compromised CA private key would let an attacker issue arbitrary certificates trusted across the PKI, undermining identity and code-signing controls. Migrating CAs to **ML-DSA** (NIST FIPS 204) provides quantum-resistant signatures so issued certificates retain integrity in the post-quantum era.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/privateca/latest/userguide/PcaTerms.html", + "https://aws.amazon.com/about-aws/whats-new/2025/11/aws-private-ca-post-quantum-digital-certificates/", + "https://aws.amazon.com/blogs/security/post-quantum-ml-dsa-code-signing-with-aws-private-ca-and-aws-kms/", + "https://csrc.nist.gov/pubs/fips/204/final" + ], + "Remediation": { + "Code": { + "CLI": "aws acm-pca create-certificate-authority --certificate-authority-configuration '{\"KeyAlgorithm\":\"ML_DSA_65\",\"SigningAlgorithm\":\"ML_DSA_65\",\"Subject\":{...}}' --certificate-authority-type SUBORDINATE", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ACMPCA::CertificateAuthority\n Properties:\n Type: SUBORDINATE\n KeyAlgorithm: ML_DSA_65 # FIX: post-quantum signature algorithm\n SigningAlgorithm: ML_DSA_65\n Subject:\n CommonName: example-pqc-ca\n```", + "Other": "Existing CAs cannot have their key algorithm changed; create a new CA with KeyAlgorithm = ML_DSA_44 / ML_DSA_65 / ML_DSA_87, re-issue certificates from it, and decommission the legacy CA once dependent workloads have rotated.", + "Terraform": "```hcl\nresource \"aws_acmpca_certificate_authority\" \"\" {\n type = \"SUBORDINATE\"\n certificate_authority_configuration {\n key_algorithm = \"ML_DSA_65\" # FIX: post-quantum signature algorithm\n signing_algorithm = \"ML_DSA_65\"\n subject {\n common_name = \"example-pqc-ca\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Create new Private CAs with a **post-quantum key algorithm** (`ML_DSA_44`, `ML_DSA_65`, or `ML_DSA_87`) and migrate workloads off legacy RSA/ECC CAs. Plan crypto-agility for your PKI so that quantum-resistant trust anchors can be rolled out before threat actors gain access to a cryptographically relevant quantum computer.", + "Url": "https://hub.prowler.com/check/acmpca_certificate_authority_pqc_key_algorithm" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py new file mode 100644 index 0000000000..738875baee --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client + +PQC_PCA_KEY_ALGORITHMS_DEFAULT = [ + "ML_DSA_44", + "ML_DSA_65", + "ML_DSA_87", +] + + +class acmpca_certificate_authority_pqc_key_algorithm(Check): + """Verify that every AWS Private CA uses a post-quantum key algorithm. + + A Private CA PASSES when its ``KeyAlgorithm`` belongs to the configured + allowlist of post-quantum signature algorithms (ML-DSA family). + Deleted CAs are skipped. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check against AWS Private CAs. + + Returns: + A list of reports with each non-deleted CA PQC key algorithm status. + """ + + findings = [] + pqc_algorithms = acmpca_client.audit_config.get( + "acmpca_pqc_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT + ) + for ca in acmpca_client.certificate_authorities.values(): + if ca.status == "DELETED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=ca) + algorithm = ca.key_algorithm or "" + if ca.key_algorithm in pqc_algorithms: + report.status = "PASS" + report.status_extended = ( + f"AWS Private CA {ca.id} uses post-quantum key algorithm " + f"{algorithm}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"AWS Private CA {ca.id} uses key algorithm {algorithm}, " + "which is not post-quantum (ML-DSA)." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/acmpca/acmpca_client.py b/prowler/providers/aws/services/acmpca/acmpca_client.py new file mode 100644 index 0000000000..c8e22730bc --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_client.py @@ -0,0 +1,4 @@ +from prowler.providers.aws.services.acmpca.acmpca_service import ACMPCA +from prowler.providers.common.provider import Provider + +acmpca_client = ACMPCA(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/acmpca/acmpca_service.py b/prowler/providers/aws/services/acmpca/acmpca_service.py new file mode 100644 index 0000000000..9c6fb77096 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_service.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.aws_provider import AwsProvider +from prowler.providers.aws.lib.service.service import AWSService + + +class ACMPCA(AWSService): + """AWS Private CA service class to list certificate authorities.""" + + def __init__(self, provider: AwsProvider) -> None: + """Initialize the AWS Private CA service. + + Args: + provider: AWS provider instance with session and audit context. + """ + + # The boto3 client identifier for AWS Private CA is "acm-pca" + super().__init__("acm-pca", provider) + self.certificate_authorities: dict[str, CertificateAuthority] = {} + self.__threading_call__(self._list_certificate_authorities) + + def _list_certificate_authorities(self, regional_client: Any) -> None: + """List AWS Private CAs and their tags in a region. + + Args: + regional_client: Regional AWS Private CA client. + """ + + logger.info("ACM PCA - Listing Certificate Authorities...") + try: + paginator = regional_client.get_paginator("list_certificate_authorities") + for page in paginator.paginate(): + for ca in page.get("CertificateAuthorities", []): + arn = ca.get("Arn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + config = ca.get("CertificateAuthorityConfiguration", {}) + tags = [] + try: + tags = regional_client.list_tags( + CertificateAuthorityArn=arn + ).get("Tags", []) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.certificate_authorities[arn] = CertificateAuthority( + arn=arn, + id=arn.split("/")[-1], + region=regional_client.region, + status=ca.get("Status", ""), + type=ca.get("Type", ""), + usage_mode=ca.get("UsageMode", ""), + key_algorithm=config.get("KeyAlgorithm", ""), + signing_algorithm=config.get("SigningAlgorithm", ""), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class CertificateAuthority(BaseModel): + """AWS Private Certificate Authority metadata. + + Attributes: + arn: Certificate authority ARN. + id: Certificate authority identifier. + region: AWS region where the certificate authority exists. + status: Certificate authority lifecycle status. + type: Certificate authority type. + usage_mode: Certificate authority usage mode. + key_algorithm: Key algorithm configured for the certificate authority. + signing_algorithm: Signing algorithm configured for the certificate authority. + tags: Tags attached to the certificate authority. + """ + + arn: str + id: str + region: str + status: str = "" + type: str = "" + usage_mode: str = "" + key_algorithm: str = "" + signing_algorithm: str = "" + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/__init__.py b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json new file mode 100644 index 0000000000..276f6e1741 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "apigateway_domain_name_pqc_tls_enabled", + "CheckTitle": "API Gateway custom domain names use a post-quantum TLS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "apigateway", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsApiGatewayDomainName", + "ResourceGroup": "network", + "Description": "**API Gateway custom domain names** for REST APIs are assessed for use of a **post-quantum (PQ) TLS security policy** such as `SecurityPolicy_TLS13_1_2_PQ_2025_09`. Custom domains with legacy policies such as `TLS_1_0` or `TLS_1_2` lack hybrid ML-KEM key exchange, leaving captured traffic vulnerable to future quantum decryption.", + "Risk": "Without a PQ-ready TLS policy, traffic to API Gateway custom domains captured today can be decrypted once a **cryptographically relevant quantum computer** exists (**harvest-now, decrypt-later** attack). This threatens long-term **confidentiality** of API payloads, credentials, and bearer tokens.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-custom-domain-tls-version.html", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-security-policies-list.html", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws apigateway update-domain-name --domain-name --patch-operations op=replace,path=/securityPolicy,value=SecurityPolicy_TLS13_1_2_PQ_2025_09", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ApiGateway::DomainName\n Properties:\n DomainName: api.example.com\n RegionalCertificateArn: \n SecurityPolicy: SecurityPolicy_TLS13_1_2_PQ_2025_09 # FIX: enhanced post-quantum security policy\n EndpointConfiguration:\n Types:\n - REGIONAL\n```", + "Other": "1. In the AWS Console, go to API Gateway > Custom domain names\n2. Select the custom domain and choose Edit on Domain name configurations\n3. Set Security policy to SecurityPolicy_TLS13_1_2_PQ_2025_09 (post-quantum) and Endpoint access mode to Strict\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_api_gateway_domain_name\" \"\" {\n domain_name = \"api.example.com\"\n regional_certificate_arn = \"\"\n security_policy = \"SecurityPolicy_TLS13_1_2_PQ_2025_09\" # FIX: enhanced post-quantum security policy\n endpoint_configuration {\n types = [\"REGIONAL\"]\n }\n}\n```" + }, + "Recommendation": { + "Text": "Migrate every API Gateway custom domain name to an **enhanced post-quantum TLS policy** such as `SecurityPolicy_TLS13_1_2_PQ_2025_09` that enables hybrid ML-KEM key exchange. Note that you must also enable Strict endpoint access mode and that mutual TLS is not supported on enhanced policies. Review allowed policies regularly as AWS publishes new PQ-ready options.", + "Url": "https://hub.prowler.com/check/apigateway_domain_name_pqc_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "API Gateway HTTP and WebSocket APIs only support the legacy TLS_1_2 security policy and therefore cannot use post-quantum TLS today; this check evaluates REST API custom domain names only." +} diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py new file mode 100644 index 0000000000..d9c2d071f9 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py @@ -0,0 +1,55 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.apigateway.apigateway_client import ( + apigateway_client, +) + +PQC_APIGATEWAY_POLICIES_DEFAULT = [ + "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09", + "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09", + "SecurityPolicy_TLS13_1_2_PQ_2025_09", +] + + +def _get_allowed_policies(configured_policies: object) -> list[str]: + if not isinstance(configured_policies, list): + return PQC_APIGATEWAY_POLICIES_DEFAULT + + return configured_policies + + +class apigateway_domain_name_pqc_tls_enabled(Check): + """Verify that every API Gateway custom domain name uses a post-quantum TLS policy. + + A custom domain name PASSES when its ``securityPolicy`` belongs to the + configured allowlist of enhanced post-quantum policies. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the API Gateway custom domain post-quantum TLS check. + + Returns: + A list of reports for API Gateway custom domain names and their + post-quantum TLS policy compliance status. + """ + findings = [] + pqc_policies = _get_allowed_policies( + apigateway_client.audit_config.get("apigateway_pqc_tls_allowed_policies") + ) + for domain in apigateway_client.domain_names: + report = Check_Report_AWS(metadata=self.metadata(), resource=domain) + policy = domain.security_policy or "" + if domain.security_policy in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"API Gateway custom domain {domain.name} uses post-quantum " + f"TLS policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"API Gateway custom domain {domain.name} uses TLS policy " + f"{policy}, which is not in the post-quantum allowlist." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/apigateway/apigateway_service.py b/prowler/providers/aws/services/apigateway/apigateway_service.py index 4f887a2da8..099551f440 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_service.py +++ b/prowler/providers/aws/services/apigateway/apigateway_service.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Optional from botocore.exceptions import ClientError from pydantic.v1 import BaseModel @@ -13,12 +13,45 @@ class APIGateway(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.rest_apis = [] + self.domain_names = [] self.__threading_call__(self._get_rest_apis) + self.__threading_call__(self._get_domain_names) self._get_authorizers() self._get_rest_api() self._get_stages() self._get_resources() + def _get_domain_names(self, regional_client: Any) -> None: + """Get API Gateway custom domain names for a regional client. + + Args: + regional_client: Regional API Gateway boto3 client used to list + custom domain names. + """ + logger.info("APIGateway - Getting custom domain names...") + try: + paginator = regional_client.get_paginator("get_domain_names") + for page in paginator.paginate(): + for item in page.get("items", []): + domain_name = item.get("domainName", "") + arn = f"arn:{self.audited_partition}:apigateway:{regional_client.region}::/domainnames/{domain_name}" + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + self.domain_names.append( + DomainName( + name=domain_name, + arn=arn, + region=regional_client.region, + security_policy=item.get("securityPolicy", ""), + tags=[item.get("tags", {})], + ) + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_rest_apis(self, regional_client): logger.info("APIGateway - Getting Rest APIs...") try: @@ -249,3 +282,21 @@ class RestAPI(BaseModel): stages: list[Stage] = [] tags: Optional[list] = [] resources: list[PathResourceMethods] = [] + + +class DomainName(BaseModel): + """API Gateway custom domain name metadata. + + Attributes: + name: Custom domain name. + arn: Custom domain name ARN. + region: AWS region where the custom domain name exists. + security_policy: TLS security policy configured for the custom domain. + tags: Custom domain tags. + """ + + name: str + arn: str + region: str + security_policy: str = "" + tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py b/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py index 6595b71085..dbe6fc534c 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py +++ b/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.py @@ -4,7 +4,11 @@ from base64 import b64decode from prowler.config.config import encoding_format_utf_8 from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.logger import logger -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.autoscaling.autoscaling_client import ( autoscaling_client, ) @@ -16,13 +20,19 @@ class autoscaling_find_secrets_ec2_launch_configuration(Check): secrets_ignore_patterns = autoscaling_client.audit_config.get( "secrets_ignore_patterns", [] ) - for ( - configuration_arn, - configuration, - ) in autoscaling_client.launch_configurations.items(): - report = Check_Report_AWS(metadata=self.metadata(), resource=configuration) + validate = autoscaling_client.audit_config.get("secrets_validate", False) + configurations = list(autoscaling_client.launch_configurations.values()) - if configuration.user_data: + # Collect the decoded User Data of each launch configuration and scan it + # all in batched Kingfisher invocations instead of one subprocess each. + # Configurations whose User Data cannot be decoded are undecodable (no report), + # matching the original per-resource behavior. + undecodable = set() + + def payloads(): + for index, configuration in enumerate(configurations): + if not configuration.user_data: + continue user_data = b64decode(configuration.user_data) try: if user_data[0:2] == b"\x1f\x8b": # GZIP magic number @@ -35,24 +45,46 @@ class autoscaling_find_secrets_ec2_launch_configuration(Check): logger.warning( f"{configuration.region} -- Unable to decode user data in autoscaling launch configuration {configuration.name}: {error}" ) + undecodable.add(index) continue except Exception as error: logger.error( f"{configuration.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + undecodable.add(index) continue + yield index, user_data - has_secrets = detect_secrets_scan( - data=user_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=autoscaling_client.audit_config.get( - "detect_secrets_plugins" - ), + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, configuration in enumerate(configurations): + report = Check_Report_AWS(metadata=self.metadata(), resource=configuration) + + if scan_error and configuration.user_data: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan autoscaling {configuration.name} User Data for " + f"secrets: {scan_error}; manual review is required." ) + findings.append(report) + continue + if index in undecodable: + report.status = "MANUAL" + report.status_extended = f"Could not decode User Data for autoscaling {configuration.name}; manual review is required to scan for secrets." + elif configuration.user_data: + has_secrets = batch_results.get(index) if has_secrets: report.status = "FAIL" report.status_extended = f"Potential secret found in autoscaling {configuration.name} User Data." + annotate_verified_secrets(report, has_secrets) else: report.status = "PASS" report.status_extended = f"No secrets found in autoscaling {configuration.name} User Data." diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py index 51c56a2011..66ecf1e88f 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.py @@ -1,65 +1,105 @@ import os import tempfile +from collections import defaultdict from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client class awslambda_function_no_secrets_in_code(Check): def execute(self): findings = [] - if awslambda_client.functions: - secrets_ignore_patterns = awslambda_client.audit_config.get( - "secrets_ignore_patterns", [] - ) + if not awslambda_client.functions: + return findings + + secrets_ignore_patterns = awslambda_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = awslambda_client.audit_config.get("secrets_validate", False) + + # Scan the top-level files of every function's package in batched + # Kingfisher invocations instead of one subprocess per file per function. + # Each package is extracted one at a time and its top-level files are + # read (byte-faithfully via latin-1) before the extraction is released, + # so only a single package is on disk at a time. Findings are keyed by + # (function index, file name) so they can be grouped back per function. + functions_with_code = [] + + def code_payloads(): for function, function_code in awslambda_client._get_function_code(): - if function_code: - report = Check_Report_AWS( - metadata=self.metadata(), resource=function + if not function_code: + continue + index = len(functions_with_code) + functions_with_code.append(function) + with tempfile.TemporaryDirectory() as tmp_dir_name: + function_code.code_zip.extractall(tmp_dir_name) + for file_name in next(os.walk(tmp_dir_name))[2]: + try: + with open( + os.path.join(tmp_dir_name, file_name), "rb" + ) as code_file: + content = code_file.read().decode("latin-1") + except Exception: + continue + yield (index, file_name), content + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + code_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + if scan_error: + # The scan failed before any function's code could be cleared. Report + # MANUAL for every function rather than risk a false PASS. + for function in awslambda_client.functions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=function) + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Lambda function {function.name} code for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + return findings + + findings_by_function = defaultdict(dict) + for (index, file_name), file_findings in batch_results.items(): + findings_by_function[index][file_name] = file_findings + + for index, function in enumerate(functions_with_code): + report = Check_Report_AWS(metadata=self.metadata(), resource=function) + report.status = "PASS" + report.status_extended = ( + f"No secrets found in Lambda function {function.name} code." + ) + + files_with_secrets = findings_by_function.get(index) + if files_with_secrets: + all_secrets = [] + secrets_findings = [] + for file_name, file_findings in files_with_secrets.items(): + all_secrets.extend(file_findings) + secrets_string = ", ".join( + f"{secret['type']} on line {secret['line_number']}" + for secret in file_findings ) + secrets_findings.append(f"{file_name}: {secrets_string}") - report.status = "PASS" - report.status_extended = ( - f"No secrets found in Lambda function {function.name} code." - ) - with tempfile.TemporaryDirectory() as tmp_dir_name: - function_code.code_zip.extractall(tmp_dir_name) - # List all files - files_in_zip = next(os.walk(tmp_dir_name))[2] - secrets_findings = [] - for file in files_in_zip: - detect_secrets_output = detect_secrets_scan( - file=f"{tmp_dir_name}/{file}", - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=awslambda_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - if detect_secrets_output: - for ( - secret - ) in ( - detect_secrets_output - ): # Appears that only 1 file is being scanned at a time, so could rework this - output_file_name = secret["filename"].replace( - f"{tmp_dir_name}/", "" - ) - secrets_string = ", ".join( - [ - f"{secret['type']} on line {secret['line_number']}" - for secret in detect_secrets_output - ] - ) - secrets_findings.append( - f"{output_file_name}: {secrets_string}" - ) + final_output_string = "; ".join(secrets_findings) + report.status = "FAIL" + report.status_extended = f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} found in Lambda function {function.name} code -> {final_output_string}." + annotate_verified_secrets(report, all_secrets) - if secrets_findings: - final_output_string = "; ".join(secrets_findings) - report.status = "FAIL" - report.status_extended = f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} found in Lambda function {function.name} code -> {final_output_string}." - - findings.append(report) + findings.append(report) return findings diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py index 9448b0239f..065cc7a9b9 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.py @@ -1,7 +1,11 @@ import json from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client @@ -11,7 +15,30 @@ class awslambda_function_no_secrets_in_variables(Check): secrets_ignore_patterns = awslambda_client.audit_config.get( "secrets_ignore_patterns", [] ) - for function in awslambda_client.functions.values(): + validate = awslambda_client.audit_config.get("secrets_validate", False) + functions = list(awslambda_client.functions.values()) + + # Scan every function's environment variables in batched Kingfisher + # invocations instead of one subprocess per function. Payloads are + # yielded lazily so only a chunk is held/written at a time, which matters + # for accounts with very large numbers of Lambda functions. + def environment_payloads(): + for index, function in enumerate(functions): + if function.environment: + yield index, json.dumps(function.environment, indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + environment_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, function in enumerate(functions): report = Check_Report_AWS(metadata=self.metadata(), resource=function) report.status = "PASS" @@ -20,17 +47,17 @@ class awslambda_function_no_secrets_in_variables(Check): ) if function.environment: - detect_secrets_output = detect_secrets_scan( - data=json.dumps(function.environment, indent=2), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=awslambda_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - original_env_vars = [] - for name, value in function.environment.items(): - original_env_vars.append(name) + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Lambda function {function.name} variables " + f"for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) if detect_secrets_output: + original_env_vars = list(function.environment.keys()) secrets_string = ", ".join( [ f"{secret['type']} in variable {original_env_vars[secret['line_number'] - 2]}" @@ -39,6 +66,7 @@ class awslambda_function_no_secrets_in_variables(Check): ) report.status = "FAIL" report.status_extended = f"Potential secret found in Lambda function {function.name} variables -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) findings.append(report) diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json new file mode 100644 index 0000000000..7625b6a626 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_agent_role_least_privilege", + "CheckTitle": "Amazon Bedrock agent execution role follows least privilege", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "TTPs/Privilege Escalation" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock Agent** execution roles (`agentResourceRoleArn`) should grant only the minimum permissions the agent needs. The evaluation FAILs when the role has an AWS-managed `*FullAccess` policy attached, has an inline statement allowing broad actions on `Resource: \"*\"`, or has no permissions boundary configured.", + "Risk": "An overly permissive **Bedrock Agent** execution role turns a successful **prompt injection** into AWS privilege escalation. A model coerced into calling tools can invoke any API the role allows — reading secrets, modifying IAM, exfiltrating data from S3, or pivoting laterally. **Least privilege** plus a **permissions boundary** keeps the blast radius bounded even when guardrails fail.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "aws iam put-role-permissions-boundary --role-name --permissions-boundary ", + "NativeIaC": "", + "Other": "1. Identify the Bedrock Agent's execution role (agentResourceRoleArn) in the IAM console\n2. Detach any AWS-managed *FullAccess policies (e.g. AmazonBedrockFullAccess, AdministratorAccess)\n3. Replace inline policies that use Resource: \"*\" with statements scoped to specific resource ARNs and minimal action sets\n4. Attach a permissions boundary that caps what the role can ever do, even if a future policy is added\n5. Re-run Prowler to confirm the check passes", + "Terraform": "```hcl\nresource \"aws_iam_role\" \"bedrock_agent\" {\n name = \"\"\n assume_role_policy = data.aws_iam_policy_document.trust.json\n permissions_boundary = aws_iam_policy.bedrock_agent_boundary.arn # CRITICAL: caps maximum privileges\n}\n\nresource \"aws_iam_role_policy\" \"bedrock_agent_inline\" {\n role = aws_iam_role.bedrock_agent.name\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [{\n Effect = \"Allow\",\n Action = [\"s3:GetObject\"], # CRITICAL: narrow action\n Resource = [\"arn:aws:s3:::my-rag-bucket/*\"] # CRITICAL: narrow resource\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to every Bedrock Agent execution role: scope `Action` and `Resource` to exactly what the agent needs, avoid AWS-managed `*FullAccess` policies, and always attach a **permissions boundary** so that future policy edits cannot exceed an approved ceiling. Treat agent roles as high-risk because prompt injection can weaponize any granted permission.", + "Url": "https://hub.prowler.com/check/bedrock_agent_role_least_privilege" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py new file mode 100644 index 0000000000..e53183d90f --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py @@ -0,0 +1,101 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import check_admin_access +from prowler.providers.aws.services.iam.lib.privilege_escalation import ( + check_privilege_escalation, +) + + +class bedrock_agent_role_least_privilege(Check): + """Ensure Bedrock Agent execution roles follow least privilege. + + A Bedrock Agent's execution role is evaluated against three criteria: + - No AWS-managed ``*FullAccess`` policy attached. + - No attached or inline policy granting administrative access or known + privilege escalation combinations. + - A permissions boundary is configured on the role. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Run the least-privilege evaluation across all Bedrock Agents. + + Returns: + A list of ``Check_Report_AWS`` with one entry per agent. The + status is ``FAIL`` when any of the criteria above is violated, + or when the execution role cannot be resolved in IAM. + """ + findings = [] + roles_by_arn = {role.arn: role for role in (iam_client.roles or [])} + + for agent in bedrock_agent_client.agents.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=agent) + report.status = "PASS" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role follows least privilege." + ) + + role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None + if role is None: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role could not be " + f"resolved in IAM and cannot be evaluated for least privilege." + ) + findings.append(report) + continue + + violations = [] + + for policy in role.attached_policies: + policy_arn = policy.get("PolicyArn", "") + policy_name = policy.get("PolicyName") or policy_arn + if policy_arn.startswith( + "arn:aws:iam::aws:policy/" + ) and policy_arn.endswith("FullAccess"): + violations.append( + f"managed policy {policy_name} grants full access" + ) + continue + policy_obj = iam_client.policies.get(policy_arn) + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"managed policy {policy_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"managed policy {policy_name} allows privilege escalation" + ) + + for inline_name in role.inline_policies: + policy_obj = iam_client.policies.get(f"{role.arn}:policy/{inline_name}") + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"inline policy {inline_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"inline policy {inline_name} allows privilege escalation" + ) + + if not role.permissions_boundary: + violations.append("no permissions boundary configured") + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role violates least " + f"privilege: {'; '.join(violations)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json index f9b8ee0df9..c85279e4a4 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "bedrock_api_key_no_long_term_credentials", - "CheckTitle": "Amazon Bedrock API key is expired", + "CheckTitle": "Amazon Bedrock long-term API key has expired", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", @@ -14,23 +14,24 @@ "Severity": "high", "ResourceType": "AwsIamUser", "ResourceGroup": "IAM", - "Description": "**Bedrock API keys** are evaluated for **lifetime** and **expiration**.\n\nThe finding identifies keys that are long-lived, set to expire far in the future, or configured to `never expire`, and distinguishes them from keys that have already expired.", - "Risk": "Long-lived or non-expiring keys enable persistent access if compromised.\n- Confidentiality: unauthorized inference and exposure of prompts/outputs\n- Availability/Cost: uncontrolled usage and spend spikes\n- Integrity: actions can continue without timely revocation or rotation", + "Description": "AWS recommends Amazon Bedrock **long-term API keys** only for **exploration**; production workloads should use **short-term API keys** (session-scoped, valid up to **12 hours**). This check fails for any active long-term Bedrock API key, escalating to `critical` severity when configured to **never expire**. Already-expired keys pass — they can no longer authenticate.", + "Risk": "Long-term Bedrock API keys persist beyond a session until their stored expiration, and keys set to **never expire** grant indefinite access until manually revoked, enabling unauthorized inference, uncontrolled usage and spend, and activity that continues past timely revocation.", "RelatedUrl": "", "AdditionalURLs": [ - "https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/getting-started-api-keys.html", - "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials", - "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds-programmatic-access.html#security-creds-alternatives-to-long-term-access-keys", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials" ], "Remediation": { "Code": { "CLI": "aws iam delete-service-specific-credential --user-name --service-specific-credential-id ", "NativeIaC": "", - "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select > Security credentials\n3. In \"API keys for Amazon Bedrock\", find the non-expired key and click Delete\n4. Confirm deletion to remove the key (removes the long-term credential so the check passes)", + "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select the IAM user backing the Bedrock API key > Security credentials\n3. In \"API keys for Amazon Bedrock\", select the active long-term key and click Delete\n4. For workloads that still need Bedrock access, generate a short-term API key from the Bedrock console (Short-term API keys tab), or call the Bedrock API with short-term credentials issued by AWS STS", "Terraform": "" }, "Recommendation": { - "Text": "Prefer **short-term credentials** and **IAM roles**; avoid `never expire`.\n\nEnforce **least privilege**, strict **rotation**, and automatic **expiration** for any long-term key. Store secrets securely, monitor with audit logs, and revoke unused or stale keys quickly.", + "Text": "Use short-term Amazon Bedrock API keys for any non-exploratory workload — they are bound to the IAM principal's session, valid for at most 12 hours, scoped to a single Region, and can be auto-refreshed by the SDK. For existing long-term keys, delete the underlying IAM service-specific credential. If a long-term key must be retained for an exploration scenario, set an explicit short expiration and never select `never expire`.", "Url": "https://hub.prowler.com/check/bedrock_api_key_no_long_term_credentials" } }, @@ -40,5 +41,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check verifies that Amazon Bedrock API keys have expiration dates set. API keys without expiration dates are considered long-term credentials and pose a security risk. The check follows security best practices for credential management and the principle of least privilege." + "Notes": "AWS recommends against using long-term Amazon Bedrock API keys outside of exploration; production workloads should use short-term API keys (session-scoped, valid up to 12 hours). The IAM `ListServiceSpecificCredentials` API only enumerates long-term keys — short-term keys are session-scoped credentials that never appear here. The check therefore passes only when an existing long-term key has already expired and can no longer authenticate; any active long-term key fails, with critical severity when it is configured to never expire." } diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py index 2edffd7ccb..9697ecbff0 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py @@ -1,49 +1,62 @@ from datetime import datetime, timezone -from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.check.models import Check, Check_Report_AWS, Severity from prowler.providers.aws.services.iam.iam_client import iam_client +# Days threshold above which a Bedrock long-term API key is considered effectively non-expiring. +NEVER_EXPIRES_THRESHOLD_DAYS = 10000 + class bedrock_api_key_no_long_term_credentials(Check): - """ - Bedrock API keys should be short-lived to reduce the risk of unauthorized access. - This check verifies if there are any long-term Bedrock API keys. - If there are, it checks if they are expired or will be expired. - If they are expired, it will be marked as PASS. - If they are not expired, it will be marked as FAIL and the severity will be critical if the key will never expire. + """Amazon Bedrock long-term API keys should not be used outside of exploration. + + AWS recommends short-term Bedrock API keys (session-scoped, valid up to 12 hours) + for any non-exploratory workload. ``ListServiceSpecificCredentials`` only enumerates + long-term keys, so every key inspected here is by definition a long-term credential. + + PASS when the long-term key has already expired (it can no longer authenticate). + FAIL (critical) when the key is configured to never expire. + FAIL (high) for any other active long-term key. """ def execute(self): - """ - Execute the Bedrock API key no long-term credentials check. - - Iterate over all the Bedrock API keys and check if they are expired or will be expired. - - Returns: - List[Check_Report_AWS]: A list of report objects with the results of the check. - """ - findings = [] for api_key in iam_client.service_specific_credentials: if api_key.service_name != "bedrock.amazonaws.com": continue - if api_key.expiration_date: - report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) - # Check if the expiration date is in the future - if api_key.expiration_date > datetime.now(timezone.utc): - report.status = "FAIL" - # Get the days until the expiration date - days_until_expiration = ( - api_key.expiration_date - datetime.now(timezone.utc) - ).days - if days_until_expiration > 10000: - self.Severity = "critical" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and never expires." - else: - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and will expire in {days_until_expiration} days." - else: - report.status = "PASS" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists but has expired." - findings.append(report) + if not api_key.expiration_date: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) + now = datetime.now(timezone.utc) + + if api_key.expiration_date <= now: + report.status = "PASS" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} has already expired and can no longer " + f"authenticate." + ) + elif (api_key.expiration_date - now).days > NEVER_EXPIRES_THRESHOLD_DAYS: + report.status = "FAIL" + report.check_metadata.Severity = Severity.critical + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is configured to never expire. Use " + f"short-term Bedrock API keys (session-scoped, valid up to " + f"12 hours) for non-exploratory workloads instead." + ) + else: + days_until_expiration = (api_key.expiration_date - now).days + report.status = "FAIL" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is active and will expire in " + f"{days_until_expiration} days. Use short-term Bedrock API " + f"keys (session-scoped, valid up to 12 hours) for " + f"non-exploratory workloads instead." + ) + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_full_access_policy_attached/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_full_access_policy_attached/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_full_access_policy_attached/bedrock_full_access_policy_attached.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_full_access_policy_attached/bedrock_full_access_policy_attached.metadata.json new file mode 100644 index 0000000000..5fff8ebb2d --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_full_access_policy_attached/bedrock_full_access_policy_attached.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_full_access_policy_attached", + "CheckTitle": "IAM role does not have AmazonBedrockFullAccess managed policy attached", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsIamRole", + "ResourceGroup": "IAM", + "Description": "**IAM roles** (excluding service roles) are evaluated for attachment of the AWS-managed `AmazonBedrockFullAccess` policy.\n\nThis policy grants unrestricted access to all Amazon Bedrock actions and resources.", + "Risk": "The `AmazonBedrockFullAccess` policy grants broad permissions across all Bedrock resources. If a role with this policy is compromised, an attacker could:\n- Invoke any model to exfiltrate data or generate harmful content\n- Modify guardrails, logging, and security configurations\n- Incur significant costs through unrestricted model invocations", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "aws iam detach-role-policy --role-name --policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess", + "NativeIaC": "```yaml\n# CloudFormation: IAM Role without AmazonBedrockFullAccess\nResources:\n :\n Type: AWS::IAM::Role\n Properties:\n AssumeRolePolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n Service: ec2.amazonaws.com\n Action: sts:AssumeRole\n ManagedPolicyArns: [] # Critical: ensure AmazonBedrockFullAccess is NOT attached\n```", + "Other": "1. Open the AWS Console and go to IAM > Roles\n2. Select the role flagged by the check\n3. On the Permissions tab, find \"AmazonBedrockFullAccess\" under Attached policies\n4. Click Detach next to \"AmazonBedrockFullAccess\"\n5. Confirm the detach\n6. Attach a scoped policy granting only required Bedrock actions", + "Terraform": "```hcl\n# IAM Role without AmazonBedrockFullAccess\nresource \"aws_iam_role\" \"\" {\n assume_role_policy = < list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + if iam_client.roles: + for role in iam_client.roles: + if not role.is_service_role: + report = Check_Report_AWS(metadata=self.metadata(), resource=role) + report.region = iam_client.region + report.status = "PASS" + report.status_extended = f"IAM Role {role.name} does not have AmazonBedrockFullAccess policy attached." + for policy in role.attached_policies: + if ( + policy["PolicyArn"] + == "arn:aws:iam::aws:policy/AmazonBedrockFullAccess" + ): + report.status = "FAIL" + report.status_extended = f"IAM Role {role.name} has AmazonBedrockFullAccess policy attached." + break + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured.metadata.json new file mode 100644 index 0000000000..c70d59fdf8 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_guardrails_configured", + "CheckTitle": "Bedrock has at least one guardrail configured in the audited region", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Amazon Bedrock guardrails** provide reusable safety policies for filtering harmful or unwanted content in model inputs and outputs.\n\nThis evaluation checks whether at least one guardrail exists in each successfully scanned region. It does **not** verify that guardrails are attached to agents or passed on individual model invocation API calls.", + "Risk": "Without any configured **Bedrock guardrails** in a region, teams lack a native reusable policy object for **content filtering** and **safety controls**. Applications may invoke models without standardized protections against **harmful content**, **prompt injection**, or **sensitive-data exposure** unless equivalent controls are enforced elsewhere.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-create.html" + ], + "Remediation": { + "Code": { + "CLI": "aws bedrock create-guardrail --name example_resource --blocked-input-messaging 'Blocked' --blocked-outputs-messaging 'Blocked' --content-policy-config 'filtersConfig=[{type=HATE,inputStrength=HIGH,outputStrength=HIGH}]'", + "NativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::Bedrock::Guardrail\n Properties:\n Name: example_resource\n BlockedInputMessaging: \"Blocked\"\n BlockedOutputsMessaging: \"Blocked\"\n ContentPolicyConfig:\n FiltersConfig:\n - Type: HATE\n InputStrength: HIGH # Critical: configures content filtering\n OutputStrength: HIGH\n```", + "Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Guardrails** from the navigation pane\n3. Click **Create guardrail**\n4. Configure content filters for harmful categories\n5. Set input and output messaging for blocked content\n6. Click **Create guardrail**", + "Terraform": "```hcl\nresource \"aws_bedrock_guardrail\" \"example_resource\" {\n name = \"example_resource\"\n blocked_input_messaging = \"Blocked\"\n blocked_outputs_messaging = \"Blocked\"\n\n content_policy_config {\n filters_config {\n type = \"HATE\" # Critical: configures content filtering\n input_strength = \"HIGH\"\n output_strength = \"HIGH\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Create at least one **Bedrock guardrail** in each region where Bedrock is used, then separately ensure those guardrails are attached to relevant agents and invocation paths.\n- Configure **content filters** for harmful categories (hate, violence, sexual, misconduct)\n- Add **sensitive information filters** and **denied topic policies**\n- Apply guardrails at the API call level using `guardrailIdentifier` where supported", + "Url": "https://hub.prowler.com/check/bedrock_guardrails_configured" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_guardrail_prompt_attack_filter_enabled", + "bedrock_guardrail_sensitive_information_filter_enabled", + "bedrock_agent_guardrail_enabled" + ], + "Notes": "This check validates guardrail existence per successfully scanned region. It does not verify attachment to agents or the use of guardrails on model invocations. Regions where Bedrock guardrails cannot be enumerated are skipped to avoid false failures." +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured.py b/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured.py new file mode 100644 index 0000000000..f0f29fdf8b --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client + + +class bedrock_guardrails_configured(Check): + """Ensure Bedrock guardrails are configured in successfully scanned regions. + + This check verifies that at least one Amazon Bedrock guardrail is configured + in each successfully scanned region. + - PASS: At least one Bedrock guardrail is configured in the region. + - FAIL: No Bedrock guardrails are configured in the region. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for region in sorted(bedrock_client.guardrails_scanned_regions): + regional_guardrails = sorted( + ( + guardrail + for guardrail in bedrock_client.guardrails.values() + if guardrail.region == region + ), + key=lambda guardrail: guardrail.name, + ) + + if regional_guardrails: + for guardrail in regional_guardrails: + report = Check_Report_AWS( + metadata=self.metadata(), resource=guardrail + ) + report.status = "PASS" + report.status_extended = f"Bedrock guardrail {guardrail.name} is available in region {region}. This does not confirm that the guardrail is attached to agents or used on model invocations." + findings.append(report) + else: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = region + report.resource_id = "bedrock-guardrails" + report.resource_arn = f"arn:{bedrock_client.audited_partition}:bedrock:{region}:{bedrock_client.audited_account}:guardrails" + report.status = "FAIL" + report.status_extended = ( + f"Bedrock has no guardrails configured in region {region}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..d01de76f2f --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_prompt_encrypted_with_cmk", + "CheckTitle": "Amazon Bedrock prompt is encrypted at rest with a customer-managed KMS key", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "Bedrock prompts should be encrypted at rest with a **customer-managed KMS key (CMK)** rather than the AWS-owned default key. Prompts can contain sensitive instructions, business logic, and references to downstream tooling that warrant tenant-controlled key material and auditable access via AWS KMS.", + "Risk": "A prompt encrypted only with the AWS-owned default key offers limited tenant control over key access and lifecycle: no customer KMS key policy to govern decrypt permissions, no control over rotation cadence or scheduled deletion, and gaps against frameworks (ISO 27001 A.8.24, NIST CSF PR.DS, KISA-ISMS-P 2.7.2) that require customer-managed keys for sensitive data at rest.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html", + "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_CreatePrompt.html", + "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdatePrompt.html" + ], + "Remediation": { + "Code": { + "CLI": "# Retrieve the current DRAFT prompt first and note the existing fields you want to preserve, such as description, defaultVariant, and variants:\naws bedrock-agent get-prompt --prompt-identifier --prompt-version DRAFT --output json\n# Then update the prompt and include the existing fields you want to keep alongside the CMK change:\naws bedrock-agent update-prompt --prompt-identifier --name --description --default-variant --variants --customer-encryption-key-arn ", + "NativeIaC": "", + "Other": "1. Open the Amazon Bedrock console\n2. Navigate to Prompt management\n3. Select the prompt\n4. Edit the prompt and choose a customer-managed KMS key for encryption\n5. Save the prompt", + "Terraform": "" + }, + "Recommendation": { + "Text": "Encrypt every Bedrock prompt with a **customer-managed KMS key** to retain control over key access, rotation, and lifecycle. When using `update-prompt`, first retrieve the current draft and carry forward the fields you want to preserve, such as the existing description, `defaultVariant`, and `variants`, so the encryption change does not unintentionally overwrite prompt configuration.", + "Url": "https://hub.prowler.com/check/bedrock_prompt_encrypted_with_cmk" + } + }, + "Categories": [ + "gen-ai", + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_prompt_management_exists" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk.py b/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk.py new file mode 100644 index 0000000000..1e5bbb4e50 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk.py @@ -0,0 +1,32 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) + + +class bedrock_prompt_encrypted_with_cmk(Check): + """Ensure that Bedrock prompts are encrypted with a customer-managed KMS key. + + This check evaluates whether each Bedrock prompt is encrypted at rest using + a customer-managed KMS key (CMK) rather than the AWS-owned default key. + - PASS: The Bedrock prompt is encrypted with a customer-managed KMS key. + - FAIL: The Bedrock prompt is not encrypted with a customer-managed KMS key. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Bedrock prompt CMK encryption check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for prompt in bedrock_agent_client.prompts.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=prompt) + if prompt.customer_encryption_key_arn: + report.status = "PASS" + report.status_extended = f"Bedrock Prompt {prompt.name} is encrypted with a customer-managed KMS key." + else: + report.status = "FAIL" + report.status_extended = f"Bedrock Prompt {prompt.name} is not encrypted with a customer-managed KMS key." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.metadata.json new file mode 100644 index 0000000000..96e88bda3a --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_prompt_management_exists", + "CheckTitle": "Amazon Bedrock Prompt Management prompts exist in the region", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock Prompt Management** enables centralized creation, versioning, and governance of prompts used with foundation models.\n\nThis region-level check verifies whether at least one managed prompt exists in each scanned region, used as an adoption signal for Prompt Management. The presence of a prompt does not by itself guarantee that every application prompt is managed.", + "Risk": "Without **Prompt Management**, prompts are scattered across applications with no central oversight, versioning, or auditability over instructions sent to foundation models, weakening governance and compliance posture.\n\nManaged prompts are a governance enabler; **prompt injection** defenses are provided by Bedrock **guardrails**, covered by separate checks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management-create.html" + ], + "Remediation": { + "Code": { + "CLI": "aws bedrock-agent create-prompt --name example_prompt --default-variant default --variants '[{\"name\":\"default\",\"templateType\":\"TEXT\",\"templateConfiguration\":{\"text\":{\"text\":\"Your prompt template here.\"}}}]'", + "NativeIaC": "", + "Other": "1. Open the Amazon Bedrock console\n2. Navigate to Prompt Management\n3. Click Create prompt\n4. Provide a name and configure the prompt template (a prompt can contain at most one variant; additional variants are created via CreatePromptVersion)\n5. Save the prompt", + "Terraform": "" + }, + "Recommendation": { + "Text": "Adopt **Bedrock Prompt Management** to centralize prompt definitions, enforce versioning, and maintain governance over model interactions.\n\nUse managed prompts with **guardrails** and apply **least privilege** access controls to restrict who can create or modify prompts.", + "Url": "https://hub.prowler.com/check/bedrock_prompt_management_exists" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_prompt_encrypted_with_cmk" + ], + "Notes": "Results are generated per scanned region. Regions where `ListPrompts` cannot be queried are omitted from the findings." +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.py b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.py new file mode 100644 index 0000000000..b8ec65dbb4 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.py @@ -0,0 +1,54 @@ +"""Check for region-level Bedrock Prompt Management adoption.""" + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) + + +class bedrock_prompt_management_exists(Check): + """Check whether Amazon Bedrock Prompt Management prompts exist in the region. + + A region is reported only when ListPrompts succeeded for it; regions where + the API call failed (e.g. AccessDenied, unsupported region) are skipped at + the service layer and produce no finding. + + - PASS: At least one managed prompt exists in the region (one finding per prompt). + - FAIL: No managed prompts exist in the region (one finding per region). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Bedrock Prompt Management exists check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for region in sorted(bedrock_agent_client.prompt_scanned_regions): + regional_prompts = sorted( + ( + prompt + for prompt in bedrock_agent_client.prompts.values() + if prompt.region == region + ), + key=lambda prompt: prompt.name, + ) + + if regional_prompts: + for prompt in regional_prompts: + report = Check_Report_AWS(metadata=self.metadata(), resource=prompt) + report.status = "PASS" + report.status_extended = f"Bedrock Prompt Management prompt {prompt.name} exists in region {region}." + findings.append(report) + else: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = region + report.resource_id = "prompt-management" + report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:prompt-management" + report.status = "FAIL" + report.status_extended = ( + f"No Bedrock Prompt Management prompts exist in region {region}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py index c0e3c6717a..aead63a67f 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_service.py +++ b/prowler/providers/aws/services/bedrock/bedrock_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.exceptions import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -13,6 +14,8 @@ class Bedrock(AWSService): super().__init__(__class__.__name__, provider) self.logging_configurations = {} self.guardrails = {} + self.guardrails_scanned_regions = set() + self.guardrails_scan_errors = {} self.__threading_call__(self._get_model_invocation_logging_configuration) self.__threading_call__(self._list_guardrails) self.__threading_call__(self._get_guardrail, self.guardrails.values()) @@ -67,7 +70,18 @@ class Bedrock(AWSService): arn=guardrail["arn"], region=regional_client.region, ) + self.guardrails_scanned_regions.add(regional_client.region) + except ClientError as error: + self.guardrails_scan_errors[regional_client.region] = error.response[ + "Error" + ].get("Code", error.__class__.__name__) + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: + self.guardrails_scan_errors[regional_client.region] = ( + error.__class__.__name__ + ) logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -122,11 +136,19 @@ class Guardrail(BaseModel): class BedrockAgent(AWSService): + """Bedrock Agent service class for managing agents and prompts.""" + def __init__(self, provider): + """Initialize the BedrockAgent service.""" # Call AWSService's __init__ super().__init__("bedrock-agent", provider) self.agents = {} + self.prompts = {} + self.prompt_scanned_regions: set = set() self.__threading_call__(self._list_agents) + self.__threading_call__(self._get_agent, self.agents.values()) + self.__threading_call__(self._list_prompts) + self.__threading_call__(self._get_prompt, self.prompts.values()) self.__threading_call__(self._list_tags_for_resource, self.agents.values()) def _list_agents(self, regional_client): @@ -153,7 +175,62 @@ class BedrockAgent(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_agent(self, agent): + """Fetch full agent details to capture the execution role ARN. + + list_agents only returns summaries (no agentResourceRoleArn), so we + need a per-agent GetAgent call. Stored on the Agent model for use by + checks like bedrock_agent_role_least_privilege. + """ + logger.info("Bedrock Agent - Getting Agent...") + try: + agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id) + agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn") + except Exception as error: + logger.error( + f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_prompts(self, regional_client): + """List all prompts in a region.""" + logger.info("Bedrock Agent - Listing Prompts...") + try: + paginator = regional_client.get_paginator("list_prompts") + for page in paginator.paginate(): + for prompt in page.get("promptSummaries", []): + prompt_arn = prompt.get("arn", "") + if not self.audit_resources or ( + is_resource_filtered(prompt_arn, self.audit_resources) + ): + self.prompts[prompt_arn] = Prompt( + id=prompt.get("id", ""), + name=prompt.get("name", ""), + arn=prompt_arn, + region=regional_client.region, + ) + self.prompt_scanned_regions.add(regional_client.region) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_prompt(self, prompt): + """Get detailed prompt information including encryption configuration.""" + logger.info("Bedrock Agent - Getting Prompt...") + try: + prompt_info = self.regional_clients[prompt.region].get_prompt( + promptIdentifier=prompt.id + ) + prompt.customer_encryption_key_arn = prompt_info.get( + "customerEncryptionKeyArn" + ) + except Exception as error: + logger.error( + f"{prompt.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_tags_for_resource(self, resource): + """List tags for a Bedrock Agent resource.""" logger.info("Bedrock Agent - Listing Tags for Resource...") try: agent_tags = ( @@ -170,9 +247,22 @@ class BedrockAgent(AWSService): class Agent(BaseModel): + """Model for a Bedrock Agent resource.""" + id: str name: str arn: str guardrail_id: Optional[str] = None + role_arn: Optional[str] = None region: str tags: Optional[list] = [] + + +class Prompt(BaseModel): + """Model representing a Bedrock Prompt Management prompt.""" + + id: str + name: str + arn: str + region: str + customer_encryption_key_arn: Optional[str] = None diff --git a/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured.metadata.json new file mode 100644 index 0000000000..0e2ac8fef5 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_vpc_endpoints_configured", + "CheckTitle": "VPC endpoints ensure private connectivity for all Bedrock APIs", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsEc2VpcEndpointService", + "ResourceGroup": "network", + "Description": "**Amazon VPCs** are evaluated for **interface VPC endpoints** to all Bedrock services: `bedrock`, `bedrock-runtime`, `bedrock-agent`, `bedrock-agent-runtime`, and `bedrock-mantle` (OpenAI-compatible API). Only endpoints in `available` state are considered. Their presence indicates private Bedrock API connectivity over **AWS PrivateLink** within the VPC.", + "Risk": "Without private Bedrock endpoints, control plane and runtime API traffic exits the VPC via IGW or NAT Gateway. This expands exposure to network path threats (e.g., DNS hijack, MITM), weakens egress isolation, and adds an internet dependency for Bedrock API access, reducing availability if NAT or edge paths fail.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/usingVPC.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html", + "https://docs.aws.amazon.com/vpc/latest/privatelink/create-interface-endpoint.html" + ], + "Remediation": { + "Code": { + "CLI": "aws ec2 create-vpc-endpoint --vpc-id --service-name com.amazonaws..bedrock-runtime --vpc-endpoint-type Interface --subnet-ids && aws ec2 create-vpc-endpoint --vpc-id --service-name com.amazonaws..bedrock --vpc-endpoint-type Interface --subnet-ids && aws ec2 create-vpc-endpoint --vpc-id --service-name com.amazonaws..bedrock-agent --vpc-endpoint-type Interface --subnet-ids && aws ec2 create-vpc-endpoint --vpc-id --service-name com.amazonaws..bedrock-agent-runtime --vpc-endpoint-type Interface --subnet-ids && aws ec2 create-vpc-endpoint --vpc-id --service-name com.amazonaws..bedrock-mantle --vpc-endpoint-type Interface --subnet-ids ", + "NativeIaC": "```yaml\n# CloudFormation: create Bedrock interface VPC endpoints in the VPC\nResources:\n BedrockEndpoint:\n Type: AWS::EC2::VPCEndpoint\n Properties:\n VpcId: \"\" # CRITICAL: target VPC\n ServiceName: !Sub \"com.amazonaws.${AWS::Region}.bedrock\" # CRITICAL: Bedrock control plane endpoint\n VpcEndpointType: Interface # CRITICAL: interface endpoint\n SubnetIds:\n - \"\"\n BedrockRuntimeEndpoint:\n Type: AWS::EC2::VPCEndpoint\n Properties:\n VpcId: \"\" # CRITICAL: target VPC\n ServiceName: !Sub \"com.amazonaws.${AWS::Region}.bedrock-runtime\" # CRITICAL: Bedrock runtime endpoint\n VpcEndpointType: Interface # CRITICAL: interface endpoint\n SubnetIds:\n - \"\"\n BedrockAgentEndpoint:\n Type: AWS::EC2::VPCEndpoint\n Properties:\n VpcId: \"\" # CRITICAL: target VPC\n ServiceName: !Sub \"com.amazonaws.${AWS::Region}.bedrock-agent\" # CRITICAL: Bedrock agent control plane endpoint\n VpcEndpointType: Interface # CRITICAL: interface endpoint\n SubnetIds:\n - \"\"\n BedrockAgentRuntimeEndpoint:\n Type: AWS::EC2::VPCEndpoint\n Properties:\n VpcId: \"\" # CRITICAL: target VPC\n ServiceName: !Sub \"com.amazonaws.${AWS::Region}.bedrock-agent-runtime\" # CRITICAL: Bedrock agent runtime endpoint\n VpcEndpointType: Interface # CRITICAL: interface endpoint\n SubnetIds:\n - \"\"\n BedrockMantleEndpoint:\n Type: AWS::EC2::VPCEndpoint\n Properties:\n VpcId: \"\" # CRITICAL: target VPC\n ServiceName: !Sub \"com.amazonaws.${AWS::Region}.bedrock-mantle\" # CRITICAL: Bedrock Mantle OpenAI-compatible endpoint\n VpcEndpointType: Interface # CRITICAL: interface endpoint\n SubnetIds:\n - \"\"\n```", + "Other": "1. In the AWS console, go to VPC > Endpoints\n2. Click Create endpoint\n3. For Service category, choose AWS services and select com.amazonaws..bedrock\n4. Select your VPC and at least one subnet\n5. Click Create endpoint\n6. Repeat steps 2-5 for com.amazonaws..bedrock-runtime, com.amazonaws..bedrock-agent, com.amazonaws..bedrock-agent-runtime, and com.amazonaws..bedrock-mantle", + "Terraform": "```hcl\n# Create Bedrock control plane interface VPC endpoint\nresource \"aws_vpc_endpoint\" \"bedrock\" {\n vpc_id = \"\" # CRITICAL: target VPC\n service_name = \"com.amazonaws..bedrock\" # CRITICAL: Bedrock control plane endpoint\n vpc_endpoint_type = \"Interface\" # CRITICAL: interface endpoint\n subnet_ids = [\"\"] # CRITICAL: subnet(s) for endpoint ENIs\n}\n\n# Create Bedrock runtime interface VPC endpoint\nresource \"aws_vpc_endpoint\" \"bedrock_runtime\" {\n vpc_id = \"\" # CRITICAL: target VPC\n service_name = \"com.amazonaws..bedrock-runtime\" # CRITICAL: Bedrock runtime endpoint\n vpc_endpoint_type = \"Interface\" # CRITICAL: interface endpoint\n subnet_ids = [\"\"] # CRITICAL: subnet(s) for endpoint ENIs\n}\n\n# Create Bedrock agent control plane interface VPC endpoint\nresource \"aws_vpc_endpoint\" \"bedrock_agent\" {\n vpc_id = \"\" # CRITICAL: target VPC\n service_name = \"com.amazonaws..bedrock-agent\" # CRITICAL: Bedrock agent control plane endpoint\n vpc_endpoint_type = \"Interface\" # CRITICAL: interface endpoint\n subnet_ids = [\"\"] # CRITICAL: subnet(s) for endpoint ENIs\n}\n\n# Create Bedrock agent runtime interface VPC endpoint\nresource \"aws_vpc_endpoint\" \"bedrock_agent_runtime\" {\n vpc_id = \"\" # CRITICAL: target VPC\n service_name = \"com.amazonaws..bedrock-agent-runtime\" # CRITICAL: Bedrock agent runtime endpoint\n vpc_endpoint_type = \"Interface\" # CRITICAL: interface endpoint\n subnet_ids = [\"\"] # CRITICAL: subnet(s) for endpoint ENIs\n}\n\n# Create Bedrock Mantle (OpenAI-compatible) interface VPC endpoint\nresource \"aws_vpc_endpoint\" \"bedrock_mantle\" {\n vpc_id = \"\" # CRITICAL: target VPC\n service_name = \"com.amazonaws..bedrock-mantle\" # CRITICAL: Bedrock Mantle OpenAI-compatible endpoint\n vpc_endpoint_type = \"Interface\" # CRITICAL: interface endpoint\n subnet_ids = [\"\"] # CRITICAL: subnet(s) for endpoint ENIs\n}\n```" + }, + "Recommendation": { + "Text": "Use **interface VPC endpoints** for all Bedrock services (`bedrock`, `bedrock-runtime`, `bedrock-agent`, `bedrock-agent-runtime`, `bedrock-mantle`) in each VPC that requires Bedrock API access.\n- Enable **private DNS** to keep calls on the AWS network\n- Apply restrictive endpoint policies (**least privilege**)\n- Reduce reliance on public egress and layer controls for **defense in depth**", + "Url": "https://hub.prowler.com/check/bedrock_vpc_endpoints_configured" + } + }, + "Categories": [ + "internet-exposed", + "trust-boundaries", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "vpc_endpoint_for_ec2_enabled" + ], + "Notes": "This check only evaluates VPCs in regions where Bedrock activity is detected (guardrails, agents, or model invocation logging enabled). Only VPC endpoints in 'available' state are considered." +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured.py b/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured.py new file mode 100644 index 0000000000..92e2de3464 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) +from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client +from prowler.providers.aws.services.vpc.vpc_client import vpc_client + +BEDROCK_ENDPOINT_SERVICES = { + "bedrock": "Bedrock control plane", + "bedrock-runtime": "Bedrock runtime", + "bedrock-agent": "Bedrock agent control plane", + "bedrock-agent-runtime": "Bedrock agent runtime", + "bedrock-mantle": "Bedrock Mantle (OpenAI-compatible API)", +} + + +class bedrock_vpc_endpoints_configured(Check): + """Ensure VPC endpoints are configured for Bedrock services. + + This check verifies that each VPC in regions with Bedrock activity has + interface VPC endpoints for all Amazon Bedrock services (control plane, + runtime, agent, agent runtime, and Mantle OpenAI-compatible API), + ensuring that traffic to these services remains within the AWS network. + - PASS: The VPC has VPC endpoints for all Bedrock services. + - FAIL: The VPC is missing one or more Bedrock VPC endpoints. + VPCs in regions without Bedrock activity are skipped. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + bedrock_regions = self._get_bedrock_active_regions() + + for vpc_id, vpc in vpc_client.vpcs.items(): + if not (vpc_client.provider.scan_unused_services or vpc.in_use): + continue + + if vpc.region not in bedrock_regions: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=vpc) + report.status = "FAIL" + + found_services = set() + + for endpoint in vpc_client.vpc_endpoints: + if endpoint.vpc_id == vpc_id and endpoint.state == "available": + for svc_suffix in BEDROCK_ENDPOINT_SERVICES: + if endpoint.service_name.endswith(f".{svc_suffix}"): + found_services.add(svc_suffix) + + missing_services = set(BEDROCK_ENDPOINT_SERVICES) - found_services + + if not missing_services: + report.status = "PASS" + report.status_extended = ( + f"VPC {vpc.id} has VPC endpoints for all Bedrock services." + ) + else: + missing_labels = [ + BEDROCK_ENDPOINT_SERVICES[svc] for svc in sorted(missing_services) + ] + report.status_extended = f"VPC {vpc.id} does not have VPC endpoints for the following Bedrock services: {', '.join(missing_labels)}." + + findings.append(report) + + return findings + + @staticmethod + def _get_bedrock_active_regions() -> set[str]: + """Return regions where Bedrock resources or logging are configured.""" + active_regions = set() + + for region, config in bedrock_client.logging_configurations.items(): + if config.enabled: + active_regions.add(region) + + for guardrail in bedrock_client.guardrails.values(): + active_regions.add(guardrail.region) + + for agent in bedrock_agent_client.agents.values(): + active_regions.add(agent.region) + + return active_regions diff --git a/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py b/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py index f9b47932bb..b86f851765 100644 --- a/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py +++ b/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.py @@ -1,5 +1,9 @@ from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.cloudformation.cloudformation_client import ( cloudformation_client, ) @@ -14,26 +18,41 @@ class cloudformation_stack_outputs_find_secrets(Check): secrets_ignore_patterns = cloudformation_client.audit_config.get( "secrets_ignore_patterns", [] ) - for stack in cloudformation_client.stacks: + validate = cloudformation_client.audit_config.get("secrets_validate", False) + stacks = list(cloudformation_client.stacks) + + # Collect one payload per stack (its Outputs) and scan them all in + # batched Kingfisher invocations instead of one subprocess per stack. + def payloads(): + for index, stack in enumerate(stacks): + if stack.outputs: + yield index, "".join(f"{output}\n" for output in stack.outputs) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, stack in enumerate(stacks): report = Check_Report_AWS(metadata=self.metadata(), resource=stack) report.status = "PASS" report.status_extended = ( f"No secrets found in CloudFormation Stack {stack.name} Outputs." ) if stack.outputs: - data = "" - # Store the CloudFormation Stack Outputs into a file - for output in stack.outputs: - data += f"{output}\n" - - detect_secrets_output = detect_secrets_scan( - data=data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=cloudformation_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - # If secrets are found, update the report status + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan CloudFormation Stack {stack.name} Outputs " + f"for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) if detect_secrets_output: secrets_string = ", ".join( [ @@ -43,7 +62,7 @@ class cloudformation_stack_outputs_find_secrets(Check): ) report.status = "FAIL" report.status_extended = f"Potential secret found in CloudFormation Stack {stack.name} Outputs -> {secrets_string}." - + annotate_verified_secrets(report, detect_secrets_output) else: report.status = "PASS" report.status_extended = ( diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/__init__.py b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json new file mode 100644 index 0000000000..465f4335c2 --- /dev/null +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "cloudfront_distributions_pqc_tls_enabled", + "CheckTitle": "CloudFront distributions enforce a post-quantum TLS 1.3 security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "cloudfront", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsCloudFrontDistribution", + "ResourceGroup": "network", + "Description": "**CloudFront distributions** are assessed for use of a **TLS 1.3-only security policy** (`TLSv1.3_2025`). CloudFront's quantum-safe key exchanges (`X25519MLKEM768`, `SecP256r1MLKEM768`) only work with TLS 1.3. Distributions that allow TLS 1.2 (or older) fallback to classical key exchanges and are exposed to `harvest-now, decrypt-later` attacks.", + "Risk": "Without a TLS 1.3-only policy, viewer traffic captured today can be decrypted once a **cryptographically relevant quantum computer** is available. Distributions using the default CloudFront certificate (`*.cloudfront.net`) cannot enable a post-quantum policy because they are pinned to the legacy `TLSv1` policy.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesGeneral.html#DownloadDistValues-security-policy", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws cloudfront update-distribution --id --distribution-config '{...\"ViewerCertificate\":{\"MinimumProtocolVersion\":\"TLSv1.3_2025\",...}}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n ViewerCertificate:\n AcmCertificateArn: \n SslSupportMethod: sni-only\n MinimumProtocolVersion: TLSv1.3_2025 # FIX: enforces TLS 1.3 + post-quantum KEX\n```", + "Other": "1. In the AWS Console, go to CloudFront > Distributions\n2. Select the distribution and open the General tab\n3. Choose Edit on Settings\n4. Set Custom SSL certificate (do not use the default *.cloudfront.net certificate)\n5. Set Security policy to TLSv1.3_2025\n6. Save changes", + "Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"\" {\n # ...\n viewer_certificate {\n acm_certificate_arn = \"\"\n ssl_support_method = \"sni-only\"\n minimum_protocol_version = \"TLSv1.3_2025\" # FIX: enforces TLS 1.3 + post-quantum KEX\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use a **custom SSL certificate** with **SNI** support and set `MinimumProtocolVersion` to `TLSv1.3_2025` so CloudFront refuses TLS 1.2 handshakes and uses the hybrid ML-KEM key exchange. Distributions still using the default CloudFront certificate must be migrated to a custom certificate to enable post-quantum TLS.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_pqc_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfront_distributions_using_deprecated_ssl_protocols" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py new file mode 100644 index 0000000000..6c472d43c7 --- /dev/null +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.cloudfront.cloudfront_client import ( + cloudfront_client, +) + +PQC_CLOUDFRONT_POLICIES_DEFAULT = [ + "TLSv1.3_2025", +] + + +class cloudfront_distributions_pqc_tls_enabled(Check): + """Verify that every CloudFront distribution enforces TLS 1.3 with post-quantum key exchange. + + Quantum-safe key exchanges (``X25519MLKEM768``, ``SecP256r1MLKEM768``) are + only available on TLS 1.3 viewer connections. A distribution PASSES when + its ``MinimumProtocolVersion`` belongs to the configured allowlist of + TLS 1.3-only policies. Distributions that rely on the default CloudFront + certificate are pinned to the legacy ``TLSv1`` policy and therefore FAIL. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the CloudFront post-quantum TLS policy check. + + Returns: + A list of reports containing each CloudFront distribution's + post-quantum TLS compliance status. + """ + findings = [] + pqc_policies = cloudfront_client.audit_config.get( + "cloudfront_pqc_min_protocol_versions", PQC_CLOUDFRONT_POLICIES_DEFAULT + ) + for distribution in cloudfront_client.distributions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=distribution) + policy = distribution.minimum_protocol_version or "" + if distribution.default_certificate: + report.status = "FAIL" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses the default " + "CloudFront certificate, which pins the security policy to " + "TLSv1 and cannot enable post-quantum TLS." + ) + elif distribution.minimum_protocol_version in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses post-quantum " + f"TLS policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses TLS policy " + f"{policy}, which is not in the post-quantum allowlist." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_service.py b/prowler/providers/aws/services/cloudfront/cloudfront_service.py index 0826c9adfd..660b483c4c 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_service.py +++ b/prowler/providers/aws/services/cloudfront/cloudfront_service.py @@ -48,6 +48,9 @@ class CloudFront(AWSService): "SSLSupportMethod", "static-ip" ) ) + minimum_protocol_version = item["ViewerCertificate"].get( + "MinimumProtocolVersion", "" + ) origins = [] for origin in item.get("Origins", {}).get("Items", []): origins.append( @@ -79,6 +82,7 @@ class CloudFront(AWSService): ssl_support_method=ssl_support_method, default_certificate=default_certificate, certificate=certificate, + minimum_protocol_version=minimum_protocol_version, ) self.distributions[distribution_id] = distribution @@ -268,3 +272,4 @@ class Distribution(BaseModel): origin_failover: Optional[bool] = None ssl_support_method: Optional[SSLSupportMethod] = None certificate: Optional[str] = None + minimum_protocol_version: str = "" diff --git a/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/__init__.py b/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled.metadata.json b/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled.metadata.json new file mode 100644 index 0000000000..8f27089444 --- /dev/null +++ b/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "cloudtrail_bedrock_logging_enabled", + "CheckTitle": "CloudTrail logs Amazon Bedrock API calls for security auditing", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "cloudtrail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsCloudTrailTrail", + "ResourceGroup": "monitoring", + "Description": "**At least one actively logging CloudTrail trail** records **Amazon Bedrock API activity** through management events or advanced event selectors targeting Bedrock resources.\n\nThis check covers **control-plane** operations such as configuration changes through CloudTrail management events and can also cover **data-plane** Bedrock events when advanced event selectors target Bedrock resource types.", + "Risk": "Without CloudTrail logging for Bedrock control-plane operations, changes to prompts, guardrails, agents, flows, or knowledge bases can become invisible, weakening forensics and incident response. Management events do not capture `InvokeModel`; pair this control with `bedrock_model_invocation_logging_enabled` or Bedrock data event selectors for invocation visibility.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html", + "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html" + ], + "Remediation": { + "Code": { + "CLI": "aws cloudtrail put-event-selectors --trail-name --advanced-event-selectors '[{\"Name\":\"Bedrock data events\",\"FieldSelectors\":[{\"Field\":\"eventCategory\",\"Equals\":[\"Data\"]},{\"Field\":\"resources.type\",\"Equals\":[\"AWS::Bedrock::Model\",\"AWS::Bedrock::Guardrail\",\"AWS::Bedrock::AgentAlias\",\"AWS::Bedrock::FlowAlias\",\"AWS::Bedrock::InlineAgent\",\"AWS::Bedrock::KnowledgeBase\",\"AWS::Bedrock::Prompt\"]}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: enable Bedrock data event logging on an actively logging trail\nResources:\n ExampleTrail:\n Type: AWS::CloudTrail::Trail\n Properties:\n TrailName: \n S3BucketName: \n IsLogging: true\n AdvancedEventSelectors:\n - Name: Bedrock data events\n FieldSelectors:\n - Field: eventCategory\n Equals:\n - Data\n - Field: resources.type # CRITICAL: target Bedrock resources\n Equals:\n - AWS::Bedrock::Model\n - AWS::Bedrock::Guardrail\n - AWS::Bedrock::AgentAlias\n - AWS::Bedrock::FlowAlias\n - AWS::Bedrock::InlineAgent\n - AWS::Bedrock::KnowledgeBase\n - AWS::Bedrock::Prompt\n```", + "Other": "1. In the AWS Console, open CloudTrail and select a trail that is actively logging\n2. Edit the trail and enable Management events to capture Bedrock control-plane operations, or add Bedrock advanced data event selectors for data-plane visibility\n3. If using data events, select the Bedrock resource types you want to log\n4. Save changes and confirm the trail remains in logging state", + "Terraform": "```hcl\n# Terraform: enable Bedrock data event logging on an actively logging trail\nresource \"aws_cloudtrail\" \"example_resource\" {\n name = \"example_resource\"\n s3_bucket_name = \"example_resource\"\n\n advanced_event_selector {\n name = \"Bedrock data events\"\n field_selector {\n field = \"eventCategory\"\n equals = [\"Data\"]\n }\n field_selector {\n field = \"resources.type\" # CRITICAL: target Bedrock resources\n equals = [\"AWS::Bedrock::Model\", \"AWS::Bedrock::Guardrail\", \"AWS::Bedrock::AgentAlias\", \"AWS::Bedrock::FlowAlias\", \"AWS::Bedrock::InlineAgent\", \"AWS::Bedrock::KnowledgeBase\", \"AWS::Bedrock::Prompt\"]\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable CloudTrail logging for Amazon Bedrock on **at least one actively logging trail**. At minimum, enable **management events** to capture Bedrock control-plane operations. For invocation-level and other data-plane visibility, add **advanced event selectors** targeting Bedrock resource types or pair this control with `bedrock_model_invocation_logging_enabled`.\n\nFor broader region coverage, pair this control with a separate multi-region CloudTrail check. Centralize logs in an encrypted bucket or CloudWatch Logs to support **defense in depth** and forensic readiness for AI workloads.", + "Url": "https://hub.prowler.com/check/cloudtrail_bedrock_logging_enabled" + } + }, + "Categories": [ + "logging", + "forensics-ready", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudtrail_multi_region_enabled_logging_management_events", + "bedrock_model_invocation_logging_enabled" + ], + "Notes": "This check passes when CloudTrail captures Bedrock control-plane activity via management events or Bedrock data events via advanced selectors. It does not require multi-region coverage, and it does not by itself guarantee `InvokeModel` visibility unless Bedrock data events are selected; use `bedrock_model_invocation_logging_enabled` for model invocation logs. Additional advanced selector filters such as `eventName` or `resources.ARN` can further narrow effective coverage and should be reviewed explicitly." +} diff --git a/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled.py b/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled.py new file mode 100644 index 0000000000..d66d10f7c6 --- /dev/null +++ b/prowler/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled.py @@ -0,0 +1,213 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.cloudtrail.cloudtrail_client import ( + cloudtrail_client, +) +from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Event_Selector, +) + + +class cloudtrail_bedrock_logging_enabled(Check): + """Ensure CloudTrail is configured to log Amazon Bedrock API calls. + + This check verifies whether at least one CloudTrail trail is configured to + capture Amazon Bedrock control-plane API calls through management events or + Bedrock data events through advanced event selectors. + + - PASS: A trail logs Bedrock control-plane API calls via management events + or Bedrock data events via Bedrock-specific advanced event selectors. + - FAIL: No CloudTrail trail is configured to log Bedrock API calls. + """ + + # Bedrock resource types supported by CloudTrail advanced event selectors. + BEDROCK_RESOURCE_TYPES = frozenset( + { + "AWS::Bedrock::AgentAlias", + "AWS::Bedrock::FlowAlias", + "AWS::Bedrock::Guardrail", + "AWS::Bedrock::InlineAgent", + "AWS::Bedrock::KnowledgeBase", + "AWS::Bedrock::Model", + "AWS::Bedrock::Prompt", + } + ) + # Bedrock control-plane event sources, including Bedrock Data Automation. + BEDROCK_EVENT_SOURCES = frozenset( + { + "bedrock.amazonaws.com", + "bedrock-agent.amazonaws.com", + "bedrock-runtime.amazonaws.com", + "bedrock-agent-runtime.amazonaws.com", + "bedrock-data-automation.amazonaws.com", + "bedrock-data-automation-runtime.amazonaws.com", + } + ) + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + if cloudtrail_client.trails is not None: + for trail in cloudtrail_client.trails.values(): + if trail.is_logging: + for data_event in trail.data_events: + match_type = self._get_bedrock_match_type(data_event) + if match_type: + report = Check_Report_AWS( + metadata=self.metadata(), resource=trail + ) + report.region = trail.home_region + report.status = "PASS" + if match_type == "classic_management": + report.status_extended = ( + f"Trail {trail.name} from home region " + f"{trail.home_region} has management events " + "enabled to log Amazon Bedrock control-plane " + "API calls." + ) + elif match_type == "advanced_management": + report.status_extended = ( + f"Trail {trail.name} from home region " + f"{trail.home_region} has an advanced " + "management event selector to log Amazon " + "Bedrock control-plane API calls." + ) + else: + report.status_extended = ( + f"Trail {trail.name} from home region " + f"{trail.home_region} has an advanced data " + "event selector to log Amazon Bedrock API " + "calls." + ) + findings.append(report) + break + if not findings: + report = Check_Report_AWS( + metadata=self.metadata(), resource=cloudtrail_client.trails + ) + report.region = cloudtrail_client.region + report.resource_arn = cloudtrail_client.trail_arn_template + report.resource_id = cloudtrail_client.audited_account + report.status = "FAIL" + report.status_extended = "No CloudTrail trails are configured to log Amazon Bedrock API calls." + findings.append(report) + return findings + + def _get_bedrock_match_type(self, data_event: Event_Selector) -> str | None: + """Return the Bedrock logging match type for an event selector. + + Args: + data_event: An Event_Selector object from the trail. + + Returns: + The matching selector type, or None if the selector does not log + the Bedrock events covered by this check. + """ + if not data_event.is_advanced: + if self._logs_classic_management_events(data_event.event_selector): + return "classic_management" + return None + + field_selectors = data_event.event_selector.get("FieldSelectors", []) + if self._logs_advanced_management_events(field_selectors): + return "advanced_management" + if self._logs_advanced_bedrock_data_events(field_selectors): + return "advanced_data" + + return None + + @staticmethod + def _logs_classic_management_events(event_selector: dict) -> bool: + """Check whether a classic selector logs Bedrock control-plane events.""" + return event_selector.get( + "IncludeManagementEvents", True + ) and event_selector.get("ReadWriteType", "All") in ("All", "WriteOnly") + + def _logs_advanced_management_events(self, field_selectors: list[dict]) -> bool: + """Check whether advanced selectors log Bedrock control-plane events.""" + event_category_selectors = [ + field for field in field_selectors if field.get("Field") == "eventCategory" + ] + if not self._selectors_match_value("Management", event_category_selectors): + return False + + read_only_selectors = [ + field for field in field_selectors if field.get("Field") == "readOnly" + ] + has_read_only_restriction = bool(read_only_selectors) and not any( + self._field_selector_matches_value("false", selector) + for selector in read_only_selectors + ) + + return not has_read_only_restriction and self._logs_bedrock_management_events( + field_selectors + ) + + def _logs_advanced_bedrock_data_events(self, field_selectors: list[dict]) -> bool: + """Check whether advanced selectors log Bedrock data events.""" + event_category_selectors = [ + field for field in field_selectors if field.get("Field") == "eventCategory" + ] + if not self._selectors_match_value("Data", event_category_selectors): + return False + + resource_type_selectors = [ + field for field in field_selectors if field.get("Field") == "resources.type" + ] + return any( + self._selectors_match_value(resource_type, resource_type_selectors) + for resource_type in self.BEDROCK_RESOURCE_TYPES + ) + + def _logs_bedrock_management_events(self, field_selectors: list[dict]) -> bool: + """Check whether advanced management selectors include Bedrock sources.""" + event_source_selectors = [ + field for field in field_selectors if field.get("Field") == "eventSource" + ] + if not event_source_selectors: + return True + + return any( + self._selectors_match_value(event_source, event_source_selectors) + for event_source in self.BEDROCK_EVENT_SOURCES + ) + + def _selectors_match_value(self, value: str, selectors: list[dict]) -> bool: + """Check whether a candidate value satisfies all selectors for a field.""" + return bool(selectors) and all( + self._field_selector_matches_value(value, selector) + for selector in selectors + ) + + @staticmethod + def _field_selector_matches_value(value: str, selector: dict) -> bool: + """Evaluate a CloudTrail advanced field selector against a candidate value.""" + conditions = [] + + if "Equals" in selector: + conditions.append(value in selector["Equals"]) + if "NotEquals" in selector: + conditions.append(value not in selector["NotEquals"]) + if "StartsWith" in selector: + conditions.append( + any(value.startswith(prefix) for prefix in selector["StartsWith"]) + ) + if "NotStartsWith" in selector: + conditions.append( + all( + not value.startswith(prefix) for prefix in selector["NotStartsWith"] + ) + ) + if "EndsWith" in selector: + conditions.append( + any(value.endswith(suffix) for suffix in selector["EndsWith"]) + ) + if "NotEndsWith" in selector: + conditions.append( + all(not value.endswith(suffix) for suffix in selector["NotEndsWith"]) + ) + + return all(conditions) if conditions else True diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json index 20ed5f0adb..9070c7b0c7 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json @@ -17,9 +17,8 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v130/monitoring/cis-v130-4-11", "https://support.icompaas.com/support/solutions/articles/62000084031-ensure-a-log-metric-filter-and-alarm-exist-for-changes-to-network-access-control-lists-nacl-", - "https://trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", "https://support.icompaas.com/support/solutions/articles/62000233134-4-11-ensure-network-access-control-list-nacl-changes-are-monitored-manual-" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py index 20d68a0121..68f45a8d27 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_acls_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateNetworkAcl.+\$\.eventName\s*=\s*.?CreateNetworkAclEntry.+\$\.eventName\s*=\s*.?DeleteNetworkAcl.+\$\.eventName\s*=\s*.?DeleteNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclAssociation.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateNetworkAcl", + "CreateNetworkAclEntry", + "DeleteNetworkAcl", + "DeleteNetworkAclEntry", + "ReplaceNetworkAclEntry", + "ReplaceNetworkAclAssociation", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py index 5f6eda3973..f7bf8e1d22 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_gateways_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateCustomerGateway.+\$\.eventName\s*=\s*.?DeleteCustomerGateway.+\$\.eventName\s*=\s*.?AttachInternetGateway.+\$\.eventName\s*=\s*.?CreateInternetGateway.+\$\.eventName\s*=\s*.?DeleteInternetGateway.+\$\.eventName\s*=\s*.?DetachInternetGateway.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateCustomerGateway", + "DeleteCustomerGateway", + "AttachInternetGateway", + "CreateInternetGateway", + "DeleteInternetGateway", + "DetachInternetGateway", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json index 82490a63da..89949cfbd6 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json @@ -37,5 +37,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "Logging and Monitoring" } diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py index f8fcc8eacb..460765cb2f 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,18 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_route_tables_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?ec2.amazonaws.com.+\$\.eventName\s*=\s*.?CreateRoute.+\$\.eventName\s*=\s*.?CreateRouteTable.+\$\.eventName\s*=\s*.?ReplaceRoute.+\$\.eventName\s*=\s*.?ReplaceRouteTableAssociation.+\$\.eventName\s*=\s*.?DeleteRouteTable.+\$\.eventName\s*=\s*.?DeleteRoute.+\$\.eventName\s*=\s*.?DisassociateRouteTable.?" + pattern = build_metric_filter_pattern( + event_source="ec2.amazonaws.com", + event_names=[ + "CreateRoute", + "CreateRouteTable", + "ReplaceRoute", + "ReplaceRouteTableAssociation", + "DeleteRouteTable", + "DeleteRoute", + "DisassociateRouteTable", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py index d7606647c4..be4fb0859d 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,21 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_vpcs_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateVpc.+\$\.eventName\s*=\s*.?DeleteVpc.+\$\.eventName\s*=\s*.?ModifyVpcAttribute.+\$\.eventName\s*=\s*.?AcceptVpcPeeringConnection.+\$\.eventName\s*=\s*.?CreateVpcPeeringConnection.+\$\.eventName\s*=\s*.?DeleteVpcPeeringConnection.+\$\.eventName\s*=\s*.?RejectVpcPeeringConnection.+\$\.eventName\s*=\s*.?AttachClassicLinkVpc.+\$\.eventName\s*=\s*.?DetachClassicLinkVpc.+\$\.eventName\s*=\s*.?DisableVpcClassicLink.+\$\.eventName\s*=\s*.?EnableVpcClassicLink.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateVpc", + "DeleteVpc", + "ModifyVpcAttribute", + "AcceptVpcPeeringConnection", + "CreateVpcPeeringConnection", + "DeleteVpcPeeringConnection", + "RejectVpcPeeringConnection", + "AttachClassicLinkVpc", + "DetachClassicLinkVpc", + "DisableVpcClassicLink", + "EnableVpcClassicLink", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py index 5154a5acb7..9cfd17ab0a 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs.py @@ -1,7 +1,11 @@ from json import dumps, loads from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( convert_to_cloudwatch_timestamp_format, ) @@ -11,95 +15,197 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_group_no_secrets_in_logs(Check): def execute(self): findings = [] - if logs_client.log_groups: - secrets_ignore_patterns = logs_client.audit_config.get( - "secrets_ignore_patterns", [] - ) + if not logs_client.log_groups: + return findings + + secrets_ignore_patterns = logs_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = logs_client.audit_config.get("secrets_validate", False) + + # Phase 1: batch-scan every (log group, log stream). Payloads are yielded + # lazily so only a chunk is written/held at a time, which matters for + # accounts with very large numbers of log groups/streams. The log group + # ARN (not its name) keys every map below, since group and stream names + # are not unique across regions and would otherwise collide. + def stream_payloads(): for log_group in logs_client.log_groups.values(): - report = Check_Report_AWS(metadata=self.metadata(), resource=log_group) - report.status = "PASS" - report.status_extended = ( - f"No secrets found in {log_group.name} log group." + if not log_group.log_streams: + continue + for log_stream_name, events in log_group.log_streams.items(): + yield ( + (log_group.arn, log_stream_name), + "\n".join(dumps(event["message"]) for event in events), + ) + + # A scanner failure here must never look like "no secrets": log groups + # whose streams could not be scanned are reported MANUAL in Phase 4. + stream_scan_error = None + try: + stream_results = detect_secrets_scan_batch( + stream_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + stream_results = {} + stream_scan_error = error + + # Phase 2: plan the per-event secrets for each flagged stream and collect + # the multiline events to rescan. Each multiline event is rescanned once + # to resolve per-line detail; the rescans are batched in Phase 3 instead + # of one subprocess per event. The event index (``line_number - 1``, + # since Phase 1 joins one event per line) is the per-event discriminator: + # a CloudWatch stream can hold several events sharing one millisecond + # timestamp, so keying only by timestamp would let a later multiline + # event overwrite an earlier one's payload and lose secret evidence. + # Output still groups/displays by timestamp; only the rescan identity is + # per event. + # stream_plans: (group arn, stream) -> + # {timestamp: {event index: {"multiline", "types"}}} + # rescan_payloads: (group arn, stream, timestamp, event index) -> + # multiline event data + stream_plans = {} + rescan_payloads = {} + groups_with_rescan = set() # group arns that depend on the Phase 3 rescan + for log_group in logs_client.log_groups.values(): + for log_stream_name in log_group.log_streams or {}: + stream_secrets = stream_results.get((log_group.arn, log_stream_name)) + if not stream_secrets: + continue + events = log_group.log_streams[log_stream_name] + plan = {} + for secret in stream_secrets: + event_index = secret["line_number"] - 1 + flagged_event = events[event_index] + cloudwatch_timestamp = convert_to_cloudwatch_timestamp_format( + flagged_event["timestamp"] + ) + try: + log_event_data = dumps( + loads(flagged_event["message"]), indent=2 + ) + except Exception: + log_event_data = dumps(flagged_event["message"], indent=2) + multiline = len(log_event_data.split("\n")) > 1 + events_at_timestamp = plan.setdefault(cloudwatch_timestamp, {}) + if event_index not in events_at_timestamp: + events_at_timestamp[event_index] = { + "multiline": multiline, + "types": [], + } + if multiline: + # More informative output is possible with more than one + # line: the event is rescanned to get the type and line + # number of each secret. + rescan_payloads[ + ( + log_group.arn, + log_stream_name, + cloudwatch_timestamp, + event_index, + ) + ] = log_event_data + groups_with_rescan.add(log_group.arn) + else: + events_at_timestamp[event_index]["types"].append(secret["type"]) + stream_plans[(log_group.arn, log_stream_name)] = plan + + # Phase 3: one batched rescan for all multiline flagged events. Validation + # is never enabled here: this rescan only resolves line numbers for + # display and must not re-authenticate the secret. + # If the rescan fails we know secrets were already found in Phase 1, so + # the affected groups must not silently pass; they are reported MANUAL. + rescan_scan_error = None + rescan_results = {} + if rescan_payloads: + try: + rescan_results = detect_secrets_scan_batch( + rescan_payloads, excluded_secrets=secrets_ignore_patterns ) - log_group_secrets = [] - if log_group.log_streams: - for log_stream_name in log_group.log_streams: - log_stream_secrets = {} - log_stream_data = "\n".join( - [ - dumps(event["message"]) - for event in log_group.log_streams[log_stream_name] - ] - ) - log_stream_secrets_output = detect_secrets_scan( - data=log_stream_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=logs_client.audit_config.get( - "detect_secrets_plugins", - ), - ) + except SecretsScanError as error: + rescan_scan_error = error - if log_stream_secrets_output: - for secret in log_stream_secrets_output: - flagged_event = log_group.log_streams[log_stream_name][ - secret["line_number"] - 1 - ] - cloudwatch_timestamp = ( - convert_to_cloudwatch_timestamp_format( - flagged_event["timestamp"] - ) - ) - if ( - cloudwatch_timestamp - not in log_stream_secrets.keys() - ): - log_stream_secrets[cloudwatch_timestamp] = ( - SecretsDict() - ) + # Phase 4: assemble one report per log group. + for log_group in logs_client.log_groups.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=log_group) + report.status = "PASS" + report.status_extended = f"No secrets found in {log_group.name} log group." - try: - log_event_data = dumps( - loads(flagged_event["message"]), indent=2 - ) - except Exception: - log_event_data = dumps( - flagged_event["message"], indent=2 - ) - if len(log_event_data.split("\n")) > 1: - # Can get more informative output if there is more than 1 line. - # Will rescan just this event to get the type of secret and the line number - event_detect_secrets_output = detect_secrets_scan( - data=log_event_data, - detect_secrets_plugins=logs_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - if event_detect_secrets_output: - for secret in event_detect_secrets_output: - log_stream_secrets[ - cloudwatch_timestamp - ].add_secret( - secret["line_number"], secret["type"] - ) - else: - log_stream_secrets[cloudwatch_timestamp].add_secret( - 1, secret["type"] - ) - if log_stream_secrets: - secrets_string = "; ".join( - [ - f"at {timestamp} - {log_stream_secrets[timestamp].to_string()}" - for timestamp in log_stream_secrets - ] - ) - log_group_secrets.append( - f"in log stream {log_stream_name} {secrets_string}" - ) - if log_group_secrets: - secrets_string = "; ".join(log_group_secrets) - report.status = "FAIL" - report.status_extended = f"Potential secrets found in log group {log_group.name} {secrets_string}." + # The stream scan failed: we cannot conclude this group is clean. + if stream_scan_error and log_group.log_streams: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan log group {log_group.name} for secrets: " + f"{stream_scan_error}; manual review is required." + ) findings.append(report) + continue + + log_group_secrets = [] + all_secrets = [] + for log_stream_name in log_group.log_streams or {}: + stream_secrets = stream_results.get((log_group.arn, log_stream_name)) + if not stream_secrets: + continue + all_secrets.extend(stream_secrets) + log_stream_secrets = {} + for cloudwatch_timestamp, events_at_timestamp in stream_plans[ + (log_group.arn, log_stream_name) + ].items(): + secrets_dict = SecretsDict() + # Multiple events can share one timestamp; aggregate each + # event's secrets into the timestamp's display entry. + for event_index, entry in events_at_timestamp.items(): + if entry["multiline"]: + for event_secret in rescan_results.get( + ( + log_group.arn, + log_stream_name, + cloudwatch_timestamp, + event_index, + ), + [], + ): + secrets_dict.add_secret( + event_secret["line_number"], event_secret["type"] + ) + else: + for secret_type in entry["types"]: + secrets_dict.add_secret(1, secret_type) + # Only record the event when at least one non-ignored secret + # remains after the rescan. A multiline event whose secrets + # were all dropped by ``secrets_ignore_patterns`` leaves an + # empty SecretsDict, which must not produce a FAIL with no + # actual secret evidence. + if secrets_dict: + log_stream_secrets[cloudwatch_timestamp] = secrets_dict + if log_stream_secrets: + secrets_string = "; ".join( + [ + f"at {timestamp} - {log_stream_secrets[timestamp].to_string()}" + for timestamp in log_stream_secrets + ] + ) + log_group_secrets.append( + f"in log stream {log_stream_name} {secrets_string}" + ) + # The multiline rescan failed for a group that had flagged secrets: + # detail is unavailable, so report MANUAL rather than risk a false + # PASS when every flagged event was multiline. + if rescan_scan_error and log_group.arn in groups_with_rescan: + report.status = "MANUAL" + report.status_extended = ( + f"Secrets were detected in log group {log_group.name} but the " + f"detailed rescan failed: {rescan_scan_error}; manual review " + "is required." + ) + elif log_group_secrets: + secrets_string = "; ".join(log_group_secrets) + report.status = "FAIL" + report.status_extended = f"Potential secrets found in log group {log_group.name} {secrets_string}." + annotate_verified_secrets(report, all_secrets) + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py index 11bf08d99d..49bf9a03a3 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change Check ): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?config.amazonaws.com.+\$\.eventName\s*=\s*.?StopConfigurationRecorder.+\$\.eventName\s*=\s*.?DeleteDeliveryChannel.+\$\.eventName\s*=\s*.?PutDeliveryChannel.+\$\.eventName\s*=\s*.?PutConfigurationRecorder.?" + pattern = build_metric_filter_pattern( + event_source="config.amazonaws.com", + event_names=[ + "StopConfigurationRecorder", + "DeleteDeliveryChannel", + "PutDeliveryChannel", + "PutConfigurationRecorder", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py index eb272ecfb1..e9567315f4 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change Check ): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateTrail.+\$\.eventName\s*=\s*.?UpdateTrail.+\$\.eventName\s*=\s*.?DeleteTrail.+\$\.eventName\s*=\s*.?StartLogging.+\$\.eventName\s*=\s*.?StopLogging.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateTrail", + "UpdateTrail", + "DeleteTrail", + "StartLogging", + "StopLogging", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py index c217cbbaf6..1b2e5173bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_authentication_failures(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.errorMessage\s*=\s*.?Failed authentication.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", "=", "Failed authentication")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py index af7ec82119..9976a885bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,32 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_aws_organizations_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?organizations\.amazonaws\.com.+\$\.eventName\s*=\s*.?AcceptHandshake.+\$\.eventName\s*=\s*.?AttachPolicy.+\$\.eventName\s*=\s*.?CancelHandshake.+\$\.eventName\s*=\s*.?CreateAccount.+\$\.eventName\s*=\s*.?CreateOrganization.+\$\.eventName\s*=\s*.?CreateOrganizationalUnit.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeclineHandshake.+\$\.eventName\s*=\s*.?DeleteOrganization.+\$\.eventName\s*=\s*.?DeleteOrganizationalUnit.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?EnableAllFeatures.+\$\.eventName\s*=\s*.?EnablePolicyType.+\$\.eventName\s*=\s*.?InviteAccountToOrganization.+\$\.eventName\s*=\s*.?LeaveOrganization.+\$\.eventName\s*=\s*.?DetachPolicy.+\$\.eventName\s*=\s*.?DisablePolicyType.+\$\.eventName\s*=\s*.?MoveAccount.+\$\.eventName\s*=\s*.?RemoveAccountFromOrganization.+\$\.eventName\s*=\s*.?UpdateOrganizationalUnit.+\$\.eventName\s*=\s*.?UpdatePolicy.?" + pattern = build_metric_filter_pattern( + event_source="organizations.amazonaws.com", + event_names=[ + "AcceptHandshake", + "AttachPolicy", + "CancelHandshake", + "CreateAccount", + "CreateOrganization", + "CreateOrganizationalUnit", + "CreatePolicy", + "DeclineHandshake", + "DeleteOrganization", + "DeleteOrganizationalUnit", + "DeletePolicy", + "EnableAllFeatures", + "EnablePolicyType", + "InviteAccountToOrganization", + "LeaveOrganization", + "DetachPolicy", + "DisablePolicyType", + "MoveAccount", + "RemoveAccountFromOrganization", + "UpdateOrganizationalUnit", + "UpdatePolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py index 4cfed985f7..20d1d62a5a 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?kms.amazonaws.com.+\$\.eventName\s*=\s*.?DisableKey.+\$\.eventName\s*=\s*.?ScheduleKeyDeletion.?" + pattern = build_metric_filter_pattern( + event_source="kms.amazonaws.com", + event_names=["DisableKey", "ScheduleKeyDeletion"], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json index 2d77c42704..6292de6071 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json @@ -17,8 +17,7 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes", - "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v5.0.0_L1.audit:8101350d6907e07863ac6748689b3e12" + "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes" ], "Remediation": { "Code": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py index 45d09b3528..d5fe1ef994 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,20 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?s3.amazonaws.com.+\$\.eventName\s*=\s*.?PutBucketAcl.+\$\.eventName\s*=\s*.?PutBucketPolicy.+\$\.eventName\s*=\s*.?PutBucketCors.+\$\.eventName\s*=\s*.?PutBucketLifecycle.+\$\.eventName\s*=\s*.?PutBucketReplication.+\$\.eventName\s*=\s*.?DeleteBucketPolicy.+\$\.eventName\s*=\s*.?DeleteBucketCors.+\$\.eventName\s*=\s*.?DeleteBucketLifecycle.+\$\.eventName\s*=\s*.?DeleteBucketReplication.?" + pattern = build_metric_filter_pattern( + event_source="s3.amazonaws.com", + event_names=[ + "PutBucketAcl", + "PutBucketPolicy", + "PutBucketCors", + "PutBucketLifecycle", + "PutBucketReplication", + "DeleteBucketPolicy", + "DeleteBucketCors", + "DeleteBucketLifecycle", + "DeleteBucketReplication", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json index 75cba1ee62..b1c36be2d8 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json @@ -17,7 +17,6 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v140/monitoring/cis-v140-4-4", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-iam-policy-change" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py index f5efd04dde..4347a9b3aa 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,26 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_policy_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?DeleteGroupPolicy.+\$\.eventName\s*=\s*.?DeleteRolePolicy.+\$\.eventName\s*=\s*.?DeleteUserPolicy.+\$\.eventName\s*=\s*.?PutGroupPolicy.+\$\.eventName\s*=\s*.?PutRolePolicy.+\$\.eventName\s*=\s*.?PutUserPolicy.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?CreatePolicyVersion.+\$\.eventName\s*=\s*.?DeletePolicyVersion.+\$\.eventName\s*=\s*.?AttachRolePolicy.+\$\.eventName\s*=\s*.?DetachRolePolicy.+\$\.eventName\s*=\s*.?AttachUserPolicy.+\$\.eventName\s*=\s*.?DetachUserPolicy.+\$\.eventName\s*=\s*.?AttachGroupPolicy.+\$\.eventName\s*=\s*.?DetachGroupPolicy.?" + pattern = build_metric_filter_pattern( + event_names=[ + "DeleteGroupPolicy", + "DeleteRolePolicy", + "DeleteUserPolicy", + "PutGroupPolicy", + "PutRolePolicy", + "PutUserPolicy", + "CreatePolicy", + "DeletePolicy", + "CreatePolicyVersion", + "DeletePolicyVersion", + "AttachRolePolicy", + "DetachRolePolicy", + "AttachUserPolicy", + "DetachUserPolicy", + "AttachGroupPolicy", + "DetachGroupPolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py index a7972e420c..3557632904 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_security_group_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?AuthorizeSecurityGroupIngress.+\$\.eventName\s*=\s*.?AuthorizeSecurityGroupEgress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupIngress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupEgress.+\$\.eventName\s*=\s*.?CreateSecurityGroup.+\$\.eventName\s*=\s*.?DeleteSecurityGroup.?" + pattern = build_metric_filter_pattern( + event_names=[ + "AuthorizeSecurityGroupIngress", + "AuthorizeSecurityGroupEgress", + "RevokeSecurityGroupIngress", + "RevokeSecurityGroupEgress", + "CreateSecurityGroup", + "DeleteSecurityGroup", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json index 20058c1ed8..76ffd832ff 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json @@ -21,7 +21,6 @@ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/console-sign-in-without-mfa.html", "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v3.0.0_L1.audit:1957056ee174cc38502d5f5f1864333b", - "https://www.clouddefense.ai/compliance-rules/gdpr/data-protection/log-metric-filter-console-login-mfa", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-no-mfa", "https://support.icompaas.com/support/solutions/articles/62000083605-ensure-a-log-metric-filter-and-alarm-exist-for-management-console-sign-in-without-mfa" ], diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py index 8437600646..07475a6185 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_sign_in_without_mfa(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.additionalEventData\.MFAUsed\s*!=\s*.?Yes.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py index 84d70b4083..e5d104b840 100644 --- a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py +++ b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py @@ -3,6 +3,45 @@ import re from prowler.lib.check.models import Check_Report_AWS +def build_metric_filter_pattern( + *, + event_names: list[str] | None = None, + event_source: str | None = None, + extra_clauses: list[tuple[str, str, str]] | None = None, +) -> str: + """Build a regex pattern to match a CloudWatch Logs filterPattern string. + + All clauses must be present for the pattern to match, regardless of the + order in which AWS stores them. Event names are matched exactly, so a + short name like ``CreateRoute`` will not be satisfied by a longer one + like ``CreateRouteTable``. + + Pass the result directly to ``check_cloudwatch_log_metric_filter``. + + Args: + event_names: AWS API action names to require (``$.eventName``). + event_source: optional service principal to require (``$.eventSource``), + e.g. ``"ec2.amazonaws.com"``. + extra_clauses: additional conditions as ``(field, operator, value)`` + tuples, where ``operator`` is ``"="`` or ``"!="``. Example: + ``("additionalEventData.MFAUsed", "!=", "Yes")``. + + Returns: + A regex string for use with ``re.search(..., flags=re.DOTALL)``. + """ + parts: list[str] = [] + if event_source is not None: + parts.append(rf"(?=.*\$\.eventSource\s*=\s*.?{re.escape(event_source)})") + for name in event_names or []: + parts.append(rf"(?=.*\$\.eventName\s*=\s*.?{re.escape(name)}\b)") + for field, operator, value in extra_clauses or []: + if operator not in ("=", "!="): + raise ValueError(f"unsupported operator {operator!r}; expected '=' or '!='") + op = r"\s*!=\s*" if operator == "!=" else r"\s*=\s*" + parts.append(rf"(?=.*\$\.{re.escape(field)}{op}.?{re.escape(value)})") + return "".join(parts) + + def check_cloudwatch_log_metric_filter( metric_filter_pattern: str, trails: list, diff --git a/prowler/providers/aws/services/codeartifact/codeartifact_service.py b/prowler/providers/aws/services/codeartifact/codeartifact_service.py index f3d312a531..1465092063 100644 --- a/prowler/providers/aws/services/codeartifact/codeartifact_service.py +++ b/prowler/providers/aws/services/codeartifact/codeartifact_service.py @@ -96,6 +96,7 @@ class CodeArtifact(AWSService): namespace=package_namespace, package=package_name, sortBy="PUBLISHED_TIME", + maxResults=1, ) ) else: @@ -111,6 +112,7 @@ class CodeArtifact(AWSService): format=package_format, package=package_name, sortBy="PUBLISHED_TIME", + maxResults=1, ) ) latest_version = "" diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py b/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py index 8d031cc25a..2e0c5863eb 100644 --- a/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py +++ b/prowler/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables.py @@ -1,7 +1,11 @@ import json from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.codebuild.codebuild_client import codebuild_client @@ -14,35 +18,71 @@ class codebuild_project_no_secrets_in_variables(Check): secrets_ignore_patterns = codebuild_client.audit_config.get( "secrets_ignore_patterns", [] ) - for project in codebuild_client.projects.values(): + validate = codebuild_client.audit_config.get("secrets_validate", False) + projects = list(codebuild_client.projects.values()) + + # Collect every scannable plaintext variable across all projects and scan + # them in batched Kingfisher invocations instead of one subprocess per + # variable. Findings are keyed by (project index, variable index). + def payloads(): + for project_index, project in enumerate(projects): + if project.environment_variables: + for var_index, env_var in enumerate(project.environment_variables): + if ( + env_var.type == "PLAINTEXT" + and env_var.name not in sensitive_vars_excluded + ): + yield (project_index, var_index), json.dumps( + {env_var.name: env_var.value} + ) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for project_index, project in enumerate(projects): report = Check_Report_AWS(metadata=self.metadata(), resource=project) report.status = "PASS" report.status_extended = f"CodeBuild project {project.name} does not have sensitive environment plaintext credentials." secrets_found = [] + all_secrets = [] + + if scan_error and any( + env_var.type == "PLAINTEXT" + and env_var.name not in sensitive_vars_excluded + for env_var in project.environment_variables or [] + ): + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan CodeBuild project {project.name} environment " + f"variables for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue if project.environment_variables: - for env_var in project.environment_variables: - if ( - env_var.type == "PLAINTEXT" - and env_var.name not in sensitive_vars_excluded - ): - detect_secrets_output = detect_secrets_scan( - data=json.dumps({env_var.name: env_var.value}), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=codebuild_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - if detect_secrets_output: - secrets_info = [ + for var_index, env_var in enumerate(project.environment_variables): + detect_secrets_output = batch_results.get( + (project_index, var_index) + ) + if detect_secrets_output: + all_secrets.extend(detect_secrets_output) + secrets_found.extend( + [ f"{secret['type']} in variable {env_var.name}" for secret in detect_secrets_output ] - secrets_found.extend(secrets_info) + ) if secrets_found: report.status = "FAIL" report.status_extended = f"CodeBuild project {project.name} has sensitive environment plaintext credentials in variables: {', '.join(secrets_found)}." + annotate_verified_secrets(report, all_secrets) findings.append(report) diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/__init__.py b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/codebuild/codebuild_service.py b/prowler/providers/aws/services/codebuild/codebuild_service.py index 361002aa65..d4d085065d 100644 --- a/prowler/providers/aws/services/codebuild/codebuild_service.py +++ b/prowler/providers/aws/services/codebuild/codebuild_service.py @@ -1,4 +1,5 @@ import datetime +from concurrent.futures import as_completed from typing import List, Optional from pydantic.v1 import BaseModel @@ -14,9 +15,9 @@ class Codebuild(AWSService): super().__init__(__class__.__name__, provider) self.projects = {} self.__threading_call__(self._list_projects) - self.__threading_call__(self._list_builds_for_project, self.projects.values()) - self.__threading_call__(self._batch_get_builds, self.projects.values()) - self.__threading_call__(self._batch_get_projects, self.projects.values()) + self.__threading_call__(self._list_builds_for_project) + self.__threading_call__(self._batch_get_builds) + self.__threading_call__(self._batch_get_projects) self.report_groups = {} self.__threading_call__(self._list_report_groups) self.__threading_call__( @@ -44,10 +45,8 @@ class Codebuild(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def _list_builds_for_project(self, project): - logger.info("Codebuild - Listing builds...") + def _fetch_project_last_build(self, regional_client, project): try: - regional_client = self.regional_clients[project.region] build_ids = regional_client.list_builds_for_project( projectName=project.name ).get("ids", []) @@ -58,28 +57,99 @@ class Codebuild(AWSService): f"{project.region}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def _batch_get_builds(self, project): - logger.info("Codebuild - Getting builds...") + def _list_builds_for_project(self, regional_client): + logger.info("Codebuild - Listing builds...") try: - if project.last_build and project.last_build.id: - regional_client = self.regional_clients[project.region] - builds_by_id = regional_client.batch_get_builds( - ids=[project.last_build.id] - ).get("builds", []) - if len(builds_by_id) > 0: - project.last_invoked_time = builds_by_id[0].get("endTime") + regional_projects = [ + project + for project in self.projects.values() + if project.region == regional_client.region + ] + + # list_builds_for_project has no batch API equivalent, so reuse the + # shared thread pool to issue per-project calls in parallel within + # this region — preserving the wall-clock performance of the + # previous implementation. + futures = [ + self.thread_pool.submit( + self._fetch_project_last_build, regional_client, project + ) + for project in regional_projects + ] + for future in as_completed(futures): + try: + future.result() + except Exception: + pass except Exception as error: logger.error( - f"{regional_client.region}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def _batch_get_projects(self, project): + def _batch_get_builds(self, regional_client): + logger.info("Codebuild - Getting builds...") + try: + # Collect all build IDs for this region + build_id_to_project = {} + for project in self.projects.values(): + if ( + project.region == regional_client.region + and project.last_build + and project.last_build.id + ): + build_id_to_project[project.last_build.id] = project + + if not build_id_to_project: + return + + build_ids = list(build_id_to_project.keys()) + + # batch_get_builds supports up to 100 IDs per call + for i in range(0, len(build_ids), 100): + batch = build_ids[i : i + 100] + response = regional_client.batch_get_builds(ids=batch) + for build_info in response.get("builds", []): + build_id = build_info.get("id") + if build_id in build_id_to_project: + end_time = build_info.get("endTime") + if end_time: + build_id_to_project[build_id].last_invoked_time = end_time + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _batch_get_projects(self, regional_client): logger.info("Codebuild - Getting projects...") try: - regional_client = self.regional_clients[project.region] - project_info = regional_client.batch_get_projects(names=[project.name])[ - "projects" - ][0] + # Collect all project names for this region + regional_projects = { + arn: project + for arn, project in self.projects.items() + if project.region == regional_client.region + } + if not regional_projects: + return + + project_names = [project.name for project in regional_projects.values()] + + # batch_get_projects supports up to 100 names per call + for i in range(0, len(project_names), 100): + batch = project_names[i : i + 100] + response = regional_client.batch_get_projects(names=batch) + for project_info in response.get("projects", []): + project_arn = project_info.get("arn") + if project_arn in regional_projects: + self._parse_project_info( + regional_projects[project_arn], project_info + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _parse_project_info(self, project, project_info): + try: project.buildspec = project_info["source"].get("buildspec") if project_info["source"]["type"] != "NO_SOURCE": project.source = Source( diff --git a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py index 0c745fc658..bc9f569f1b 100644 --- a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py +++ b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py @@ -7,6 +7,8 @@ from prowler.providers.aws.services.codepipeline.codepipeline_client import ( codepipeline_client, ) +HTTP_TIMEOUT = 30 + class codepipeline_project_repo_private(Check): """Checks if AWS CodePipeline source repositories are configured as private. @@ -87,9 +89,8 @@ class codepipeline_project_repo_private(Check): repo_url = repo_url[:-4] try: - context = ssl._create_unverified_context() req = urllib.request.Request(repo_url, method="HEAD") - response = urllib.request.urlopen(req, context=context) + response = urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) return not response.geturl().endswith("sign_in") - except (urllib.error.HTTPError, urllib.error.URLError): + except (urllib.error.URLError, TimeoutError, ssl.SSLError): return False diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json new file mode 100644 index 0000000000..cbbd7a66dd --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "config_delegated_admin_and_org_aggregator_all_regions", + "CheckTitle": "AWS Config has a delegated administrator and an organization aggregator covering all AWS regions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "config", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsConfigConfigurationAggregator", + "ResourceGroup": "governance", + "Description": "**AWS Config** has a delegated administrator registered via AWS Organizations and at least one Configuration Aggregator with an OrganizationAggregationSource that covers all AWS regions, ensuring centralized org-wide configuration visibility.", + "Risk": "Without an org-wide **AWS Config** aggregator and a delegated administrator, configuration data is fragmented across accounts and regions, **compliance reporting** is incomplete, and **drift detection** is delayed. Adversaries or misconfigurations can persist in unmonitored accounts, eroding **audit readiness** and **regulatory posture**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/config/latest/developerguide/aggregate-data.html", + "https://docs.aws.amazon.com/config/latest/developerguide/set-up-aggregator-cli.html", + "https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-config.html" + ], + "Remediation": { + "Code": { + "CLI": "aws organizations register-delegated-administrator --account-id --service-principal config.amazonaws.com && aws configservice put-configuration-aggregator --configuration-aggregator-name org-aggregator --organization-aggregation-source RoleArn=,AllAwsRegions=true", + "NativeIaC": "", + "Other": "1. From the AWS Organizations management account, register the delegated administrator for config.amazonaws.com\n2. In the delegated admin account, open AWS Config\n3. Create a Configuration Aggregator and select Add my organization as the source\n4. Enable Include all AWS Regions\n5. Confirm an IAM role with AWSConfigRoleForOrganizations is attached\n6. Verify the aggregator status reaches SUCCEEDED for all member accounts", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register a **delegated administrator** for AWS Config via AWS Organizations and create at least one **Configuration Aggregator** with an OrganizationAggregationSource that covers **all AWS regions**. This centralizes configuration data across the organization for unified compliance and audit reporting.", + "Url": "https://hub.prowler.com/check/config_delegated_admin_and_org_aggregator_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "config_recorder_all_regions_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py new file mode 100644 index 0000000000..f56ea191f8 --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py @@ -0,0 +1,115 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.config.config_client import config_client +from prowler.providers.aws.services.config.config_service import Aggregator + + +class config_delegated_admin_and_org_aggregator_all_regions(Check): + """Ensure AWS Config has a delegated admin and an org aggregator covering all regions. + + This check verifies that: + 1. A delegated administrator is registered for the config.amazonaws.com + service principal via AWS Organizations. + 2. At least one AWS Config Configuration Aggregator exists with an + OrganizationAggregationSource that covers all AWS regions + (AllAwsRegions=true). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. One finding per + aggregator-region, or a single synthetic FAIL when no aggregators + exist in any region. + """ + findings = [] + + has_delegated_admin = ( + bool(config_client.delegated_administrators) + and not config_client.delegated_administrators_lookup_failed + ) + delegated_admin_unknown = config_client.delegated_administrators_lookup_failed + + # No aggregators in any region: emit one synthetic FAIL anchored to the + # audited account in the default region. + if not config_client.aggregators: + synthetic = Aggregator( + name="unknown", + arn=config_client.get_unknown_arn( + region=config_client.region, + resource_type="config-aggregator", + ), + region=config_client.region, + all_aws_regions=False, + aws_regions=None, + organization_aggregation_source_present=False, + ) + report = Check_Report_AWS(metadata=self.metadata(), resource=synthetic) + if delegated_admin_unknown: + delegated_state = ( + "delegated administrator status could not be determined" + ) + elif has_delegated_admin: + delegated_state = "delegated administrator configured" + else: + delegated_state = ( + "no delegated administrator registered for config.amazonaws.com" + ) + report.status = "FAIL" + report.status_extended = ( + f"AWS Config has no Organization Aggregator configured in any " + f"region ({delegated_state})." + ) + findings.append(report) + return findings + + for region, aggregators_in_region in config_client.aggregators.items(): + for aggregator in aggregators_in_region: + report = Check_Report_AWS(metadata=self.metadata(), resource=aggregator) + + org_aware = aggregator.organization_aggregation_source_present + covers_all = aggregator.all_aws_regions + + issues = [] + if delegated_admin_unknown: + issues.append( + "delegated administrator status for config.amazonaws.com " + "could not be determined" + ) + elif not has_delegated_admin: + issues.append( + "no delegated administrator registered for config.amazonaws.com" + ) + if not org_aware: + issues.append( + f"aggregator {aggregator.name} is not an organization aggregator" + ) + elif not covers_all: + issues.append( + f"aggregator {aggregator.name} does not cover all AWS regions" + ) + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} has issues: {', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} is an organization aggregator covering all " + f"AWS regions with delegated admin configured." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + config_client.audit_config.get("mute_non_default_regions", False) + and region != config_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/config/config_service.py b/prowler/providers/aws/services/config/config_service.py index 443cee2233..85ab22fd8e 100644 --- a/prowler/providers/aws/services/config/config_service.py +++ b/prowler/providers/aws/services/config/config_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -12,10 +13,16 @@ class Config(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.recorders = {} + self.aggregators: dict[str, list] = {} + self.delegated_administrators: list = [] + self.delegated_administrators_lookup_failed: bool = False self.__threading_call__(self.describe_configuration_recorders) self.__threading_call__( self._describe_configuration_recorder_status, self.recorders.values() ) + self.__threading_call__(self._describe_configuration_aggregators) + # Organizations API is not regional; single call. + self._list_config_delegated_administrators() def _get_recorder_arn_template(self, region): return f"arn:{self.audited_partition}:config:{region}:{self.audited_account}:recorder" @@ -73,6 +80,108 @@ class Config(AWSService): f"{recorder.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_configuration_aggregators(self, regional_client): + """Describe AWS Config configuration aggregators per region. + + An aggregator counts as organization-aware when its + OrganizationAggregationSource key is present in the response. + """ + logger.info("Config - Describing Configuration Aggregators...") + try: + paginator = regional_client.get_paginator( + "describe_configuration_aggregators" + ) + region_aggregators: list = [] + for page in paginator.paginate(): + for aggregator in page.get("ConfigurationAggregators", []): + name = aggregator.get("ConfigurationAggregatorName", "") + arn = aggregator.get("ConfigurationAggregatorArn", "") + org_source = aggregator.get("OrganizationAggregationSource") + org_aware = org_source is not None + all_aws_regions = False + aws_regions: Optional[list] = None + if org_aware: + all_aws_regions = org_source.get("AllAwsRegions", False) + aws_regions = org_source.get("AwsRegions") + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + region_aggregators.append( + Aggregator( + name=name, + arn=arn, + region=regional_client.region, + all_aws_regions=all_aws_regions, + aws_regions=aws_regions, + organization_aggregation_source_present=org_aware, + ) + ) + if region_aggregators: + self.aggregators[regional_client.region] = region_aggregators + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "AccessDenied", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_config_delegated_administrators(self): + """List delegated administrators for the AWS Config service principal. + + Uses the Organizations API directly (not regional). Sets + delegated_administrators_lookup_failed to True on AccessDenied so callers + can surface the unknown delegated-admin state in findings. + """ + logger.info( + "Config - Listing delegated administrators for config.amazonaws.com..." + ) + try: + org_client = self.session.client("organizations") + paginator = org_client.get_paginator("list_delegated_administrators") + for page in paginator.paginate(ServicePrincipal="config.amazonaws.com"): + for admin in page.get("DelegatedAdministrators", []): + self.delegated_administrators.append( + ConfigDelegatedAdministrator( + id=admin.get("Id", ""), + arn=admin.get("Arn", ""), + name=admin.get("Name", ""), + email=admin.get("Email", ""), + status=admin.get("Status", ""), + joined_method=admin.get("JoinedMethod", ""), + ) + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code in ( + "AccessDeniedException", + "AccessDenied", + "AWSOrganizationsNotInUseException", + ): + self.delegated_administrators_lookup_failed = True + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Recorder(BaseModel): name: str @@ -80,3 +189,25 @@ class Recorder(BaseModel): recording: Optional[bool] last_status: Optional[str] region: str + + +class Aggregator(BaseModel): + """Represents an AWS Config Configuration Aggregator.""" + + name: str + arn: str + region: str + all_aws_regions: bool = False + aws_regions: Optional[list] = None + organization_aggregation_source_present: bool = False + + +class ConfigDelegatedAdministrator(BaseModel): + """Represents a delegated administrator registered for config.amazonaws.com.""" + + id: str + arn: str + name: str + email: str + status: str + joined_method: str diff --git a/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json index bc074fe730..7228d5dd59 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json @@ -34,7 +34,8 @@ } }, "Categories": [ - "secrets" + "secrets", + "ec2-imdsv1" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json index 5637f40e23..77d24a4820 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json @@ -36,7 +36,8 @@ }, "Categories": [ "identity-access", - "secrets" + "secrets", + "ec2-imdsv1" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py index 3c3864c479..31b3f8fb05 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py +++ b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.py @@ -4,7 +4,11 @@ from base64 import b64decode from prowler.config.config import encoding_format_utf_8 from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.logger import logger -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ec2.ec2_client import ec2_client @@ -14,54 +18,86 @@ class ec2_instance_secrets_user_data(Check): secrets_ignore_patterns = ec2_client.audit_config.get( "secrets_ignore_patterns", [] ) - for instance in ec2_client.instances: - if instance.state != "terminated": - report = Check_Report_AWS(metadata=self.metadata(), resource=instance) - if instance.user_data: - user_data = b64decode(instance.user_data) - try: - if user_data[0:2] == b"\x1f\x8b": # GZIP magic number - user_data = zlib.decompress( - user_data, zlib.MAX_WBITS | 32 - ).decode(encoding_format_utf_8) - else: - user_data = user_data.decode(encoding_format_utf_8) - except UnicodeDecodeError as error: - logger.warning( - f"{instance.region} -- Unable to decode user data in EC2 instance {instance.id}: {error}" - ) - continue - except Exception as error: - logger.error( - f"{instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - continue - detect_secrets_output = detect_secrets_scan( - data=user_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ec2_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - if detect_secrets_output: - secrets_string = ", ".join( - [ - f"{secret['type']} on line {secret['line_number']}" - for secret in detect_secrets_output - ] - ) - report.status = "FAIL" - report.status_extended = f"Potential secret found in EC2 instance {instance.id} User Data -> {secrets_string}." + validate = ec2_client.audit_config.get("secrets_validate", False) + instances = list(ec2_client.instances) + # Collect the decoded User Data of each non-terminated instance and scan + # it all in batched Kingfisher invocations instead of one subprocess each. + # Instances whose User Data cannot be decoded are undecodable (no report), + # matching the original per-resource behavior. + undecodable = set() + + def payloads(): + for index, instance in enumerate(instances): + if instance.state == "terminated" or not instance.user_data: + continue + user_data = b64decode(instance.user_data) + try: + if user_data[0:2] == b"\x1f\x8b": # GZIP magic number + user_data = zlib.decompress( + user_data, zlib.MAX_WBITS | 32 + ).decode(encoding_format_utf_8) else: - report.status = "PASS" - report.status_extended = ( - f"No secrets found in EC2 instance {instance.id} User Data." - ) + user_data = user_data.decode(encoding_format_utf_8) + except UnicodeDecodeError as error: + logger.warning( + f"{instance.region} -- Unable to decode user data in EC2 instance {instance.id}: {error}" + ) + undecodable.add(index) + continue + except Exception as error: + logger.error( + f"{instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + undecodable.add(index) + continue + yield index, user_data + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, instance in enumerate(instances): + if instance.state == "terminated": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + if scan_error and instance.user_data: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan EC2 instance {instance.id} User Data for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + if index in undecodable: + report.status = "MANUAL" + report.status_extended = f"Could not decode User Data for EC2 instance {instance.id}; manual review is required to scan for secrets." + elif instance.user_data: + detect_secrets_output = batch_results.get(index) + if detect_secrets_output: + secrets_string = ", ".join( + [ + f"{secret['type']} on line {secret['line_number']}" + for secret in detect_secrets_output + ] + ) + report.status = "FAIL" + report.status_extended = f"Potential secret found in EC2 instance {instance.id} User Data -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status = "PASS" - report.status_extended = f"No secrets found in EC2 instance {instance.id} since User Data is empty." + report.status_extended = ( + f"No secrets found in EC2 instance {instance.id} User Data." + ) + else: + report.status = "PASS" + report.status_extended = f"No secrets found in EC2 instance {instance.id} since User Data is empty." - findings.append(report) + findings.append(report) return findings diff --git a/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py b/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py index 823553bcdf..e84da724a2 100644 --- a/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py +++ b/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.py @@ -4,7 +4,11 @@ from base64 import b64decode from prowler.config.config import encoding_format_utf_8 from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.logger import logger -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ec2.ec2_client import ec2_client @@ -14,43 +18,77 @@ class ec2_launch_template_no_secrets(Check): secrets_ignore_patterns = ec2_client.audit_config.get( "secrets_ignore_patterns", [] ) - for template in ec2_client.launch_templates: + validate = ec2_client.audit_config.get("secrets_validate", False) + templates = list(ec2_client.launch_templates) + + # Track versions whose User Data cannot be decoded so the template is + # surfaced (MANUAL) instead of silently claiming no secrets were found. + undecodable_versions = {} + + # Collect the decoded User Data of every (template, version) and scan it + # all in batched Kingfisher invocations instead of one subprocess per + # version. Versions whose User Data cannot be decoded are recorded above. + def payloads(): + for template_index, template in enumerate(templates): + for version_index, version in enumerate(template.versions): + if not version.template_data.user_data: + continue + user_data = b64decode(version.template_data.user_data) + try: + if user_data[0:2] == b"\x1f\x8b": # GZIP magic number + user_data = zlib.decompress( + user_data, zlib.MAX_WBITS | 32 + ).decode(encoding_format_utf_8) + else: + user_data = user_data.decode(encoding_format_utf_8) + except UnicodeDecodeError as error: + logger.warning( + f"{template.region} -- Unable to decode User Data in EC2 Launch Template {template.name} version {version.version_number}: {error}" + ) + undecodable_versions.setdefault(template_index, []).append( + version.version_number + ) + continue + except Exception as error: + logger.error( + f"{template.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + undecodable_versions.setdefault(template_index, []).append( + version.version_number + ) + continue + yield (template_index, version_index), user_data + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for template_index, template in enumerate(templates): report = Check_Report_AWS(metadata=self.metadata(), resource=template) - versions_with_secrets = [] - - for version in template.versions: - if not version.template_data.user_data: - continue - user_data = b64decode(version.template_data.user_data) - - try: - if user_data[0:2] == b"\x1f\x8b": # GZIP magic number - user_data = zlib.decompress( - user_data, zlib.MAX_WBITS | 32 - ).decode(encoding_format_utf_8) - else: - user_data = user_data.decode(encoding_format_utf_8) - except UnicodeDecodeError as error: - logger.warning( - f"{template.region} -- Unable to decode User Data in EC2 Launch Template {template.name} version {version.version_number}: {error}" - ) - continue - except Exception as error: - logger.error( - f"{template.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - continue - - version_secrets = detect_secrets_scan( - data=user_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ec2_client.audit_config.get( - "detect_secrets_plugins" - ), + if scan_error and any( + version.template_data.user_data for version in template.versions + ): + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan EC2 Launch Template {template.name} User Data " + f"for secrets: {scan_error}; manual review is required." ) + findings.append(report) + continue + versions_with_secrets = [] + all_secrets = [] + + for version_index, version in enumerate(template.versions): + version_secrets = batch_results.get((template_index, version_index)) if version_secrets: + all_secrets.extend(version_secrets) secrets_string = ", ".join( [ f"{secret['type']} on line {secret['line_number']}" @@ -61,9 +99,14 @@ class ec2_launch_template_no_secrets(Check): f"Version {version.version_number}: {secrets_string}" ) + undecodable = undecodable_versions.get(template_index, []) if len(versions_with_secrets) > 0: report.status = "FAIL" report.status_extended = f"Potential secret found in User Data for EC2 Launch Template {template.name} in template versions: {', '.join(versions_with_secrets)}." + annotate_verified_secrets(report, all_secrets) + elif undecodable: + report.status = "MANUAL" + report.status_extended = f"Could not decode User Data for EC2 Launch Template {template.name} versions: {', '.join(str(version_number) for version_number in undecodable)}; manual review is required to scan for secrets." else: report.status = "PASS" report.status_extended = f"No secrets found in User Data of any version for EC2 Launch Template {template.name}." diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py index cd835d0149..b0bc7198ee 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py @@ -1,7 +1,11 @@ from json import dumps from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ecs.ecs_client import ecs_client @@ -11,33 +15,67 @@ class ecs_task_definitions_no_environment_secrets(Check): secrets_ignore_patterns = ecs_client.audit_config.get( "secrets_ignore_patterns", [] ) - for task_definition in ecs_client.task_definitions.values(): + validate = ecs_client.audit_config.get("secrets_validate", False) + task_definitions = list(ecs_client.task_definitions.values()) + + # Scan every (task definition, container) environment in batched + # Kingfisher invocations instead of one subprocess per container. + # Payloads are yielded lazily so only a chunk is held/written at a time. + def environment_payloads(): + for td_index, task_definition in enumerate(task_definitions): + for c_index, container in enumerate( + task_definition.container_definitions + ): + if container.environment: + dump_env_vars = { + env_var.name: env_var.value + for env_var in container.environment + } + yield (td_index, c_index), dumps(dump_env_vars, indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + environment_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for td_index, task_definition in enumerate(task_definitions): report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) report.resource_id = f"{task_definition.name}:{task_definition.revision}" report.status = "PASS" extended_status_parts = [] + all_secrets = [] - for container in task_definition.container_definitions: + if scan_error and any( + container.environment + for container in task_definition.container_definitions + ): + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan ECS task definition {task_definition.name} with " + f"revision {task_definition.revision} for secrets: {scan_error}; " + "manual review is required." + ) + findings.append(report) + continue + + for c_index, container in enumerate(task_definition.container_definitions): container_secrets_found = [] if container.environment: - dump_env_vars = {} - original_env_vars = [] - for env_var in container.environment: - dump_env_vars.update({env_var.name: env_var.value}) - original_env_vars.append(env_var.name) - - env_data = dumps(dump_env_vars, indent=2) - detect_secrets_output = detect_secrets_scan( - data=env_data, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ecs_client.audit_config.get( - "detect_secrets_plugins", - ), - ) + original_env_vars = [ + env_var.name for env_var in container.environment + ] + detect_secrets_output = batch_results.get((td_index, c_index)) if detect_secrets_output: + all_secrets.extend(detect_secrets_output) secrets_string = ", ".join( [ f"{secret['type']} on the environment variable {original_env_vars[secret['line_number'] - 2]}" @@ -56,6 +94,7 @@ class ecs_task_definitions_no_environment_secrets(Check): + "; ".join(extended_status_parts) + "." ) + annotate_verified_secrets(report, all_secrets) else: report.status_extended = f"No secrets found in variables of ECS task definition {task_definition.name} with revision {task_definition.revision}." findings.append(report) diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json new file mode 100644 index 0000000000..0293501578 --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "elbv2_alb_drop_invalid_header_fields_enabled", + "CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access", + "Effects/Data Exposure" + ], + "ServiceName": "elbv2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsElbv2LoadBalancer", + "ResourceGroup": "network", + "Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.", + "Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields", + "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4" + ], + "Remediation": { + "Code": { + "CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true", + "NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```", + "Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.", + "Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"\" {\n name = \"\"\n load_balancer_type = \"application\"\n subnets = [\"\", \"\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```" + }, + "Recommendation": { + "Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.", + "Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py new file mode 100644 index 0000000000..86740a253a --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client + + +class elbv2_alb_drop_invalid_header_fields_enabled(Check): + def execute(self): + findings = [] + for lb in elbv2_client.loadbalancersv2.values(): + if lb.type == "application": + report = Check_Report_AWS( + metadata=self.metadata(), + resource=lb, + ) + report.status = "PASS" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is configured to drop invalid " + "header fields." + ) + if lb.drop_invalid_header_fields != "true": + report.status = "FAIL" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is not configured to drop " + "invalid header fields." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py b/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py index 0a767cafed..f49ac1ceba 100644 --- a/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py +++ b/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py @@ -9,15 +9,13 @@ from prowler.providers.aws.lib.service.service import AWSService class GlobalAccelerator(AWSService): def __init__(self, provider): - # Call AWSService's __init__ - super().__init__(__class__.__name__, provider) + # Global Accelerator is a global service that supports endpoints in multiple AWS Regions + # but you must specify the US West (Oregon) Region to create, update, or otherwise work with accelerators. + # That is, for example, specify --region us-west-2 on AWS CLI commands. + region = "us-west-2" if provider.identity.partition == "aws" else None + super().__init__(__class__.__name__, provider, region=region) self.accelerators = {} if self.audited_partition == "aws": - # Global Accelerator is a global service that supports endpoints in multiple AWS Regions - # but you must specify the US West (Oregon) Region to create, update, or otherwise work with accelerators. - # That is, for example, specify --region us-west-2 on AWS CLI commands. - self.region = "us-west-2" - self.client = self.session.client(self.service, self.region) self._list_accelerators() self.__threading_call__(self._list_tags, self.accelerators.values()) diff --git a/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py index 50c92f8619..fec480efb1 100644 --- a/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py +++ b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py @@ -1,52 +1,83 @@ -import json - -from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan -from prowler.providers.aws.services.glue.glue_client import glue_client - - -class glue_etl_jobs_no_secrets_in_arguments(Check): - """Check if Glue ETL jobs have secrets in their default arguments. - - Scans the DefaultArguments of each Glue job for hardcoded credentials, - tokens, passwords, and other sensitive values that should be stored in - Secrets Manager or Parameter Store instead. - """ - - def execute(self): - findings = [] - secrets_ignore_patterns = glue_client.audit_config.get( - "secrets_ignore_patterns", [] - ) - for job in glue_client.jobs: - report = Check_Report_AWS(metadata=self.metadata(), resource=job) - report.status = "PASS" - report.status_extended = ( - f"No secrets found in Glue job {job.name} default arguments." - ) - - if job.arguments: - secrets_found = [] - for arg_name, arg_value in job.arguments.items(): - detect_secrets_output = detect_secrets_scan( - data=json.dumps({arg_name: arg_value}), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=glue_client.audit_config.get( - "detect_secrets_plugins", - ), - ) - if detect_secrets_output: - secrets_found.extend( - [ - f"{secret['type']} in argument {arg_name}" - for secret in detect_secrets_output - ] - ) - - if secrets_found: - report.status = "FAIL" - report.status_extended = f"Potential secrets found in Glue job {job.name} default arguments: {', '.join(secrets_found)}." - - findings.append(report) - - return findings +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.glue.glue_client import glue_client + + +class glue_etl_jobs_no_secrets_in_arguments(Check): + """Check if Glue ETL jobs have secrets in their default arguments. + + Scans the DefaultArguments of each Glue job for hardcoded credentials, + tokens, passwords, and other sensitive values that should be stored in + Secrets Manager or Parameter Store instead. + """ + + def execute(self): + findings = [] + secrets_ignore_patterns = glue_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = glue_client.audit_config.get("secrets_validate", False) + jobs = list(glue_client.jobs) + + # Collect every default argument across all jobs and scan them in batched + # Kingfisher invocations instead of one subprocess per argument. Findings + # are keyed by (job index, argument name). + def payloads(): + for job_index, job in enumerate(jobs): + if job.arguments: + for arg_name, arg_value in job.arguments.items(): + yield (job_index, arg_name), json.dumps({arg_name: arg_value}) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for job_index, job in enumerate(jobs): + report = Check_Report_AWS(metadata=self.metadata(), resource=job) + report.status = "PASS" + report.status_extended = ( + f"No secrets found in Glue job {job.name} default arguments." + ) + + if job.arguments and scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Glue job {job.name} default arguments for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + + if job.arguments: + secrets_found = [] + all_secrets = [] + for arg_name in job.arguments: + detect_secrets_output = batch_results.get((job_index, arg_name)) + if detect_secrets_output: + all_secrets.extend(detect_secrets_output) + secrets_found.extend( + [ + f"{secret['type']} in argument {arg_name}" + for secret in detect_secrets_output + ] + ) + + if secrets_found: + report.status = "FAIL" + report.status_extended = f"Potential secrets found in Glue job {job.name} default arguments: {', '.join(secrets_found)}." + annotate_verified_secrets(report, all_secrets) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/__init__.py b/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe.metadata.json b/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe.metadata.json new file mode 100644 index 0000000000..692390afce --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "iam_inline_policy_no_wildcard_marketplace_subscribe", + "CheckTitle": "Inline IAM policy does not allow 'aws-marketplace:Subscribe' on all resources", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsIamPolicy", + "ResourceGroup": "IAM", + "Description": "**IAM inline policies** are analyzed to identify statements that grant `aws-marketplace:Subscribe` on all resources (`*`). This action controls the ability to subscribe to AWS Marketplace products, including **Amazon Bedrock foundation models**, and should be scoped to specific product ARNs to enforce least privilege.", + "Risk": "Granting `aws-marketplace:Subscribe` on all resources via inline policies allows subscribing to any Marketplace product, including expensive Bedrock foundation models, leading to uncontrolled costs, shadow AI usage, and compliance violations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "```yaml\nResources:\n ExampleRole:\n Type: AWS::IAM::Role\n Properties:\n Policies:\n - PolicyName: scoped-marketplace-subscribe\n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action:\n - aws-marketplace:Subscribe # FIX: scope to specific product ARNs\n Resource:\n - arn:aws:aws-marketplace::123456789012:product/approved-product-id\n```", + "Other": "1. In the AWS Console, open IAM and go to Users, Roles, or Groups where the inline policy is attached\n2. Select the entity, go to the Permissions tab, and open the inline policy\n3. Click Edit policy and switch to the JSON editor\n4. Replace \"Resource\": \"*\" with specific, approved AWS Marketplace product ARNs\n5. Save changes and re-run the check to confirm it passes", + "Terraform": "```hcl\nresource \"aws_iam_role_policy\" \"scoped_marketplace_subscribe\" {\n name = \"scoped-marketplace-subscribe\"\n role = aws_iam_role.example.id\n\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [\"aws-marketplace:Subscribe\"]\n Resource = [\"arn:aws:aws-marketplace::123456789012:product/approved-product-id\"] # FIX: scope to specific products\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Replace `Resource: \"*\"` with specific, approved AWS Marketplace product ARNs. Prefer managed policies over inline and apply the principle of least privilege to `aws-marketplace:Subscribe` permissions to prevent unauthorized subscriptions to costly Bedrock models and other Marketplace products.", + "Url": "https://hub.prowler.com/check/iam_inline_policy_no_wildcard_marketplace_subscribe" + } + }, + "Categories": [ + "gen-ai", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "iam_policy_no_wildcard_marketplace_subscribe" + ], + "Notes": "This check only evaluates IAM inline policies. See iam_policy_no_wildcard_marketplace_subscribe for the customer-managed policy variant." +} diff --git a/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe.py b/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe.py new file mode 100644 index 0000000000..38abdf3984 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import ( + policy_allows_marketplace_subscribe_on_all_resources, +) + + +class iam_inline_policy_no_wildcard_marketplace_subscribe(Check): + def execute(self) -> list[Check_Report_AWS]: + findings = [] + for policy in iam_client.policies.values(): + if policy.type == "Inline": + report = Check_Report_AWS(metadata=self.metadata(), resource=policy) + report.region = iam_client.region + report.resource_id = f"{policy.entity}/{policy.name}" + report.status = "PASS" + + resource_type_str = report.resource_arn.split(":")[-1].split("/")[0] + resource_attached = report.resource_arn.split("/")[-1] + + report.status_extended = f"Inline policy {policy.name}{' attached to ' + resource_type_str + ' ' + resource_attached if policy.attached else ''} does not allow 'aws-marketplace:Subscribe' on all resources." + + if ( + policy.document + and policy_allows_marketplace_subscribe_on_all_resources( + policy.document + ) + ): + report.status = "FAIL" + report.status_extended = f"Inline policy {policy.name}{' attached to ' + resource_type_str + ' ' + resource_attached if policy.attached else ''} allows 'aws-marketplace:Subscribe' on all resources." + + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption.py b/prowler/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption.py index 6e310d7ca9..fa0058f401 100644 --- a/prowler/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption.py +++ b/prowler/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption.py @@ -16,6 +16,8 @@ class iam_no_custom_policy_permissive_role_assumption(Check): for policy in iam_client.policies.values(): # Check only custom policies if policy.type == "Custom": + if not policy.attached and not iam_client.provider.scan_unused_services: + continue report = Check_Report_AWS(metadata=self.metadata(), resource=policy) report.region = iam_client.region report.status = "PASS" diff --git a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json index 79b02882d7..4c7962242e 100644 --- a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json +++ b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json @@ -37,7 +37,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "privilege-escalation" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.py b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.py index c867292b22..7406ec8332 100644 --- a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.py +++ b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.py @@ -11,6 +11,8 @@ class iam_policy_allows_privilege_escalation(Check): for policy in iam_client.policies.values(): if policy.type == "Custom": + if not policy.attached and not iam_client.provider.scan_unused_services: + continue report = Check_Report_AWS(metadata=self.metadata(), resource=policy) report.region = iam_client.region report.status = "PASS" diff --git a/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail.py b/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail.py index 4887bbcf6b..844be7f87a 100644 --- a/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail.py +++ b/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail.py @@ -11,6 +11,8 @@ class iam_policy_no_full_access_to_cloudtrail(Check): for policy in iam_client.policies.values(): # Check only custom policies if policy.type == "Custom": + if not policy.attached and not iam_client.provider.scan_unused_services: + continue report = Check_Report_AWS(metadata=self.metadata(), resource=policy) report.region = iam_client.region report.status = "PASS" diff --git a/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py b/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py index adad5d0d1d..e4bf1151da 100644 --- a/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py +++ b/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py @@ -11,6 +11,8 @@ class iam_policy_no_full_access_to_kms(Check): for policy in iam_client.policies.values(): # Check only custom policies if policy.type == "Custom": + if not policy.attached and not iam_client.provider.scan_unused_services: + continue report = Check_Report_AWS(metadata=self.metadata(), resource=policy) report.region = iam_client.region report.status = "PASS" diff --git a/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/__init__.py b/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe.metadata.json b/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe.metadata.json new file mode 100644 index 0000000000..22c7a44cfe --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "iam_policy_no_wildcard_marketplace_subscribe", + "CheckTitle": "Custom IAM policy does not allow 'aws-marketplace:Subscribe' on all resources", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsIamPolicy", + "ResourceGroup": "IAM", + "Description": "**Customer-managed IAM policies** are examined for statements that grant `aws-marketplace:Subscribe` on all resources (`*`). This action controls the ability to subscribe to AWS Marketplace products, including **Amazon Bedrock foundation models**, and should be scoped to specific product ARNs to enforce least privilege.", + "Risk": "Granting `aws-marketplace:Subscribe` on all resources allows subscribing to any Marketplace product, including expensive Bedrock foundation models, leading to uncontrolled costs, shadow AI usage, and compliance violations from unapproved deployments.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "aws iam create-policy-version --policy-arn --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"aws-marketplace:Subscribe\"],\"Resource\":\"arn:aws:aws-marketplace:::product/\"}]}' --set-as-default", + "NativeIaC": "```yaml\nResources:\n ScopedMarketplacePolicy:\n Type: AWS::IAM::ManagedPolicy\n Properties:\n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action:\n - aws-marketplace:Subscribe # FIX: scope to specific product ARNs\n Resource:\n - arn:aws:aws-marketplace::123456789012:product/approved-product-id\n```", + "Other": "1. In the AWS Console, open IAM > Policies\n2. Find the custom policy that allows aws-marketplace:Subscribe on Resource: *\n3. Click Edit and switch to the JSON editor\n4. Replace \"Resource\": \"*\" with specific, approved AWS Marketplace product ARNs\n5. Save changes and re-run the check to confirm it passes", + "Terraform": "```hcl\nresource \"aws_iam_policy\" \"scoped_marketplace_subscribe\" {\n name = \"scoped-marketplace-subscribe\"\n\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [\"aws-marketplace:Subscribe\"]\n Resource = [\"arn:aws:aws-marketplace::123456789012:product/approved-product-id\"] # FIX: scope to specific products\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Replace `Resource: \"*\"` with specific, approved AWS Marketplace product ARNs. Apply the principle of least privilege to `aws-marketplace:Subscribe` permissions to prevent unauthorized subscriptions to costly Bedrock models and other Marketplace products.", + "Url": "https://hub.prowler.com/check/iam_policy_no_wildcard_marketplace_subscribe" + } + }, + "Categories": [ + "gen-ai", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "iam_inline_policy_no_wildcard_marketplace_subscribe" + ], + "Notes": "This check only evaluates customer-managed IAM policies. AWS managed policies are maintained by AWS and cannot be modified. See iam_inline_policy_no_wildcard_marketplace_subscribe for the inline policy variant." +} diff --git a/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe.py b/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe.py new file mode 100644 index 0000000000..a1299f3d70 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import ( + policy_allows_marketplace_subscribe_on_all_resources, +) + + +class iam_policy_no_wildcard_marketplace_subscribe(Check): + def execute(self) -> list[Check_Report_AWS]: + findings = [] + for policy in iam_client.policies.values(): + if policy.type == "Custom": + if not policy.attached and not iam_client.provider.scan_unused_services: + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=policy) + report.region = iam_client.region + report.status = "PASS" + report.status_extended = f"Custom Policy {policy.name} does not allow 'aws-marketplace:Subscribe' on all resources." + + if ( + policy.document + and policy_allows_marketplace_subscribe_on_all_resources( + policy.document + ) + ): + report.status = "FAIL" + report.status_extended = f"Custom Policy {policy.name} allows 'aws-marketplace:Subscribe' on all resources." + + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.metadata.json b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.metadata.json new file mode 100644 index 0000000000..0038a055f4 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "iam_role_access_not_stale_to_bedrock", + "CheckTitle": "Regular Bedrock access ensures IAM roles retain only actively used permissions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:iam::account-id:role/resource-id", + "Severity": "medium", + "ResourceType": "AwsIamRole", + "ResourceGroup": "IAM", + "Description": "IAM roles granted **Bedrock** permissions are evaluated for recent service usage.\n\nRoles whose last Bedrock access exceeds the configured threshold (default **60 days**) or that have **never** accessed Bedrock are flagged, indicating stale permissions that should be reviewed.", + "Risk": "Stale Bedrock permissions widen the **blast radius** of a credential compromise.\n\nAn attacker who assumes a role with unused Bedrock permissions can invoke foundation models, exfiltrate data through model responses, or incur significant costs — all without triggering expected usage patterns.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_access-advisor.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the IAM console and select the role\n2. Review the **Access Advisor** tab to confirm Bedrock has not been accessed recently\n3. Remove or detach any policies granting Bedrock permissions that are no longer needed\n4. If the role still requires Bedrock access, verify usage and reduce scope to least privilege", + "Terraform": "" + }, + "Recommendation": { + "Text": "Apply the **principle of least privilege** by regularly reviewing IAM Access Advisor data and revoking Bedrock permissions that are no longer actively used.\n\nEstablish a periodic access review process and automate alerts for stale permissions to maintain a minimal attack surface.", + "Url": "https://hub.prowler.com/check/iam_role_access_not_stale_to_bedrock" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_api_key_no_administrative_privileges", + "bedrock_api_key_no_long_term_credentials" + ], + "Notes": "The staleness threshold is configurable via the `max_unused_bedrock_access_days` audit config key (default: 60 days)." +} diff --git a/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.py b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.py new file mode 100644 index 0000000000..b29b3de916 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.py @@ -0,0 +1,106 @@ +from datetime import datetime, timezone +from typing import Optional + +from dateutil.parser import parse + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client + + +class iam_role_access_not_stale_to_bedrock(Check): + """Detect IAM roles with stale Bedrock permissions. + + This check evaluates whether IAM roles with Bedrock service permissions + have actively used those permissions within the configured threshold + (default 60 days). + + - PASS: The role has accessed Bedrock within the allowed period. + - FAIL: The role has Bedrock permissions but has not used them within + the allowed period or has never used them. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Bedrock access staleness check for IAM roles. + + Iterates over IAM roles, inspecting service last accessed data for + the ``bedrock`` namespace. Roles whose last Bedrock access exceeds + the configured threshold are reported as non-compliant. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + max_unused_bedrock_days = iam_client.audit_config.get( + "max_unused_bedrock_access_days", 60 + ) + + if iam_client.roles is None: + return findings + + for role in iam_client.roles: + last_accessed_services = iam_client.role_last_accessed_services.get( + (role.name, role.arn), [] + ) + bedrock_service = self._find_bedrock_service(last_accessed_services) + if bedrock_service is None: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=role) + report.region = iam_client.region + + self._evaluate_bedrock_staleness( + report, + bedrock_service, + max_unused_bedrock_days, + role.name, + "Role", + ) + findings.append(report) + + return findings + + @staticmethod + def _find_bedrock_service( + last_accessed_services: list[dict], + ) -> Optional[dict]: + """Return the Bedrock entry from a service last accessed list.""" + for service in last_accessed_services: + if service.get("ServiceNamespace") == "bedrock": + return service + return None + + @staticmethod + def _evaluate_bedrock_staleness( + report: Check_Report_AWS, + bedrock_service: dict, + max_days: int, + identity_name: str, + identity_type: str, + ) -> None: + """Populate a check report based on Bedrock access recency.""" + last_authenticated = bedrock_service.get("LastAuthenticated") + if last_authenticated is None: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has Bedrock permissions " + f"but has never used them." + ) + return + + if isinstance(last_authenticated, str): + last_authenticated = parse(last_authenticated) + + days_since_access = (datetime.now(timezone.utc) - last_authenticated).days + + if days_since_access > max_days: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has not accessed Bedrock " + f"in {days_since_access} days (threshold: {max_days} days)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"IAM {identity_type} {identity_name} accessed Bedrock " + f"{days_since_access} days ago (threshold: {max_days} days)." + ) diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index a8469d3b9d..fa01550b11 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -1,5 +1,6 @@ import csv from datetime import datetime +from time import sleep from typing import Optional from botocore.client import ClientError @@ -92,10 +93,19 @@ class IAM(AWSService): self._get_access_keys_metadata() self.last_accessed_services = {} self._get_last_accessed_services() + self.role_last_accessed_services = {} + if ( + "iam_role_access_not_stale_to_bedrock" + in provider.audit_metadata.expected_checks + ): + self._get_role_last_accessed_services() self.user_temporary_credentials_usage = {} self._get_user_temporary_credentials_usage() self.organization_features = [] self._list_organizations_features() + # ListRoles does not echo PermissionsBoundary; backfill via GetRole. + if self.roles: + self.__threading_call__(self._get_role_permissions_boundary, self.roles) # List missing tags self.__threading_call__(self._list_tags, self.users) self.__threading_call__(self._list_tags, self.roles) @@ -126,6 +136,7 @@ class IAM(AWSService): arn=role["Arn"], assume_role_policy=role["AssumeRolePolicyDocument"], is_service_role=is_service_role(role), + permissions_boundary=role.get("PermissionsBoundary"), ) ) except ClientError as error: @@ -453,6 +464,34 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_role_permissions_boundary(self, role): + """Backfill ``role.permissions_boundary`` via ``GetRole``. + + ``ListRoles`` does not return ``PermissionsBoundary`` in practice, so + the value is fetched per role and stored on the ``Role`` model. + + Args: + role: The ``Role`` instance to enrich. + """ + try: + response = self.client.get_role(RoleName=role.name) + role.permissions_boundary = response.get("Role", {}).get( + "PermissionsBoundary" + ) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_attached_role_policies(self): logger.info("IAM - List Attached Role Policies...") try: @@ -901,6 +940,74 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_role_last_accessed_services(self): + """Retrieve service last accessed details for all IAM roles. + + Uses a fire-all-then-collect pattern: all generate calls are + submitted first so the jobs run server-side in parallel, then + results are collected in a second pass. + """ + logger.info("IAM - Getting Role Last Accessed Services ...") + try: + if self.roles is None: + return + + # Phase 1: fire all generate requests + pending_jobs = [] + for role in self.roles: + try: + details = self.client.generate_service_last_accessed_details( + Arn=role.arn + ) + pending_jobs.append((role.name, role.arn, details["JobId"])) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + # Phase 2: collect results + max_retries = 60 + for role_name, role_arn, job_id in pending_jobs: + try: + retries = 0 + response = self.client.get_service_last_accessed_details( + JobId=job_id + ) + while response["JobStatus"] == "IN_PROGRESS": + retries += 1 + if retries > max_retries: + logger.warning( + f"{self.region} -- Timeout waiting for service last accessed details for role {role_name}" + ) + break + sleep(1) + response = self.client.get_service_last_accessed_details( + JobId=job_id + ) + if response["JobStatus"] == "COMPLETED": + self.role_last_accessed_services[(role_name, role_arn)] = ( + response.get("ServicesLastAccessed", []) + ) + + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_access_keys_metadata(self): logger.info("IAM - Getting Access Keys Metadata ...") try: @@ -1064,6 +1171,7 @@ class Role(BaseModel): is_service_role: bool attached_policies: list[dict] = [] inline_policies: list[str] = [] + permissions_boundary: Optional[dict] = None tags: Optional[list] diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.metadata.json b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.metadata.json new file mode 100644 index 0000000000..04547bfbc5 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "iam_user_access_not_stale_to_bedrock", + "CheckTitle": "Regular Bedrock access ensures IAM users retain only actively used permissions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:iam::account-id:user/resource-id", + "Severity": "medium", + "ResourceType": "AwsIamUser", + "ResourceGroup": "IAM", + "Description": "IAM users granted **Bedrock** permissions are evaluated for recent service usage.\n\nUsers whose last Bedrock access exceeds the configured threshold (default **60 days**) or that have **never** accessed Bedrock are flagged, indicating stale permissions that should be reviewed.", + "Risk": "Stale Bedrock permissions widen the **blast radius** of a credential compromise.\n\nAn attacker who gains access to a user with unused Bedrock permissions can invoke foundation models, exfiltrate data through model responses, or incur significant costs — all without triggering expected usage patterns.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_access-advisor.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the IAM console and select the user\n2. Review the **Access Advisor** tab to confirm Bedrock has not been accessed recently\n3. Remove or detach any policies granting Bedrock permissions that are no longer needed\n4. If the user still requires Bedrock access, verify usage and reduce scope to least privilege", + "Terraform": "" + }, + "Recommendation": { + "Text": "Apply the **principle of least privilege** by regularly reviewing IAM Access Advisor data and revoking Bedrock permissions that are no longer actively used.\n\nEstablish a periodic access review process and automate alerts for stale permissions to maintain a minimal attack surface.", + "Url": "https://hub.prowler.com/check/iam_user_access_not_stale_to_bedrock" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_api_key_no_administrative_privileges", + "bedrock_api_key_no_long_term_credentials" + ], + "Notes": "The staleness threshold is configurable via the `max_unused_bedrock_access_days` audit config key (default: 60 days)." +} diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.py b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.py new file mode 100644 index 0000000000..582d5ebb06 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.py @@ -0,0 +1,103 @@ +from datetime import datetime, timezone +from typing import Optional + +from dateutil.parser import parse + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client + + +class iam_user_access_not_stale_to_bedrock(Check): + """Detect IAM users with stale Bedrock permissions. + + This check evaluates whether IAM users with Bedrock service permissions + have actively used those permissions within the configured threshold + (default 60 days). + + - PASS: The user has accessed Bedrock within the allowed period. + - FAIL: The user has Bedrock permissions but has not used them within + the allowed period or has never used them. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Bedrock access staleness check for IAM users. + + Iterates over IAM users, inspecting service last accessed data for + the ``bedrock`` namespace. Users whose last Bedrock access exceeds + the configured threshold are reported as non-compliant. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + max_unused_bedrock_days = iam_client.audit_config.get( + "max_unused_bedrock_access_days", 60 + ) + + for user in iam_client.users: + last_accessed_services = iam_client.last_accessed_services.get( + (user.name, user.arn), [] + ) + bedrock_service = self._find_bedrock_service(last_accessed_services) + if bedrock_service is None: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=user) + report.region = iam_client.region + + self._evaluate_bedrock_staleness( + report, + bedrock_service, + max_unused_bedrock_days, + user.name, + "User", + ) + findings.append(report) + + return findings + + @staticmethod + def _find_bedrock_service( + last_accessed_services: list[dict], + ) -> Optional[dict]: + """Return the Bedrock entry from a service last accessed list.""" + for service in last_accessed_services: + if service.get("ServiceNamespace") == "bedrock": + return service + return None + + @staticmethod + def _evaluate_bedrock_staleness( + report: Check_Report_AWS, + bedrock_service: dict, + max_days: int, + identity_name: str, + identity_type: str, + ) -> None: + """Populate a check report based on Bedrock access recency.""" + last_authenticated = bedrock_service.get("LastAuthenticated") + if last_authenticated is None: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has Bedrock permissions " + f"but has never used them." + ) + return + + if isinstance(last_authenticated, str): + last_authenticated = parse(last_authenticated) + + days_since_access = (datetime.now(timezone.utc) - last_authenticated).days + + if days_since_access > max_days: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has not accessed Bedrock " + f"in {days_since_access} days (threshold: {max_days} days)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"IAM {identity_type} {identity_name} accessed Bedrock " + f"{days_since_access} days ago (threshold: {max_days} days)." + ) diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/__init__.py b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker.metadata.json b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker.metadata.json new file mode 100644 index 0000000000..75dad3dd70 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "iam_user_access_not_stale_to_sagemaker", + "CheckTitle": "Regular SageMaker access ensures IAM users retain only actively used permissions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsIamUser", + "ResourceGroup": "IAM", + "Description": "IAM users granted **SageMaker** permissions are evaluated for recent service usage.\n\nUsers whose last SageMaker access exceeds the configured threshold (default **90 days**) or that have **never** accessed SageMaker are flagged, indicating stale permissions that should be reviewed.", + "Risk": "Stale SageMaker permissions widen the **blast radius** of a credential compromise.\n\nAn attacker who gains access to a user with unused SageMaker permissions can access ML training data, models, endpoints, and notebooks — all without triggering expected usage patterns. Removing or scoping down stale permissions enforces least privilege and limits blast radius.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_access-advisor.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the IAM console and select the user\n2. Review the **Access Advisor** tab to confirm SageMaker has not been accessed recently\n3. Remove or detach any policies granting SageMaker permissions that are no longer needed\n4. If the user still requires SageMaker access, verify usage and reduce scope to least privilege", + "Terraform": "" + }, + "Recommendation": { + "Text": "Apply the **principle of least privilege** by regularly reviewing IAM Access Advisor data and revoking SageMaker permissions that are no longer actively used.\n\nEstablish a periodic access review process and automate alerts for stale permissions to maintain a minimal attack surface.", + "Url": "https://hub.prowler.com/check/iam_user_access_not_stale_to_sagemaker" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "iam_user_access_not_stale_to_bedrock" + ], + "Notes": "The staleness threshold is configurable via the `max_unused_sagemaker_access_days` audit config key (default: 90 days)." +} diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker.py b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker.py new file mode 100644 index 0000000000..089d08a317 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker.py @@ -0,0 +1,103 @@ +from datetime import datetime, timezone +from typing import Optional + +from dateutil.parser import parse + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client + + +class iam_user_access_not_stale_to_sagemaker(Check): + """Detect IAM users with stale SageMaker permissions. + + This check evaluates whether IAM users with SageMaker service permissions + have actively used those permissions within the configured threshold + (default 90 days). + + - PASS: The user has accessed SageMaker within the allowed period. + - FAIL: The user has SageMaker permissions but has not used them within + the allowed period or has never used them. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the SageMaker access staleness check for IAM users. + + Iterates over IAM users, inspecting service last accessed data for + the ``sagemaker`` namespace. Users whose last SageMaker access exceeds + the configured threshold are reported as non-compliant. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + max_unused_sagemaker_days = iam_client.audit_config.get( + "max_unused_sagemaker_access_days", 90 + ) + + for user in iam_client.users: + last_accessed_services = iam_client.last_accessed_services.get( + (user.name, user.arn), [] + ) + sagemaker_service = self._find_sagemaker_service(last_accessed_services) + if sagemaker_service is None: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=user) + report.region = iam_client.region + + self._evaluate_sagemaker_staleness( + report, + sagemaker_service, + max_unused_sagemaker_days, + user.name, + "User", + ) + findings.append(report) + + return findings + + @staticmethod + def _find_sagemaker_service( + last_accessed_services: list[dict], + ) -> Optional[dict]: + """Return the SageMaker entry from a service last accessed list.""" + for service in last_accessed_services: + if service.get("ServiceNamespace") == "sagemaker": + return service + return None + + @staticmethod + def _evaluate_sagemaker_staleness( + report: Check_Report_AWS, + sagemaker_service: dict, + max_days: int, + identity_name: str, + identity_type: str, + ) -> None: + """Populate a check report based on SageMaker access recency.""" + last_authenticated = sagemaker_service.get("LastAuthenticated") + if last_authenticated is None: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has SageMaker permissions " + f"but has never used them." + ) + return + + if isinstance(last_authenticated, str): + last_authenticated = parse(last_authenticated) + + days_since_access = (datetime.now(timezone.utc) - last_authenticated).days + + if days_since_access > max_days: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has not accessed SageMaker " + f"in {days_since_access} days (threshold: {max_days} days)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"IAM {identity_type} {identity_name} accessed SageMaker " + f"{days_since_access} days ago (threshold: {max_days} days)." + ) diff --git a/prowler/providers/aws/services/iam/lib/policy.py b/prowler/providers/aws/services/iam/lib/policy.py index d8806f280b..333fbad993 100644 --- a/prowler/providers/aws/services/iam/lib/policy.py +++ b/prowler/providers/aws/services/iam/lib/policy.py @@ -617,6 +617,11 @@ def is_condition_block_restrictive( "aws:sourceorgpaths", "aws:userid", "aws:username", + "aws:calledvia", + "aws:calledviafirst", + "aws:calledvialast", + "kms:calleraccount", + "kms:viaservice", "s3:resourceaccount", "lambda:eventsourcetoken", # For Alexa Home functions, a token that the invoker must supply. ], @@ -635,6 +640,11 @@ def is_condition_block_restrictive( "aws:sourceorgpaths", "aws:userid", "aws:username", + "aws:calledvia", + "aws:calledviafirst", + "aws:calledvialast", + "kms:calleraccount", + "kms:viaservice", "s3:resourceaccount", "lambda:eventsourcetoken", ], @@ -984,6 +994,93 @@ def is_codebuild_using_allowed_github_org( return False, None +def policy_allows_marketplace_subscribe_on_all_resources( + policy_document: dict, +) -> bool: + """Check if a policy document can allow aws-marketplace:Subscribe on Resource:*. + + Inspects statements with Resource ``*`` for Allow effects that grant + ``aws-marketplace:Subscribe`` via ``Action`` or ``NotAction`` (wildcard + patterns expanded through ``expand_actions``). Unconditional Deny + statements on Resource ``*`` (via either ``Action`` or ``NotAction``) + covering the same action take precedence. Conditional Deny statements + are not treated as global cancellation because the condition scope is + request-dependent and is not evaluated here. Conditional Allow + statements are still treated as potentially allowing access on + ``Resource:*``, since the wildcard scope remains risky even when + gated by a condition. + + Args: + policy_document: The IAM policy document to analyse. + + Returns: + True if the policy can allow aws-marketplace:Subscribe on all + resources, False otherwise. + """ + if not policy_document or "Statement" not in policy_document: + return False + + target_actions = set( + expand_actions( + "aws-marketplace:Subscribe", + InvalidActionHandling.REMOVE, + ) + ) + if not target_actions: + target_actions = {"aws-marketplace:Subscribe"} + + statements = policy_document.get("Statement", []) + if not isinstance(statements, list): + statements = [statements] + + allowed_on_all = set() + denied_on_all = set() + all_aws_actions = None + + for statement in statements: + effect = statement.get("Effect", "") + if not isinstance(effect, str): + continue + effect_lower = effect.strip().lower() + if effect_lower not in ("allow", "deny"): + continue + + resources = statement.get("Resource", []) + if isinstance(resources, str): + resources = [resources] + if "*" not in resources: + continue + + if effect_lower == "deny" and "Condition" in statement: + continue + + statement_actions = set() + action_patterns = _get_patterns_from_standard_value(statement.get("Action")) + for pattern in action_patterns: + statement_actions.update( + expand_actions(pattern, InvalidActionHandling.REMOVE) + ) + + not_action_patterns = _get_patterns_from_standard_value( + statement.get("NotAction") + ) + if not_action_patterns: + if all_aws_actions is None: + all_aws_actions = set(expand_actions("*", InvalidActionHandling.REMOVE)) + exclusions = set() + for pattern in not_action_patterns: + exclusions.update(expand_actions(pattern, InvalidActionHandling.REMOVE)) + statement_actions.update(all_aws_actions.difference(exclusions)) + + if effect_lower == "allow": + allowed_on_all.update(statement_actions) + else: + denied_on_all.update(statement_actions) + + effective = allowed_on_all.difference(denied_on_all) + return bool(target_actions & effective) + + def has_codebuild_trusted_principal(trust_policy: dict) -> bool: """ Returns True if the trust policy allows codebuild.amazonaws.com as a trusted principal, otherwise False. diff --git a/prowler/providers/aws/services/rolesanywhere/__init__.py b/prowler/providers/aws/services/rolesanywhere/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py new file mode 100644 index 0000000000..254f0a5dbf --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py @@ -0,0 +1,6 @@ +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + RolesAnywhere, +) +from prowler.providers.common.provider import Provider + +rolesanywhere_client = RolesAnywhere(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py new file mode 100644 index 0000000000..46dca281d6 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py @@ -0,0 +1,64 @@ +from typing import Dict, List + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.lib.service.service import AWSService + + +class RolesAnywhere(AWSService): + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.trust_anchors = {} + self.__threading_call__(self._list_trust_anchors) + + def _list_trust_anchors(self, regional_client): + logger.info("RolesAnywhere - Listing Trust Anchors...") + try: + paginator = regional_client.get_paginator("list_trust_anchors") + for page in paginator.paginate(): + for ta in page.get("trustAnchors", []): + arn = ta.get("trustAnchorArn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + source = ta.get("source", {}) or {} + source_data = source.get("sourceData", {}) or {} + tags = [] + try: + tags = regional_client.list_tags_for_resource( + resourceArn=arn + ).get("tags", []) + except Exception as error: + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.trust_anchors[arn] = TrustAnchor( + arn=arn, + id=ta.get("trustAnchorId", ""), + name=ta.get("name", ""), + region=regional_client.region, + enabled=ta.get("enabled", False), + source_type=source.get("sourceType", ""), + acm_pca_arn=source_data.get("acmPcaArn", ""), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class TrustAnchor(BaseModel): + arn: str + id: str + name: str + region: str + enabled: bool = False + source_type: str = "" + acm_pca_arn: str = "" + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/__init__.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json new file mode 100644 index 0000000000..7d919f83a7 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "rolesanywhere_trust_anchor_pqc_pki", + "CheckTitle": "IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "rolesanywhere", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsRolesAnywhereTrustAnchor", + "ResourceGroup": "security", + "Description": "**IAM Roles Anywhere trust anchors** are assessed for use of a **post-quantum digital signature algorithm** (ML-DSA). A trust anchor backed by an AWS Private CA whose `KeyAlgorithm` is RSA or ECC produces signatures vulnerable to forgery by a future quantum attacker, allowing an unintended actor to issue certificates and obtain unauthorized AWS access.", + "Risk": "Trust anchors are the root of trust for workloads authenticating to AWS via X.509 certificates. If the signing CA uses RSA or ECC, an attacker with quantum capability could forge end-entity certificates and impersonate workloads. Migrating trust anchors to **ML-DSA-backed PKI** (NIST FIPS 204) protects this control plane in the post-quantum era.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html", + "https://aws.amazon.com/about-aws/whats-new/2026/03/iam-roles-anywhere-post-quantum-digital-certificates/", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/pubs/fips/204/final" + ], + "Remediation": { + "Code": { + "CLI": "aws rolesanywhere create-trust-anchor --name pqc-trust --source 'sourceType=AWS_ACM_PCA,sourceData={acmPcaArn=}' --enabled", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::RolesAnywhere::TrustAnchor\n Properties:\n Name: pqc-trust\n Enabled: true\n Source:\n SourceType: AWS_ACM_PCA\n SourceData:\n AcmPcaArn: # FIX: PCA must use ML_DSA key algorithm\n```", + "Other": "1. Create a new AWS Private CA with a post-quantum KeyAlgorithm (ML_DSA_44/65/87)\n2. Create a new Roles Anywhere trust anchor with sourceType=AWS_ACM_PCA pointing to the new CA\n3. Rotate end-entity certificates issued from the new CA\n4. Delete the legacy trust anchor once workloads have rotated", + "Terraform": "```hcl\nresource \"aws_rolesanywhere_trust_anchor\" \"\" {\n name = \"pqc-trust\"\n enabled = true\n source {\n source_type = \"AWS_ACM_PCA\"\n source_data {\n acm_pca_arn = \"\" # FIX: PCA must use ML_DSA key algorithm\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Back IAM Roles Anywhere trust anchors with an **AWS Private CA that uses an ML-DSA key algorithm**. For trust anchors backed by an external certificate bundle, ensure the certificates were issued by an ML-DSA CA and re-verify periodically as the cryptographic landscape evolves.", + "Url": "https://hub.prowler.com/check/rolesanywhere_trust_anchor_pqc_pki" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [ + "acmpca_certificate_authority_pqc_key_algorithm" + ], + "RelatedTo": [], + "Notes": "Trust anchors backed by an external CERTIFICATE_BUNDLE cannot be evaluated automatically by this check and are reported as FAIL with guidance to migrate to an AWS Private CA using an ML-DSA key algorithm." +} diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py new file mode 100644 index 0000000000..495ed4e9c7 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py @@ -0,0 +1,79 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import ( + rolesanywhere_client, +) + +PQC_PCA_KEY_ALGORITHMS_DEFAULT = [ + "ML_DSA_44", + "ML_DSA_65", + "ML_DSA_87", +] + + +class rolesanywhere_trust_anchor_pqc_pki(Check): + """Verify that IAM Roles Anywhere trust anchors are backed by a post-quantum PKI. + + For trust anchors whose source is ``AWS_ACM_PCA``, the linked Private CA's + ``KeyAlgorithm`` is checked against the configured ML-DSA allowlist. + Trust anchors backed by an external ``CERTIFICATE_BUNDLE`` are reported as + FAIL because their certificate signature algorithm cannot be inspected + from the IAM Roles Anywhere API alone. + """ + + def execute(self) -> list[Check_Report_AWS]: + findings = [] + pqc_algorithms = rolesanywhere_client.audit_config.get( + "rolesanywhere_pqc_pca_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT + ) + for trust_anchor in rolesanywhere_client.trust_anchors.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=trust_anchor) + if trust_anchor.source_type == "AWS_ACM_PCA": + linked_ca = acmpca_client.certificate_authorities.get( + trust_anchor.acm_pca_arn + ) + if linked_ca and linked_ca.status != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id}, which is in " + f"{linked_ca.status or ''} status and cannot be " + "used as an active post-quantum PKI trust root." + ) + elif linked_ca and linked_ca.key_algorithm in pqc_algorithms: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id} using post-quantum " + f"key algorithm {linked_ca.key_algorithm}." + ) + elif linked_ca: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id} using key algorithm " + f"{linked_ca.key_algorithm or ''}, which is not " + "post-quantum (ML-DSA)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {trust_anchor.acm_pca_arn}, which " + "could not be inspected (cross-account or missing " + "acm-pca permissions). Verify the CA uses an ML-DSA key " + "algorithm." + ) + else: + source = trust_anchor.source_type or "" + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} uses " + f"source type {source}; the certificate signature algorithm " + "cannot be inspected automatically. Migrate to an AWS Private " + "CA using an ML-DSA key algorithm to enable post-quantum " + "evaluation." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.metadata.json b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.metadata.json index 5f096173cb..ba8d080169 100644 --- a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.metadata.json +++ b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "route53_dangling_ip_subdomain_takeover", - "CheckTitle": "Route53 A record does not point to a dangling IP address", + "CheckTitle": "Route53 record does not point to a dangling AWS resource", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", "TTPs/Initial Access", @@ -13,13 +13,14 @@ "Severity": "high", "ResourceType": "AwsRoute53HostedZone", "ResourceGroup": "network", - "Description": "**Route 53 `A` records** (non-alias) that use literal IPs are evaluated for **public AWS addresses** not currently assigned to resources in the account. Entries that match AWS ranges yet lack ownership are identified as potential **dangling IP targets**.", - "Risk": "**Dangling DNS `A` records** pointing to released AWS IPs enable **subdomain takeover**. An attacker who later obtains that IP can:\n- Redirect or alter content (integrity)\n- Capture credentials/cookies (confidentiality)\n- Disrupt or impersonate services (availability)", + "Description": "**Route 53 records** are evaluated for two **subdomain takeover** vectors: (1) non-alias **`A` records** using literal IPs in **public AWS ranges** that are not assigned to resources in the account (released EIPs/ENI public IPs); and (2) non-alias **`CNAME` records** targeting an **S3 website endpoint** (`*.s3-website[.-].amazonaws.com`) whose bucket no longer exists in the account.", + "Risk": "**Dangling DNS records** pointing to released AWS resources enable **subdomain takeover**. An attacker who later claims the IP — or registers an S3 bucket with the same name in any AWS account — can:\n- Redirect or alter content (integrity)\n- Capture credentials/cookies (confidentiality)\n- Disrupt or impersonate services (availability)", "RelatedUrl": "", "AdditionalURLs": [ "https://support.icompaas.com/support/solutions/articles/62000233461-ensure-route53-records-contains-dangling-ips-", "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/Route53/dangling-dns-records.html", - "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-deleting.html" + "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-deleting.html", + "https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteEndpoints.html" ], "Remediation": { "Code": { @@ -29,7 +30,7 @@ "Terraform": "```hcl\n# Terraform: convert A record to Alias to avoid dangling public IPs\nresource \"aws_route53_record\" \"\" {\n zone_id = \"\"\n name = \"\"\n type = \"A\"\n\n alias { # CRITICAL: Alias to AWS resource (no direct IP)\n name = \"\" # e.g., dualstack..amazonaws.com\n zone_id = \"\"\n evaluate_target_health = false\n }\n}\n```" }, "Recommendation": { - "Text": "Remove or update any record that points to an unassigned IP. Avoid hard-coding AWS public IPs in `A` records; use **aliases/CNAMEs** to managed endpoints. Enforce **asset lifecycle** decommissioning, routine DNS-asset reconciliation, and **change control** with monitoring to prevent and detect drift.", + "Text": "Remove or update any record that points to an unowned AWS resource: unassigned public IPs in `A` records and S3 website endpoints in `CNAME` records whose bucket has been deleted. Avoid hard-coding AWS public IPs in `A` records; prefer **aliases** to managed endpoints (ALB, CloudFront, S3) and delete CNAMEs as soon as the backing bucket is removed. Enforce **asset lifecycle** decommissioning, routine DNS-asset reconciliation, and **change control** with monitoring to prevent and detect drift.", "Url": "https://hub.prowler.com/check/route53_dangling_ip_subdomain_takeover" } }, diff --git a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py index 444f7dd2a2..25703b979f 100644 --- a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py +++ b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py @@ -1,17 +1,29 @@ +import re from ipaddress import ip_address -import awsipranges - from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.utils.utils import validate_ip_address +from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks from prowler.providers.aws.services.ec2.ec2_client import ec2_client from prowler.providers.aws.services.route53.route53_client import route53_client +from prowler.providers.aws.services.s3.s3_client import s3_client + +# S3 website endpoint formats: +# .s3-website-.amazonaws.com (legacy, dash) +# .s3-website..amazonaws.com (newer, dot) +S3_WEBSITE_ENDPOINT_REGEX = re.compile( + r"^(?P[^.]+(?:\.[^.]+)*)\.s3-website[.-](?P[a-z0-9-]+)\.amazonaws\.com\.?$" +) class route53_dangling_ip_subdomain_takeover(Check): def execute(self) -> Check_Report_AWS: findings = [] + # AWS public IP prefixes are fetched lazily, at most once per run, only + # when a dangling-candidate public IP is found. + aws_ip_networks = None + # When --region is used, Route53 service gathers EIPs from all regions # to avoid false positives. Otherwise, use ec2_client data directly. if route53_client.all_account_elastic_ips: @@ -24,37 +36,59 @@ class route53_dangling_ip_subdomain_takeover(Check): if ni.association and ni.association.get("PublicIp"): public_ips.append(ni.association.get("PublicIp")) + owned_bucket_names = {bucket.name for bucket in s3_client.buckets.values()} + for record_set in route53_client.record_sets: - # Check only A records and avoid aliases (only need to check IPs not AWS Resources) + hosted_zone = route53_client.hosted_zones[record_set.hosted_zone_id] + + # A records: dangling-IP path (released EIPs / unowned AWS IPs) if record_set.type == "A" and not record_set.is_alias: for record in record_set.records: - # Check if record is an IP Address if validate_ip_address(record): + record_ip = ip_address(record) report = Check_Report_AWS( metadata=self.metadata(), resource=record_set ) report.resource_id = ( f"{record_set.hosted_zone_id}/{record_set.name}/{record}" ) - report.resource_arn = route53_client.hosted_zones[ - record_set.hosted_zone_id - ].arn - report.resource_tags = route53_client.hosted_zones[ - record_set.hosted_zone_id - ].tags + report.resource_arn = hosted_zone.arn + report.resource_tags = hosted_zone.tags report.status = "PASS" - report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {route53_client.hosted_zones[record_set.hosted_zone_id].name} is not a dangling IP." + report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is not a dangling IP." # If Public IP check if it is in the AWS Account - if ( - not ip_address(record).is_private - and record not in public_ips - ): - report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {route53_client.hosted_zones[record_set.hosted_zone_id].name} does not belong to AWS and it is not a dangling IP." + if not record_ip.is_private and record not in public_ips: + report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} does not belong to AWS and it is not a dangling IP." # Check if potential dangling IP is within AWS Ranges - aws_ip_ranges = awsipranges.get_ranges() - if aws_ip_ranges.get(record): + if aws_ip_networks is None: + aws_ip_networks = get_public_ip_networks() + if any(record_ip in network for network in aws_ip_networks): report.status = "FAIL" - report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {route53_client.hosted_zones[record_set.hosted_zone_id].name} is a dangling IP which can lead to a subdomain takeover attack." + report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is a dangling IP which can lead to a subdomain takeover attack." findings.append(report) + # CNAME records: dangling S3 website endpoint + # (deleted bucket whose name can be re-registered by anyone) + elif record_set.type == "CNAME" and not record_set.is_alias: + for record in record_set.records: + match = S3_WEBSITE_ENDPOINT_REGEX.match(record.lower()) + if not match: + continue + bucket_name = match.group("bucket") + report = Check_Report_AWS( + metadata=self.metadata(), resource=record_set + ) + report.resource_id = ( + f"{record_set.hosted_zone_id}/{record_set.name}/{record}" + ) + report.resource_arn = hosted_zone.arn + report.resource_tags = hosted_zone.tags + if bucket_name in owned_bucket_names: + report.status = "PASS" + report.status_extended = f"Route53 CNAME {record_set.name} in Hosted Zone {hosted_zone.name} points to S3 website endpoint of bucket {bucket_name} which exists in the account." + else: + report.status = "FAIL" + report.status_extended = f"Route53 CNAME {record_set.name} in Hosted Zone {hosted_zone.name} points to S3 website endpoint of bucket {bucket_name} which does not exist in the account and can lead to a subdomain takeover attack." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/route53/route53_service.py b/prowler/providers/aws/services/route53/route53_service.py index bb579ca6ee..54de22440d 100644 --- a/prowler/providers/aws/services/route53/route53_service.py +++ b/prowler/providers/aws/services/route53/route53_service.py @@ -95,8 +95,10 @@ class Route53(AWSService): region, so we need to query all enabled regions to avoid false positives. """ logger.info("Route53 - Gathering Elastic IPs from all regions...") - all_regions = self.provider._enabled_regions or set( - self.provider._identity.audited_regions + all_regions = ( + self.provider._enabled_regions + if self.provider._enabled_regions is not None + else set(self.provider._identity.audited_regions) ) for region in all_regions: @@ -174,14 +176,12 @@ class RecordSet(BaseModel): class Route53Domains(AWSService): def __init__(self, provider): - # Call AWSService's __init__ - super().__init__(__class__.__name__, provider) + # Route53Domains is a global service that supports endpoints in multiple AWS Regions + # but you must specify the US East (N. Virginia) Region to create, update, or otherwise work with domains. + region = "us-east-1" if provider.identity.partition == "aws" else None + super().__init__(__class__.__name__, provider, region=region) self.domains = {} if self.audited_partition == "aws": - # Route53Domains is a global service that supports endpoints in multiple AWS Regions - # but you must specify the US East (N. Virginia) Region to create, update, or otherwise work with domains. - self.region = "us-east-1" - self.client = self.session.client(self.service, self.region) self._list_domains() self._get_domain_detail() self._list_tags_for_domain() diff --git a/prowler/providers/aws/services/s3/s3_bucket_default_encryption/s3_bucket_default_encryption.metadata.json b/prowler/providers/aws/services/s3/s3_bucket_default_encryption/s3_bucket_default_encryption.metadata.json index eb0c07e8af..bda2595347 100644 --- a/prowler/providers/aws/services/s3/s3_bucket_default_encryption/s3_bucket_default_encryption.metadata.json +++ b/prowler/providers/aws/services/s3/s3_bucket_default_encryption/s3_bucket_default_encryption.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "s3_bucket_default_encryption", - "CheckTitle": "S3 bucket has default server-side encryption (SSE) enabled", + "CheckTitle": "[DEPRECATED] S3 bucket has default server-side encryption (SSE) enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", @@ -14,13 +14,11 @@ "Severity": "medium", "ResourceType": "AwsS3Bucket", "ResourceGroup": "storage", - "Description": "**Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.", + "Description": "[DEPRECATED] **Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.", "Risk": "Without default encryption, older objects may remain unencrypted and new uploads won't be forced to use `SSE-KMS`. This reduces confidentiality and governance by limiting key audit logs, rotation, and cross-account controls, and increases exposure if data is copied, replicated, or accessed outside intended paths.", "RelatedUrl": "", "AdditionalURLs": [ - "https://docs.amazonaws.cn/en_us/AmazonS3/latest/userguide/bucket-encryption.html", - "https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/", - "https://docs.aws.amazon.com/us_en/AmazonS3/latest/userguide/default-encryption-faq.html" + "https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html" ], "Remediation": { "Code": { @@ -39,5 +37,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "This check is being deprecated since AWS automatically applies SSE-S3 to every S3 bucket (both new buckets and previously-unencrypted existing buckets) as of January 5, 2023, and encryption can no longer be disabled. For SSE-KMS validation, use `s3_bucket_kms_encryption` instead." } diff --git a/prowler/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability.py b/prowler/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability.py index eb509b1c85..ce14eca587 100644 --- a/prowler/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability.py +++ b/prowler/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability.py @@ -24,30 +24,30 @@ class s3_bucket_shadow_resource_vulnerability(Check): # First, check buckets in the current account for bucket in s3_client.buckets.values(): - report = Check_Report_AWS(self.metadata(), resource=bucket) - report.region = bucket.region - report.resource_id = bucket.name - report.resource_arn = bucket.arn - report.resource_tags = bucket.tags - report.status = "PASS" - report.status_extended = ( - f"S3 bucket {bucket.name} is not a known shadow resource." - ) - - # Check if this bucket matches any predictable pattern + # Only emit a finding when the bucket name actually matches one of + # the predictable service patterns. A bucket whose name does not + # match any pattern is, by definition, not a shadow resource, so a + # PASS finding for it would be tautological and add no signal. for service, pattern_format in predictable_patterns.items(): pattern = pattern_format.replace("", bucket.region) if re.match(pattern, bucket.name): + report = Check_Report_AWS(self.metadata(), resource=bucket) + report.region = bucket.region + report.resource_id = bucket.name + report.resource_arn = bucket.arn + report.resource_tags = bucket.tags + if bucket.owner_id != s3_client.audited_canonical_id: report.status = "FAIL" report.status_extended = f"S3 bucket {bucket.name} for service {service} is a known shadow resource and it is owned by another account ({bucket.owner_id})." else: report.status = "PASS" report.status_extended = f"S3 bucket {bucket.name} for service {service} is a known shadow resource but it is correctly owned by the audited account." + + findings.append(report) + reported_buckets.add(bucket.name) break - findings.append(report) - reported_buckets.add(bucket.name) # Now check for shadow resources in other accounts by testing predictable patterns # We'll test different regions to see if shadow resources exist diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json new file mode 100644 index 0000000000..0e127a7759 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_clarify_exists", + "CheckTitle": "Amazon SageMaker Clarify processing jobs exist in the region", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Clarify** provides bias detection and model explainability for ML workloads.\n\nThis check verifies that at least one SageMaker processing job using the AWS-managed Clarify container image exists in each successfully scanned region. The absence of Clarify jobs indicates that responsible-AI controls such as bias detection and explainability are not in place.", + "Risk": "Without **SageMaker Clarify** processing jobs, ML models may be deployed without bias analysis or explainability reports. This can lead to:\n- **Regulatory non-compliance** with AI governance frameworks\n- **Undetected bias** in model predictions affecting protected groups\n- **Lack of accountability** for ML model decisions in production", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/clarify-configure-processing-jobs.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg-ecr-paths/sagemaker-algo-docker-registry-paths.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker create-processing-job --processing-job-name clarify-bias-check --app-specification ImageUri= --role-arn --processing-resources 'ClusterConfig={InstanceCount=1,InstanceType=ml.m5.xlarge,VolumeSizeInGB=20}'", + "NativeIaC": "", + "Other": "1. Open the AWS Console and go to Amazon SageMaker\n2. Navigate to Processing > Processing jobs\n3. Click Create processing job\n4. Select the SageMaker Clarify container image for your region\n5. Configure input/output paths and the analysis configuration\n6. Click Create processing job", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create SageMaker Clarify processing jobs to evaluate models for bias and explainability before deployment. Integrate Clarify into your ML pipeline to ensure responsible AI practices.", + "Url": "https://hub.prowler.com/check/sagemaker_clarify_exists" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Results are generated per scanned region. Regions where `ListProcessingJobs` cannot be queried are omitted from the findings." +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py new file mode 100644 index 0000000000..9c559613d3 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_clarify_exists(Check): + """Check whether at least one SageMaker Clarify processing job exists per region. + + A region is reported only when ListProcessingJobs succeeded for it; regions + where the API call failed (e.g. AccessDenied, unsupported region) are + skipped at the service layer and produce no finding. + + - PASS: At least one processing job uses the AWS-managed Clarify container + image in the region (one finding per job). + - FAIL: No processing job uses the Clarify container image in the region + (one finding per region). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the SageMaker Clarify exists check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for region in sorted(sagemaker_client.processing_jobs_scanned_regions): + clarify_jobs = sorted( + ( + job + for job in sagemaker_client.sagemaker_processing_jobs + if job.region == region + and job.image_uri + and "sagemaker-clarify-processing" in job.image_uri + ), + key=lambda job: job.name, + ) + + if clarify_jobs: + for job in clarify_jobs: + report = Check_Report_AWS(metadata=self.metadata(), resource=job) + report.status = "PASS" + report.status_extended = f"SageMaker Clarify processing job {job.name} exists in region {region}." + findings.append(report) + else: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = region + report.resource_id = "sagemaker-clarify" + report.resource_arn = f"arn:{sagemaker_client.audited_partition}:sagemaker:{region}:{sagemaker_client.audited_account}:processing-job" + report.status = "FAIL" + report.status_extended = ( + f"No SageMaker Clarify processing jobs found in region {region}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured.metadata.json new file mode 100644 index 0000000000..2c9d52dc11 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_domain_sso_configured", + "CheckTitle": "SageMaker domains use SSO authentication instead of IAM mode", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Domain** configured with **IAM Identity Center (SSO) authentication**. The check validates that each SageMaker Domain uses SSO mode (`AuthMode: SSO`) and is associated with an IAM Identity Center instance (`SingleSignOnManagedApplicationInstanceId` or `SingleSignOnApplicationArn` present), ensuring user access is centrally managed through AWS IAM Identity Center.", + "Risk": "IAM-mode domains create per-user IAM users or roles managed locally to SageMaker, drifting from the organization's identity provider and weakening lifecycle controls such as offboarding, MFA enforcement, and session policies. SSO-mode domains without an IAM Identity Center association leave authentication in an inconsistent state and bypass centralized access governance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/onboard-sso-users.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker describe-domain --domain-id --query '{AuthMode:AuthMode,SingleSignOnManagedApplicationInstanceId:SingleSignOnManagedApplicationInstanceId,SingleSignOnApplicationArn:SingleSignOnApplicationArn}'", + "NativeIaC": "```yaml\n# CloudFormation: Create a SageMaker Domain with SSO authentication\nResources:\n SageMakerDomain:\n Type: AWS::SageMaker::Domain\n Properties:\n DomainName: \n AuthMode: SSO # Critical: enables IAM Identity Center authentication\n DefaultUserSettings:\n ExecutionRole: \n VpcId: \n SubnetIds:\n - \n```", + "Other": "SageMaker Domains cannot be switched from IAM to SSO mode after creation. To remediate, create a new Domain with AuthMode set to SSO and migrate user profiles.", + "Terraform": "```hcl\n# Terraform: Create a SageMaker Domain with SSO authentication\nresource \"aws_sagemaker_domain\" \"example\" {\n domain_name = \"\"\n auth_mode = \"SSO\" # Critical: enables IAM Identity Center authentication\n vpc_id = \"\"\n subnet_ids = [\"\"]\n\n default_user_settings {\n execution_role = \"\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure SageMaker Domains with SSO authentication mode to anchor user access to AWS IAM Identity Center. This enforces centralized identity lifecycle management, MFA policies, and session controls. Domains created with IAM mode must be recreated with SSO mode since the auth mode cannot be changed after creation.", + "Url": "https://hub.prowler.com/check/sagemaker_domain_sso_configured" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured.py b/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured.py new file mode 100644 index 0000000000..54672cfdb7 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_domain_sso_configured(Check): + def execute(self): + findings = [] + for domain in sagemaker_client.sagemaker_domains: + report = Check_Report_AWS(metadata=self.metadata(), resource=domain) + if domain.auth_mode == "SSO": + if ( + domain.single_sign_on_managed_application_instance_id + or domain.single_sign_on_application_arn + ): + report.status = "PASS" + report.status_extended = f"SageMaker domain {domain.name} is configured with SSO authentication and is associated with an IAM Identity Center instance." + else: + report.status = "FAIL" + report.status_extended = f"SageMaker domain {domain.name} is configured with SSO authentication but is not associated with an IAM Identity Center instance." + else: + report.status = "FAIL" + current_mode = domain.auth_mode if domain.auth_mode else "unknown" + report.status_extended = f"SageMaker domain {domain.name} is not configured with SSO authentication, current mode is {current_mode}." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json new file mode 100644 index 0000000000..d1d025fe7e --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_models_monitor_enabled", + "CheckTitle": "Amazon SageMaker has a monitoring schedule scheduled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Models Monitor** detects data drift, model quality issues, and bias drift in production.", + "Risk": "Without an **active monitoring schedule**, data drift, model quality issues, and bias drift go undetected, so **model quality degrades silently** while downstream decisions such as fraud detection, access control, and pricing keep relying on a degrading model.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor-scheduling.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Amazon SageMaker Model Monitor** and keep at least one **monitoring schedule** in the `Scheduled` state so data quality, model quality, and bias drift are continuously evaluated against a baseline.", + "Url": "https://hub.prowler.com/check/sagemaker_models_monitor_enabled" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py new file mode 100644 index 0000000000..f9f893a895 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_models_monitor_enabled(Check): + def execute(self): + findings = [] + for monitoring_schedule in sagemaker_client.sagemaker_monitoring_schedules: + report = Check_Report_AWS( + metadata=self.metadata(), resource=monitoring_schedule + ) + if monitoring_schedule.is_scheduled: + report.status = "PASS" + report.status_extended = f"SageMaker monitoring schedule {monitoring_schedule.name} is enabled in region {monitoring_schedule.region}." + elif not monitoring_schedule.has_schedules: + report.status = "FAIL" + report.status_extended = f"No SageMaker monitoring schedules found in region {monitoring_schedule.region}." + else: + report.status = "FAIL" + report.status_extended = f"No active SageMaker monitoring schedule in region {monitoring_schedule.region}; existing schedules are not in Scheduled status." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.metadata.json new file mode 100644 index 0000000000..fe9bd5db95 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_models_registry_in_use", + "CheckTitle": "Amazon SageMaker Model Registry should have at least one approved model package", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Model Registry** is evaluated to verify that at least one Model Package Group exists and contains at least one model package with **ModelApprovalStatus = Approved**. This confirms that the ML governance workflow (register → review → approve → deploy) is actively in use.", + "Risk": "An empty Model Registry, or one with no approved packages, indicates that models are being deployed outside any review process. This breaks provenance and accountability for production ML workloads, making it impossible to enforce governance controls such as auditing, versioning, and approval workflows.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-registry-approve.html", + "https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ListModelPackageGroups.html", + "https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ListModelPackages.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker list-model-package-groups\naws sagemaker list-model-packages --model-package-group-name \naws sagemaker update-model-package --model-package-arn --model-approval-status Approved", + "NativeIaC": "", + "Other": "1. In the AWS console, navigate to SageMaker > Models > Model Registry.\n2. Create a Model Package Group if none exists.\n3. Register a model version in the group.\n4. Review and approve at least one model package by setting its approval status to Approved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register all production models in the **SageMaker Model Registry** and enforce an approval workflow before deployment. Ensure at least one model package per group reaches **Approved** status. Use **IAM policies** to restrict who can approve model packages and integrate with **CI/CD pipelines** to automate registration.", + "Url": "https://hub.prowler.com/check/sagemaker_models_registry_in_use" + } + }, + "Categories": [ + "gen-ai", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.py new file mode 100644 index 0000000000..5c7ff31fa5 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_models_registry_in_use(Check): + """Ensure that SageMaker Model Registry has at least one approved model package.""" + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports indicating whether the SageMaker Model Registry + in each region contains at least one approved model package. + """ + findings = [] + for registry in sagemaker_client.sagemaker_model_registries: + report = Check_Report_AWS(metadata=self.metadata(), resource=registry) + if not registry.has_groups: + report.status = "FAIL" + report.status_extended = f"SageMaker Model Registry in region {registry.region} has no Model Package Groups." + elif registry.has_approved_packages: + report.status = "PASS" + report.status_extended = f"SageMaker Model Registry in region {registry.region} has at least one approved model package." + else: + report.status = "FAIL" + report.status_extended = f"SageMaker Model Registry in region {registry.region} has Model Package Groups but no approved model packages." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index c44307a70d..20ea4c0280 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -15,13 +15,22 @@ class SageMaker(AWSService): self.sagemaker_notebook_instances = [] self.sagemaker_models = [] self.sagemaker_training_jobs = [] + self.sagemaker_processing_jobs = [] + self.processing_jobs_scanned_regions = set() + self.sagemaker_domains = [] self.endpoint_configs = {} + self.sagemaker_model_registries = [] + self.sagemaker_monitoring_schedules = [] # Retrieve resources concurrently self.__threading_call__(self._list_notebook_instances) self.__threading_call__(self._list_models) self.__threading_call__(self._list_training_jobs) + self.__threading_call__(self._list_processing_jobs) self.__threading_call__(self._list_endpoint_configs) + self.__threading_call__(self._list_domains) + self.__threading_call__(self._list_model_package_groups) + self.__threading_call__(self._list_monitoring_schedules) # Describe resources concurrently self.__threading_call__(self._describe_model, self.sagemaker_models) @@ -31,9 +40,13 @@ class SageMaker(AWSService): self.__threading_call__( self._describe_training_job, self.sagemaker_training_jobs ) + self.__threading_call__( + self._describe_processing_job, self.sagemaker_processing_jobs + ) self.__threading_call__( self._describe_endpoint_config, list(self.endpoint_configs.values()) ) + self.__threading_call__(self._describe_domain, self.sagemaker_domains) # List tags concurrently for each resource collection # This replaces the previous sequential sequential execution to improve performance @@ -44,9 +57,13 @@ class SageMaker(AWSService): self.__threading_call__( self._list_tags_for_resource, self.sagemaker_training_jobs ) + self.__threading_call__( + self._list_tags_for_resource, self.sagemaker_processing_jobs + ) self.__threading_call__( self._list_tags_for_resource, list(self.endpoint_configs.values()) ) + self.__threading_call__(self._list_tags_for_resource, self.sagemaker_domains) def _list_notebook_instances(self, regional_client): logger.info("SageMaker - listing notebook instances...") @@ -120,6 +137,66 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_processing_jobs(self, regional_client): + """List SageMaker processing jobs in a region. + + Populates ``self.sagemaker_processing_jobs`` with `ProcessingJob` + entries and adds ``regional_client.region`` to + ``self.processing_jobs_scanned_regions`` once pagination succeeds, so + regions where ``ListProcessingJobs`` fails are skipped by checks that + consume that set. + + Args: + regional_client: Regional SageMaker boto3 client. + """ + logger.info("SageMaker - listing processing jobs...") + try: + list_processing_jobs_paginator = regional_client.get_paginator( + "list_processing_jobs" + ) + for page in list_processing_jobs_paginator.paginate(): + for processing_job in page["ProcessingJobSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + processing_job["ProcessingJobArn"], self.audit_resources + ) + ): + self.sagemaker_processing_jobs.append( + ProcessingJob( + name=processing_job["ProcessingJobName"], + region=regional_client.region, + arn=processing_job["ProcessingJobArn"], + ) + ) + self.processing_jobs_scanned_regions.add(regional_client.region) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_processing_job(self, processing_job): + """Describe a SageMaker processing job and enrich its image metadata. + + Reads ``AppSpecification.ImageUri`` from ``DescribeProcessingJob`` and + stores it on ``processing_job.image_uri``. Errors are logged and + swallowed so a failure in one job does not abort the scan. + + Args: + processing_job: ProcessingJob model to enrich in-place. + """ + logger.info("SageMaker - describing processing job...") + try: + regional_client = self.regional_clients[processing_job.region] + describe_processing_job = regional_client.describe_processing_job( + ProcessingJobName=processing_job.name + ) + app_spec = describe_processing_job.get("AppSpecification", {}) + processing_job.image_uri = app_spec.get("ImageUri") + except Exception as error: + logger.error( + f"{processing_job.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _describe_notebook_instance(self, notebook_instance): logger.info("SageMaker - describing notebook instances...") try: @@ -203,6 +280,71 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_model_package_groups(self, regional_client): + logger.info("SageMaker - listing model package groups...") + registry_arn = self.get_unknown_arn( + region=regional_client.region, + resource_type="model-registry", + ) + has_groups = False + has_approved = False + try: + paginator = regional_client.get_paginator("list_model_package_groups") + for page in paginator.paginate(): + for group in page["ModelPackageGroupSummaryList"]: + has_groups = True + if not has_approved: + group_name = group["ModelPackageGroupName"] + try: + pkg_paginator = regional_client.get_paginator( + "list_model_packages" + ) + for pkg_page in pkg_paginator.paginate( + ModelPackageGroupName=group_name, + ModelApprovalStatus="Approved", + ): + if pkg_page["ModelPackageSummaryList"]: + has_approved = True + break + except ClientError as pkg_error: + if pkg_error.response["Error"]["Code"] in ( + "AccessDeniedException", + "UnrecognizedClientException", + ): + raise + logger.error( + f"{regional_client.region} -- {pkg_error.__class__.__name__}[{pkg_error.__traceback__.tb_lineno}]: {pkg_error}" + ) + except Exception as pkg_error: + logger.error( + f"{regional_client.region} -- {pkg_error.__class__.__name__}[{pkg_error.__traceback__.tb_lineno}]: {pkg_error}" + ) + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "UnrecognizedClientException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.sagemaker_model_registries.append( + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=regional_client.region, + has_groups=has_groups, + has_approved_packages=has_approved, + ) + ) + def _list_tags_for_resource(self, resource): """ Lists tags for a specific SageMaker resource. @@ -218,6 +360,46 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_domains(self, regional_client): + logger.info("SageMaker - listing domains...") + try: + list_domains_paginator = regional_client.get_paginator("list_domains") + for page in list_domains_paginator.paginate(): + for domain in page["Domains"]: + if not self.audit_resources or ( + is_resource_filtered(domain["DomainArn"], self.audit_resources) + ): + self.sagemaker_domains.append( + Domain( + domain_id=domain["DomainId"], + name=domain["DomainName"], + region=regional_client.region, + arn=domain["DomainArn"], + ) + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_domain(self, domain): + logger.info("SageMaker - describing domain...") + try: + regional_client = self.regional_clients[domain.region] + describe_domain = regional_client.describe_domain(DomainId=domain.domain_id) + if "AuthMode" in describe_domain: + domain.auth_mode = describe_domain["AuthMode"] + domain.single_sign_on_managed_application_instance_id = describe_domain.get( + "SingleSignOnManagedApplicationInstanceId" + ) + domain.single_sign_on_application_arn = describe_domain.get( + "SingleSignOnApplicationArn" + ) + except Exception as error: + logger.error( + f"{domain.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_endpoint_configs(self, regional_client): logger.info("SageMaker - listing endpoint configs...") try: @@ -266,6 +448,46 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_monitoring_schedules(self, regional_client): + logger.info("SageMaker - listing monitoring schedules...") + name = "SageMaker Monitoring Schedules" + arn = self.get_unknown_arn( + region=regional_client.region, + resource_type="monitoring-schedule", + ) + has_schedules = False + is_scheduled = False + try: + paginator = regional_client.get_paginator("list_monitoring_schedules") + for page in paginator.paginate(): + for schedule in page["MonitoringScheduleSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + schedule["MonitoringScheduleArn"], self.audit_resources + ) + ): + has_schedules = True + if schedule["MonitoringScheduleStatus"] == "Scheduled": + is_scheduled = True + name = schedule["MonitoringScheduleName"] + arn = schedule["MonitoringScheduleArn"] + break + if is_scheduled: + break + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.sagemaker_monitoring_schedules.append( + MonitoringSchedule( + name=name, + region=regional_client.region, + arn=arn, + has_schedules=has_schedules, + is_scheduled=is_scheduled, + ) + ) + class NotebookInstance(BaseModel): name: str @@ -298,14 +520,62 @@ class TrainingJob(BaseModel): tags: Optional[list] = [] +class ProcessingJob(BaseModel): + """Represents a SageMaker processing job. + + Attributes: + name: Processing job name. + region: AWS region where the job lives. + arn: Processing job ARN. + image_uri: Container image URI from `AppSpecification.ImageUri`, + populated by `_describe_processing_job`. + tags: Resource tags, populated by `_list_tags_for_resource`. + """ + + name: str + region: str + arn: str + image_uri: Optional[str] = None + tags: Optional[list] = [] + + class ProductionVariant(BaseModel): name: str initial_instance_count: int +class Domain(BaseModel): + domain_id: str + name: str + region: str + arn: str + auth_mode: Optional[str] = None + single_sign_on_managed_application_instance_id: Optional[str] = None + single_sign_on_application_arn: Optional[str] = None + tags: Optional[list] = [] + + class EndpointConfig(BaseModel): name: str region: str arn: str production_variants: list[ProductionVariant] = [] tags: Optional[list] = [] + + +class ModelRegistry(BaseModel): + """Represents the SageMaker Model Registry state for a specific region.""" + + name: str + arn: str + region: str + has_groups: bool = False + has_approved_packages: bool = False + + +class MonitoringSchedule(BaseModel): + name: str + region: str + arn: str + has_schedules: bool = False + is_scheduled: bool = False 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/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json new file mode 100644 index 0000000000..99748671ae --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "securityhub_delegated_admin_enabled_all_regions", + "CheckTitle": "Security Hub has delegated admin configured and is enabled in all regions with organization auto-enable", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "securityhub", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsSecurityHubHub", + "ResourceGroup": "security", + "Description": "**AWS Security Hub** has a delegated administrator configured at the organization level, hubs are active in all opted-in regions, and organization auto-enable is active so that new member accounts are automatically enrolled.", + "Risk": "Without org-wide **AWS Security Hub** configuration, findings can be aggregated inconsistently, delegated admin may be missing in some regions, and new accounts will not be auto-enrolled. This fragments **security posture visibility**, delays **incident response**, and lets misconfigurations and compliance drift go undetected across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/designate-orgs-admin-account.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/accounts-orgs-auto-enable.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-regions.html" + ], + "Remediation": { + "Code": { + "CLI": "aws securityhub enable-organization-admin-account --admin-account-id && aws securityhub update-organization-configuration --auto-enable --auto-enable-standards DEFAULT", + "NativeIaC": "", + "Other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Navigate to Services > AWS Security Hub\n4. Click Register delegated administrator and enter the security account ID\n5. Switch to the delegated admin account\n6. In Security Hub console, go to Settings > Accounts\n7. Enable auto-enable for new organization accounts\n8. Repeat hub enablement for all opted-in regions", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **delegated administrator** for AWS Security Hub via AWS Organizations. Enable Security Hub in **all opted-in regions** and turn on **auto-enable** so new member accounts are automatically enrolled. This ensures uniform security posture monitoring across the entire organization.", + "Url": "https://hub.prowler.com/check/securityhub_delegated_admin_enabled_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "securityhub_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py new file mode 100644 index 0000000000..4828752183 --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py @@ -0,0 +1,84 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.securityhub.securityhub_client import ( + securityhub_client, +) + + +class securityhub_delegated_admin_enabled_all_regions(Check): + """Ensure Security Hub has a delegated admin and is enabled in all regions. + + This check verifies that: + 1. A delegated administrator account is configured for Security Hub + 2. Security Hub is active (ACTIVE status) in each region + 3. Organization auto-enable is configured for new member accounts + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check for each region. + """ + findings = [] + + # Build a set of regions that have an organization admin account configured + regions_with_admin = { + admin.region + for admin in securityhub_client.organization_admin_accounts + if admin.admin_status == "ENABLED" + } + admin_lookup_failed = securityhub_client.organization_admin_lookup_failed + + for securityhub in securityhub_client.securityhubs: + report = Check_Report_AWS(metadata=self.metadata(), resource=securityhub) + + # Check if this region has a delegated admin + has_delegated_admin = securityhub.region in regions_with_admin + + # Check if hub is active + hub_active = securityhub.status == "ACTIVE" + + # Check if auto-enable is configured for organization members + auto_enable_on = securityhub.organization_auto_enable + + # Determine overall status + issues = [] + if admin_lookup_failed: + issues.append("delegated administrator status could not be determined") + elif not has_delegated_admin: + issues.append("no delegated administrator configured") + if not hub_active: + issues.append("Security Hub not enabled") + if ( + hub_active + and securityhub.organization_config_available + and not auto_enable_on + ): + # Only report auto-enable issue if hub is active and org config data + # is available (i.e., we could actually read AutoEnable from the API). + issues.append("organization auto-enable not configured") + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has issues: " + f"{', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has delegated " + f"admin configured with hub active and organization auto-enable " + f"enabled." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + securityhub_client.audit_config.get("mute_non_default_regions", False) + and securityhub.region != securityhub_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/securityhub/securityhub_service.py b/prowler/providers/aws/services/securityhub/securityhub_service.py index 0799c6e048..5e2d445742 100644 --- a/prowler/providers/aws/services/securityhub/securityhub_service.py +++ b/prowler/providers/aws/services/securityhub/securityhub_service.py @@ -13,8 +13,14 @@ class SecurityHub(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.securityhubs = [] + self.organization_admin_accounts = [] + self.organization_admin_lookup_failed: bool = False self.__threading_call__(self._describe_hub) self.__threading_call__(self._list_tags, self.securityhubs) + self.__threading_call__(self._list_organization_admin_accounts) + self.__threading_call__( + self._describe_organization_configuration, self.securityhubs + ) def _describe_hub(self, regional_client): logger.info("SecurityHub - Describing Hub...") @@ -104,6 +110,95 @@ class SecurityHub(AWSService): f"{resource.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_organization_admin_accounts(self, regional_client): + """List Security Hub delegated administrator accounts for the organization. + + This API is only available to the organization management account or + a delegated administrator account. + """ + logger.info("SecurityHub - listing organization admin accounts...") + try: + paginator = regional_client.get_paginator( + "list_organization_admin_accounts" + ) + for page in paginator.paginate(): + for admin in page.get("AdminAccounts", []): + admin_account = OrganizationAdminAccount( + admin_account_id=admin.get("AdminAccountId"), + admin_status=admin.get("AdminStatus"), + region=regional_client.region, + ) + # Avoid duplicates across regions for the same admin account + if not any( + existing.admin_account_id == admin_account.admin_account_id + and existing.region == admin_account.region + for existing in self.organization_admin_accounts + ): + self.organization_admin_accounts.append(admin_account) + except ClientError as error: + self.organization_admin_lookup_failed = True + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.organization_admin_lookup_failed = True + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_organization_configuration(self, securityhub): + """Describe the organization configuration for a Security Hub instance. + + This provides information about auto-enable settings for the organization. + Only invoked for hubs in ACTIVE status. + """ + logger.info("SecurityHub - describing organization configuration...") + try: + if securityhub.status != "ACTIVE": + return + regional_client = self.regional_clients[securityhub.region] + org_config = regional_client.describe_organization_configuration() + securityhub.organization_auto_enable = org_config.get("AutoEnable", False) + securityhub.auto_enable_standards = org_config.get( + "AutoEnableStandards", "NONE" + ) + securityhub.organization_config_available = True + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + # Expected when not running from management or delegated admin account + logger.warning( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class OrganizationAdminAccount(BaseModel): + """Represents a Security Hub delegated administrator account.""" + + admin_account_id: str + admin_status: str # ENABLED or DISABLE_IN_PROGRESS + region: str + class SecurityHubHub(BaseModel): arn: str @@ -112,4 +207,8 @@ class SecurityHubHub(BaseModel): standards: str integrations: str region: str - tags: Optional[list] + tags: Optional[list] = [] + # Organization configuration fields + organization_auto_enable: bool = False + auto_enable_standards: str = "NONE" + organization_config_available: bool = False diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/__init__.py b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json new file mode 100644 index 0000000000..83353b0caa --- /dev/null +++ b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "ses_identity_dkim_enabled", + "CheckTitle": "SES identity has DKIM signing enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ses", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "messaging", + "Description": "**Amazon SES identities** are evaluated for **DKIM (DomainKeys Identified Mail)** signing enabled and verified. DKIM adds a cryptographic signature to outgoing emails, allowing recipients to verify that the email was sent by the domain owner and was not altered in transit.", + "Risk": "Without DKIM signing, emails sent from SES identities are vulnerable to **spoofing and tampering**. Attackers can forge emails that appear to come from your domain, leading to phishing attacks, brand impersonation, and loss of email deliverability. Email providers are more likely to reject or mark unsigned emails as spam, impacting business communication and reputation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim.html", + "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sesv2 put-email-identity-dkim-signing-attributes --email-identity --signing-attributes-origin AWS_SES", + "NativeIaC": "", + "Other": "1. In the AWS Console, go to Simple Email Service (SES)\n2. Open Verified identities and select the affected identity\n3. Click the Authentication tab\n4. Under DKIM, click Edit\n5. Enable DKIM signatures and select 'Provide DKIM authentication token (Easy DKIM)'\n6. Save changes and add the provided CNAME records to your DNS provider", + "Terraform": "```hcl\nresource \"aws_ses_domain_dkim\" \"\" {\n domain = \"\"\n}\n\n# Add the CNAME records to Route53 (or your DNS provider)\nresource \"aws_route53_record\" \"_dkim\" {\n count = 3\n zone_id = \"\"\n name = \"${aws_ses_domain_dkim..dkim_tokens[count.index]}._domainkey.\"\n type = \"CNAME\"\n ttl = 600\n records = [\"${aws_ses_domain_dkim..dkim_tokens[count.index]}.dkim.amazonses.com\"]\n}\n```" + }, + "Recommendation": { + "Text": "Enable **DKIM signing** for all SES identities and ensure the DKIM status is **SUCCESS**. Add the required CNAME records to your DNS provider to complete verification. Combine DKIM with **SPF** and **DMARC** for comprehensive email authentication following **defense in depth** principles. Monitor DKIM status regularly and rotate DKIM keys as recommended by AWS.", + "Url": "https://hub.prowler.com/check/ses_identity_dkim_enabled" + } + }, + "Categories": [ + "identity-access", + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py new file mode 100644 index 0000000000..2654010e28 --- /dev/null +++ b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ses.ses_client import ses_client + + +class ses_identity_dkim_enabled(Check): + def execute(self): + findings = [] + for identity in ses_client.email_identities.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=identity) + if identity.dkim_status == "SUCCESS" and identity.dkim_signing_enabled: + report.status = "PASS" + report.status_extended = f"SES identity {identity.name} has DKIM signing enabled and verified." + elif identity.dkim_status in ( + "PENDING", + "NOT_STARTED", + "TEMPORARY_FAILURE", + ): + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM signing not verified (status: {identity.dkim_status})." + elif identity.dkim_status == "FAILED": + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM signing failed verification." + elif ( + identity.dkim_status == "SUCCESS" and not identity.dkim_signing_enabled + ): + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM verified but signing is disabled." + else: + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} does not have DKIM signing configured." + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/ses/ses_service.py b/prowler/providers/aws/services/ses/ses_service.py index ff19f829e6..7d0feb8f54 100644 --- a/prowler/providers/aws/services/ses/ses_service.py +++ b/prowler/providers/aws/services/ses/ses_service.py @@ -46,9 +46,15 @@ class SES(AWSService): identity_attributes = regional_client.get_email_identity( EmailIdentity=identity.name ) - for _, content in identity_attributes["Policies"].items(): + for _, content in identity_attributes.get("Policies", {}).items(): identity.policy = loads(content) - identity.tags = identity_attributes["Tags"] + identity.tags = identity_attributes.get("Tags", []) + dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {} + identity.dkim_status = dkim_attrs.get("Status") + identity.dkim_signing_enabled = dkim_attrs.get("SigningEnabled", False) + identity.dkim_signing_attributes_origin = dkim_attrs.get( + "SigningAttributesOrigin" + ) except Exception as error: logger.error( @@ -67,3 +73,6 @@ class Identity(BaseModel): type: Optional[str] policy: Optional[dict] = None tags: Optional[list] = [] + dkim_status: Optional[str] = None + dkim_signing_attributes_origin: Optional[str] = None + dkim_signing_enabled: Optional[bool] = False diff --git a/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py b/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py index 0ec8502bab..1755c6f736 100644 --- a/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py +++ b/prowler/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets.py @@ -1,7 +1,11 @@ import json from prowler.lib.check.models import Check, Check_Report_AWS -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.aws.services.ssm.ssm_client import ssm_client @@ -11,7 +15,26 @@ class ssm_document_secrets(Check): secrets_ignore_patterns = ssm_client.audit_config.get( "secrets_ignore_patterns", [] ) - for document in ssm_client.documents.values(): + validate = ssm_client.audit_config.get("secrets_validate", False) + documents = list(ssm_client.documents.values()) + + # Collect one payload per document (its content) and scan them all in + # batched Kingfisher invocations instead of one subprocess per document. + def payloads(): + for index, document in enumerate(documents): + if document.content: + yield index, json.dumps(document.content, indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, document in enumerate(documents): report = Check_Report_AWS(metadata=self.metadata(), resource=document) report.status = "PASS" report.status_extended = ( @@ -19,13 +42,15 @@ class ssm_document_secrets(Check): ) if document.content: - detect_secrets_output = detect_secrets_scan( - data=json.dumps(document.content, indent=2), - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=ssm_client.audit_config.get( - "detect_secrets_plugins" - ), - ) + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan SSM Document {document.name} for secrets: " + f"{scan_error}; manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) if detect_secrets_output: secrets_string = ", ".join( [ @@ -35,6 +60,7 @@ class ssm_document_secrets(Check): ) report.status = "FAIL" report.status_extended = f"Potential secret found in SSM Document {document.name} -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) findings.append(report) diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/__init__.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.metadata.json b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..14b65b886b --- /dev/null +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "stepfunctions_statemachine_encrypted_with_cmk", + "CheckTitle": "Step Functions state machine is encrypted at rest with a customer-managed KMS key", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)" + ], + "ServiceName": "stepfunctions", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsStepFunctionStateMachine", + "ResourceGroup": "serverless", + "Description": "**AWS Step Functions state machines** store execution history and input/output data passed between workflow states. This check verifies that each state machine uses a **customer-managed KMS key** (`CUSTOMER_MANAGED_KMS_KEY`) for encryption at rest rather than the default AWS-owned key.", + "Risk": "Without a customer-managed KMS key, execution history containing **sensitive input/output data** is protected only by an AWS-owned key you cannot control, rotate, or revoke. This limits **auditability** via CloudTrail, prevents independent access revocation, and weakens **confidentiality** for regulated workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/step-functions/latest/dg/encryption-at-rest.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#customer-cmk" + ], + "Remediation": { + "Code": { + "CLI": "aws stepfunctions update-state-machine --state-machine-arn --encryption-configuration '{\"kmsKeyId\": \"\", \"type\": \"CUSTOMER_MANAGED_KMS_KEY\", \"kmsDataKeyReusePeriodSeconds\": 300}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::StepFunctions::StateMachine\n Properties:\n RoleArn: arn:aws:iam:::role/\n DefinitionString: |\n {\"StartAt\":\"Pass\",\"States\":{\"Pass\":{\"Type\":\"Pass\",\"End\":true}}}\n EncryptionConfiguration:\n KmsKeyId: arn:aws:kms:::key/ # Critical: customer-managed KMS key\n Type: CUSTOMER_MANAGED_KMS_KEY # Critical: must be CUSTOMER_MANAGED_KMS_KEY\n KmsDataKeyReusePeriodSeconds: 300\n```", + "Other": "1. Open AWS Console > Step Functions > State machines\n2. Select the state machine and click Edit\n3. Under Encryption, select Customer managed key\n4. Choose an existing KMS key or create a new one\n5. Save changes", + "Terraform": "```hcl\nresource \"aws_sfn_state_machine\" \"\" {\n name = \"\"\n role_arn = \"arn:aws:iam:::role/\"\n definition = jsonencode({ StartAt = \"Pass\", States = { Pass = { Type = \"Pass\", End = true } } })\n\n encryption_configuration {\n kms_key_id = \"arn:aws:kms:::key/\" # Critical: customer-managed KMS key\n type = \"CUSTOMER_MANAGED_KMS_KEY\" # Critical: must be CUSTOMER_MANAGED_KMS_KEY\n kms_data_key_reuse_period_seconds = 300\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure each Step Functions state machine to use a **customer-managed KMS key** for encryption at rest. Assign a least-privilege key policy, enable **automatic key rotation**, and grant the execution role `kms:GenerateDataKey` and `kms:Decrypt`. Monitor key usage via CloudTrail.", + "Url": "https://hub.prowler.com/check/stepfunctions_statemachine_encrypted_with_cmk" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "stepfunctions_statemachine_logging_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.py new file mode 100644 index 0000000000..b14de0b482 --- /dev/null +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.stepfunctions.stepfunctions_client import ( + stepfunctions_client, +) +from prowler.providers.aws.services.stepfunctions.stepfunctions_service import ( + EncryptionType, +) + + +class stepfunctions_statemachine_encrypted_with_cmk(Check): + """Ensure Step Functions state machines are encrypted at rest with a customer-managed KMS key. + + This check evaluates whether each AWS Step Functions state machine uses a + customer-managed KMS key (CUSTOMER_MANAGED_KMS_KEY) for encryption at rest rather + than the default AWS-owned key (AWS_OWNED_KEY). + + - PASS: The state machine encryption_configuration type is CUSTOMER_MANAGED_KMS_KEY. + - FAIL: The state machine has no encryption_configuration or its type is AWS_OWNED_KEY. + """ + + def execute(self) -> List[Check_Report_AWS]: + """Execute the Step Functions state machine encryption at rest check. + + Iterates over all Step Functions state machines and generates a report + indicating whether each state machine uses a customer-managed KMS key + for encryption at rest. + + Returns: + List[Check_Report_AWS]: A list of report objects with the results of the check. + """ + findings = [] + for state_machine in stepfunctions_client.state_machines.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=state_machine) + + if ( + state_machine.encryption_configuration + and state_machine.encryption_configuration.type + == EncryptionType.CUSTOMER_MANAGED_KMS_KEY + ): + report.status = "PASS" + report.status_extended = f"Step Functions state machine {state_machine.name} is encrypted at rest with a customer-managed KMS key." + else: + report.status = "FAIL" + report.status_extended = f"Step Functions state machine {state_machine.name} is not encrypted at rest with a customer-managed KMS key." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/__init__.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.metadata.json b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.metadata.json new file mode 100644 index 0000000000..746b53d8fd --- /dev/null +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "stepfunctions_statemachine_no_secrets_in_definition", + "CheckTitle": "Step Functions state machine has no sensitive credentials in its definition", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Credential Access", + "Effects/Data Exposure", + "Sensitive Data Identifications/Security" + ], + "ServiceName": "stepfunctions", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "AwsStepFunctionStateMachine", + "ResourceGroup": "serverless", + "Description": "**AWS Step Functions state machines** are inspected for **hardcoded secrets** (keys, tokens, passwords) embedded directly in the state machine **definition** (Amazon States Language JSON).\n\nSuch values indicate sensitive data is stored directly in task parameters instead of being sourced securely.", + "Risk": "Plaintext secrets in state machine definitions reduce confidentiality: values can be viewed in the AWS Console, CLI, and may leak into execution logs or public outputs. Compromised credentials enable unauthorized AWS actions, lateral movement, and data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/step-functions/latest/dg/concepts-amazon-states-language.html", + "https://docs.aws.amazon.com/step-functions/latest/dg/security-best-practices.html", + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_how-services-use-secrets_step-functions.html", + "https://docs.aws.amazon.com/systems-manager/latest/userguide/integration-ps-secretsmanager.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::StepFunctions::StateMachine\n Properties:\n StateMachineName: \n RoleArn: \n DefinitionString: |\n {\n \"Comment\": \"Example state machine\",\n \"StartAt\": \"MyTask\",\n \"States\": {\n \"MyTask\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::aws-sdk:secretsmanager:getSecretValue\",\n \"Parameters\": {\n \"SecretId\": \"\"\n },\n \"End\": true\n }\n }\n }\n```", + "Other": "1. In AWS Console, go to Step Functions and open your state machine\n2. Click Edit\n3. Remove any hardcoded secrets from the definition\n4. Use AWS Secrets Manager or Parameter Store to retrieve secrets at runtime\n5. Grant the state machine IAM role permission to access the secret\n6. Save the updated definition", + "Terraform": "```hcl\nresource \"aws_sfn_state_machine\" \"\" {\n name = \"\"\n role_arn = \"\"\n\n definition = jsonencode({\n Comment = \"Example state machine\"\n StartAt = \"MyTask\"\n States = {\n MyTask = {\n Type = \"Task\"\n Resource = \"arn:aws:states:::aws-sdk:secretsmanager:getSecretValue\"\n Parameters = {\n SecretId = \"\" # Reference secret by name, never hardcode value\n }\n End = true\n }\n }\n })\n}\n```" + }, + "Recommendation": { + "Text": "Store secrets outside the state machine definition and retrieve them securely at runtime using **AWS Secrets Manager** or **AWS Systems Manager Parameter Store**.\n- Use the `aws-sdk:secretsmanager:getSecretValue` integration to fetch secrets dynamically\n- Enforce **least privilege** on the state machine IAM role\n- Rotate secrets regularly and never embed them in the definition", + "Url": "https://hub.prowler.com/check/stepfunctions_statemachine_no_secrets_in_definition" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.py new file mode 100644 index 0000000000..12934528e9 --- /dev/null +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition.py @@ -0,0 +1,71 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.stepfunctions.stepfunctions_client import ( + stepfunctions_client, +) + + +class stepfunctions_statemachine_no_secrets_in_definition(Check): + """Check that AWS Step Functions state machine definitions contain no hardcoded secrets.""" + + def execute(self) -> list[Check_Report_AWS]: + findings = [] + secrets_ignore_patterns = stepfunctions_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = stepfunctions_client.audit_config.get("secrets_validate", False) + state_machines = list(stepfunctions_client.state_machines.values()) + + # Collect one payload per state machine (its definition) and scan them + # all in batched Kingfisher invocations instead of one subprocess each. + def payloads(): + for index, state_machine in enumerate(state_machines): + if state_machine.definition: + yield index, state_machine.definition + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, state_machine in enumerate(state_machines): + report = Check_Report_AWS(metadata=self.metadata(), resource=state_machine) + report.status = "PASS" + report.status_extended = f"No secrets found in Step Functions state machine {state_machine.name} definition." + + if state_machine.definition: + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Step Functions state machine " + f"{state_machine.name} definition for secrets: {scan_error}; " + "manual review is required." + ) + findings.append(report) + continue + detect_secrets_output = batch_results.get(index) + if detect_secrets_output: + secrets_string = ", ".join( + [ + f"{secret['type']} on line {secret['line_number']}" + for secret in detect_secrets_output + ] + ) + report.status = "FAIL" + report.status_extended = ( + f"Potential {'secrets' if len(detect_secrets_output) > 1 else 'secret'} " + f"found in Step Functions state machine {state_machine.name} definition " + f"-> {secrets_string}." + ) + annotate_verified_secrets(report, detect_secrets_output) + + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/__init__.py b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json new file mode 100644 index 0000000000..773c85b7ab --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "transfer_server_pqc_ssh_kex_enabled", + "CheckTitle": "AWS Transfer Family server uses a post-quantum hybrid SSH key exchange security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "transfer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsTransferServer", + "ResourceGroup": "network", + "Description": "**AWS Transfer Family servers** (SFTP, FTPS, AS2) are assessed for use of an approved **post-quantum (PQ) hybrid SSH key exchange security policy**. Servers whose `SecurityPolicyName` is not in the configured allowlist of PQ-ready Transfer Family security policies leave file-transfer sessions exposed to **harvest-now, decrypt-later** attacks.", + "Risk": "Without a PQ-ready security policy, SSH/SFTP traffic captured today can be stored and decrypted in the future once a **cryptographically relevant quantum computer** is available. This threatens long-term **confidentiality** of transferred files, credentials, and metadata.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/transfer/latest/userguide/post-quantum-security-policies.html", + "https://docs.aws.amazon.com/transfer/latest/userguide/security-policies.html", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws transfer update-server --server-id --security-policy-name TransferSecurityPolicy-2025-03", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Transfer::Server\n Properties:\n Protocols:\n - SFTP\n SecurityPolicyName: TransferSecurityPolicy-2025-03 # FIX: post-quantum hybrid SSH KEX policy\n```", + "Other": "1. In the AWS Console, go to AWS Transfer Family > Servers\n2. Select the server and choose Edit on the Additional details panel\n3. Set Cryptographic algorithm options (Security policy) to TransferSecurityPolicy-2025-03 (or another approved PQ policy)\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_transfer_server\" \"\" {\n protocols = [\"SFTP\"]\n security_policy_name = \"TransferSecurityPolicy-2025-03\" # FIX: post-quantum hybrid SSH KEX policy\n endpoint_type = \"PUBLIC\"\n}\n```" + }, + "Recommendation": { + "Text": "Migrate AWS Transfer Family servers to a **post-quantum security policy** (e.g., `TransferSecurityPolicy-2025-03`, `TransferSecurityPolicy-FIPS-2025-03`, `TransferSecurityPolicy-AS2Restricted-2025-07`) that adds ML-KEM hybrid SSH key exchange. Avoid deprecated `*-PQ-SSH-Experimental-2023-04` policies. Review allowed policies regularly as AWS publishes new PQ-ready options.", + "Url": "https://hub.prowler.com/check/transfer_server_pqc_ssh_kex_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "transfer_server_in_transit_encryption_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py new file mode 100644 index 0000000000..a7d70df2df --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.transfer.transfer_client import transfer_client + +PQC_TRANSFER_POLICIES_DEFAULT = [ + "TransferSecurityPolicy-2025-03", + "TransferSecurityPolicy-FIPS-2025-03", + "TransferSecurityPolicy-AS2Restricted-2025-07", +] + + +class transfer_server_pqc_ssh_kex_enabled(Check): + """Verify that every AWS Transfer Family server uses a post-quantum security policy. + + A Transfer Family server PASSES when its ``SecurityPolicyName`` is in the + configured allowlist of policies that enable hybrid ML-KEM SSH key exchange. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Check whether Transfer Family servers use approved PQ SSH KEX policies. + + Iterates through discovered AWS Transfer Family servers and compares each + server's ``SecurityPolicyName`` with the configured allowlist of + post-quantum hybrid SSH key exchange security policies. + + Returns: + list[Check_Report_AWS]: A list of reports for each Transfer Family + server, including the evaluated security policy context. + """ + findings = [] + pqc_policies = transfer_client.audit_config.get( + "transfer_pqc_ssh_allowed_policies", PQC_TRANSFER_POLICIES_DEFAULT + ) + for server in transfer_client.servers.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=server) + policy = server.security_policy_name or "" + if server.security_policy_name in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"Transfer Server {server.id} uses post-quantum security policy " + f"{policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Transfer Server {server.id} uses security policy {policy}, " + "which does not enable post-quantum hybrid SSH key exchange." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/transfer/transfer_service.py b/prowler/providers/aws/services/transfer/transfer_service.py index f86e195a31..968b0c9e60 100644 --- a/prowler/providers/aws/services/transfer/transfer_service.py +++ b/prowler/providers/aws/services/transfer/transfer_service.py @@ -46,6 +46,9 @@ class Transfer(AWSService): ) for protocol in server_description.get("Protocols", []): server.protocols.append(Protocol(protocol)) + server.security_policy_name = server_description.get( + "SecurityPolicyName", "" + ) server.tags = server_description.get("Tags", []) except Exception as error: logger.error( @@ -65,4 +68,5 @@ class Server(BaseModel): id: str region: str protocols: List[Protocol] = Field(default_factory=list) + security_policy_name: str = "" tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py b/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py index bad30341b2..5569569290 100644 --- a/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py +++ b/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py @@ -9,20 +9,20 @@ from prowler.providers.aws.lib.service.service import AWSService class TrustedAdvisor(AWSService): def __init__(self, provider): - # Call AWSService's __init__ - super().__init__("support", provider) + # Support API is not available in China Partition + # But only in us-east-1 or us-gov-west-1 https://docs.aws.amazon.com/general/latest/gr/awssupport.html + partition = provider.identity.partition + if partition == "aws": + support_region = "us-east-1" + elif partition == "aws-cn": + support_region = None + else: + support_region = "us-gov-west-1" + super().__init__("support", provider, region=support_region) self.account_arn_template = f"arn:{self.audited_partition}:trusted-advisor:{self.region}:{self.audited_account}:account" self.checks = [] self.premium_support = PremiumSupport(enabled=False) - # Support API is not available in China Partition - # But only in us-east-1 or us-gov-west-1 https://docs.aws.amazon.com/general/latest/gr/awssupport.html if self.audited_partition != "aws-cn": - if self.audited_partition == "aws": - support_region = "us-east-1" - else: - support_region = "us-gov-west-1" - self.client = self.session.client(self.service, region_name=support_region) - self.client.region = support_region self._describe_services() if getattr(self.premium_support, "enabled", False): self._describe_trusted_advisor_checks() @@ -34,13 +34,13 @@ class TrustedAdvisor(AWSService): for check in self.client.describe_trusted_advisor_checks(language="en").get( "checks", [] ): - check_arn = f"arn:{self.audited_partition}:trusted-advisor:{self.client.region}:{self.audited_account}:check/{check['id']}" + check_arn = f"arn:{self.audited_partition}:trusted-advisor:{self.region}:{self.audited_account}:check/{check['id']}" self.checks.append( Check( id=check["id"], name=check["name"], arn=check_arn, - region=self.client.region, + region=self.region, ) ) except ClientError as error: @@ -50,22 +50,22 @@ class TrustedAdvisor(AWSService): == "Amazon Web Services Premium Support Subscription is required to use this service." ): logger.warning( - f"{self.client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) else: logger.error( - f"{self.client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: logger.error( - f"{self.client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) def _describe_trusted_advisor_check_result(self): logger.info("TrustedAdvisor - Describing Check Result...") try: for check in self.checks: - if check.region == self.client.region: + if check.region == self.region: try: response = self.client.describe_trusted_advisor_check_result( checkId=check.id @@ -78,11 +78,11 @@ class TrustedAdvisor(AWSService): == "InvalidParameterValueException" ): logger.warning( - f"{self.client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: logger.error( - f"{self.client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) def _describe_services(self): diff --git a/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.metadata.json b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.metadata.json new file mode 100644 index 0000000000..b213398059 --- /dev/null +++ b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "waf_regional_webacl_logging_enabled", + "CheckTitle": "AWS WAF Classic Regional Web ACL has logging enabled", + "CheckType": [ + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)", + "Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS" + ], + "ServiceName": "waf", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsWafRegionalWebAcl", + "ResourceGroup": "security", + "Description": "**AWS WAF Classic Regional Web ACLs** are evaluated for **logging** enabled to capture evaluated web requests and rule actions. Regional Web ACLs protect Application Load Balancers and API Gateway stages.", + "Risk": "Without **WAF logging**, you lose **visibility** into attacks (SQLi/XSS probes, bots, brute-force) and into allow/block decisions for ALB and API Gateway traffic. This limits detection, forensics, and incident response, weakening **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/waf/latest/developerguide/classic-logging.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/waf-controls.html", + "https://docs.aws.amazon.com/cli/latest/reference/waf-regional/put-logging-configuration.html" + ], + "Remediation": { + "Code": { + "CLI": "aws waf-regional put-logging-configuration --logging-configuration ResourceArn=,LogDestinationConfigs= --region ", + "NativeIaC": "", + "Other": "1. Create an Amazon Kinesis Data Firehose delivery stream with a name starting with \"aws-waf-logs-\" in the same region as your Web ACL\n2. Open the AWS WAF console and switch to AWS WAF Classic\n3. Select Filter: Regional (your region) and go to Web ACLs\n4. Open the target Web ACL and go to the Logging tab\n5. Click Enable logging and select the Firehose delivery stream created in step 1\n6. Click Enable/Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **logging** on all Regional Web ACLs and send records to a centralized logging platform. Apply **least privilege** to log destinations, redact sensitive fields, and monitor for anomalies. Integrate logs with incident response for **defense in depth** and faster containment.", + "Url": "https://hub.prowler.com/check/waf_regional_webacl_logging_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [ + "waf_global_webacl_logging_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.py b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.py new file mode 100644 index 0000000000..8d832d6c1e --- /dev/null +++ b/prowler/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled.py @@ -0,0 +1,43 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.waf.wafregional_client import wafregional_client + + +class waf_regional_webacl_logging_enabled(Check): + """Ensure AWS WAF Classic Regional Web ACLs have logging enabled. + + This check evaluates whether each AWS WAF Classic Regional Web ACL has logging + enabled by verifying the presence of at least one log destination configured + in its logging configuration. + + - PASS: The Web ACL has at least one log destination configured. + - FAIL: The Web ACL has no log destinations configured (logging is disabled). + """ + + def execute(self) -> List[Check_Report_AWS]: + """Execute the WAF Regional Web ACL logging enabled check. + + Iterates over all WAF Classic Regional Web ACLs and generates a report + indicating whether each Web ACL has logging enabled. + + Returns: + List[Check_Report_AWS]: A list of report objects with the results of the check. + """ + findings = [] + for acl in wafregional_client.web_acls.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=acl) + report.status = "FAIL" + report.status_extended = ( + f"AWS WAF Regional Web ACL {acl.name} does not have logging enabled." + ) + + if acl.logging_enabled: + report.status = "PASS" + report.status_extended = ( + f"AWS WAF Regional Web ACL {acl.name} does have logging enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/waf/waf_service.py b/prowler/providers/aws/services/waf/waf_service.py index b1fda19c50..602b7116f6 100644 --- a/prowler/providers/aws/services/waf/waf_service.py +++ b/prowler/providers/aws/services/waf/waf_service.py @@ -9,15 +9,13 @@ from prowler.providers.aws.lib.service.service import AWSService class WAF(AWSService): def __init__(self, provider): - # Call AWSService's __init__ - super().__init__("waf", provider) + # AWS WAF is available globally for CloudFront distributions, but you must use the Region US East (N. Virginia) to create your web ACL and any resources used in the web ACL, such as rule groups, IP sets, and regex pattern sets. + region = "us-east-1" if provider.identity.partition == "aws" else None + super().__init__("waf", provider, region=region) self.rules = {} self.rule_groups = {} self.web_acls = {} if self.audited_partition == "aws": - # AWS WAF is available globally for CloudFront distributions, but you must use the Region US East (N. Virginia) to create your web ACL and any resources used in the web ACL, such as rule groups, IP sets, and regex pattern sets. - self.region = "us-east-1" - self.client = self.session.client(self.service, self.region) self._list_rules() self.__threading_call__(self._get_rule, self.rules.values()) self._list_rule_groups() @@ -170,6 +168,7 @@ class WAFRegional(AWSService): ) self.__threading_call__(self._list_web_acls) self.__threading_call__(self._get_web_acl, self.web_acls.values()) + self.__threading_call__(self._get_logging_configuration, self.web_acls.values()) self.__threading_call__(self._list_resources_for_web_acl) def _list_rules(self, regional_client): @@ -279,6 +278,34 @@ class WAFRegional(AWSService): f"{acl.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_logging_configuration(self, acl): + """Fetch and store the logging configuration for a Regional Web ACL. + + Calls the WAF Regional GetLoggingConfiguration API for the given ACL and + sets acl.logging_enabled to True if at least one log destination is configured, + False otherwise. + + Args: + acl (WebAcl): The Regional Web ACL instance to update. + """ + logger.info( + f"WAFRegional - Getting Regional Web ACL {acl.name} logging configuration..." + ) + try: + get_logging_configuration = self.regional_clients[ + acl.region + ].get_logging_configuration(ResourceArn=acl.arn) + acl.logging_enabled = bool( + get_logging_configuration.get("LoggingConfiguration", {}).get( + "LogDestinationConfigs", [] + ) + ) + + except Exception as error: + logger.error( + f"{acl.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_resources_for_web_acl(self, regional_client): logger.info("WAFRegional - Describing resources...") try: diff --git a/prowler/providers/aws/services/wafv2/wafv2_service.py b/prowler/providers/aws/services/wafv2/wafv2_service.py index 6a9d3ca5b8..5682502ae3 100644 --- a/prowler/providers/aws/services/wafv2/wafv2_service.py +++ b/prowler/providers/aws/services/wafv2/wafv2_service.py @@ -11,13 +11,11 @@ from prowler.providers.aws.lib.service.service import AWSService class WAFv2(AWSService): def __init__(self, provider): - # Call AWSService's __init__ - super().__init__(__class__.__name__, provider) + # AWS WAFv2 is available globally for CloudFront distributions, but you must use the Region US East (N. Virginia) to create your web ACL. + region = "us-east-1" if provider.identity.partition == "aws" else None + super().__init__(__class__.__name__, provider, region=region) self.web_acls = {} if self.audited_partition == "aws": - # AWS WAFv2 is available globally for CloudFront distributions, but you must use the Region US East (N. Virginia) to create your web ACL. - self.region = "us-east-1" - self.client = self.session.client(self.service, self.region) self._list_web_acls_global() self.__threading_call__(self._list_web_acls_regional) self.__threading_call__(self._get_web_acl, self.web_acls.values()) diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index 506b433690..cb27bdfdb1 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -97,6 +97,7 @@ class AzureProvider(Provider): """ _type: str = "azure" + sdk_only: bool = False _session: DefaultAzureCredential _identity: AzureIdentityInfo _audit_config: dict @@ -241,7 +242,10 @@ class AzureProvider(Provider): azure_credentials = None if tenant_id and client_id and client_secret: azure_credentials = self.validate_static_credentials( - tenant_id=tenant_id, client_id=client_id, client_secret=client_secret + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + region_config=self._region_config, ) # Set up the Azure session @@ -410,6 +414,9 @@ class AzureProvider(Provider): authority=config["authority"], base_url=config["base_url"], credential_scopes=config["credential_scopes"], + graph_host=config["graph_host"], + graph_scope=config["graph_scope"], + logs_endpoint=config["logs_endpoint"], ) except ArgumentTypeError as validation_error: logger.error( @@ -441,8 +448,8 @@ class AzureProvider(Provider): None """ printed_subscriptions = [] - for key, value in self._identity.subscriptions.items(): - intermediate = key + ": " + value + for subscription_id, display_name in self._identity.subscriptions.items(): + intermediate = display_name + ": " + subscription_id printed_subscriptions.append(intermediate) report_lines = [ f"Azure Tenant Domain: {Fore.YELLOW}{self._identity.tenant_domain}{Style.RESET_ALL} Azure Tenant ID: {Fore.YELLOW}{self._identity.tenant_ids[0]}{Style.RESET_ALL}", @@ -507,6 +514,7 @@ class AzureProvider(Provider): tenant_id=azure_credentials["tenant_id"], client_id=azure_credentials["client_id"], client_secret=azure_credentials["client_secret"], + authority=region_config.authority, ) return credentials except ClientAuthenticationError as error: @@ -579,7 +587,10 @@ class AzureProvider(Provider): ) else: try: - credentials = InteractiveBrowserCredential(tenant_id=tenant_id) + credentials = InteractiveBrowserCredential( + tenant_id=tenant_id, + authority=region_config.authority, + ) except Exception as error: logger.critical( "Failed to retrieve azure credentials using browser authentication" @@ -662,6 +673,7 @@ class AzureProvider(Provider): tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, + region_config=region_config, ) # Set up the Azure session @@ -675,7 +687,11 @@ class AzureProvider(Provider): region_config, ) # Create a SubscriptionClient - subscription_client = SubscriptionClient(credentials) + subscription_client = SubscriptionClient( + credentials, + base_url=region_config.base_url, + credential_scopes=region_config.credential_scopes, + ) # Get info from the subscriptions available_subscriptions = [] @@ -949,7 +965,7 @@ class AzureProvider(Provider): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" ) - asyncio.get_event_loop().run_until_complete(get_azure_identity()) + asyncio.run(get_azure_identity()) # Managed identities only can be assigned resource, resource group and subscription scope permissions elif managed_identity_auth: @@ -969,19 +985,30 @@ class AzureProvider(Provider): ) if not subscription_ids: logger.info("Scanning all the Azure subscriptions...") - for subscription in subscriptions_client.subscriptions.list(): - # TODO: get tags or labels - # TODO: fill with AzureSubscription - identity.subscriptions.update( - {subscription.display_name: subscription.subscription_id} - ) + # TODO: get tags or labels + # TODO: fill with AzureSubscription + subscription_pairs = [ + (subscription.display_name, subscription.subscription_id) + for subscription in subscriptions_client.subscriptions.list() + ] else: logger.info("Scanning the subscriptions passed as argument ...") - for id in subscription_ids: - subscription = subscriptions_client.subscriptions.get( - subscription_id=id + subscription_pairs = [ + ( + subscriptions_client.subscriptions.get( + subscription_id=id + ).display_name, + id, ) - identity.subscriptions.update({subscription.display_name: id}) + for id in subscription_ids + ] + + # Key the subscriptions dict by subscription ID (which is + # guaranteed unique) and store the display name as the value. + # This avoids collisions when multiple subscriptions share + # the same display name. + for display_name, subscription_id in subscription_pairs: + identity.subscriptions[subscription_id] = display_name # If there are no subscriptions listed -> checks are not going to be run against any resource if not identity.subscriptions: @@ -1017,28 +1044,32 @@ class AzureProvider(Provider): Returns: A dictionary containing the locations available for each subscription. The dictionary - has subscription display names as keys and lists of location names as values. + has subscription IDs as keys and lists of location names as values. Examples: >>> provider = AzureProvider(...) >>> provider.get_locations() { - 'Subscription 1': ['eastus', 'eastus2', 'westus', 'westus2'], - 'Subscription 2': ['eastus', 'eastus2', 'westus', 'westus2'] + 'sub-id-1': ['eastus', 'eastus2', 'westus', 'westus2'], + 'sub-id-2': ['eastus', 'eastus2', 'westus', 'westus2'] } """ credentials = self.session - subscription_client = SubscriptionClient(credentials) + subscription_client = SubscriptionClient( + credentials, + base_url=self.region_config.base_url, + credential_scopes=self.region_config.credential_scopes, + ) locations = {} - for display_name, subscription_id in self._identity.subscriptions.items(): - locations[display_name] = [] + for subscription_id, display_name in self._identity.subscriptions.items(): + locations[subscription_id] = [] # List locations for each subscription for location in subscription_client.subscriptions.list_locations( subscription_id ): - locations[display_name].append(location.name) + locations[subscription_id].append(location.name) return locations @@ -1073,7 +1104,10 @@ class AzureProvider(Provider): @staticmethod def validate_static_credentials( - tenant_id: str = None, client_id: str = None, client_secret: str = None + tenant_id: str = None, + client_id: str = None, + client_secret: str = None, + region_config: AzureRegionConfig = None, ) -> dict: """ Validates the static credentials for the Azure provider. @@ -1082,6 +1116,9 @@ class AzureProvider(Provider): tenant_id (str): The Azure Active Directory tenant ID. client_id (str): The Azure client ID. client_secret (str): The Azure client secret. + region_config (AzureRegionConfig): The region configuration used to + build the per-cloud login endpoint and Graph scope. Defaults to + the public-cloud configuration when not provided. Raises: AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid. @@ -1118,8 +1155,13 @@ class AzureProvider(Provider): message="The provided Azure Client Secret is not valid.", ) + if region_config is None: + region_config = AzureProvider.setup_region_config("AzureCloud") + try: - AzureProvider.verify_client(tenant_id, client_id, client_secret) + AzureProvider.verify_client( + tenant_id, client_id, client_secret, region_config + ) return { "tenant_id": tenant_id, "client_id": client_id, @@ -1151,7 +1193,9 @@ class AzureProvider(Provider): ) @staticmethod - def verify_client(tenant_id, client_id, client_secret) -> None: + def verify_client( + tenant_id, client_id, client_secret, region_config: AzureRegionConfig = None + ) -> None: """ Verifies the Azure client credentials using the specified tenant ID, client ID, and client secret. @@ -1159,6 +1203,9 @@ class AzureProvider(Provider): tenant_id (str): The Azure Active Directory tenant ID. client_id (str): The Azure client ID. client_secret (str): The Azure client secret. + region_config (AzureRegionConfig): The region configuration used to + build the per-cloud login endpoint and Graph scope. Defaults to + the public-cloud configuration when not provided. Raises: AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid. @@ -1168,7 +1215,13 @@ class AzureProvider(Provider): Returns: None """ - url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + if region_config is None: + region_config = AzureProvider.setup_region_config("AzureCloud") + # `authority` is None for the public cloud and a bare host (e.g. + # `login.chinacloudapi.cn`) for sovereign clouds, mirroring the + # `AzureAuthorityHosts` constants used by azure-identity. + login_endpoint = region_config.authority or "login.microsoftonline.com" + url = f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token" headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", @@ -1177,7 +1230,7 @@ class AzureProvider(Provider): "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, - "scope": "https://graph.microsoft.com/.default", + "scope": region_config.graph_scope, } response = requests.post(url, headers=headers, data=data).json() if "access_token" not in response.keys() and "error_codes" in response.keys(): diff --git a/prowler/providers/azure/lib/mutelist/mutelist.py b/prowler/providers/azure/lib/mutelist/mutelist.py index 90ad609a1a..7d80d2e4cf 100644 --- a/prowler/providers/azure/lib/mutelist/mutelist.py +++ b/prowler/providers/azure/lib/mutelist/mutelist.py @@ -8,17 +8,23 @@ class AzureMutelist(Mutelist): self, finding: Check_Report_Azure, subscription_id: str, + subscription_name: str = "", ) -> bool: - return self.is_muted( - subscription_id, # support Azure Subscription ID in mutelist - finding.check_metadata.CheckID, - finding.location, - finding.resource_name, - unroll_dict(unroll_tags(finding.resource_tags)), - ) or self.is_muted( - finding.subscription, # support Azure Subscription Name in mutelist - finding.check_metadata.CheckID, - finding.location, - finding.resource_name, - unroll_dict(unroll_tags(finding.resource_tags)), - ) + account_names = [subscription_id] + for account_name in (subscription_name, finding.subscription): + if account_name and account_name not in account_names: + account_names.append(account_name) + + tags = unroll_dict(unroll_tags(finding.resource_tags)) + + for account_name in account_names: + if self.is_muted( + account_name, + finding.check_metadata.CheckID, + finding.location, + finding.resource_name, + tags, + ): + return True + + return False diff --git a/prowler/providers/azure/lib/regions/regions.py b/prowler/providers/azure/lib/regions/regions.py index 6b88ab5561..b72a7c3af4 100644 --- a/prowler/providers/azure/lib/regions/regions.py +++ b/prowler/providers/azure/lib/regions/regions.py @@ -4,6 +4,18 @@ AZURE_CHINA_CLOUD = "https://management.chinacloudapi.cn" AZURE_US_GOV_CLOUD = "https://management.usgovcloudapi.net" AZURE_GENERIC_CLOUD = "https://management.azure.com" +AZURE_GENERIC_GRAPH_HOST = "https://graph.microsoft.com" +AZURE_CHINA_GRAPH_HOST = "https://microsoftgraph.chinacloudapi.cn" +AZURE_US_GOV_GRAPH_HOST = "https://graph.microsoft.us" + +AZURE_GENERIC_GRAPH_SCOPE = f"{AZURE_GENERIC_GRAPH_HOST}/.default" +AZURE_CHINA_GRAPH_SCOPE = f"{AZURE_CHINA_GRAPH_HOST}/.default" +AZURE_US_GOV_GRAPH_SCOPE = f"{AZURE_US_GOV_GRAPH_HOST}/.default" + +AZURE_GENERIC_LOGS_ENDPOINT = "https://api.loganalytics.io" +AZURE_CHINA_LOGS_ENDPOINT = "https://api.loganalytics.azure.cn" +AZURE_US_GOV_LOGS_ENDPOINT = "https://api.loganalytics.us" + def get_regions_config(region): allowed_regions = { @@ -11,16 +23,25 @@ def get_regions_config(region): "authority": None, "base_url": AZURE_GENERIC_CLOUD, "credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"], + "graph_host": AZURE_GENERIC_GRAPH_HOST, + "graph_scope": AZURE_GENERIC_GRAPH_SCOPE, + "logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT, }, "AzureChinaCloud": { "authority": AzureAuthorityHosts.AZURE_CHINA, "base_url": AZURE_CHINA_CLOUD, "credential_scopes": [AZURE_CHINA_CLOUD + "/.default"], + "graph_host": AZURE_CHINA_GRAPH_HOST, + "graph_scope": AZURE_CHINA_GRAPH_SCOPE, + "logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT, }, "AzureUSGovernment": { "authority": AzureAuthorityHosts.AZURE_GOVERNMENT, "base_url": AZURE_US_GOV_CLOUD, "credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"], + "graph_host": AZURE_US_GOV_GRAPH_HOST, + "graph_scope": AZURE_US_GOV_GRAPH_SCOPE, + "logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT, }, } return allowed_regions[region] diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py index a4fc4b9b9b..a0a832ca01 100644 --- a/prowler/providers/azure/lib/service/service.py +++ b/prowler/providers/azure/lib/service/service.py @@ -1,5 +1,11 @@ from concurrent.futures import ThreadPoolExecutor, as_completed +from kiota_authentication_azure.azure_identity_authentication_provider import ( + AzureIdentityAuthenticationProvider, +) +from msgraph.graph_request_adapter import GraphRequestAdapter +from msgraph_core import GraphClientFactory + from prowler.lib.logger import logger from prowler.providers.azure.azure_provider import AzureProvider @@ -47,17 +53,39 @@ class AzureService: clients = {} try: if "GraphServiceClient" in str(service): - clients.update({identity.tenant_domain: service(credentials=session)}) + # GraphServiceClient(credentials, scopes=...) only customises the + # OAuth scope; the underlying httpx client's base URL stays at + # graph.microsoft.com. For sovereign clouds we must also point + # the HTTP transport at the per-cloud host, which is done by + # building a custom GraphRequestAdapter with a NationalClouds + # base URL. + auth_provider = AzureIdentityAuthenticationProvider( + session, scopes=[region_config.graph_scope] + ) + http_client = GraphClientFactory.create_with_default_middleware( + host=region_config.graph_host + ) + request_adapter = GraphRequestAdapter(auth_provider, client=http_client) + clients.update( + {identity.tenant_domain: service(request_adapter=request_adapter)} + ) elif "LogsQueryClient" in str(service): - for display_name, id in identity.subscriptions.items(): - clients.update({display_name: service(credential=session)}) - else: - for display_name, id in identity.subscriptions.items(): + for subscription_id, display_name in identity.subscriptions.items(): clients.update( { - display_name: service( + subscription_id: service( credential=session, - subscription_id=id, + endpoint=region_config.logs_endpoint, + ) + } + ) + else: + for subscription_id, display_name in identity.subscriptions.items(): + clients.update( + { + subscription_id: service( + credential=session, + subscription_id=subscription_id, base_url=region_config.base_url, credential_scopes=region_config.credential_scopes, ) diff --git a/prowler/providers/azure/models.py b/prowler/providers/azure/models.py index 752d1372c6..62d03db365 100644 --- a/prowler/providers/azure/models.py +++ b/prowler/providers/azure/models.py @@ -20,6 +20,9 @@ class AzureRegionConfig(BaseModel): authority: Optional[str] = None base_url: str = "" credential_scopes: list = [] + graph_host: str = "https://graph.microsoft.com" + graph_scope: str = "https://graph.microsoft.com/.default" + logs_endpoint: str = "https://api.loganalytics.io" class AzureSubscription(BaseModel): diff --git a/prowler/providers/azure/services/aisearch/aisearch_service.py b/prowler/providers/azure/services/aisearch/aisearch_service.py index 2324be227d..3f482a41a5 100644 --- a/prowler/providers/azure/services/aisearch/aisearch_service.py +++ b/prowler/providers/azure/services/aisearch/aisearch_service.py @@ -36,7 +36,7 @@ class AISearch(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return aisearch_services diff --git a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.py b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.py index 424b7a832e..9e048c7ba2 100644 --- a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.py +++ b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.py @@ -9,20 +9,23 @@ class aisearch_service_not_publicly_accessible(Check): findings = [] for ( - subscription_name, + subscription_id, aisearch_services, ) in aisearch_client.aisearch_services.items(): + subscription_name = aisearch_client.subscriptions.get( + subscription_id, subscription_id + ) for aisearch_service in aisearch_services.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=aisearch_service ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"AISearch Service {aisearch_service.name} from subscription {subscription_name} allows public access." + report.status_extended = f"AISearch Service {aisearch_service.name} from subscription {subscription_name} ({subscription_id}) allows public access." if not aisearch_service.public_network_access: report.status = "PASS" - report.status_extended = f"AISearch Service {aisearch_service.name} from subscription {subscription_name} does not allows public access." + report.status_extended = f"AISearch Service {aisearch_service.name} from subscription {subscription_name} ({subscription_id}) does not allows public access." findings.append(report) diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json new file mode 100644 index 0000000000..77096d6a20 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_auto_upgrade_enabled", + "CheckTitle": "AKS cluster has automatic upgrade enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for an **automatic upgrade channel** other than `none`. Automatic upgrades keep the Kubernetes control plane and node pools on supported patch/minor versions and reduce version drift across clusters.", + "Risk": "Without automatic upgrades, AKS clusters can remain on unsupported or vulnerable Kubernetes versions. Delayed patching increases exposure to **known CVEs**, control plane/node defects, and exploit chains that can affect workload **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/auto-upgrade-cluster", + "https://learn.microsoft.com/en-us/azure/aks/supported-kubernetes-versions" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --auto-upgrade-channel patch", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure an AKS automatic upgrade channel so clusters receive Kubernetes version updates and security patches without relying only on manual upgrade processes. Use `patch` or `stable` for conservative production upgrades, reserve faster channels such as `rapid` for environments that can absorb quicker version changes, and avoid `none` unless a documented exception and manual patching process exists.", + "Url": "https://hub.prowler.com/check/aks_cluster_auto_upgrade_enabled" + } + }, + "Categories": [ + "vulnerabilities", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Automatic upgrade channels can change cluster versions during Azure-managed upgrade windows. Select a channel that matches the workload change tolerance and use planned maintenance windows, staging validation, and documented rollback procedures for production clusters." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py new file mode 100644 index 0000000000..9d1f8b48a1 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_auto_upgrade_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + auto_upgrade_channel = ( + (cluster.auto_upgrade_channel or "").strip().lower() + ) + if auto_upgrade_channel and auto_upgrade_channel != "none": + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has auto-upgrade channel." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cluster '{cluster.name}' does not have auto-upgrade configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json new file mode 100644 index 0000000000..362b8e4d73 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_azure_monitor_enabled", + "CheckTitle": "AKS cluster has Azure Monitor metrics enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**Azure Kubernetes Service** clusters are evaluated for **Azure Monitor managed service for Prometheus** (the cluster's `azureMonitorProfile.metrics` configuration). When enabled, cluster and workload metrics are collected into an Azure Monitor workspace for dashboards and alerting. This is distinct from Container Insights (the `omsagent` logs addon).", + "Risk": "Without **Azure Monitor metrics**, cluster degradation, resource exhaustion, and anomalous behavior go undetected. Lack of metric-based observability delays incident detection and hampers investigation after a security or availability event.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/kubernetes-monitoring-enable", + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/prometheus-metrics-enable", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-azure-monitor-metrics", + "NativeIaC": "```bicep\n// Bicep: AKS cluster with Azure Monitor managed Prometheus metrics enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n azureMonitorProfile: {\n metrics: {\n enabled: true // CRITICAL: enables Azure Monitor managed service for Prometheus (metrics)\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your AKS cluster\n2. In the left menu, select Monitoring > Insights\n3. Click Configure monitoring\n4. Ensure 'Enable Prometheus metrics' is selected and choose (or create) an Azure Monitor workspace\n5. Click Configure to enable Azure Monitor managed Prometheus metrics", + "Terraform": "```hcl\n# Terraform: AKS cluster with Azure Monitor managed Prometheus metrics enabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n monitor_metrics {} # CRITICAL: enables Azure Monitor managed service for Prometheus (metrics)\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Azure Monitor managed service for Prometheus** on AKS clusters so cluster and workload metrics are collected into an Azure Monitor workspace. Pair it with alerting on cluster health and resource saturation, and consider also enabling Container Insights (logs) for full observability.", + "Url": "https://hub.prowler.com/check/aks_cluster_azure_monitor_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py new file mode 100644 index 0000000000..0c47f6373c --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py @@ -0,0 +1,32 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_azure_monitor_enabled(Check): + """ + Ensure Azure Monitor is enabled for AKS clusters. + + This check evaluates whether each Azure Kubernetes Service cluster has Azure Monitor integration enabled for metrics collection, log aggregation, and alerting. + + - PASS: The cluster has Azure Monitor enabled. + - FAIL: The cluster does not have Azure Monitor enabled. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.azure_monitor_enabled: + report.status = "PASS" + report.status_extended = f"Cluster '{cluster.name}' has Azure Monitor managed Prometheus metrics enabled." + else: + report.status = "FAIL" + report.status_extended = f"Cluster '{cluster.name}' does not have Azure Monitor managed Prometheus metrics enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json new file mode 100644 index 0000000000..90bbd45f3b --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_defender_enabled", + "CheckTitle": "AKS cluster has Microsoft Defender security profile enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for the **Microsoft Defender security profile** (`securityProfile.defender.securityMonitoring.enabled=true`). Defender for Containers extends security monitoring, threat detection, vulnerability assessment, and security posture insights to Azure Kubernetes Service workloads.", + "Risk": "Without the Microsoft Defender security profile, AKS runtime threats such as **cryptomining**, suspicious Kubernetes API activity, vulnerable container images, and malicious workload behavior may go undetected. Compromised containers can pivot to cluster resources and cloud APIs, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-deployment-overview", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-defender", + "NativeIaC": "```bicep\n// AKS cluster with Microsoft Defender security monitoring enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: ''\n identity: {\n type: 'SystemAssigned'\n }\n properties: {\n dnsPrefix: ''\n securityProfile: {\n defender: {\n logAnalyticsWorkspaceResourceId: ''\n securityMonitoring: {\n enabled: true // Critical: enables Defender security monitoring for AKS\n }\n }\n }\n agentPoolProfiles: [\n {\n name: 'system'\n count: 1\n vmSize: 'Standard_DS2_v2'\n mode: 'System'\n }\n ]\n }\n}\n```", + "Other": "1. In Microsoft Defender for Cloud, enable the Containers plan for the subscription that contains the AKS cluster\n2. In the AKS cluster configuration, enable the Microsoft Defender security profile\n3. Verify that Defender security monitoring is enabled for the managed cluster", + "Terraform": "```hcl\nresource \"azurerm_log_analytics_workspace\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n sku = \"PerGB2018\"\n}\n\nresource \"azurerm_kubernetes_cluster\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"system\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n\n microsoft_defender {\n log_analytics_workspace_id = azurerm_log_analytics_workspace.example.id\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable the Microsoft Defender security profile for AKS clusters and ensure the subscription has Defender for Containers enabled. Route security monitoring data to the appropriate workspace and review Defender alerts and recommendations as part of the cluster operations process.", + "Url": "https://hub.prowler.com/check/aks_cluster_defender_enabled" + } + }, + "Categories": [ + "threat-detection", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates the AKS managed cluster Defender security monitoring flag. Defender for Containers plan availability, billing, workspace routing, and alert response processes should be validated separately at the subscription and security operations levels." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py new file mode 100644 index 0000000000..08074754a6 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_defender_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.defender_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has Defender for Containers " + "enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' does not have Defender for " + "Containers enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json new file mode 100644 index 0000000000..5577d4799c --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_local_accounts_disabled", + "CheckTitle": "AKS cluster has local accounts disabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**Azure Kubernetes Service** clusters are evaluated for **local account** status. Disabling local accounts forces all cluster authentication through Microsoft Entra ID, ensuring centralized identity management, MFA, and conditional access.", + "Risk": "Local accounts bypass **Entra ID** authentication, MFA, and conditional access policies. Compromised local credentials (such as the static cluster-admin certificate) provide persistent cluster access without an identity-based audit trail or governance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/manage-local-accounts-managed-azure-ad", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --disable-local-accounts", + "NativeIaC": "```bicep\n// Bicep: AKS cluster with local accounts disabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n disableLocalAccounts: true // CRITICAL: forces authentication through Entra ID\n aadProfile: {\n managed: true\n enableAzureRbac: true\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your AKS cluster\n2. Ensure Microsoft Entra ID (AAD) authentication is configured for the cluster\n3. Under Settings, select Cluster configuration (or Authentication and Authorization)\n4. Set Local accounts to Disabled\n5. Save the configuration", + "Terraform": "```hcl\n# Terraform: AKS cluster with local accounts disabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n local_account_disabled = true # CRITICAL: forces authentication through Entra ID\n\n azure_active_directory_role_based_access_control {\n azure_rbac_enabled = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disable local accounts on AKS clusters and require **Microsoft Entra ID** authentication with Azure RBAC. Before disabling, confirm Entra ID integration and break-glass access are in place so administrators retain authenticated access, then enforce MFA and conditional access on cluster identities.", + "Url": "https://hub.prowler.com/check/aks_cluster_local_accounts_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py new file mode 100644 index 0000000000..85670e0d15 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_local_accounts_disabled(Check): + """ + Ensure local accounts are disabled on AKS clusters. + + This check evaluates whether each Azure Kubernetes Service cluster has local accounts disabled, forcing all authentication through Microsoft Entra ID. + + - PASS: The cluster has local accounts disabled. + - FAIL: The cluster has local accounts enabled. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.local_accounts_disabled: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has local accounts disabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' has local accounts enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.py index e5478b0d90..12ef99e524 100644 --- a/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.py +++ b/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.py @@ -6,16 +6,19 @@ class aks_cluster_rbac_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, clusters in aks_client.clusters.items(): + for subscription_id, clusters in aks_client.clusters.items(): + subscription_name = aks_client.subscriptions.get( + subscription_id, subscription_id + ) for cluster in clusters.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"RBAC is enabled for cluster '{cluster.name}' in subscription '{subscription_name}'." + report.status_extended = f"RBAC is enabled for cluster '{cluster.name}' in subscription '{subscription_name} ({subscription_id})'." if not cluster.rbac_enabled: report.status = "FAIL" - report.status_extended = f"RBAC is not enabled for cluster '{cluster.name}' in subscription '{subscription_name}'." + report.status_extended = f"RBAC is not enabled for cluster '{cluster.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.py b/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.py index 6de9b3653f..7eab51e085 100644 --- a/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.py +++ b/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.py @@ -6,17 +6,20 @@ class aks_clusters_created_with_private_nodes(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, clusters in aks_client.clusters.items(): + for subscription_id, clusters in aks_client.clusters.items(): + subscription_name = aks_client.subscriptions.get( + subscription_id, subscription_id + ) for cluster in clusters.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Cluster '{cluster.name}' was created with private nodes in subscription '{subscription_name}'" + report.status_extended = f"Cluster '{cluster.name}' was created with private nodes in subscription '{subscription_name} ({subscription_id})'" for agent_pool in cluster.agent_pool_profiles: if getattr(agent_pool, "enable_node_public_ip", True): report.status = "FAIL" - report.status_extended = f"Cluster '{cluster.name}' was not created with private nodes in subscription '{subscription_name}'" + report.status_extended = f"Cluster '{cluster.name}' was not created with private nodes in subscription '{subscription_name} ({subscription_id})'" break findings.append(report) diff --git a/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.py b/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.py index b607abb6d9..5c73934e50 100644 --- a/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.py +++ b/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.py @@ -6,18 +6,21 @@ class aks_clusters_public_access_disabled(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, clusters in aks_client.clusters.items(): + for subscription_id, clusters in aks_client.clusters.items(): + subscription_name = aks_client.subscriptions.get( + subscription_id, subscription_id + ) for cluster in clusters.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Public access to nodes is enabled for cluster '{cluster.name}' in subscription '{subscription_name}'" + report.status_extended = f"Public access to nodes is enabled for cluster '{cluster.name}' in subscription '{subscription_name} ({subscription_id})'" if cluster.private_fqdn: for agent_pool in cluster.agent_pool_profiles: if not getattr(agent_pool, "enable_node_public_ip", False): report.status = "PASS" - report.status_extended = f"Public access to nodes is disabled for cluster '{cluster.name}' in subscription '{subscription_name}'" + report.status_extended = f"Public access to nodes is disabled for cluster '{cluster.name}' in subscription '{subscription_name} ({subscription_id})'" findings.append(report) diff --git a/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.py b/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.py index 2af996ffa5..53a1562b47 100644 --- a/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.py +++ b/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.py @@ -6,16 +6,19 @@ class aks_network_policy_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, clusters in aks_client.clusters.items(): + for subscription_id, clusters in aks_client.clusters.items(): + subscription_name = aks_client.subscriptions.get( + subscription_id, subscription_id + ) for cluster_id, cluster in clusters.items(): report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Network policy is enabled for cluster '{cluster.name}' in subscription '{subscription_name}'." + report.status_extended = f"Network policy is enabled for cluster '{cluster.name}' in subscription '{subscription_name} ({subscription_id})'." if not getattr(cluster, "network_policy", False): report.status = "FAIL" - report.status_extended = f"Network policy is not enabled for cluster '{cluster.name}' in subscription '{subscription_name}'." + report.status_extended = f"Network policy is not enabled for cluster '{cluster.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/aks/aks_service.py b/prowler/providers/azure/services/aks/aks_service.py index 4c269fbf28..081edd7b17 100644 --- a/prowler/providers/azure/services/aks/aks_service.py +++ b/prowler/providers/azure/services/aks/aks_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from azure.mgmt.containerservice import ContainerServiceClient @@ -17,14 +17,14 @@ class AKS(AzureService): logger.info("AKS - Getting clusters...") clusters = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: clusters_list = client.managed_clusters.list() - clusters.update({subscription_name: {}}) + clusters.update({subscription_id: {}}) for cluster in clusters_list: if getattr(cluster, "kubernetes_version", None): - clusters[subscription_name].update( + clusters[subscription_id].update( { cluster.id: Cluster( id=cluster.id, @@ -55,12 +55,62 @@ class AKS(AzureService): ) ], rbac_enabled=getattr(cluster, "enable_rbac", False), + auto_upgrade_channel=getattr( + getattr(cluster, "auto_upgrade_profile", None), + "upgrade_channel", + None, + ), + defender_enabled=bool( + getattr( + getattr( + getattr( + getattr( + cluster, + "security_profile", + None, + ), + "defender", + None, + ), + "security_monitoring", + None, + ), + "enabled", + False, + ) + ), + azure_monitor_enabled=( + bool( + getattr( + getattr( + getattr( + cluster, + "azure_monitor_profile", + None, + ), + "metrics", + None, + ), + "enabled", + False, + ) + ) + if getattr( + cluster, "azure_monitor_profile", None + ) + else False + ), + local_accounts_disabled=bool( + getattr( + cluster, "disable_local_accounts", False + ) + ), ) } ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return clusters @@ -82,3 +132,7 @@ class Cluster: agent_pool_profiles: List[ManagedClusterAgentPoolProfile] rbac_enabled: bool location: str + auto_upgrade_channel: Optional[str] = None + defender_enabled: bool = False + azure_monitor_enabled: bool = False + local_accounts_disabled: bool = False diff --git a/prowler/providers/azure/services/apim/apim_service.py b/prowler/providers/azure/services/apim/apim_service.py index 793eb727c3..98fb00f276 100644 --- a/prowler/providers/azure/services/apim/apim_service.py +++ b/prowler/providers/azure/services/apim/apim_service.py @@ -147,7 +147,7 @@ class APIM(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return instances diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py index c08d4fe954..0fe68aa223 100644 --- a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py +++ b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.py @@ -50,9 +50,11 @@ class apim_threat_detection_llm_jacking(Check): ], ) - # 1. Aggregate logs from all APIM instances first - all_llm_logs: List[LogsQueryLogEntry] = [] for subscription, instances in apim_client.instances.items(): + subscription_name = apim_client.subscriptions.get( + subscription, subscription + ) + all_llm_logs: List[LogsQueryLogEntry] = [] for instance in instances: if instance.log_analytics_workspace_id: logs = apim_client.get_llm_operations_logs( @@ -60,7 +62,8 @@ class apim_threat_detection_llm_jacking(Check): ) all_llm_logs.extend(logs) - # 2. Perform a single, global analysis on all collected logs + # Analyze logs only within the current subscription to avoid + # cross-subscription attribution when scanning multiple subscriptions. potential_llm_jacking_attackers = {} for log in all_llm_logs: operation_name = log.operation_id @@ -91,19 +94,17 @@ class apim_threat_detection_llm_jacking(Check): report = Check_Report_Azure(self.metadata(), resource=resource) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Potential LLM Jacking attack detected from IP address {principal_ip} with a threshold of {action_ratio}." + report.status_extended = f"Potential LLM Jacking attack detected from IP address {principal_ip} in subscription {subscription_name} ({subscription}) with an action ratio of {action_ratio}, above the configured threshold of {threshold}." findings.append(report) - # 4. If no threats were found after checking all principals, create a single PASS report + # If no threats were found after checking all principals, create a single PASS report. if not found_potential_llm_jacking_attackers: report = Check_Report_Azure(self.metadata(), resource={}) - report.resource_name = subscription - report.resource_id = ( - f"/subscriptions/{apim_client.subscriptions[subscription]}" - ) + report.resource_name = subscription_name + report.resource_id = f"/subscriptions/{subscription}" report.subscription = subscription report.status = "PASS" - report.status_extended = f"No potential LLM Jacking attacks detected across all monitored APIM instances in the last {threat_detection_minutes} minutes." + report.status_extended = f"No potential LLM Jacking attacks detected across monitored APIM instances in subscription {subscription_name} ({subscription}) in the last {threat_detection_minutes} minutes." findings.append(report) return findings diff --git a/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.py b/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.py index 44103a7625..d146a23334 100644 --- a/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.py +++ b/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.py @@ -7,18 +7,21 @@ class app_client_certificates_on(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Clients are required to present a certificate for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Clients are required to present a certificate for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if app.client_cert_mode != "Required": report.status = "FAIL" - report.status_extended = f"Clients are not required to present a certificate for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Clients are not required to present a certificate for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.py b/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.py index 93d9d5b944..885fa64317 100644 --- a/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.py +++ b/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.py @@ -7,18 +7,21 @@ class app_ensure_auth_is_set_up(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Authentication is set up for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Authentication is set up for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if not app.auth_enabled: report.status = "FAIL" - report.status_extended = f"Authentication is not set up for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Authentication is not set up for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.py b/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.py index 47a0b8851a..04c846c904 100644 --- a/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.py +++ b/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.py @@ -7,18 +7,21 @@ class app_ensure_http_is_redirected_to_https(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"HTTP is redirected to HTTPS for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"HTTP is redirected to HTTPS for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if not app.https_only: report.status = "FAIL" - report.status_extended = f"HTTP is not redirected to HTTPS for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"HTTP is not redirected to HTTPS for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py index bc4caf7cf3..46aaa5aa9a 100644 --- a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py +++ b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py @@ -7,9 +7,12 @@ class app_ensure_java_version_is_latest(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): linux_framework = getattr(app.configurations, "linux_fx_version", "") windows_framework_version = getattr( @@ -18,19 +21,19 @@ class app_ensure_java_version_is_latest(Check): if "java" in linux_framework.lower() or windows_framework_version: report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" java_latest_version = app_client.audit_config.get( "java_latest_version", "17" ) - report.status_extended = f"Java version is set to '{f'java{windows_framework_version}' if windows_framework_version else linux_framework}', but should be set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Java version is set to '{f'java{windows_framework_version}' if windows_framework_version else linux_framework}', but should be set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if ( f"java{java_latest_version}" in linux_framework or java_latest_version == windows_framework_version ): report.status = "PASS" - report.status_extended = f"Java version is set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Java version is set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py index 8c48c629e3..7ccd31fbb5 100644 --- a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py +++ b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py @@ -7,9 +7,12 @@ class app_ensure_php_version_is_latest(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): framework = getattr(app.configurations, "linux_fx_version", "") @@ -17,14 +20,14 @@ class app_ensure_php_version_is_latest(Check): app.configurations, "php_version", "" ): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" php_latest_version = app_client.audit_config.get( "php_latest_version", "8.2" ) - report.status_extended = f"PHP version is set to '{framework}', the latest version that you could use is the '{php_latest_version}' version, for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"PHP version is set to '{framework}', the latest version that you could use is the '{php_latest_version}' version, for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if ( php_latest_version in framework @@ -32,7 +35,7 @@ class app_ensure_php_version_is_latest(Check): == php_latest_version ): report.status = "PASS" - report.status_extended = f"PHP version is set to '{php_latest_version}' for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"PHP version is set to '{php_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py index 9be2d127e1..9ea6690843 100644 --- a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py +++ b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py @@ -7,9 +7,12 @@ class app_ensure_python_version_is_latest(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): framework = getattr(app.configurations, "linux_fx_version", "") @@ -17,12 +20,12 @@ class app_ensure_python_version_is_latest(Check): app.configurations, "python_version", "" ): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" python_latest_version = app_client.audit_config.get( "python_latest_version", "3.12" ) - report.status_extended = f"Python version is '{framework}', the latest version that you could use is the '{python_latest_version}' version, for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Python version is '{framework}', the latest version that you could use is the '{python_latest_version}' version, for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if ( python_latest_version in framework @@ -30,7 +33,7 @@ class app_ensure_python_version_is_latest(Check): == python_latest_version ): report.status = "PASS" - report.status_extended = f"Python version is set to '{python_latest_version}' for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Python version is set to '{python_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.py b/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.py index 52ea5c83cd..d08a9ef90d 100644 --- a/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.py +++ b/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.py @@ -7,20 +7,23 @@ class app_ensure_using_http20(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"HTTP/2.0 is not enabled for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"HTTP/2.0 is not enabled for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if app.configurations and getattr( app.configurations, "http20_enabled", False ): report.status = "PASS" - report.status_extended = f"HTTP/2.0 is enabled for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"HTTP/2.0 is enabled for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.py b/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.py index 7177b05a69..94f41e8f55 100644 --- a/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.py +++ b/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.py @@ -7,21 +7,24 @@ class app_ftp_deployment_disabled(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"FTP is enabled for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"FTP is enabled for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if ( app.configurations and getattr(app.configurations, "ftps_state", "AllAllowed") != "AllAllowed" ): report.status = "PASS" - report.status_extended = f"FTP is disabled for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"FTP is disabled for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py index 4c1fb89756..45f273784d 100644 --- a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py +++ b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py @@ -7,23 +7,24 @@ class app_function_access_keys_configured(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): if function.function_keys is not None: report = Check_Report_Azure( metadata=self.metadata(), resource=function ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Function {function.name} does not have function keys configured." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) does not have function keys configured." if len(function.function_keys) > 0: report.status = "PASS" - report.status_extended = ( - f"Function {function.name} has function keys configured." - ) + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) has function keys configured." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py index 004af0da30..6fec5e7042 100644 --- a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py +++ b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py @@ -7,19 +7,20 @@ class app_function_application_insights_enabled(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): if function.enviroment_variables is not None: report = Check_Report_Azure( metadata=self.metadata(), resource=function ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = ( - f"Function {function.name} is not using Application Insights." - ) + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) is not using Application Insights." if function.enviroment_variables.get( "APPINSIGHTS_INSTRUMENTATIONKEY", None @@ -27,9 +28,7 @@ class app_function_application_insights_enabled(Check): "APPLICATIONINSIGHTS_CONNECTION_STRING", None ): report.status = "PASS" - report.status_extended = ( - f"Function {function.name} is using Application Insights." - ) + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) is using Application Insights." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.py b/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.py index c899986036..9922e174cb 100644 --- a/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.py +++ b/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.py @@ -7,19 +7,20 @@ class app_function_ftps_deployment_disabled(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Function {function.name} has {'FTP' if function.ftps_state == 'AllAllowed' else 'FTPS' if function.ftps_state == 'FtpsOnly' else 'FTP or FTPS'} deployment enabled" + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) has {'FTP' if function.ftps_state == 'AllAllowed' else 'FTPS' if function.ftps_state == 'FtpsOnly' else 'FTP or FTPS'} deployment enabled." if function.ftps_state == "Disabled": report.status = "PASS" - report.status_extended = ( - f"Function {function.name} has FTP and FTPS deployment disabled" - ) + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) has FTP and FTPS deployment disabled." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.py b/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.py index 0d68971f95..6ee83d6c5f 100644 --- a/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.py +++ b/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.py @@ -7,18 +7,26 @@ class app_function_identity_is_configured(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Function {function.name} does not have a managed identity enabled." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) does not have a managed identity enabled." if function.identity: + identity_type = ( + function.identity.type + if getattr(function.identity, "type", "") + else "managed" + ) report.status = "PASS" - report.status_extended = f"Function {function.name} has a {function.identity.type if getattr(function.identity, 'type', '') else 'managed'} identity enabled." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) has a {identity_type} identity enabled." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py b/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py index 9804ce283c..5031ca7120 100644 --- a/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py +++ b/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py @@ -14,22 +14,25 @@ class app_function_identity_without_admin_privileges(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): if function.identity: report = Check_Report_Azure( metadata=self.metadata(), resource=function ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Function {function.name} has a managed identity enabled but without admin privileges." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) has a managed identity enabled but without admin privileges." admin_roles_assigned = [] for role_assignment in iam_client.role_assignments[ - subscription_name + subscription_id ].values(): if ( role_assignment.agent_id == function.identity.principal_id @@ -43,8 +46,8 @@ class app_function_identity_without_admin_privileges(Check): ): admin_roles_assigned.append( getattr( - iam_client.roles[subscription_name].get( - f"/subscriptions/{iam_client.subscriptions[subscription_name]}/providers/Microsoft.Authorization/roleDefinitions/{role_assignment.role_id}" + iam_client.roles[subscription_id].get( + f"/subscriptions/{subscription_id}/providers/Microsoft.Authorization/roleDefinitions/{role_assignment.role_id}" ), "name", "", @@ -53,7 +56,7 @@ class app_function_identity_without_admin_privileges(Check): if admin_roles_assigned: report.status = "FAIL" - report.status_extended = f"Function {function.name} has a managed identity enabled and it is configure with admin privileges using {'roles: ' + ', '.join(admin_roles_assigned) if len(admin_roles_assigned) > 1 else 'role ' + admin_roles_assigned[0]}." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) has a managed identity enabled and it is configure with admin privileges using {'roles: ' + ', '.join(admin_roles_assigned) if len(admin_roles_assigned) > 1 else 'role ' + admin_roles_assigned[0]}." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py index 3cd8d349b4..828362a8fe 100644 --- a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py +++ b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py @@ -7,19 +7,20 @@ class app_function_latest_runtime_version(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): if function.enviroment_variables is not None: report = Check_Report_Azure( metadata=self.metadata(), resource=function ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = ( - f"Function {function.name} is using the latest runtime." - ) + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) is using the latest runtime." if ( function.enviroment_variables.get( @@ -28,7 +29,7 @@ class app_function_latest_runtime_version(Check): != "~4" ): report.status = "FAIL" - report.status_extended = f"Function {function.name} is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.py b/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.py index 3d506ae6e8..eede7d990f 100644 --- a/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.py +++ b/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.py @@ -7,22 +7,21 @@ class app_function_not_publicly_accessible(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = ( - f"Function {function.name} is publicly accessible." - ) + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) is publicly accessible." if not function.public_access: report.status = "PASS" - report.status_extended = ( - f"Function {function.name} is not publicly accessible." - ) + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) is not publicly accessible." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.py b/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.py index 027b98ac88..716c32955d 100644 --- a/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.py +++ b/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.py @@ -7,18 +7,21 @@ class app_function_vnet_integration_enabled(Check): findings = [] for ( - subscription_name, + subscription_id, functions, ) in app_client.functions.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for function in functions.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Function {function.name} does not have virtual network integration enabled." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) does not have virtual network integration enabled." if function.vnet_subnet_id: report.status = "PASS" - report.status_extended = f"Function {function.name} has Virtual Network integration enabled with subnet '{function.vnet_subnet_id}' enabled." + report.status_extended = f"Function {function.name} from subscription {subscription_name} ({subscription_id}) has Virtual Network integration enabled with subnet '{function.vnet_subnet_id}' enabled." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.py b/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.py index 137ec3c494..ee3596e6bc 100644 --- a/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.py +++ b/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.py @@ -6,25 +6,28 @@ class app_http_logs_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, apps in app_client.apps.items(): + for subscription_id, apps in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): if "functionapp" not in app.kind: report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" if not app.monitor_diagnostic_settings: - report.status_extended = f"App {app.name} does not have a diagnostic setting in subscription {subscription_name}." + report.status_extended = f"App {app.name} does not have a diagnostic setting in subscription {subscription_name} ({subscription_id})." else: for diagnostic_setting in app.monitor_diagnostic_settings: - report.status_extended = f"App {app.name} does not have HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name}" + report.status_extended = f"App {app.name} does not have HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name} ({subscription_id})" for log in diagnostic_setting.logs: if log.category == "AppServiceHTTPLogs" and log.enabled: report.status = "PASS" - report.status_extended = f"App {app.name} has HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name}" + report.status_extended = f"App {app.name} has HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name} ({subscription_id})" break elif log.category_group == "allLogs" and log.enabled: report.status = "PASS" - report.status_extended = f"App {app.name} has allLogs category group which includes HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name}" + report.status_extended = f"App {app.name} has allLogs category group which includes HTTP Logs enabled in diagnostic setting {diagnostic_setting.name} in subscription {subscription_name} ({subscription_id})" break findings.append(report) diff --git a/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json b/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json index 4b74e1288b..43b7641221 100644 --- a/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json +++ b/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json @@ -13,7 +13,7 @@ "Risk": "Allowing `TLS 1.0/1.1` enables protocol downgrades and weak cipher negotiation, exposing HTTPS traffic to **MITM** interception, credential theft, and tampering. This undermines the **confidentiality** and **integrity** of sessions and data in transit, and can enable account takeover via stolen tokens.", "RelatedUrl": "", "AdditionalURLs": [ - "https://learn.microsoft.com/en-us/+azure/app-service/overview-tls", + "https://learn.microsoft.com/en-us/azure/app-service/overview-tls", "https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-bindings#enforce-tls-versions", "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-tls-encryption-in-use.html", "https://icompaas.freshdesk.com/support/solutions/articles/62000234773-ensure-that-minimum-tls-version-is-set-to-tls-v1-2-or-higher" diff --git a/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.py b/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.py index f6931ba7cf..75427c16c8 100644 --- a/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.py +++ b/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.py @@ -7,20 +7,23 @@ class app_minimum_tls_version_12(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Minimum TLS version is not set to 1.2 for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Minimum TLS version is not set to 1.2 for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." if app.configurations and getattr( app.configurations, "min_tls_version", "" ) in ["1.2", "1.3"]: report.status = "PASS" - report.status_extended = f"Minimum TLS version is set to {app.configurations.min_tls_version} for app '{app.name}' in subscription '{subscription_name}'." + report.status_extended = f"Minimum TLS version is set to {app.configurations.min_tls_version} for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.py b/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.py index 35961046f9..87bc58580f 100644 --- a/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.py +++ b/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.py @@ -7,18 +7,21 @@ class app_register_with_identity(Check): findings = [] for ( - subscription_name, + subscription_id, apps, ) in app_client.apps.items(): + subscription_name = app_client.subscriptions.get( + subscription_id, subscription_id + ) for app in apps.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=app) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"App '{app.name}' in subscription '{subscription_name}' has an identity configured." + report.status_extended = f"App '{app.name}' in subscription '{subscription_name} ({subscription_id})' has an identity configured." if not app.identity: report.status = "FAIL" - report.status_extended = f"App '{app.name}' in subscription '{subscription_name}' does not have an identity configured." + report.status_extended = f"App '{app.name}' in subscription '{subscription_name} ({subscription_id})' does not have an identity configured." findings.append(report) diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py index d70c51e778..201cd6a344 100644 --- a/prowler/providers/azure/services/app/app_service.py +++ b/prowler/providers/azure/services/app/app_service.py @@ -20,10 +20,10 @@ class App(AzureService): logger.info("App - Getting apps...") apps = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: apps_list = client.web_apps.list() - apps.update({subscription_name: {}}) + apps.update({subscription_id: {}}) for app in apps_list: # Filter function apps @@ -41,7 +41,7 @@ class App(AzureService): resource_group_name=app.resource_group, name=app.name ) - apps[subscription_name].update( + apps[subscription_id].update( { app.id: WebApp( resource_id=app.id, @@ -81,7 +81,7 @@ class App(AzureService): getattr(app, "client_cert_mode", "Ignore"), ), monitor_diagnostic_settings=self._get_app_monitor_settings( - app.name, app.resource_group, subscription_name + app.name, app.resource_group, subscription_id ), https_only=getattr(app, "https_only", False), identity=ManagedServiceIdentity( @@ -106,7 +106,7 @@ class App(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return apps @@ -115,17 +115,17 @@ class App(AzureService): logger.info("Function - Getting functions...") functions = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: functions_list = client.web_apps.list() - functions.update({subscription_name: {}}) + functions.update({subscription_id: {}}) for function in functions_list: # Filter function apps if getattr(function, "kind", "").startswith("functionapp"): # List host keys host_keys = self._get_function_host_keys( - subscription_name, function.resource_group, function.name + subscription_id, function.resource_group, function.name ) if host_keys is not None: function_keys = getattr(host_keys, "function_keys", {}) @@ -133,16 +133,16 @@ class App(AzureService): function_keys = None application_settings = self._list_application_settings( - subscription_name, function.resource_group, function.name + subscription_id, function.resource_group, function.name ) function_config = self._get_function_config( - subscription_name, + subscription_id, function.resource_group, function.name, ) - functions[subscription_name].update( + functions[subscription_id].update( { function.id: FunctionApp( id=function.id, @@ -175,7 +175,7 @@ class App(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return functions @@ -200,13 +200,13 @@ class App(AzureService): monitor_diagnostics_settings = [] try: monitor_diagnostics_settings = monitor_client.diagnostic_settings_with_uri( - self.subscriptions[subscription], - f"subscriptions/{self.subscriptions[subscription]}/resourceGroups/{resource_group}/providers/Microsoft.Web/sites/{app_name}", + subscription, + f"subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.Web/sites/{app_name}", monitor_client.clients[subscription], ) except Exception as error: logger.error( - f"Subscription name: {self.subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {self.subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return monitor_diagnostics_settings diff --git a/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.py b/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.py index c7761c115e..d803c2ea18 100644 --- a/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.py +++ b/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.py @@ -8,19 +8,20 @@ class appinsights_ensure_is_configured(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, components in appinsights_client.components.items(): + for subscription_id, components in appinsights_client.components.items(): + subscription_name = appinsights_client.subscriptions.get( + subscription_id, subscription_id + ) report = Check_Report_Azure(metadata=self.metadata(), resource={}) report.status = "PASS" - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{appinsights_client.subscriptions[subscription_name]}" - ) - report.status_extended = f"There is at least one AppInsight configured in subscription {subscription_name}." + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" + report.status_extended = f"There is at least one AppInsight configured in subscription {subscription_name} ({subscription_id})." if len(components) < 1: report.status = "FAIL" - report.status_extended = f"There are no AppInsight configured in subscription {subscription_name}." + report.status_extended = f"There are no AppInsight configured in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/appinsights/appinsights_service.py b/prowler/providers/azure/services/appinsights/appinsights_service.py index aae9dbf9b0..918a0f1b0f 100644 --- a/prowler/providers/azure/services/appinsights/appinsights_service.py +++ b/prowler/providers/azure/services/appinsights/appinsights_service.py @@ -15,13 +15,13 @@ class AppInsights(AzureService): logger.info("AppInsights - Getting components...") components = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: components_list = client.components.list() - components.update({subscription_name: {}}) + components.update({subscription_id: {}}) for component in components_list: - components[subscription_name].update( + components[subscription_id].update( { component.app_id: Component( resource_id=component.id, @@ -35,7 +35,7 @@ class AppInsights(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return components diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.py b/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.py index 05cd0b1d6d..368dc3535e 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.py @@ -9,17 +9,20 @@ class containerregistry_admin_user_disabled(Check): findings = [] for subscription, registries in containerregistry_client.registries.items(): + subscription_name = containerregistry_client.subscriptions.get( + subscription, subscription + ) for container_registry_info in registries.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=container_registry_info ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} has its admin user enabled." + report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription_name} ({subscription}) has its admin user enabled." if not container_registry_info.admin_user_enabled: report.status = "PASS" - report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} has its admin user disabled." + report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription_name} ({subscription}) has its admin user disabled." findings.append(report) diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py b/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py index e6401af404..707be1aafb 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.py @@ -9,17 +9,20 @@ class containerregistry_not_publicly_accessible(Check): findings = [] for subscription, registries in containerregistry_client.registries.items(): + subscription_name = containerregistry_client.subscriptions.get( + subscription, subscription + ) for container_registry_info in registries.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=container_registry_info ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} allows unrestricted network access." + report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription_name} ({subscription}) allows unrestricted network access." if not container_registry_info.public_network_access: report.status = "PASS" - report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} does not allow unrestricted network access." + report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription_name} ({subscription}) does not allow unrestricted network access." findings.append(report) diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_service.py b/prowler/providers/azure/services/containerregistry/containerregistry_service.py index e0004429f0..ee6cce39f2 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_service.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_service.py @@ -64,7 +64,7 @@ class ContainerRegistry(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return registries @@ -81,13 +81,13 @@ class ContainerRegistry(AzureService): monitor_diagnostics_settings = [] try: monitor_diagnostics_settings = monitor_client.diagnostic_settings_with_uri( - self.subscriptions[subscription], - f"subscriptions/{self.subscriptions[subscription]}/resourceGroups/{resource_group}/providers/Microsoft.ContainerRegistry/registries/{registry_name}", + subscription, + f"subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.ContainerRegistry/registries/{registry_name}", monitor_client.clients[subscription], ) except Exception as error: logger.error( - f"Subscription name: {self.subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {self.subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return monitor_diagnostics_settings diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.py b/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.py index 5962a34c77..e9e6c324ad 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.py @@ -9,17 +9,20 @@ class containerregistry_uses_private_link(Check): findings = [] for subscription, registries in containerregistry_client.registries.items(): + subscription_name = containerregistry_client.subscriptions.get( + subscription, subscription + ) for container_registry_info in registries.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=container_registry_info ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} does not use a private link." + report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription_name} ({subscription}) does not use a private link." if container_registry_info.private_endpoint_connections: report.status = "PASS" - report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription} uses a private link." + report.status_extended = f"Container Registry {container_registry_info.name} from subscription {subscription_name} ({subscription}) uses a private link." findings.append(report) diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json new file mode 100644 index 0000000000..41246ff7a6 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_automatic_failover_enabled", + "CheckTitle": "Cosmos DB account has automatic failover enabled", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **automatic failover** configuration. When enabled, Cosmos DB automatically promotes a secondary region to primary during a regional outage, ensuring continuous availability without manual intervention.", + "Risk": "Without **automatic failover**, a regional outage requires **manual failover** which delays recovery and risks data unavailability. Applications dependent on the primary region experience downtime until an operator intervenes.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-manage-database-account#automatic-failover", + "https://learn.microsoft.com/en-us/azure/cosmos-db/high-availability", + "https://learn.microsoft.com/en-us/azure/cosmos-db/distribute-data-globally" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --enable-automatic-failover true", + "NativeIaC": "```bicep\n// Bicep: Enable automatic failover on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [\n { locationName: '', failoverPriority: 0 }\n { locationName: '', failoverPriority: 1 }\n ]\n enableAutomaticFailover: true // Critical: Promotes a secondary region during a primary region outage\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Replicate data globally\n3. Click Automatic Failover\n4. Toggle Enable Automatic Failover to On\n5. Set failover priorities for each region\n6. Click Save", + "Terraform": "```hcl\n# Terraform: Enable automatic failover on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n geo_location {\n location = \"\"\n failover_priority = 1\n }\n\n enable_automatic_failover = true # Critical: Promotes a secondary region during a primary region outage\n}\n```" + }, + "Recommendation": { + "Text": "Enable **automatic failover** on Cosmos DB accounts with **multi-region** deployments so a secondary region is promoted automatically when the primary region becomes unavailable. Configure **failover priorities** to reflect your recovery strategy, validate **RTO/RPO** expectations with periodic failover drills, and combine with **multi-region writes** where active-active is required.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_automatic_failover_enabled" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py new file mode 100644 index 0000000000..7d7d2e5023 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_automatic_failover_enabled(Check): + """Ensure that Cosmos DB accounts have automatic failover enabled.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB automatic failover check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `enableAutomaticFailover` is True, FAIL otherwise. + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not have automatic failover enabled." + if account.enable_automatic_failover: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has automatic failover enabled." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json new file mode 100644 index 0000000000..1631aa5b3a --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_backup_policy_continuous", + "CheckTitle": "Cosmos DB account uses continuous backup policy", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **continuous backup** policy. Continuous backup provides **point-in-time restore (PITR)** enabling recovery to any point within the retention window, unlike periodic backup which only supports full restores at fixed intervals.", + "Risk": "**Periodic backup** limits recovery to the last backup snapshot. Data changes between snapshots are lost during restore. **Continuous backup** enables **granular recovery** from accidental deletes, corruption, or ransomware.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction", + "https://learn.microsoft.com/en-us/azure/cosmos-db/migrate-continuous-backup", + "https://learn.microsoft.com/en-us/azure/cosmos-db/restore-account-continuous-backup" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --backup-policy-type Continuous --continuous-tier Continuous30Days", + "NativeIaC": "```bicep\n// Bicep: Switch a Cosmos DB account to Continuous backup (irreversible)\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n backupPolicy: {\n type: 'Continuous' // Critical: Enables point-in-time restore. Migration from Periodic is one-way.\n continuousModeProperties: {\n tier: 'Continuous30Days' // or 'Continuous7Days' for the lower-cost tier\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Backup & Restore\n3. Click Switch to Continuous backup\n4. Select the retention tier (Continuous30Days or Continuous7Days)\n5. Acknowledge that the migration from Periodic to Continuous is irreversible\n6. Click Save", + "Terraform": "```hcl\n# Terraform: Configure Cosmos DB account with Continuous backup (irreversible)\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n backup {\n type = \"Continuous\" # Critical: Enables point-in-time restore. One-way migration from Periodic.\n tier = \"Continuous30Days\" # or \"Continuous7Days\" for the lower-cost tier\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use **Continuous backup** for Cosmos DB accounts that require **point-in-time restore (PITR)**. Pick the retention tier (`Continuous7Days` or `Continuous30Days`) based on recovery objectives, and validate restore procedures with periodic drills. Note that switching from **Periodic** to **Continuous** is a **one-way** migration; plan the change and review pricing before applying.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_backup_policy_continuous" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py new file mode 100644 index 0000000000..f1a00fc2e8 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_backup_policy_continuous(Check): + """Ensure that Cosmos DB accounts use the continuous backup policy.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB continuous-backup check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `backupPolicy.type` is `Continuous`, FAIL otherwise (including + when the property is missing). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not use continuous backup policy." + if account.backup_policy_type == "Continuous": + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} uses continuous backup policy." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.py index d664fbfa3a..69d8bff663 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.py @@ -6,14 +6,17 @@ class cosmosdb_account_firewall_use_selected_networks(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, accounts in cosmosdb_client.accounts.items(): + subscription_name = cosmosdb_client.subscriptions.get( + subscription, subscription + ) for account in accounts: report = Check_Report_Azure(metadata=self.metadata(), resource=account) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has firewall rules that allow access from all networks." + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription_name} ({subscription}) has firewall rules that allow access from all networks." if account.is_virtual_network_filter_enabled: report.status = "PASS" - report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has firewall rules that allow access only from selected networks." + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription_name} ({subscription}) has firewall rules that allow access only from selected networks." findings.append(report) return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json new file mode 100644 index 0000000000..f9ddb2baa0 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_minimum_tls_version", + "CheckTitle": "Cosmos DB account enforces TLS 1.2 or higher", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **minimum TLS version**. TLS 1.0 and 1.1 are deprecated and contain known weaknesses. Enforcing **TLS 1.2** ensures all client connections negotiate modern, secure encryption protocols.", + "Risk": "Allowing **TLS 1.0/1.1** exposes client connections to **POODLE**, **BEAST**, and other protocol downgrade attacks that can compromise the **confidentiality** and **integrity** of data in transit, and may enable credential interception via weakened cipher suites.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/security", + "https://learn.microsoft.com/en-us/cli/azure/cosmosdb", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.documentdb/databaseaccounts" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --minimal-tls-version Tls12", + "NativeIaC": "```bicep\n// Bicep: Enforce minimum TLS 1.2 on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n minimalTlsVersion: 'Tls12' // Critical: Rejects client connections negotiating TLS 1.0 or 1.1\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Networking\n3. Locate the Minimum TLS version setting\n4. Select TLS 1.2\n5. Click Save\n6. Validate that all client applications support TLS 1.2 before enforcing the change", + "Terraform": "```hcl\n# Terraform: Enforce minimum TLS 1.2 on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n minimal_tls_version = \"Tls12\" # Critical: Rejects client connections negotiating TLS 1.0 or 1.1\n}\n```" + }, + "Recommendation": { + "Text": "Set the Cosmos DB account **minimum TLS version** to at least **1.2** to block legacy protocols (`TLS 1.0`/`1.1`) vulnerable to known downgrade and cipher attacks. Inventory and update **client SDKs** and **drivers** to support TLS 1.2 prior to enforcement, and pair this control with **private endpoints**, **AAD/RBAC authentication**, and **handshake failure monitoring** to identify outdated clients.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_minimum_tls_version" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py new file mode 100644 index 0000000000..5d85072367 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_minimum_tls_version(Check): + """Ensure that Cosmos DB accounts enforce TLS 1.2 or higher.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB minimum TLS version check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `minimalTlsVersion` is `Tls12` or higher, FAIL otherwise + (including when the property is missing or set to a legacy value). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not enforce TLS 1.2 or higher." + if account.minimal_tls_version in {"Tls12", "Tls13"}: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} enforces TLS 1.2 or higher." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..2d8a61a54e --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_public_network_access_disabled", + "CheckTitle": "Cosmos DB account has public network access disabled", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **public network access**. Disabling public network access ensures the account is only reachable through **private endpoints** or **VNet service endpoints**, reducing the attack surface and preventing direct exposure of the data plane to the internet.", + "Risk": "Allowing **public network access** exposes the Cosmos DB data plane to the internet. Combined with leaked **connection strings**, weak **firewall rules**, or compromised **AAD tokens**, this enables **unauthorized data access**, **enumeration**, **brute force**, and **data exfiltration** from any source on the internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-private-endpoints", + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-firewall", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.documentdb/databaseaccounts" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --public-network-access Disabled", + "NativeIaC": "```bicep\n// Bicep: Disable public network access on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n publicNetworkAccess: 'Disabled' // Critical: Blocks all public-internet traffic to the data plane\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Networking\n3. Under Public network access, select Disabled\n4. Configure private endpoints or VNet service endpoints before saving so clients retain connectivity\n5. Click Save", + "Terraform": "```hcl\n# Terraform: Disable public network access on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n public_network_access_enabled = false # Critical: Blocks all public-internet traffic to the data plane\n}\n```" + }, + "Recommendation": { + "Text": "Disable **public network access** on Cosmos DB accounts and require connectivity via **private endpoints** or **VNet service endpoints**. Before enforcement, validate that all application workloads have private-network connectivity, and pair this control with **AAD/RBAC authentication**, **TLS 1.2+**, and **firewall rules** restricted to the minimum trusted ranges to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py new file mode 100644 index 0000000000..ec2d29728d --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_public_network_access_disabled(Check): + """Ensure that Cosmos DB accounts have public network access disabled.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB public network access check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `publicNetworkAccess` is `Disabled` or `SecuredByPerimeter` + (Microsoft Network Security Perimeter), FAIL otherwise (including when + the property is missing or set to `Enabled`). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not have public network access disabled (current value: {account.public_network_access!r})." + if account.public_network_access in {"Disabled", "SecuredByPerimeter"}: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has public network access disabled." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.py index b521792256..acf77e240c 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.py @@ -6,14 +6,17 @@ class cosmosdb_account_use_aad_and_rbac(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, accounts in cosmosdb_client.accounts.items(): + subscription_name = cosmosdb_client.subscriptions.get( + subscription, subscription + ) for account in accounts: report = Check_Report_Azure(metadata=self.metadata(), resource=account) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} is not using AAD and RBAC" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription_name} ({subscription}) is not using AAD and RBAC" if account.disable_local_auth: report.status = "PASS" - report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} is using AAD and RBAC" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription_name} ({subscription}) is using AAD and RBAC" findings.append(report) return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.py index 8229801134..d54abca891 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.py @@ -6,14 +6,17 @@ class cosmosdb_account_use_private_endpoints(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, accounts in cosmosdb_client.accounts.items(): + subscription_name = cosmosdb_client.subscriptions.get( + subscription, subscription + ) for account in accounts: report = Check_Report_Azure(metadata=self.metadata(), resource=account) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} is not using private endpoints connections" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription_name} ({subscription}) is not using private endpoints connections" if account.private_endpoint_connections: report.status = "PASS" - report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} is using private endpoints connections" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription_name} ({subscription}) is using private endpoints connections" findings.append(report) return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py index 2d229bc060..e7c53799a7 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from azure.mgmt.cosmosdb import CosmosDBManagementClient @@ -36,19 +36,34 @@ class CosmosDB(AzureService): name=private_endpoint_connection.name, type=private_endpoint_connection.type, ) - for private_endpoint_connection in getattr( - account, "private_endpoint_connections", [] + for private_endpoint_connection in ( + getattr(account, "private_endpoint_connections", []) + or [] ) if private_endpoint_connection ], disable_local_auth=getattr( account, "disable_local_auth", False ), + enable_automatic_failover=getattr( + account, "enable_automatic_failover", False + ), + backup_policy_type=getattr( + getattr(account, "backup_policy", None), + "type", + None, + ), + public_network_access=getattr( + account, "public_network_access", None + ), + minimal_tls_version=getattr( + account, "minimal_tls_version", None + ), ) ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return accounts @@ -71,3 +86,7 @@ class Account: location: str private_endpoint_connections: List[PrivateEndpointConnection] disable_local_auth: bool = False + enable_automatic_failover: bool = False + backup_policy_type: Optional[str] = None + public_network_access: Optional[str] = None + minimal_tls_version: Optional[str] = None diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py index 7920500d88..b7367d3cbb 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -62,10 +62,21 @@ class Databricks(AzureService): else: managed_disk_encryption = None + enable_no_public_ip = getattr( + workspace_parameters, "enable_no_public_ip", None + ) workspaces[subscription][workspace.id] = DatabricksWorkspace( id=workspace.id, name=workspace.name, location=workspace.location, + public_network_access=getattr( + workspace, "public_network_access", None + ), + no_public_ip_enabled=( + getattr(enable_no_public_ip, "value", None) + if enable_no_public_ip + else None + ), custom_managed_vnet_id=( getattr( workspace_parameters, "custom_virtual_network_id", None @@ -107,6 +118,8 @@ class DatabricksWorkspace(BaseModel): id: The unique identifier of the workspace. name: The name of the workspace. location: The Azure region where the workspace is deployed. + public_network_access: Whether public network access is "Enabled" or "Disabled", if configured. + no_public_ip_enabled: Whether secure cluster connectivity (no public IP) is enabled. None when the workspace does not expose this classic-compute setting (e.g. serverless workspaces). custom_managed_vnet_id: The ID of the custom managed virtual network, if configured. managed_disk_encryption: The encryption settings for the workspace's managed disks. """ @@ -114,5 +127,7 @@ class DatabricksWorkspace(BaseModel): id: str name: str location: str + public_network_access: Optional[str] = None + no_public_ip_enabled: Optional[bool] = None custom_managed_vnet_id: Optional[str] = None managed_disk_encryption: Optional[ManagedDiskEncryption] = None diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.py index a8e366f15a..ec40b94c10 100644 --- a/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.py +++ b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.py @@ -17,6 +17,9 @@ class databricks_workspace_cmk_encryption_enabled(Check): def execute(self): findings = [] for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) for workspace in workspaces.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=workspace @@ -25,9 +28,9 @@ class databricks_workspace_cmk_encryption_enabled(Check): enc = workspace.managed_disk_encryption if enc: report.status = "PASS" - report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} has customer-managed key (CMK) encryption enabled with key {enc.key_vault_uri}/{enc.key_name}/{enc.key_version}." + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has customer-managed key (CMK) encryption enabled with key {enc.key_vault_uri}/{enc.key_name}/{enc.key_version}." else: report.status = "FAIL" - report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} does not have customer-managed key (CMK) encryption enabled." + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not have customer-managed key (CMK) encryption enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json new file mode 100644 index 0000000000..4062199474 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_no_public_ip_enabled", + "CheckTitle": "Databricks workspace has secure cluster connectivity (no public IP)", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **secure cluster connectivity** (No Public IP / NPIP). When enabled, compute (cluster) nodes are deployed without public IP addresses and communicate with the control plane through a secure relay, reducing the workspace's exposure to the internet.", + "Risk": "Without **secure cluster connectivity**, Databricks compute nodes are assigned **public IP addresses** and are directly reachable from the internet. This enables **port scanning**, **lateral movement** from a compromised node, and **data exfiltration** that bypasses private-network controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace create --name --resource-group --location --sku premium --enable-no-public-ip", + "NativeIaC": "```bicep\n// Bicep: Deploy a Databricks workspace with secure cluster connectivity (No Public IP)\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n parameters: {\n enableNoPublicIp: {\n value: true // CRITICAL: Deploys cluster nodes without public IP addresses\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and start creating a new Azure Databricks workspace (No Public IP must be set at creation; it cannot be enabled on an existing workspace)\n2. On the Networking tab, set Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP) to Yes\n3. Complete the VNet injection configuration if required\n4. Review + create the workspace\n5. Migrate workloads to the new workspace and decommission the old one", + "Terraform": "```hcl\n# Terraform: Databricks workspace with secure cluster connectivity (No Public IP)\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n custom_parameters {\n no_public_ip = true # CRITICAL: Deploys cluster nodes without public IP addresses\n }\n}\n```" + }, + "Recommendation": { + "Text": "Deploy Databricks workspaces with **secure cluster connectivity (No Public IP)** so compute nodes have no public IP addresses. Because this is set at creation, plan a migration for existing workspaces, and pair it with **VNet injection**, **private endpoints / disabled public network access**, and least-privilege NSG rules to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_no_public_ip_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Secure cluster connectivity (No Public IP) applies to classic compute and is set at workspace creation. Serverless workspaces do not expose this setting (they have no customer-managed cluster nodes with public IPs) and are reported as MANUAL for verification." +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py new file mode 100644 index 0000000000..13b7742ffa --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_no_public_ip_enabled(Check): + """ + Ensure Azure Databricks workspaces have secure cluster connectivity (no public IP) enabled. + + This check evaluates whether each Azure Databricks workspace in the subscription is deployed with secure cluster connectivity (No Public IP / NPIP), so cluster nodes are not assigned public IP addresses. + + Secure cluster connectivity is a classic-compute setting. Serverless workspaces do not expose it (they have no customer-managed cluster nodes with public IPs), so the workspace is reported as MANUAL for verification rather than failed. + + - PASS: The workspace has secure cluster connectivity enabled (no_public_ip_enabled is True). + - FAIL: The workspace has secure cluster connectivity disabled (no_public_ip_enabled is False). + - MANUAL: The workspace does not expose the setting (no_public_ip_enabled is None, e.g. serverless workspaces). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.no_public_ip_enabled is None: + report.status = "MANUAL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + elif workspace.no_public_ip_enabled: + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has secure cluster connectivity (no public IP) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not have secure cluster connectivity (no public IP) enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..cb92d6df0f --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_public_network_access_disabled", + "CheckTitle": "Databricks workspace has public network access disabled", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **public network access**. Disabling public network access ensures the workspace is only reachable through **private endpoints**, reducing the attack surface and preventing direct exposure of the control plane and data plane to the internet.", + "Risk": "Allowing **public network access** exposes the Databricks workspace control plane and data plane to the internet. Combined with leaked **credentials**, **personal access tokens**, or unpatched vulnerabilities, this enables **unauthorized access**, **brute force**, and **data exfiltration** from any source on the internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link#step-4-disable-public-network-access", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace update --name --resource-group --public-network-access Disabled", + "NativeIaC": "```bicep\n// Bicep: Disable public network access on a Databricks workspace\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n publicNetworkAccess: 'Disabled' // CRITICAL: Blocks all public-internet traffic to the workspace\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Databricks workspace\n2. In the left menu, select Networking\n3. Under Public network access, select Disabled\n4. Configure Azure Private Link private endpoints before saving so clients retain connectivity\n5. Click Save", + "Terraform": "```hcl\n# Terraform: Disable public network access on a Databricks workspace\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n public_network_access_enabled = false # CRITICAL: Blocks all public-internet traffic to the workspace\n}\n```" + }, + "Recommendation": { + "Text": "Disable **public network access** on Databricks workspaces and require connectivity via **Azure Private Link** private endpoints. Before enforcement, validate that all application workloads have private-network connectivity, and pair this control with **VNet injection**, **secure cluster connectivity (no public IP)**, and least-privilege access to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py new file mode 100644 index 0000000000..e8193cedb6 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_public_network_access_disabled(Check): + """ + Ensure Azure Databricks workspaces have public network access disabled. + + This check evaluates whether each Azure Databricks workspace in the subscription restricts connectivity to private endpoints by disabling public network access. + + - PASS: The workspace has public network access disabled (public_network_access is "Disabled"). + - FAIL: The workspace has public network access enabled (or the value is not set). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.public_network_access == "Disabled": + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has public network access disabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has public network access enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.py index f667342dab..7092536b7d 100644 --- a/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.py +++ b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.py @@ -17,6 +17,9 @@ class databricks_workspace_vnet_injection_enabled(Check): def execute(self): findings = [] for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) for workspace in workspaces.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=workspace @@ -24,9 +27,9 @@ class databricks_workspace_vnet_injection_enabled(Check): report.subscription = subscription if workspace.custom_managed_vnet_id: report.status = "PASS" - report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} is deployed in a customer-managed VNet ({workspace.custom_managed_vnet_id})." + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) is deployed in a customer-managed VNet ({workspace.custom_managed_vnet_id})." else: report.status = "FAIL" - report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} is not deployed in a customer-managed VNet (VNet Injection is not enabled)." + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) is not deployed in a customer-managed VNet (VNet Injection is not enabled)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py b/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py index 06cb1f06d2..8a8013a261 100644 --- a/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py +++ b/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py @@ -7,9 +7,12 @@ class defender_additional_email_configured_with_a_security_contact(Check): findings = [] for ( - subscription_name, + subscription_id, security_contact_configurations, ) in defender_client.security_contact_configurations.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) for contact_configuration in security_contact_configurations.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=contact_configuration @@ -19,14 +22,14 @@ class defender_additional_email_configured_with_a_security_contact(Check): if contact_configuration.name else "Security Contact" ) - report.subscription = subscription_name + report.subscription = subscription_id if len(contact_configuration.emails) > 0: report.status = "PASS" - report.status_extended = f"There is another correct email configured for subscription {subscription_name}." + report.status_extended = f"There is another correct email configured for subscription {subscription_name} ({subscription_id})." else: report.status = "FAIL" - report.status_extended = f"There is not another correct email configured for subscription {subscription_name}." + report.status_extended = f"There is not another correct email configured for subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.py b/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.py index 1e24f4918a..d06447bdf8 100644 --- a/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.py +++ b/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.py @@ -7,9 +7,12 @@ class defender_assessments_vm_endpoint_protection_installed(Check): findings = [] for ( - subscription_name, + subscription_id, assessments, ) in defender_client.assessments.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) if ( "Install endpoint protection solution on virtual machines" in assessments @@ -20,9 +23,9 @@ class defender_assessments_vm_endpoint_protection_installed(Check): "Install endpoint protection solution on virtual machines" ], ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Endpoint protection is set up in all VMs in subscription {subscription_name}." + report.status_extended = f"Endpoint protection is set up in all VMs in subscription {subscription_name} ({subscription_id})." if ( assessments[ @@ -31,7 +34,7 @@ class defender_assessments_vm_endpoint_protection_installed(Check): == "Unhealthy" ): report.status = "FAIL" - report.status_extended = f"Endpoint protection is not set up in all VMs in subscription {subscription_name}." + report.status_extended = f"Endpoint protection is not set up in all VMs in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.py b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.py index 8a9935457d..47d2a76cef 100644 --- a/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.py +++ b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.py @@ -24,9 +24,12 @@ class defender_attack_path_notifications_properly_configured(Check): min_risk_index = risk_levels.index(min_risk_level) for ( - subscription_name, + subscription_id, security_contact_configurations, ) in defender_client.security_contact_configurations.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) for contact_configuration in security_contact_configurations.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=contact_configuration @@ -36,21 +39,21 @@ class defender_attack_path_notifications_properly_configured(Check): if contact_configuration.name else "Security Contact" ) - report.subscription = subscription_name + report.subscription = subscription_id actual_risk_level = getattr( contact_configuration, "attack_path_minimal_risk_level", None ) if not actual_risk_level or actual_risk_level not in risk_levels: report.status = "FAIL" - report.status_extended = f"Attack path notifications are not enabled in subscription {subscription_name} for security contact {contact_configuration.name}." + report.status_extended = f"Attack path notifications are not enabled in subscription {subscription_name} ({subscription_id}) for security contact {contact_configuration.name}." else: actual_risk_index = risk_levels.index(actual_risk_level) if actual_risk_index <= min_risk_index: report.status = "PASS" - report.status_extended = f"Attack path notifications are enabled with minimal risk level {actual_risk_level} in subscription {subscription_name} for security contact {contact_configuration.name}." + report.status_extended = f"Attack path notifications are enabled with minimal risk level {actual_risk_level} in subscription {subscription_name} ({subscription_id}) for security contact {contact_configuration.name}." else: report.status = "FAIL" - report.status_extended = f"Attack path notifications are enabled with minimal risk level {actual_risk_level} in subscription {subscription_name} for security contact {contact_configuration.name}." + report.status_extended = f"Attack path notifications are enabled with minimal risk level {actual_risk_level} in subscription {subscription_name} ({subscription_id}) for security contact {contact_configuration.name}." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.py b/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.py index 5a4121a511..0c1162ace5 100644 --- a/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.py +++ b/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.py @@ -7,21 +7,24 @@ class defender_auto_provisioning_log_analytics_agent_vms_on(Check): findings = [] for ( - subscription_name, + subscription_id, auto_provisioning_settings, ) in defender_client.auto_provisioning_settings.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) for auto_provisioning_setting in auto_provisioning_settings.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=auto_provisioning_setting, ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Defender Auto Provisioning Log Analytics Agents from subscription {subscription_name} is set to ON." + report.status_extended = f"Defender Auto Provisioning Log Analytics Agents from subscription {subscription_name} ({subscription_id}) is set to ON." if auto_provisioning_setting.auto_provision != "On": report.status = "FAIL" - report.status_extended = f"Defender Auto Provisioning Log Analytics Agents from subscription {subscription_name} is set to OFF." + report.status_extended = f"Defender Auto Provisioning Log Analytics Agents from subscription {subscription_name} ({subscription_id}) is set to OFF." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.py b/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.py index f3bb4dbc25..90404e39e4 100644 --- a/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.py +++ b/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.py @@ -7,9 +7,12 @@ class defender_auto_provisioning_vulnerabilty_assessments_machines_on(Check): findings = [] for ( - subscription_name, + subscription_id, assessments, ) in defender_client.assessments.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) if ( "Machines should have a vulnerability assessment solution" in assessments @@ -20,9 +23,9 @@ class defender_auto_provisioning_vulnerabilty_assessments_machines_on(Check): "Machines should have a vulnerability assessment solution" ], ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Vulnerability assessment is set up in all VMs in subscription {subscription_name}." + report.status_extended = f"Vulnerability assessment is set up in all VMs in subscription {subscription_name} ({subscription_id})." if ( assessments[ @@ -31,7 +34,7 @@ class defender_auto_provisioning_vulnerabilty_assessments_machines_on(Check): == "Unhealthy" ): report.status = "FAIL" - report.status_extended = f"Vulnerability assessment is not set up in all VMs in subscription {subscription_name}." + report.status_extended = f"Vulnerability assessment is not set up in all VMs in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.py b/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.py index 51dd1e3648..56a5e10752 100644 --- a/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.py +++ b/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.py @@ -7,9 +7,12 @@ class defender_container_images_resolved_vulnerabilities(Check): findings = [] for ( - subscription_name, + subscription_id, assessments, ) in defender_client.assessments.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) if ( "Azure running container images should have vulnerabilities resolved (powered by Microsoft Defender Vulnerability Management)" in assessments @@ -28,9 +31,9 @@ class defender_container_images_resolved_vulnerabilities(Check): "Azure running container images should have vulnerabilities resolved (powered by Microsoft Defender Vulnerability Management)" ], ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Azure running container images do not have unresolved vulnerabilities in subscription '{subscription_name}'." + report.status_extended = f"Azure running container images do not have unresolved vulnerabilities in subscription '{subscription_name} ({subscription_id})'." if ( assessments[ "Azure running container images should have vulnerabilities resolved (powered by Microsoft Defender Vulnerability Management)" @@ -38,7 +41,7 @@ class defender_container_images_resolved_vulnerabilities(Check): == "Unhealthy" ): report.status = "FAIL" - report.status_extended = f"Azure running container images have unresolved vulnerabilities in subscription '{subscription_name}'." + report.status_extended = f"Azure running container images have unresolved vulnerabilities in subscription '{subscription_name} ({subscription_id})'." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.py b/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.py index 06b067d1a4..2cb5d1974e 100644 --- a/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.py +++ b/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.py @@ -6,20 +6,21 @@ class defender_container_images_scan_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "Containers" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["Containers"] ) report.subscription = subscription report.status = "PASS" - report.status_extended = ( - f"Container image scan is enabled in subscription {subscription}." - ) + report.status_extended = f"Container image scan is enabled in subscription {subscription_name} ({subscription})." if not pricings["Containers"].extensions.get( "ContainerRegistriesVulnerabilityAssessments" ): report.status = "FAIL" - report.status_extended = f"Container image scan is disabled in subscription {subscription}." + report.status_extended = f"Container image scan is disabled in subscription {subscription_name} ({subscription})." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json new file mode 100644 index 0000000000..cae92490af --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "defender_ensure_defender_cspm_is_on", + "CheckTitle": "Microsoft Defender CSPM is set to On", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.security/pricings", + "ResourceGroup": "security", + "Description": "**Microsoft Defender for Cloud** Cloud Security Posture Management (CSPM) plan is evaluated for **standard tier** activation. Defender CSPM provides advanced posture management capabilities including attack path analysis, cloud security explorer, agentless scanning, and governance rules.", + "Risk": "Without Defender CSPM, the subscription relies on **foundational CSPM** (free tier) which lacks attack path analysis, agentless vulnerability scanning, and security governance. Advanced threats exploiting misconfiguration chains go undetected.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-cloud-security-posture-management", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security" + ], + "Remediation": { + "Code": { + "CLI": "az security pricing create -n CloudPosture --tier Standard", + "NativeIaC": "```bicep\ntargetScope = 'subscription'\n\nresource defenderCSPM 'Microsoft.Security/pricings@2024-01-01' = {\n name: 'CloudPosture'\n properties: {\n pricingTier: 'Standard' // Critical: enables Defender CSPM\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Microsoft Defender for Cloud\n3. Select Environment Settings\n4. Click on the subscription\n5. Set Cloud Security Posture Management (CSPM) to On\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n tier = \"Standard\" # Critical: enables Defender CSPM\n resource_type = \"CloudPosture\"\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Defender CSPM** standard tier for advanced cloud security posture management. Evaluate the cost against the security benefits \u2014 CSPM provides attack path analysis and agentless scanning.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_cspm_is_on" + } + }, + "Categories": [ + "threat-detection" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py new file mode 100644 index 0000000000..cf7957400c --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.defender.defender_client import defender_client + + +class defender_ensure_defender_cspm_is_on(Check): + """ + Ensure Microsoft Defender Cloud Security Posture Management (CSPM) is set to On. + + This check evaluates whether the Defender CSPM plan (CloudPosture pricing) is enabled with the Standard tier for each subscription. + + - PASS: The CloudPosture pricing tier is "Standard" (Defender CSPM is on). + - FAIL: The CloudPosture pricing tier is not "Standard" (Defender CSPM is off). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) + if "CloudPosture" in pricings: + report = Check_Report_Azure( + metadata=self.metadata(), + resource=pricings["CloudPosture"], + ) + report.subscription = subscription + report.resource_name = "Defender plan CSPM" + report.status = "PASS" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." + if pricings["CloudPosture"].pricing_tier != "Standard": + report.status = "FAIL" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." + + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.py index cd30f2e5dd..e7362491bf 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_app_services_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "AppServices" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["AppServices"] @@ -13,10 +16,10 @@ class defender_ensure_defender_for_app_services_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan App Services" report.status = "PASS" - report.status_extended = f"Defender plan Defender for App Services from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for App Services from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["AppServices"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for App Services from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for App Services from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.py index c05102b351..f759967661 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_arm_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "Arm" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["Arm"] @@ -13,10 +16,10 @@ class defender_ensure_defender_for_arm_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan ARM" report.status = "PASS" - report.status_extended = f"Defender plan Defender for ARM from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for ARM from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["Arm"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for ARM from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for ARM from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.py index 3b05ddd415..6aeb8d0ddd 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.py @@ -6,16 +6,19 @@ class defender_ensure_defender_for_azure_sql_databases_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "SqlServers" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["SqlServers"] ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Defender plan Defender for Azure SQL DB Servers from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for Azure SQL DB Servers from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["SqlServers"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for Azure SQL DB Servers from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for Azure SQL DB Servers from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.py index 56a5ff32e3..00741c18f8 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.py @@ -6,16 +6,19 @@ class defender_ensure_defender_for_containers_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "Containers" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["Containers"] ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Defender plan Defender for Containers from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for Containers from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["Containers"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for Containers from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for Containers from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.py index eba77eb94f..b709f00831 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_cosmosdb_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "CosmosDbs" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["CosmosDbs"] @@ -13,10 +16,10 @@ class defender_ensure_defender_for_cosmosdb_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan Cosmos DB" report.status = "PASS" - report.status_extended = f"Defender plan Defender for Cosmos DB from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for Cosmos DB from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["CosmosDbs"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for Cosmos DB from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for Cosmos DB from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.py index 21f43a7e13..45b991cbdb 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_databases_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if ( "SqlServers" in pricings and "SqlServerVirtualMachines" in pricings @@ -17,7 +20,7 @@ class defender_ensure_defender_for_databases_is_on(Check): ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Defender plan Defender for Databases from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for Databases from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if ( pricings["SqlServers"].pricing_tier != "Standard" or pricings["SqlServerVirtualMachines"].pricing_tier != "Standard" @@ -26,7 +29,7 @@ class defender_ensure_defender_for_databases_is_on(Check): or pricings["CosmosDbs"].pricing_tier != "Standard" ): report.status = "FAIL" - report.status_extended = f"Defender plan Defender for Databases from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for Databases from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.py index e096e93bab..86fd78f554 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_dns_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "Dns" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["Dns"] @@ -13,10 +16,10 @@ class defender_ensure_defender_for_dns_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan DNS" report.status = "PASS" - report.status_extended = f"Defender plan Defender for DNS from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for DNS from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["Dns"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for DNS from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for DNS from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.py index 202e76b4b4..42bcb62ed4 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_keyvault_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "KeyVaults" in pricings: report = Check_Report_Azure( metadata=self.metadata(), resource=pricings["KeyVaults"] @@ -13,10 +16,10 @@ class defender_ensure_defender_for_keyvault_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan KeyVaults" report.status = "PASS" - report.status_extended = f"Defender plan Defender for KeyVaults from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for KeyVaults from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["KeyVaults"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for KeyVaults from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for KeyVaults from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.py index 7497e9fc2a..187b1950f1 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_os_relational_databases_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "OpenSourceRelationalDatabases" in pricings: report = Check_Report_Azure( metadata=self.metadata(), @@ -14,10 +17,10 @@ class defender_ensure_defender_for_os_relational_databases_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan Open-Source Relational Databases" report.status = "PASS" - report.status_extended = f"Defender plan Defender for Open-Source Relational Databases from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for Open-Source Relational Databases from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["OpenSourceRelationalDatabases"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for Open-Source Relational Databases from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for Open-Source Relational Databases from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.py index 54cf846b78..3c5afd49b9 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_server_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "VirtualMachines" in pricings: report = Check_Report_Azure( metadata=self.metadata(), @@ -14,10 +17,10 @@ class defender_ensure_defender_for_server_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan Servers" report.status = "PASS" - report.status_extended = f"Defender plan Defender for Servers from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for Servers from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["VirtualMachines"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for Servers from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for Servers from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.py index 741b5906a9..5f40f32b28 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_sql_servers_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "SqlServerVirtualMachines" in pricings: report = Check_Report_Azure( metadata=self.metadata(), @@ -14,10 +17,10 @@ class defender_ensure_defender_for_sql_servers_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan SQL Server VMs" report.status = "PASS" - report.status_extended = f"Defender plan Defender for SQL Server VMs from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for SQL Server VMs from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["SqlServerVirtualMachines"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for SQL Server VMs from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for SQL Server VMs from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.py index 390d6e8cde..e4844d343d 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.py @@ -6,6 +6,9 @@ class defender_ensure_defender_for_storage_is_on(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) if "StorageAccounts" in pricings: report = Check_Report_Azure( metadata=self.metadata(), @@ -14,10 +17,10 @@ class defender_ensure_defender_for_storage_is_on(Check): report.subscription = subscription report.resource_name = "Defender plan Storage Accounts" report.status = "PASS" - report.status_extended = f"Defender plan Defender for Storage Accounts from subscription {subscription} is set to ON (pricing tier standard)." + report.status_extended = f"Defender plan Defender for Storage Accounts from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." if pricings["StorageAccounts"].pricing_tier != "Standard": report.status = "FAIL" - report.status_extended = f"Defender plan Defender for Storage Accounts from subscription {subscription} is set to OFF (pricing tier not standard)." + report.status_extended = f"Defender plan Defender for Storage Accounts from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.py index e608e09fbd..07cbc79102 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.py +++ b/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.py @@ -7,18 +7,19 @@ class defender_ensure_iot_hub_defender_is_on(Check): findings = [] for ( - subscription_name, + subscription_id, iot_security_solutions, ) in defender_client.iot_security_solutions.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) if not iot_security_solutions: report = Check_Report_Azure(metadata=self.metadata(), resource={}) report.status = "FAIL" - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{defender_client.subscriptions[subscription_name]}" - ) - report.status_extended = f"No IoT Security Solutions found in the subscription {subscription_name}." + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" + report.status_extended = f"No IoT Security Solutions found in the subscription {subscription_name} ({subscription_id})." findings.append(report) else: for iot_security_solution in iot_security_solutions.values(): @@ -26,13 +27,13 @@ class defender_ensure_iot_hub_defender_is_on(Check): metadata=self.metadata(), resource=iot_security_solution, ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"The security solution {iot_security_solution.name} is enabled in subscription {subscription_name}." + report.status_extended = f"The security solution {iot_security_solution.name} is enabled in subscription {subscription_name} ({subscription_id})." if iot_security_solution.status != "Enabled": report.status = "FAIL" - report.status_extended = f"The security solution {iot_security_solution.name} is disabled in subscription {subscription_name}" + report.status_extended = f"The security solution {iot_security_solution.name} is disabled in subscription {subscription_name} ({subscription_id})" findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.py b/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.py index 4899f2dc06..c836fa1c67 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.py +++ b/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.py @@ -7,29 +7,30 @@ class defender_ensure_mcas_is_enabled(Check): findings = [] for ( - subscription_name, + subscription_id, settings, ) in defender_client.settings.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) if "MCAS" not in settings: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{defender_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"Microsoft Defender for Cloud Apps not exists for subscription {subscription_name}." + report.status_extended = f"Microsoft Defender for Cloud Apps not exists for subscription {subscription_name} ({subscription_id})." else: report = Check_Report_Azure( metadata=self.metadata(), resource=settings["MCAS"] ) - report.subscription = subscription_name + report.subscription = subscription_id if settings["MCAS"].enabled: report.status = "PASS" - report.status_extended = f"Microsoft Defender for Cloud Apps is enabled for subscription {subscription_name}." + report.status_extended = f"Microsoft Defender for Cloud Apps is enabled for subscription {subscription_name} ({subscription_id})." else: report.status = "FAIL" - report.status_extended = f"Microsoft Defender for Cloud Apps is disabled for subscription {subscription_name}." + report.status_extended = f"Microsoft Defender for Cloud Apps is disabled for subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py index d01fec8966..fb99f0277b 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py @@ -7,9 +7,12 @@ class defender_ensure_notify_alerts_severity_is_high(Check): findings = [] for ( - subscription_name, + subscription_id, security_contact_configurations, ) in defender_client.security_contact_configurations.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) for contact_configuration in security_contact_configurations.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=contact_configuration @@ -19,16 +22,16 @@ class defender_ensure_notify_alerts_severity_is_high(Check): if contact_configuration.name else "Security Contact" ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {subscription_name}." + report.status_extended = f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {subscription_name} ({subscription_id})." if ( contact_configuration.alert_minimal_severity and contact_configuration.alert_minimal_severity != "Critical" ): report.status = "PASS" - report.status_extended = f"Notifications are enabled for alerts with a minimum severity of high or lower ({contact_configuration.alert_minimal_severity}) in subscription {subscription_name}." + report.status_extended = f"Notifications are enabled for alerts with a minimum severity of high or lower ({contact_configuration.alert_minimal_severity}) in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py b/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py index ed16c609c3..7c751bb816 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py @@ -7,9 +7,12 @@ class defender_ensure_notify_emails_to_owners(Check): findings = [] for ( - subscription_name, + subscription_id, security_contact_configurations, ) in defender_client.security_contact_configurations.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) for contact_configuration in security_contact_configurations.values(): report = Check_Report_Azure( metadata=self.metadata(), @@ -20,16 +23,16 @@ class defender_ensure_notify_emails_to_owners(Check): if contact_configuration.name else "Security Contact" ) - report.subscription = subscription_name + report.subscription = subscription_id if ( contact_configuration.notifications_by_role.state and "Owner" in contact_configuration.notifications_by_role.roles ): report.status = "PASS" - report.status_extended = f"The Owner role is notified for subscription {subscription_name}." + report.status_extended = f"The Owner role is notified for subscription {subscription_name} ({subscription_id})." else: report.status = "FAIL" - report.status_extended = f"The Owner role is not notified for subscription {subscription_name}." + report.status_extended = f"The Owner role is not notified for subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.py b/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.py index 1984888f02..01da3dec82 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.py +++ b/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.py @@ -7,9 +7,12 @@ class defender_ensure_system_updates_are_applied(Check): findings = [] for ( - subscription_name, + subscription_id, assessments, ) in defender_client.assessments.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) if ( "Log Analytics agent should be installed on virtual machines" in assessments @@ -23,9 +26,9 @@ class defender_ensure_system_updates_are_applied(Check): "System updates should be installed on your machines" ], ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"System updates are applied for all the VMs in the subscription {subscription_name}." + report.status_extended = f"System updates are applied for all the VMs in the subscription {subscription_name} ({subscription_id})." if ( assessments[ @@ -42,7 +45,7 @@ class defender_ensure_system_updates_are_applied(Check): == "Unhealthy" ): report.status = "FAIL" - report.status_extended = f"System updates are not applied for all the VMs in the subscription {subscription_name}." + report.status_extended = f"System updates are not applied for all the VMs in the subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.py b/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.py index 47aa40a904..9b2049c9fb 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.py +++ b/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.py @@ -7,29 +7,30 @@ class defender_ensure_wdatp_is_enabled(Check): findings = [] for ( - subscription_name, + subscription_id, settings, ) in defender_client.settings.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) if "WDATP" not in settings: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{defender_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"Microsoft Defender for Endpoint integration not exists for subscription {subscription_name}." + report.status_extended = f"Microsoft Defender for Endpoint integration not exists for subscription {subscription_name} ({subscription_id})." else: report = Check_Report_Azure( metadata=self.metadata(), resource=settings["WDATP"] ) - report.subscription = subscription_name + report.subscription = subscription_id if settings["WDATP"].enabled: report.status = "PASS" - report.status_extended = f"Microsoft Defender for Endpoint integration is enabled for subscription {subscription_name}." + report.status_extended = f"Microsoft Defender for Endpoint integration is enabled for subscription {subscription_name} ({subscription_id})." else: report.status = "FAIL" - report.status_extended = f"Microsoft Defender for Endpoint integration is disabled for subscription {subscription_name}." + report.status_extended = f"Microsoft Defender for Endpoint integration is disabled for subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_service.py b/prowler/providers/azure/services/defender/defender_service.py index 089a8846d7..7da96cd8ec 100644 --- a/prowler/providers/azure/services/defender/defender_service.py +++ b/prowler/providers/azure/services/defender/defender_service.py @@ -30,14 +30,14 @@ class Defender(AzureService): def _get_pricings(self): logger.info("Defender - Getting pricings...") pricings = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: pricings_list = client.pricings.list( - scope_id=f"subscriptions/{self.subscriptions[subscription_name]}" + scope_id=f"subscriptions/{subscription_id}" ) - pricings.update({subscription_name: {}}) + pricings.update({subscription_id: {}}) for pricing in pricings_list.value: - pricings[subscription_name].update( + pricings[subscription_id].update( { pricing.name: Pricing( resource_id=pricing.id, @@ -60,23 +60,23 @@ class Defender(AzureService): except ResourceNotFoundError as error: if "Subscription Not Registered" in error.message: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: Subscription Not Registered - Please register to Microsoft.Security in order to view your security status" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: Subscription Not Registered - Please register to Microsoft.Security in order to view your security status" ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return pricings def _get_auto_provisioning_settings(self): logger.info("Defender - Getting auto provisioning settings...") auto_provisioning = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: auto_provisioning_settings = client.auto_provisioning_settings.list() - auto_provisioning.update({subscription_name: {}}) + auto_provisioning.update({subscription_id: {}}) for ap in auto_provisioning_settings: - auto_provisioning[subscription_name].update( + auto_provisioning[subscription_id].update( { ap.name: AutoProvisioningSetting( resource_id=ap.id, @@ -89,25 +89,25 @@ class Defender(AzureService): except ClientAuthenticationError as error: if "Subscription Not Registered" in error.message: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: Subscription Not Registered - Please register to Microsoft.Security in order to view your security status" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: Subscription Not Registered - Please register to Microsoft.Security in order to view your security status" ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return auto_provisioning def _get_assessments(self): logger.info("Defender - Getting assessments...") assessments = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: assessments_list = client.assessments.list( - f"subscriptions/{self.subscriptions[subscription_name]}" + f"subscriptions/{subscription_id}" ) - assessments.update({subscription_name: {}}) + assessments.update({subscription_id: {}}) for assessment in assessments_list: - assessments[subscription_name].update( + assessments[subscription_id].update( { assessment.display_name: Assesment( resource_id=assessment.id, @@ -120,19 +120,19 @@ class Defender(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return assessments def _get_settings(self): logger.info("Defender - Getting settings...") settings = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: settings_list = client.settings.list() - settings.update({subscription_name: {}}) + settings.update({subscription_id: {}}) for setting in settings_list: - settings[subscription_name].update( + settings[subscription_id].update( { setting.name: Setting( resource_id=setting.id, @@ -146,11 +146,11 @@ class Defender(AzureService): except ClientAuthenticationError as error: if "Subscription Not Registered" in error.message: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: Subscription Not Registered - Please register to Microsoft.Security in order to view your security status" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: Subscription Not Registered - Please register to Microsoft.Security in order to view your security status" ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return settings @@ -166,7 +166,7 @@ class Defender(AzureService): """ logger.info("Defender - Getting security contacts...") security_contacts = {} - for subscription_name, subscription_id in self.subscriptions.items(): + for subscription_id, display_name in self.subscriptions.items(): try: url = f"https://management.azure.com/subscriptions/{subscription_id}/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview" headers = { @@ -176,7 +176,7 @@ class Defender(AzureService): response = requests.get(url, headers=headers) response.raise_for_status() contact_configurations = response.json().get("value", []) - security_contacts[subscription_name] = {} + security_contacts[subscription_id] = {} for contact_configuration in contact_configurations: props = contact_configuration.get("properties", {}) @@ -204,7 +204,7 @@ class Defender(AzureService): if value is not None: alert_minimal_severity = value - security_contacts[subscription_name][ + security_contacts[subscription_id][ contact_configuration.get("name", "default") ] = SecurityContactConfiguration( id=contact_configuration.get("id", ""), @@ -221,21 +221,21 @@ class Defender(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return security_contacts def _get_iot_security_solutions(self): logger.info("Defender - Getting IoT Security Solutions...") iot_security_solutions = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: iot_security_solutions_list = ( client.iot_security_solution.list_by_subscription() ) - iot_security_solutions.update({subscription_name: {}}) + iot_security_solutions.update({subscription_id: {}}) for iot_security_solution in iot_security_solutions_list: - iot_security_solutions[subscription_name].update( + iot_security_solutions[subscription_id].update( { iot_security_solution.id: IoTSecuritySolution( resource_id=iot_security_solution.id, @@ -246,7 +246,7 @@ class Defender(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return iot_security_solutions @@ -257,22 +257,22 @@ class Defender(AzureService): Returns: A dictionary of JIT policies for each subscription. The format will be: { - "subscription_name": { + "subscription_id": { "jit_policy_id": JITPolicy } } """ logger.info("Defender - Getting JIT policies...") jit_policies = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: - jit_policies[subscription_name] = {} + jit_policies[subscription_id] = {} policies = client.jit_network_access_policies.list() for policy in policies: vm_ids = set() for vm in getattr(policy, "virtual_machines", []): vm_ids.add(vm.id) - jit_policies[subscription_name].update( + jit_policies[subscription_id].update( { policy.id: JITPolicy( id=policy.id, @@ -284,7 +284,7 @@ class Defender(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return jit_policies diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json new file mode 100644 index 0000000000..c786fd49af --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_app_registration_credential_not_expired", + "CheckTitle": "App registration credentials are not expired or expiring soon", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** app registrations are evaluated for **credential validity**. Each app's password secrets and certificate credentials are checked for expiration. Credentials that are already expired, expiring within 30 days, or have no expiration date are flagged.", + "Risk": "Expired credentials cause **service outages** when apps can no longer authenticate. Credentials without expiration violate **least privilege** by persisting indefinitely. Long-lived or leaked secrets enable **unauthorized API access**, **data exfiltration**, and **lateral movement** via the app's permissions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-service-principal-portal", + "https://learn.microsoft.com/en-us/graph/api/resources/application" + ], + "Remediation": { + "Code": { + "CLI": "az ad app credential reset --id --years 1", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Applications > App registrations\n3. Select the application with the expiring credential\n4. Go to Certificates & secrets\n5. Delete the expired credential\n6. Add a new credential with an appropriate expiration (recommended: 6-12 months)\n7. Update the consuming application with the new credential\n8. Consider migrating to managed identities or federated credentials where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Rotate app registration credentials before expiration. Set expiration to 6-12 months maximum. Prefer **managed identities** or **federated credentials** over secrets. Monitor credential expiry with Azure Monitor alerts or Microsoft Entra workbooks.", + "Url": "https://hub.prowler.com/check/entra_app_registration_credential_not_expired" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates both password secrets and certificate credentials. Each credential is reported individually. Apps with no credentials are skipped." +} diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py new file mode 100644 index 0000000000..5cbdf009a0 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +EXPIRY_WARNING_DAYS = 30 + + +class entra_app_registration_credential_not_expired(Check): + """ + Ensure Microsoft Entra ID app registration credentials are not expired or expiring soon. + + This check evaluates each app registration's password secrets and certificate credentials. A credential is reported individually and flagged when it is already expired, expiring within 30 days, or has no expiration date. Apps with no credentials are skipped. + + - PASS: The credential is valid for more than 30 days. + - FAIL: The credential is expired, expiring within 30 days, or has no expiration date. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, apps in entra_client.app_registrations.items(): + for app_id, app in apps.items(): + if not app.credentials: + continue + + for credential in app.credentials: + report = Check_Report_Azure(metadata=self.metadata(), resource=app) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = ( + f"{app.name} ({credential.credential_type}: " + f"{credential.display_name or 'unnamed'})" + ) + + if credential.end_date_time is None: + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential with no expiration date." + ) + else: + now = datetime.now(timezone.utc) + end = credential.end_date_time + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + if end <= now: + days_ago = (now - end).days + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential that expired {days_ago} days ago." + ) + elif (end - now).days <= EXPIRY_WARNING_DAYS: + days_left = (end - now).days + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential expiring in {days_left} days (within the " + f"{EXPIRY_WARNING_DAYS}-day rotation threshold); rotate it soon." + ) + else: + days_left = (end - now).days + report.status = "PASS" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential valid for {days_left} more days (beyond the " + f"{EXPIRY_WARNING_DAYS}-day rotation threshold)." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/__init__.py b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json new file mode 100644 index 0000000000..11dfa6e85b --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_authentication_methods_policy_strong_auth_enforced", + "CheckTitle": "Strong authentication methods are enabled with registration enforcement", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** authentication methods policy is evaluated for **strong authentication enforcement**. The check verifies that at least one strong method (Microsoft Authenticator, FIDO2, or X.509 Certificate) is enabled and that the MFA registration campaign is active to prompt users to enroll.", + "Risk": "Without strong authentication methods, the tenant relies on **passwords alone** or weak factors like SMS/voice. Password-only authentication enables **credential stuffing**, **phishing**, and **brute force** attacks. Without registration enforcement, users may never enroll in MFA even when it is available.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage", + "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-nudge-authenticator-app" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Protection > Authentication methods > Policies\n3. Enable Microsoft Authenticator and/or FIDO2 security keys\n4. Go to Protection > Authentication methods > Registration campaign\n5. Set State to Enabled\n6. Configure included users/groups\n7. Consider disabling weak methods (SMS, Voice) after users migrate", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Microsoft Authenticator** and **FIDO2 security keys** as authentication methods. Activate the **MFA registration campaign** to prompt users to register. Disable weak methods (SMS, voice) after migration. Use **Conditional Access** to require strong authentication for all users.", + "Url": "https://hub.prowler.com/check/entra_authentication_methods_policy_strong_auth_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check reports a single finding per tenant and passes only when both the MFA registration campaign is enabled and at least one strong method is enabled. Microsoft recommends phishing-resistant methods (FIDO2, certificate-based) over app-based push notifications." +} diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py new file mode 100644 index 0000000000..efdbcfa6a0 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py @@ -0,0 +1,63 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +# Methods considered strong (phishing-resistant or app-based MFA) +STRONG_METHODS = {"microsoftAuthenticator", "fido2", "x509Certificate"} + + +class entra_authentication_methods_policy_strong_auth_enforced(Check): + """ + Ensure the Entra ID authentication methods policy enforces strong authentication. + + This check evaluates the tenant authentication methods policy and reports a single finding per tenant. Strong authentication is considered enforced only when BOTH conditions hold: + 1. The MFA registration campaign is enabled (users are prompted to register methods). + 2. At least one strong, phishing-resistant or app-based method (Microsoft Authenticator, FIDO2, or X.509 certificate) is enabled. + + - PASS: Both conditions hold. + - FAIL: One or both conditions are missing; the status extended names exactly what is missing. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, policy in entra_client.authentication_methods_policy.items(): + if policy is None: + continue + + report = Check_Report_Azure(metadata=self.metadata(), resource=policy) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = "Authentication Methods Policy" + report.resource_id = policy.id + + registration_enabled = policy.registration_enforcement_state == "enabled" + enabled_strong = [ + config.method_name + for config in policy.method_configurations + if config.state == "enabled" and config.method_name in STRONG_METHODS + ] + + if registration_enabled and enabled_strong: + report.status = "PASS" + report.status_extended = ( + f"Strong authentication is enforced for tenant {tenant_domain}: " + f"the MFA registration campaign is enabled and strong methods are " + f"enabled ({', '.join(enabled_strong)})." + ) + else: + issues = [] + if not registration_enabled: + issues.append("the MFA registration campaign is not enabled") + if not enabled_strong: + issues.append( + "no strong authentication methods (Microsoft Authenticator, " + "FIDO2, or X.509 Certificate) are enabled" + ) + report.status = "FAIL" + report.status_extended = ( + f"Strong authentication is not enforced for tenant " + f"{tenant_domain}: {'; '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index eb1d62ac11..e23f3cd34a 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -1,5 +1,6 @@ import asyncio from asyncio import gather +from datetime import datetime from typing import List, Optional from uuid import UUID @@ -49,6 +50,8 @@ class Entra(AzureService): self._get_named_locations(), self._get_directory_roles(), self._get_conditional_access_policy(), + self._get_app_registrations(), + self._get_authentication_methods_policy(), ) ) @@ -58,6 +61,8 @@ class Entra(AzureService): self.named_locations = attributes[3] self.directory_roles = attributes[4] self.conditional_access_policy = attributes[5] + self.app_registrations = attributes[6] + self.authentication_methods_policy = attributes[7] if created_loop: asyncio.set_event_loop(None) @@ -69,7 +74,12 @@ class Entra(AzureService): try: request_configuration = RequestConfiguration( query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( - select=["id", "displayName", "accountEnabled"] + select=[ + "id", + "displayName", + "accountEnabled", + "signInActivity", + ] ) ) for tenant, client in self.clients.items(): @@ -82,6 +92,16 @@ class Entra(AzureService): try: while users_response: for user in getattr(users_response, "value", []) or []: + sign_in_activity = getattr(user, "sign_in_activity", None) + last_sign_in = ( + getattr( + sign_in_activity, + "last_sign_in_date_time", + None, + ) + if sign_in_activity + else None + ) users[tenant].update( { user.id: User( @@ -93,6 +113,7 @@ class Entra(AzureService): account_enabled=getattr( user, "account_enabled", True ), + last_sign_in=last_sign_in, ) } ) @@ -416,12 +437,142 @@ class Entra(AzureService): return conditional_access_policy + async def _get_app_registrations(self): + logger.info("Entra - Getting app registrations...") + app_registrations = {} + try: + for tenant, client in self.clients.items(): + app_registrations[tenant] = {} + apps_response = await client.applications.get() + + try: + while apps_response: + for app in getattr(apps_response, "value", []) or []: + credentials = [] + for cred in getattr(app, "password_credentials", []) or []: + credentials.append( + AppCredential( + display_name=getattr(cred, "display_name", "") + or "", + credential_type="password", + end_date_time=getattr( + cred, "end_date_time", None + ), + ) + ) + for cred in getattr(app, "key_credentials", []) or []: + credentials.append( + AppCredential( + display_name=getattr(cred, "display_name", "") + or "", + credential_type="certificate", + end_date_time=getattr( + cred, "end_date_time", None + ), + ) + ) + app_registrations[tenant][app.id] = AppRegistration( + id=app.id, + name=getattr(app, "display_name", "") or "", + credentials=credentials, + ) + + next_link = getattr(apps_response, "odata_next_link", None) + if not next_link: + break + apps_response = await client.applications.with_url( + next_link + ).get() + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return app_registrations + + async def _get_authentication_methods_policy(self): + logger.info("Entra - Getting authentication methods policy...") + auth_methods_policy = {} + try: + for tenant, client in self.clients.items(): + policy_response = ( + await client.policies.authentication_methods_policy.get() + ) + + if not policy_response: + auth_methods_policy[tenant] = None + continue + + # Parse registration enforcement + reg_enforcement = getattr( + policy_response, "registration_enforcement", None + ) + reg_campaign = ( + getattr( + reg_enforcement, + "authentication_methods_registration_campaign", + None, + ) + if reg_enforcement + else None + ) + registration_enforcement_state = ( + getattr(reg_campaign, "state", None) if reg_campaign else None + ) + + # Parse authentication method configurations + method_configs = [] + for config in ( + getattr( + policy_response, + "authentication_method_configurations", + [], + ) + or [] + ): + odata_type = getattr(config, "odata_type", "") or "" + # Extract method name from odata_type + # e.g. "#microsoft.graph.microsoftAuthenticatorAuthenticationMethodConfiguration" + method_name = ( + odata_type.split(".")[-1].replace( + "AuthenticationMethodConfiguration", "" + ) + if odata_type + else getattr(config, "id", "unknown") + ) + method_configs.append( + AuthMethodConfig( + id=getattr(config, "id", "") or "", + method_name=method_name, + state=getattr(config, "state", "disabled") or "disabled", + ) + ) + + auth_methods_policy[tenant] = AuthMethodsPolicy( + id=getattr(policy_response, "id", "") or "", + registration_enforcement_state=registration_enforcement_state, + method_configurations=method_configs, + ) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return auth_methods_policy + class User(BaseModel): id: str name: str is_mfa_capable: bool = False account_enabled: bool = True + last_sign_in: Optional[datetime] = None class DefaultUserRolePermissions(BaseModel): @@ -481,3 +632,27 @@ class ConditionalAccessPolicy(BaseModel): users: dict[str, List[str]] target_resources: dict[str, List[str]] access_controls: dict[str, List[str]] + + +class AppCredential(BaseModel): + display_name: str = "" + credential_type: str # "password" or "certificate" + end_date_time: Optional[datetime] = None + + +class AppRegistration(BaseModel): + id: str + name: str + credentials: List[AppCredential] = [] + + +class AuthMethodConfig(BaseModel): + id: str = "" + method_name: str + state: str = "disabled" # "enabled" or "disabled" + + +class AuthMethodsPolicy(BaseModel): + id: str = "" + registration_enforcement_state: Optional[str] = None # "enabled" or "disabled" + method_configurations: List[AuthMethodConfig] = [] diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/__init__.py b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json new file mode 100644 index 0000000000..64ccb383a1 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_user_with_recent_sign_in", + "CheckTitle": "Enabled user has signed in within the last 90 days", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** enabled user accounts are evaluated for **recent sign-in activity**. Accounts that have not signed in for more than 90 days are flagged as stale. Stale accounts indicate orphaned identities that may have been abandoned after personnel changes, project completions, or role transitions without proper deprovisioning.", + "Risk": "Stale accounts retain **role assignments** and **group memberships**. Attackers target dormant accounts via **credential stuffing** and **password spraying** because owners are unlikely to notice anomalous activity. Compromise enables **lateral movement**, **data exfiltration**, and **persistence** while evading detection tuned to active users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/signinactivity", + "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-sign-ins" + ], + "Remediation": { + "Code": { + "CLI": "az rest --method patch --url https://graph.microsoft.com/v1.0/users/ --body '{\"accountEnabled\":false}'", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Users > All users\n3. Add filter: Sign-in activity > Last interactive sign-in date is before <90 days ago>\n4. Review each stale account with the account owner or manager\n5. Disable accounts confirmed as no longer needed\n6. After a grace period, delete disabled accounts\n7. Establish a recurring access review to automate this process", + "Terraform": "" + }, + "Recommendation": { + "Text": "Implement **automated access reviews** in Entra ID to periodically review and remove stale accounts. Configure reviews to run quarterly, targeting all users or specific groups. Set auto-apply to disable accounts that are not confirmed by reviewers. For immediate remediation, filter users by last sign-in date and disable accounts inactive for more than 90 days after confirming with managers.", + "Url": "https://hub.prowler.com/check/entra_user_with_recent_sign_in" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The signInActivity resource requires Microsoft Entra ID P1 or P2 license. Tenants without this license will not have sign-in activity data available, and all users will be reported as never having signed in." +} diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py new file mode 100644 index 0000000000..fa6d1af05d --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py @@ -0,0 +1,77 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +STALE_THRESHOLD_DAYS = 90 + + +class entra_user_with_recent_sign_in(Check): + """ + Ensure enabled Entra ID users have signed in within the last 90 days. + + This check evaluates each enabled user's last interactive sign-in to detect stale or dormant accounts that should be reviewed or deprovisioned. Sign-in activity requires Entra ID P1/P2 licensing. + + - PASS: The enabled user signed in within the last 90 days. + - FAIL: The enabled user has not signed in for more than 90 days, or has never signed in. + - FAIL (tenant-level): No sign-in activity data is available for any enabled user, indicating missing P1/P2 licensing or Graph permissions (reported once instead of flagging every user). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, users in entra_client.users.items(): + enabled_users = {k: v for k, v in users.items() if v.account_enabled} + + if not enabled_users: + continue + + # If all enabled users are missing sign-in data, avoid claiming + # they never signed in. This usually indicates missing telemetry, + # often due to licensing or Graph permission limitations. + all_null = all(u.last_sign_in is None for u in enabled_users.values()) + if all_null: + first_user = next(iter(enabled_users.values())) + report = Check_Report_Azure( + metadata=self.metadata(), resource=first_user + ) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = "Sign-in Activity Data" + count = len(enabled_users) + noun = "user" if count == 1 else "users" + report.status = "FAIL" + report.status_extended = ( + f"No sign-in activity data available for any of the " + f"{count} enabled {noun}. This likely means the tenant " + f"is missing Entra ID P1/P2 licensing or the required " + f"Graph permissions to read sign-in activity." + ) + findings.append(report) + continue + + for user_domain_name, user in enabled_users.items(): + report = Check_Report_Azure(metadata=self.metadata(), resource=user) + report.subscription = f"Tenant: {tenant_domain}" + + if user.last_sign_in is None: + report.status = "FAIL" + report.status_extended = f"User {user.name} has never signed in." + else: + last = user.last_sign_in + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + days_since = (datetime.now(timezone.utc) - last).days + if days_since > STALE_THRESHOLD_DAYS: + report.status = "FAIL" + report.status_extended = ( + f"User {user.name} has not signed in for {days_since} days." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"User {user.name} signed in {days_since} days ago." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py index eec21c474a..917200864e 100644 --- a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py @@ -20,10 +20,13 @@ class entra_user_with_vm_access_has_mfa(Check): for users in entra_client.users.values(): for user in users.values(): for ( - subscription_name, + subscription_id, role_assigns, ) in iam_client.role_assignments.items(): - if (user.id, subscription_name) in already_reported: + subscription_name = entra_client.subscriptions.get( + subscription_id, subscription_id + ) + if (user.id, subscription_id) in already_reported: continue for assignment in role_assigns.values(): @@ -44,15 +47,15 @@ class entra_user_with_vm_access_has_mfa(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=user ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"User {user.name} without MFA can access VMs in subscription {subscription_name}" + report.status_extended = f"User {user.name} without MFA can access VMs in subscription {subscription_name} ({subscription_id})" if user.is_mfa_capable: report.status = "PASS" - report.status_extended = f"User {user.name} can access VMs in subscription {subscription_name} but it has MFA." + report.status_extended = f"User {user.name} can access VMs in subscription {subscription_name} ({subscription_id}) but it has MFA." findings.append(report) - already_reported.add((user.id, subscription_name)) + already_reported.add((user.id, subscription_id)) break return findings diff --git a/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py b/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py index c6c16326a3..ee0604dd34 100644 --- a/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py +++ b/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py @@ -8,6 +8,7 @@ class iam_custom_role_has_permissions_to_administer_resource_locks(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, roles in iam_client.custom_roles.items(): + subscription_name = iam_client.subscriptions.get(subscription, subscription) exits_role_with_permission_over_locks = False for custom_role in roles.values(): @@ -18,7 +19,7 @@ class iam_custom_role_has_permissions_to_administer_resource_locks(Check): ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Role {custom_role.name} from subscription {subscription} has no permission to administer resource locks." + report.status_extended = f"Role {custom_role.name} from subscription {subscription_name} ({subscription}) has no permission to administer resource locks." for permission_item in custom_role.permissions: if exits_role_with_permission_over_locks: @@ -26,7 +27,7 @@ class iam_custom_role_has_permissions_to_administer_resource_locks(Check): for action in permission_item.actions: if search("^Microsoft.Authorization/locks/.*", action): report.status = "PASS" - report.status_extended = f"Role {custom_role.name} from subscription {subscription} has permission to administer resource locks." + report.status_extended = f"Role {custom_role.name} from subscription {subscription_name} ({subscription}) has permission to administer resource locks." exits_role_with_permission_over_locks = True break findings.append(report) diff --git a/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.py b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.py index 4880880cb0..409d7292ad 100644 --- a/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.py +++ b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.py @@ -6,11 +6,14 @@ class iam_role_user_access_admin_restricted(Check): def execute(self): findings = [] - for subscription_name, assignments in iam_client.role_assignments.items(): + for subscription_id, assignments in iam_client.role_assignments.items(): + subscription_name = iam_client.subscriptions.get( + subscription_id, subscription_id + ) for assignment in assignments.values(): role_assignment_name = getattr( - iam_client.roles[subscription_name].get( - f"/subscriptions/{iam_client.subscriptions[subscription_name]}/providers/Microsoft.Authorization/roleDefinitions/{assignment.role_id}" + iam_client.roles[subscription_id].get( + f"/subscriptions/{subscription_id}/providers/Microsoft.Authorization/roleDefinitions/{assignment.role_id}" ), "name", "", @@ -18,12 +21,12 @@ class iam_role_user_access_admin_restricted(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=assignment ) - report.subscription = subscription_name + report.subscription = subscription_id if role_assignment_name == "User Access Administrator": report.status = "FAIL" - report.status_extended = f"Role assignment {assignment.name} in subscription {subscription_name} grants User Access Administrator role to {getattr(assignment, 'agent_type', '')} {getattr(assignment, 'agent_id', '')}." + report.status_extended = f"Role assignment {assignment.name} in subscription {subscription_name} ({subscription_id}) grants User Access Administrator role to {getattr(assignment, 'agent_type', '')} {getattr(assignment, 'agent_id', '')}." else: report.status = "PASS" - report.status_extended = f"Role assignment {assignment.name} in subscription {subscription_name} does not grant User Access Administrator role." + report.status_extended = f"Role assignment {assignment.name} in subscription {subscription_name} ({subscription_id}) does not grant User Access Administrator role." findings.append(report) return findings diff --git a/prowler/providers/azure/services/iam/iam_service.py b/prowler/providers/azure/services/iam/iam_service.py index 55f1eb7e71..6a9ff814e9 100644 --- a/prowler/providers/azure/services/iam/iam_service.py +++ b/prowler/providers/azure/services/iam/iam_service.py @@ -23,7 +23,7 @@ class IAM(AzureService): builtin_roles.update({subscription: {}}) custom_roles.update({subscription: {}}) all_roles = client.role_definitions.list( - scope=f"/subscriptions/{self.subscriptions[subscription]}", + scope=f"/subscriptions/{subscription}", ) for role in all_roles: if role.role_type == "CustomRole": @@ -53,7 +53,7 @@ class IAM(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return builtin_roles, custom_roles @@ -83,7 +83,7 @@ class IAM(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return role_assignments diff --git a/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py b/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py index 8580a3aab7..abee3905b8 100644 --- a/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py +++ b/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py @@ -8,20 +8,21 @@ class iam_subscription_roles_owner_custom_not_created(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, roles in iam_client.custom_roles.items(): + subscription_name = iam_client.subscriptions.get(subscription, subscription) for custom_role in roles.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=custom_role ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Role {custom_role.name} from subscription {subscription} is not a custom owner role." + report.status_extended = f"Role {custom_role.name} from subscription {subscription_name} ({subscription}) is not a custom owner role." for scope in custom_role.assignable_scopes: if search("^/.*", scope): for permission_item in custom_role.permissions: for action in permission_item.actions: if action == "*": report.status = "FAIL" - report.status_extended = f"Role {custom_role.name} from subscription {subscription} is a custom owner role." + report.status_extended = f"Role {custom_role.name} from subscription {subscription_name} ({subscription}) is a custom owner role." break findings.append(report) diff --git a/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.py b/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.py index 1a363f2d61..0eafd35de5 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.py +++ b/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.py @@ -17,6 +17,9 @@ class keyvault_access_only_through_private_endpoints(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: if ( keyvault.properties @@ -29,9 +32,9 @@ class keyvault_access_only_through_private_endpoints(Check): if keyvault.properties.public_network_access_disabled: report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has public network access disabled and is using private endpoints." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) has public network access disabled and is using private endpoints." else: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has public network access enabled while using private endpoints." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) has public network access enabled while using private endpoints." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json index a82171835f..fbb45216fb 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_key_expiration_set_in_non_rbac", - "CheckTitle": "Key Vault without RBAC authorization has expiration date set for all enabled keys", + "CheckTitle": "Key in non-RBAC Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/keys", "ResourceGroup": "security", - "Description": "**Azure Key Vaults** using access **policies (non-RBAC)** are assessed to confirm all **enabled keys** have an `expiration` (`exp`) defined. The finding highlights keys in these vaults that lack a set lifetime.", + "Description": "Each **enabled key** in an **Azure Key Vault** using **access policies (non-RBAC)** is assessed to confirm it has an `expiration` (`exp`) attribute defined.", "Risk": "Non-expiring keys enable indefinite use, degrading **confidentiality** and **integrity**. Stale or compromised keys can decrypt data, forge signatures, and maintain persistence. Absent lifetimes weaken rotation discipline and impede timely revocation, increasing exposure to cryptographic and operational drift.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py index debca1ac34..fd4b4074ff 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py +++ b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py @@ -6,21 +6,23 @@ class keyvault_key_expiration_set_in_non_rbac(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: - if not keyvault.properties.enable_rbac_authorization and keyvault.keys: - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) - report.subscription = subscription - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has all the keys with expiration date set." - has_key_without_expiration = False - for key in keyvault.keys: - if not key.attributes.expires and key.enabled: + if not keyvault.properties.enable_rbac_authorization: + for key in keyvault.keys or []: + if not key.enabled: + continue + report = Check_Report_Azure( + metadata=self.metadata(), resource=key + ) + report.subscription = subscription + if not key.attributes.expires: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} without expiration date set." - has_key_without_expiration = True - findings.append(report) - if not has_key_without_expiration: + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) does not have an expiration date set." + else: + report.status = "PASS" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) has an expiration date set." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json index 45c459c948..b42c6d82b7 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/keys", "ResourceGroup": "security", - "Description": "**Azure Key Vault** keys configured with a **rotation policy** that includes a `Rotate` lifetime action.\n\nThe evaluation looks for lifetime actions that schedule automatic key version creation; keys without this policy are not configured for auto-rotation.", + "Description": "Each **Azure Key Vault** key is assessed for a **rotation policy** that includes a `Rotate` lifetime action scheduling automatic key version creation.", "Risk": "Without **auto-rotation**, keys may outlive policy, increasing exposure if material is leaked and weakening **confidentiality**.\n\nExpired keys without planned rollover can break decrypt/unwrap operations, impacting **availability**. Long-lived keys hinder incident response and enable prolonged misuse of stale versions.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py index 088a02d827..8a587a41ed 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py +++ b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py @@ -6,24 +6,25 @@ class keyvault_key_rotation_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: - if keyvault.keys: - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) + for key in keyvault.keys or []: + report = Check_Report_Azure(metadata=self.metadata(), resource=key) report.subscription = subscription - for key in keyvault.keys: - if ( - key.rotation_policy - and key.rotation_policy.lifetime_actions - and key.rotation_policy.lifetime_actions[0].action - == "Rotate" - ): - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} with rotation policy set." - else: - report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} without rotation policy set." - - findings.append(report) + if ( + key.rotation_policy + and key.rotation_policy.lifetime_actions + and any( + action.action == "Rotate" + for action in key.rotation_policy.lifetime_actions + ) + ): + report.status = "PASS" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) has a rotation policy set." + else: + report.status = "FAIL" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) does not have a rotation policy set." + findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json index b093492c7e..241c95ec7f 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.metadata.json @@ -1,7 +1,7 @@ { "Provider": "azure", "CheckID": "keyvault_logging_enabled", - "CheckTitle": "Key Vault has a diagnostic setting capturing audit logs", + "CheckTitle": "Key Vault has at least one diagnostic setting with audit logging enabled", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "microsoft.keyvault/vaults", "ResourceGroup": "security", - "Description": "**Azure Key Vault** diagnostic settings capture **audit logs** (`AuditEvent`) when category groups `audit` and `allLogs` are enabled and routed to a supported destination. Logged events include management and data-plane operations on vaults, keys, secrets, and certificates.", + "Description": "**Azure Key Vault** diagnostic settings capture **audit logs** (`AuditEvent`) when the `AuditEvent` category is enabled, or when category groups `audit` and `allLogs` are enabled, and routed to a supported destination. Logged events include management and data-plane operations on vaults, keys, secrets, and certificates.", "Risk": "Without **Key Vault audit logging**, access and changes to keys, secrets, and certificates are untracked.\n\nAttackers can misuse keys to decrypt data, alter or delete crypto material, and evade detection-eroding **confidentiality** and **integrity** and delaying **incident response**.", "RelatedUrl": "", "AdditionalURLs": [ @@ -20,13 +20,13 @@ ], "Remediation": { "Code": { - "CLI": "az monitor diagnostic-settings create --name --resource --workspace --logs '[{\"categoryGroup\":\"audit\",\"enabled\":true},{\"categoryGroup\":\"allLogs\",\"enabled\":true}]'", - "NativeIaC": "```bicep\n// Enable Key Vault diagnostic settings with audit + allLogs\nparam keyVaultName string\nparam workspaceId string\n\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {\n name: keyVaultName\n}\n\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n scope: kv\n properties: {\n workspaceId: workspaceId\n logs: [\n {\n categoryGroup: 'audit' // critical: enables audit logs\n enabled: true // required to pass the check\n }\n {\n categoryGroup: 'allLogs' // critical: enables allLogs group\n enabled: true // required to pass the check\n }\n ]\n }\n}\n```", - "Other": "1. In Azure Portal, go to your Key Vault > Monitoring > Diagnostic settings\n2. Click Add diagnostic setting\n3. Under Category groups, select audit and allLogs\n4. Choose a destination (e.g., Send to Log Analytics workspace) and select the workspace\n5. Click Save", - "Terraform": "```hcl\n# Enable diagnostic settings on Key Vault with audit + allLogs\nresource \"azurerm_monitor_diagnostic_setting\" \"\" {\n name = \"\"\n target_resource_id = \"\" # Key Vault resource ID\n log_analytics_workspace_id = \"\" # Destination workspace ID\n\n enabled_log { # critical: audit category group\n category_group = \"audit\" # enables audit logs\n }\n enabled_log { # critical: allLogs category group\n category_group = \"allLogs\" # enables all logs\n }\n}\n```" + "CLI": "az monitor diagnostic-settings create --name --resource --workspace --logs '[{\"category\":\"AuditEvent\",\"enabled\":true}]'", + "NativeIaC": "```bicep\n// Enable Key Vault AuditEvent diagnostic logs\nparam keyVaultName string\nparam workspaceId string\n\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {\n name: keyVaultName\n}\n\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n scope: kv\n properties: {\n workspaceId: workspaceId\n logs: [\n {\n category: 'AuditEvent'\n enabled: true\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, go to your Key Vault > Monitoring > Diagnostic settings\n2. Click Add diagnostic setting\n3. Enable AuditEvent audit logs, or select the audit and allLogs category groups\n4. Choose a destination (e.g., Send to Log Analytics workspace) and select the workspace\n5. Click Save", + "Terraform": "```hcl\n# Enable AuditEvent diagnostic logs on Key Vault\nresource \"azurerm_monitor_diagnostic_setting\" \"\" {\n name = \"\"\n target_resource_id = \"\"\n log_analytics_workspace_id = \"\"\n\n enabled_log {\n category = \"AuditEvent\"\n }\n}\n```" }, "Recommendation": { - "Text": "Enable **diagnostic settings** to collect `AuditEvent` logs-covering category groups `audit` and `allLogs`-and send them to a central sink. Apply **least privilege** to log access, enforce secure **retention/immutability**, monitor with alerts for anomalous operations, and use **separation of duties** to prevent logging bypass.", + "Text": "Enable **diagnostic settings** to collect `AuditEvent` logs and send them to a central sink. Apply **least privilege** to log access, enforce secure **retention/immutability**, monitor with alerts for anomalous operations, and use **separation of duties** to prevent logging bypass.", "Url": "https://hub.prowler.com/check/keyvault_logging_enabled" } }, diff --git a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py index 2aff55c77d..ea80b920c5 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py +++ b/prowler/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled.py @@ -6,33 +6,30 @@ class keyvault_logging_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, key_vaults in keyvault_client.key_vaults.items(): + for subscription_id, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription_id, subscription_id + ) for keyvault in key_vaults: report = Check_Report_Azure(metadata=self.metadata(), resource=keyvault) - report.subscription = subscription_name - if not keyvault.monitor_diagnostic_settings: - report.status = "FAIL" - report.status_extended = f"There are no diagnostic settings capturing audit logs for Key Vault {keyvault.name} in subscription {subscription_name}." - findings.append(report) - else: - for diagnostic_setting in keyvault.monitor_diagnostic_settings: - report.resource_name = diagnostic_setting.name - report.resource_id = diagnostic_setting.id - report.location = keyvault.location - report.status = "FAIL" - report.status_extended = f"Diagnostic setting {diagnostic_setting.name} for Key Vault {keyvault.name} in subscription {subscription_name} does not have audit logging." - audit = False - allLogs = False - for log in diagnostic_setting.logs: - if log.category_group == "audit" and log.enabled: - audit = True - if log.category_group == "allLogs" and log.enabled: - allLogs = True - if audit and allLogs: - report.status = "PASS" - report.status_extended = f"Diagnostic setting {diagnostic_setting.name} for Key Vault {keyvault.name} in subscription {subscription_name} has audit logging." - break - - findings.append(report) + report.subscription = subscription_id + report.status = "FAIL" + report.status_extended = f"Key Vault {keyvault.name} in subscription {subscription_name} ({subscription_id}) does not have a diagnostic setting with audit logging." + for diagnostic_setting in keyvault.monitor_diagnostic_settings or []: + has_audit_category = False + has_audit_group = False + has_all_logs = False + for log in diagnostic_setting.logs: + if log.category == "AuditEvent" and log.enabled: + has_audit_category = True + if log.category_group == "audit" and log.enabled: + has_audit_group = True + if log.category_group == "allLogs" and log.enabled: + has_all_logs = True + if has_audit_category or (has_audit_group and has_all_logs): + report.status = "PASS" + report.status_extended = f"Key Vault {keyvault.name} in subscription {subscription_name} ({subscription_id}) has a diagnostic setting with audit logging." + break + findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json index edfb367858..38ca08f08d 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_non_rbac_secret_expiration_set", - "CheckTitle": "Non-RBAC Key Vault has expiration date set for all secrets", + "CheckTitle": "Secret in non-RBAC Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/secrets", "ResourceGroup": "security", - "Description": "**Azure Key Vault (non-RBAC)** secrets are expected to have an **explicit expiration date**.\n\nThis examines each **enabled secret** to confirm the `expires` attribute is defined.", + "Description": "Each **enabled secret** in an **Azure Key Vault (non-RBAC)** is assessed to confirm it has an **explicit expiration date** (`expires` attribute) defined.", "Risk": "Secrets without expiration persist indefinitely, widening the window for misuse.\n\nIf leaked or forgotten, they allow long-term, covert access to services and data, undermining **confidentiality** and **integrity**, and complicating incident response and revocation.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py index 9afe72fb21..a0d83eeae6 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py +++ b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py @@ -6,24 +6,23 @@ class keyvault_non_rbac_secret_expiration_set(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: - if ( - not keyvault.properties.enable_rbac_authorization - and keyvault.secrets - ): - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) - report.subscription = subscription - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has all the secrets with expiration date set." - has_secret_without_expiration = False - for secret in keyvault.secrets: - if not secret.attributes.expires and secret.enabled: + if not keyvault.properties.enable_rbac_authorization: + for secret in keyvault.secrets or []: + if not secret.enabled: + continue + report = Check_Report_Azure( + metadata=self.metadata(), resource=secret + ) + report.subscription = subscription + if not secret.attributes.expires: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the secret {secret.name} without expiration date set." - has_secret_without_expiration = True - findings.append(report) - if not has_secret_without_expiration: + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) does not have an expiration date set." + else: + report.status = "PASS" + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) has an expiration date set." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints.py b/prowler/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints.py index 84c6b17e57..9af1b8ba2d 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints.py +++ b/prowler/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints.py @@ -6,16 +6,19 @@ class keyvault_private_endpoints(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: report = Check_Report_Azure(metadata=self.metadata(), resource=keyvault) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} is not using private endpoints." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) is not using private endpoints." if ( keyvault.properties and keyvault.properties.private_endpoint_connections ): report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} is using private endpoints." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) is using private endpoints." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled.py b/prowler/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled.py index e26c5b84f7..1025cf5118 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled.py +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled.py @@ -6,16 +6,19 @@ class keyvault_rbac_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: report = Check_Report_Azure(metadata=self.metadata(), resource=keyvault) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} is not using RBAC for access control." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) is not using RBAC for access control." if ( keyvault.properties and keyvault.properties.enable_rbac_authorization ): report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} is using RBAC for access control." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) is using RBAC for access control." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json index 531840721b..8b6c1d336f 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_rbac_key_expiration_set", - "CheckTitle": "RBAC-enabled Key Vault has expiration date set for all keys", + "CheckTitle": "Key in RBAC-enabled Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/keys", "ResourceGroup": "security", - "Description": "**Azure Key Vaults** with **RBAC-enabled access control** are evaluated to confirm every **enabled key** defines an **expiration** (`exp`). Any key lacking this attribute is identified.", + "Description": "Each **enabled key** in an **Azure Key Vault** with **RBAC-enabled access control** is assessed to confirm it has an **expiration** (`exp`) attribute defined.", "Risk": "**Keys without expiration** can remain active indefinitely.\nIf exposed, attackers can decrypt data, forge signatures (code/tokens), and maintain persistence, undermining **confidentiality** and **integrity**. Absent end-of-life also weakens rotation discipline and crypto agility.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py index 1ca90c445b..edc3416343 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py @@ -6,21 +6,23 @@ class keyvault_rbac_key_expiration_set(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: - if keyvault.properties.enable_rbac_authorization and keyvault.keys: - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) - report.subscription = subscription - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has all the keys with expiration date set." - has_key_without_expiration = False - for key in keyvault.keys: - if not key.attributes.expires and key.enabled: + if keyvault.properties.enable_rbac_authorization: + for key in keyvault.keys or []: + if not key.enabled: + continue + report = Check_Report_Azure( + metadata=self.metadata(), resource=key + ) + report.subscription = subscription + if not key.attributes.expires: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} without expiration date set." - has_key_without_expiration = True - findings.append(report) - if not has_key_without_expiration: + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) does not have an expiration date set." + else: + report.status = "PASS" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) has an expiration date set." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json index 681668cb63..2cab692a98 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_rbac_secret_expiration_set", - "CheckTitle": "RBAC-enabled Key Vault has expiration date set for all enabled secrets", + "CheckTitle": "Secret in RBAC-enabled Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/secrets", "ResourceGroup": "security", - "Description": "**Azure Key Vault (RBAC)** secrets are assessed to confirm every **enabled secret** has an `exp` (expiration) date configured", + "Description": "Each **enabled secret** in an **Azure Key Vault (RBAC)** is assessed to confirm it has an `exp` (expiration) date configured.", "Risk": "Without an **expiration**, secrets become perpetual credentials. Leaked or abandoned values can grant persistent access, undermining **confidentiality** and **integrity**. Attackers can reuse old secrets to maintain footholds, perform unauthorized API calls, and exfiltrate data.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py index cd5ec567aa..ed9ee564b4 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py @@ -5,21 +5,24 @@ from prowler.providers.azure.services.keyvault.keyvault_client import keyvault_c class keyvault_rbac_secret_expiration_set(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: - if keyvault.properties.enable_rbac_authorization and keyvault.secrets: - for secret in keyvault.secrets: + if keyvault.properties.enable_rbac_authorization: + for secret in keyvault.secrets or []: + if not secret.enabled: + continue report = Check_Report_Azure( metadata=self.metadata(), resource=secret ) report.subscription = subscription - if not secret.attributes.expires and secret.enabled: + if not secret.attributes.expires: report.status = "FAIL" - report.status_extended = f"Secret '{secret.name}' in KeyVault '{keyvault.name}' does not have expiration date set." + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) does not have an expiration date set." else: report.status = "PASS" - report.status_extended = f"Secret '{secret.name}' in KeyVault '{keyvault.name}' has expiration date set." + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription_name} ({subscription}) has an expiration date set." findings.append(report) - return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable.py b/prowler/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable.py index 3ffe1f5b93..ee3da35496 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable.py +++ b/prowler/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable.py @@ -6,16 +6,19 @@ class keyvault_recoverable(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): + subscription_name = keyvault_client.subscriptions.get( + subscription, subscription + ) for keyvault in key_vaults: report = Check_Report_Azure(metadata=self.metadata(), resource=keyvault) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} is not recoverable." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) is not recoverable." if ( keyvault.properties.enable_soft_delete and keyvault.properties.enable_purge_protection ): report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} is recoverable." + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription_name} ({subscription}) is recoverable." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_service.py b/prowler/providers/azure/services/keyvault/keyvault_service.py index 8f8a0cc452..9fb3fd98af 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_service.py +++ b/prowler/providers/azure/services/keyvault/keyvault_service.py @@ -56,7 +56,7 @@ class KeyVault(AzureService): except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return key_vaults @@ -172,7 +172,7 @@ class KeyVault(AzureService): except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) try: @@ -204,7 +204,7 @@ class KeyVault(AzureService): # TODO: handle different errors here since we are catching all HTTP Errors here except HttpResponseError: logger.warning( - f"Subscription name: {subscription} -- has no access policy configured for keyvault {keyvault_name}" + f"Subscription ID: {subscription} -- has no access policy configured for keyvault {keyvault_name}" ) return keys @@ -256,7 +256,7 @@ class KeyVault(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return secrets @@ -268,13 +268,13 @@ class KeyVault(AzureService): monitor_diagnostics_settings = [] try: monitor_diagnostics_settings = monitor_client.diagnostic_settings_with_uri( - self.subscriptions[subscription], - f"subscriptions/{self.subscriptions[subscription]}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{keyvault_name}", + subscription, + f"subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{keyvault_name}", monitor_client.clients[subscription], ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return monitor_diagnostics_settings diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.py b/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.py index 2a7d99b622..695e0b1033 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment.py @@ -8,9 +8,12 @@ class monitor_alert_create_policy_assignment(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Authorization/policyAssignments/write" @@ -18,19 +21,17 @@ class monitor_alert_create_policy_assignment(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for creating Policy Assignments in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for creating Policy Assignments in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for creating Policy Assignments in subscription {subscription_name}." + report.status_extended = f"There is not an alert for creating Policy Assignments in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.py b/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.py index 2decfe40be..bb141d17fb 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg.py @@ -8,9 +8,12 @@ class monitor_alert_create_update_nsg(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Network/networkSecurityGroups/write" @@ -18,19 +21,17 @@ class monitor_alert_create_update_nsg(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for creating/updating Network Security Groups in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for creating/updating Network Security Groups in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for creating/updating Network Security Groups in subscription {subscription_name}." + report.status_extended = f"There is not an alert for creating/updating Network Security Groups in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.py b/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.py index bc8f0bc694..3ace548574 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule.py @@ -8,9 +8,12 @@ class monitor_alert_create_update_public_ip_address_rule(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Network/publicIPAddresses/write" @@ -18,19 +21,17 @@ class monitor_alert_create_update_public_ip_address_rule(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for creating/updating Public IP address rule in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for creating/updating Public IP address rule in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for creating/updating Public IP address rule in subscription {subscription_name}." + report.status_extended = f"There is not an alert for creating/updating Public IP address rule in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.py b/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.py index 71334a364b..afd11b8550 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution.py @@ -8,9 +8,12 @@ class monitor_alert_create_update_security_solution(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Security/securitySolutions/write" @@ -18,19 +21,17 @@ class monitor_alert_create_update_security_solution(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for creating/updating Security Solution in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for creating/updating Security Solution in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for creating/updating Security Solution in subscription {subscription_name}." + report.status_extended = f"There is not an alert for creating/updating Security Solution in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.py b/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.py index feae49c01d..cb11e7b714 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr.py @@ -8,9 +8,12 @@ class monitor_alert_create_update_sqlserver_fr(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Sql/servers/firewallRules/write" @@ -18,19 +21,17 @@ class monitor_alert_create_update_sqlserver_fr(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for creating/updating SQL Server firewall rule in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for creating/updating SQL Server firewall rule in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for creating/updating SQL Server firewall rule in subscription {subscription_name}." + report.status_extended = f"There is not an alert for creating/updating SQL Server firewall rule in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.py b/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.py index bf4d2eb170..aa4bf8438e 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg.py @@ -8,9 +8,12 @@ class monitor_alert_delete_nsg(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Network/networkSecurityGroups/delete" @@ -20,19 +23,17 @@ class monitor_alert_delete_nsg(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for deleting Network Security Groups in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for deleting Network Security Groups in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for deleting Network Security Groups in subscription {subscription_name}." + report.status_extended = f"There is not an alert for deleting Network Security Groups in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.py b/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.py index cd236de59d..abed374b1e 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment.py @@ -8,9 +8,12 @@ class monitor_alert_delete_policy_assignment(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Authorization/policyAssignments/delete" @@ -18,19 +21,17 @@ class monitor_alert_delete_policy_assignment(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for deleting policy assignment in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for deleting policy assignment in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for deleting policy assignment in subscription {subscription_name}." + report.status_extended = f"There is not an alert for deleting policy assignment in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.py b/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.py index a60a972d65..7ea8420bc0 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule.py @@ -8,9 +8,12 @@ class monitor_alert_delete_public_ip_address_rule(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Network/publicIPAddresses/delete" @@ -18,19 +21,17 @@ class monitor_alert_delete_public_ip_address_rule(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for deleting public IP address rule in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for deleting public IP address rule in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for deleting public IP address rule in subscription {subscription_name}." + report.status_extended = f"There is not an alert for deleting public IP address rule in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.py b/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.py index 94b0f510e2..975e5ff2df 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution.py @@ -8,9 +8,12 @@ class monitor_alert_delete_security_solution(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Security/securitySolutions/delete" @@ -18,19 +21,17 @@ class monitor_alert_delete_security_solution(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for deleting Security Solution in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for deleting Security Solution in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for deleting Security Solution in subscription {subscription_name}." + report.status_extended = f"There is not an alert for deleting Security Solution in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.py b/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.py index 7b09098aaf..700a0caba1 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr.py @@ -8,9 +8,12 @@ class monitor_alert_delete_sqlserver_fr(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: if check_alert_rule( alert_rule, "Microsoft.Sql/servers/firewallRules/delete" @@ -18,19 +21,17 @@ class monitor_alert_delete_sqlserver_fr(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an alert configured for deleting SQL Server firewall rule in subscription {subscription_name}." + report.status_extended = f"There is an alert configured for deleting SQL Server firewall rule in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is not an alert for deleting SQL Server firewall rule in subscription {subscription_name}." + report.status_extended = f"There is not an alert for deleting SQL Server firewall rule in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.py b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.py index 1a20efcdd3..8eea7dacb2 100644 --- a/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.py +++ b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.py @@ -7,9 +7,12 @@ class monitor_alert_service_health_exists(Check): findings = [] for ( - subscription_name, + subscription_id, activity_log_alerts, ) in monitor_client.alert_rules.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for alert_rule in activity_log_alerts: # Check if alert rule is enabled and has required Service Health conditions if alert_rule.enabled: @@ -31,19 +34,17 @@ class monitor_alert_service_health_exists(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=alert_rule ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"There is an activity log alert for Service Health in subscription {subscription_name}." + report.status_extended = f"There is an activity log alert for Service Health in subscription {subscription_name} ({subscription_id})." break else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"There is no activity log alert for Service Health in subscription {subscription_name}." + report.status_extended = f"There is no activity log alert for Service Health in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.py b/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.py index 0e5ee3f3ec..f0bc2d2f38 100644 --- a/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.py +++ b/prowler/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories.py @@ -7,9 +7,12 @@ class monitor_diagnostic_setting_with_appropriate_categories(Check): findings = [] for ( - subscription_name, + subscription_id, diagnostic_settings, ) in monitor_client.diagnostics_settings.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) compliant_setting = None for diagnostic_setting in diagnostic_settings: @@ -41,18 +44,16 @@ class monitor_diagnostic_setting_with_appropriate_categories(Check): report = Check_Report_Azure( metadata=self.metadata(), resource=compliant_setting ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Diagnostic setting {compliant_setting.name} captures appropriate categories in subscription {subscription_name}." + report.status_extended = f"Diagnostic setting {compliant_setting.name} captures appropriate categories in subscription {subscription_name} ({subscription_id})." else: report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = f"No diagnostic setting captures all appropriate categories (Administrative, Security, Alert, Policy) in subscription {subscription_name}." + report.status_extended = f"No diagnostic setting captures all appropriate categories (Administrative, Security, Alert, Policy) in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.py b/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.py index c23e7a2af1..1dbe142a28 100644 --- a/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.py +++ b/prowler/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists.py @@ -7,30 +7,29 @@ class monitor_diagnostic_settings_exists(Check): findings = [] for ( - subscription_name, + subscription_id, diagnostic_settings, ) in monitor_client.diagnostics_settings.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) if diagnostic_settings: # At least one diagnostic setting exists - report on the first one diagnostic_setting = diagnostic_settings[0] report = Check_Report_Azure( metadata=self.metadata(), resource=diagnostic_setting ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Diagnostic setting {diagnostic_setting.name} found in subscription {subscription_name}." + report.status_extended = f"Diagnostic setting {diagnostic_setting.name} found in subscription {subscription_name} ({subscription_id})." else: # No diagnostic settings - report on subscription report = Check_Report_Azure(metadata=self.metadata(), resource={}) - report.subscription = subscription_name - report.resource_name = subscription_name - report.resource_id = ( - f"/subscriptions/{monitor_client.subscriptions[subscription_name]}" - ) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" report.status = "FAIL" - report.status_extended = ( - f"No diagnostic settings found in subscription {subscription_name}." - ) + report.status_extended = f"No diagnostic settings found in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_service.py b/prowler/providers/azure/services/monitor/monitor_service.py index 948b0cceec..07d41d58f2 100644 --- a/prowler/providers/azure/services/monitor/monitor_service.py +++ b/prowler/providers/azure/services/monitor/monitor_service.py @@ -23,13 +23,13 @@ class Monitor(AzureService): try: diagnostics_settings_list = self.diagnostic_settings_with_uri( subscription, - f"subscriptions/{self.subscriptions[subscription]}/", + f"subscriptions/{subscription}/", client, ) diagnostics_settings.update({subscription: diagnostics_settings_list}) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return diagnostics_settings @@ -61,7 +61,7 @@ class Monitor(AzureService): ) except Exception as error: logger.error( - f"Subscription id: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return diagnostics_settings @@ -94,7 +94,7 @@ class Monitor(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return alert_rules diff --git a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.py b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.py index 2400fe2206..135fde0c64 100644 --- a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.py +++ b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted.py @@ -8,24 +8,25 @@ class monitor_storage_account_with_activity_logs_cmk_encrypted(Check): findings = [] for ( - subscription_name, + subscription_id, diagnostic_settings, ) in monitor_client.diagnostics_settings.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for diagnostic_setting in diagnostic_settings: - for storage_account in storage_client.storage_accounts[ - subscription_name - ]: + for storage_account in storage_client.storage_accounts[subscription_id]: if storage_account.name == diagnostic_setting.storage_account_name: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) - report.subscription = subscription_name + report.subscription = subscription_id if storage_account.encryption_type == "Microsoft.Storage": report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} storing activity log in subscription {subscription_name} is not encrypted with Customer Managed Key." + report.status_extended = f"Storage account {storage_account.name} storing activity log in subscription {subscription_name} ({subscription_id}) is not encrypted with Customer Managed Key." else: report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} storing activity log in subscription {subscription_name} is encrypted with Customer Managed Key or not necessary." + report.status_extended = f"Storage account {storage_account.name} storing activity log in subscription {subscription_name} ({subscription_id}) is encrypted with Customer Managed Key or not necessary." findings.append(report) diff --git a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.py b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.py index 0fc6b71768..6008f7d909 100644 --- a/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.py +++ b/prowler/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private.py @@ -8,24 +8,25 @@ class monitor_storage_account_with_activity_logs_is_private(Check): findings = [] for ( - subscription_name, + subscription_id, diagnostic_settings, ) in monitor_client.diagnostics_settings.items(): + subscription_name = monitor_client.subscriptions.get( + subscription_id, subscription_id + ) for diagnostic_setting in diagnostic_settings: - for storage_account in storage_client.storage_accounts[ - subscription_name - ]: + for storage_account in storage_client.storage_accounts[subscription_id]: if storage_account.name == diagnostic_setting.storage_account_name: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) - report.subscription = subscription_name + report.subscription = subscription_id if storage_account.allow_blob_public_access: report.status = "FAIL" - report.status_extended = f"Blob public access enabled in storage account {storage_account.name} storing activity logs in subscription {subscription_name}." + report.status_extended = f"Blob public access enabled in storage account {storage_account.name} storing activity logs in subscription {subscription_name} ({subscription_id})." else: report.status = "PASS" - report.status_extended = f"Blob public access disabled in storage account {storage_account.name} storing activity logs in subscription {subscription_name}." + report.status_extended = f"Blob public access disabled in storage account {storage_account.name} storing activity logs in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py index 5071da4b20..d29a2e0879 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py @@ -7,14 +7,17 @@ class mysql_flexible_server_audit_log_connection_activated(Check): findings = [] for ( - subscription_name, + subscription_id, servers, ) in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) for server in servers.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=server) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"Audit log is disabled for server {server.name} in subscription {subscription_name}." + report.status_extended = f"Audit log is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." if "audit_log_events" in server.configurations: report.resource_id = server.configurations[ @@ -25,7 +28,7 @@ class mysql_flexible_server_audit_log_connection_activated(Check): "audit_log_events" ].value.lower().split(","): report.status = "PASS" - report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}." + report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py index 81918f7756..c7a33bae44 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py @@ -7,14 +7,17 @@ class mysql_flexible_server_audit_log_enabled(Check): findings = [] for ( - subscription_name, + subscription_id, servers, ) in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) for server in servers.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.status = "FAIL" - report.subscription = subscription_name - report.status_extended = f"Audit log is disabled for server {server.name} in subscription {subscription_name}." + report.subscription = subscription_id + report.status_extended = f"Audit log is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." if "audit_log_enabled" in server.configurations: report.resource_id = server.configurations[ @@ -23,7 +26,7 @@ class mysql_flexible_server_audit_log_enabled(Check): if server.configurations["audit_log_enabled"].value.lower() == "on": report.status = "PASS" - report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}." + report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..1dac541283 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "MySQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and recovery from a full regional outage.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for MySQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on MySQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure MySQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..5b1685354c --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure MySQL Flexible Server stores backups in a paired Azure region, enabling recovery from a full regional outage. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Geo-redundant backup is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"Geo-redundant backup is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..22a9b93401 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_high_availability_enabled", + "CheckTitle": "MySQL flexible server has high availability enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **high availability** mode. Zone-redundant or same-zone HA provisions a standby replica and provides automatic failover during planned and unplanned outages.", + "Risk": "Without **high availability**, a server or zone failure causes **downtime** until manual recovery or redeployment. Applications experience extended unavailability and potential data loss for in-flight transactions during unplanned outages.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-high-availability", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server update --name --resource-group --high-availability ZoneRedundant", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with zone-redundant high availability\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n sku: {\n name: 'Standard_D2ds_v4'\n tier: 'GeneralPurpose'\n }\n properties: {\n highAvailability: {\n mode: 'ZoneRedundant' // CRITICAL: provisions a standby replica with automatic failover\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open your Azure Database for MySQL flexible server\n2. Under Settings, select High availability\n3. Enable High availability and choose Zone redundant (or Same zone)\n4. Save (high availability requires the General Purpose or Business Critical tier)", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with zone-redundant high availability\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku_name = \"GP_Standard_D2ds_v4\"\n\n high_availability {\n mode = \"ZoneRedundant\" # CRITICAL: provisions a standby replica with automatic failover\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **high availability** (zone-redundant where supported, otherwise same-zone) on production MySQL Flexible Servers so a standby replica provides automatic failover. High availability requires the General Purpose or Business Critical tier; pair it with geo-redundant backup and periodic failover testing for full resilience.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "High availability for Azure MySQL Flexible Server requires the General Purpose or Business Critical compute tier and is not available on the Burstable tier." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py new file mode 100644 index 0000000000..e1f3054baf --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_high_availability_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have high availability enabled. + + This check evaluates whether each Azure MySQL Flexible Server is configured with high availability (zone-redundant or same-zone), providing automatic failover to a standby replica during outages. + + - PASS: The server has high availability enabled (high_availability_mode is set and not "Disabled"). + - FAIL: The server does not have high availability enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if ( + server.high_availability_mode is not None + and server.high_availability_mode != "Disabled" + ): + report.status = "PASS" + report.status_extended = f"High availability is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"High availability is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.py index dbd12bd344..d9aa962c92 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.py +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.py @@ -7,27 +7,30 @@ class mysql_flexible_server_minimum_tls_version_12(Check): findings = [] for ( - subscription_name, + subscription_id, servers, ) in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) for server in servers.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=server) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"TLS version is not configured in server {server.name} in subscription {subscription_name}." + report.status_extended = f"TLS version is not configured in server {server.name} in subscription {subscription_name} ({subscription_id})." if "tls_version" in server.configurations: report.resource_id = server.configurations[ "tls_version" ].resource_id report.status = "PASS" - report.status_extended = f"TLS version is {server.configurations['tls_version'].value} in server {server.name} in subscription {subscription_name}. This version of TLS is considered secure." + report.status_extended = f"TLS version is {server.configurations['tls_version'].value} in server {server.name} in subscription {subscription_name} ({subscription_id}). This version of TLS is considered secure." tls_aviable = server.configurations["tls_version"].value.split(",") if "TLSv1.0" in tls_aviable or "TLSv1.1" in tls_aviable: report.status = "FAIL" - report.status_extended = f"TLS version is {server.configurations['tls_version'].value} in server {server.name} in subscription {subscription_name}. There is at leat one version of TLS that is considered insecure." + report.status_extended = f"TLS version is {server.configurations['tls_version'].value} in server {server.name} in subscription {subscription_name} ({subscription_id}). There is at leat one version of TLS that is considered insecure." findings.append(report) diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py index 79930de947..03a32736e3 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py @@ -7,14 +7,17 @@ class mysql_flexible_server_ssl_connection_enabled(Check): findings = [] for ( - subscription_name, + subscription_id, servers, ) in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) for server in servers.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=server) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"SSL connection is disabled for server {server.name} in subscription {subscription_name}." + report.status_extended = f"SSL connection is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." if "require_secure_transport" in server.configurations: report.resource_id = server.configurations[ @@ -25,7 +28,7 @@ class mysql_flexible_server_ssl_connection_enabled(Check): == "on" ): report.status = "PASS" - report.status_extended = f"SSL connection is enabled for server {server.name} in subscription {subscription_name}." + report.status_extended = f"SSL connection is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index d2f152a492..b3a386a193 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.rdbms.mysql_flexibleservers import MySQLManagementClient @@ -16,12 +17,14 @@ class MySQL(AzureService): def _get_flexible_servers(self): logger.info("MySQL - Getting servers...") servers = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: servers_list = client.servers.list() - servers.update({subscription_name: {}}) + servers.update({subscription_id: {}}) for server in servers_list: - servers[subscription_name].update( + backup = getattr(server, "backup", None) + ha = getattr(server, "high_availability", None) + servers[subscription_id].update( { server.id: FlexibleServer( resource_id=server.id, @@ -31,12 +34,16 @@ class MySQL(AzureService): configurations=self._get_configurations( client, server.id.split("/")[4], server.name ), + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), ) } ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return servers @@ -78,3 +85,5 @@ class FlexibleServer: location: str version: str configurations: dict[Configuration] + geo_redundant_backup: Optional[str] = None + high_availability_mode: Optional[str] = None diff --git a/prowler/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists.py b/prowler/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists.py index 85e815b3ab..1efc4bde99 100644 --- a/prowler/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists.py +++ b/prowler/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists.py @@ -6,17 +6,16 @@ class network_bastion_host_exists(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, bastion_hosts in network_client.bastion_hosts.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) if not bastion_hosts: report = Check_Report_Azure(metadata=self.metadata(), resource={}) report.subscription = subscription report.resource_name = subscription - report.resource_id = ( - f"/subscriptions/{network_client.subscriptions[subscription]}" - ) + report.resource_id = f"/subscriptions/{subscription}" report.status = "FAIL" - report.status_extended = ( - f"Bastion Host from subscription {subscription} does not exist" - ) + report.status_extended = f"Bastion Host from subscription {subscription_name} ({subscription}) does not exist" findings.append(report) else: for bastion_host in bastion_hosts: @@ -25,7 +24,7 @@ class network_bastion_host_exists(Check): ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Bastion Host {bastion_host.name} exists in subscription {subscription}." + report.status_extended = f"Bastion Host {bastion_host.name} exists in subscription {subscription_name} ({subscription})." findings.append(report) return findings diff --git a/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.metadata.json b/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.metadata.json index da13704693..2f212d1630 100644 --- a/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.metadata.json +++ b/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.metadata.json @@ -9,8 +9,8 @@ "Severity": "high", "ResourceType": "microsoft.network/networkwatchers", "ResourceGroup": "network", - "Description": "**Azure Network Watcher** has **NSG flow logs** enabled and configured to forward traffic records to a centralized **Log Analytics workspace**", - "Risk": "Missing or disabled flow logging blinds visibility into network behavior, hindering detection of:\n- **Lateral movement** and internal scanning\n- **C2 beacons** and exfiltration patterns\nThis degrades incident response and correlation, impacting **confidentiality** and **integrity**.", + "Description": "**Azure Network Watcher** has **flow logs** enabled for supported targets, such as **virtual networks** and **network security groups**, and configured with **Traffic Analytics** to forward records to a centralized **Log Analytics workspace**", + "Risk": "Missing, disabled, or non-centralized flow logging blinds visibility into network behavior, hindering detection of:\n- **Lateral movement** and internal scanning\n- **C2 beacons** and exfiltration patterns\nThis degrades incident response and correlation, impacting **confidentiality** and **integrity**.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-tutorial", @@ -18,13 +18,13 @@ ], "Remediation": { "Code": { - "CLI": "az network watcher flow-log create --location --name --resource-group --nsg --storage-account --enabled true --workspace ", - "NativeIaC": "```bicep\n// Enable NSG flow logs and send to Log Analytics\nresource flowLog 'Microsoft.Network/networkWatchers/flowLogs@2022-09-01' = {\n name: '/'\n location: ''\n properties: {\n enabled: true // CRITICAL: turns on flow logs\n targetResourceId: '' // NSG resource ID\n storageId: '' // required for NSG flow logs\n flowAnalyticsConfiguration: {\n networkWatcherFlowAnalyticsConfiguration: {\n enabled: true // CRITICAL: sends flow logs to Log Analytics\n workspaceResourceId: '' // Log Analytics workspace resource ID\n }\n }\n }\n}\n```", - "Other": "1. In Azure portal, go to Network Watcher > Flow logs\n2. Click + Create (or Create flow log)\n3. Select the target NSG and region\n4. Set Status to On\n5. Select a Storage account\n6. Enable Traffic analytics, then select your Log Analytics workspace\n7. Click Review + create, then Create", - "Terraform": "```hcl\n# Enable NSG flow logs and send to Log Analytics\nresource \"azurerm_network_watcher_flow_log\" \"\" {\n network_watcher_name = \"\"\n resource_group_name = \"\"\n network_security_group_id = \"\"\n storage_account_id = \"\"\n\n enabled = true # CRITICAL: turns on flow logs\n\n traffic_analytics { \n enabled = true # CRITICAL: sends flow logs to Log Analytics\n workspace_id = \"\" # workspace_id (GUID) or use data source\n workspace_region = \"\"\n workspace_resource_id = \"\" # Log Analytics workspace resource ID\n }\n}\n```" + "CLI": "az network watcher flow-log create --location --name --resource-group --target-resource-id --storage-account --enabled true --workspace ", + "NativeIaC": "```bicep\n// Enable flow logs for a supported target (for example, a virtual network or NSG)\nresource flowLog 'Microsoft.Network/networkWatchers/flowLogs@2023-09-01' = {\n name: '/'\n location: ''\n properties: {\n enabled: true\n targetResourceId: ''\n storageId: ''\n flowAnalyticsConfiguration: {\n networkWatcherFlowAnalyticsConfiguration: {\n enabled: true\n workspaceResourceId: ''\n }\n }\n }\n}\n```", + "Other": "1. In Azure portal, go to Network Watcher > Flow logs\n2. Click + Create\n3. Select the subscription and region\n4. Choose the appropriate flow log type and target resource, such as a virtual network or network security group\n5. Set Status to On\n6. Select a Storage account\n7. Enable Traffic analytics and select the Log Analytics workspace\n8. Click Review + create, then Create", + "Terraform": "```hcl\n# Enable flow logs for a supported target and send analytics to Log Analytics\nresource \"azurerm_network_watcher_flow_log\" \"\" {\n name = \"\"\n network_watcher_name = \"\"\n resource_group_name = \"\"\n target_resource_id = \"\"\n storage_account_id = \"\"\n\n enabled = true\n\n traffic_analytics {\n enabled = true\n workspace_id = \"\"\n workspace_region = \"\"\n workspace_resource_id = \"\"\n }\n}\n```" }, "Recommendation": { - "Text": "Enable and centrally aggregate **NSG flow logs** to a **Log Analytics workspace**.\n\n- Enforce least privilege on log data\n- Define retention and secure storage\n- Use layered monitoring (e.g., Traffic Analytics)\n- Ensure coverage across regions/subscriptions and critical NSGs", + "Text": "Enable and centrally aggregate **flow logs** for supported Network Watcher targets, including **virtual networks** and **network security groups**, to a **Log Analytics workspace**.\n\n- Enforce least privilege on log data\n- Define retention and secure storage\n- Use layered monitoring (e.g., Traffic Analytics)\n- Ensure coverage across regions, subscriptions, and critical network segments", "Url": "https://hub.prowler.com/check/network_flow_log_captured_sent" } }, @@ -34,5 +34,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor." + "Notes": "Configuring flow logs and Traffic Analytics increases storage and analytics costs. For new Azure deployments, prefer virtual network flow logs where they satisfy your monitoring requirements because NSG flow logs are on the retirement path." } diff --git a/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.py b/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.py index 4d8749e281..832fa105c3 100644 --- a/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.py +++ b/prowler/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent.py @@ -6,21 +6,34 @@ class network_flow_log_captured_sent(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, network_watchers in network_client.network_watchers.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) for network_watcher in network_watchers: report = Check_Report_Azure( metadata=self.metadata(), resource=network_watcher ) report.subscription = subscription - report.status = "FAIL" - report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} has no flow logs" if network_watcher.flow_logs: - report.status = "FAIL" - report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} has flow logs disabled" + report.status = "PASS" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) has flow logs that are captured and sent to Log Analytics workspace" + has_failed = False for flow_log in network_watcher.flow_logs: - if flow_log.enabled: - report.status = "PASS" - report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} has flow logs that are captured and sent to Log Analytics workspace" - break + if not has_failed: + if not flow_log.enabled: + report.status = "FAIL" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) has flow logs disabled" + has_failed = True + elif not ( + flow_log.traffic_analytics_enabled + and flow_log.workspace_resource_id + ): + report.status = "FAIL" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) has enabled flow logs that are not configured to send traffic analytics to a Log Analytics workspace" + has_failed = True + else: + report.status = "FAIL" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) has no flow logs" findings.append(report) diff --git a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json index e2a5c5b40d..e53e649e69 100644 --- a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json +++ b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json @@ -9,8 +9,8 @@ "Severity": "medium", "ResourceType": "microsoft.network/networkwatchers", "ResourceGroup": "network", - "Description": "**Azure Network Watcher** has **NSG flow logs** enabled and configured to retain for at least `90` days (or `0` for unlimited). The evaluation checks that flow logging is enabled and that the retention policy meets the required duration for each configured log.", - "Risk": "Absent or short-retained **NSG flow logs** reduce visibility into IP flows, delaying detection of port scans, brute force, data exfiltration, and lateral movement.\n\nForensics and accountability degrade, threatening **confidentiality** and **integrity**.", + "Description": "**Azure Network Watcher** has **flow logs** enabled for supported targets, such as **virtual networks** and **network security groups**, and configured to retain for at least `90` days (or `0` for unlimited). The evaluation checks that flow logging is enabled and that the retention policy meets the required duration for each configured log.", + "Risk": "Absent or short-retained **flow logs** reduce visibility into IP flows, delaying detection of port scans, brute force, data exfiltration, and lateral movement.\n\nForensics and accountability degrade, threatening **confidentiality** and **integrity**.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest", @@ -20,13 +20,13 @@ ], "Remediation": { "Code": { - "CLI": "az network watcher flow-log create --location --name --nsg --storage-account --retention 90", - "NativeIaC": "```bicep\n// Enable NSG flow logs with retention >= 90 days\nresource flowlog 'Microsoft.Network/networkWatchers/flowLogs@2023-09-01' = {\n name: '/'\n location: ''\n properties: {\n targetResourceId: ''\n storageId: ''\n enabled: true // critical: turns on flow logs\n retentionPolicy: {\n enabled: true // critical: activates retention policy\n days: 90 // critical: 0 (unlimited) or >= 90 to pass\n }\n }\n}\n```", - "Other": "1. In Azure Portal, go to Network Watcher > NSG flow logs\n2. Select the NSG to configure\n3. Set Status to On\n4. Set Retention (days) to 0 (unlimited) or at least 90\n5. Select a Storage account\n6. Click Save", - "Terraform": "```hcl\n# Enable NSG flow logs with retention >= 90 days\nresource \"azurerm_network_watcher_flow_log\" \"\" {\n name = \"\"\n network_watcher_name = \"\"\n resource_group_name = \"\"\n target_resource_id = \"\"\n storage_account_id = \"\"\n\n enabled = true # critical: turns on flow logs\n\n retention_policy {\n enabled = true # critical: activates retention policy\n days = 90 # critical: 0 (unlimited) or >= 90 to pass\n }\n}\n```" + "CLI": "az network watcher flow-log create --location --name --target-resource-id --storage-account --enabled true --retention 90", + "NativeIaC": "```bicep\n// Enable flow logs with retention >= 90 days for a supported target\nresource flowlog 'Microsoft.Network/networkWatchers/flowLogs@2023-09-01' = {\n name: '/'\n location: ''\n properties: {\n targetResourceId: ''\n storageId: ''\n enabled: true\n retentionPolicy: {\n enabled: true\n days: 90\n }\n }\n}\n```", + "Other": "1. In Azure Portal, go to Network Watcher > Flow logs\n2. Select the relevant flow log or create one for the target resource, such as a virtual network or network security group\n3. Set Status to On\n4. Set Retention (days) to 0 (unlimited) or at least 90\n5. Select a Storage account\n6. Click Save or Review + create", + "Terraform": "```hcl\n# Enable flow logs with retention >= 90 days\nresource \"azurerm_network_watcher_flow_log\" \"\" {\n name = \"\"\n network_watcher_name = \"\"\n resource_group_name = \"\"\n target_resource_id = \"\"\n storage_account_id = \"\"\n\n enabled = true\n\n retention_policy {\n enabled = true\n days = 90\n }\n}\n```" }, "Recommendation": { - "Text": "Enable **NSG flow logs** and keep retention `90` days (`0` for unlimited). Restrict and monitor access to logs, store immutably, and stream to a SIEM to detect anomalies. Apply **defense in depth** and **least privilege**. Plan migration to **Virtual network flow logs** as NSG flow logs are being retired.", + "Text": "Enable **flow logs** and keep retention `90` days (`0` for unlimited) for supported targets, including **virtual networks** and **network security groups**. Restrict and monitor access to logs, store immutably, and stream to a SIEM to detect anomalies. Apply **defense in depth** and **least privilege**. Prefer **virtual network flow logs** for new deployments as NSG flow logs are being retired.", "Url": "https://hub.prowler.com/check/network_flow_log_more_than_90_days" } }, @@ -36,5 +36,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use." + "Notes": "Longer retention improves investigation depth but increases storage cost. For new Azure deployments, prefer virtual network flow logs where they satisfy your monitoring requirements because NSG flow logs are on the retirement path." } diff --git a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py index 69d17b5e0a..5030e35211 100644 --- a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py +++ b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py @@ -6,6 +6,9 @@ class network_flow_log_more_than_90_days(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, network_watchers in network_client.network_watchers.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) for network_watcher in network_watchers: report = Check_Report_Azure( metadata=self.metadata(), resource=network_watcher @@ -13,24 +16,24 @@ class network_flow_log_more_than_90_days(Check): report.subscription = subscription if network_watcher.flow_logs: report.status = "PASS" - report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} has flow logs enabled for more than 90 days" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) has flow logs enabled for more than 90 days" has_failed = False for flow_log in network_watcher.flow_logs: if not has_failed: if not flow_log.enabled: report.status = "FAIL" - report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} has flow logs disabled" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) has flow logs disabled" has_failed = True elif ( flow_log.retention_policy.days < 90 and flow_log.retention_policy.days != 0 ) and not has_failed: report.status = "FAIL" - report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} flow logs retention policy is less than 90 days" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) flow logs retention policy is less than 90 days" has_failed = True else: report.status = "FAIL" - report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} has no flow logs" + report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription_name} ({subscription}) has no flow logs" findings.append(report) return findings diff --git a/prowler/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted.py b/prowler/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted.py index 61f018bae4..1539c3534f 100644 --- a/prowler/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted.py +++ b/prowler/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted.py @@ -6,13 +6,16 @@ class network_http_internet_access_restricted(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, security_groups in network_client.security_groups.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) for security_group in security_groups: report = Check_Report_Azure( metadata=self.metadata(), resource=security_group ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has HTTP internet access restricted." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has HTTP internet access restricted." rule_fail_condition = any( ( rule.destination_port_range == "80" @@ -33,7 +36,7 @@ class network_http_internet_access_restricted(Check): ) if rule_fail_condition: report.status = "FAIL" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has HTTP internet access allowed." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has HTTP internet access allowed." findings.append(report) return findings diff --git a/prowler/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan.py b/prowler/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan.py index 83340768eb..98b32a0c3b 100644 --- a/prowler/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan.py +++ b/prowler/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan.py @@ -12,20 +12,21 @@ class network_public_ip_shodan(Check): if shodan_api_key: api = shodan.Shodan(shodan_api_key) for subscription, public_ips in network_client.public_ip_addresses.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) for ip in public_ips: report = Check_Report_Azure(metadata=self.metadata(), resource=ip) report.subscription = subscription try: shodan_info = api.host(ip.ip_address) report.status = "FAIL" - report.status_extended = f"Public IP {ip.ip_address} listed in Shodan with open ports {str(shodan_info['ports'])} and ISP {shodan_info['isp']} in {shodan_info['country_name']}. More info at https://www.shodan.io/host/{ip.ip_address}." + report.status_extended = f"Public IP {ip.ip_address} from subscription {subscription_name} ({subscription}) listed in Shodan with open ports {str(shodan_info['ports'])} and ISP {shodan_info['isp']} in {shodan_info['country_name']}. More info at https://www.shodan.io/host/{ip.ip_address}." findings.append(report) except shodan.APIError as error: if "No information available for that IP" in error.value: report.status = "PASS" - report.status_extended = ( - f"Public IP {ip.ip_address} is not listed in Shodan." - ) + report.status_extended = f"Public IP {ip.ip_address} from subscription {subscription_name} ({subscription}) is not listed in Shodan." findings.append(report) continue else: diff --git a/prowler/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted.py b/prowler/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted.py index 7d08678d27..2b321ed0c8 100644 --- a/prowler/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted.py +++ b/prowler/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted.py @@ -6,13 +6,16 @@ class network_rdp_internet_access_restricted(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, security_groups in network_client.security_groups.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) for security_group in security_groups: report = Check_Report_Azure( metadata=self.metadata(), resource=security_group ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has RDP internet access restricted." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has RDP internet access restricted." rule_fail_condition = any( ( rule.destination_port_range == "3389" @@ -33,7 +36,7 @@ class network_rdp_internet_access_restricted(Check): ) if rule_fail_condition: report.status = "FAIL" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has RDP internet access allowed." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has RDP internet access allowed." findings.append(report) return findings diff --git a/prowler/providers/azure/services/network/network_service.py b/prowler/providers/azure/services/network/network_service.py index 75c4f3a971..a924cf9609 100644 --- a/prowler/providers/azure/services/network/network_service.py +++ b/prowler/providers/azure/services/network/network_service.py @@ -17,6 +17,7 @@ class Network(AzureService): self.bastion_hosts = self._get_bastion_hosts() self.network_watchers = self._get_network_watchers() self.public_ip_addresses = self._get_public_ip_addresses() + self.virtual_networks = self._get_virtual_networks() def _get_security_groups(self): logger.info("Network - Getting Network Security Groups...") @@ -54,7 +55,7 @@ class Network(AzureService): except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return security_groups @@ -79,6 +80,9 @@ class Network(AzureService): id=flow_log.id, name=flow_log.name, enabled=flow_log.enabled, + target_resource_id=getattr( + flow_log, "target_resource_id", None + ), retention_policy=RetentionPolicy( enabled=( flow_log.retention_policy.enabled @@ -91,6 +95,34 @@ class Network(AzureService): else 0 ), ), + traffic_analytics_enabled=bool( + getattr( + getattr( + getattr( + flow_log, + "flow_analytics_configuration", + None, + ), + "network_watcher_flow_analytics_configuration", + None, + ), + "enabled", + False, + ) + ), + workspace_resource_id=getattr( + getattr( + getattr( + flow_log, + "flow_analytics_configuration", + None, + ), + "network_watcher_flow_analytics_configuration", + None, + ), + "workspace_resource_id", + None, + ), ) for flow_log in flow_logs ], @@ -99,7 +131,7 @@ class Network(AzureService): except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return network_watchers @@ -118,12 +150,12 @@ class Network(AzureService): return flow_logs except ResourceNotFoundError as error: logger.warning( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return [] except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return [] @@ -145,7 +177,7 @@ class Network(AzureService): except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return bastion_hosts @@ -168,10 +200,45 @@ class Network(AzureService): except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return public_ip_addresses + def _get_virtual_networks(self): + logger.info("Network - Getting Virtual Networks...") + virtual_networks = {} + for subscription, client in self.clients.items(): + try: + virtual_networks[subscription] = [] + vnet_list = client.virtual_networks.list_all() + for vnet in vnet_list: + subnets = [] + for subnet in getattr(vnet, "subnets", []) or []: + nsg = getattr(subnet, "network_security_group", None) + subnets.append( + VNetSubnet( + id=subnet.id, + name=subnet.name, + nsg_id=getattr(nsg, "id", None) if nsg else None, + ) + ) + virtual_networks[subscription].append( + VirtualNetwork( + id=vnet.id, + name=vnet.name, + location=vnet.location, + enable_ddos_protection=getattr( + vnet, "enable_ddos_protection", False + ), + subnets=subnets, + ) + ) + except Exception as error: + logger.error( + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return virtual_networks + @dataclass class BastionHost: @@ -192,6 +259,9 @@ class FlowLog: name: str enabled: bool retention_policy: RetentionPolicy + target_resource_id: Optional[str] = None + traffic_analytics_enabled: bool = False + workspace_resource_id: Optional[str] = None @dataclass @@ -227,3 +297,23 @@ class PublicIp: name: str location: str ip_address: str + + +@dataclass +class VNetSubnet: + id: str + name: str + nsg_id: Optional[str] = None + + +@dataclass +class VirtualNetwork: + id: str + name: str + location: str + enable_ddos_protection: bool = False + subnets: List[VNetSubnet] = None + + def __post_init__(self): + if self.subnets is None: + self.subnets = [] diff --git a/prowler/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted.py b/prowler/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted.py index e4207194e1..f6ad26b9ef 100644 --- a/prowler/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted.py +++ b/prowler/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted.py @@ -6,13 +6,16 @@ class network_ssh_internet_access_restricted(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, security_groups in network_client.security_groups.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) for security_group in security_groups: report = Check_Report_Azure( metadata=self.metadata(), resource=security_group ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has SSH internet access restricted." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has SSH internet access restricted." rule_fail_condition = any( ( rule.destination_port_range == "22" @@ -33,7 +36,7 @@ class network_ssh_internet_access_restricted(Check): ) if rule_fail_condition: report.status = "FAIL" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has SSH internet access allowed." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has SSH internet access allowed." findings.append(report) return findings diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/__init__.py b/prowler/providers/azure/services/network/network_subnet_nsg_associated/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json new file mode 100644 index 0000000000..73cb41f886 --- /dev/null +++ b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "network_subnet_nsg_associated", + "CheckTitle": "Subnet has a network security group associated", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.network/virtualnetworks/subnets", + "ResourceGroup": "network", + "Description": "**Azure Virtual Network** subnets are evaluated for **Network Security Group (NSG)** association. Each subnet should have an NSG to enforce inbound and outbound traffic filtering rules. Subnets without NSGs allow all traffic by default.", + "Risk": "Subnets without NSGs have **no network-level access control**. All inbound and outbound traffic is allowed by default, enabling **lateral movement**, **unauthorized access** to resources, and **data exfiltration** across the virtual network.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview", + "https://learn.microsoft.com/en-us/azure/virtual-network/tutorial-filter-network-traffic" + ], + "Remediation": { + "Code": { + "CLI": "az network vnet subnet update --resource-group --vnet-name --name --network-security-group ", + "NativeIaC": "```bicep\nresource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' existing = {\n name: ''\n}\n\nresource subnet 'Microsoft.Network/virtualNetworks/subnets@2023-09-01' = {\n name: ''\n parent: vnet\n properties: {\n addressPrefix: '10.0.1.0/24'\n networkSecurityGroup: {\n id: '' // Critical: associates NSG with subnet\n }\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Virtual networks and select the VNet\n3. Click on Subnets\n4. Select the subnet without an NSG\n5. Under Network security group, select an existing NSG or create a new one\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_subnet_network_security_group_association\" \"\" {\n subnet_id = \"\" # Critical: associates NSG with subnet\n network_security_group_id = \"\"\n}\n```" + }, + "Recommendation": { + "Text": "Associate a **Network Security Group** with every subnet. Create NSG rules following **least privilege** \u2014 deny all by default and allow only required traffic. Exclude Azure-managed subnets (GatewaySubnet, AzureFirewallSubnet, AzureBastionSubnet) which have their own security controls.", + "Url": "https://hub.prowler.com/check/network_subnet_nsg_associated" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check excludes Azure-managed subnets (GatewaySubnet, AzureFirewallSubnet, AzureFirewallManagementSubnet, AzureBastionSubnet, RouteServerSubnet) which should not have custom NSGs." +} diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py new file mode 100644 index 0000000000..054f663351 --- /dev/null +++ b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.network.network_client import network_client + +# Subnets that are managed by Azure and should not have custom NSGs +EXCLUDED_SUBNET_NAMES = { + "GatewaySubnet", + "AzureFirewallSubnet", + "AzureFirewallManagementSubnet", + "AzureBastionSubnet", + "RouteServerSubnet", +} + + +class network_subnet_nsg_associated(Check): + """ + Ensure every subnet has a Network Security Group (NSG) associated. + + This check evaluates whether each subnet in every virtual network has an NSG associated to enforce inbound and outbound traffic filtering. Azure-managed subnets (e.g. GatewaySubnet, AzureFirewallSubnet, AzureBastionSubnet) are excluded because they must not have custom NSGs. + + - PASS: The subnet has an NSG associated. + - FAIL: The subnet does not have an NSG associated. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for subscription_name, vnets in network_client.virtual_networks.items(): + for vnet in vnets: + for subnet in vnet.subnets: + if subnet.name in EXCLUDED_SUBNET_NAMES: + continue + + report = Check_Report_Azure(metadata=self.metadata(), resource=vnet) + report.subscription = subscription_name + report.resource_name = f"{vnet.name}/{subnet.name}" + report.resource_id = subnet.id + report.location = vnet.location + + if subnet.nsg_id: + report.status = "PASS" + report.status_extended = ( + f"Subnet '{subnet.name}' in VNet '{vnet.name}' " + f"has an NSG associated." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Subnet '{subnet.name}' in VNet '{vnet.name}' " + f"does not have an NSG associated." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted.py b/prowler/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted.py index ebd5fc7d50..94e177eb24 100644 --- a/prowler/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted.py +++ b/prowler/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted.py @@ -6,13 +6,16 @@ class network_udp_internet_access_restricted(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, security_groups in network_client.security_groups.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) for security_group in security_groups: report = Check_Report_Azure( metadata=self.metadata(), resource=security_group ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has UDP internet access restricted." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has UDP internet access restricted." rule_fail_condition = any( ( rule.protocol in ["UDP", "Udp"] @@ -28,7 +31,7 @@ class network_udp_internet_access_restricted(Check): ) if rule_fail_condition: report.status = "FAIL" - report.status_extended = f"Security Group {security_group.name} from subscription {subscription} has UDP internet access allowed." + report.status_extended = f"Security Group {security_group.name} from subscription {subscription_name} ({subscription}) has UDP internet access allowed." findings.append(report) return findings diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/__init__.py b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json new file mode 100644 index 0000000000..dfeef103c1 --- /dev/null +++ b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "azure", + "CheckID": "network_vnet_ddos_protection_enabled", + "CheckTitle": "Virtual network has Azure DDoS Network Protection enabled", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.network/virtualnetworks", + "ResourceGroup": "network", + "Description": "**Azure Virtual Networks** are evaluated for **DDoS Network Protection**. When enabled, Azure DDoS Protection provides enhanced mitigation against volumetric, protocol, and application-layer DDoS attacks targeting resources deployed in the virtual network.", + "Risk": "Without DDoS protection, Azure resources are only covered by **DDoS Infrastructure Protection** (basic), which may not mitigate sophisticated or large-scale attacks. Successful DDoS attacks cause **service unavailability** and potential **data exposure** during failover.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview" + ], + "Remediation": { + "Code": { + "CLI": "az network ddos-protection create --resource-group --name && az network vnet update --resource-group --name --ddos-protection-plan ", + "NativeIaC": "```bicep\nresource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {\n name: ''\n location: ''\n properties: {\n addressSpace: { addressPrefixes: ['10.0.0.0/16'] }\n enableDdosProtection: true // Critical: enables DDoS Protection\n ddosProtectionPlan: { id: '' }\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Search for 'DDoS protection plans'\n3. Create a new DDoS protection plan\n4. Go to Virtual networks and select the target VNet\n5. Under DDoS protection, select 'Enable' and select the plan\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_virtual_network\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n address_space = [\"10.0.0.0/16\"]\n\n ddos_protection_plan {\n id = \"\" # Critical: associates DDoS Protection Plan\n enable = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Azure DDoS Network Protection** on virtual networks hosting internet-facing workloads. Consider cost implications — DDoS Network Protection has a monthly charge per plan. Use **DDoS IP Protection** as a lower-cost alternative for individual public IPs.", + "Url": "https://hub.prowler.com/check/network_vnet_ddos_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Azure DDoS Network Protection has a monthly cost (~$2,944/month per plan). Consider whether the workload justifies this cost. Internal-only VNets without internet-facing resources may not need DDoS protection." +} diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py new file mode 100644 index 0000000000..6a9a081fb7 --- /dev/null +++ b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.network.network_client import network_client + + +class network_vnet_ddos_protection_enabled(Check): + """ + Ensure Azure DDoS Network Protection is enabled on virtual networks. + + This check evaluates whether each virtual network has a DDoS protection plan associated, providing always-on traffic monitoring and adaptive mitigation against volumetric and protocol attacks. + + - PASS: The virtual network has DDoS protection enabled. + - FAIL: The virtual network does not have DDoS protection enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for subscription_name, vnets in network_client.virtual_networks.items(): + for vnet in vnets: + report = Check_Report_Azure(metadata=self.metadata(), resource=vnet) + report.subscription = subscription_name + report.resource_name = vnet.name + report.resource_id = vnet.id + report.location = vnet.location + + if vnet.enable_ddos_protection: + report.status = "PASS" + report.status_extended = ( + f"Virtual network '{vnet.name}' has DDoS " + f"protection enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Virtual network '{vnet.name}' does not have " + f"DDoS protection enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled.py b/prowler/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled.py index 5235ac0d73..b2a19f2bca 100644 --- a/prowler/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled.py +++ b/prowler/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled.py @@ -6,6 +6,9 @@ class network_watcher_enabled(Check): def execute(self) -> list[Check_Report_Azure]: findings = [] for subscription, network_watchers in network_client.network_watchers.items(): + subscription_name = network_client.subscriptions.get( + subscription, subscription + ) missing_locations = set(network_client.locations[subscription]) - set( network_watcher.location for network_watcher in network_watchers ) @@ -15,12 +18,10 @@ class network_watcher_enabled(Check): report = Check_Report_Azure(metadata=self.metadata(), resource={}) report.subscription = subscription report.resource_name = subscription - report.resource_id = ( - f"/subscriptions/{network_client.subscriptions[subscription]}" - ) + report.resource_id = f"/subscriptions/{subscription}" report.location = "global" report.status = "FAIL" - report.status_extended = f"Network Watcher is not enabled for the following locations in subscription '{subscription}': {', '.join(missing_locations)}." + report.status_extended = f"Network Watcher is not enabled for the following locations in subscription '{subscription_name} ({subscription})': {', '.join(missing_locations)}." findings.append(report) else: # Report each network watcher that exists @@ -30,7 +31,7 @@ class network_watcher_enabled(Check): ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Network Watcher {network_watcher.name} is enabled in location {network_watcher.location} in subscription '{subscription}'." + report.status_extended = f"Network Watcher {network_watcher.name} is enabled in location {network_watcher.location} in subscription '{subscription_name} ({subscription})'." findings.append(report) return findings diff --git a/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.py b/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.py index ba9ccf1eea..4c36663a1f 100644 --- a/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.py +++ b/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.py @@ -6,18 +6,21 @@ class policy_ensure_asc_enforcement_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, policies in policy_client.policy_assigments.items(): + for subscription_id, policies in policy_client.policy_assigments.items(): + subscription_name = policy_client.subscriptions.get( + subscription_id, subscription_id + ) if "SecurityCenterBuiltIn" in policies: report = Check_Report_Azure( metadata=self.metadata(), resource=policies["SecurityCenterBuiltIn"], ) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Policy assigment '{policies['SecurityCenterBuiltIn'].id}' is configured with enforcement mode '{policies['SecurityCenterBuiltIn'].enforcement_mode}'." + report.status_extended = f"Policy assigment '{policies['SecurityCenterBuiltIn'].id}' from subscription {subscription_name} ({subscription_id}) is configured with enforcement mode '{policies['SecurityCenterBuiltIn'].enforcement_mode}'." if policies["SecurityCenterBuiltIn"].enforcement_mode != "Default": report.status = "FAIL" - report.status_extended = f"Policy assigment '{policies['SecurityCenterBuiltIn'].id}' is not configured with enforcement mode Default." + report.status_extended = f"Policy assigment '{policies['SecurityCenterBuiltIn'].id}' from subscription {subscription_name} ({subscription_id}) is not configured with enforcement mode Default." findings.append(report) diff --git a/prowler/providers/azure/services/policy/policy_service.py b/prowler/providers/azure/services/policy/policy_service.py index c7950f6d17..1d1381202f 100644 --- a/prowler/providers/azure/services/policy/policy_service.py +++ b/prowler/providers/azure/services/policy/policy_service.py @@ -16,13 +16,13 @@ class Policy(AzureService): logger.info("Policy - Getting policy assigments...") policy_assigments = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: policy_assigments_list = client.policy_assignments.list() - policy_assigments.update({subscription_name: {}}) + policy_assigments.update({subscription_id: {}}) for policy_assigment in policy_assigments_list: - policy_assigments[subscription_name].update( + policy_assigments[subscription_id].update( { policy_assigment.name: PolicyAssigment( id=policy_assigment.id, @@ -33,7 +33,7 @@ class Policy(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return policy_assigments diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled.py index fc78091b5b..0015b959f2 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled.py @@ -11,17 +11,20 @@ class postgresql_flexible_server_allow_access_services_disabled(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has allow public access from any Azure service enabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has allow public access from any Azure service enabled" if not any( rule.start_ip == "0.0.0.0" and rule.end_ip == "0.0.0.0" for rule in server.firewall ): report.status = "PASS" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has allow public access from any Azure service disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has allow public access from any Azure service disabled" findings.append(report) return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on.py index a395f605b8..763317d405 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on.py @@ -11,14 +11,17 @@ class postgresql_flexible_server_connection_throttling_on(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has connection_throttling disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has connection_throttling disabled" if server.connection_throttling == "ON": report.status = "PASS" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has connection_throttling enabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has connection_throttling enabled" findings.append(report) return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled.py index 35952cd9a0..b5fa583699 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled.py @@ -11,14 +11,17 @@ class postgresql_flexible_server_enforce_ssl_enabled(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has enforce ssl disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has enforce ssl disabled" if server.require_secure_transport == "ON": report.status = "PASS" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has enforce ssl enabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has enforce ssl enabled" findings.append(report) return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled.py index c87df50bfa..15aa6b2225 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled.py @@ -11,6 +11,9 @@ class postgresql_flexible_server_entra_id_authentication_enabled(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription @@ -23,7 +26,7 @@ class postgresql_flexible_server_entra_id_authentication_enabled(Check): not server.active_directory_auth or server.active_directory_auth != "ENABLED" ): - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has Microsoft Entra ID authentication disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has Microsoft Entra ID authentication disabled" else: # Authentication is enabled, now check for admins admin_count = ( @@ -31,13 +34,13 @@ class postgresql_flexible_server_entra_id_authentication_enabled(Check): ) if admin_count == 0: - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has Microsoft Entra ID authentication enabled but no Entra ID administrators configured" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has Microsoft Entra ID authentication enabled but no Entra ID administrators configured" else: report.status = "PASS" admin_text = ( "administrator" if admin_count == 1 else "administrators" ) - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has Microsoft Entra ID authentication enabled with {admin_count} {admin_text} configured" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has Microsoft Entra ID authentication enabled with {admin_count} {admin_text} configured" findings.append(report) return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..7be125ecf2 --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "postgresql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "PostgreSQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "postgresql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbforpostgresql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure PostgreSQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and cross-region disaster recovery.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbforpostgresql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az postgres flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2023-06-01-preview' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for PostgreSQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_postgresql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on PostgreSQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/postgresql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure PostgreSQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..94fdaa7a1c --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.postgresql.postgresql_client import ( + postgresql_client, +) + + +class postgresql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure PostgreSQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure PostgreSQL Flexible Server stores backups in a paired Azure region, enabling cross-region disaster recovery. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for ( + subscription, + flexible_servers, + ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) + for server in flexible_servers: + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has geo-redundant backup enabled." + else: + report.status = "FAIL" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) does not have geo-redundant backup enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/__init__.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..58b6588886 --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "postgresql_flexible_server_high_availability_enabled", + "CheckTitle": "PostgreSQL flexible server has high availability enabled", + "CheckType": [], + "ServiceName": "postgresql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbforpostgresql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure PostgreSQL Flexible Server** is evaluated for **high availability** mode. Zone-redundant or same-zone HA provisions a standby replica and provides automatic failover during planned and unplanned outages.", + "Risk": "Without **high availability**, a server or zone failure causes **downtime** until manual recovery or redeployment. Critical databases experience extended unavailability and potential data loss for in-flight transactions during unplanned outages.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-high-availability", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbforpostgresql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az postgres flexible-server update --name --resource-group --high-availability ZoneRedundant", + "NativeIaC": "```bicep\n// Bicep: PostgreSQL Flexible Server with zone-redundant high availability\nresource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2023-06-01-preview' = {\n name: ''\n location: ''\n sku: {\n name: 'Standard_D2ds_v4'\n tier: 'GeneralPurpose'\n }\n properties: {\n highAvailability: {\n mode: 'ZoneRedundant' // CRITICAL: provisions a standby replica with automatic failover\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open your Azure Database for PostgreSQL flexible server\n2. Under Settings, select High availability\n3. Enable High availability and choose Zone redundant (or Same zone)\n4. Save (high availability requires the General Purpose or Memory Optimized tier)", + "Terraform": "```hcl\n# Terraform: PostgreSQL Flexible Server with zone-redundant high availability\nresource \"azurerm_postgresql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku_name = \"GP_Standard_D2ds_v4\"\n\n high_availability {\n mode = \"ZoneRedundant\" # CRITICAL: provisions a standby replica with automatic failover\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **high availability** (zone-redundant where supported, otherwise same-zone) on production PostgreSQL Flexible Servers so a standby replica provides automatic failover. High availability requires the General Purpose or Memory Optimized tier; pair it with geo-redundant backup and periodic failover testing for full resilience.", + "Url": "https://hub.prowler.com/check/postgresql_flexible_server_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "High availability for Azure PostgreSQL Flexible Server requires the General Purpose or Memory Optimized compute tier and is not available on the Burstable tier." +} diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py new file mode 100644 index 0000000000..14c69f591e --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.postgresql.postgresql_client import ( + postgresql_client, +) + + +class postgresql_flexible_server_high_availability_enabled(Check): + """ + Ensure Azure PostgreSQL Flexible Servers have high availability enabled. + + This check evaluates whether each Azure PostgreSQL Flexible Server is configured with high availability (zone-redundant or same-zone), providing automatic failover to a standby replica during outages. + + - PASS: The server has high availability enabled (high_availability_mode is set and not "Disabled"). + - FAIL: The server does not have high availability enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for ( + subscription, + flexible_servers, + ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) + for server in flexible_servers: + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription + if ( + server.high_availability_mode is not None + and server.high_availability_mode != "Disabled" + ): + report.status = "PASS" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has high availability enabled." + else: + report.status = "FAIL" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) does not have high availability enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on.py index 4ff3b90e77..f04f20cb69 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on.py @@ -11,14 +11,17 @@ class postgresql_flexible_server_log_checkpoints_on(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_checkpoints disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_checkpoints disabled" if server.log_checkpoints == "ON": report.status = "PASS" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_checkpoints enabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_checkpoints enabled" findings.append(report) return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on.py index ee7bda8fd9..4d1f1a9749 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on.py @@ -11,14 +11,17 @@ class postgresql_flexible_server_log_connections_on(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_connections disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_connections disabled" if server.log_connections == "ON": report.status = "PASS" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_connections enabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_connections enabled" findings.append(report) return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on.py index af18535948..aecb6f94a4 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on.py @@ -11,14 +11,17 @@ class postgresql_flexible_server_log_disconnections_on(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_disconnections disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_disconnections disabled" if server.log_disconnections == "ON": report.status = "PASS" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_disconnections enabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_disconnections enabled" findings.append(report) return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3.py index f1cb0939c8..45a5a64e32 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3.py +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3.py @@ -11,13 +11,16 @@ class postgresql_flexible_server_log_retention_days_greater_3(Check): subscription, flexible_servers, ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) for server in flexible_servers: report = Check_Report_Azure(metadata=self.metadata(), resource=server) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_retention disabled" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_retention disabled" if server.log_retention_days: - report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription} has log_retention set to {server.log_retention_days}" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has log_retention set to {server.log_retention_days}" if ( int(server.log_retention_days) > 3 and int(server.log_retention_days) < 8 diff --git a/prowler/providers/azure/services/postgresql/postgresql_service.py b/prowler/providers/azure/services/postgresql/postgresql_service.py index ef9449081c..681c57a32c 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_service.py +++ b/prowler/providers/azure/services/postgresql/postgresql_service.py @@ -1,5 +1,7 @@ from dataclasses import dataclass +from typing import Optional +from azure.core.exceptions import ResourceNotFoundError from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient from prowler.lib.logger import logger @@ -20,59 +22,73 @@ class PostgreSQL(AzureService): flexible_servers.update({subscription: []}) flexible_servers_list = client.servers.list() for postgresql_server in flexible_servers_list: - resource_group = self._get_resource_group(postgresql_server.id) - # Fetch full server object once to extract multiple properties - server_details = client.servers.get( - resource_group, postgresql_server.name - ) - require_secure_transport = self._get_require_secure_transport( - subscription, resource_group, postgresql_server.name - ) - active_directory_auth = self._extract_active_directory_auth( - server_details - ) - entra_id_admins = self._get_entra_id_admins( - subscription, resource_group, postgresql_server.name - ) - log_checkpoints = self._get_log_checkpoints( - subscription, resource_group, postgresql_server.name - ) - log_disconnections = self._get_log_disconnections( - subscription, resource_group, postgresql_server.name - ) - log_connections = self._get_log_connections( - subscription, resource_group, postgresql_server.name - ) - connection_throttling = self._get_connection_throttling( - subscription, resource_group, postgresql_server.name - ) - log_retention_days = self._get_log_retention_days( - subscription, resource_group, postgresql_server.name - ) - firewall = self._get_firewall( - subscription, resource_group, postgresql_server.name - ) - location = server_details.location - flexible_servers[subscription].append( - Server( - id=postgresql_server.id, - name=postgresql_server.name, - resource_group=resource_group, - location=location, - require_secure_transport=require_secure_transport, - active_directory_auth=active_directory_auth, - entra_id_admins=entra_id_admins, - log_checkpoints=log_checkpoints, - log_connections=log_connections, - log_disconnections=log_disconnections, - connection_throttling=connection_throttling, - log_retention_days=log_retention_days, - firewall=firewall, + # Isolate each server: a failure collecting one server must + # not abort collection of the remaining servers in the + # subscription. + try: + resource_group = self._get_resource_group(postgresql_server.id) + # Fetch full server object once to extract multiple properties + server_details = client.servers.get( + resource_group, postgresql_server.name + ) + require_secure_transport = self._get_require_secure_transport( + subscription, resource_group, postgresql_server.name + ) + active_directory_auth = self._extract_active_directory_auth( + server_details + ) + entra_id_admins = self._get_entra_id_admins( + subscription, resource_group, postgresql_server.name + ) + log_checkpoints = self._get_log_checkpoints( + subscription, resource_group, postgresql_server.name + ) + log_disconnections = self._get_log_disconnections( + subscription, resource_group, postgresql_server.name + ) + log_connections = self._get_log_connections( + subscription, resource_group, postgresql_server.name + ) + connection_throttling = self._get_connection_throttling( + subscription, resource_group, postgresql_server.name + ) + log_retention_days = self._get_log_retention_days( + subscription, resource_group, postgresql_server.name + ) + firewall = self._get_firewall( + subscription, resource_group, postgresql_server.name + ) + location = server_details.location + backup = getattr(server_details, "backup", None) + ha = getattr(server_details, "high_availability", None) + flexible_servers[subscription].append( + Server( + id=postgresql_server.id, + name=postgresql_server.name, + resource_group=resource_group, + location=location, + require_secure_transport=require_secure_transport, + active_directory_auth=active_directory_auth, + entra_id_admins=entra_id_admins, + log_checkpoints=log_checkpoints, + log_connections=log_connections, + log_disconnections=log_disconnections, + connection_throttling=connection_throttling, + log_retention_days=log_retention_days, + firewall=firewall, + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), + ) + ) + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return flexible_servers @@ -149,15 +165,54 @@ class PostgreSQL(AzureService): ) return admin_list except Exception as e: - logger.error(f"Error getting Entra ID admins for {server_name}: {e}") + if "authentication is not enabled" in str(e): + # Expected when the server uses PostgreSQL authentication only + # (Entra/Azure AD auth disabled); not an error. + logger.warning( + f"Entra ID authentication is not enabled for {server_name}; skipping Entra ID admins." + ) + else: + logger.error(f"Error getting Entra ID admins for {server_name}: {e}") return [] - def _get_connection_throttling(self, subscription, resouce_group_name, server_name): + def _get_connection_throttling( + self, subscription: str, resouce_group_name: str, server_name: str + ) -> Optional[str]: + """Get the ``connection_throttle.enable`` setting for a flexible server. + + The ``connection_throttle.enable`` server parameter was removed in + PostgreSQL 16+, so it no longer exists on newer flexible servers. When + the parameter is genuinely absent the Azure SDK raises + ``ResourceNotFoundError`` (error code ``ConfigurationNotExists``); that + case is treated as "not enabled" and ``None`` is returned so collection + of the server can continue. + + Any other error (permissions, throttling, transient SDK failures) is + intentionally left to propagate: returning ``None`` for those would make + the downstream check report the server as having connection throttling + disabled, silently turning a collection failure into a security finding. + + Args: + subscription: Azure subscription identifier. + resouce_group_name: Resource group containing the server. + server_name: PostgreSQL flexible server name. + + Returns: + The uppercased throttling value, or ``None`` when the parameter does + not exist on the server. + + Raises: + ResourceNotFoundError is handled; any other exception propagates to + the caller so it can be surfaced as a collection failure. + """ client = self.clients[subscription] - connection_throttling = client.configurations.get( - resouce_group_name, server_name, "connection_throttle.enable" - ) - return connection_throttling.value.upper() + try: + connection_throttling = client.configurations.get( + resouce_group_name, server_name, "connection_throttle.enable" + ) + return connection_throttling.value.upper() + except ResourceNotFoundError: + return None def _get_log_retention_days(self, subscription, resouce_group_name, server_name): client = self.clients[subscription] @@ -214,6 +269,8 @@ class Server: log_checkpoints: str log_connections: str log_disconnections: str - connection_throttling: str - log_retention_days: str + connection_throttling: Optional[str] + log_retention_days: Optional[str] firewall: list[Firewall] + geo_redundant_backup: Optional[str] = None + high_availability_mode: Optional[str] = None diff --git a/prowler/providers/azure/services/recovery/__init__.py b/prowler/providers/azure/services/recovery/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_service.py b/prowler/providers/azure/services/recovery/recovery_service.py index efc7630bf7..38645219bd 100644 --- a/prowler/providers/azure/services/recovery/recovery_service.py +++ b/prowler/providers/azure/services/recovery/recovery_service.py @@ -52,41 +52,54 @@ class Recovery(AzureService): """ logger.info("Recovery - Getting Recovery Services vaults...") vaults_dict: dict[str, dict[str, BackupVault]] = {} + subscription_id = "unknown" try: vaults_dict: dict[str, dict[str, BackupVault]] = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): vaults = client.vaults.list_by_subscription_id() - vaults_dict[subscription_name] = {} + vaults_dict[subscription_id] = {} for vault in vaults: vault_obj = BackupVault( id=vault.id, name=vault.name, location=vault.location, ) - vaults_dict[subscription_name][vault_obj.id] = vault_obj + vaults_dict[subscription_id][vault_obj.id] = vault_obj except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- " + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" ) return vaults_dict class RecoveryBackup(AzureService): + """ + Service wrapper for Azure Recovery Services backup data. + + Collects the backup protected items and backup policies for each Recovery + Services vault discovered by the Recovery service. + """ + def __init__( - self, provider: AzureProvider, vaults: dict[str, dict[str, BackupVault]] + self, + provider: AzureProvider, + vaults: dict[str, dict[str, BackupVault]], ): super().__init__(RecoveryServicesBackupClient, provider) - for subscription_name, vaults in vaults.items(): + for subscription_id, vaults in vaults.items(): for vault in vaults.values(): - vault.backup_protected_items = self._get_backup_protected_items( - subscription_name=subscription_name, vault=vault + protected_items = self._get_backup_protected_items( + subscription_id=subscription_id, vault=vault ) + vault.backup_protected_items = protected_items vault.backup_policies = self._get_backup_policies( - subscription_name=subscription_name, vault=vault + subscription_id=subscription_id, vault=vault ) def _get_backup_protected_items( - self, subscription_name: str, vault: BackupVault + self, subscription_id: str, vault: BackupVault ) -> dict[str, BackupItem]: """ Retrieve all backup protected items for a given vault. @@ -95,71 +108,78 @@ class RecoveryBackup(AzureService): backup_protected_items_dict: dict[str, BackupItem] = {} try: backup_protected_items = self.clients[ - subscription_name + subscription_id ].backup_protected_items.list( vault_name=vault.name, resource_group_name=vault.id.split("/")[4], ) for item in backup_protected_items: item_properties = getattr(item, "properties", None) + workload_type = None + backup_policy_id = None + if item_properties: + workload_type = item_properties.workload_type + backup_policy_id = item_properties.policy_id backup_protected_items_dict[item.id] = BackupItem( id=item.id, name=item.name, - workload_type=( - item_properties.workload_type if item_properties else None - ), - backup_policy_id=( - item_properties.policy_id if item_properties else None - ), + workload_type=workload_type, + backup_policy_id=backup_policy_id, ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- " + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" ) return backup_protected_items_dict def _get_backup_policies( - self, subscription_name: str, vault: BackupVault + self, subscription_id: str, vault: BackupVault ) -> dict[str, BackupPolicy]: """ Retrieve all backup policies for a given vault. """ logger.info("Recovery - Getting backup policies...") backup_policies_dict: dict[str, BackupPolicy] = {} - unique_backup_policies: set[str] = set() try: - for item in vault.backup_protected_items.values(): - if item.backup_policy_id: - unique_backup_policies.add(item.backup_policy_id) - for policy_id in unique_backup_policies: - policy = self.clients[subscription_name].protection_policies.get( - vault_name=vault.name, - resource_group_name=vault.id.split("/")[4], - policy_name=policy_id.split("/")[-1], - ) - backup_policies_dict[policy_id] = BackupPolicy( + client = self.clients[subscription_id] + backup_policies = client.backup_policies.list( + vault_name=vault.name, + resource_group_name=vault.id.split("/")[4], + ) + for policy in backup_policies: + retention_days = self._get_backup_policy_retention_days(policy) + backup_policies_dict[policy.id] = BackupPolicy( id=policy.id, name=policy.name, - retention_days=getattr( - getattr( - getattr( - getattr( - getattr(policy, "properties", None), - "retention_policy", - None, - ), - "daily_schedule", - None, - ), - "retention_duration", - None, - ), - "count", - None, - ), + retention_days=retention_days, ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- " + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" ) return backup_policies_dict + + @staticmethod + def _get_backup_policy_retention_days(policy) -> Optional[int]: + """Return the daily retention duration count for a backup policy.""" + return getattr( + getattr( + getattr( + getattr( + getattr(policy, "properties", None), + "retention_policy", + None, + ), + "daily_schedule", + None, + ), + "retention_duration", + None, + ), + "count", + None, + ) diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/__init__.py b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json new file mode 100644 index 0000000000..33f8516d1b --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "recovery_vault_backup_policy_retention_adequate", + "CheckTitle": "Recovery Services backup policy has at least 30 days retention", + "CheckType": [], + "ServiceName": "recovery", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**Azure Recovery Services** backup policies are evaluated for adequate **daily retention**. Policies with fewer than 30 days of retention may not provide sufficient recovery points for incident investigation, compliance requirements, or ransomware recovery scenarios.", + "Risk": "Short retention periods limit the ability to recover from **delayed-discovery incidents** such as ransomware that encrypts backups over time, data corruption that propagates across backup cycles, or compliance investigations requiring historical data. Insufficient retention may violate regulatory requirements.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-vm-backup-faq", + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-backup-faq" + ], + "Remediation": { + "Code": { + "CLI": "az backup policy set --resource-group --vault-name --name --policy @policy.json", + "NativeIaC": "", + "Other": "1. Sign in to the Azure portal\n2. Go to Recovery Services vaults and select the vault\n3. Go to Backup policies\n4. Select the policy to modify\n5. Under Retention range, set Daily backup point retention to at least 30 days\n6. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set daily backup retention to at least **30 days**. For production workloads, consider 90+ days. Configure **weekly, monthly, and yearly** retention points for long-term compliance. Use **immutable vaults** to prevent backup deletion by compromised admin accounts.", + "Url": "https://hub.prowler.com/check/recovery_vault_backup_policy_retention_adequate" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates daily retention from the backup policy's retention_policy.daily_schedule.retention_duration.count. Policies without a daily schedule are flagged. The 30-day minimum aligns with common compliance requirements and incident response timelines." +} diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py new file mode 100644 index 0000000000..b770f87a62 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py @@ -0,0 +1,65 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client + +MINIMUM_RETENTION_DAYS = 30 + + +class recovery_vault_backup_policy_retention_adequate(Check): + """Check if vault backup policies retain backups for at least 30 days.""" + + def execute(self) -> list[Check_Report_Azure]: + """Execute the check across Recovery Services vault backup policies.""" + + findings = [] + + for subscription_name, vaults in recovery_client.vaults.items(): + for vault in vaults.values(): + if not vault.backup_policies: + report = Check_Report_Azure( + metadata=self.metadata(), resource=vault + ) + report.subscription = subscription_name + report.resource_name = vault.name + report.resource_id = vault.id + report.location = vault.location + report.status = "FAIL" + report.status_extended = ( + f"Recovery vault '{vault.name}' has no backup " + f"policies configured." + ) + findings.append(report) + continue + + for policy in vault.backup_policies.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=vault + ) + report.subscription = subscription_name + report.resource_name = f"{vault.name}/{policy.name}" + report.resource_id = policy.id + report.location = vault.location + + if policy.retention_days is None: + report.status = "FAIL" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has no daily retention configured." + ) + elif policy.retention_days < MINIMUM_RETENTION_DAYS: + report.status = "FAIL" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has {policy.retention_days}-day " + f"retention (minimum: {MINIMUM_RETENTION_DAYS})." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has {policy.retention_days}-day " + f"retention." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/__init__.py b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json new file mode 100644 index 0000000000..04e4405851 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "recovery_vault_has_protected_items", + "CheckTitle": "Recovery Services vault has backup protected items configured", + "CheckType": [], + "ServiceName": "recovery", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.recoveryservices/vaults", + "ResourceGroup": "storage", + "Description": "**Azure Recovery Services** vaults are evaluated for **backup protected items**. A vault with no protected items indicates that no resources (VMs, databases, file shares) are configured for backup through this vault, potentially leaving critical workloads unprotected.", + "Risk": "Empty vaults represent a gap in **disaster recovery** coverage. Resources without backup protection face permanent data loss from accidental deletion, ransomware, corruption, or regional outages. The vault may have been provisioned but backup was never configured.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-vms-first-look-arm", + "https://learn.microsoft.com/en-us/azure/backup/backup-overview" + ], + "Remediation": { + "Code": { + "CLI": "az backup protection enable-for-vm --resource-group --vault-name --vm --policy-name DefaultPolicy", + "NativeIaC": "", + "Other": "1. Sign in to the Azure portal\n2. Go to Recovery Services vaults and select the vault\n3. Click + Backup\n4. Select the workload type (Azure VM, SQL, File Share, etc.)\n5. Select the backup policy\n6. Choose the resources to protect\n7. Click Enable Backup", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure **backup protection** for critical resources including VMs, databases, and file shares. Use **Azure Backup policies** with appropriate retention and frequency. If the vault is unused, consider deleting it to reduce resource sprawl.", + "Url": "https://hub.prowler.com/check/recovery_vault_has_protected_items" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check reports on each Recovery Services vault individually. An empty vault is not necessarily a security risk if backups are managed through a different vault or Azure Backup center." +} diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py new file mode 100644 index 0000000000..6ebd9e5328 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client + + +class recovery_vault_has_protected_items(Check): + """Check if Recovery Services vaults have protected backup items.""" + + def execute(self) -> list[Check_Report_Azure]: + """Execute the check across Recovery Services vaults.""" + + findings = [] + + for subscription_name, vaults in recovery_client.vaults.items(): + for vault in vaults.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=vault) + report.subscription = subscription_name + report.resource_name = vault.name + report.resource_id = vault.id + report.location = vault.location + + if vault.backup_protected_items: + report.status = "PASS" + report.status_extended = ( + f"Recovery Services vault '{vault.name}' has " + f"{len(vault.backup_protected_items)} protected items." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Recovery Services vault '{vault.name}' has no " + f"protected items configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py index f9c7237302..bef7e2f70f 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py @@ -6,17 +6,20 @@ class sqlserver_auditing_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has an auditing policy configured." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has an auditing policy configured." for auditing_policy in sql_server.auditing_policies: if auditing_policy.state == "Disabled": report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} does not have any auditing policy configured." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) does not have any auditing policy configured." break findings.append(report) diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days.py b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days.py index 5b1120b00e..93f2914564 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days.py @@ -6,6 +6,9 @@ class sqlserver_auditing_retention_90_days(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server @@ -20,14 +23,14 @@ class sqlserver_auditing_retention_90_days(Check): if policy.state == "Enabled": if policy.retention_days <= 90: report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has auditing retention less than 91 days." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has auditing retention less than 91 days." has_failed = True else: report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has auditing retention greater than 90 days." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has auditing retention greater than 90 days." else: report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has auditing disabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has auditing disabled." has_failed = True if has_policy: findings.append(report) diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled.py index 6d5b1c265d..234ccf1a3f 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled.py @@ -6,20 +6,23 @@ class sqlserver_azuread_administrator_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has an Active Directory administrator." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has an Active Directory administrator." if ( sql_server.administrators is None or sql_server.administrators.administrator_type != "ActiveDirectory" ): report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} does not have an Active Directory administrator." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) does not have an Active Directory administrator." findings.append(report) diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled.py index de2934bcf9..0b86e67e2e 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled.py @@ -6,6 +6,9 @@ class sqlserver_microsoft_defender_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: if sql_server.security_alert_policies: report = Check_Report_Azure( @@ -13,10 +16,10 @@ class sqlserver_microsoft_defender_enabled(Check): ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has microsoft defender disabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has microsoft defender disabled." if sql_server.security_alert_policies.state == "Enabled": report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has microsoft defender enabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has microsoft defender enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version.py b/prowler/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version.py index 2f55951436..fb88662776 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version.py @@ -11,15 +11,18 @@ class sqlserver_recommended_minimal_tls_version(Check): "recommended_minimal_tls_versions", ["1.2", "1.3"] ) for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} is using TLS version {sql_server.minimal_tls_version} as minimal accepted which is not recommended. Please use one of the recommended versions: {', '.join(recommended_minimal_tls_versions)}." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) is using TLS version {sql_server.minimal_tls_version} as minimal accepted which is not recommended. Please use one of the recommended versions: {', '.join(recommended_minimal_tls_versions)}." if sql_server.minimal_tls_version in recommended_minimal_tls_versions: - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} is using version {sql_server.minimal_tls_version} as minimal accepted which is recommended." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) is using version {sql_server.minimal_tls_version} as minimal accepted which is recommended." report.status = "PASS" findings.append(report) diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_service.py b/prowler/providers/azure/services/sqlserver/sqlserver_service.py index 4d4ca00ebc..af02dace0d 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_service.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_service.py @@ -72,7 +72,7 @@ class SQLServer(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return sql_servers @@ -141,7 +141,7 @@ class SQLServer(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return databases diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk.py b/prowler/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk.py index 2b4cd94d1b..5f44e2663c 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk.py @@ -6,10 +6,17 @@ class sqlserver_tde_encrypted_with_cmk(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: - databases = ( - sql_server.databases if sql_server.databases is not None else [] - ) + databases = [ + database + for database in ( + sql_server.databases if sql_server.databases is not None else [] + ) + if database.name.lower() != "master" + ] if len(databases) > 0: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server @@ -25,14 +32,14 @@ class sqlserver_tde_encrypted_with_cmk(Check): break if database.tde_encryption.status == "Enabled": report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has TDE enabled with CMK." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has TDE enabled with CMK." else: report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has TDE disabled with CMK." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has TDE disabled with CMK." found_disabled = True else: report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has TDE disabled without CMK." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has TDE disabled without CMK." findings.append(report) return findings diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled.py index 05de0efc7a..b7bda558a2 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled.py @@ -6,6 +6,9 @@ class sqlserver_tde_encryption_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: databases = ( sql_server.databases if sql_server.databases is not None else [] @@ -20,10 +23,10 @@ class sqlserver_tde_encryption_enabled(Check): report.subscription = subscription if database.tde_encryption.status == "Enabled": report.status = "PASS" - report.status_extended = f"Database {database.name} from SQL Server {sql_server.name} from subscription {subscription} has TDE enabled" + report.status_extended = f"Database {database.name} from SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has TDE enabled" else: report.status = "FAIL" - report.status_extended = f"Database {database.name} from SQL Server {sql_server.name} from subscription {subscription} has TDE disabled" + report.status_extended = f"Database {database.name} from SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has TDE disabled" findings.append(report) return findings diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access.py b/prowler/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access.py index 9936a9a077..d9e84eaf96 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access.py @@ -6,20 +6,23 @@ class sqlserver_unrestricted_inbound_access(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} does not have firewall rules allowing 0.0.0.0-255.255.255.255." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) does not have firewall rules allowing 0.0.0.0-255.255.255.255." for firewall_rule in sql_server.firewall_rules: if ( firewall_rule.start_ip_address == "0.0.0.0" and firewall_rule.end_ip_address == "255.255.255.255" ): report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has firewall rules allowing 0.0.0.0-255.255.255.255." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has firewall rules allowing 0.0.0.0-255.255.255.255." break findings.append(report) diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled.py index 62a6a1d458..d853a9ea5e 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled.py @@ -6,24 +6,27 @@ class sqlserver_va_emails_notifications_admins_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment disabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment disabled." if ( sql_server.vulnerability_assessment and sql_server.vulnerability_assessment.storage_container_path ): - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment enabled but no scan reports configured for subscription admins." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment enabled but no scan reports configured for subscription admins." if ( sql_server.vulnerability_assessment.recurring_scans and sql_server.vulnerability_assessment.recurring_scans.email_subscription_admins ): report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment enabled and scan reports configured for subscription admins." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment enabled and scan reports configured for subscription admins." findings.append(report) return findings diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled.py index 2aaf40a99a..45798248f4 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled.py @@ -6,24 +6,27 @@ class sqlserver_va_periodic_recurring_scans_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment disabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment disabled." if ( sql_server.vulnerability_assessment and sql_server.vulnerability_assessment.storage_container_path ): - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment enabled but no recurring scans." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment enabled but no recurring scans." if ( sql_server.vulnerability_assessment.recurring_scans and sql_server.vulnerability_assessment.recurring_scans.is_enabled ): report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has periodic recurring scans enabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has periodic recurring scans enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured.py b/prowler/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured.py index 727696225c..a0c8b55e8f 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured.py @@ -6,18 +6,21 @@ class sqlserver_va_scan_reports_configured(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment disabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment disabled." if ( sql_server.vulnerability_assessment and sql_server.vulnerability_assessment.storage_container_path ): - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment enabled but no scan reports configured." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment enabled but no scan reports configured." if sql_server.vulnerability_assessment.recurring_scans and ( ( sql_server.vulnerability_assessment.recurring_scans.email_subscription_admins @@ -31,7 +34,7 @@ class sqlserver_va_scan_reports_configured(Check): ) ): report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment enabled and scan reports configured." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment enabled and scan reports configured." findings.append(report) return findings diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled.py index caf0ee0081..62a97abd07 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled.py @@ -6,20 +6,23 @@ class sqlserver_vulnerability_assessment_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, sql_servers in sqlserver_client.sql_servers.items(): + subscription_name = sqlserver_client.subscriptions.get( + subscription, subscription + ) for sql_server in sql_servers: report = Check_Report_Azure( metadata=self.metadata(), resource=sql_server ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment disabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment disabled." if ( sql_server.vulnerability_assessment and sql_server.vulnerability_assessment.storage_container_path is not None ): report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has vulnerability assessment enabled." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription_name} ({subscription}) has vulnerability assessment enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.py b/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.py index c4b946c7cf..75ad2d0544 100644 --- a/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.py +++ b/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.py @@ -19,6 +19,9 @@ class storage_account_key_access_disabled(Check): """ findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account @@ -26,9 +29,9 @@ class storage_account_key_access_disabled(Check): report.subscription = subscription if not storage_account.allow_shared_key_access: report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has shared key access disabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has shared key access disabled." else: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has shared key access enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has shared key access enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..142b1082cf --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "storage_account_public_network_access_disabled", + "CheckTitle": "Storage account has 'Public Network Access' disabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.storage/storageaccounts", + "ResourceGroup": "storage", + "Description": "**Azure Storage accounts** with **public network access** disabled cannot be reached from public networks. Setting `publicNetworkAccess` to `Disabled` overrides the public access settings of individual containers and forces access through private endpoints or trusted services. This is independent from the 'Allow Blob Anonymous Access' setting.", + "Risk": "Leaving **public network access** enabled exposes the storage account endpoints to the **public Internet**, widening the attack surface and undermining **defense in depth**.\n\nThis increases the risk of **unauthorized access**, **data exfiltration**, and reconnaissance against the account, especially when combined with weak network rules or overly permissive access policies.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security?tabs=azure-portal#change-the-default-network-access-rule", + "https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security-set-default-access" + ], + "Remediation": { + "Code": { + "CLI": "az storage account update --name --resource-group --public-network-access Disabled", + "NativeIaC": "```bicep\n// Storage account with public network access disabled\nresource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {\n name: ''\n location: resourceGroup().location\n kind: 'StorageV2'\n sku: { name: 'Standard_LRS' }\n properties: {\n publicNetworkAccess: 'Disabled' // Critical: disables public network access to the account\n }\n}\n```", + "Other": "1. In the Azure portal, go to Storage accounts and select the target account\n2. Under Security + networking, click Networking\n3. Set Public network access to Disabled\n4. Click Save", + "Terraform": "```hcl\nresource \"azurerm_storage_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n account_tier = \"Standard\"\n account_replication_type = \"LRS\"\n public_network_access_enabled = false # Critical: disables public network access\n}\n```" + }, + "Recommendation": { + "Text": "Disable **public network access** on the storage account and reach it through **private endpoints** or trusted Azure services only. Combine this with **least privilege** RBAC, short-lived `SAS` tokens, and network restrictions. Validate client connectivity before disabling public access in production.", + "Url": "https://hub.prowler.com/check/storage_account_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates the storage account publicNetworkAccess property. It is independent from the 'Allow Blob Anonymous Access' setting evaluated by storage_blob_public_access_level_is_disabled." +} diff --git a/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled.py b/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled.py new file mode 100644 index 0000000000..3db62c5256 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled.py @@ -0,0 +1,38 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.storage.storage_client import storage_client + + +class storage_account_public_network_access_disabled(Check): + """ + Ensure that 'Public Network Access' is 'Disabled' for storage accounts. + + This check evaluates the storage account's publicNetworkAccess property, which controls + whether the account is reachable from public networks. It is independent from the + 'Allow Blob Anonymous Access' setting (covered by + storage_blob_public_access_level_is_disabled). + - PASS: The storage account has public network access disabled. + - FAIL: The storage account has public network access enabled (or unset, which Azure treats as enabled). + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) + for storage_account in storage_accounts: + report = Check_Report_Azure( + metadata=self.metadata(), resource=storage_account + ) + report.subscription = subscription + + if storage_account.public_network_access == "Disabled": + report.status = "PASS" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has public network access disabled." + else: + report.status = "FAIL" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has public network access enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.metadata.json b/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.metadata.json index 079376899b..1cf3e35569 100644 --- a/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.metadata.json +++ b/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.metadata.json @@ -1,7 +1,7 @@ { "Provider": "azure", "CheckID": "storage_blob_public_access_level_is_disabled", - "CheckTitle": "Storage account has 'Allow blob public access' disabled", + "CheckTitle": "Storage account has 'Allow Blob Anonymous Access' disabled", "CheckType": [], "ServiceName": "storage", "SubServiceName": "", @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "microsoft.storage/storageaccounts", "ResourceGroup": "storage", - "Description": "**Azure Storage accounts** with **blob public access** disabled prevent containers or blobs from being set to a public access level. Setting `allow blob public access` to `false` enforces no anonymous reads across the account.", + "Description": "**Azure Storage accounts** with **blob anonymous (public) access** disabled prevent containers or blobs from being set to a public access level. Setting `allowBlobPublicAccess` to `false` enforces no anonymous reads across the account. This is independent from the account's 'Public Network Access' setting, which is evaluated by storage_account_public_network_access_disabled.", "Risk": "Allowing public access permits unauthenticated users to read blob data or enumerate container contents when any container is made public, compromising confidentiality.\n\nExposed objects can be scraped at scale, enabling data exfiltration and intelligence gathering without audit attribution.", "RelatedUrl": "", "AdditionalURLs": [ @@ -33,5 +33,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "This check evaluates the 'Allow Blob Anonymous Access' (allowBlobPublicAccess) setting. The account's 'Public Network Access' (publicNetworkAccess) setting is evaluated by storage_account_public_network_access_disabled." } diff --git a/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.py b/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.py index 38759f0569..f1edb63b90 100644 --- a/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.py +++ b/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled.py @@ -6,17 +6,20 @@ class storage_blob_public_access_level_is_disabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) report.subscription = subscription report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has allow blob public access enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has allow blob public access enabled." if not storage_account.allow_blob_public_access: report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has allow blob public access disabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has allow blob public access disabled." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.py b/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.py index cf55d6f830..73a40dcfd4 100644 --- a/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.py +++ b/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.py @@ -6,6 +6,9 @@ class storage_blob_versioning_is_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: if storage_account.blob_properties: report = Check_Report_Azure( @@ -16,9 +19,9 @@ class storage_blob_versioning_is_enabled(Check): storage_account.blob_properties, "versioning_enabled", False ): report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has blob versioning enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has blob versioning enabled." else: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have blob versioning enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) does not have blob versioning enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.py b/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.py index 65ed50545e..6b23a5b050 100644 --- a/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.py +++ b/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.py @@ -19,6 +19,9 @@ class storage_cross_tenant_replication_disabled(Check): """ findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account @@ -26,9 +29,9 @@ class storage_cross_tenant_replication_disabled(Check): report.subscription = subscription if not storage_account.allow_cross_tenant_replication: report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has cross-tenant replication disabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has cross-tenant replication disabled." else: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has cross-tenant replication enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has cross-tenant replication enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied.py b/prowler/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied.py index 4b9210eef5..1f26f8e535 100644 --- a/prowler/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied.py +++ b/prowler/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied.py @@ -6,17 +6,20 @@ class storage_default_network_access_rule_is_denied(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has network access rule set to Deny." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has network access rule set to Deny." if storage_account.network_rule_set.default_action == "Allow": report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has network access rule set to Allow." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has network access rule set to Allow." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.py b/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.py index 4b82c363e5..39d42c3133 100644 --- a/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.py +++ b/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.py @@ -20,6 +20,9 @@ class storage_default_to_entra_authorization_enabled(Check): """ findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account @@ -28,11 +31,11 @@ class storage_default_to_entra_authorization_enabled(Check): report.resource_name = storage_account.name report.resource_id = storage_account.id report.status = "FAIL" - report.status_extended = f"Default to Microsoft Entra authorization is not enabled for storage account {storage_account.name}." + report.status_extended = f"Default to Microsoft Entra authorization is not enabled for storage account {storage_account.name} from subscription {subscription_name} ({subscription})." if storage_account.default_to_entra_authorization: report.status = "PASS" - report.status_extended = f"Default to Microsoft Entra authorization is enabled for storage account {storage_account.name}." + report.status_extended = f"Default to Microsoft Entra authorization is enabled for storage account {storage_account.name} from subscription {subscription_name} ({subscription})." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled.py b/prowler/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled.py index a8109d90f1..7d23dd5268 100644 --- a/prowler/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled.py +++ b/prowler/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled.py @@ -6,17 +6,20 @@ class storage_ensure_azure_services_are_trusted_to_access_is_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} allows trusted Microsoft services to access this storage account." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) allows trusted Microsoft services to access this storage account." if "AzureServices" not in storage_account.network_rule_set.bypass: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not allow trusted Microsoft services to access this storage account." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) does not allow trusted Microsoft services to access this storage account." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys.py b/prowler/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys.py index f58fd33702..a704d14d0e 100644 --- a/prowler/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys.py +++ b/prowler/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys.py @@ -6,17 +6,20 @@ class storage_ensure_encryption_with_customer_managed_keys(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} encrypts with CMKs." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) encrypts with CMKs." if storage_account.encryption_type != "Microsoft.Keyvault": report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not encrypt with CMKs." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) does not encrypt with CMKs." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py index cf92ee25f3..ba5b0f065f 100644 --- a/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py +++ b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py @@ -6,6 +6,9 @@ class storage_ensure_file_shares_soft_delete_is_enabled(Check): def execute(self) -> list: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: if getattr(storage_account, "file_service_properties", None): report = Check_Report_Azure( @@ -20,10 +23,10 @@ class storage_ensure_file_shares_soft_delete_is_enabled(Check): storage_account.file_service_properties.share_delete_retention_policy.enabled ): report.status = "PASS" - report.status_extended = f"File share soft delete is enabled for storage account {storage_account.name} with a retention period of {storage_account.file_service_properties.share_delete_retention_policy.days} days." + report.status_extended = f"File share soft delete is enabled for storage account {storage_account.name} from subscription {subscription_name} ({subscription}) with a retention period of {storage_account.file_service_properties.share_delete_retention_policy.days} days." else: report.status = "FAIL" - report.status_extended = f"File share soft delete is not enabled for storage account {storage_account.name}." + report.status_extended = f"File share soft delete is not enabled for storage account {storage_account.name} from subscription {subscription_name} ({subscription})." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12.py b/prowler/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12.py index d63b3bfc9c..8e5b0c84de 100644 --- a/prowler/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12.py +++ b/prowler/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12.py @@ -6,17 +6,20 @@ class storage_ensure_minimum_tls_version_12(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has TLS version set to 1.2." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has TLS version set to 1.2." if storage_account.minimum_tls_version != "TLS1_2": report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have TLS version set to 1.2." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) does not have TLS version set to 1.2." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts.py b/prowler/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts.py index 7b73759922..e344c1ac49 100644 --- a/prowler/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts.py +++ b/prowler/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts.py @@ -6,6 +6,9 @@ class storage_ensure_private_endpoints_in_storage_accounts(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account @@ -13,10 +16,10 @@ class storage_ensure_private_endpoints_in_storage_accounts(Check): report.subscription = subscription if storage_account.private_endpoint_connections: report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has private endpoint connections." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has private endpoint connections." else: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have private endpoint connections." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) does not have private endpoint connections." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled.py b/prowler/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled.py index 7f8d3b39f4..965acfb5a5 100644 --- a/prowler/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled.py +++ b/prowler/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled.py @@ -6,6 +6,9 @@ class storage_ensure_soft_delete_is_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: if storage_account.blob_properties: report = Check_Report_Azure( @@ -18,10 +21,10 @@ class storage_ensure_soft_delete_is_enabled(Check): False, ): report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has soft delete enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has soft delete enabled." else: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has soft delete disabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has soft delete disabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.py b/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.py index ee2d49e53d..8c71ee4819 100644 --- a/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.py +++ b/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.py @@ -19,6 +19,9 @@ class storage_geo_redundant_enabled(Check): """ findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account @@ -32,10 +35,10 @@ class storage_geo_redundant_enabled(Check): or storage_account.replication_settings == "Standard_RAGZRS" ): report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has Geo-redundant storage {storage_account.replication_settings} enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has Geo-redundant storage {storage_account.replication_settings} enabled." else: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have Geo-redundant storage enabled, it has {storage_account.replication_settings} instead." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) does not have Geo-redundant storage enabled, it has {storage_account.replication_settings} instead." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled.py b/prowler/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled.py index cb969975c2..4294419d24 100644 --- a/prowler/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled.py +++ b/prowler/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled.py @@ -6,16 +6,19 @@ class storage_infrastructure_encryption_is_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has infrastructure encryption enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has infrastructure encryption enabled." if not storage_account.infrastructure_encryption: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has infrastructure encryption disabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has infrastructure encryption disabled." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days.py b/prowler/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days.py index 0007b51e6b..9fa07d029c 100644 --- a/prowler/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days.py +++ b/prowler/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days.py @@ -6,6 +6,9 @@ class storage_key_rotation_90_days(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account @@ -13,14 +16,14 @@ class storage_key_rotation_90_days(Check): report.subscription = subscription if not storage_account.key_expiration_period_in_days: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has no key expiration period set." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has no key expiration period set." else: if storage_account.key_expiration_period_in_days > 90: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has an invalid key expiration period of {storage_account.key_expiration_period_in_days} days." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has an invalid key expiration period of {storage_account.key_expiration_period_in_days} days." else: report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has a key expiration period of {storage_account.key_expiration_period_in_days} days." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has a key expiration period of {storage_account.key_expiration_period_in_days} days." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled.py b/prowler/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled.py index 0711ab3991..ec4e4e0bfa 100644 --- a/prowler/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled.py +++ b/prowler/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled.py @@ -6,16 +6,19 @@ class storage_secure_transfer_required_is_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for storage_account in storage_accounts: report = Check_Report_Azure( metadata=self.metadata(), resource=storage_account ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has secure transfer required enabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has secure transfer required enabled." if not storage_account.enable_https_traffic_only: report.status = "FAIL" - report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has secure transfer required disabled." + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription_name} ({subscription}) has secure transfer required disabled." findings.append(report) diff --git a/prowler/providers/azure/services/storage/storage_service.py b/prowler/providers/azure/services/storage/storage_service.py index 429f5ba7e3..74b8b3da30 100644 --- a/prowler/providers/azure/services/storage/storage_service.py +++ b/prowler/providers/azure/services/storage/storage_service.py @@ -42,6 +42,9 @@ class Storage(AzureService): enable_https_traffic_only=storage_account.enable_https_traffic_only, infrastructure_encryption=storage_account.encryption.require_infrastructure_encryption, allow_blob_public_access=storage_account.allow_blob_public_access, + public_network_access=getattr( + storage_account, "public_network_access", None + ), network_rule_set=NetworkRuleSet( bypass=getattr( storage_account.network_rule_set, @@ -111,7 +114,7 @@ class Storage(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return storage_accounts @@ -156,16 +159,16 @@ class Storage(AzureService): in str(error).strip() ): logger.warning( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) continue logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) def _get_file_share_properties(self): @@ -247,11 +250,11 @@ class Storage(AzureService): except Exception as error: if "File is not supported for the account." in str(error).strip(): logger.warning( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) continue logger.error( - f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -301,6 +304,7 @@ class Account(BaseModel): enable_https_traffic_only: bool infrastructure_encryption: Optional[bool] = None allow_blob_public_access: bool + public_network_access: Optional[str] = None network_rule_set: NetworkRuleSet encryption_type: str minimum_tls_version: str diff --git a/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.metadata.json b/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.metadata.json index 0649b30110..3bffd83e67 100644 --- a/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.metadata.json +++ b/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.metadata.json @@ -34,5 +34,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check passes if SMB channel encryption is set to a secure algorithm." + "Notes": "This check passes only if every SMB channel encryption algorithm allowed on the file shares is in the recommended list, which is configurable via azure.recommended_smb_channel_encryption_algorithms and defaults to AES-256-GCM only, as required by CIS." } diff --git a/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.py b/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.py index c8e9f1da3c..61d4abc185 100644 --- a/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.py +++ b/prowler/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm.py @@ -1,29 +1,38 @@ from prowler.lib.check.models import Check, Check_Report_Azure from prowler.providers.azure.services.storage.storage_client import storage_client -SECURE_ENCRYPTION_ALGORITHMS = ["AES-256-GCM"] +DEFAULT_SECURE_ENCRYPTION_ALGORITHMS = ["AES-256-GCM"] class storage_smb_channel_encryption_with_secure_algorithm(Check): """ - Ensure SMB channel encryption for file shares is set to the recommended algorithm (AES-256-GCM or higher). + Ensure SMB channel encryption for file shares only allows secure algorithms (AES-256-GCM or higher by default). + + The list of allowed algorithms is configurable via + azure.recommended_smb_channel_encryption_algorithms in the Prowler configuration file. This check evaluates whether SMB file shares are configured to use only the recommended SMB channel encryption algorithms. - - PASS: Storage account has the recommended SMB channel encryption (AES-256-GCM or higher) enabled for file shares. - - FAIL: Storage account does not have the recommended SMB channel encryption enabled for file shares or uses an unsupported algorithm. + - PASS: Storage account only allows secure SMB channel encryption algorithms for file shares. + - FAIL: Storage account does not have SMB channel encryption enabled, or it allows at least one algorithm that is not in the recommended list. """ def execute(self) -> list[Check_Report_Azure]: findings = [] + secure_encryption_algorithms = storage_client.audit_config.get( + "recommended_smb_channel_encryption_algorithms", + DEFAULT_SECURE_ENCRYPTION_ALGORITHMS, + ) for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for account in storage_accounts: if account.file_service_properties: + channel_encryption = ( + account.file_service_properties.smb_protocol_settings.channel_encryption + ) pretty_current_algorithms = ( - ", ".join( - account.file_service_properties.smb_protocol_settings.channel_encryption - ) - if account.file_service_properties.smb_protocol_settings.channel_encryption - else "none" + ", ".join(channel_encryption) if channel_encryption else "none" ) report = Check_Report_Azure( metadata=self.metadata(), @@ -32,20 +41,18 @@ class storage_smb_channel_encryption_with_secure_algorithm(Check): report.subscription = subscription report.resource_name = account.name - if ( - not account.file_service_properties.smb_protocol_settings.channel_encryption - ): + if not channel_encryption: report.status = "FAIL" - report.status_extended = f"Storage account {account.name} from subscription {subscription} does not have SMB channel encryption enabled for file shares." - elif any( - algorithm in SECURE_ENCRYPTION_ALGORITHMS - for algorithm in account.file_service_properties.smb_protocol_settings.channel_encryption + report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) does not have SMB channel encryption enabled for file shares." + elif all( + algorithm in secure_encryption_algorithms + for algorithm in channel_encryption ): report.status = "PASS" - report.status_extended = f"Storage account {account.name} from subscription {subscription} has a secure algorithm for SMB channel encryption ({', '.join(SECURE_ENCRYPTION_ALGORITHMS)}) enabled for file shares since it supports {pretty_current_algorithms}." + report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) only allows secure algorithms for SMB channel encryption on file shares since it supports {pretty_current_algorithms}." else: report.status = "FAIL" - report.status_extended = f"Storage account {account.name} from subscription {subscription} does not have SMB channel encryption with a secure algorithm for file shares since it supports {pretty_current_algorithms}." + report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) allows insecure algorithms for SMB channel encryption on file shares since it supports {pretty_current_algorithms} and only {', '.join(secure_encryption_algorithms)} is recommended." findings.append(report) return findings diff --git a/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.py b/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.py index 19f2d37765..7bc928756d 100644 --- a/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.py +++ b/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.py @@ -16,6 +16,9 @@ class storage_smb_protocol_version_is_latest(Check): findings = [] for subscription, storage_accounts in storage_client.storage_accounts.items(): + subscription_name = storage_client.subscriptions.get( + subscription, subscription + ) for account in storage_accounts: if getattr(account, "file_service_properties", None) and getattr( account.file_service_properties.smb_protocol_settings, @@ -40,9 +43,9 @@ class storage_smb_protocol_version_is_latest(Check): == LATEST_SMB_VERSION ): report.status = "PASS" - report.status_extended = f"Storage account {account.name} from subscription {subscription} allows only the latest SMB protocol version ({LATEST_SMB_VERSION}) for file shares." + report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) allows only the latest SMB protocol version ({LATEST_SMB_VERSION}) for file shares." else: report.status = "FAIL" - report.status_extended = f"Storage account {account.name} from subscription {subscription} allows SMB protocol versions: {', '.join(account.file_service_properties.smb_protocol_settings.supported_versions) if account.file_service_properties.smb_protocol_settings.supported_versions else 'None'}. Only the latest SMB protocol version ({LATEST_SMB_VERSION}) should be allowed." + report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) allows SMB protocol versions: {', '.join(account.file_service_properties.smb_protocol_settings.supported_versions) if account.file_service_properties.smb_protocol_settings.supported_versions else 'None'}. Only the latest SMB protocol version ({LATEST_SMB_VERSION}) should be allowed." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py index d5865937f9..1238b75d2f 100644 --- a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py +++ b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py @@ -22,8 +22,11 @@ class vm_backup_enabled(Check): A list of reports containing the result of the check. """ findings = [] - for subscription_name, vms in vm_client.virtual_machines.items(): - vaults = recovery_client.vaults.get(subscription_name, {}) + for subscription_id, vms in vm_client.virtual_machines.items(): + subscription_name = recovery_client.subscriptions.get( + subscription_id, subscription_id + ) + vaults = recovery_client.vaults.get(subscription_id, {}) for vm in vms.values(): found = False found_vault_name = None @@ -40,12 +43,12 @@ class vm_backup_enabled(Check): if found: break report = Check_Report_Azure(metadata=self.metadata(), resource=vm) - report.subscription = subscription_name + report.subscription = subscription_id if found: report.status = "PASS" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is protected by Azure Backup (vault: {found_vault_name})." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) is protected by Azure Backup (vault: {found_vault_name})." else: report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is not protected by Azure Backup." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) is not protected by Azure Backup." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size.py b/prowler/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size.py index ac3df970b4..68164a7282 100644 --- a/prowler/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size.py +++ b/prowler/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size.py @@ -32,17 +32,20 @@ class vm_desired_sku_size(Check): ], ) - for subscription_name, vms in vm_client.virtual_machines.items(): + for subscription_id, vms in vm_client.virtual_machines.items(): + subscription_name = vm_client.subscriptions.get( + subscription_id, subscription_id + ) for vm in vms.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=vm) - report.subscription = subscription_name + report.subscription = subscription_id if vm.vm_size in DESIRED_SKU_SIZES: report.status = "PASS" - report.status_extended = f"VM {vm.resource_name} is using desired SKU size {vm.vm_size} in subscription {subscription_name}." + report.status_extended = f"VM {vm.resource_name} is using desired SKU size {vm.vm_size} in subscription {subscription_name} ({subscription_id})." else: report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} is using {vm.vm_size} which is not a desired SKU size in subscription {subscription_name}." + report.status_extended = f"VM {vm.resource_name} is using {vm.vm_size} which is not a desired SKU size in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk.py b/prowler/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk.py index e803110a9c..029f9c8775 100644 --- a/prowler/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk.py +++ b/prowler/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk.py @@ -6,20 +6,23 @@ class vm_ensure_attached_disks_encrypted_with_cmk(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, disks in vm_client.disks.items(): + for subscription_id, disks in vm_client.disks.items(): + subscription_name = vm_client.subscriptions.get( + subscription_id, subscription_id + ) for disk_id, disk in disks.items(): if disk.vms_attached: report = Check_Report_Azure(metadata=self.metadata(), resource=disk) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {subscription_name}." + report.status_extended = f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {subscription_name} ({subscription_id})." if ( not disk.encryption_type or disk.encryption_type == "EncryptionAtRestWithPlatformKey" ): report.status = "FAIL" - report.status_extended = f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {subscription_name}." + report.status_extended = f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk.py b/prowler/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk.py index ecf9cd0f87..f4e86296f4 100644 --- a/prowler/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk.py +++ b/prowler/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk.py @@ -6,20 +6,23 @@ class vm_ensure_unattached_disks_encrypted_with_cmk(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, disks in vm_client.disks.items(): + for subscription_id, disks in vm_client.disks.items(): + subscription_name = vm_client.subscriptions.get( + subscription_id, subscription_id + ) for disk_id, disk in disks.items(): if not disk.vms_attached: report = Check_Report_Azure(metadata=self.metadata(), resource=disk) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "PASS" - report.status_extended = f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {subscription_name}." + report.status_extended = f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {subscription_name} ({subscription_id})." if ( not disk.encryption_type or disk.encryption_type == "EncryptionAtRestWithPlatformKey" ): report.status = "FAIL" - report.status_extended = f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {subscription_name}." + report.status_extended = f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {subscription_name} ({subscription_id})." findings.append(report) diff --git a/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.py b/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.py index 4f6c378777..efd54edac4 100644 --- a/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.py +++ b/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.py @@ -14,10 +14,13 @@ class vm_ensure_using_approved_images(Check): def execute(self): findings = [] - for subscription_name, vms in vm_client.virtual_machines.items(): + for subscription_id, vms in vm_client.virtual_machines.items(): + subscription_name = vm_client.subscriptions.get( + subscription_id, subscription_id + ) for vm in vms.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=vm) - report.subscription = subscription_name + report.subscription = subscription_id image_id = getattr(vm, "image_reference", None) if ( image_id @@ -25,9 +28,9 @@ class vm_ensure_using_approved_images(Check): and "/providers/Microsoft.Compute/images/" in image_id ): report.status = "PASS" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is using an approved machine image: {image_id.split('/')[-1]}." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) is using an approved machine image: {image_id.split('/')[-1]}." else: report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is not using an approved machine image." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) is not using an approved machine image." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks.py b/prowler/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks.py index e771fc80e5..316cc426a0 100644 --- a/prowler/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks.py +++ b/prowler/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks.py @@ -6,12 +6,15 @@ class vm_ensure_using_managed_disks(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, vms in vm_client.virtual_machines.items(): + for subscription_id, vms in vm_client.virtual_machines.items(): + subscription_name = vm_client.subscriptions.get( + subscription_id, subscription_id + ) for vm in vms.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=vm) report.status = "PASS" - report.subscription = subscription_name - report.status_extended = f"VM {vm.resource_name} is using managed disks in subscription {subscription_name}" + report.subscription = subscription_id + report.status_extended = f"VM {vm.resource_name} is using managed disks in subscription {subscription_name} ({subscription_id})" using_managed_disks = ( True @@ -31,7 +34,7 @@ class vm_ensure_using_managed_disks(Check): if not using_managed_disks: report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} is not using managed disks in subscription {subscription_name}" + report.status_extended = f"VM {vm.resource_name} is not using managed disks in subscription {subscription_name} ({subscription_id})" findings.append(report) diff --git a/prowler/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled.py b/prowler/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled.py index 9434608454..913b972b9b 100644 --- a/prowler/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled.py +++ b/prowler/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled.py @@ -15,19 +15,22 @@ class vm_jit_access_enabled(Check): def execute(self): findings = [] jit_enabled_vms = set() - for subscription_name, vms in vm_client.virtual_machines.items(): - for jit_policy in defender_client.jit_policies[subscription_name].values(): + for subscription_id, vms in vm_client.virtual_machines.items(): + subscription_name = defender_client.subscriptions.get( + subscription_id, subscription_id + ) + for jit_policy in defender_client.jit_policies[subscription_id].values(): jit_enabled_vms.update(jit_policy.vm_ids) for vm in vms.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=vm) - report.subscription = subscription_name + report.subscription = subscription_id if vm.resource_id.lower() in { vm_id.lower() for vm_id in jit_enabled_vms }: report.status = "PASS" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} has JIT (Just-in-Time) access enabled." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) has JIT (Just-in-Time) access enabled." else: report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} does not have JIT (Just-in-Time) access enabled." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) does not have JIT (Just-in-Time) access enabled." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.py b/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.py index c31b2c9a18..7783a42009 100644 --- a/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.py +++ b/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.py @@ -13,17 +13,20 @@ class vm_linux_enforce_ssh_authentication(Check): def execute(self) -> list[Check_Report_Azure]: findings = [] - for subscription_name, vms in vm_client.virtual_machines.items(): + for subscription_id, vms in vm_client.virtual_machines.items(): + subscription_name = vm_client.subscriptions.get( + subscription_id, subscription_id + ) for vm in vms.values(): if vm.linux_configuration: report = Check_Report_Azure(metadata=self.metadata(), resource=vm) - report.subscription = subscription_name + report.subscription = subscription_id if vm.linux_configuration.disable_password_authentication: report.status = "PASS" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} has password authentication disabled (SSH key authentication enforced)." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) has password authentication disabled (SSH key authentication enforced)." else: report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} has password authentication enabled (password-based SSH allowed)." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription_id}) has password authentication enabled (password-based SSH allowed)." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py index c6150f1d7f..4b893397b1 100644 --- a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py +++ b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py @@ -14,6 +14,7 @@ class vm_scaleset_associated_with_load_balancer(Check): def execute(self): findings = [] for subscription, scale_sets in vm_client.vm_scale_sets.items(): + subscription_name = vm_client.subscriptions.get(subscription, subscription) for scale_set in scale_sets.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=scale_set @@ -28,9 +29,9 @@ class vm_scaleset_associated_with_load_balancer(Check): pool.split("/")[-1] for pool in scale_set.load_balancer_backend_pools ] - report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is associated with load balancer backend pool(s): {', '.join(backend_pool_names)}." + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription_name} ({subscription})' is associated with load balancer backend pool(s): {', '.join(backend_pool_names)}." else: report.status = "FAIL" - report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is not associated with any load balancer backend pool." + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription_name} ({subscription})' is not associated with any load balancer backend pool." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py b/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py index 4061fe4790..ac77dd01e0 100644 --- a/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py +++ b/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py @@ -14,6 +14,7 @@ class vm_scaleset_not_empty(Check): def execute(self): findings = [] for subscription, scale_sets in vm_client.vm_scale_sets.items(): + subscription_name = vm_client.subscriptions.get(subscription, subscription) for scale_set in scale_sets.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=scale_set @@ -21,9 +22,9 @@ class vm_scaleset_not_empty(Check): report.subscription = subscription if not scale_set.instance_ids: report.status = "FAIL" - report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is empty: no VM instances present." + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription_name} ({subscription})' is empty: no VM instances present." else: report.status = "PASS" - report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' has {len(scale_set.instance_ids)} VM instances." + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription_name} ({subscription})' has {len(scale_set.instance_ids)} VM instances." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_service.py b/prowler/providers/azure/services/vm/vm_service.py index ea63de6197..b20f4b5678 100644 --- a/prowler/providers/azure/services/vm/vm_service.py +++ b/prowler/providers/azure/services/vm/vm_service.py @@ -20,10 +20,10 @@ class VirtualMachines(AzureService): logger.info("VirtualMachines - Getting virtual machines...") virtual_machines = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: virtual_machines_list = client.virtual_machines.list_all() - virtual_machines.update({subscription_name: {}}) + virtual_machines.update({subscription_id: {}}) for vm in virtual_machines_list: storage_profile = getattr(vm, "storage_profile", None) @@ -98,7 +98,7 @@ class VirtualMachines(AzureService): uefi_settings=uefi_settings, ) - virtual_machines[subscription_name].update( + virtual_machines[subscription_id].update( { vm.id: VirtualMachine( resource_id=vm.id, @@ -144,7 +144,7 @@ class VirtualMachines(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return virtual_machines @@ -153,10 +153,10 @@ class VirtualMachines(AzureService): logger.info("VirtualMachines - Getting disks...") disks = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: disks_list = client.disks.list() - disks.update({subscription_name: {}}) + disks.update({subscription_id: {}}) for disk in disks_list: vms_attached = [] @@ -164,7 +164,7 @@ class VirtualMachines(AzureService): vms_attached.append(disk.managed_by) if disk.managed_by_extended: vms_attached.extend(disk.managed_by_extended) - disks[subscription_name].update( + disks[subscription_id].update( { disk.unique_id: Disk( resource_id=disk.id, @@ -179,7 +179,7 @@ class VirtualMachines(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return disks @@ -191,7 +191,7 @@ class VirtualMachines(AzureService): Returns: A nested dictionary with the following structure: { - "subscription_name": { + "subscription_id": { "vm_scale_set_id": VirtualMachineScaleSet() } } @@ -200,10 +200,10 @@ class VirtualMachines(AzureService): "VirtualMachines - Getting VM scale sets and their load balancer associations..." ) vm_scale_sets = {} - for subscription_name, client in self.clients.items(): + for subscription_id, client in self.clients.items(): try: scale_sets = client.virtual_machine_scale_sets.list_all() - vm_scale_sets[subscription_name] = {} + vm_scale_sets[subscription_id] = {} for scale_set in scale_sets: backend_pools = [] nic_configs = [] @@ -235,9 +235,9 @@ class VirtualMachines(AzureService): backend_pools.append(pool.id) # Get instance IDs using the private method instance_ids = self._get_vmss_instance_ids( - subscription_name, scale_set.id + subscription_id, scale_set.id ) - vm_scale_sets[subscription_name][scale_set.id] = ( + vm_scale_sets[subscription_id][scale_set.id] = ( VirtualMachineScaleSet( resource_id=scale_set.id, resource_name=scale_set.name, @@ -248,28 +248,28 @@ class VirtualMachines(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return vm_scale_sets def _get_vmss_instance_ids( - self, subscription_name: str, scale_set_id: str + self, subscription_id: str, scale_set_id: str ) -> list[str]: """ Given a subscription and scale set ID, return the list of VM instance IDs in the scale set. Args: - subscription_name: The name of the subscription. + subscription_id: The name of the subscription. scale_set_id: The ID of the scale set. Returns: A list of VM instance IDs that compose the scale set. """ logger.info( - f"VirtualMachines - Getting VM scale set instance IDs for {scale_set_id} in {subscription_name}..." + f"VirtualMachines - Getting VM scale set instance IDs for {scale_set_id} in {subscription_id}..." ) vm_instance_ids = [] - client = self.clients.get(subscription_name, None) + client = self.clients.get(subscription_id, None) try: resource_id_parts = scale_set_id.split("/") resource_group = "" diff --git a/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py b/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py index 444cfcfa3b..09017d26b5 100644 --- a/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py +++ b/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py @@ -19,6 +19,9 @@ class vm_sufficient_daily_backup_retention_period(Check): ) for subscription, vms in vm_client.virtual_machines.items(): + subscription_name = recovery_client.subscriptions.get( + subscription, subscription + ) vaults = recovery_client.vaults.get(subscription, {}) for vm in vms.values(): backup_found = False @@ -44,9 +47,9 @@ class vm_sufficient_daily_backup_retention_period(Check): report.subscription = subscription if retention_days >= min_retention_days: report.status = "PASS" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription} has a daily backup retention period of {retention_days} days (minimum required: {min_retention_days})." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription}) has a daily backup retention period of {retention_days} days (minimum required: {min_retention_days})." else: report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} in subscription {subscription} has insufficient daily backup retention period of {retention_days} days (minimum required: {min_retention_days})." + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} ({subscription}) has insufficient daily backup retention period of {retention_days} days (minimum required: {min_retention_days})." findings.append(report) return findings diff --git a/prowler/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled.py b/prowler/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled.py index d4896b70ec..4a5163c2db 100644 --- a/prowler/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled.py +++ b/prowler/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled.py @@ -6,12 +6,15 @@ class vm_trusted_launch_enabled(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription_name, vms in vm_client.virtual_machines.items(): + for subscription_id, vms in vm_client.virtual_machines.items(): + subscription_name = vm_client.subscriptions.get( + subscription_id, subscription_id + ) for vm in vms.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=vm) - report.subscription = subscription_name + report.subscription = subscription_id report.status = "FAIL" - report.status_extended = f"VM {vm.resource_name} has trusted launch disabled in subscription {subscription_name}" + report.status_extended = f"VM {vm.resource_name} has trusted launch disabled in subscription {subscription_name} ({subscription_id})" if ( vm.security_profile @@ -20,7 +23,7 @@ class vm_trusted_launch_enabled(Check): and vm.security_profile.uefi_settings.v_tpm_enabled ): report.status = "PASS" - report.status_extended = f"VM {vm.resource_name} has trusted launch enabled in subscription {subscription_name}" + report.status_extended = f"VM {vm.resource_name} has trusted launch enabled in subscription {subscription_name} ({subscription_id})" findings.append(report) diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py index 35dd88ef76..9c39839f5d 100644 --- a/prowler/providers/cloudflare/cloudflare_provider.py +++ b/prowler/providers/cloudflare/cloudflare_provider.py @@ -46,6 +46,7 @@ class CloudflareProvider(Provider): """Cloudflare provider.""" _type: str = "cloudflare" + sdk_only: bool = False _session: CloudflareSession _identity: CloudflareIdentityInfo _audit_config: dict @@ -274,8 +275,12 @@ class CloudflareProvider(Provider): for account in client.accounts.list(): account_id = getattr(account, "id", None) - # Prevent infinite loop - skip if we've seen this account + # Prevent infinite loop on repeated pages from the SDK paginator if account_id in seen_account_ids: + logger.warning( + "Detected repeated Cloudflare account ID while listing accounts. " + "Stopping pagination to avoid an infinite loop." + ) break seen_account_ids.add(account_id) @@ -332,19 +337,16 @@ class CloudflareProvider(Provider): return except PermissionDeniedError as error: error_str = str(error) - # Check for user-level authentication required (code 9109) - if "9109" in error_str: - logger.error(f"CloudflareUserTokenRequiredError: {error}") - raise CloudflareUserTokenRequiredError( - file=os.path.basename(__file__), - ) # Check for invalid API key or email (code 9103) - comes as 403 if "9103" in error_str or "Unknown X-Auth-Key" in error_str: logger.error(f"CloudflareInvalidAPIKeyError: {error}") raise CloudflareInvalidAPIKeyError( file=os.path.basename(__file__), ) - # For other permission errors, try accounts.list() as fallback + # For permission errors (including 9109 account-scoped tokens), + # try accounts.list() as fallback before failing. + # Error 9109 means the token is account-scoped, not user-level, + # which is valid for scanning — only fail if accounts.list() also fails. logger.warning( f"Unable to retrieve Cloudflare user info: {error}. " "Trying accounts.list() as fallback." @@ -398,7 +400,20 @@ class CloudflareProvider(Provider): # Fallback: try accounts.list() try: - accounts = list(client.accounts.list()) + accounts: list = [] + seen_account_ids: set = set() + for account in client.accounts.list(): + account_id = getattr(account, "id", None) + # Prevent infinite loop on repeated pages from the SDK paginator + if account_id in seen_account_ids: + logger.warning( + "Detected repeated Cloudflare account ID while validating credentials. " + "Stopping pagination to avoid an infinite loop." + ) + break + seen_account_ids.add(account_id) + accounts.append(account) + if not accounts: logger.error("CloudflareNoAccountsError: No accounts found") raise CloudflareNoAccountsError( diff --git a/prowler/providers/cloudflare/lib/plan.py b/prowler/providers/cloudflare/lib/plan.py new file mode 100644 index 0000000000..e6fa20d77d --- /dev/null +++ b/prowler/providers/cloudflare/lib/plan.py @@ -0,0 +1,35 @@ +from typing import Optional + +# Cloudflare returns the plan name in ``zone.plan.name`` (e.g. "Free Website", +# "Pro Website", "Business Website", "Enterprise Website"). Free plans do not +# expose WAF managed rulesets at all, while paid plans expose them but the +# legacy ``waf`` zone setting can lag behind the actual deployment state. +PAID_PLAN_KEYWORDS = ("pro", "business", "enterprise") +FREE_PLAN_KEYWORDS = ("free",) + + +def _plan_matches(plan: Optional[str], keywords: tuple[str, ...]) -> bool: + if not isinstance(plan, str): + return False + plan_lower = plan.lower() + return any(keyword in plan_lower for keyword in keywords) + + +def is_paid_plan(plan: Optional[str]) -> bool: + """Return True when the Cloudflare zone plan is a paid tier.""" + return _plan_matches(plan, PAID_PLAN_KEYWORDS) + + +def is_free_plan(plan: Optional[str]) -> bool: + """Return True when the Cloudflare zone plan is the Free tier.""" + return _plan_matches(plan, FREE_PLAN_KEYWORDS) + + +def paid_plan_suffix(plan: Optional[str], message: str) -> str: + """Return an explanatory suffix only when the zone is on a paid plan.""" + return f" {message}" if is_paid_plan(plan) else "" + + +def free_plan_suffix(plan: Optional[str], message: str) -> str: + """Return an explanatory suffix only when the zone is on the Free plan.""" + return f" {message}" if is_free_plan(plan) else "" diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py index 64265c3985..4d3a7eed71 100644 --- a/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py +++ b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py @@ -1,6 +1,19 @@ from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.lib.plan import ( + free_plan_suffix, + paid_plan_suffix, +) from prowler.providers.cloudflare.services.zone.zone_client import zone_client +PAID_PLAN_FALSE_POSITIVE_HINT = ( + "This may be a false positive if WAF managed rulesets are configured via " + "the Cloudflare dashboard; verify manually in Security > WAF." +) +FREE_PLAN_UNAVAILABLE_HINT = ( + "This may be expected because the Web Application Firewall is not " + "available on the Cloudflare Free plan." +) + class zone_waf_enabled(Check): """Ensure that WAF is enabled for Cloudflare zones. @@ -35,6 +48,16 @@ class zone_waf_enabled(Check): report.status_extended = f"WAF is enabled for zone {zone.name}." else: report.status = "FAIL" - report.status_extended = f"WAF is not enabled for zone {zone.name}." + # Two plan-specific hints can be appended to the FAIL message: + # - Paid plans: the legacy ``waf`` zone setting can read ``off`` + # while WAF managed rulesets are deployed via the dashboard, + # so the FAIL may be a false positive. + # - Free plans: WAF is not available at all, so the FAIL is + # expected and the suffix points that out. + report.status_extended = ( + f"WAF is not enabled for zone {zone.name}." + f"{paid_plan_suffix(zone.plan, PAID_PLAN_FALSE_POSITIVE_HINT)}" + f"{free_plan_suffix(zone.plan, FREE_PLAN_UNAVAILABLE_HINT)}" + ) findings.append(report) return findings diff --git a/prowler/providers/common/arguments.py b/prowler/providers/common/arguments.py index 727568a68f..2e4d9c8896 100644 --- a/prowler/providers/common/arguments.py +++ b/prowler/providers/common/arguments.py @@ -10,24 +10,107 @@ provider_arguments_lib_path = "lib.arguments.arguments" validate_provider_arguments_function = "validate_arguments" init_provider_arguments_function = "init_parser" +# Kept in sync with parser.py's argv normalisation; both consumers import this. +PROVIDER_ALIASES = { + "microsoft365": "m365", + "oci": "oraclecloud", +} + + +def _invoked_provider_from_argv(available_providers: Sequence[str]) -> Optional[str]: + """Return the provider name the user invoked, or None. + + Mirrors `ProwlerArgumentParser.parse()` resolution: only inspects + `sys.argv[1]`. Scanning the whole argv would misclassify + `prowler --output-directory stackit` as `stackit`. + """ + available = set(available_providers) + if len(sys.argv) < 2: + return "aws" if "aws" in available else None + first = sys.argv[1] + if first in ("-h", "--help", "-v", "--version"): + return None + if first.startswith("-"): + return "aws" if "aws" in available else None + normalized = PROVIDER_ALIASES.get(first, first) + return normalized if normalized in available else None + def init_providers_parser(self): - """init_providers_parser calls the provider init_parser function to load all the arguments and flags. Receives a ProwlerArgumentParser object""" - # We need to call the arguments parser for each provider + """Build the subparser of each available provider. + + Built-in load failures are captured silently on + `self._builtin_load_failures`; the warn/exit decision is deferred to + `enforce_invoked_provider_loaded()` because `parse(args=...)` can + override `sys.argv` after this function ran. + """ + self._builtin_load_failures = {} providers = Provider.get_available_providers() for provider in providers: - try: - getattr( - import_module( - f"{providers_path}.{provider}.{provider_arguments_lib_path}" - ), - init_provider_arguments_function, - )(self) - except Exception as error: - logger.critical( + if Provider.is_builtin(provider): + try: + getattr( + import_module( + f"{providers_path}.{provider}.{provider_arguments_lib_path}" + ), + init_provider_arguments_function, + )(self) + except Exception as error: + self._builtin_load_failures[provider] = error + else: + cls = Provider._load_ep_provider(provider) + if cls and hasattr(cls, "init_parser"): + try: + cls.init_parser(self) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +def enforce_invoked_provider_loaded(self): + """Apply selective fail-loud over the failures captured at init time. + + Called by `ProwlerArgumentParser.parse()` AFTER argv normalisation so + the invoked provider matches what argparse will dispatch to — including + the case where `parse(args=...)` overrode the ambient `sys.argv`. + + Invoked + failed → critical + `sys.exit(1)`. Others → warning. + """ + failures = getattr(self, "_builtin_load_failures", {}) + if not failures: + return + invoked = _invoked_provider_from_argv(Provider.get_available_providers()) + for provider, error in failures.items(): + if provider == invoked: + continue + if isinstance(error, ImportError): + logger.warning( + f"Skipping built-in provider '{provider}' due to missing " + f"dependency: {error}. It will be unavailable in this " + f"invocation, but the CLI continues because you invoked a " + f"different provider." + ) + else: + logger.warning( + f"Skipping built-in provider '{provider}': " f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - sys.exit(1) + if invoked is None or invoked not in failures: + return + error = failures[invoked] + if isinstance(error, ImportError): + logger.critical( + f"Failed to load arguments for built-in provider '{invoked}'. " + f"Missing dependency: {error}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + else: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + sys.exit(1) def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]: @@ -70,3 +153,19 @@ def validate_asff_usage( False, f"json-asff output format is only available for the aws provider, but {provider} was selected", ) + + +def validate_sarif_usage( + provider: Optional[str], output_formats: Optional[Sequence[str]] +) -> tuple[bool, str]: + """Ensure sarif output is only requested for the IaC provider.""" + if not output_formats or "sarif" not in output_formats: + return (True, "") + + if provider == "iac": + return (True, "") + + return ( + False, + f"sarif output format is only available for the iac provider, but {provider} was selected", + ) diff --git a/prowler/providers/common/builtin.py b/prowler/providers/common/builtin.py new file mode 100644 index 0000000000..d60b5483d9 --- /dev/null +++ b/prowler/providers/common/builtin.py @@ -0,0 +1,29 @@ +"""Leaf helper for built-in provider detection. + +Lives in its own module — with no imports back into `prowler.lib.check` — so +that callers in `prowler.lib.check.*` can ask "is this provider built-in?" +without creating an import cycle through `prowler.providers.common.provider` +(which transitively imports `prowler.config.config` and from there +`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`). + +Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate +to a leaf module is the canonical way to break the cycle in this codebase. +""" + +import importlib.util + + +def is_builtin_provider(provider: str) -> bool: + """Return True if the provider's own package ships with the SDK. + + Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)` + because `find_spec` propagates `ModuleNotFoundError` when a parent package + in the dotted path does not exist (instead of returning `None`). The + try/except is what makes the call safe for external providers, whose + package does not live under `prowler.providers.{provider}`. + """ + try: + spec = importlib.util.find_spec(f"prowler.providers.{provider}") + return spec is not None + except (ImportError, ValueError): + return False diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index ea70252f0a..84e71c4809 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -4,6 +4,7 @@ from os.path import isdir from pydantic.v1 import BaseModel +from prowler.config.config import output_file_timestamp from prowler.providers.common.provider import Provider @@ -48,6 +49,16 @@ class ProviderOutputOptions: if updated_audit_config: provider._audit_config = updated_audit_config + # Secrets validation: --scan-secrets-validate opts into live validation + # of discovered secrets. Set the audit_config key directly so it applies + # even for providers whose default config does not declare it. + self.scan_secrets_validate = getattr(arguments, "scan_secrets_validate", False) + if self.scan_secrets_validate: + provider = Provider.get_global_provider() + audit_config = provider.audit_config or {} + audit_config["secrets_validate"] = True + provider._audit_config = audit_config + # Check output directory, if it is not created -> create it if self.output_directory and not self.fixer: if not isdir(self.output_directory): @@ -69,3 +80,15 @@ class Connection: is_connected: bool = False error: Exception = None + + +def default_output_options(provider, arguments, bulk_checks_metadata): + """Generic OutputOptions fallback for external providers that do not + implement get_output_options, so the run still produces output instead of + aborting. Honors arguments.output_filename and otherwise derives a name + from the provider type.""" + output_options = ProviderOutputOptions(arguments, bulk_checks_metadata) + output_options.output_filename = getattr(arguments, "output_filename", None) or ( + f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + return output_options diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index debc1c4ef2..e69f2cb1d5 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -1,4 +1,7 @@ import importlib +import importlib.metadata +import importlib.util +import os import pkgutil import sys from abc import ABC, abstractmethod @@ -135,6 +138,181 @@ class Provider(ABC): """ return set() + # --- Dynamic provider contract methods (not @abstractmethod for incremental migration) --- + + _cli_help_text: str = "" + + # CLI/SDK-only provider, hidden from the app (API/UI). Defaults True; a + # provider opts into the app with ``sdk_only = False``. See get_app_providers(). + sdk_only: bool = True + + @classmethod + def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider": + """Instantiate the provider from CLI arguments and return the instance. + + The caller wires the returned instance into the global provider slot + via Provider.set_global_provider(). Implementations that already call + set_global_provider(self) from __init__ are also supported — the call + site tolerates a None return in that case. + """ + raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()") + + def get_output_options(self, arguments, _bulk_checks_metadata): + """Create the provider-specific OutputOptions.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_output_options()" + ) + + def get_stdout_detail(self, _finding) -> str: + """Return the detail string for stdout reporting (region, location, etc.).""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_stdout_detail()" + ) + + def get_finding_sort_key(self) -> Optional[str]: + """Return the attribute name to sort findings by, or None for no sorting.""" + return None + + def get_summary_entity(self) -> tuple: + """Return (entity_type, audited_entities) for the summary table.""" + return (self.type, getattr(self.identity, "account_id", "")) + + def get_finding_output_data(self, _check_output) -> dict: + """Return provider-specific fields for Finding.generate_output().""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_finding_output_data()" + ) + + def get_html_assessment_summary(self) -> str: + """Return the HTML assessment summary card for this provider.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_html_assessment_summary()" + ) + + def generate_compliance_output( + self, + _findings, + _bulk_compliance_frameworks, + _input_compliance_frameworks, + _output_options, + _generated_outputs, + ) -> None: + """Generate compliance CSV output for this provider's frameworks.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented generate_compliance_output()" + ) + + def get_mutelist_finding_args(self) -> dict: + """Return extra kwargs for mutelist.is_finding_muted() besides 'finding'. + + External providers must return a dict with the identity key their + Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``. + The ``finding`` kwarg is added automatically by the caller. + """ + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()" + ) + + @classmethod + def get_scan_arguments( + cls, + provider_uid: str, + secret: dict, + mutelist_content: Optional[dict] = None, + ) -> dict: + """Build the provider constructor kwargs from a stored uid and secret. + + This is the programmatic construction interface intended for callers + that will persist a provider as a single ``uid`` plus a ``secret`` dict + (e.g. the API), as opposed to the CLI which passes explicit per-provider + flags. + + The base implementation is a default: it passes the secret through, adds + the mutelist, and intentionally drops ``provider_uid``. The API consumes + this contract for external providers, so an external provider whose uid + is part of the scan scope (e.g. a subscription or project id) or that + renames/filters secret keys overrides this to inject the uid into the + right kwarg; until it does, the base default is not the final shape for + that provider. Built-in providers whose scope derives from the uid are + mapped on the API side and do not go through this method. + """ + kwargs = {**secret} + if mutelist_content is not None: + kwargs["mutelist_content"] = mutelist_content + return kwargs + + @classmethod + def get_connection_arguments(cls, provider_uid: str, secret: dict) -> dict: + """Build the ``test_connection`` kwargs from a stored uid and secret. + + Companion to :meth:`get_scan_arguments` for the connection check, which + often needs a different shape than the constructor. The base passes the + secret through and intentionally drops ``provider_uid``. An external + provider whose uid is part of the scope overrides this to add its + identity kwarg (and ``provider_id`` where its ``test_connection`` + expects it); built-in providers are mapped on the API side and do not go + through this method. + """ + return {**secret} + + @classmethod + def get_credentials_schema(cls) -> dict: + """Return the provider's credential schemas keyed by secret type. + + Maps each secret type the provider accepts (``"static"``, ``"role"`` or + ``"service_account"``) to the pydantic model that validates a secret of + that type. The provider declares which type each schema belongs to, so + the API validates a secret against the model for the secret type it is + created with and the chosen type stays bound to the shape it claims. + + Each model documents each field via ``Field(description=...)`` and + whether it is required (no default) or optional. An empty dict means no + schema is declared: the secret is accepted as an object and validated by + :meth:`test_connection`. + """ + return {} + + def display_compliance_table( + self, + _findings: list, + _bulk_checks_metadata: dict, + _compliance_framework: str, + _output_filename: str, + _output_directory: str, + _compliance_overview: bool, + ) -> bool: + """Render a custom compliance table in the terminal. + + External providers can override this to display a detailed + compliance table (e.g., per-section breakdown). Return True + if the table was rendered, False to fall back to the generic table. + """ + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented display_compliance_table()" + ) + + # Class-level flag: True for providers that delegate scanning to an external + # tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and + # metadata validation. Subclasses override as `is_external_tool_provider = True`. + # Kept as a class attribute (not a property) so it can be read from the class + # without instantiation — the metadata validators in lib.check.models need to + # decide whether to relax validation before any provider instance exists. + is_external_tool_provider: bool = False + + # --- End dynamic provider contract methods --- + + @staticmethod + def get_excluded_regions_from_env() -> set: + """Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable. + + The variable is a comma-separated list of region identifiers to skip + during scans (e.g. "me-south-1, ap-east-1"). Whitespace around entries + is tolerated and empty entries are dropped. Returns an empty set when + the variable is unset or contains no usable values. + """ + raw = os.environ.get("PROWLER_AWS_DISALLOWED_REGIONS", "") + return {region.strip() for region in raw.split(",") if region.strip()} + @staticmethod def get_global_provider() -> "Provider": return Provider._global @@ -146,20 +324,62 @@ class Provider(ABC): @staticmethod def init_global_provider(arguments: Namespace) -> None: try: - provider_class_path = ( - f"{providers_path}.{arguments.provider}.{arguments.provider}_provider" - ) - provider_class_name = f"{arguments.provider.capitalize()}Provider" - provider_class = getattr( - import_module(provider_class_path), provider_class_name - ) + # Delegate class resolution to the public, side-effect-free + # resolver. init_global_provider owns the CLI-specific error + # handling: a missing transitive dep in a built-in becomes a + # logger.critical + sys.exit(1); a completely unknown provider + # re-raises so the outer try/except can sys.exit too. + try: + provider_class = Provider.get_class(arguments.provider) + except ImportError as e: + if Provider.is_builtin(arguments.provider): + # Built-in's transitive dependency is missing — loud CLI error. + logger.critical( + f"Failed to load built-in provider '{arguments.provider}'. " + f"Missing dependency: {e}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + sys.exit(1) + # Unknown or missing external provider — propagate so the + # outer try/except can handle it (sys.exit(1) via generic + # exception handler). + raise + + # Built-in wins on name collision — warn that a same-named + # plug-in is ignored. This lives here (not in get_class) so + # that `prowler --help` and API callers that resolve a class + # without initialising a global provider do not see spurious + # warnings. Match by name only — never ep.load() a shadowing + # plug-in, or its module code would run during a built-in run. + if Provider.is_builtin(arguments.provider) and any( + ep.name == arguments.provider + for ep in importlib.metadata.entry_points(group="prowler.providers") + ): + logger.warning( + f"Plug-in provider '{arguments.provider}' registered " + f"via entry points is being IGNORED — a built-in with " + f"the same name exists. To use your plug-in, register " + f"it under a different name." + ) fixer_config = load_and_validate_config_file( arguments.provider, arguments.fixer_config ) + # Dispatch by exact provider name (equality, not substring) so + # external plug-ins whose names contain a built-in substring + # (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed + # to the wrong built-in branch. Anything that doesn't match a + # built-in falls through to the dynamic else and uses the + # contract's `from_cli_args`. if not isinstance(Provider._global, provider_class): - if "aws" in provider_class_name.lower(): + if arguments.provider == "aws": + excluded_regions = ( + set(arguments.excluded_region) + if getattr(arguments, "excluded_region", None) + else None + ) provider_class( retries_max_attempts=arguments.aws_retries_max_attempts, role_arn=arguments.role, @@ -169,6 +389,7 @@ class Provider(ABC): mfa=arguments.mfa, profile=arguments.profile, regions=set(arguments.region) if arguments.region else None, + excluded_regions=excluded_regions, organizations_role_arn=arguments.organizations_role, scan_unused_services=arguments.scan_unused_services, resource_tags=arguments.resource_tag, @@ -177,7 +398,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "azure" in provider_class_name.lower(): + elif arguments.provider == "azure": provider_class( az_cli_auth=arguments.az_cli_auth, sp_env_auth=arguments.sp_env_auth, @@ -190,7 +411,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "gcp" in provider_class_name.lower(): + elif arguments.provider == "gcp": provider_class( retries_max_attempts=arguments.gcp_retries_max_attempts, organization_id=arguments.organization_id, @@ -204,7 +425,7 @@ class Provider(ABC): fixer_config=fixer_config, skip_api_check=arguments.skip_api_check, ) - elif "kubernetes" in provider_class_name.lower(): + elif arguments.provider == "kubernetes": provider_class( kubeconfig_file=arguments.kubeconfig_file, context=arguments.context, @@ -214,7 +435,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "m365" in provider_class_name.lower(): + elif arguments.provider == "m365": provider_class( region=arguments.region, config_path=arguments.config_file, @@ -228,7 +449,7 @@ class Provider(ABC): init_modules=arguments.init_modules, fixer_config=fixer_config, ) - elif "nhn" in provider_class_name.lower(): + elif arguments.provider == "nhn": provider_class( username=arguments.nhn_username, password=arguments.nhn_password, @@ -237,7 +458,26 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "github" in provider_class_name.lower(): + elif arguments.provider == "stackit": + provider_class( + project_id=arguments.stackit_project_id, + service_account_key_path=getattr( + arguments, "stackit_service_account_key_path", None + ), + service_account_key=getattr( + arguments, "stackit_service_account_key", None + ), + regions=( + set(arguments.stackit_region) + if arguments.stackit_region + else None + ), + scan_unused_services=arguments.scan_unused_services, + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) + elif arguments.provider == "github": orgs = [] repos = [] @@ -261,15 +501,21 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, config_path=arguments.config_file, 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 "googleworkspace" in provider_class_name.lower(): + elif arguments.provider == "googleworkspace": provider_class( config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "cloudflare" in provider_class_name.lower(): + elif arguments.provider == "cloudflare": provider_class( filter_zones=arguments.region, filter_accounts=arguments.account_id, @@ -277,7 +523,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "iac" in provider_class_name.lower(): + elif arguments.provider == "iac": provider_class( scan_path=arguments.scan_path, scan_repository_url=arguments.scan_repository_url, @@ -290,13 +536,13 @@ class Provider(ABC): oauth_app_token=arguments.oauth_app_token, provider_uid=arguments.provider_uid, ) - elif "llm" in provider_class_name.lower(): + elif arguments.provider == "llm": provider_class( max_concurrency=arguments.max_concurrency, config_path=arguments.config_file, fixer_config=fixer_config, ) - elif "image" in provider_class_name.lower(): + elif arguments.provider == "image": provider_class( images=arguments.images, image_list_file=arguments.image_list_file, @@ -314,7 +560,7 @@ class Provider(ABC): registry_insecure=arguments.registry_insecure, registry_list_images=arguments.registry_list_images, ) - elif "mongodbatlas" in provider_class_name.lower(): + elif arguments.provider == "mongodbatlas": provider_class( atlas_public_key=arguments.atlas_public_key, atlas_private_key=arguments.atlas_private_key, @@ -323,7 +569,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "oraclecloud" in provider_class_name.lower(): + elif arguments.provider == "oraclecloud": provider_class( oci_config_file=arguments.oci_config_file, profile=arguments.profile, @@ -334,7 +580,7 @@ class Provider(ABC): fixer_config=fixer_config, use_instance_principal=arguments.use_instance_principal, ) - elif "openstack" in provider_class_name.lower(): + elif arguments.provider == "openstack": provider_class( clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None), clouds_yaml_content=getattr( @@ -359,7 +605,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "alibabacloud" in provider_class_name.lower(): + elif arguments.provider == "alibabacloud": provider_class( role_arn=arguments.role_arn, role_session_name=arguments.role_session_name, @@ -371,13 +617,60 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "vercel" in provider_class_name.lower(): + elif arguments.provider == "vercel": provider_class( projects=getattr(arguments, "project", None), config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif arguments.provider == "okta": + provider_class( + okta_org_domain=getattr(arguments, "okta_org_domain", ""), + okta_client_id=getattr(arguments, "okta_client_id", ""), + okta_private_key=getattr(arguments, "okta_private_key", ""), + okta_private_key_file=getattr( + arguments, "okta_private_key_file", "" + ), + okta_scopes=getattr(arguments, "okta_scopes", None), + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) + elif arguments.provider == "scaleway": + # Credentials are read from the SCW_ACCESS_KEY / + # SCW_SECRET_KEY env vars by the provider itself; there + # are no credential CLI flags to avoid leaking secrets. + provider_class( + organization_id=getattr(arguments, "organization_id", None), + project_id=getattr(arguments, "project_id", None), + region=getattr(arguments, "region", None), + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) + elif arguments.provider == "linode": + # Credentials are read from the LINODE_TOKEN env var by the + # provider itself; there are no credential CLI flags to + # avoid leaking secrets. + provider_class( + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + regions=getattr(arguments, "region", None), + ) + else: + # Dynamic fallback: any external/custom provider. + # Honor the from_cli_args type hint (-> Provider): if the + # implementation returns an instance, wire it as the global + # provider here. Implementations that call + # set_global_provider(self) from __init__ return None and + # remain supported (the condition below is a no-op for them). + provider_instance = provider_class.from_cli_args( + arguments, fixer_config + ) + if provider_instance is not None: + Provider.set_global_provider(provider_instance) except TypeError as error: logger.critical( @@ -390,17 +683,163 @@ class Provider(ABC): ) sys.exit(1) + # Cache for entry-point provider classes {name: class} + _ep_providers: dict = {} + @staticmethod def get_available_providers() -> list[str]: """get_available_providers returns a list of the available providers""" - providers = [] - # Dynamically import the package based on its string path + providers = set() + # Built-in providers from local package prowler_providers = importlib.import_module(providers_path) - # Iterate over all modules found in the prowler_providers package for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__): if provider != "common" and ispkg: - providers.append(provider) - return providers + providers.add(provider) + # External providers registered via entry points + for ep in importlib.metadata.entry_points(group="prowler.providers"): + providers.add(ep.name) + return sorted(providers) + + @staticmethod + def get_app_providers() -> list[str]: + """Return the providers the app (API/UI) may expose: those with + ``sdk_only = False``. + + Counterpart of :meth:`get_available_providers`, which lists every + provider for the CLI. A provider whose class cannot be imported is + treated as ``sdk_only`` (excluded) so a broken plug-in never leaks in. + """ + app_providers = [] + for name in Provider.get_available_providers(): + try: + provider_class = Provider.get_class(name) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not getattr(provider_class, "sdk_only", True): + app_providers.append(name) + return app_providers + + @staticmethod + def is_tool_wrapper_provider(provider: str) -> bool: + """Return True if the provider delegates scanning to an external tool. + + Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`, + the leaf module that holds the actual logic. Kept on `Provider` as a + convenience entry point for callers that already import `Provider`. + """ + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl + + return _impl(provider) + + @staticmethod + def is_builtin(provider: str) -> bool: + """Return True if the provider's own package is importable as a built-in. + + Delegates to `prowler.providers.common.builtin.is_builtin_provider`, + the leaf module that holds the actual check. Kept on `Provider` as a + convenience entry point for callers that already import `Provider`. + Call sites in `prowler.lib.check.*` should import from the leaf + directly to avoid the import cycle through this module. + """ + from prowler.providers.common.builtin import is_builtin_provider as _impl + + return _impl(provider) + + @staticmethod + def _load_ep_provider(name: str): + """Load an external provider class from entry points, with cache. + + Caches both hits and misses so repeated lookups for unknown names do + not re-iterate entry_points(). Symmetric with + tool_wrapper._ep_class_cache. + """ + if name in Provider._ep_providers: + return Provider._ep_providers[name] + for ep in importlib.metadata.entry_points(group="prowler.providers"): + if ep.name == name: + try: + cls = ep.load() + Provider._ep_providers[name] = cls + return cls + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + Provider._ep_providers[name] = None + return None + + @staticmethod + def get_class(provider: str) -> type: + """Resolve the provider class for a name (built-in or entry-point). + + Does not call ``sys.exit`` and does not initialize the global + provider (it may populate the ``_ep_providers`` memoization cache). + Collision warnings are emitted by ``init_global_provider``, not here. + The caller handles errors (CLI exits; the API can return HTTP 400). + + Args: + provider: Provider name, e.g. ``"aws"`` or an external plug-in. + + Returns: + The provider class (a subclass of :class:`Provider`). + + Raises: + ImportError: If not found as built-in or entry point, a built-in's + transitive dependency is missing, or an entry point resolves to + an object that is not a subclass of :class:`Provider`. + """ + if Provider.is_builtin(provider): + provider_class_path = f"{providers_path}.{provider}.{provider}_provider" + provider_class_name = f"{provider.capitalize()}Provider" + # Let ImportError propagate — the caller decides whether to + # sys.exit (CLI) or return HTTP 400 (API). + module = import_module(provider_class_path) + try: + return getattr(module, provider_class_name) + except AttributeError as error: + # is_builtin already confirmed this is a built-in, so the + # module MUST define the expected class. A missing class is a + # broken built-in contract — raise rather than fall back to a + # same-named external plug-in, which would contradict + # is_builtin and silently return a foreign class. + raise ImportError( + f"Built-in provider '{provider}' module " + f"'{provider_class_path}' does not define expected class " + f"'{provider_class_name}'" + ) from error + + cls = Provider._load_ep_provider(provider) + if cls is None: + raise ImportError( + f"Provider '{provider}' not found as built-in or entry point" + ) + # ep.load() can return any object; enforce the public contract that + # get_class returns a Provider subclass. isinstance(cls, type) guards + # issubclass against a TypeError when cls is not a class at all. + if not (isinstance(cls, type) and issubclass(cls, Provider)): + raise ImportError( + f"Entry-point provider '{provider}' resolved to {cls!r}, " + f"which is not a subclass of Provider" + ) + return cls + + @staticmethod + def get_providers_help_text() -> dict: + """Returns a dict of {provider_name: cli_help_text} for all available providers.""" + help_text = {} + for name in Provider.get_available_providers(): + try: + cls = Provider.get_class(name) + help_text[name] = getattr(cls, "_cli_help_text", "") + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + help_text[name] = "" + return help_text @staticmethod def update_provider_config(audit_config: dict, variable: str, value: str): diff --git a/prowler/providers/gcp/exceptions/exceptions.py b/prowler/providers/gcp/exceptions/exceptions.py index 5c5845951c..09cb642cba 100644 --- a/prowler/providers/gcp/exceptions/exceptions.py +++ b/prowler/providers/gcp/exceptions/exceptions.py @@ -34,11 +34,17 @@ class GCPBaseException(ProwlerException): "message": "Error loading Service Account Private Key credentials from dictionary", "remediation": "Check the dictionary and ensure it contains a Service Account Private Key.", }, + (3011, "GCPGetOrganizationProjectsError"): { + "message": "Error retrieving projects under the organization via the Cloud Asset API", + "remediation": "Ensure the Cloud Asset API is enabled in the credentials' project and that the principal has 'roles/cloudasset.viewer' bound at the organization level. See https://cloud.google.com/asset-inventory/docs/access-control.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): provider = "GCP" - error_info = self.GCP_ERROR_CODES.get((code, self.__class__.__name__)) + # Copy the catalog entry so a custom message does not mutate the + # class-level GCP_ERROR_CODES shared across exception instances. + error_info = dict(self.GCP_ERROR_CODES.get((code, self.__class__.__name__))) if message: error_info["message"] = message super().__init__( @@ -104,3 +110,10 @@ class GCPLoadServiceAccountKeyFromDictError(GCPCredentialsError): super().__init__( 3010, file=file, original_exception=original_exception, message=message ) + + +class GCPGetOrganizationProjectsError(GCPBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 3011, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 5017b84c42..69ab9404ef 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -21,6 +21,8 @@ from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS from prowler.providers.gcp.exceptions.exceptions import ( + GCPBaseException, + GCPGetOrganizationProjectsError, GCPInvalidProviderIdError, GCPLoadADCFromDictError, GCPLoadServiceAccountKeyFromDictError, @@ -59,6 +61,7 @@ class GcpProvider(Provider): """ _type: str = "gcp" + sdk_only: bool = False _session: Credentials _project_ids: list _excluded_project_ids: list @@ -621,10 +624,7 @@ class GcpProvider(Provider): credentials_file: str Returns: - dict[str, GCPProject] - - Usage: - >>> GcpProvider.get_projects(credentials=credentials, organization_id=organization_id) + dict of project_id and GCPProject object """ projects = {} try: @@ -632,7 +632,10 @@ class GcpProvider(Provider): try: # Initialize Cloud Asset Inventory API for recursive project retrieval asset_service = discovery.build( - "cloudasset", "v1", credentials=credentials + "cloudasset", + "v1", + credentials=credentials, + num_retries=DEFAULT_RETRY_ATTEMPTS, ) # Set the scope to the specified organization and filter for projects scope = f"organizations/{organization_id}" @@ -643,7 +646,7 @@ class GcpProvider(Provider): ) while request is not None: - response = request.execute() + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) for asset in response.get("assets", []): # Extract labels and other project details @@ -688,13 +691,25 @@ class GcpProvider(Provider): ) except HttpError as http_error: if "Cloud Asset API has not been used" in str(http_error): - logger.error( - f"Projects cannot be retrieved from the Organization since Cloud Asset API has not been used before or it is disabled [{http_error.__traceback__.tb_lineno}]. Enable it by visiting https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry." + message = ( + "Projects cannot be retrieved from the Organization since the Cloud Asset API " + "has not been used before or it is disabled. Enable it by visiting " + "https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry." ) else: - logger.error( - f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}" + message = ( + f"Cloud Asset API call failed while listing projects under organization " + f"'{organization_id}': {http_error}. Ensure the credentials' principal has " + "'roles/cloudasset.viewer' bound at the organization level." ) + logger.critical( + f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {message}" + ) + raise GCPGetOrganizationProjectsError( + file=__file__, + original_exception=http_error, + message=message, + ) else: try: # Initialize Cloud Resource Manager API for simple project listing @@ -781,8 +796,10 @@ class GcpProvider(Provider): labels={}, lifecycle_state="ACTIVE", ) - # If no projects were able to be accessed via API, add them manually from the credentials file - elif credentials_file: + # If no projects were able to be accessed via API, add them manually from the credentials file. + # Skip this fallback when an organization scan was explicitly requested: silently + # downgrading scope to the service account's home project hides permission errors. + elif credentials_file and not organization_id: with open(credentials_file, "r", encoding="utf-8") as file: project_id = json.load(file)["project_id"] # Handle empty or null project names @@ -798,6 +815,8 @@ class GcpProvider(Provider): labels={}, lifecycle_state="ACTIVE", ) + except GCPBaseException as gcp_error: + raise gcp_error except Exception as error: logger.critical( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/providers/gcp/services/cloudfunction/__init__.py b/prowler/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py new file mode 100644 index 0000000000..a252da1be7 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) + +cloudfunction_client = CloudFunction(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json new file mode 100644 index 0000000000..72c908bf80 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "cloudfunction_function_inside_vpc", + "CheckTitle": "Cloud Function is connected to a VPC network", + "CheckType": [], + "ServiceName": "cloudfunction", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "cloudfunctions.googleapis.com/Function", + "Description": "Cloud Functions are attached to a **Serverless VPC Access connector** so egress traffic is routed through a private VPC network instead of the public internet.\n\nThe evaluation reviews each function's network configuration to confirm that a connector is configured.", + "Risk": "Without a VPC connector, Cloud Functions cannot privately reach internal resources such as `Cloud SQL`, `Memorystore`, or `GKE`, forcing those services to be exposed over public IPs. This expands the **attack surface**, weakens **confidentiality** of internal traffic, and breaks network segmentation controls required by most security frameworks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/functions/docs/networking/connecting-vpc", + "https://cloud.google.com/vpc/docs/serverless-vpc-access" + ], + "Remediation": { + "Code": { + "CLI": "gcloud functions deploy --region= --vpc-connector=projects//locations//connectors/ --egress-settings=all-traffic", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Cloud Functions\n2. Select the function and click Edit\n3. Under Connections, select the VPC connector for your network\n4. Set Egress settings to route all traffic through the VPC connector\n5. Save and redeploy the function", + "Terraform": "```hcl\nresource \"google_cloudfunctions2_function\" \"\" {\n name = \"\"\n location = \"us-central1\"\n\n service_config {\n vpc_connector = \"\" # Critical: routes egress through the VPC\n vpc_connector_egress_settings = \"ALL_TRAFFIC\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Apply **defense in depth** by routing Cloud Function egress through a **Serverless VPC Access connector** when the function must reach internal resources.\n\nScope each connector to **least privilege** subnets so functions cannot reach unintended endpoints.", + "Url": "https://hub.prowler.com/check/cloudfunction_function_inside_vpc" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "compute_instance_public_ip" + ], + "Notes": "A VPC connector must be created in the same region as the Cloud Function. This check only verifies that a connector is attached; it does not validate egress settings or connector configuration." +} diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py new file mode 100644 index 0000000000..3b28db7d8e --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_inside_vpc(Check): + """Check that Cloud Functions are attached to a Serverless VPC Access connector. + + Verifies that each active Cloud Function has a `vpcConnector` configured so + egress traffic flows through a private VPC network instead of the public + internet. Functions in non-`ACTIVE` states are skipped because their network + configuration is transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the VPC-connector check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `PASS` when a `vpc_connector` is set and `FAIL` + otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP( + metadata=self.metadata(), + resource=function, + resource_id=function.name, + ) + if function.vpc_connector: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is connected to a VPC via " + f"connector: {function.vpc_connector}." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cloud Function {function.name} is not connected to any VPC network." + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..8c9247b2bd --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "cloudfunction_function_not_publicly_accessible", + "CheckTitle": "Cloud Function is not publicly invocable", + "CheckType": [], + "ServiceName": "cloudfunction", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "cloudfunctions.googleapis.com/Function", + "Description": "Cloud Functions deny invocation to `allUsers` and `allAuthenticatedUsers`, so only **explicitly authorized identities or services** can trigger them.\n\nThe evaluation reviews each function's IAM policy bindings to confirm no public principals are granted invoker access.", + "Risk": "Publicly invocable Cloud Functions expose **business logic** to the internet and let any caller trigger execution. This enables **unauthorized data access** when the function returns sensitive output, **code execution** in shared environments, and **denial-of-wallet** attacks driven by uncontrolled invocation costs.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/functions/docs/securing/authenticating", + "https://cloud.google.com/iam/docs/overview" + ], + "Remediation": { + "Code": { + "CLI": "gcloud functions remove-iam-policy-binding --region= --member= --role=roles/cloudfunctions.invoker", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Cloud Functions\n2. Select the function and open the Permissions tab\n3. Remove any binding with allUsers or allAuthenticatedUsers\n4. Grant invocation rights only to specific service accounts or user groups", + "Terraform": "```hcl\nresource \"google_cloudfunctions2_function_iam_binding\" \"\" {\n project = \"\"\n location = \"\"\n cloud_function = \"\"\n role = \"roles/cloudfunctions.invoker\"\n members = [\"serviceAccount:\"] # Critical: never include allUsers or allAuthenticatedUsers\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to Cloud Function invocation: grant `roles/cloudfunctions.invoker` only to specific service accounts or groups.\n\nFor externally exposed functions, front them with **API Gateway** or **Cloud Endpoints** that enforce authentication and rate limiting.", + "Url": "https://hub.prowler.com/check/cloudfunction_function_not_publicly_accessible" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfunction_function_inside_vpc", + "secretmanager_secret_not_publicly_accessible", + "cloudstorage_bucket_public_access" + ], + "Notes": "This check evaluates function-level IAM policies. Organization policy constraints/iam.allowedPolicyMemberDomains can prevent public bindings at the org level." +} diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py new file mode 100644 index 0000000000..14ee874f22 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py @@ -0,0 +1,44 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_not_publicly_accessible(Check): + """Check that Cloud Functions do not grant invocation rights to all users. + + Verifies that no active Cloud Function has an IAM binding granting access + to `allUsers` or `allAuthenticatedUsers`. Non-`ACTIVE` functions are + skipped because their IAM bindings are transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public-access check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `FAIL` when the function is invokable by + `allUsers` or `allAuthenticatedUsers` and `PASS` otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP( + metadata=self.metadata(), + resource=function, + resource_id=function.name, + ) + if function.publicly_accessible: + report.status = "FAIL" + report.status_extended = ( + f"Cloud Function {function.name} is publicly invocable " + f"(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is not publicly accessible." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py new file mode 100644 index 0000000000..9905c98748 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py @@ -0,0 +1,146 @@ +from typing import Optional + +from googleapiclient import discovery +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class CloudFunction(GCPService): + """Cloud Functions v2 service client. + + Enumerates Cloud Functions across every accessible project and region + using the `cloudfunctions.googleapis.com` v2 API and exposes them through + the `functions` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload Cloud Functions.""" + super().__init__("cloudfunctions", provider, api_version="v2") + self.functions = [] + self._run_client = None + self._get_functions() + self._get_functions_iam_policy() + + def _get_functions(self) -> None: + """Fetch Cloud Functions for every project and location.""" + for project_id in self.project_ids: + try: + locations = self.client.projects().locations() + locations_request = locations.list(name=f"projects/{project_id}") + while locations_request is not None: + locations_response = locations_request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for location in locations_response.get("locations", []): + location_id = location["locationId"] + try: + functions = locations.functions() + request = functions.list( + parent=f"projects/{project_id}/locations/{location_id}" + ) + while request is not None: + response = request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for fn in response.get("functions", []): + service_config = fn.get("serviceConfig", {}) + self.functions.append( + Function( + id=fn["name"], + name=fn["name"].split("/")[-1], + project_id=project_id, + location=location_id, + state=fn.get("state", "UNKNOWN"), + environment=fn.get("environment", "GEN_1"), + service=service_config.get("service"), + vpc_connector=service_config.get( + "vpcConnector" + ), + ) + ) + request = functions.list_next( + previous_request=request, + previous_response=response, + ) + except Exception as error: + logger.error( + f"{location_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + locations_request = locations.list_next( + previous_request=locations_request, + previous_response=locations_response, + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_functions_iam_policy(self) -> None: + """Fetch IAM policy for every Cloud Function in parallel. + + For gen2 functions, IAM is delegated to the underlying Cloud Run + service, so a `run.googleapis.com` v2 client is required. + """ + if any(f.environment == "GEN_2" for f in self.functions): + self._run_client = discovery.build( + "run", + "v2", + credentials=self.credentials, + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + self.__threading_call__(self._get_function_iam_policy, self.functions) + + def _get_function_iam_policy(self, function: "Function") -> None: + """Mark a Cloud Function as publicly accessible when bound to `allUsers` or `allAuthenticatedUsers`. + + Cloud Functions gen2 delegates invocation IAM to its backing Cloud Run + service, so the binding is queried via the Run API. Gen1 functions are + queried through the Cloud Functions API directly. + """ + try: + if function.environment == "GEN_2" and function.service: + response = ( + self._run_client.projects() + .locations() + .services() + .getIamPolicy(resource=function.service) + .execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + ) + else: + response = ( + self.client.projects() + .locations() + .functions() + .getIamPolicy(resource=function.id) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + ) + for binding in response.get("bindings", []): + members = binding.get("members", []) + if "allUsers" in members or "allAuthenticatedUsers" in members: + function.publicly_accessible = True + break + except Exception as error: + logger.error( + f"{function.location} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Function(BaseModel): + """Cloud Function resource consumed by GCP checks.""" + + id: str + name: str + project_id: str + location: str + state: str + environment: str = "GEN_1" + service: Optional[str] = None + vpc_connector: Optional[str] = None + publicly_accessible: bool = False diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled.metadata.json b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled.metadata.json new file mode 100644 index 0000000000..5ec026f9ed --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "gcp", + "CheckID": "cloudsql_instance_cmek_encryption_enabled", + "CheckTitle": "Cloud SQL instance is encrypted with a customer-managed key (CMEK)", + "CheckType": [], + "ServiceName": "cloudsql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "sqladmin.googleapis.com/Instance", + "Description": "**Cloud SQL instances** use **customer-managed encryption keys** (`CMEK`) via Cloud KMS for at-rest encryption. The evaluation identifies instances lacking a configured **Cloud KMS key**, indicating use of default Google-managed encryption instead.", + "Risk": "Without CMEK, Google holds sole control of the encryption keys. If the organization must demonstrate key custody, meet data residency requirements, or immediately revoke access to data (e.g., upon contract termination), Google-managed keys are insufficient. This may violate ISMS-P 2.7.1 and regulatory requirements for sensitive or personal data.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/sql/docs/mysql/cmek", + "https://cloud.google.com/sql/docs/postgres/cmek", + "https://cloud.google.com/sql/docs/sqlserver/cmek", + "https://cloud.google.com/kms/docs/resource-hierarchy" + ], + "Remediation": { + "Code": { + "CLI": "gcloud sql instances create \\\n --database-version= \\\n --region= \\\n --disk-encryption-key=projects//locations//keyRings//cryptoKeys/", + "NativeIaC": "", + "Other": "CMEK must be configured at instance creation time. To migrate an existing instance:\n1. Create a new Cloud SQL instance with CMEK enabled.\n2. Export data from the existing instance.\n3. Import data into the new CMEK-enabled instance.\n4. Update application connection strings.", + "Terraform": "```hcl\nresource \"google_sql_database_instance\" \"example\" {\n name = \"\"\n database_version = \"\"\n region = \"\"\n\n encryption_key_name = \"projects//locations//keyRings//cryptoKeys/\"\n\n settings {\n tier = \"db-custom-2-7680\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "For instances storing personal or sensitive data, create new Cloud SQL instances with CMEK using a Cloud KMS key in the same region. Ensure the Cloud SQL service account has the roles/cloudkms.cryptoKeyEncrypterDecrypter role on the key, and enable key rotation per your policy.", + "Url": "https://hub.prowler.com/check/cloudsql_instance_cmek_encryption_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "kms_key_rotation_enabled", + "kms_key_not_publicly_accessible", + "bigquery_dataset_cmk_encryption" + ], + "Notes": "CMEK cannot be enabled on an existing Cloud SQL instance; it must be set at creation time. Existing instances require data migration to a new CMEK-enabled instance." +} diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled.py new file mode 100644 index 0000000000..8048ced453 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled.py @@ -0,0 +1,25 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudsql.cloudsql_client import cloudsql_client + + +class cloudsql_instance_cmek_encryption_enabled(Check): + def execute(self) -> Check_Report_GCP: + findings = [] + for instance in cloudsql_client.instances: + if instance.instance_type != "CLOUD_SQL_INSTANCE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.cmek_key_name: + report.status = "PASS" + report.status_extended = ( + f"Database instance {instance.name} is encrypted with " + f"customer-managed key: {instance.cmek_key_name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database instance {instance.name} is not encrypted with a " + f"customer-managed key (CMEK); Google-managed key is in use." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..3e66f9cdd3 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "cloudsql_instance_high_availability_enabled", + "CheckTitle": "Cloud SQL instance has high availability (REGIONAL) configured", + "CheckType": [], + "ServiceName": "cloudsql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "sqladmin.googleapis.com/Instance", + "Description": "Ensures that Cloud SQL instances have high availability configured by setting availabilityType to REGIONAL. A REGIONAL instance maintains a standby replica in a different zone within the same region and automatically fails over on zone-level outages.", + "Risk": "Instances with ZONAL availability have no standby replica. A zone-level outage will cause database downtime until manual recovery, violating availability requirements and potentially breaching SLAs and ISMS-P 2.12.1 disaster preparedness controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/sql/docs/postgres/high-availability", + "https://cloud.google.com/sql/docs/sqlserver/high-availability" + ], + "Remediation": { + "Code": { + "CLI": "gcloud sql instances patch --availability-type=REGIONAL", + "NativeIaC": "", + "Other": "1. Go to Google Cloud Console > SQL > Instances.\n2. Click the instance name, then Edit.\n3. Under Availability, select Multiple zones (Highly available).\n4. Click Save.", + "Terraform": "```hcl\nresource \"google_sql_database_instance\" \"example\" {\n name = \"\"\n database_version = \"POSTGRES_15\"\n region = \"\"\n\n settings {\n tier = \"db-custom-2-7680\"\n\n availability_type = \"REGIONAL\" # Critical: enables HA standby replica\n\n backup_configuration {\n enabled = true\n start_time = \"02:00\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set availabilityType to REGIONAL for all production Cloud SQL instances. This creates a standby replica in a different zone and enables automatic failover, reducing RTO in the event of a zone outage.", + "Url": "https://hub.prowler.com/check/cloudsql_instance_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudsql_instance_automated_backups" + ], + "Notes": "Enabling HA increases instance cost approximately 2x due to the standby replica. ZONAL instances are acceptable for non-production workloads where downtime is tolerable." +} diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py new file mode 100644 index 0000000000..37bf576345 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudsql.cloudsql_client import cloudsql_client + + +class cloudsql_instance_high_availability_enabled(Check): + """Check that Cloud SQL primary instances are configured for high availability. + + Verifies that each Cloud SQL primary instance has `availabilityType` set to + `REGIONAL`, which provisions a standby replica in a different zone within + the same region and enables automatic failover on zone-level outages. Read + replicas are skipped because they inherit availability from their primary. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the high availability check across all Cloud SQL instances. + + Returns: + A list of `Check_Report_GCP` findings, one per Cloud SQL primary + instance. Status is `PASS` when `availability_type == "REGIONAL"` + and `FAIL` otherwise. + """ + findings = [] + for instance in cloudsql_client.instances: + if instance.instance_type != "CLOUD_SQL_INSTANCE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.availability_type == "REGIONAL": + report.status = "PASS" + report.status_extended = ( + f"Database instance {instance.name} has high availability " + f"(REGIONAL) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database instance {instance.name} does not have high " + f"availability configured (current: " + f"{instance.availability_type})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py index 2d04a4248c..1fe706bb02 100644 --- a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -24,6 +26,8 @@ class CloudSQL(GCPService): for address in instance.get("ipAddresses", []): if address["type"] == "PRIMARY": public_ip = True + settings = instance.get("settings", {}) + ip_config = settings.get("ipConfiguration", {}) self.instances.append( Instance( name=instance["name"], @@ -31,19 +35,26 @@ class CloudSQL(GCPService): region=instance["region"], ip_addresses=instance.get("ipAddresses", []), public_ip=public_ip, - require_ssl=instance["settings"] - .get("ipConfiguration", {}) - .get("requireSsl", False), - ssl_mode=instance["settings"] - .get("ipConfiguration", {}) - .get("sslMode", "ALLOW_UNENCRYPTED_AND_ENCRYPTED"), - automated_backups=instance["settings"] - .get("backupConfiguration", {}) - .get("enabled", False), - authorized_networks=instance["settings"] - .get("ipConfiguration", {}) - .get("authorizedNetworks", []), - flags=instance["settings"].get("databaseFlags", []), + require_ssl=ip_config.get("requireSsl", False), + ssl_mode=ip_config.get( + "sslMode", "ALLOW_UNENCRYPTED_AND_ENCRYPTED" + ), + automated_backups=settings.get( + "backupConfiguration", {} + ).get("enabled", False), + authorized_networks=ip_config.get( + "authorizedNetworks", [] + ), + flags=settings.get("databaseFlags", []), + availability_type=settings.get( + "availabilityType", "ZONAL" + ), + instance_type=instance.get( + "instanceType", "CLOUD_SQL_INSTANCE" + ), + cmek_key_name=instance.get( + "diskEncryptionConfiguration", {} + ).get("kmsKeyName"), project_id=project_id, ) ) @@ -68,4 +79,7 @@ class Instance(BaseModel): ssl_mode: str automated_backups: bool flags: list + availability_type: str = "ZONAL" + instance_type: str = "CLOUD_SQL_INSTANCE" + cmek_key_name: Optional[str] = None project_id: str diff --git a/prowler/providers/gcp/services/compute/compute_service.py b/prowler/providers/gcp/services/compute/compute_service.py index 0965142766..41cce29a7b 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -87,9 +87,15 @@ class Compute(GCPService): .execute(num_retries=DEFAULT_RETRY_ATTEMPTS) ) for item in response["commonInstanceMetadata"].get("items", []): - if item["key"] == "enable-oslogin" and item["value"] == "TRUE": + if ( + item["key"] == "enable-oslogin" + and item["value"].lower() == "true" + ): enable_oslogin = True - if item["key"] == "enable-oslogin-2fa" and item["value"] == "TRUE": + if ( + item["key"] == "enable-oslogin-2fa" + and item["value"].lower() == "true" + ): enable_oslogin_2fa = True self.compute_projects.append( Project( diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py index 13e96276e7..987a86068c 100644 --- a/prowler/providers/gcp/services/iam/iam_service.py +++ b/prowler/providers/gcp/services/iam/iam_service.py @@ -37,6 +37,7 @@ class IAM(GCPService): display_name=account.get("displayName", ""), project_id=project_id, uniqueId=account.get("uniqueId", ""), + disabled=account.get("disabled", False), ) ) @@ -102,6 +103,7 @@ class ServiceAccount(BaseModel): keys: list[Key] = [] project_id: str uniqueId: str + disabled: bool = False class AccessApproval(GCPService): diff --git a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py index 12440aff25..912237b9e5 100644 --- a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py +++ b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py @@ -19,7 +19,12 @@ class iam_service_account_unused(Check): resource_id=account.email, location=iam_client.region, ) - if account.uniqueId in sa_ids_used: + if account.disabled: + report.status = "PASS" + report.status_extended = ( + f"Service Account {account.email} is disabled and cannot be used." + ) + elif account.uniqueId in sa_ids_used: report.status = "PASS" report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days." else: diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json index 5efe894b04..7312ffefeb 100644 --- a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json @@ -1,14 +1,14 @@ { "Provider": "gcp", "CheckID": "kms_key_rotation_enabled", - "CheckTitle": "KMS key is rotated at least annually", + "CheckTitle": "KMS key has automatic rotation enabled", "CheckType": [], "ServiceName": "kms", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "cloudkms.googleapis.com/CryptoKey", - "Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled or a rotation interval `365` days.\n\nThe evaluation reviews each key's rotation settings to confirm periodic creation of new key versions.", + "Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled, regardless of the rotation interval.\n\nThe evaluation reviews each key's rotation settings to confirm that a rotation period is configured so new key versions are created periodically.", "Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.", "RelatedUrl": "", "AdditionalURLs": [ @@ -17,13 +17,13 @@ ], "Remediation": { "Code": { - "CLI": "gcloud kms keys update --keyring= --location= --rotation-period=365d --next-rotation-time=", + "CLI": "gcloud kms keys update --keyring= --location= --rotation-period= --next-rotation-time=", "NativeIaC": "", - "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 365 days or less\n5. Set Next rotation date/time\n6. Click Save", - "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"31536000s\" # Critical: sets automatic rotation to 365 days (<= 365 ensures PASS)\n}\n```" + "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set a Rotation period\n5. Set Next rotation date/time\n6. Click Save", + "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: enables automatic rotation (any period ensures PASS)\n}\n```" }, "Recommendation": { - "Text": "Enable **auto-rotation** for customer-managed keys with an interval `365` days.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.", + "Text": "Enable **auto-rotation** for customer-managed keys by configuring a rotation period.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.", "Url": "https://hub.prowler.com/check/kms_key_rotation_enabled" } }, @@ -31,6 +31,8 @@ "encryption" ], "DependsOn": [], - "RelatedTo": [], + "RelatedTo": [ + "kms_key_rotation_max_90_days" + ], "Notes": "" } diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py index 577a7c9f99..ae924ca380 100644 --- a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py @@ -1,5 +1,3 @@ -import datetime - from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.kms.kms_client import kms_client @@ -9,36 +7,16 @@ class kms_key_rotation_enabled(Check): findings = [] for key in kms_client.crypto_keys: report = Check_Report_GCP(metadata=self.metadata(), resource=key) - now = datetime.datetime.now() - condition_next_rotation_time = False - if key.next_rotation_time: - try: - next_rotation_time = datetime.datetime.strptime( - key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ" - ) - except ValueError: - next_rotation_time = datetime.datetime.strptime( - key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ" - ) - condition_next_rotation_time = ( - abs((next_rotation_time - now).days) <= 90 - ) - condition_rotation_period = False if key.rotation_period: - condition_rotation_period = ( - int(key.rotation_period[:-1]) // (24 * 3600) <= 90 - ) - if condition_rotation_period and condition_next_rotation_time: report.status = "PASS" - report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + report.status_extended = ( + f"Key {key.name} has automatic rotation enabled." + ) else: report.status = "FAIL" - if condition_rotation_period: - report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days." - elif condition_next_rotation_time: - report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." - else: - report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + report.status_extended = ( + f"Key {key.name} does not have automatic rotation enabled." + ) findings.append(report) return findings diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/__init__.py b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json new file mode 100644 index 0000000000..f1597fee53 --- /dev/null +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "kms_key_rotation_max_90_days", + "CheckTitle": "KMS key is rotated every 90 days or less", + "CheckType": [], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "cloudkms.googleapis.com/CryptoKey", + "Description": "Google Cloud KMS customer-managed keys are rotated with an interval of `90` days or less, in line with the CIS Benchmark.\n\nThe evaluation reviews each key's rotation settings to confirm that both the rotation period and the next rotation time stay within 90 days.", + "Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/gcp/CloudKMS/rotate-kms-encryption-keys.html", + "https://cloud.google.com/iam/docs/manage-access-service-accounts" + ], + "Remediation": { + "Code": { + "CLI": "gcloud kms keys update --keyring= --location= --rotation-period=90d --next-rotation-time=", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 90 days or less\n5. Set Next rotation date/time\n6. Click Save", + "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: sets automatic rotation to 90 days (<= 90 ensures PASS)\n}\n```" + }, + "Recommendation": { + "Text": "Enable **auto-rotation** for customer-managed keys with an interval of `90` days or less.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.", + "Url": "https://hub.prowler.com/check/kms_key_rotation_max_90_days" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "kms_key_rotation_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py new file mode 100644 index 0000000000..cbca4f0e91 --- /dev/null +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py @@ -0,0 +1,44 @@ +import datetime + +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.kms.kms_client import kms_client + + +class kms_key_rotation_max_90_days(Check): + def execute(self) -> Check_Report_GCP: + findings = [] + for key in kms_client.crypto_keys: + report = Check_Report_GCP(metadata=self.metadata(), resource=key) + now = datetime.datetime.now() + condition_next_rotation_time = False + if key.next_rotation_time: + try: + next_rotation_time = datetime.datetime.strptime( + key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ" + ) + except ValueError: + next_rotation_time = datetime.datetime.strptime( + key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ" + ) + condition_next_rotation_time = ( + abs((next_rotation_time - now).days) <= 90 + ) + condition_rotation_period = False + if key.rotation_period: + condition_rotation_period = ( + int(key.rotation_period[:-1]) // (24 * 3600) <= 90 + ) + if condition_rotation_period and condition_next_rotation_time: + report.status = "PASS" + report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + else: + report.status = "FAIL" + if condition_rotation_period: + report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + elif condition_next_rotation_time: + report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." + else: + report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py index 4654be4d29..84bf078dac 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,12 +13,10 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -33,6 +34,11 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable break findings.append(report) + # Credit projects whose logs are centrally monitored via an org-level + # aggregated sink to a bucket-scoped metric + alert (instead of failing them). + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -46,8 +52,12 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py index 166f7b7ee8..e7d74f3f8e 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"' - in metric.filter - ): + if metric_filter in metric.filter: metric_name = getattr(metric, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), @@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: project_obj = logging_client.projects.get(project) @@ -46,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled( location=logging_client.region, resource_name=(getattr(project_obj, "name", None) or "GCP Project"), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py index 7902f9ed72..cf7cdb1679 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.serviceName="compute.googleapis.com"' projects_with_metric = set() for metric in logging_client.metrics: - if 'protoPayload.serviceName="compute.googleapis.com"' in metric.filter: + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py index 1e6584e5fb..f836dc25b2 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check) break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check) else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py index 8c8927ec32..b7bc619ea4 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")' - in metric.filter - ): + if metric_filter in metric.filter: metric_name = getattr(metric, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), @@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: project_obj = logging_client.projects.get(project) @@ -47,8 +51,12 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled( location=logging_client.region, resource_name=(getattr(project_obj, "name", None) or "GCP Project"), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py index 3e499db10a..3c03ab0fde 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.methodName="cloudsql.instances.update"' projects_with_metric = set() for metric in logging_client.metrics: - if 'protoPayload.methodName="cloudsql.instances.update"' in metric.filter: + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py index e2b7cdcc13..0e05838f05 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled( else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py index c8b15ce1ee..1330ad7a9a 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check) break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check) else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py index f840d75852..27f25879e8 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled( else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 637c8782b2..7d8843584e 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -1,9 +1,12 @@ +import re + from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS from prowler.providers.gcp.gcp_provider import GcpProvider from prowler.providers.gcp.lib.service.service import GCPService +from prowler.providers.gcp.services.monitoring.monitoring_service import Monitoring class Logging(GCPService): @@ -12,6 +15,7 @@ class Logging(GCPService): self.sinks = [] self.metrics = [] self._get_sinks() + self._get_org_sinks() self._get_metrics() def _get_sinks(self): @@ -39,6 +43,38 @@ class Logging(GCPService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_org_sinks(self): + """Fetch org-level sinks with includeChildren so child projects are not falsely failed.""" + org_ids = set() + for project in self.projects.values(): + if project.organization: + org_ids.add(project.organization.id) + + for org_id in org_ids: + try: + request = self.client.sinks().list(parent=f"organizations/{org_id}") + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + + for sink in response.get("sinks", []): + self.sinks.append( + Sink( + name=sink["name"], + destination=sink["destination"], + filter=sink.get("filter", "all"), + project_id=f"organizations/{org_id}", + include_children=sink.get("includeChildren", False), + ) + ) + + request = self.client.sinks().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_metrics(self): for project_id in self.project_ids: try: @@ -57,6 +93,7 @@ class Logging(GCPService): type=metric["metricDescriptor"]["type"], filter=metric["filter"], project_id=project_id, + bucket_name=metric.get("bucketName", ""), ) ) @@ -76,6 +113,7 @@ class Sink(BaseModel): destination: str filter: str project_id: str + include_children: bool = False class Metric(BaseModel): @@ -83,3 +121,140 @@ class Metric(BaseModel): type: str filter: str project_id: str + bucket_name: str = "" + + +# A positive selector of the Admin Activity stream: a ``logName`` predicate +# (``:`` has-substring or ``=`` equals) or a ``log_id()`` call. Written verbose +# so each fragment stays legible; ``(?![a-z_])`` keeps a longer stream name +# (``.../activity_v2``) from impersonating Admin Activity. +_ACTIVITY_SELECTOR = re.compile( + r""" + (?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id( + ["']? [^"'\s)]* # optional quote, then path prefix + cloudaudit\.googleapis\.com/activity (?![a-z_]) # the Admin Activity stream itself + """, + re.IGNORECASE | re.VERBOSE, +) + +# The same selector for *any* Cloud Audit stream (activity, data_access, +# system_event, policy, access_transparency, …). Used to strip the OR-combined +# audit clauses so we can prove nothing restrictive is left over. +_CLOUDAUDIT_SELECTOR = re.compile( + r""" + (?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id( + ["']? [^"'\s)]* # optional quote, then path prefix + cloudaudit\.googleapis\.com/[a-z_]+ # any cloudaudit stream + ["']? \s* \)? # optional closing quote / paren + """, + re.IGNORECASE | re.VERBOSE, +) + +# Operators that exclude or narrow coverage. Any of these means we cannot prove +# the sink delivers the *whole* Admin Activity stream, so it is not credited. +_NEGATION_OR_RESTRICTION = re.compile( + r""" + \bNOT\b # NOT exclusion + | \bAND\b # AND conjunction (restriction) + | != | !: # "!=" / "!:" inequality + | (?:^|[\s(]) -\s* [A-Za-z_] # leading "-" exclusion operator + """, + re.IGNORECASE | re.VERBOSE, +) + + +def _sink_delivers_activity_logs(sink_filter: str) -> bool: + """True only when a sink's filter *provably* exports the full Admin Activity + audit stream (or everything). + + Crediting flips a child project to PASS on a CIS security control, so the + match is deliberately conservative: a false FAIL is safe, a false PASS is + not. A non-``"all"`` filter is credited only when + + 1. it positively selects the Admin Activity stream + (``logName:.../activity``, ``logName="...activity"`` or + ``log_id("...activity")``); + 2. it carries no operator that excludes or narrows the stream — ``NOT`` / + ``-`` / ``!=`` (negation) or ``AND`` (restriction); and + 3. nothing but ``OR``-combined Cloud Audit selectors remains once those are + stripped — an ``OR`` only widens coverage, but any leftover predicate + (``severity>=ERROR``, ``resource.type=...``) could narrow it. + + Sink filters encode the stream URL-encoded (``...%2Factivity``) or as a path + — normalize before matching. + """ + if not sink_filter or sink_filter.strip().lower() == "all": + return True + normalized = sink_filter.replace("%2F", "/").replace("%2f", "/") + # 1. The Admin Activity stream must be positively selected. + if not _ACTIVITY_SELECTOR.search(normalized): + return False + # 2. No operator may exclude or narrow that coverage. + if _NEGATION_OR_RESTRICTION.search(normalized): + return False + # 3. Only OR-combined audit selectors may remain — strip them and the OR + # glue; anything left is a predicate we cannot prove is full-coverage. + remainder = _CLOUDAUDIT_SELECTOR.sub(" ", normalized) + remainder = re.sub(r"\bOR\b|[()\s]", " ", remainder, flags=re.IGNORECASE) + return remainder.strip() == "" + + +def get_projects_covered_by_aggregated_metric( + logging_client: Logging, + monitoring_client: Monitoring, + metric_filter: str, +) -> dict[str, str]: + """Return {project_id: metric_name} for scanned projects whose logs are routed, + via an organization-level sink with includeChildren=True, to a bucket that holds + a bucket-scoped log metric matching ``metric_filter`` that has an alert policy. + + The CIS GCP logging-metric checks are written per-project, but a common (and + recommended) topology centralizes monitoring: an org-level aggregated sink ships + every child project's logs into one bucket, where a single bucket-scoped metric + + alert covers them all. Without crediting that, those child projects are falsely + failed. Mirrors the org-sink handling already in ``logging_sink_created`` (#11355). + + A sink is credited when it exports everything (``filter == "all"``) or when its + filter carries the Admin Activity audit stream — the only stream the CIS metric + filters can match (see ``_sink_delivers_activity_logs``). + """ + # Buckets that hold a matching, alerted, bucket-scoped metric -> metric name. + bucket_to_metric = {} + for metric in logging_client.metrics: + if not getattr(metric, "bucket_name", ""): + continue + if metric_filter not in metric.filter: + continue + if any( + metric.name in policy_filter + for alert_policy in monitoring_client.alert_policies + for policy_filter in alert_policy.filters + ): + bucket_to_metric[metric.bucket_name] = metric.name + if not bucket_to_metric: + return {} + + # Org resources whose includeChildren sink targets one of those buckets. + org_to_metric = {} + for sink in logging_client.sinks: + if not getattr(sink, "include_children", False): + continue + if not _sink_delivers_activity_logs(getattr(sink, "filter", "all")): + continue + for bucket, metric_name in bucket_to_metric.items(): + # sink.destination e.g. "logging.googleapis.com/projects/.../buckets/X"; + # metric.bucket_name e.g. "projects/.../buckets/X". + if sink.destination.endswith(bucket): + org_to_metric[sink.project_id] = metric_name + break + if not org_to_metric: + return {} + + # Scanned projects sitting under a covering organization. + covered = {} + for project_id in logging_client.project_ids: + project = logging_client.projects.get(project_id) + organization = getattr(project, "organization", None) if project else None + if organization and f"organizations/{organization.id}" in org_to_metric: + covered[project_id] = org_to_metric[f"organizations/{organization.id}"] + return covered diff --git a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py index 30104a050d..a7846e3dd8 100644 --- a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py +++ b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py @@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client class logging_sink_created(Check): def execute(self) -> Check_Report_GCP: findings = [] + + # Map project_id -> sink for direct project-level sinks projects_with_logging_sink = {} for sink in logging_client.sinks: - if sink.filter == "all": + if sink.filter == "all" and not sink.include_children: projects_with_logging_sink[sink.project_id] = sink + # Collect org resource names that have a covering sink (includeChildren=True) + covering_org_sinks = {} + for sink in logging_client.sinks: + if sink.filter == "all" and sink.include_children: + covering_org_sinks[sink.project_id] = sink + for project in logging_client.project_ids: - if project not in projects_with_logging_sink.keys(): - project_obj = logging_client.projects.get(project) - report = Check_Report_GCP( - metadata=self.metadata(), - resource=project_obj, - resource_id=project, - project_id=project, - location=logging_client.region, - resource_name=(getattr(project_obj, "name", None) or "GCP Project"), - ) - report.status = "FAIL" - report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." - findings.append(report) - else: + project_obj = logging_client.projects.get(project) + + # Determine whether this project is covered by an org-level sink + org = getattr(project_obj, "organization", None) if project_obj else None + org_resource = f"organizations/{org.id}" if org else None + covering_sink = ( + covering_org_sinks.get(org_resource) if org_resource else None + ) + + if project in projects_with_logging_sink: sink = projects_with_logging_sink[project] sink_name = getattr(sink, "name", None) or "unknown" report = Check_Report_GCP( @@ -40,4 +44,31 @@ class logging_sink_created(Check): report.status = "PASS" report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}." findings.append(report) + elif covering_sink: + sink_name = getattr(covering_sink, "name", None) or "unknown" + report = Check_Report_GCP( + metadata=self.metadata(), + resource=covering_sink, + resource_id=sink_name, + project_id=project, + location=logging_client.region, + resource_name=( + sink_name if sink_name != "unknown" else "Logging Sink" + ), + ) + report.status = "PASS" + report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}." + findings.append(report) + else: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=project_obj, + resource_id=project, + project_id=project, + location=logging_client.region, + resource_name=(getattr(project_obj, "name", None) or "GCP Project"), + ) + report.status = "FAIL" + report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." + findings.append(report) return findings diff --git a/prowler/providers/gcp/services/secretmanager/__init__.py b/prowler/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_client.py b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py new file mode 100644 index 0000000000..dc18866cb3 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) + +secretmanager_client = SecretManager(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..9f1f4d3381 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "secretmanager_secret_not_publicly_accessible", + "CheckTitle": "Secret Manager secret is not publicly accessible", + "CheckType": [], + "ServiceName": "secretmanager", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "secretmanager.googleapis.com/Secret", + "Description": "Secret Manager secrets deny access to `allUsers` and `allAuthenticatedUsers`, so only **explicitly authorized identities** can read or use them.\n\nThe evaluation reviews each secret's IAM policy bindings to confirm no public principals are granted access.", + "Risk": "Granting public IAM access to a secret exposes credentials, API keys, certificates, or other sensitive values to any caller on the internet or any authenticated Google account. This compromises **confidentiality**, enables **lateral movement** with leaked credentials, and can trigger regulatory breaches under PCI-DSS, ISO 27001, and GDPR.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/secret-manager/docs/access-control", + "https://cloud.google.com/iam/docs/overview" + ], + "Remediation": { + "Code": { + "CLI": "gcloud secrets remove-iam-policy-binding --member= --role=roles/secretmanager.secretAccessor", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Secret Manager\n2. Select the secret and open the Permissions tab\n3. Remove any binding with allUsers or allAuthenticatedUsers\n4. Grant access only to required service accounts or groups", + "Terraform": "```hcl\nresource \"google_secret_manager_secret_iam_binding\" \"\" {\n secret_id = \"\"\n role = \"roles/secretmanager.secretAccessor\"\n members = [\"serviceAccount:\"] # Critical: never include allUsers or allAuthenticatedUsers\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to Secret Manager: grant `roles/secretmanager.secretAccessor` only to the specific service accounts or groups that need to read each secret.\n\nUse **workload identity** for GKE workloads and rotate credentials regularly to limit blast radius when a binding is misconfigured.", + "Url": "https://hub.prowler.com/check/secretmanager_secret_not_publicly_accessible" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "secretmanager_secret_rotation_enabled", + "kms_key_not_publicly_accessible", + "cloudstorage_bucket_public_access" + ], + "Notes": "This check evaluates the secret-level IAM policy. Project-level IAM policies that may indirectly grant public access are not evaluated by this check." +} diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py new file mode 100644 index 0000000000..fb52a119fa --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.secretmanager.secretmanager_client import ( + secretmanager_client, +) + + +class secretmanager_secret_not_publicly_accessible(Check): + """Check that Secret Manager secrets do not grant access to all users. + + Verifies that no Secret Manager secret has an IAM binding granting access + to `allUsers` or `allAuthenticatedUsers`. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public-access check across all Secret Manager secrets. + + Returns: + A list of `Check_Report_GCP` findings, one per secret. Status is + `FAIL` when the secret is accessible to `allUsers` or + `allAuthenticatedUsers` and `PASS` otherwise. + """ + findings = [] + for secret in secretmanager_client.secrets: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=secret, + resource_id=secret.name, + ) + if secret.publicly_accessible: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} is publicly accessible " + f"(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Secret {secret.name} is not publicly accessible." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.metadata.json b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.metadata.json new file mode 100644 index 0000000000..09dd158b49 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "secretmanager_secret_rotation_enabled", + "CheckTitle": "Secret Manager secret is rotated every 90 days or less", + "CheckType": [], + "ServiceName": "secretmanager", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "secretmanager.googleapis.com/Secret", + "Description": "Secret Manager secrets have **automatic rotation** configured with a rotation period of `90` days or less and the next scheduled rotation has not been missed.\n\nThe evaluation reviews each secret's `rotation` settings to confirm both the period and the upcoming rotation time are within bounds.", + "Risk": "Without timely rotation, a leaked or compromised secret remains valid indefinitely, eroding **confidentiality** and widening the **blast radius** of any credential exposure. Stale secrets also bypass periodic re-authorization controls expected by frameworks such as PCI-DSS and ISO 27001.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/secret-manager/docs/rotation-recommendations", + "https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets" + ], + "Remediation": { + "Code": { + "CLI": "gcloud secrets update --rotation-period=7776000s --next-rotation-time=", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Secret Manager\n2. Select the secret and click Edit secret\n3. Under Rotation, enable Automatic rotation with a period of 90 days or less\n4. Configure a Pub/Sub topic to receive rotation notifications\n5. Click Save", + "Terraform": "```hcl\nresource \"google_secret_manager_secret\" \"\" {\n secret_id = \"\"\n\n replication {\n auto {}\n }\n\n rotation {\n rotation_period = \"7776000s\" # Critical: enables rotation within 90 days\n next_rotation_time = \"\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **automatic rotation** for every Secret Manager secret with a period of `90` days or less.\n\nWire **Pub/Sub notifications** to trigger rotation logic and apply **defense in depth** by versioning each secret update so consumers can roll back without losing availability.", + "Url": "https://hub.prowler.com/check/secretmanager_secret_rotation_enabled" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [ + "secretmanager_secret_not_publicly_accessible", + "kms_key_rotation_enabled", + "iam_sa_user_managed_key_rotate_90_days" + ], + "Notes": "A secret without a rotationPeriod field has no automatic rotation configured and will be marked as FAIL. Rotation periods exceeding 90 days are also marked as FAIL." +} diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.py new file mode 100644 index 0000000000..84d53e7f3a --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.py @@ -0,0 +1,83 @@ +import datetime + +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.secretmanager.secretmanager_client import ( + secretmanager_client, +) + + +class secretmanager_secret_rotation_enabled(Check): + """ + Ensure Secret Manager secrets have automatic rotation configured within the max rotation period. + + - PASS: Secret has a rotation period within the maximum (default 90 days) and the next rotation is not overdue. + - FAIL: Secret has no rotation, the period exceeds the maximum, or the next rotation has been missed. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Evaluate every Secret Manager secret's rotation configuration against the maximum rotation period.""" + findings = [] + + max_rotation_days = int( + getattr(secretmanager_client, "audit_config", {}).get( + "secretmanager_max_rotation_days", 90 + ) + ) + + for secret in secretmanager_client.secrets: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=secret, + resource_id=secret.name, + ) + + rotation_seconds = None + if secret.rotation_period: + try: + rotation_seconds = float(secret.rotation_period[:-1]) + except (ValueError, IndexError): + rotation_seconds = None + + rotation_overdue = False + if rotation_seconds is not None and secret.next_rotation_time: + try: + parsed = secret.next_rotation_time.replace("Z", "+00:00") + next_rotation_time = datetime.datetime.fromisoformat(parsed) + rotation_overdue = next_rotation_time < datetime.datetime.now( + datetime.timezone.utc + ) + except (ValueError, AttributeError): + rotation_overdue = True + + max_rotation_seconds = max_rotation_days * 86400 + rotation_days = ( + int(rotation_seconds // 86400) if rotation_seconds is not None else None + ) + + if rotation_seconds is None: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} does not have automatic rotation enabled." + ) + elif rotation_seconds > max_rotation_seconds: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} has rotation enabled but the period " + f"({rotation_days} days) exceeds the {max_rotation_days}-day maximum." + ) + elif rotation_overdue: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} has rotation configured " + f"({rotation_days} days) but the scheduled rotation is overdue." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Secret {secret.name} has automatic rotation enabled " + f"with a period of {rotation_days} days." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_service.py b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py new file mode 100644 index 0000000000..ca01663083 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py @@ -0,0 +1,94 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class SecretManager(GCPService): + """Secret Manager service client. + + Enumerates Secret Manager secrets across every accessible project using + the `secretmanager.googleapis.com` v1 API and exposes them through the + `secrets` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload secrets with their IAM policies.""" + super().__init__("secretmanager", provider) + self.secrets = [] + self._get_secrets() + self._get_secrets_iam_policy() + + def _get_secrets(self) -> None: + """Fetch Secret Manager secrets for every project.""" + for project_id in self.project_ids: + try: + request = ( + self.client.projects() + .secrets() + .list(parent=f"projects/{project_id}") + ) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for secret in response.get("secrets", []): + rotation = secret.get("rotation") or {} + self.secrets.append( + Secret( + id=secret["name"], + name=secret["name"].split("/")[-1], + project_id=project_id, + rotation_period=rotation.get("rotationPeriod"), + next_rotation_time=rotation.get("nextRotationTime"), + ) + ) + request = ( + self.client.projects() + .secrets() + .list_next(previous_request=request, previous_response=response) + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_secrets_iam_policy(self) -> None: + """Fetch IAM policy for every Secret Manager secret in parallel.""" + self.__threading_call__(self._get_secret_iam_policy, self.secrets) + + def _get_secret_iam_policy(self, secret: "Secret") -> None: + """Mark a secret as publicly accessible when bound to `allUsers` or `allAuthenticatedUsers`.""" + try: + response = ( + self.client.projects() + .secrets() + .getIamPolicy(resource=secret.id) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + ) + for binding in response.get("bindings", []): + members = binding.get("members", []) + if "allUsers" in members or "allAuthenticatedUsers" in members: + secret.publicly_accessible = True + break + except Exception as error: + logger.error( + f"{secret.project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Secret(BaseModel): + """Secret Manager secret resource consumed by GCP checks.""" + + id: str + name: str + project_id: str + location: str = "global" + rotation_period: Optional[str] = None + next_rotation_time: Optional[str] = None + publicly_accessible: bool = False diff --git a/prowler/providers/github/exceptions/exceptions.py b/prowler/providers/github/exceptions/exceptions.py index b49cce8ebe..b0f472a76c 100644 --- a/prowler/providers/github/exceptions/exceptions.py +++ b/prowler/providers/github/exceptions/exceptions.py @@ -34,6 +34,14 @@ class GithubBaseException(ProwlerException): "message": "The provided provider ID does not match with the authenticated user or accessible organizations", "remediation": "Check the provider ID and ensure it matches the authenticated user or an organization you have access to.", }, + (5007, "GithubRepoListFileNotFoundError"): { + "message": "The repo list file was not found", + "remediation": "Check the file path and ensure it exists.", + }, + (5008, "GithubRepoListFileReadError"): { + "message": "Error reading the repo list file", + "remediation": "Check the file permissions and format.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -104,3 +112,21 @@ class GithubInvalidProviderIdError(GithubCredentialsError): super().__init__( 5006, file=file, original_exception=original_exception, message=message ) + + +class GithubRepoListFileNotFoundError(GithubBaseException): + """Exception raised when the repo list file is not found.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 5007, file=file, original_exception=original_exception, message=message + ) + + +class GithubRepoListFileReadError(GithubBaseException): + """Exception raised when the repo list file cannot be read.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 5008, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index 16d13f7434..d832a93f98 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -22,6 +22,8 @@ from prowler.providers.github.exceptions.exceptions import ( GithubInvalidCredentialsError, GithubInvalidProviderIdError, GithubInvalidTokenError, + GithubRepoListFileNotFoundError, + GithubRepoListFileReadError, GithubSetUpIdentityError, GithubSetUpSessionError, ) @@ -89,7 +91,10 @@ class GithubProvider(Provider): """ _type: str = "github" + sdk_only: bool = False _auth_method: str = None + MAX_REPO_LIST_LINES: int = 10_000 + MAX_REPO_NAME_LENGTH: int = 500 _session: GithubSession _identity: GithubIdentityInfo _audit_config: dict @@ -113,7 +118,11 @@ class GithubProvider(Provider): mutelist_path: str = None, mutelist_content: dict = None, 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 @@ -130,6 +139,7 @@ class GithubProvider(Provider): mutelist_path (str): Path to the mutelist file. mutelist_content (dict): Mutelist content. repositories (list): List of repository names to scan in 'owner/repo-name' format. + repo_list_file (str): Path to a file containing repository names (one per line). organizations (list): List of organization or user names to scan repositories for. """ logger.info("Instantiating GitHub Provider...") @@ -147,6 +157,10 @@ class GithubProvider(Provider): else: self._repositories = list(repositories) + # Load repos from file if provided + if repo_list_file: + self._load_repos_from_file(repo_list_file) + if organizations is None: self._organizations = [] elif isinstance(organizations, str): @@ -200,8 +214,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.""" @@ -256,6 +282,46 @@ class GithubProvider(Provider): """ return self._organizations + def _load_repos_from_file(self, file_path: str) -> None: + """Load repository names from a file (one per line).""" + try: + repo_count = 0 + before = len(self._repositories) + with open(file_path, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + repo_count += 1 + if repo_count > self.MAX_REPO_LIST_LINES: + raise GithubRepoListFileReadError( + file=file_path, + message=f"Repo list file exceeds maximum of {self.MAX_REPO_LIST_LINES} lines.", + ) + if len(line) > self.MAX_REPO_NAME_LENGTH: + logger.warning( + f"Skipping repo name exceeding {self.MAX_REPO_NAME_LENGTH} chars at line {repo_count} in {file_path}" + ) + continue + self._repositories.append(line) + self._repositories = list(dict.fromkeys(self._repositories)) + logger.info( + f"Loaded {len(self._repositories) - before} repositories from {file_path}" + ) + except FileNotFoundError: + raise GithubRepoListFileNotFoundError( + file=file_path, + message=f"Repo list file not found: {file_path}", + ) + except (GithubRepoListFileReadError, GithubRepoListFileNotFoundError): + raise + except Exception as error: + raise GithubRepoListFileReadError( + file=file_path, + original_exception=error, + message=f"Error reading repo list file: {error}", + ) + @staticmethod def setup_session( personal_access_token: str = None, diff --git a/prowler/providers/github/lib/arguments/arguments.py b/prowler/providers/github/lib/arguments/arguments.py index 748d77a927..ab68597fdc 100644 --- a/prowler/providers/github/lib/arguments/arguments.py +++ b/prowler/providers/github/lib/arguments/arguments.py @@ -50,6 +50,12 @@ def init_parser(self): default=None, metavar="REPOSITORY", ) + github_scoping_subparser.add_argument( + "--repo-list-file", + dest="repo_list_file", + default=None, + help="Path to a file containing a list of repositories to scan (one per line in 'owner/repo-name' format). Lines starting with # are treated as comments.", + ) github_scoping_subparser.add_argument( "--organization", "--organizations", @@ -58,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/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py index bf615a21b6..1f05035723 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py @@ -28,6 +28,8 @@ class repository_default_branch_deletion_disabled(Check): report.status_extended = ( f"Repository {repo.name} does allow default branch deletion." ) + if repo.default_branch.branch_deletion_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has default branch deletion disabled in a ruleset, but the ruleset is not active." if not repo.default_branch.branch_deletion: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json index f774f204d0..2ef741d253 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** blocks **force pushes** through branch protection.\n\nEvaluates whether the default branch permits force pushes.", + "Description": "**GitHub repository default branch** blocks **force pushes** through branch protection.\n\nEvaluates whether the default branch permits force pushes. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Allowing **force pushes on the default branch** erodes **integrity** and **auditability** by enabling history rewrites and deletion of commits. Attackers or insiders can inject unreviewed code, bypass reviews and status checks, and corrupt PRs, risking supply-chain compromise and reduced **availability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-force-pushes" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-force-pushes", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py index 16eb7fa794..de61256080 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py @@ -26,6 +26,11 @@ class repository_default_branch_disallows_force_push(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does allow force pushes on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.allow_force_pushes_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has force pushes disallowed in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if not repo.default_branch.allow_force_pushes: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/__init__.py b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.metadata.json new file mode 100644 index 0000000000..f2cb69a16e --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "github", + "CheckID": "repository_default_branch_dismisses_stale_reviews", + "CheckTitle": "Repository default branch dismisses stale pull request approvals", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "devops", + "Description": "GitHub repository default branch ensures that when new commits are pushed to an open pull request, any previously granted approvals are automatically dismissed, requiring fresh reviews before the pull request can be merged.", + "Risk": "Without this setting, a contributor can receive approvals on a clean version of their code, then push malicious or unauthorized changes afterward. The pull request retains its approvals and can be merged without anyone reviewing the new changes, bypassing the entire code review process.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#dismiss-stale-pull-request-approvals-when-new-commits-are-pushed" + ], + "Remediation": { + "Code": { + "CLI": "gh api -X PATCH /repos///branches//protection/required_pull_request_reviews -f dismiss_stale_reviews=true", + "NativeIaC": "", + "Other": "Using Rulesets (recommended): 1. Go to repository Settings > Rules > Rulesets. 2. Click 'New ruleset' > 'New branch ruleset' (or edit an existing one targeting the default branch). 3. Under 'Require a pull request before merging', enable 'Dismiss stale pull request approvals when new commits are pushed'. 4. Click 'Create' or 'Save changes'. Using classic branch protection: 1. Go to repository Settings > Branches. 2. Edit or add a branch protection rule for the default branch. 3. Under 'Require a pull request before merging', enable 'Dismiss stale pull request approvals when new commits are pushed'. 4. Click 'Save changes'.", + "Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"\" {\n repository = \"\"\n branch = \"\"\n\n required_pull_request_reviews {\n dismiss_stale_reviews = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable \"Dismiss stale pull request approvals when new commits are pushed\" in your branch protection rules. This ensures every code change undergoes a fresh review before merging.", + "Url": "https://hub.prowler.com/check/repository_default_branch_dismisses_stale_reviews" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.py b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.py new file mode 100644 index 0000000000..3b3fc6d3db --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.py @@ -0,0 +1,44 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_branch_dismisses_stale_reviews(Check): + """Check if a repository dismisses stale pull request approvals when new commits are pushed. + + This class verifies whether each repository is configured to automatically + invalidate existing approvals when new commits are pushed to an open pull request. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the check. + + Returns: + List[CheckReportGithub]: A list of reports for each repository. + """ + findings = [] + + for repo in repository_client.repositories.values(): + + if repo.default_branch.dismiss_stale_reviews is not None: + + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not dismiss stale pull request approvals when new commits are pushed." + if ( + repo.default_branch.dismiss_stale_reviews_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has dismiss stale pull request approvals configured in a ruleset, but the ruleset is not active." + + if repo.default_branch.dismiss_stale_reviews: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} does dismiss stale pull request approvals when new commits are pushed." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json index 70668adbc1..75c84dc752 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** applies **branch protection rules** to **administrators** via `enforce_admins`, holding admin pushes to the same requirements as other contributors (reviews, status checks, and push restrictions).", + "Description": "**GitHub repository default branch** applies **branch protection rules** to **administrators** via `enforce_admins`, holding admin pushes to the same requirements as other contributors (reviews, status checks, and push restrictions). This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without admin enforcement, privileged users can bypass reviews and checks, enabling **unauthorized code changes**. A compromised admin token can inject backdoors, alter dependencies, or disable safeguards, undermining **integrity**, exposing secrets (**confidentiality**), and causing outages (**availability**).", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#do-not-allow-bypassing-the-above-settings" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#do-not-allow-bypassing-the-above-settings", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py index 0a70cf0aea..142f0a5b7b 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py @@ -26,6 +26,8 @@ class repository_default_branch_protection_applies_to_admins(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce administrators to be subject to the same branch protection rules as other users." + if repo.default_branch.enforce_admins_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has a ruleset that would apply to administrators, but the ruleset is not active." if repo.default_branch.enforce_admins: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json index 8f173e868f..dbefae6179 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** has **branch protection rules** enabled to restrict direct changes and require reviewed, validated merges. The evaluation determines whether the default branch enforces such rules.", + "Description": "**GitHub repository default branch** has **branch protection rules** enabled to restrict direct changes and require reviewed, validated merges. The evaluation determines whether the default branch enforces such rules. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without default-branch protection, changes can bypass reviews and checks, enabling:\n- Unauthorized direct pushes/force pushes\n- Malicious code injection and workflow tampering\n- Accidental deletions or unstable releases\nThis undermines code **integrity** and service **availability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py index 1348018ee1..b305f5019a 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py @@ -26,6 +26,8 @@ class repository_default_branch_protection_enabled(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch.name})." + if repo.default_branch.protected_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has a ruleset configured on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.protected: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json index 465e61aad9..53f849271a 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** requires **Code Owners** approval for pull requests that modify paths declared in `CODEOWNERS`", + "Description": "**GitHub repository default branch** requires **Code Owners** approval for pull requests that modify paths declared in `CODEOWNERS`. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without required **Code Owners** review, non-owners can merge changes to sensitive code, undermining **integrity**.\nThis increases the chance of **malicious code injection**, hidden backdoors, or fragile changes that enable **data exfiltration** or cause outages, impacting confidentiality and availability.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-review-from-code-owners", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py index 4c5b75010a..3b8a749961 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py @@ -30,6 +30,11 @@ class repository_default_branch_requires_codeowners_review(Check): else: report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require code owner approval for changes to owned code." + if ( + repo.default_branch.require_code_owner_reviews_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has code owner approval configured in a ruleset, but the ruleset is not active." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json index beaff24afb..c304ef26c1 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json @@ -9,12 +9,13 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** uses **branch protection** to require **conversation resolution** on pull requests (`Require conversation resolution before merging`).", + "Description": "**GitHub repository default branch** uses **branch protection** to require **conversation resolution** on pull requests (`Require conversation resolution before merging`). This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Unresolved threads let code with known concerns reach default, weakening **integrity** and **confidentiality**. Insecure changes or secrets may ship, enabling injection, auth bypass, or data exposure. **Availability** can suffer from regressions; review accountability is reduced.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py index da33754cf5..9c25f8a14f 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py @@ -26,6 +26,11 @@ class repository_default_branch_requires_conversation_resolution(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require conversation resolution on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.conversation_resolution_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has conversation resolution configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.conversation_resolution: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json index 215ab496aa..5ecb27ffc6 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json @@ -9,12 +9,13 @@ "Severity": "low", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** enforces `Require linear history`, blocking merge commits and allowing only `squash` or `rebase` merges", + "Description": "**GitHub repository default branch** enforces `Require linear history`, blocking merge commits and allowing only `squash` or `rebase` merges. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without a **linear history**, commit provenance is harder to verify, weakening **integrity** and **accountability**.\n\nMerge commits can obscure diffs entering the default branch, hindering audits and rollbacks, enabling unnoticed **malicious or unreviewed code**, and delaying incident response.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-linear-history", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py index 29a0e51b51..86f944738c 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py @@ -26,6 +26,11 @@ class repository_default_branch_requires_linear_history(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.required_linear_history_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has linear history configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.required_linear_history: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json index dda4f25ea0..c7f07b1101 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json @@ -9,12 +9,13 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** enforces **required reviews** with a minimum of `2` approving reviews before a pull request can be merged.\n\nAssesses whether an approval threshold of at least `2` is configured for code changes targeting the default branch.", + "Description": "**GitHub repository default branch** enforces **required reviews** with a minimum of `2` approving reviews before a pull request can be merged.\n\nAssesses whether an approval threshold of at least `2` is configured for code changes targeting the default branch. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without multi-review approval on the default branch, a single actor can merge changes, degrading **integrity** and **accountability**. This enables:\n- supply-chain tampering or backdoors\n- introduction of exploitable bugs\n- bypass of change control via compromised accounts", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/approving-a-pull-request-with-required-reviews", - "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging" + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py index 6312b98d02..5ddf37e853 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py @@ -26,6 +26,8 @@ class repository_default_branch_requires_multiple_approvals(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes." + if repo.default_branch.approval_count_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has at least 2 approvals configured in a ruleset, but the ruleset is not active." if repo.default_branch.approval_count >= 2: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json index d3d9c91806..9b045fc4e9 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json @@ -9,12 +9,13 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** enforces **signed and verified commits** (`Require signed commits`), allowing only commits with valid cryptographic signatures to be pushed or merged.", + "Description": "**GitHub repository default branch** enforces **signed and verified commits** (`Require signed commits`), allowing only commits with valid cryptographic signatures to be pushed or merged. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without required signing, commit authorship can be spoofed and unverified changes added, impacting integrity.\n- Backdoor injection\n- History tampering and forged identities\n- Release pipeline abuse and supply-chain compromise", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-signed-commits", - "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification" + "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py index 4b6aa3ae4c..110a7d4a1e 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py @@ -26,6 +26,11 @@ class repository_default_branch_requires_signed_commits(Check): report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not require signed commits on default branch ({repo.default_branch.name})." + if ( + repo.default_branch.require_signed_commits_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has signed commits configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active." if repo.default_branch.require_signed_commits: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json index 6d450fde33..d0799962ce 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json @@ -9,13 +9,14 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "**GitHub repository default branch** uses **required status checks**, indicating whether merges are gated by successful check results on pull requests", + "Description": "**GitHub repository default branch** uses **required status checks**, indicating whether merges are gated by successful check results on pull requests. This protection can be enforced through classic **branch protection** or repository **rulesets**.", "Risk": "Without required checks, unvetted commits can be merged, degrading code **integrity** and **availability**. Skipped or failing validations may introduce vulnerable dependencies, break builds, or allow malicious code, enabling supply-chain compromise and rapid propagation to production.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging", - "https://docs.gearset.com/en/articles/2437757-managing-status-check-rules-in-github" + "https://docs.gearset.com/en/articles/2437757-managing-status-check-rules-in-github", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" ], "Remediation": { "Code": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py index e67b9def2c..f96d5b058d 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py @@ -28,6 +28,8 @@ class repository_default_branch_status_checks_required(Check): report.status_extended = ( f"Repository {repo.name} does not enforce status checks." ) + if repo.default_branch.status_checks_source == "ruleset_not_active": + report.status_extended = f"Repository {repo.name} has status checks configured in a ruleset, but the ruleset is not active." if repo.default_branch.status_checks: report.status = "PASS" diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index deb6864cb2..fd8aa28662 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -1,4 +1,5 @@ from datetime import datetime +from fnmatch import fnmatch from typing import Optional import github @@ -100,6 +101,263 @@ class Repository(GithubService): ) return [] + def _default_branch_matches_rule_pattern( + self, pattern: str, default_branch: str + ) -> bool: + """Check whether a ruleset ref pattern applies to the default branch.""" + branch_ref = f"refs/heads/{default_branch}" + + if pattern in {"~ALL", "~DEFAULT_BRANCH"}: + return True + + return fnmatch(branch_ref, pattern) + + def _ruleset_targets_default_branch( + self, ruleset: dict, default_branch: str + ) -> bool: + """Check whether a ruleset targets the repository default branch.""" + ref_name_conditions = (ruleset.get("conditions") or {}).get("ref_name") + if not ref_name_conditions: + return False + + include_patterns = ref_name_conditions.get("include") or [] + exclude_patterns = ref_name_conditions.get("exclude") or [] + + if not include_patterns: + return False + + if not any( + self._default_branch_matches_rule_pattern(pattern, default_branch) + for pattern in include_patterns + ): + return False + + return not any( + self._default_branch_matches_rule_pattern(pattern, default_branch) + for pattern in exclude_patterns + ) + + def _get_repository_rulesets(self, repo) -> Optional[list[dict]]: + """Fetch repository and parent branch rulesets with full rule details.""" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + try: + rulesets = [] + page = 1 + + while True: + _, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/repos/{repo.full_name}/rulesets?includes_parents=true&targets=branch&per_page=100&page={page}", + headers=headers, + ) + + if not isinstance(response, list): + break + + rulesets.extend(response) + + if len(response) < 100: + break + + page += 1 + + detailed_rulesets = [] + for ruleset in rulesets: + ruleset_id = ruleset.get("id") + if ruleset_id is None: + continue + + _, ruleset_details = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/repos/{repo.full_name}/rulesets/{ruleset_id}?includes_parents=true", + headers=headers, + ) + if isinstance(ruleset_details, dict): + detailed_rulesets.append(ruleset_details) + + return detailed_rulesets + except github.GithubException as error: + status_code = getattr(error, "status", None) + if status_code == 404: + logger.info( + f"{repo.full_name}: rulesets endpoint not available for this repository." + ) + return None + if status_code == 403: + logger.warning( + f"{repo.full_name}: insufficient permissions to query repository rulesets." + ) + return None + self._handle_github_api_error( + error, "fetching repository rulesets", repo.full_name + ) + except Exception as error: + logger.error( + f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return None + + def _evaluate_default_branch_rulesets( + self, repo, default_branch: str + ) -> dict[str, tuple[Optional[int], str]]: + """Evaluate default-branch protection coverage provided by rulesets. + + Fetches the repository (and parent) rulesets once and walks every rule that + targets the default branch, mapping each ruleset rule to its equivalent classic + branch-protection attribute. + + Returns: + dict mapping a ``Branch`` attribute name to a ``(value, source)`` tuple where + ``source`` is ``"ruleset"`` (enforced by an active ruleset), ``"ruleset_not_active"`` + (configured by a ruleset that is not active) or absent when no ruleset addresses + the attribute. ``value`` is only meaningful for ``approval_count`` (the enforced + review count); for boolean attributes it is ``None`` and the caller derives the + value from ``source`` and the attribute polarity. + """ + result: dict[str, tuple[Optional[int], str]] = {} + rulesets = self._get_repository_rulesets(repo) + if not rulesets: + return result + + active: set[str] = set() + inactive: set[str] = set() + active_approval_count: Optional[int] = None + inactive_approval_count: Optional[int] = None + any_active_ruleset = False + any_inactive_ruleset = False + # A ruleset with no bypass actors applies to everyone, including administrators + # (the rulesets equivalent of "enforce admins"). A ruleset that has bypass actors + # would not apply to admins even if activated, so it must not drive the + # enforce-admins finding in either the active or the inactive case. + admins_enforced_active = False + admins_configured_inactive = False + + for ruleset in rulesets: + if ruleset.get("target") != "branch": + continue + + if not self._ruleset_targets_default_branch(ruleset, default_branch): + continue + + enforcement = ruleset.get("enforcement") + is_active = enforcement in {"active", "enabled"} + is_inactive = enforcement in {"disabled", "evaluate"} + if not (is_active or is_inactive): + continue + + has_no_bypass_actors = not (ruleset.get("bypass_actors") or []) + if is_active: + any_active_ruleset = True + if has_no_bypass_actors: + admins_enforced_active = True + else: + any_inactive_ruleset = True + if has_no_bypass_actors: + admins_configured_inactive = True + + bucket = active if is_active else inactive + + for rule in ruleset.get("rules") or []: + rule_type = rule.get("type") + params = rule.get("parameters") or {} + + if rule_type == "required_linear_history": + bucket.add("required_linear_history") + elif rule_type == "required_signatures": + bucket.add("require_signed_commits") + elif rule_type == "required_status_checks": + # Only enforced when at least one status check is configured; + # an empty list (or just strict policy) requires nothing. + if params.get("required_status_checks"): + bucket.add("status_checks") + elif rule_type == "non_fast_forward": + # Presence of the rule disallows force pushes. + bucket.add("allow_force_pushes") + elif rule_type == "deletion": + # Presence of the rule disallows branch deletion. + bucket.add("branch_deletion") + elif rule_type == "pull_request": + bucket.add("require_pull_request") + if params.get("require_code_owner_review") is True: + bucket.add("require_code_owner_reviews") + if params.get("required_review_thread_resolution") is True: + bucket.add("conversation_resolution") + if params.get("dismiss_stale_reviews_on_push") is True: + bucket.add("dismiss_stale_reviews") + count = params.get("required_approving_review_count") + if isinstance(count, int): + if is_active: + active_approval_count = max( + active_approval_count or 0, count + ) + else: + inactive_approval_count = max( + inactive_approval_count or 0, count + ) + + for concept in ( + "required_linear_history", + "require_signed_commits", + "status_checks", + "allow_force_pushes", + "branch_deletion", + "require_pull_request", + "require_code_owner_reviews", + "conversation_resolution", + "dismiss_stale_reviews", + ): + if concept in active: + result[concept] = (None, "ruleset") + elif concept in inactive: + result[concept] = (None, "ruleset_not_active") + + if active_approval_count is not None: + result["approval_count"] = (active_approval_count, "ruleset") + elif inactive_approval_count is not None: + result["approval_count"] = (inactive_approval_count, "ruleset_not_active") + + if any_active_ruleset: + result["protected"] = (None, "ruleset") + elif any_inactive_ruleset: + result["protected"] = (None, "ruleset_not_active") + + if admins_enforced_active: + result["enforce_admins"] = (None, "ruleset") + elif admins_configured_inactive: + result["enforce_admins"] = (None, "ruleset_not_active") + + return result + + @staticmethod + def _merge_ruleset_bool( + classic_value: Optional[bool], ruleset_source: Optional[str], good: bool = True + ) -> tuple[Optional[bool], Optional[str]]: + """Merge a boolean branch-protection attribute with its ruleset evaluation. + + Args: + classic_value: The value resolved from classic branch protection. + ruleset_source: ``"ruleset"``, ``"ruleset_not_active"`` or ``None``. + good: The compliant value for the attribute (``True`` for positive attributes, + ``False`` for inverted ones such as ``allow_force_pushes``). + + Returns: + A ``(value, source)`` tuple. Classic protection wins when it already satisfies + the control; otherwise an active ruleset enforces the compliant value, and an + inactive ruleset surfaces the non-compliant value with a ``"ruleset_not_active"`` + source so checks can explain the gap. + """ + classic_pass = classic_value == good + if ruleset_source == "ruleset": + return good, "ruleset" + if ruleset_source == "ruleset_not_active" and not classic_pass: + return (not good), "ruleset_not_active" + return classic_value, ("classic" if classic_pass else None) + def _list_repositories(self): """ List repositories based on provider scoping configuration. @@ -256,6 +514,19 @@ class Repository(GithubService): status_checks = False enforce_admins = False conversation_resolution = False + dismiss_stale_reviews = False + dismiss_stale_reviews_source = None + protected_source = None + require_pull_request_source = None + approval_count_source = None + required_linear_history_source = None + allow_force_pushes_source = None + branch_deletion_source = None + require_code_owner_reviews_source = None + require_signed_commits_source = None + status_checks_source = None + enforce_admins_source = None + conversation_resolution_source = None try: branch = repo.get_branch(default_branch) if branch.protected: @@ -283,6 +554,13 @@ class Repository(GithubService): if require_pr else False ) + dismiss_stale_reviews = ( + protection.required_pull_request_reviews.dismiss_stale_reviews + if require_pr + else False + ) + if dismiss_stale_reviews: + dismiss_stale_reviews_source = "classic" require_signed_commits = branch.get_required_signatures() except Exception as error: # If the branch is not found, it is not protected @@ -303,10 +581,104 @@ class Repository(GithubService): status_checks = None enforce_admins = None conversation_resolution = None + dismiss_stale_reviews = None + dismiss_stale_reviews_source = None logger.error( f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + # Branch protection enforced through rulesets is equivalent to classic branch + # protection, so merge any ruleset coverage before reporting findings to avoid + # false positives for repositories that have migrated to rulesets. + if branch_protection is not None: + ruleset_eval = self._evaluate_default_branch_rulesets( + repo, default_branch + ) + + branch_protection, protected_source = self._merge_ruleset_bool( + branch_protection, ruleset_eval.get("protected", (None, None))[1] + ) + require_pr, require_pull_request_source = self._merge_ruleset_bool( + require_pr, + ruleset_eval.get("require_pull_request", (None, None))[1], + ) + ( + required_linear_history, + required_linear_history_source, + ) = self._merge_ruleset_bool( + required_linear_history, + ruleset_eval.get("required_linear_history", (None, None))[1], + ) + ( + require_signed_commits, + require_signed_commits_source, + ) = self._merge_ruleset_bool( + require_signed_commits, + ruleset_eval.get("require_signed_commits", (None, None))[1], + ) + status_checks, status_checks_source = self._merge_ruleset_bool( + status_checks, ruleset_eval.get("status_checks", (None, None))[1] + ) + ( + require_code_owner_reviews, + require_code_owner_reviews_source, + ) = self._merge_ruleset_bool( + require_code_owner_reviews, + ruleset_eval.get("require_code_owner_reviews", (None, None))[1], + ) + ( + conversation_resolution, + conversation_resolution_source, + ) = self._merge_ruleset_bool( + conversation_resolution, + ruleset_eval.get("conversation_resolution", (None, None))[1], + ) + enforce_admins, enforce_admins_source = self._merge_ruleset_bool( + enforce_admins, + ruleset_eval.get("enforce_admins", (None, None))[1], + ) + allow_force_pushes, allow_force_pushes_source = ( + self._merge_ruleset_bool( + allow_force_pushes, + ruleset_eval.get("allow_force_pushes", (None, None))[1], + good=False, + ) + ) + branch_deletion, branch_deletion_source = self._merge_ruleset_bool( + branch_deletion, + ruleset_eval.get("branch_deletion", (None, None))[1], + good=False, + ) + + # Dismiss stale reviews keeps its dedicated handling so the classic source + # set above (when enabled) is preserved. + _, dismiss_source = ruleset_eval.get( + "dismiss_stale_reviews", (None, None) + ) + if dismiss_source == "ruleset": + dismiss_stale_reviews = True + dismiss_stale_reviews_source = "ruleset" + elif ( + dismiss_source == "ruleset_not_active" and not dismiss_stale_reviews + ): + dismiss_stale_reviews = False + dismiss_stale_reviews_source = "ruleset_not_active" + + # Approval count takes the strongest requirement between classic and rulesets. + approval_value, approval_source = ruleset_eval.get( + "approval_count", (None, None) + ) + if approval_source == "ruleset" and approval_value is not None: + if approval_value > approval_cnt: + approval_cnt = approval_value + approval_count_source = "ruleset" + elif ( + approval_source == "ruleset_not_active" + and (approval_value or 0) >= 2 + and approval_cnt < 2 + ): + approval_count_source = "ruleset_not_active" + secret_scanning_enabled = False dependabot_alerts_enabled = False try: @@ -352,17 +724,30 @@ class Repository(GithubService): default_branch=Branch( name=default_branch, protected=branch_protection, + protected_source=protected_source, default_branch=True, require_pull_request=require_pr, + require_pull_request_source=require_pull_request_source, approval_count=approval_cnt, + approval_count_source=approval_count_source, required_linear_history=required_linear_history, + required_linear_history_source=required_linear_history_source, allow_force_pushes=allow_force_pushes, + allow_force_pushes_source=allow_force_pushes_source, branch_deletion=branch_deletion, + branch_deletion_source=branch_deletion_source, status_checks=status_checks, + status_checks_source=status_checks_source, enforce_admins=enforce_admins, + enforce_admins_source=enforce_admins_source, conversation_resolution=conversation_resolution, + conversation_resolution_source=conversation_resolution_source, require_code_owner_reviews=require_code_owner_reviews, + require_code_owner_reviews_source=require_code_owner_reviews_source, require_signed_commits=require_signed_commits, + require_signed_commits_source=require_signed_commits_source, + dismiss_stale_reviews=dismiss_stale_reviews, + dismiss_stale_reviews_source=dismiss_stale_reviews_source, ), private=repo.private, archived=repo.archived, @@ -432,17 +817,30 @@ class Branch(BaseModel): name: str protected: Optional[bool] + protected_source: Optional[str] = None default_branch: bool require_pull_request: Optional[bool] + require_pull_request_source: Optional[str] = None approval_count: Optional[int] + approval_count_source: Optional[str] = None required_linear_history: Optional[bool] + required_linear_history_source: Optional[str] = None allow_force_pushes: Optional[bool] + allow_force_pushes_source: Optional[str] = None branch_deletion: Optional[bool] + branch_deletion_source: Optional[str] = None status_checks: Optional[bool] + status_checks_source: Optional[str] = None enforce_admins: Optional[bool] + enforce_admins_source: Optional[str] = None require_code_owner_reviews: Optional[bool] + require_code_owner_reviews_source: Optional[str] = None require_signed_commits: Optional[bool] + require_signed_commits_source: Optional[str] = None conversation_resolution: Optional[bool] + conversation_resolution_source: Optional[str] = None + dismiss_stale_reviews: Optional[bool] + dismiss_stale_reviews_source: Optional[str] = None class Repo(BaseModel): diff --git a/prowler/providers/googleworkspace/googleworkspace_provider.py b/prowler/providers/googleworkspace/googleworkspace_provider.py index 83beee0d3e..2a40a59ddf 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, ) @@ -53,17 +54,23 @@ class GoogleworkspaceProvider(Provider): """ _type: str = "googleworkspace" + sdk_only: bool = False _session: GoogleWorkspaceSession _identity: GoogleWorkspaceIdentityInfo + _domain_resource: GoogleWorkspaceResource _audit_config: dict _mutelist: GoogleWorkspaceMutelist audit_metadata: Audit_Metadata - # Google Workspace Admin SDK OAuth2 scopes - DIRECTORY_SCOPES = [ + # Google Workspace OAuth2 scopes + SCOPES = [ "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", ] def __init__( @@ -108,6 +115,7 @@ class GoogleworkspaceProvider(Provider): self._session, resolved_delegated_user, ) + self._domain_resource = GoogleWorkspaceResource.from_identity(self._identity) # Audit Config if config_content: @@ -149,6 +157,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 @@ -214,7 +228,7 @@ class GoogleworkspaceProvider(Provider): try: credentials = service_account.Credentials.from_service_account_file( credentials_file, - scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES, + scopes=GoogleworkspaceProvider.SCOPES, ) except FileNotFoundError as error: raise GoogleWorkspaceInvalidCredentialsError( @@ -241,7 +255,7 @@ class GoogleworkspaceProvider(Provider): try: credentials = service_account.Credentials.from_service_account_info( credentials_data, - scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES, + scopes=GoogleworkspaceProvider.SCOPES, ) except ValueError as error: raise GoogleWorkspaceInvalidCredentialsError( @@ -264,7 +278,7 @@ class GoogleworkspaceProvider(Provider): try: credentials = service_account.Credentials.from_service_account_file( env_file, - scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES, + scopes=GoogleworkspaceProvider.SCOPES, ) except FileNotFoundError as error: raise GoogleWorkspaceInvalidCredentialsError( @@ -293,7 +307,7 @@ class GoogleworkspaceProvider(Provider): try: credentials = service_account.Credentials.from_service_account_info( credentials_data, - scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES, + scopes=GoogleworkspaceProvider.SCOPES, ) except ValueError as error: raise GoogleWorkspaceInvalidCredentialsError( @@ -414,7 +428,7 @@ class GoogleworkspaceProvider(Provider): ) # Fetch all domains (primary + aliases) to support domain aliases - # The scope admin.directory.domain.readonly is already in DIRECTORY_SCOPES + # The scope admin.directory.domain.readonly is already in SCOPES above try: domains_response = service.domains().list(customer="my_customer").execute() valid_domains = [ @@ -446,10 +460,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 2b12cda333..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,6 +42,27 @@ class GoogleWorkspaceService: ) return None + 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 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 + 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 = ""): """ Centralized Google Workspace API error handling. 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/additionalservices/__init__.py b/prowler/providers/googleworkspace/services/additionalservices/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/additionalservices/additionalservices_client.py b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_client.py new file mode 100644 index 0000000000..0d256528fa --- /dev/null +++ b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServices, +) + +additionalservices_client = AdditionalServices(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/__init__.py b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled.metadata.json b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled.metadata.json new file mode 100644 index 0000000000..8dcf6c1f3f --- /dev/null +++ b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "additionalservices_external_groups_disabled", + "CheckTitle": "Access to external Google Groups is off for everyone", + "CheckType": [], + "ServiceName": "additionalservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The Additional Google services configuration **disables access to external Google Groups** for all users. This setting controls whether users can access groups created outside the organization from their Google Workspace account.", + "Risk": "When external Google Groups access is enabled, users can access and participate in groups created **outside the organization**, potentially exposing them to **phishing, social engineering, or data leakage** through unmanaged external group communications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/advanced/turn-on-or-off-additional-google-services", + "https://docs.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** > **Additional Google services**\n3. Scroll down to **Google Groups**\n4. Set it to **OFF for everyone**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable access to **external Google Groups** for all users. If specific users require access to external groups, enable it by exception for those users or groups only.", + "Url": "https://hub.prowler.com/check/additionalservices_external_groups_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check covers the 'Additional Google services > Google Groups' toggle, which is distinct from the 'Groups for Business' sharing settings covered by the groups service checks." +} diff --git a/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled.py b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled.py new file mode 100644 index 0000000000..cf61220054 --- /dev/null +++ b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.additionalservices.additionalservices_client import ( + additionalservices_client, +) + + +class additionalservices_external_groups_disabled(Check): + """Check that access to external Google Groups is disabled for all users. + + This check verifies that the domain-level Additional Google services policy + disables external Google Groups access, preventing users from accessing + groups created outside the organization. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if additionalservices_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=additionalservices_client.policies, + resource_id="additionalServicesPolicies", + resource_name="Additional Services Policies", + customer_id=additionalservices_client.provider.identity.customer_id, + ) + + groups_state = additionalservices_client.policies.groups_service_state + + if groups_state == "DISABLED": + report.status = "PASS" + report.status_extended = ( + f"Access to external Google Groups is disabled " + f"in domain {additionalservices_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if groups_state is None: + report.status_extended = ( + f"Access to external Google Groups is not explicitly configured " + f"in domain {additionalservices_client.provider.identity.domain}. " + f"The default is ON for everyone. " + f"External Google Groups access should be disabled." + ) + else: + report.status_extended = ( + f"Access to external Google Groups is enabled " + f"in domain {additionalservices_client.provider.identity.domain}. " + f"External Google Groups access should be disabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/additionalservices/additionalservices_service.py b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_service.py new file mode 100644 index 0000000000..5a41060bc8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_service.py @@ -0,0 +1,92 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class AdditionalServices(GoogleWorkspaceService): + """Google Workspace Additional Services for auditing domain-level service toggles. + + Uses the Cloud Identity Policy API v1 to read the service status of + additional Google services configured in the Admin Console, such as + the external Google Groups access toggle. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = AdditionalServicesPolicies() + self.policies_fetched = False + self._fetch_additional_services_policies() + + def _fetch_additional_services_policies(self): + """Fetch Additional Services policies from the Cloud Identity Policy API v1.""" + logger.info("Additional Services - Fetching policies...") + + try: + service = self._build_service("cloudidentity", "v1") + + if not service: + logger.error("Failed to build Cloud Identity service") + return + + request = service.policies().list( + pageSize=100, + filter='setting.type.matches("groups.service_status")', + ) + 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/") + value = setting.get("value", {}) + + if setting_type == "groups.service_status": + self.policies.groups_service_state = value.get( + "serviceState" + ) + logger.debug( + "Additional Services - Groups service state: " + f"{self.policies.groups_service_state}" + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching Additional Services policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + + logger.info( + f"Additional Services policies fetched - " + f"Groups service state: {self.policies.groups_service_state}" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching Additional Services policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class AdditionalServicesPolicies(BaseModel): + """Model for domain-level Additional Google Services policy settings.""" + + # groups.service_status + groups_service_state: Optional[str] = None diff --git a/prowler/providers/googleworkspace/services/calendar/__init__.py b/prowler/providers/googleworkspace/services/calendar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_client.py b/prowler/providers/googleworkspace/services/calendar/calendar_client.py new file mode 100644 index 0000000000..9162bb3207 --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + Calendar, +) + +calendar_client = Calendar(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/__init__.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.metadata.json b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.metadata.json new file mode 100644 index 0000000000..3cca0005dd --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "calendar_external_invitations_warning", + "CheckTitle": "External invitation warnings are enabled for Google Calendar", + "CheckType": [], + "ServiceName": "calendar", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide Google Calendar configuration **warns users** when they invite guests from outside the organization to an event. This prompt gives users a chance to reconsider before sharing meeting details with external parties, reducing the likelihood of **accidental information disclosure** through calendar invitations.", + "Risk": "Without external invitation warnings, users may unintentionally include **external guests** in internal meetings, exposing **confidential meeting details**, agendas, and internal attendee lists to unauthorized parties. This is a common vector for inadvertent data leakage through everyday calendar actions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/calendar/allow-external-invitations-in-google-calendar-events", + "https://docs.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** > **Calendar**\n3. Click **Sharing settings**\n4. Under **External invitations**, check **Warn users when inviting guests outside of the domain**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable external invitation warnings so users are notified whenever a meeting invitation includes guests outside the organization. This simple prompt helps prevent accidental disclosure of meeting details to unintended recipients.", + "Url": "https://hub.prowler.com/check/calendar_external_invitations_warning" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "calendar_external_sharing_primary_calendar", + "calendar_external_sharing_secondary_calendar" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..f2c8f58b2f --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.calendar.calendar_client import ( + calendar_client, +) + + +class calendar_external_invitations_warning(Check): + """Check that external invitation warnings are enabled for Google Calendar + + This check verifies that the domain-level policy warns users when they + invite guests from outside the organization, reducing the risk of accidental + information disclosure through calendar events. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if calendar_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=calendar_client.policies, + resource_id="calendarPolicies", + resource_name="Calendar Policies", + customer_id=calendar_client.provider.identity.customer_id, + ) + + warning_enabled = calendar_client.policies.external_invitations_warning + + if warning_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"External invitation warnings for Google Calendar are enabled " + f"in domain {calendar_client.provider.identity.domain}." + ) + elif warning_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"External invitation warnings for Google Calendar use Google's " + f"secure default configuration (enabled) " + f"in domain {calendar_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"External invitation warnings for Google Calendar are disabled " + f"in domain {calendar_client.provider.identity.domain}. " + f"Users should be warned when inviting guests outside the organization." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/__init__.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.metadata.json b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.metadata.json new file mode 100644 index 0000000000..d923754a9e --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "calendar_external_sharing_primary_calendar", + "CheckTitle": "External sharing for primary calendars is restricted to free/busy only", + "CheckType": [], + "ServiceName": "calendar", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default for primary calendars shares **only free/busy information** with external users. When external sharing is set to share full event details, sensitive information such as meeting titles, attendees, locations, and descriptions is exposed to users outside the organization.", + "Risk": "Overly permissive external sharing of primary calendars exposes **sensitive meeting metadata** — titles, attendees, locations, and descriptions — to users outside the organization. This increases the risk of **information disclosure**, **social engineering**, and **targeted phishing** based on insights into organizational activities.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options", + "https://docs.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** > **Calendar**\n3. Click **Sharing settings**\n4. Under **External sharing options for primary calendars**, select **Only free/busy information (hide event details)**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict external sharing of primary calendars to free/busy information only. This preserves scheduling functionality with external users while preventing exposure of sensitive meeting details.", + "Url": "https://hub.prowler.com/check/calendar_external_sharing_primary_calendar" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "calendar_external_sharing_secondary_calendar", + "calendar_external_invitations_warning" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..42be5e9f0d --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.calendar.calendar_client import ( + calendar_client, +) + + +class calendar_external_sharing_primary_calendar(Check): + """Check that external sharing for primary calendars is restricted to free/busy only + + This check verifies that the domain-level policy for primary calendar external + sharing is set to share only free/busy information, preventing exposure of + event details to external users. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if calendar_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=calendar_client.policies, + resource_id="calendarPolicies", + resource_name="Calendar Policies", + customer_id=calendar_client.provider.identity.customer_id, + ) + + sharing = calendar_client.policies.primary_calendar_external_sharing + + if sharing == "EXTERNAL_FREE_BUSY_ONLY": + report.status = "PASS" + report.status_extended = ( + f"Primary calendar external sharing in domain " + f"{calendar_client.provider.identity.domain} is restricted to " + f"free/busy information only." + ) + elif sharing is None: + report.status = "PASS" + report.status_extended = ( + f"Primary calendar external sharing uses Google's secure default " + f"configuration (free/busy only) " + f"in domain {calendar_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Primary calendar external sharing in domain " + f"{calendar_client.provider.identity.domain} is set to {sharing}. " + f"External sharing should be restricted to free/busy information only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/__init__.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.metadata.json b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.metadata.json new file mode 100644 index 0000000000..d1ac914a0f --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "calendar_external_sharing_secondary_calendar", + "CheckTitle": "External sharing for secondary calendars is restricted to free/busy only", + "CheckType": [], + "ServiceName": "calendar", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default for secondary calendars shares **only free/busy information** with external users. Secondary calendars are additional calendars users create beyond their primary calendar (e.g., for projects, teams, or personal events), and are commonly used to organize sensitive or focused activities that should not be visible to external parties.", + "Risk": "Overly permissive external sharing of secondary calendars exposes **project-specific or team-specific event details** to users outside the organization. Because secondary calendars often hold more targeted activities (e.g., product launches, internal reviews), unrestricted external sharing increases the risk of **information disclosure** and **competitive intelligence leakage**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options", + "https://docs.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** > **Calendar**\n3. Click **Sharing settings**\n4. Under **External sharing options for secondary calendars**, select **Only free/busy information (hide event details)**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict external sharing of secondary calendars to free/busy information only. This preserves scheduling interoperability with external collaborators while preventing exposure of sensitive event details in user-created calendars.", + "Url": "https://hub.prowler.com/check/calendar_external_sharing_secondary_calendar" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "calendar_external_sharing_primary_calendar", + "calendar_external_invitations_warning" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..5f53fd2089 --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.calendar.calendar_client import ( + calendar_client, +) + + +class calendar_external_sharing_secondary_calendar(Check): + """Check that external sharing for secondary calendars is restricted to free/busy only + + This check verifies that the domain-level policy for secondary calendar external + sharing is set to share only free/busy information, preventing exposure of + event details in user-created calendars to external users. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if calendar_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=calendar_client.policies, + resource_id="calendarPolicies", + resource_name="Calendar Policies", + customer_id=calendar_client.provider.identity.customer_id, + ) + + sharing = calendar_client.policies.secondary_calendar_external_sharing + + if sharing == "EXTERNAL_FREE_BUSY_ONLY": + report.status = "PASS" + report.status_extended = ( + f"Secondary calendar external sharing in domain " + f"{calendar_client.provider.identity.domain} is restricted to " + f"free/busy information only." + ) + else: + report.status = "FAIL" + if sharing is None: + report.status_extended = ( + f"Secondary calendar external sharing is not explicitly configured " + f"in domain {calendar_client.provider.identity.domain}. " + f"External sharing should be restricted to free/busy information only." + ) + else: + report.status_extended = ( + f"Secondary calendar external sharing in domain " + f"{calendar_client.provider.identity.domain} is set to {sharing}. " + f"External sharing should be restricted to free/busy information only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_service.py b/prowler/providers/googleworkspace/services/calendar/calendar_service.py new file mode 100644 index 0000000000..aa822d4218 --- /dev/null +++ b/prowler/providers/googleworkspace/services/calendar/calendar_service.py @@ -0,0 +1,117 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Calendar(GoogleWorkspaceService): + """Google Workspace Calendar service for auditing domain-level calendar policies. + + Uses the Cloud Identity Policy API v1 to read calendar sharing + and invitation settings configured in the Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = CalendarPolicies() + self.policies_fetched = False + self._fetch_calendar_policies() + + def _fetch_calendar_policies(self): + """Fetch calendar policies from the Cloud Identity Policy API v1.""" + logger.info("Calendar - Fetching calendar 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("calendar.*")', + ) + 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/") + value = setting.get("value", {}) + + if ( + setting_type + == "calendar.primary_calendar_max_allowed_external_sharing" + ): + self.policies.primary_calendar_external_sharing = value.get( + "maxAllowedExternalSharing" + ) + logger.debug( + "Primary calendar external sharing: " + f"{self.policies.primary_calendar_external_sharing}" + ) + + elif ( + setting_type + == "calendar.secondary_calendar_max_allowed_external_sharing" + ): + self.policies.secondary_calendar_external_sharing = ( + value.get("maxAllowedExternalSharing") + ) + logger.debug( + "Secondary calendar external sharing: " + f"{self.policies.secondary_calendar_external_sharing}" + ) + + elif setting_type == "calendar.external_invitations": + self.policies.external_invitations_warning = value.get( + "warnOnInvite" + ) + logger.debug( + "External invitations warning: " + f"{self.policies.external_invitations_warning}" + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching calendar policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + + logger.info( + f"Calendar policies fetched - " + f"Primary sharing: {self.policies.primary_calendar_external_sharing}, " + f"Secondary sharing: {self.policies.secondary_calendar_external_sharing}, " + f"Invitation warnings: {self.policies.external_invitations_warning}" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching calendar policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class CalendarPolicies(BaseModel): + """Model for domain-level Calendar policy settings.""" + + primary_calendar_external_sharing: Optional[str] = None + secondary_calendar_external_sharing: Optional[str] = None + external_invitations_warning: Optional[bool] = None diff --git a/prowler/providers/googleworkspace/services/chat/__init__.py b/prowler/providers/googleworkspace/services/chat/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/__init__.py b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.metadata.json new file mode 100644 index 0000000000..070de032e1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "chat_apps_installation_disabled", + "CheckTitle": "Chat apps installation is disabled for users", + "CheckType": [], + "ServiceName": "chat", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Chat apps connect to external services to look up information, schedule meetings, or complete tasks. Apps are accounts created by Google, users in the organization, or third parties that can access user data including **email addresses**, **conversation content**, and **organizational information**.", + "Risk": "Unrestricted Chat app installation allows **unvetted third-party applications** to access user data including conversation content and organizational information. An attacker could distribute a malicious Chat app to **exfiltrate confidential data** or establish **persistent access** to internal communications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization", + "https://docs.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** > **Google Chat and classic Hangouts**\n3. Click **Chat apps**\n4. Under Chat apps access settings, set **Allow users to install Chat apps** to **OFF**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable Chat apps installation to prevent **unvetted third-party applications** from accessing organizational data through the Chat platform.", + "Url": "https://hub.prowler.com/check/chat_apps_installation_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "chat_incoming_webhooks_disabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.py new file mode 100644 index 0000000000..c80be7e6dc --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.chat.chat_client import chat_client + + +class chat_apps_installation_disabled(Check): + """Check that users cannot install Chat apps. + + This check verifies that the domain-level Chat policy prevents users + from installing Chat apps, reducing the risk of data exposure through + third-party or unvetted applications. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if chat_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=chat_client.policies, + resource_id="chatPolicies", + resource_name="Chat Policies", + customer_id=chat_client.provider.identity.customer_id, + ) + + apps_enabled = chat_client.policies.enable_apps + + if apps_enabled is False: + report.status = "PASS" + report.status_extended = ( + f"Chat apps installation is disabled " + f"in domain {chat_client.provider.identity.domain}." + ) + elif apps_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"Chat apps installation uses Google's secure default " + f"configuration (disabled) " + f"in domain {chat_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Chat apps installation is enabled " + f"in domain {chat_client.provider.identity.domain}. " + f"Chat apps installation should be disabled to prevent unvetted apps." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/chat/chat_client.py b/prowler/providers/googleworkspace/services/chat/chat_client.py new file mode 100644 index 0000000000..b8a12ecdcc --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.chat.chat_service import Chat + +chat_client = Chat(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/__init__.py b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.metadata.json new file mode 100644 index 0000000000..adc1b1fc5f --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "chat_external_file_sharing_disabled", + "CheckTitle": "External file sharing in Chat is set to no files", + "CheckType": [], + "ServiceName": "chat", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Chat **external file sharing** controls whether users can share files with people outside the organization via Chat conversations. Files often contain **confidential information**, and organizations in regulated industries need to control the flow of this information outside their boundaries.", + "Risk": "Enabled external file sharing allows users to send files containing **confidential information** to external parties through Chat. This creates a **data leakage** channel that bypasses DLP controls, particularly dangerous for organizations handling **regulated data** such as PII, PHI, or financial records.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization", + "https://docs.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** > **Google Chat and classic Hangouts**\n3. Click **Chat File Sharing**\n4. Under Setting, set **External filesharing** to **No files**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **external file sharing** in Chat to prevent users from sharing files with people outside the organization through Chat conversations.", + "Url": "https://hub.prowler.com/check/chat_external_file_sharing_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "chat_internal_file_sharing_disabled", + "drive_sharing_allowlisted_domains" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.py new file mode 100644 index 0000000000..7c17b314e2 --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.chat.chat_client import chat_client + + +class chat_external_file_sharing_disabled(Check): + """Check that external file sharing in Google Chat is disabled. + + This check verifies that the domain-level Chat policy prevents users + from sharing files with people outside the organization via Chat, + protecting sensitive information from unauthorized external access. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if chat_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=chat_client.policies, + resource_id="chatPolicies", + resource_name="Chat Policies", + customer_id=chat_client.provider.identity.customer_id, + ) + + external_sharing = chat_client.policies.external_file_sharing + + if external_sharing == "NO_FILES": + report.status = "PASS" + report.status_extended = ( + f"External file sharing in Chat is disabled " + f"in domain {chat_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if external_sharing is None: + report.status_extended = ( + f"External file sharing in Chat is not explicitly configured " + f"in domain {chat_client.provider.identity.domain}. " + f"External file sharing should be set to No files." + ) + else: + report.status_extended = ( + f"External file sharing in Chat is set to {external_sharing} " + f"in domain {chat_client.provider.identity.domain}. " + f"External file sharing should be set to No files." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/__init__.py b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.metadata.json new file mode 100644 index 0000000000..34b0016065 --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "chat_external_messaging_restricted", + "CheckTitle": "External Chat messaging is restricted to allowed domains", + "CheckType": [], + "ServiceName": "chat", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Chat **external messaging** controls whether users can send messages to people outside the organization. If external messaging is allowed, it can optionally be restricted to only **allowlisted domains** to limit the scope of external communication.", + "Risk": "Unrestricted external messaging allows users to communicate freely with **any external party**, increasing the risk of **data exfiltration** through conversation content and **social engineering attacks** from untrusted domains targeting internal users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization", + "https://docs.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** > **Google Chat and classic Hangouts**\n3. Click **External Chat Settings**\n4. Select **Chat externally**\n5. Set **Allow users to send messages outside the organization** to **ON**\n6. Check **Only allow this for allowlisted domains**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict **external Chat messaging** to **allowlisted domains** only to limit information flow to trusted parties and reduce exposure to external threats.", + "Url": "https://hub.prowler.com/check/chat_external_messaging_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "chat_external_spaces_restricted", + "drive_sharing_allowlisted_domains" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.py b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.py new file mode 100644 index 0000000000..aef75403b9 --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.py @@ -0,0 +1,59 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.chat.chat_client import chat_client + + +class chat_external_messaging_restricted(Check): + """Check that external Chat messaging is restricted to allowed domains. + + This check verifies that external Chat messaging is either disabled + entirely or restricted to allowlisted domains only, preventing + unrestricted communication with external users. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if chat_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=chat_client.policies, + resource_id="chatPolicies", + resource_name="Chat Policies", + customer_id=chat_client.provider.identity.customer_id, + ) + + allow_external = chat_client.policies.allow_external_chat + restriction = chat_client.policies.external_chat_restriction + + if allow_external is False: + report.status = "PASS" + report.status_extended = ( + f"External Chat messaging is disabled " + f"in domain {chat_client.provider.identity.domain}." + ) + elif allow_external is None and restriction is None: + report.status = "PASS" + report.status_extended = ( + f"External Chat messaging uses Google's secure default " + f"configuration (disabled) " + f"in domain {chat_client.provider.identity.domain}." + ) + elif restriction == "TRUSTED_DOMAINS": + report.status = "PASS" + report.status_extended = ( + f"External Chat messaging is restricted to allowed domains " + f"in domain {chat_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"External Chat messaging is not restricted to allowed domains " + f"in domain {chat_client.provider.identity.domain}. " + f"External messaging should be restricted to allowed domains only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/__init__.py b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.metadata.json new file mode 100644 index 0000000000..1385f34ef8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "chat_external_spaces_restricted", + "CheckTitle": "External spaces in Chat are restricted to allowed domains", + "CheckType": [], + "ServiceName": "chat", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Chat **external spaces** allow users to create or join collaborative spaces that include people outside the organization. If external spaces are allowed, they can optionally be restricted to only **allowlisted domains** to limit external participation.", + "Risk": "Unrestricted external spaces allow users to add **anyone from any domain** to persistent group conversations. This increases the risk of **confidential information exposure** in shared spaces and enables **unauthorized external access** to ongoing organizational discussions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization", + "https://docs.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** > **Google Chat and classic Hangouts**\n3. Click **External Spaces**\n4. Set **Allow users to create and join spaces with people outside their organization** to **ON**\n5. Check **Only allow users to add people from allowlisted domains**\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict **external spaces** to **allowlisted domains** only to control which external parties can participate in organizational Chat spaces.", + "Url": "https://hub.prowler.com/check/chat_external_spaces_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "chat_external_messaging_restricted", + "drive_sharing_allowlisted_domains" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.py b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.py new file mode 100644 index 0000000000..d6ad421bda --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.py @@ -0,0 +1,59 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.chat.chat_client import chat_client + + +class chat_external_spaces_restricted(Check): + """Check that external spaces in Google Chat are restricted. + + This check verifies that external spaces are either disabled entirely + or restricted to allowlisted domains only, preventing users from + creating or joining spaces with unrestricted external participants. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if chat_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=chat_client.policies, + resource_id="chatPolicies", + resource_name="Chat Policies", + customer_id=chat_client.provider.identity.customer_id, + ) + + spaces_enabled = chat_client.policies.external_spaces_enabled + allowlist_mode = chat_client.policies.external_spaces_domain_allowlist_mode + + if spaces_enabled is False: + report.status = "PASS" + report.status_extended = ( + f"External spaces are disabled " + f"in domain {chat_client.provider.identity.domain}." + ) + elif allowlist_mode == "TRUSTED_DOMAINS": + report.status = "PASS" + report.status_extended = ( + f"External spaces are restricted to allowed domains " + f"in domain {chat_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if spaces_enabled is None and allowlist_mode is None: + report.status_extended = ( + f"External spaces restriction is not explicitly configured " + f"in domain {chat_client.provider.identity.domain}. " + f"External spaces should be restricted to allowed domains only." + ) + else: + report.status_extended = ( + f"External spaces are not restricted to allowed domains " + f"in domain {chat_client.provider.identity.domain}. " + f"External spaces should be restricted to allowed domains only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/__init__.py b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.metadata.json new file mode 100644 index 0000000000..8e8f4b6301 --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "chat_incoming_webhooks_disabled", + "CheckTitle": "Incoming webhooks in Chat are disabled for users", + "CheckType": [], + "ServiceName": "chat", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Incoming webhooks** let external applications post asynchronous messages into Google Chat spaces without being a Chat app. When enabled, users can configure webhooks and developers can call them to send content from **external applications**.", + "Risk": "Exposed webhook URLs allow **unauthorized content injection** into Chat spaces. Attackers can send **fraudulent or misleading messages** that appear to come from trusted services, creating a vector for **social engineering** and **phishing** within internal communications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization", + "https://docs.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** > **Google Chat and classic Hangouts**\n3. Click **Chat apps**\n4. Under Chat apps access settings, set **Allow users to add and use incoming webhooks** to **OFF**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **incoming webhooks** to prevent unauthenticated external applications from **injecting content** into internal Chat spaces.", + "Url": "https://hub.prowler.com/check/chat_incoming_webhooks_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "chat_apps_installation_disabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.py new file mode 100644 index 0000000000..753daf02da --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.chat.chat_client import chat_client + + +class chat_incoming_webhooks_disabled(Check): + """Check that incoming webhooks are disabled in Google Chat. + + This check verifies that the domain-level Chat policy prevents users + from adding and using incoming webhooks, reducing the risk of + unauthorized content being posted into Chat spaces. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if chat_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=chat_client.policies, + resource_id="chatPolicies", + resource_name="Chat Policies", + customer_id=chat_client.provider.identity.customer_id, + ) + + webhooks_enabled = chat_client.policies.enable_webhooks + + if webhooks_enabled is False: + report.status = "PASS" + report.status_extended = ( + f"Incoming webhooks are disabled " + f"in domain {chat_client.provider.identity.domain}." + ) + elif webhooks_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"Incoming webhooks use Google's secure default " + f"configuration (disabled) " + f"in domain {chat_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Incoming webhooks are enabled " + f"in domain {chat_client.provider.identity.domain}. " + f"Incoming webhooks should be disabled to prevent unauthorized content." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/__init__.py b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.metadata.json new file mode 100644 index 0000000000..b41f662cc3 --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "chat_internal_file_sharing_disabled", + "CheckTitle": "Internal file sharing in Chat is set to no files", + "CheckType": [], + "ServiceName": "chat", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Chat **internal file sharing** controls whether users can share files with other people inside the organization via Chat conversations. Organizations in regulated industries may need to **control and audit** all file sharing, even between internal users.", + "Risk": "Unrestricted internal file sharing in Chat allows files with **sensitive information** to be distributed freely without passing through approved channels. This undermines **data governance** and **audit trail** requirements, making it harder to track data movement within the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization", + "https://docs.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** > **Google Chat and classic Hangouts**\n3. Click **Chat File Sharing**\n4. Under Setting, set **Internal filesharing** to **No files**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **internal file sharing** in Chat to enforce file distribution through **approved channels** with proper audit trails and governance controls.", + "Url": "https://hub.prowler.com/check/chat_internal_file_sharing_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "chat_external_file_sharing_disabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.py new file mode 100644 index 0000000000..614750ecda --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.chat.chat_client import chat_client + + +class chat_internal_file_sharing_disabled(Check): + """Check that internal file sharing in Google Chat is disabled. + + This check verifies that the domain-level Chat policy prevents users + from sharing files internally via Chat, providing maximum control over + file distribution within the organization. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if chat_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=chat_client.policies, + resource_id="chatPolicies", + resource_name="Chat Policies", + customer_id=chat_client.provider.identity.customer_id, + ) + + internal_sharing = chat_client.policies.internal_file_sharing + + if internal_sharing == "NO_FILES": + report.status = "PASS" + report.status_extended = ( + f"Internal file sharing in Chat is disabled " + f"in domain {chat_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if internal_sharing is None: + report.status_extended = ( + f"Internal file sharing in Chat is not explicitly configured " + f"in domain {chat_client.provider.identity.domain}. " + f"Internal file sharing should be set to No files." + ) + else: + report.status_extended = ( + f"Internal file sharing in Chat is set to {internal_sharing} " + f"in domain {chat_client.provider.identity.domain}. " + f"Internal file sharing should be set to No files." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/chat/chat_service.py b/prowler/providers/googleworkspace/services/chat/chat_service.py new file mode 100644 index 0000000000..92d4d77dfa --- /dev/null +++ b/prowler/providers/googleworkspace/services/chat/chat_service.py @@ -0,0 +1,125 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Chat(GoogleWorkspaceService): + """Google Workspace Chat service for auditing domain-level Chat policies. + + Uses the Cloud Identity Policy API v1 to read Chat file sharing, external + messaging, spaces, and apps access settings configured in the Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = ChatPolicies() + self.policies_fetched = False + self._fetch_chat_policies() + + def _fetch_chat_policies(self): + """Fetch Chat policies from the Cloud Identity Policy API v1.""" + logger.info("Chat - Fetching Chat 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("chat.*")', + ) + 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 == "chat.chat_file_sharing": + self.policies.external_file_sharing = value.get( + "externalFileSharing" + ) + self.policies.internal_file_sharing = value.get( + "internalFileSharing" + ) + logger.debug("Chat file sharing settings fetched.") + + elif setting_type == "chat.external_chat_restriction": + self.policies.allow_external_chat = value.get( + "allowExternalChat" + ) + self.policies.external_chat_restriction = value.get( + "externalChatRestriction" + ) + logger.debug( + "Chat external chat restriction settings fetched." + ) + + elif setting_type == "chat.chat_external_spaces": + self.policies.external_spaces_enabled = value.get("enabled") + self.policies.external_spaces_domain_allowlist_mode = ( + value.get("domainAllowlistMode") + ) + logger.debug("Chat external spaces settings fetched.") + + elif setting_type == "chat.chat_apps_access": + self.policies.enable_apps = value.get("enableApps") + self.policies.enable_webhooks = value.get("enableWebhooks") + logger.debug("Chat apps access settings fetched.") + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching Chat policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + logger.info("Chat policies fetched successfully.") + + except Exception as error: + self._handle_api_error( + error, + "fetching Chat policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class ChatPolicies(BaseModel): + """Model for domain-level Chat policy settings.""" + + # chat.chat_file_sharing + external_file_sharing: Optional[str] = None + internal_file_sharing: Optional[str] = None + + # chat.external_chat_restriction + allow_external_chat: Optional[bool] = None + external_chat_restriction: Optional[str] = None + + # chat.chat_external_spaces + external_spaces_enabled: Optional[bool] = None + external_spaces_domain_allowlist_mode: Optional[str] = None + + # chat.chat_apps_access + enable_apps: Optional[bool] = None + enable_webhooks: Optional[bool] = None diff --git a/prowler/providers/googleworkspace/services/directory/directory_service.py b/prowler/providers/googleworkspace/services/directory/directory_service.py index ef0b54c18c..6afa8e4521 100644 --- a/prowler/providers/googleworkspace/services/directory/directory_service.py +++ b/prowler/providers/googleworkspace/services/directory/directory_service.py @@ -8,23 +8,21 @@ class Directory(GoogleWorkspaceService): def __init__(self, provider): super().__init__(provider) + self._service = self._build_service("admin", "directory_v1") self.users = self._list_users() + self._roles = self._list_roles() + self._populate_role_assignments() def _list_users(self): logger.info("Directory - Listing Users...") users = {} try: - # Build the Admin SDK Directory service - service = self._build_service("admin", "directory_v1") - - if not service: + if not self._service: logger.error("Failed to build Directory service") return users - # Fetch users using the Directory API - # Reference: https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list - request = service.users().list( + request = self._service.users().list( customer=self.provider.identity.customer_id, maxResults=500, # Max allowed by API orderBy="email", @@ -38,14 +36,11 @@ class Directory(GoogleWorkspaceService): user = User( id=user_data.get("id"), email=user_data.get("primaryEmail"), - is_admin=user_data.get("isAdmin", False), ) users[user.id] = user - logger.debug( - f"Processed user: {user.email} (Admin: {user.is_admin})" - ) + logger.debug(f"Processed user: {user.email}") - request = service.users().list_next(request, response) + request = self._service.users().list_next(request, response) except Exception as error: self._handle_api_error( @@ -62,9 +57,108 @@ class Directory(GoogleWorkspaceService): return users + def _list_roles(self): + logger.info("Directory - Listing Roles...") + roles = {} + + try: + if not self._service: + return roles + + request = self._service.roles().list( + customer=self.provider.identity.customer_id, + ) + + while request is not None: + try: + response = request.execute() + + for role_data in response.get("items", []): + role_id = str(role_data.get("roleId", "")) + role_name = role_data.get("roleName", "") + if role_id and role_name: + roles[role_id] = Role( + id=role_id, + name=role_name, + description=role_data.get("roleDescription", ""), + is_super_admin_role=role_data.get( + "isSuperAdminRole", False + ), + ) + + request = self._service.roles().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "listing roles", + self.provider.identity.customer_id, + ) + break + + logger.info(f"Found {len(roles)} roles in the domain") + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return roles + + def _populate_role_assignments(self): + logger.info("Directory - Fetching Role Assignments...") + + if not self._service: + return + + try: + request = self._service.roleAssignments().list( + customer=self.provider.identity.customer_id, + ) + + while request is not None: + try: + response = request.execute() + + for assignment in response.get("items", []): + user_id = str(assignment.get("assignedTo", "")) + role_id = str(assignment.get("roleId", "")) + user = self.users.get(user_id) + role = self._roles.get(role_id) + if user and role: + user.role_assignments.append(role) + if role.is_super_admin_role: + user.is_admin = True + + request = self._service.roleAssignments().list_next( + request, response + ) + + except Exception as error: + self._handle_api_error( + error, + "listing role assignments", + self.provider.identity.customer_id, + ) + break + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Role(BaseModel): + + id: str + name: str + description: str = "" + is_super_admin_role: bool = False + class User(BaseModel): id: str email: str is_admin: bool = False + role_assignments: list[Role] = [] diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.metadata.json b/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.metadata.json index 24165aaade..fcd769cd12 100644 --- a/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.metadata.json +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_count/directory_super_admin_count.metadata.json @@ -14,7 +14,7 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles", - "https://support.google.com/a/answer/9011373" + "https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts" ], "Remediation": { "Code": { 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..ee857bae24 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,10 @@ 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, + resource=directory_client.provider.domain_resource, + resource_id="directoryUsers", + resource_name="Directory Users", customer_id=directory_client.provider.identity.customer_id, - location="global", ) if 2 <= admin_count <= 4: diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/__init__.py b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json new file mode 100644 index 0000000000..ec531f10c8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "directory_super_admin_only_admin_roles", + "CheckTitle": "All super admin accounts are used only for super admin activities", + "CheckType": [], + "ServiceName": "directory", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Super admin accounts do not also hold **additional admin roles** such as Groups Admin, User Management Admin, etc. Each super administrator has a separate, non-admin account for daily activities, following the **principle of least privilege**.", + "Risk": "A super admin account that also holds additional admin roles increases the **attack surface** for phishing and credential theft. Compromising a single dual-role account grants full administrative access, bypassing **separation of duties** and enabling unauthorized changes to users, billing, and security settings.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles", + "https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Directory** > **Users**\n3. Click on the super admin user who also has additional admin roles\n4. Click **Admin roles and privileges**\n5. Remove the additional admin roles from the super admin account\n6. Create a separate account for daily admin tasks", + "Terraform": "" + }, + "Recommendation": { + "Text": "Apply the principle of separation of duties by maintaining dedicated super admin accounts exclusively for privileged tasks. Daily administrative activities should be performed from separate accounts with only the delegated roles required.", + "Url": "https://hub.prowler.com/check/directory_super_admin_only_admin_roles" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "directory_super_admin_count" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..d267eb3140 --- /dev/null +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py @@ -0,0 +1,61 @@ +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, +) + + +class directory_super_admin_only_admin_roles(Check): + """Check that super admin accounts are used only for super admin activities + + This check verifies that no super admin user has additional admin roles assigned + beyond the Super Admin role. Super admins should have separate accounts for daily + activities to follow least privilege. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if directory_client.users: + for user in directory_client.users.values(): + if user.is_admin: + extra_roles = [ + r.description or r.name + for r in user.role_assignments + if not r.is_super_admin_role + ] + if 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) + + if not findings: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=directory_client.provider.domain_resource, + resource_id="directoryUsers", + resource_name="Directory Users", + customer_id=directory_client.provider.identity.customer_id, + ) + 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) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/__init__.py b/prowler/providers/googleworkspace/services/drive/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.metadata.json new file mode 100644 index 0000000000..8f1c14510a --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_access_checker_recipients_only", + "CheckTitle": "Drive Access Checker is configured to recipients only", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide Access Checker configuration ensures that when a user shares a Drive file via a Google product other than Drive itself (e.g. by pasting a link in Gmail), the suggestions never expand sharing to a wider audience or to anyone with the link. Access Checker is set to **recipients only**.", + "Risk": "If Access Checker suggests broader audiences or public visibility, users may **inadvertently widen access** to a file beyond the people they intended to share with. This is a common cause of unintentional internal or external over-sharing.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Sharing options**\n4. Under **Access Checker**, select **Recipients only**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the Drive Access Checker to suggest sharing only with the explicit recipients of a link. This prevents accidental over-sharing through Gmail and other Google integrations.", + "Url": "https://hub.prowler.com/check/drive_access_checker_recipients_only" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "drive_external_sharing_warn_users", + "drive_publishing_files_disabled" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..435e9c0f7d --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_access_checker_recipients_only(Check): + """Check that Access Checker is configured to recipients only + + This check verifies that the domain-level Drive and Docs Access Checker + setting suggests granting access only to the explicit recipients of a + shared link, rather than expanding access to wider audiences or making + files publicly accessible. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + access_checker = drive_client.policies.access_checker_suggestions + + if access_checker == "RECIPIENTS_ONLY": + report.status = "PASS" + report.status_extended = ( + f"Drive and Docs Access Checker in domain " + f"{drive_client.provider.identity.domain} is restricted to " + f"recipients only." + ) + else: + report.status = "FAIL" + if access_checker is None: + report.status_extended = ( + f"Drive and Docs Access Checker is not explicitly " + f"configured in domain {drive_client.provider.identity.domain}. " + f"Access Checker should be set to recipients only." + ) + else: + report.status_extended = ( + f"Drive and Docs Access Checker in domain " + f"{drive_client.provider.identity.domain} is set to " + f"{access_checker}. Access Checker should be set to recipients only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_client.py b/prowler/providers/googleworkspace/services/drive/drive_client.py new file mode 100644 index 0000000000..e52197020f --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.drive.drive_service import Drive + +drive_client = Drive(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.metadata.json new file mode 100644 index 0000000000..576f78b19f --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_desktop_access_disabled", + "CheckTitle": "Google Drive for desktop is disabled", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default **disables Google Drive for desktop** for the organization. The Drive for desktop client synchronizes Drive content to local devices and uses its own \"offline\" mechanism that does not respect the central offline-access device policy, so disabling it closes a synchronization channel that would otherwise place organizational content on potentially unmanaged endpoints.", + "Risk": "When Drive for desktop is enabled, organizational files are **synchronized to local devices** and remain accessible if the device is lost, stolen, or compromised. Because Drive for desktop bypasses the central offline-access controls, this channel is a frequently overlooked path for sensitive data to leave organization-managed environments.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/set-up-drive-for-desktop-for-your-organization", + "https://docs.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** > **Drive and Docs**\n3. Click **Features and Applications** > **Google Drive for desktop**\n4. **Uncheck** *Allow Google Drive for desktop in your organization*\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable Google Drive for desktop to prevent local synchronization of organizational content. This reduces the risk of data loss when devices are lost or stolen and closes a channel that bypasses central offline-access controls.", + "Url": "https://hub.prowler.com/check/drive_desktop_access_disabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} 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 new file mode 100644 index 0000000000..189694adaf --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_desktop_access_disabled(Check): + """Check that Google Drive for desktop is disabled + + This check verifies that the domain-level Drive and Docs policy disables + Google Drive for desktop. The desktop client synchronizes Drive content + to local devices and bypasses the standard offline access controls, + so disabling it reduces the risk of organizational data being lost or + stolen along with an end-user device. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + allow_desktop = drive_client.policies.allow_drive_for_desktop + + if allow_desktop is False: + report.status = "PASS" + report.status_extended = ( + f"Google Drive for desktop is disabled in domain " + f"{drive_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if allow_desktop is None: + report.status_extended = ( + f"Google Drive for desktop is not explicitly configured " + f"in domain {drive_client.provider.identity.domain}. " + f"Drive for desktop should be disabled to prevent local " + f"synchronization of organizational content." + ) + else: + report.status_extended = ( + f"Google Drive for desktop is enabled in domain " + f"{drive_client.provider.identity.domain}. " + f"Drive for desktop should be disabled to prevent local " + f"synchronization of organizational content." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.metadata.json new file mode 100644 index 0000000000..474cadfe20 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_external_sharing_warn_users", + "CheckTitle": "Users are warned when sharing files outside the domain", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide Drive and Docs configuration **warns users** when they attempt to share a file with users outside the organization. This prompt gives users an opportunity to reconsider before exposing organizational content to external parties, reducing the likelihood of **accidental data disclosure** through everyday sharing actions.", + "Risk": "Without external sharing warnings, users may unintentionally share **sensitive documents** with external recipients who are not entitled to the data. This is a common vector for inadvertent leakage of intellectual property, personally identifiable information, and confidential business data through routine Drive sharing.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Sharing options**\n4. Under **Sharing outside of **, ensure sharing outside the domain is allowed and check **For files owned by users in warn when sharing outside of **\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable external sharing warnings so users are notified whenever they attempt to share a file outside the organization. This simple prompt helps prevent accidental disclosure of sensitive content to unintended recipients.", + "Url": "https://hub.prowler.com/check/drive_external_sharing_warn_users" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "drive_publishing_files_disabled", + "drive_sharing_allowlisted_domains", + "drive_warn_sharing_with_allowlisted_domains", + "drive_access_checker_recipients_only", + "drive_internal_users_distribute_content" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..98da6de892 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_external_sharing_warn_users(Check): + """Check that users are warned when sharing files outside the domain + + This check verifies that the domain-level Drive and Docs policy warns + users when they attempt to share a file with someone outside the + organization, reducing the risk of accidental information disclosure. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + warning_enabled = drive_client.policies.warn_for_external_sharing + + if warning_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"External sharing warnings for Drive and Docs are enabled " + f"in domain {drive_client.provider.identity.domain}." + ) + elif warning_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"External sharing warnings for Drive and Docs use Google's " + f"secure default configuration (enabled) " + f"in domain {drive_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"External sharing warnings for Drive and Docs are disabled " + f"in domain {drive_client.provider.identity.domain}. " + f"Users should be warned when sharing files outside the organization." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.metadata.json new file mode 100644 index 0000000000..df537051ba --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_internal_users_distribute_content", + "CheckTitle": "Only internal users can distribute content outside the organization", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default restricts distributing organizational content to shared drives owned by **another organization** to eligible **internal users** only. This prevents external collaborators with manager access to internal shared drives from moving content out of the organization.", + "Risk": "If external users can move files from internal shared drives into shared drives owned by another organization, the organization **loses authoritative control** over its own data. This is a frequently overlooked path for unintentional or malicious data exfiltration through shared drive collaboration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Sharing options**\n4. Under **Distributing content outside of **, select **Only users in **\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict the ability to distribute content to shared drives owned by another organization to internal users only. This preserves authoritative control over organizational data and closes a common shared-drive exfiltration path.", + "Url": "https://hub.prowler.com/check/drive_internal_users_distribute_content" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "drive_external_sharing_warn_users", + "drive_publishing_files_disabled" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..cdf9f1c642 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_internal_users_distribute_content(Check): + """Check that only internal users can distribute content externally + + This check verifies that the domain-level Drive and Docs policy restricts + distributing content to shared drives owned by another organization to + eligible internal users only, preventing external collaborators from + moving organizational content out of the domain. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + allowed = drive_client.policies.allowed_parties_for_distributing_content + + if allowed in ("ELIGIBLE_INTERNAL_USERS", "NONE"): + report.status = "PASS" + report.status_extended = ( + f"Distributing content outside the organization in domain " + f"{drive_client.provider.identity.domain} is restricted to " + f"{allowed}." + ) + else: + report.status = "FAIL" + if allowed is None: + report.status_extended = ( + f"Allowed parties for distributing content externally is not " + f"explicitly configured in domain " + f"{drive_client.provider.identity.domain}. " + f"Only internal users should be allowed to distribute content externally." + ) + else: + report.status_extended = ( + f"Distributing content outside the organization in domain " + f"{drive_client.provider.identity.domain} is set to {allowed}. " + f"Only internal users should be allowed to distribute content externally." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.metadata.json new file mode 100644 index 0000000000..323d7f07a1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_publishing_files_disabled", + "CheckTitle": "Publishing Drive files to the web is disabled", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide Drive and Docs default **prevents users from publishing files to the web** or making them visible to the world as public or unlisted. Publishing a file to the web exposes its content to anyone on the internet, often without any audit trail, making it one of the highest-impact misconfigurations available to end users.", + "Risk": "Allowing users to publish Drive files to the web creates a path for **unbounded data exposure**. Sensitive documents, intellectual property, customer data, or internal communications can be made publicly accessible — and indexed by search engines — with a single click, often unintentionally.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Sharing options**\n4. Under **Sharing outside of **, **uncheck** *When sharing outside of is allowed, users in can make files and published web content visible to anyone with the link*\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable the ability for users to publish Drive files to the web or make them visible to anyone with the link. This eliminates the most direct path to unintentional public data exposure through Drive.", + "Url": "https://hub.prowler.com/check/drive_publishing_files_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "drive_external_sharing_warn_users", + "drive_sharing_allowlisted_domains", + "drive_internal_users_distribute_content" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..397f1eee68 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py @@ -0,0 +1,53 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_publishing_files_disabled(Check): + """Check that publishing Drive files to the web is disabled + + This check verifies that the domain-level Drive and Docs policy prevents + users from publishing files to the web or making them visible to anyone + with the link, blocking unintended public exposure of organizational + content. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + allow_publishing = drive_client.policies.allow_publishing_files + + if allow_publishing is False: + report.status = "PASS" + report.status_extended = ( + f"Publishing files to the web is disabled in domain " + f"{drive_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if allow_publishing is None: + report.status_extended = ( + f"Publishing files to the web is not explicitly configured " + f"in domain {drive_client.provider.identity.domain}. " + f"Users should not be able to publish files to the web or make them public." + ) + else: + report.status_extended = ( + f"Publishing files to the web is enabled in domain " + f"{drive_client.provider.identity.domain}. " + f"Users should not be able to publish files to the web or make them public." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_service.py b/prowler/providers/googleworkspace/services/drive/drive_service.py new file mode 100644 index 0000000000..68c4b48453 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_service.py @@ -0,0 +1,153 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Drive(GoogleWorkspaceService): + """Google Workspace Drive and Docs service for auditing domain-level Drive policies. + + Uses the Cloud Identity Policy API v1 to read Drive and Docs sharing, + shared drive creation, and Drive for desktop settings configured in the + Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = DrivePolicies() + self.policies_fetched = False + self._fetch_drive_policies() + + def _fetch_drive_policies(self): + """Fetch Drive and Docs policies from the Cloud Identity Policy API v1.""" + logger.info("Drive - Fetching Drive and Docs 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("drive_and_docs.*")', + ) + 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/") + value = setting.get("value", {}) + + if setting_type == "drive_and_docs.external_sharing": + self.policies.external_sharing_mode = value.get( + "externalSharingMode" + ) + self.policies.warn_for_external_sharing = value.get( + "warnForExternalSharing" + ) + self.policies.warn_for_sharing_outside_allowlisted_domains = value.get( + "warnForSharingOutsideAllowlistedDomains" + ) + self.policies.allow_publishing_files = value.get( + "allowPublishingFiles" + ) + self.policies.access_checker_suggestions = value.get( + "accessCheckerSuggestions" + ) + self.policies.allowed_parties_for_distributing_content = ( + value.get("allowedPartiesForDistributingContent") + ) + logger.debug( + "Drive external sharing settings fetched: " + f"mode={self.policies.external_sharing_mode}, " + f"warn={self.policies.warn_for_external_sharing}, " + f"publish={self.policies.allow_publishing_files}" + ) + + elif setting_type == "drive_and_docs.shared_drive_creation": + self.policies.allow_shared_drive_creation = value.get( + "allowSharedDriveCreation" + ) + self.policies.allow_managers_to_override_settings = ( + value.get("allowManagersToOverrideSettings") + ) + self.policies.allow_non_member_access = value.get( + "allowNonMemberAccess" + ) + self.policies.allowed_parties_for_download_print_copy = ( + value.get("allowedPartiesForDownloadPrintCopy") + ) + logger.debug( + "Drive shared drive creation settings fetched: " + f"creation={self.policies.allow_shared_drive_creation}, " + f"managers_override={self.policies.allow_managers_to_override_settings}" + ) + + elif setting_type == "drive_and_docs.drive_for_desktop": + self.policies.allow_drive_for_desktop = value.get( + "allowDriveForDesktop" + ) + logger.debug( + "Drive for desktop setting fetched: " + f"{self.policies.allow_drive_for_desktop}" + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching Drive and Docs policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + + logger.info( + f"Drive and Docs policies fetched - " + f"External sharing mode: {self.policies.external_sharing_mode}, " + f"Shared drive creation: {self.policies.allow_shared_drive_creation}, " + f"Drive for desktop: {self.policies.allow_drive_for_desktop}" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching Drive and Docs policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class DrivePolicies(BaseModel): + """Model for domain-level Drive and Docs policy settings.""" + + # drive_and_docs.external_sharing + external_sharing_mode: Optional[str] = None + warn_for_external_sharing: Optional[bool] = None + warn_for_sharing_outside_allowlisted_domains: Optional[bool] = None + allow_publishing_files: Optional[bool] = None + access_checker_suggestions: Optional[str] = None + allowed_parties_for_distributing_content: Optional[str] = None + + # drive_and_docs.shared_drive_creation + allow_shared_drive_creation: Optional[bool] = None + allow_managers_to_override_settings: Optional[bool] = None + allow_non_member_access: Optional[bool] = None + allowed_parties_for_download_print_copy: Optional[str] = None + + # drive_and_docs.drive_for_desktop + allow_drive_for_desktop: Optional[bool] = None diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.metadata.json new file mode 100644 index 0000000000..c27742b7ed --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_shared_drive_creation_allowed", + "CheckTitle": "Users are allowed to create new shared drives", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default **allows users to create new shared drives**. Shared drives are owned by the organization (not the individual user), so content stored in them survives the deletion of the original creator's account, supporting data continuity and reducing the risk of accidental data loss.", + "Risk": "When users cannot create shared drives, they store collaborative content in their personal **My Drive** instead. When that user account is deleted, the data is also deleted, leading to **unintentional data loss** of organizationally significant information. Allowing shared drive creation makes data survivable across account lifecycle events.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/users/answer/7212025", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Shared drive creation**\n4. **Uncheck** *Prevent users in from creating new shared drives*\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Allow users to create new shared drives. This protects the organization from data loss when user accounts are deleted by ensuring collaborative content lives in organization-owned shared drives instead of personal My Drive folders.", + "Url": "https://hub.prowler.com/check/drive_shared_drive_creation_allowed" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [ + "drive_shared_drive_managers_cannot_override", + "drive_shared_drive_members_only_access", + "drive_shared_drive_disable_download_print_copy" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..e381ad8f26 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_shared_drive_creation_allowed(Check): + """Check that users are allowed to create new shared drives + + This check verifies that the domain-level Drive and Docs policy permits + users to create new shared drives. Allowing shared drive creation helps + prevent data loss when individual user accounts are deleted, since + content lives in shared drives owned by the organization rather than + in personal My Drive folders. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + allow_creation = drive_client.policies.allow_shared_drive_creation + + if allow_creation is True: + report.status = "PASS" + report.status_extended = ( + f"Users in domain {drive_client.provider.identity.domain} " + f"are allowed to create new shared drives." + ) + elif allow_creation is None: + report.status = "PASS" + report.status_extended = ( + f"Shared drive creation uses Google's secure default " + f"configuration (allowed) " + f"in domain {drive_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Users in domain {drive_client.provider.identity.domain} " + f"are prevented from creating new shared drives. " + f"Users should be allowed to create new shared drives to avoid " + f"data loss when accounts are deleted." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.metadata.json new file mode 100644 index 0000000000..b2a7738925 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_shared_drive_disable_download_print_copy", + "CheckTitle": "Viewers and commenters cannot download, print, or copy shared drive files", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default prevents viewers and commenters of files stored in shared drives from **downloading, printing, or copying** the file contents. They can only read and comment on the existing content, preventing bulk extraction of sensitive material from shared drives.", + "Risk": "When viewers and commenters can download, print, or copy shared drive files, they can **bulk-extract sensitive content** — including intellectual property, personally identifiable information, and confidential business documents — using nothing more than read access. This is one of the most direct paths to data exfiltration through Drive.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Shared drive creation**\n4. **Uncheck** *Allow viewers and commenters to download, print, and copy files*\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict download, print, and copy actions in shared drives to editors or managers only. This prevents bulk data exfiltration by users who only need read or comment access to the underlying content.", + "Url": "https://hub.prowler.com/check/drive_shared_drive_disable_download_print_copy" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [ + "drive_shared_drive_creation_allowed", + "drive_shared_drive_managers_cannot_override", + "drive_shared_drive_members_only_access" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..85972f3674 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_shared_drive_disable_download_print_copy(Check): + """Check that download/print/copy is disabled for viewers and commenters + + This check verifies that the domain-level Drive and Docs policy prevents + viewers and commenters of shared drive files from downloading, printing, + or copying their contents — limiting them to read and comment actions + only and reducing the risk of bulk data exfiltration. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + allowed = drive_client.policies.allowed_parties_for_download_print_copy + + if allowed in ("EDITORS_ONLY", "MANAGERS_ONLY"): + report.status = "PASS" + report.status_extended = ( + f"Download, print, and copy in shared drives in domain " + f"{drive_client.provider.identity.domain} is restricted to " + f"{allowed}." + ) + elif allowed is None: + report.status = "PASS" + report.status_extended = ( + f"Download, print, and copy restrictions for shared drives use " + f"Google's secure default configuration (disabled for viewers " + f"and commenters) " + f"in domain {drive_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Download, print, and copy in shared drives in domain " + f"{drive_client.provider.identity.domain} is set to {allowed}. " + f"These actions should be restricted to editors or managers only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.metadata.json new file mode 100644 index 0000000000..62e46c2ae2 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_shared_drive_managers_cannot_override", + "CheckTitle": "Shared drive managers cannot override shared drive settings", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default prevents members with **manager access** to a shared drive from overriding the shared drive settings established by administrators. This ensures that security controls — such as external access, member-only access, and download restrictions — cannot be relaxed at the individual shared drive level.", + "Risk": "If shared drive managers can override organizational defaults, **unauthorized data exposure** can occur when a manager intentionally or accidentally weakens a shared drive's security posture (for example, allowing external members or enabling download for viewers).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Shared drive creation**\n4. **Uncheck** *Allow members with manager access to override the settings below*\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Prevent shared drive managers from overriding organizationally established shared drive settings. This ensures that security controls remain consistent across all shared drives and cannot be relaxed by non-administrators.", + "Url": "https://hub.prowler.com/check/drive_shared_drive_managers_cannot_override" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [ + "drive_shared_drive_creation_allowed", + "drive_shared_drive_members_only_access", + "drive_shared_drive_disable_download_print_copy" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..b6f6d1b887 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_shared_drive_managers_cannot_override(Check): + """Check that shared drive managers cannot override shared drive settings + + This check verifies that the domain-level Drive and Docs policy prevents + members with manager access from overriding the shared drive settings + configured by administrators, ensuring that security controls cannot be + relaxed at the shared drive level. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + allow_override = drive_client.policies.allow_managers_to_override_settings + + if allow_override is False: + report.status = "PASS" + report.status_extended = ( + f"Shared drive managers in domain " + f"{drive_client.provider.identity.domain} cannot override " + f"shared drive settings." + ) + else: + report.status = "FAIL" + if allow_override is None: + report.status_extended = ( + f"Manager override of shared drive settings is not " + f"explicitly configured in domain " + f"{drive_client.provider.identity.domain}. " + f"Managers should not be allowed to override shared drive settings." + ) + else: + report.status_extended = ( + f"Shared drive managers in domain " + f"{drive_client.provider.identity.domain} are allowed to " + f"override shared drive settings. " + f"Managers should not be allowed to override shared drive settings." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.metadata.json new file mode 100644 index 0000000000..d9495ea249 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_shared_drive_members_only_access", + "CheckTitle": "Shared drive file access is restricted to members only", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default restricts shared drive file access to **explicit members** only. Non-members cannot be added to individual files inside the drive, preserving the shared drive's membership boundary as the authoritative access control surface.", + "Risk": "If non-members can be added to files inside a shared drive, the **drive's membership becomes meaningless** as a security control. Sensitive content scoped to a specific team can be silently extended to users who were never granted access to the drive itself, leading to unintended information disclosure.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Shared drive creation**\n4. **Uncheck** *Allow people who aren't shared drive members to be added to files*\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict shared drive file access to explicit shared drive members. This preserves the drive membership as the authoritative access boundary and prevents silent expansion of access to non-members.", + "Url": "https://hub.prowler.com/check/drive_shared_drive_members_only_access" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [ + "drive_shared_drive_creation_allowed", + "drive_shared_drive_managers_cannot_override", + "drive_shared_drive_disable_download_print_copy" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..34cde3f6c6 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_shared_drive_members_only_access(Check): + """Check that shared drive file access is restricted to members only + + This check verifies that the domain-level Drive and Docs policy prevents + people who are not shared drive members from being added to files within + a shared drive, restricting file access to that drive's explicit + membership. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + allow_non_member = drive_client.policies.allow_non_member_access + + if allow_non_member is False: + report.status = "PASS" + report.status_extended = ( + f"Shared drive file access in domain " + f"{drive_client.provider.identity.domain} is restricted to " + f"shared drive members only." + ) + else: + report.status = "FAIL" + if allow_non_member is None: + report.status_extended = ( + f"Shared drive non-member access is not explicitly " + f"configured in domain {drive_client.provider.identity.domain}. " + f"Shared drive file access should be restricted to members only." + ) + else: + report.status_extended = ( + f"Shared drive file access in domain " + f"{drive_client.provider.identity.domain} allows non-members " + f"to be added to files. " + f"Shared drive file access should be restricted to members only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.metadata.json new file mode 100644 index 0000000000..ddba469b0b --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_sharing_allowlisted_domains", + "CheckTitle": "Document sharing is restricted to allowlisted domains", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide default restricts external sharing of Drive and Docs files to **a curated list of allowlisted domains**, rather than allowing sharing with arbitrary external recipients. This converts external sharing from an open default into a controlled allow-list that aligns with documented business relationships.", + "Risk": "When external sharing is unrestricted, users can share organizational content with **any external Google account**, including untrusted or unknown parties. Restricting sharing to allowlisted domains drastically reduces the surface area for accidental and malicious data exfiltration through Drive.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Sharing options**\n4. Under **Sharing outside of **, select **ALLOWLISTED DOMAINS - Files owned by users in can be shared with Google Accounts in compatible allowlisted domains**\n5. Configure the allowlisted domains list as appropriate for your organization\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict Drive and Docs external sharing to allowlisted domains. This converts external sharing from an open default into a controlled allow-list aligned with documented business relationships and reduces the risk of accidental or malicious data exposure.", + "Url": "https://hub.prowler.com/check/drive_sharing_allowlisted_domains" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "drive_external_sharing_warn_users", + "drive_warn_sharing_with_allowlisted_domains", + "drive_publishing_files_disabled" + ], + "Notes": "" +} 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 new file mode 100644 index 0000000000..766afecda5 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py @@ -0,0 +1,53 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_sharing_allowlisted_domains(Check): + """Check that document sharing is restricted to allowlisted domains + + This check verifies that the domain-level Drive and Docs policy restricts + external sharing to a list of explicitly allowlisted domains, blocking + sharing with arbitrary external recipients. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + mode = drive_client.policies.external_sharing_mode + + if mode == "ALLOWLISTED_DOMAINS": + report.status = "PASS" + report.status_extended = ( + f"Drive and Docs external sharing in domain " + f"{drive_client.provider.identity.domain} is restricted to " + f"allowlisted domains." + ) + else: + report.status = "FAIL" + if mode is None: + report.status_extended = ( + f"Drive and Docs external sharing mode is not explicitly " + f"configured in domain {drive_client.provider.identity.domain}. " + f"Sharing should be restricted to allowlisted domains." + ) + else: + report.status_extended = ( + f"Drive and Docs external sharing in domain " + f"{drive_client.provider.identity.domain} is set to {mode}. " + f"Sharing should be restricted to allowlisted domains." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/__init__.py b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.metadata.json new file mode 100644 index 0000000000..c788df1a27 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "drive_warn_sharing_with_allowlisted_domains", + "CheckTitle": "Users are warned when sharing files with allowlisted domains", + "CheckType": [], + "ServiceName": "drive", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "At the domain level, even when external sharing is restricted to allowlisted domains, Google Drive **warns users** before they share a file with a user in an allowlisted domain. This second-step prompt helps users recognize when they are crossing the organizational boundary, even within permitted destinations.", + "Risk": "Allowlisted domains are still external. Users may not realize that even an allowlisted recipient is outside the organization, leading to **unintentional disclosure of sensitive content** to legitimate but external collaborators. A warning prompt at share time mitigates that without preventing the sharing itself.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization", + "https://docs.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** > **Drive and Docs**\n3. Click **Sharing settings** > **Sharing options**\n4. Under **Sharing outside of **, ensure **ALLOWLISTED DOMAINS** is selected\n5. Check **Warn when files owned by users or shared drives in are shared with users in allowlisted domains**\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable warnings for sharing with allowlisted domains so users are reminded that they are sharing externally, even when the destination is permitted. This preserves the convenience of allowlisted sharing while reducing accidental disclosure.", + "Url": "https://hub.prowler.com/check/drive_warn_sharing_with_allowlisted_domains" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "drive_external_sharing_warn_users", + "drive_sharing_allowlisted_domains" + ], + "Notes": "This check is meaningful only when external sharing is restricted to allowlisted domains. See the related check drive_sharing_allowlisted_domains." +} 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 new file mode 100644 index 0000000000..23fd47a229 --- /dev/null +++ b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.drive.drive_client import drive_client + + +class drive_warn_sharing_with_allowlisted_domains(Check): + """Check that users are warned when sharing with allowlisted domains + + This check verifies that the domain-level Drive and Docs policy warns + users when they share files with users in allowlisted domains, providing + an opportunity to reconsider before sharing externally even within + permitted domains. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if drive_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, + ) + + warn_enabled = ( + drive_client.policies.warn_for_sharing_outside_allowlisted_domains + ) + + if warn_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"Users are warned when sharing files with allowlisted " + f"domains in domain {drive_client.provider.identity.domain}." + ) + elif warn_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"Warning when sharing with allowlisted domains uses Google's " + f"secure default configuration (enabled) " + f"in domain {drive_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Warning when sharing with allowlisted domains is disabled " + f"in domain {drive_client.provider.identity.domain}. " + f"Users should be warned when sharing files with users in allowlisted domains." + ) + + findings.append(report) + + return findings 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_anomalous_attachment_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.metadata.json new file mode 100644 index 0000000000..2295591dba --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_anomalous_attachment_protection_enabled", + "CheckTitle": "Protection against anomalous attachment types in emails is enabled", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails contain anomalous attachment types. Unusual file types that are uncommon for the sender or organization may indicate an attempt to deliver malware through less-scrutinized formats.", + "Risk": "Without protection against anomalous attachment types, users may receive **emails with unusual file formats** that are designed to bypass standard security filters. Attackers may use **uncommon file extensions or MIME types** to deliver malware that evades signature-based detection.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments", + "https://docs.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** > **Attachments**\n4. Check **Protect against anomalous attachment types in emails**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection against anomalous attachment types in emails and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_anomalous_attachment_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_encrypted_attachment_protection_enabled", + "gmail_script_attachment_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.py new file mode 100644 index 0000000000..b8895d4e21 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.py @@ -0,0 +1,74 @@ +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_anomalous_attachment_protection_enabled(Check): + """Check that protection against anomalous attachment types in emails is enabled. + + This check verifies that Gmail is configured to take action on + emails containing unusual attachment types, helping prevent + malware delivery via uncommon file formats. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.enable_anomalous_attachment_protection + consequence = ( + gmail_client.policies.anomalous_attachment_protection_consequence + ) + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection against anomalous attachment types in emails " + f"is disabled in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif enabled is None: + report.status = "FAIL" + report.status_extended = ( + f"Protection against anomalous attachment types in emails " + f"is not configured and uses Google's insecure default " + f"(disabled) in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection against anomalous attachment types in emails " + f"is set to take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + report.status_extended = ( + f"Protection against anomalous attachment types in emails " + f"is enabled in domain " + f"{gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Protection against anomalous attachment types in emails " + f"is enabled with consequence '{consequence}' " + f"in domain {gmail_client.provider.identity.domain}." + ) + + findings.append(report) + + return findings 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..14484c251c --- /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://knowledge.workspace.google.com/admin/gmail/let-users-automatically-forward-their-own-gmail-emails", + "https://docs.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..65cd6f1fcb --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py @@ -0,0 +1,52 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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..530d7af908 --- /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://knowledge.workspace.google.com/admin/gmail/advanced/set-up-comprehensive-mail-storage", + "https://docs.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..704e5a391a --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py @@ -0,0 +1,52 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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_domain_spoofing_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json new file mode 100644 index 0000000000..7e6be13d58 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_domain_spoofing_protection_enabled", + "CheckTitle": "Protection against domain spoofing based on similar domain names is enabled", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails appear to come from domain names that look similar to the organization's domain. Lookalike domains are a common phishing technique used to trick users into trusting malicious messages.", + "Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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** > **Spoofing and authentication**\n4. Check **Protect against domain spoofing based on similar domain names**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection against domain spoofing based on similar domain names and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_domain_spoofing_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_employee_name_spoofing_protection_enabled", + "gmail_inbound_domain_spoofing_protection_enabled", + "gmail_unauthenticated_email_protection_enabled", + "gmail_groups_spoofing_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py new file mode 100644 index 0000000000..d3a6bc94c1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py @@ -0,0 +1,65 @@ +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_domain_spoofing_protection_enabled(Check): + """Check that protection against domain spoofing based on similar domain names is enabled. + + This check verifies that Gmail is configured to take action on + emails that appear to come from similar-looking domain names, + helping prevent phishing via domain impersonation. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.detect_domain_name_spoofing + consequence = gmail_client.policies.domain_spoofing_consequence + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection against domain spoofing based on similar " + f"domain names is disabled in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection against domain spoofing based on similar " + f"domain names is set to take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + report.status_extended = ( + f"Protection against domain spoofing based on similar " + f"domain names uses Google's secure default configuration " + f"(enabled) in domain " + f"{gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Protection against domain spoofing based on similar " + f"domain names is enabled with consequence " + f"'{consequence}' in domain " + f"{gmail_client.provider.identity.domain}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json new file mode 100644 index 0000000000..d6d107f360 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_employee_name_spoofing_protection_enabled", + "CheckTitle": "Protection against spoofing of employee names is enabled", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when the sender's display name matches an employee's name but the email comes from an external address. This is a common social engineering technique where attackers impersonate colleagues or executives.", + "Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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** > **Spoofing and authentication**\n4. Check **Protect against spoofing of employee names**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection against spoofing of employee names and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_employee_name_spoofing_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_domain_spoofing_protection_enabled", + "gmail_inbound_domain_spoofing_protection_enabled", + "gmail_unauthenticated_email_protection_enabled", + "gmail_groups_spoofing_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py new file mode 100644 index 0000000000..ea6283c940 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py @@ -0,0 +1,63 @@ +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_employee_name_spoofing_protection_enabled(Check): + """Check that protection against spoofing of employee names is enabled. + + This check verifies that Gmail is configured to take action on + emails where the sender name matches an employee name but comes + from an external address, helping prevent social engineering attacks. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.detect_employee_name_spoofing + consequence = gmail_client.policies.employee_name_spoofing_consequence + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection against spoofing of employee names is " + f"disabled in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection against spoofing of employee names is set " + f"to take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + report.status_extended = ( + f"Protection against spoofing of employee names uses " + f"Google's secure default configuration (enabled) " + f"in domain {gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Protection against spoofing of employee names is " + f"enabled with consequence '{consequence}' in domain " + f"{gmail_client.provider.identity.domain}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.metadata.json new file mode 100644 index 0000000000..3367e11777 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_encrypted_attachment_protection_enabled", + "CheckTitle": "Protection against encrypted attachments from untrusted senders is enabled", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when an encrypted attachment is received from an untrusted sender. Encrypted attachments cannot be scanned for malware by security filters, making them a common vector for delivering malicious payloads.", + "Risk": "Without protection against encrypted attachments from untrusted senders, users may receive **password-protected archives containing malware** that bypass standard content scanning. Attackers commonly use encrypted attachments to evade detection and deliver **ransomware, trojans, or other malicious payloads**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments", + "https://docs.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** > **Attachments**\n4. Check **Protect against encrypted attachments from untrusted senders**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection against encrypted attachments from untrusted senders and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_encrypted_attachment_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_script_attachment_protection_enabled", + "gmail_anomalous_attachment_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.py new file mode 100644 index 0000000000..30a58cb4b4 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.py @@ -0,0 +1,66 @@ +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_encrypted_attachment_protection_enabled(Check): + """Check that protection against encrypted attachments from untrusted senders is enabled. + + This check verifies that Gmail is configured to take action on + encrypted attachments from untrusted senders, helping prevent + malware delivery via password-protected archives. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.enable_encrypted_attachment_protection + consequence = ( + gmail_client.policies.encrypted_attachment_protection_consequence + ) + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection against encrypted attachments from untrusted " + f"senders is disabled in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection against encrypted attachments from untrusted " + f"senders is set to take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + report.status_extended = ( + f"Protection against encrypted attachments from untrusted " + f"senders uses Google's secure default configuration " + f"(enabled) in domain " + f"{gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Protection against encrypted attachments from untrusted " + f"senders is enabled with consequence '{consequence}' " + f"in domain {gmail_client.provider.identity.domain}." + ) + + 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..0f14ecd73b --- /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://knowledge.workspace.google.com/admin/security/help-prevent-phishing-with-pre-delivery-message-scanning", + "https://docs.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..33e5a58f9b --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py @@ -0,0 +1,54 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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..4df5f8bd84 --- /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://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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..c71de1459a --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py @@ -0,0 +1,52 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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_groups_spoofing_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json new file mode 100644 index 0000000000..c5f5ee61a9 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_groups_spoofing_protection_enabled", + "CheckTitle": "Groups are protected from inbound emails spoofing your domain", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when groups receive inbound emails that spoof the organization's domain. Google Groups are a high-value target because a single spoofed message can reach many recipients at once.", + "Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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** > **Spoofing and authentication**\n4. Check **Protect your Groups from inbound emails spoofing your domain**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection of groups from inbound emails spoofing your domain and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_groups_spoofing_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_domain_spoofing_protection_enabled", + "gmail_employee_name_spoofing_protection_enabled", + "gmail_inbound_domain_spoofing_protection_enabled", + "gmail_unauthenticated_email_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py new file mode 100644 index 0000000000..fd4238239f --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py @@ -0,0 +1,84 @@ +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_groups_spoofing_protection_enabled(Check): + """Check that groups are protected from inbound emails spoofing your domain. + + This check verifies that Gmail is configured to take action on + inbound emails to groups that spoof the organization's domain, + helping prevent impersonation attacks targeting group mailboxes. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.detect_groups_spoofing + consequence = gmail_client.policies.groups_spoofing_consequence + visibility_type = gmail_client.policies.groups_spoofing_visibility_type + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection of groups from inbound emails spoofing your " + f"domain is disabled in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif enabled is None: + report.status = "FAIL" + report.status_extended = ( + f"Protection of groups from inbound emails spoofing your " + f"domain is not configured and uses Google's insecure " + f"default (disabled) in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection of groups from inbound emails spoofing your " + f"domain is set to take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + scope = ( + "private groups only" + if visibility_type == "PRIVATE_GROUPS_ONLY" + else "all groups" + ) + report.status_extended = ( + f"Protection of groups from inbound emails spoofing your " + f"domain is enabled for {scope} in domain " + f"{gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + scope = ( + "private groups only" + if visibility_type == "PRIVATE_GROUPS_ONLY" + else "all groups" + ) + report.status_extended = ( + f"Protection of groups from inbound emails spoofing your " + f"domain is enabled for {scope} with consequence " + f"'{consequence}' in domain " + f"{gmail_client.provider.identity.domain}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json new file mode 100644 index 0000000000..a049a7ede5 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_inbound_domain_spoofing_protection_enabled", + "CheckTitle": "Protection against inbound emails spoofing your domain is enabled", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when inbound emails spoof the organization's own domain. This protects against attackers sending emails that appear to originate from within the organization but are actually external.", + "Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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** > **Spoofing and authentication**\n4. Check **Protect against inbound emails spoofing your domain**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection against inbound emails spoofing your domain and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_inbound_domain_spoofing_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_domain_spoofing_protection_enabled", + "gmail_employee_name_spoofing_protection_enabled", + "gmail_unauthenticated_email_protection_enabled", + "gmail_groups_spoofing_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py new file mode 100644 index 0000000000..b9a22cadc6 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py @@ -0,0 +1,63 @@ +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_inbound_domain_spoofing_protection_enabled(Check): + """Check that protection against inbound emails spoofing your domain is enabled. + + This check verifies that Gmail is configured to take action on + inbound emails that spoof the organization's own domain, helping + prevent impersonation of internal senders. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.detect_inbound_domain_spoofing + consequence = gmail_client.policies.inbound_domain_spoofing_consequence + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection against inbound emails spoofing your domain " + f"is disabled in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection against inbound emails spoofing your domain " + f"is set to take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + report.status_extended = ( + f"Protection against inbound emails spoofing your domain " + f"uses Google's secure default configuration (enabled) " + f"in domain {gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Protection against inbound emails spoofing your domain " + f"is enabled with consequence '{consequence}' " + f"in domain {gmail_client.provider.identity.domain}." + ) + + 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..a4f9fd0460 --- /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://knowledge.workspace.google.com/admin/gmail/let-users-delegate-access-to-a-gmail-account", + "https://docs.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..5e32e58b99 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.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_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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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..95cd7567c3 --- /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://knowledge.workspace.google.com/admin/gmail/advanced/allow-per-user-outbound-gateways", + "https://docs.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..3f384de1d0 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py @@ -0,0 +1,52 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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..48f077d8ea --- /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://knowledge.workspace.google.com/admin/sync/turn-pop-and-imap-on-or-off-for-users", + "https://docs.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..18b5034e31 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py @@ -0,0 +1,70 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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_script_attachment_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.metadata.json new file mode 100644 index 0000000000..cae7d474ac --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_script_attachment_protection_enabled", + "CheckTitle": "Protection against attachments with scripts from untrusted senders is enabled", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when an attachment containing scripts is received from an untrusted sender. Script-bearing attachments (e.g., .js, .vbs, .ps1) are a common malware delivery mechanism.", + "Risk": "Without protection against script-bearing attachments from untrusted senders, users may receive **files containing malicious scripts** that can execute harmful code when opened. Attackers commonly use script attachments to deliver **malware, backdoors, or credential stealers**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments", + "https://docs.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** > **Attachments**\n4. Check **Protect against attachments with scripts from untrusted senders**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection against attachments with scripts from untrusted senders and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_script_attachment_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_encrypted_attachment_protection_enabled", + "gmail_anomalous_attachment_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.py new file mode 100644 index 0000000000..1dd2ed59d1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.py @@ -0,0 +1,65 @@ +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_script_attachment_protection_enabled(Check): + """Check that protection against attachments with scripts from untrusted senders is enabled. + + This check verifies that Gmail is configured to take action on + attachments containing scripts from untrusted senders, helping + prevent malware delivery via script-bearing files. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.enable_script_attachment_protection + consequence = gmail_client.policies.script_attachment_protection_consequence + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection against attachments with scripts from " + f"untrusted senders is disabled in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection against attachments with scripts from " + f"untrusted senders is set to take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + report.status_extended = ( + f"Protection against attachments with scripts from " + f"untrusted senders uses Google's secure default " + f"configuration (enabled) in domain " + f"{gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Protection against attachments with scripts from " + f"untrusted senders is enabled with consequence " + f"'{consequence}' in domain " + f"{gmail_client.provider.identity.domain}." + ) + + 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..ed9dc71803 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_service.py @@ -0,0 +1,248 @@ +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.enable_encrypted_attachment_protection = ( + value.get("enableEncryptedAttachmentProtection") + ) + self.policies.encrypted_attachment_protection_consequence = value.get( + "encryptedAttachmentProtectionConsequence" + ) + self.policies.enable_script_attachment_protection = ( + value.get("enableAttachmentWithScriptsProtection") + ) + self.policies.script_attachment_protection_consequence = ( + value.get("scriptAttachmentProtectionConsequence") + ) + self.policies.enable_anomalous_attachment_protection = ( + value.get("enableAnomalousAttachmentProtection") + ) + 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.detect_domain_name_spoofing = value.get( + "detectDomainNameSpoofing" + ) + self.policies.domain_spoofing_consequence = value.get( + "domainSpoofingConsequence" + ) + self.policies.detect_employee_name_spoofing = value.get( + "detectEmployeeNameSpoofing" + ) + self.policies.employee_name_spoofing_consequence = ( + value.get("employeeNameSpoofingConsequence") + ) + self.policies.detect_inbound_domain_spoofing = value.get( + "detectDomainSpoofingFromUnauthenticatedSenders" + ) + self.policies.inbound_domain_spoofing_consequence = ( + value.get("inboundDomainSpoofingConsequence") + ) + self.policies.detect_unauthenticated_emails = value.get( + "detectUnauthenticatedEmails" + ) + self.policies.unauthenticated_email_consequence = value.get( + "unauthenticatedEmailConsequence" + ) + self.policies.detect_groups_spoofing = value.get( + "detectGroupsSpoofing" + ) + self.policies.groups_spoofing_visibility_type = value.get( + "groupsSpoofingVisibilityType" + ) + 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 + enable_encrypted_attachment_protection: Optional[bool] = None + encrypted_attachment_protection_consequence: Optional[str] = None + enable_script_attachment_protection: Optional[bool] = None + script_attachment_protection_consequence: Optional[str] = None + enable_anomalous_attachment_protection: Optional[bool] = 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 + detect_domain_name_spoofing: Optional[bool] = None + domain_spoofing_consequence: Optional[str] = None + detect_employee_name_spoofing: Optional[bool] = None + employee_name_spoofing_consequence: Optional[str] = None + detect_inbound_domain_spoofing: Optional[bool] = None + inbound_domain_spoofing_consequence: Optional[str] = None + detect_unauthenticated_emails: Optional[bool] = None + unauthenticated_email_consequence: Optional[str] = None + detect_groups_spoofing: Optional[bool] = None + groups_spoofing_visibility_type: 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..518b90b962 --- /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://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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..01411dcc04 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py @@ -0,0 +1,52 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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_unauthenticated_email_protection_enabled/__init__.py b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.metadata.json new file mode 100644 index 0000000000..ec730d0a8b --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "googleworkspace", + "CheckID": "gmail_unauthenticated_email_protection_enabled", + "CheckTitle": "Protection against any unauthenticated emails is enabled", + "CheckType": [], + "ServiceName": "gmail", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails are not authenticated via SPF or DKIM. Unauthenticated emails cannot be verified as originating from the claimed sender, making them more likely to be spoofed or forged.", + "Risk": "Without protection against unauthenticated emails, users may receive **spoofed or forged messages** that fail SPF and DKIM checks but are still delivered normally. This enables **phishing**, **spam**, and **impersonation attacks** that exploit the lack of sender verification.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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** > **Spoofing and authentication**\n4. Check **Protect against any unauthenticated emails**\n5. Select the desired action (e.g., Move email to spam)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable protection against any unauthenticated emails and configure an appropriate action such as moving to spam or quarantining.", + "Url": "https://hub.prowler.com/check/gmail_unauthenticated_email_protection_enabled" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [ + "gmail_domain_spoofing_protection_enabled", + "gmail_employee_name_spoofing_protection_enabled", + "gmail_inbound_domain_spoofing_protection_enabled", + "gmail_groups_spoofing_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.py new file mode 100644 index 0000000000..44ff6fa24d --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.py @@ -0,0 +1,70 @@ +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_unauthenticated_email_protection_enabled(Check): + """Check that protection against any unauthenticated emails is enabled. + + This check verifies that Gmail is configured to take action on + emails that are not authenticated via SPF or DKIM, helping prevent + delivery of spoofed or forged messages. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if gmail_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + enabled = gmail_client.policies.detect_unauthenticated_emails + consequence = gmail_client.policies.unauthenticated_email_consequence + + if enabled is False: + report.status = "FAIL" + report.status_extended = ( + f"Protection against unauthenticated emails is disabled " + f"in domain {gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif enabled is None: + report.status = "FAIL" + report.status_extended = ( + f"Protection against unauthenticated emails is not " + f"configured and uses Google's insecure default " + f"(disabled) in domain " + f"{gmail_client.provider.identity.domain}. " + f"Enable the protection and configure a protective action." + ) + elif consequence == "NO_ACTION": + report.status = "FAIL" + report.status_extended = ( + f"Protection against unauthenticated emails is set to " + f"take no action in domain " + f"{gmail_client.provider.identity.domain}. " + f"A protective action should be configured." + ) + elif consequence is None: + report.status = "PASS" + report.status_extended = ( + f"Protection against unauthenticated emails is enabled " + f"in domain {gmail_client.provider.identity.domain}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Protection against unauthenticated emails is enabled " + f"with consequence '{consequence}' in domain " + f"{gmail_client.provider.identity.domain}." + ) + + 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..78678ff9f5 --- /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://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection", + "https://docs.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..a62480de3b --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py @@ -0,0 +1,56 @@ +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.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, + ) + + 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 = "FAIL" + report.status_extended = ( + f"Warning prompts for clicks on untrusted domain links " + f"are not configured and use Google's insecure default " + f"(disabled) in domain " + f"{gmail_client.provider.identity.domain}. " + f"Untrusted link warnings should be enabled to protect users." + ) + 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/googleworkspace/services/groups/__init__.py b/prowler/providers/googleworkspace/services/groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_client.py b/prowler/providers/googleworkspace/services/groups/groups_client.py new file mode 100644 index 0000000000..0f4881b52c --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, +) + +groups_client = Groups(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/__init__.py b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json new file mode 100644 index 0000000000..6a5bb80c3e --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "groups_creation_restricted", + "CheckTitle": "Group creation is restricted to admins with no external members or incoming email", + "CheckType": [], + "ServiceName": "groups", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Groups for Business **creation settings** control who can create groups and whether group owners can add external members or allow incoming email from outside the organization. Restricting creation to admins and disabling external member and incoming email options limits the attack surface.", + "Risk": "Allowing any user to create groups with external members or incoming email from outside increases the risk of **unauthorized data sharing**, **spam delivery**, and **shadow IT** groups that bypass organizational controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business", + "https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Creating groups**\n4. Select **Only organization admins can create groups**\n5. Uncheck **Group owners can allow external members**\n6. Uncheck **Group owners can allow incoming email from outside the organization**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict **group creation** to **organization admins** and disable **external member** and **incoming email** options to maintain control over group membership and communication boundaries.", + "Url": "https://hub.prowler.com/check/groups_creation_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "groups_external_access_restricted", + "groups_view_conversations_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py new file mode 100644 index 0000000000..ad9ce534ed --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py @@ -0,0 +1,76 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.groups.groups_client import ( + groups_client, +) + + +class groups_creation_restricted(Check): + """Check that group creation is restricted to admins only with no external members or incoming email. + + This check verifies three sub-settings: + - Only organization admins can create groups (not all users) + - Group owners cannot allow external members + - Group owners cannot allow incoming email from outside the organization + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if groups_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=groups_client.policies, + resource_id="groupsPolicies", + resource_name="Groups Policies", + customer_id=groups_client.provider.identity.customer_id, + ) + + policies = groups_client.policies + domain = groups_client.provider.identity.domain + + access_level = policies.create_groups_access_level + external_members = policies.owners_can_allow_external_members + incoming_mail = policies.owners_can_allow_incoming_mail_from_public + + issues = [] + + # Check creation access level + # Default is USERS_IN_DOMAIN (insecure) — only ADMIN_ONLY is compliant + if access_level is None or access_level != "ADMIN_ONLY": + effective = access_level or "USERS_IN_DOMAIN (default)" + issues.append( + f"group creation is set to {effective} instead of ADMIN_ONLY" + ) + + # Check external members + # Default is false (secure) — only false is compliant + if external_members is True: + issues.append("group owners can allow external members") + + # Check incoming mail from outside + # Default is false (secure) — only true is non-compliant + if incoming_mail is True: + issues.append( + "group owners can allow incoming email from outside the organization" + ) + + if not issues: + report.status = "PASS" + report.status_extended = ( + f"Group creation is properly restricted in domain {domain}: " + f"admin-only creation, no external members, " + f"no incoming email from outside." + ) + else: + report.status = "FAIL" + issues_text = "; ".join(issues) + report.status_extended = ( + f"Group creation is not fully restricted " + f"in domain {domain}: {issues_text}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/__init__.py b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.metadata.json new file mode 100644 index 0000000000..9d32063c90 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "groups_external_access_restricted", + "CheckTitle": "Accessing groups from outside the organization is set to private", + "CheckType": [], + "ServiceName": "groups", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Groups for Business **external access** controls whether people outside the organization can view and search for groups. When set to private, only users within the domain can discover and access groups, while external users can still email a group if the group's own settings allow it.", + "Risk": "Allowing external access to groups exposes **group names, descriptions, and membership** to anyone outside the organization, increasing the risk of **information disclosure** and enabling external parties to identify targets for **social engineering attacks**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business", + "https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Sharing options**\n4. Set **Accessing groups from outside this organization** to **Private**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set **external group access** to **private** to prevent external parties from viewing or searching for organizational groups.", + "Url": "https://hub.prowler.com/check/groups_external_access_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "groups_creation_restricted", + "groups_view_conversations_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.py new file mode 100644 index 0000000000..4ac057b905 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.groups.groups_client import ( + groups_client, +) + + +class groups_external_access_restricted(Check): + """Check that accessing groups from outside the organization is set to private. + + This check verifies that the domain-level Groups for Business policy + restricts external access so that only domain users can view groups, + preventing information exposure to external parties. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if groups_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=groups_client.policies, + resource_id="groupsPolicies", + resource_name="Groups Policies", + customer_id=groups_client.provider.identity.customer_id, + ) + + collaboration = groups_client.policies.collaboration_capability + domain = groups_client.provider.identity.domain + + if collaboration == "DOMAIN_USERS_ONLY": + report.status = "PASS" + report.status_extended = ( + f"Groups external access is set to private (domain users only) " + f"in domain {domain}." + ) + elif collaboration is None: + report.status = "PASS" + report.status_extended = ( + f"Groups external access uses Google's secure default " + f"configuration (private) in domain {domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Groups external access is set to {collaboration} " + f"in domain {domain}. " + f"External access should be set to private (domain users only)." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/groups/groups_service.py b/prowler/providers/googleworkspace/services/groups/groups_service.py new file mode 100644 index 0000000000..1ebc0508e3 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_service.py @@ -0,0 +1,118 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Groups(GoogleWorkspaceService): + """Google Workspace Groups for Business service for auditing domain-level group policies. + + Uses the Cloud Identity Policy API v1 to read group sharing, creation, + and conversation viewing settings configured in the Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = GroupsPolicies() + self.policies_fetched = False + self._fetch_groups_for_business_policies() + + def _fetch_groups_for_business_policies(self): + """Fetch Groups for Business policies from the Cloud Identity Policy API v1.""" + logger.info("Groups for Business - Fetching policies...") + + try: + service = self._build_service("cloudidentity", "v1") + + if not service: + logger.error("Failed to build Cloud Identity service") + return + + request = service.policies().list( + pageSize=100, + filter='setting.type.matches("groups_for_business.*")', + ) + fetch_succeeded = True + + while request is not None: + try: + response = request.execute() + + for policy in response.get("policies", []): + if not self._is_customer_level_policy(policy): + continue + + setting = policy.get("setting", {}) + setting_type = setting.get("type", "").removeprefix("settings/") + logger.debug(f"Processing setting type: {setting_type}") + + value = setting.get("value", {}) + + if setting_type == "groups_for_business.groups_sharing": + self.policies.collaboration_capability = value.get( + "collaborationCapability" + ) + self.policies.create_groups_access_level = value.get( + "createGroupsAccessLevel" + ) + self.policies.owners_can_allow_external_members = value.get( + "ownersCanAllowExternalMembers" + ) + self.policies.owners_can_allow_incoming_mail_from_public = ( + value.get("ownersCanAllowIncomingMailFromPublic") + ) + self.policies.view_topics_default_access_level = value.get( + "viewTopicsDefaultAccessLevel" + ) + self.policies.owners_can_hide_groups = value.get( + "ownersCanHideGroups" + ) + self.policies.new_groups_are_hidden = value.get( + "newGroupsAreHidden" + ) + logger.debug( + "Groups for Business sharing settings fetched." + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching Groups for Business policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + + logger.info( + f"Groups for Business policies fetched - " + f"Collaboration: {self.policies.collaboration_capability}, " + f"Creation: {self.policies.create_groups_access_level}, " + f"View topics: {self.policies.view_topics_default_access_level}" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching Groups for Business policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class GroupsPolicies(BaseModel): + """Model for domain-level Groups for Business policy settings.""" + + # groups_for_business.groups_sharing + collaboration_capability: Optional[str] = None + create_groups_access_level: Optional[str] = None + owners_can_allow_external_members: Optional[bool] = None + owners_can_allow_incoming_mail_from_public: Optional[bool] = None + view_topics_default_access_level: Optional[str] = None + owners_can_hide_groups: Optional[bool] = None + new_groups_are_hidden: Optional[bool] = None diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/__init__.py b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json new file mode 100644 index 0000000000..88f0927155 --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "groups_view_conversations_restricted", + "CheckTitle": "Default permission to view conversations is set to all group members", + "CheckType": [], + "ServiceName": "groups", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Google Groups for Business **conversation viewing** controls who can see group conversations by default. Restricting this to group members only ensures that conversations are visible only to participants, not all users in the organization or external parties.", + "Risk": "Allowing all organization users or anyone to view group conversations can lead to **information disclosure** of sensitive discussions, internal decisions, and confidential data shared within groups.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business", + "https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Groups for Business**\n3. Click **Sharing options**\n4. Set **Default for permission to view conversations** to **All group members**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default **permission to view conversations** to **all group members** to ensure group discussions are only visible to their participants.", + "Url": "https://hub.prowler.com/check/groups_view_conversations_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "groups_external_access_restricted", + "groups_creation_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py new file mode 100644 index 0000000000..66fb15a43b --- /dev/null +++ b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.groups.groups_client import ( + groups_client, +) + + +class groups_view_conversations_restricted(Check): + """Check that the default permission to view conversations is set to All Group Members. + + This check verifies that the domain-level Groups for Business policy + restricts conversation viewing to group members only, preventing + broader access by all organization users or anyone. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if groups_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=groups_client.policies, + resource_id="groupsPolicies", + resource_name="Groups Policies", + customer_id=groups_client.provider.identity.customer_id, + ) + + view_access = groups_client.policies.view_topics_default_access_level + domain = groups_client.provider.identity.domain + + if view_access == "GROUP_MEMBERS": + report.status = "PASS" + report.status_extended = ( + f"Default permission to view conversations is set to " + f"all group members in domain {domain}." + ) + elif view_access is None: + report.status = "FAIL" + report.status_extended = ( + f"Default permission to view conversations uses Google's default " + f"configuration (all organization users) in domain {domain}. " + f"It should be restricted to all group members only." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Default permission to view conversations is set to " + f"{view_access} in domain {domain}. " + f"It should be restricted to all group members only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/marketplace/__init__.py b/prowler/providers/googleworkspace/services/marketplace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/__init__.py b/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted.metadata.json b/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted.metadata.json new file mode 100644 index 0000000000..78fe1850ad --- /dev/null +++ b/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "marketplace_apps_access_restricted", + "CheckTitle": "Users access to Google Workspace Marketplace apps is restricted", + "CheckType": [], + "ServiceName": "marketplace", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide Google Workspace Marketplace configuration **restricts which apps users can install**. Only admin-approved apps from the Marketplace allowlist are permitted, preventing users from installing unvetted third-party applications.", + "Risk": "Allowing unrestricted Marketplace app installation exposes the organization to **unvetted third-party applications** that may request broad OAuth scopes, potentially gaining access to **sensitive organizational data** including emails, documents, and calendar events without proper security review.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization", + "https://docs.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 Marketplace apps**\n3. Click **Settings**\n4. Under **Manage Google Workspace Marketplace allowlist access**, select **Allow users to install and run only selected apps from the Marketplace**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict **Marketplace app installation** to only **admin-approved apps** to prevent users from installing unvetted third-party applications that could access sensitive organizational data.", + "Url": "https://hub.prowler.com/check/marketplace_apps_access_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted.py b/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted.py new file mode 100644 index 0000000000..cee1adaa71 --- /dev/null +++ b/prowler/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.marketplace.marketplace_client import ( + marketplace_client, +) + + +class marketplace_apps_access_restricted(Check): + """Check that Google Workspace Marketplace app installation is restricted. + + This check verifies that the domain-level Marketplace policy restricts + which apps users can install, preventing unvetted third-party applications + from accessing organizational data. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if marketplace_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=marketplace_client.policies, + resource_id="marketplacePolicies", + resource_name="Marketplace Policies", + customer_id=marketplace_client.provider.identity.customer_id, + ) + + access_level = marketplace_client.policies.access_level + + if access_level == "ALLOW_LISTED_APPS": + report.status = "PASS" + report.status_extended = ( + f"Marketplace app installation is restricted to admin-approved apps " + f"in domain {marketplace_client.provider.identity.domain}." + ) + elif access_level == "ALLOW_NONE": + report.status = "PASS" + report.status_extended = ( + f"Marketplace app installation is fully blocked " + f"in domain {marketplace_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if access_level is None: + report.status_extended = ( + f"Marketplace app access is not explicitly configured " + f"in domain {marketplace_client.provider.identity.domain}. " + f"The default allows all apps. " + f"App installation should be restricted to approved apps only." + ) + else: + report.status_extended = ( + f"Marketplace allows users to install any app " + f"in domain {marketplace_client.provider.identity.domain}. " + f"App installation should be restricted to approved apps only." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/marketplace/marketplace_client.py b/prowler/providers/googleworkspace/services/marketplace/marketplace_client.py new file mode 100644 index 0000000000..ccc6cdf208 --- /dev/null +++ b/prowler/providers/googleworkspace/services/marketplace/marketplace_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, +) + +marketplace_client = Marketplace(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/marketplace/marketplace_service.py b/prowler/providers/googleworkspace/services/marketplace/marketplace_service.py new file mode 100644 index 0000000000..4c117a4468 --- /dev/null +++ b/prowler/providers/googleworkspace/services/marketplace/marketplace_service.py @@ -0,0 +1,89 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Marketplace(GoogleWorkspaceService): + """Google Workspace Marketplace service for auditing domain-level Marketplace policies. + + Uses the Cloud Identity Policy API v1 to read the Marketplace app access + settings configured in the Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = MarketplacePolicies() + self.policies_fetched = False + self._fetch_marketplace_policies() + + def _fetch_marketplace_policies(self): + """Fetch Marketplace policies from the Cloud Identity Policy API v1.""" + logger.info("Marketplace - Fetching marketplace 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("workspace_marketplace.*")', + ) + 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/") + value = setting.get("value", {}) + + if setting_type == "workspace_marketplace.apps_access_options": + self.policies.access_level = value.get("accessLevel") + logger.debug( + "Marketplace access level: " + f"{self.policies.access_level}" + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching Marketplace policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + + logger.info( + f"Marketplace policies fetched - " + f"Access level: {self.policies.access_level}" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching Marketplace policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class MarketplacePolicies(BaseModel): + """Model for domain-level Marketplace policy settings.""" + + # workspace_marketplace.apps_access_options + access_level: Optional[str] = None diff --git a/prowler/providers/googleworkspace/services/rules/__init__.py b/prowler/providers/googleworkspace/services/rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json new file mode 100644 index 0000000000..a93b4fb737 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_admin_privilege_granted_alert_configured", + "CheckTitle": "User granted Admin privilege alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **User granted Admin privilege** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when a user is given elevated admin privileges.", + "Risk": "Without this alert enabled, administrators will not be notified when users receive **elevated admin privileges**. Unauthorized privilege escalation could indicate account compromise or insider threats and requires immediate verification.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **User granted Admin privilege** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **User granted Admin privilege** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_admin_privilege_granted_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py new file mode 100644 index 0000000000..55b9a685bf --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "User granted Admin privilege" + + +class rules_admin_privilege_granted_alert_configured(Check): + """Check that the User granted Admin privilege system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_client.py b/prowler/providers/googleworkspace/services/rules/rules_client.py new file mode 100644 index 0000000000..8d1c8821a1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, +) + +rules_client = Rules(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json new file mode 100644 index 0000000000..34885cb605 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_gmail_employee_spoofing_alert_configured", + "CheckTitle": "Gmail potential employee spoofing alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Gmail potential employee spoofing** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when incoming messages have a sender name matching the directory but from an external domain.", + "Risk": "Without this alert enabled, administrators will not be notified of potential **employee spoofing via email**. Attackers may impersonate internal employees using external email addresses to conduct phishing attacks against the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Gmail potential employee spoofing** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Gmail potential employee spoofing** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_gmail_employee_spoofing_alert_configured" + } + }, + "Categories": [ + "logging", + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py new file mode 100644 index 0000000000..0993f72d3d --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Gmail potential employee spoofing" + + +class rules_gmail_employee_spoofing_alert_configured(Check): + """Check that the Gmail potential employee spoofing system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json new file mode 100644 index 0000000000..35fa25f248 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_government_backed_attacks_alert_configured", + "CheckTitle": "Government-backed attacks alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Government-backed attacks** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google believes users are being targeted by a government-backed attacker.", + "Risk": "Without this alert enabled, administrators will not be notified of potential **government-backed attacks** targeting their users. These attacks are sophisticated and require immediate response to protect affected accounts and investigate the threat.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Government-backed attacks** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **High**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Government-backed attacks** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_government_backed_attacks_alert_configured" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py new file mode 100644 index 0000000000..b566d99e0c --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Government-backed attacks" + + +class rules_government_backed_attacks_alert_configured(Check): + """Check that the Government-backed attacks system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json new file mode 100644 index 0000000000..870144a873 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_leaked_password_alert_configured", + "CheckTitle": "Leaked password alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Leaked password** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects compromised credentials requiring a password reset.", + "Risk": "Without this alert enabled, administrators will not be notified when Google detects that a user's **credentials have been compromised** in a publicized breach. The user likely reused their password at another site that was breached, and their account requires an immediate password change.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Leaked password** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Leaked password** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_leaked_password_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py new file mode 100644 index 0000000000..797bed4f71 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Leaked password" + + +class rules_leaked_password_alert_configured(Check): + """Check that the Leaked password system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json new file mode 100644 index 0000000000..f1f4fbc622 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_password_changed_alert_configured", + "CheckTitle": "User's password changed alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **User's password changed** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are promptly notified when user passwords are changed.", + "Risk": "Without this alert enabled, administrators will not be notified when user passwords are changed. This could allow **credential compromise and account takeover** to go undetected, giving attackers time to establish persistence.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **User's password changed** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **User's password changed** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_password_changed_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py new file mode 100644 index 0000000000..fb6382caf8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "User's password changed" + + +class rules_password_changed_alert_configured(Check): + """Check that the User's password changed system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_service.py b/prowler/providers/googleworkspace/services/rules/rules_service.py new file mode 100644 index 0000000000..76b0b0df7a --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_service.py @@ -0,0 +1,143 @@ +from typing import Dict, List, Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + +SYSTEM_RULE_DEFAULTS: Dict[str, str] = { + "User's password changed": "INACTIVE", + "Government-backed attacks": "ACTIVE", + "User suspended due to suspicious activity": "ACTIVE", + "User granted Admin privilege": "INACTIVE", + "Suspicious programmatic login": "ACTIVE", + "Suspicious login": "ACTIVE", + "Leaked password": "ACTIVE", + "Gmail potential employee spoofing": "ACTIVE", +} + + +class Rules(GoogleWorkspaceService): + """Google Workspace Rules service for auditing system-defined alert rules. + + Uses the Cloud Identity Policy API v1 to read system-defined alert rule + configurations from the Admin Console "Rules" section. + """ + + def __init__(self, provider): + super().__init__(provider) + self.system_defined_alerts: List[SystemDefinedAlert] = [] + self.policies_fetched = False + self._fetch_system_defined_alerts() + + def _fetch_system_defined_alerts(self): + """Fetch system-defined alert rules from the Cloud Identity Policy API v1.""" + logger.info("Rules - Fetching system-defined alert rules...") + + 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("rule.system_defined_alerts")', + ) + fetch_succeeded = True + found_rules: Dict[str, SystemDefinedAlert] = {} + + 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", {}) + value = setting.get("value", {}) + display_name = value.get("displayName", "") + + if display_name not in SYSTEM_RULE_DEFAULTS: + continue + + alert = self._parse_alert(value) + found_rules[display_name] = alert + logger.debug( + f"System-defined alert rule: {display_name} " + f"state={alert.state} " + f"has_recipients={alert.email_notifications_enabled}" + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching system-defined alert rules", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + for rule_name, default_state in SYSTEM_RULE_DEFAULTS.items(): + if rule_name not in found_rules: + is_active_default = default_state == "ACTIVE" + found_rules[rule_name] = SystemDefinedAlert( + display_name=rule_name, + state=default_state, + email_notifications_enabled=is_active_default, + all_super_admins=is_active_default, + ) + logger.debug( + f"System-defined alert rule (default): {rule_name} " + f"state={default_state}" + ) + + self.system_defined_alerts = list(found_rules.values()) + self.policies_fetched = fetch_succeeded + + logger.info( + f"Rules policies fetched - " + f"{len(self.system_defined_alerts)} system-defined alert rules" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching system-defined alert rules", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + @staticmethod + def _parse_alert(value: dict) -> "SystemDefinedAlert": + """Parse a single system-defined alert rule from the API response.""" + display_name = value.get("displayName", "") + state = value.get("state", "INACTIVE") + + alert_center_action = value.get("action", {}).get("alertCenterAction", {}) + severity = alert_center_action.get("alertCenterConfig", {}).get("severity") + recipients = alert_center_action.get("recipients", []) + + all_super_admins = any(r.get("allSuperAdmins") is True for r in recipients) + + return SystemDefinedAlert( + display_name=display_name, + state=state, + severity=severity, + email_notifications_enabled=len(recipients) > 0, + all_super_admins=all_super_admins, + ) + + +class SystemDefinedAlert(BaseModel): + """Model for a system-defined alert rule.""" + + display_name: str + state: str = "INACTIVE" + severity: Optional[str] = None + email_notifications_enabled: bool = False + all_super_admins: bool = False diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json new file mode 100644 index 0000000000..b79120abde --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_suspicious_activity_suspension_alert_configured", + "CheckTitle": "User suspended due to suspicious activity alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **User suspended due to suspicious activity** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google suspends an account due to a potential compromise.", + "Risk": "Without this alert enabled, administrators will not be promptly notified when Google **suspends a user account** due to detected compromise. The suspended user cannot work, and the underlying security incident requires immediate investigation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **User suspended due to suspicious activity** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **High**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **User suspended due to suspicious activity** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_suspicious_activity_suspension_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py new file mode 100644 index 0000000000..cd243be8e3 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "User suspended due to suspicious activity" + + +class rules_suspicious_activity_suspension_alert_configured(Check): + """Check that the User suspended due to suspicious activity system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json new file mode 100644 index 0000000000..93af99b565 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_suspicious_login_alert_configured", + "CheckTitle": "Suspicious login alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Suspicious login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects a sign-in attempt that does not match a user's normal behavior.", + "Risk": "Without this alert enabled, administrators will not be notified of **suspicious login attempts** such as sign-ins from unusual locations. This could indicate an active attack using previously obtained credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Suspicious login** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Low**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Suspicious login** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_suspicious_login_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py new file mode 100644 index 0000000000..eee7844c43 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Suspicious login" + + +class rules_suspicious_login_alert_configured(Check): + """Check that the Suspicious login system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json new file mode 100644 index 0000000000..202df2adad --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_suspicious_programmatic_login_alert_configured", + "CheckTitle": "Suspicious programmatic login alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Suspicious programmatic login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects suspicious login attempts from applications or programs.", + "Risk": "Without this alert enabled, administrators will not be notified of **suspicious programmatic login attempts**. This could indicate automated credential stuffing or unauthorized API access using compromised credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "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. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Suspicious programmatic login** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Low**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Suspicious programmatic login** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_suspicious_programmatic_login_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py new file mode 100644 index 0000000000..0d609a99e1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Suspicious programmatic login" + + +class rules_suspicious_programmatic_login_alert_configured(Check): + """Check that the Suspicious programmatic login system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/__init__.py b/prowler/providers/googleworkspace/services/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/__init__.py b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json new file mode 100644 index 0000000000..3a2c0b4566 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_2sv_enforced", + "CheckTitle": "2-Step Verification is enforced for all users", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **enforces 2-Step Verification (Multi-Factor Authentication)** for all users. 2-Step Verification requires users to present a second form of authentication beyond their password, significantly reducing the risk of account compromise.", + "Risk": "Without 2-Step Verification enforcement, users can access their accounts with **only a password**. If credentials are compromised through phishing, credential stuffing, or data breaches, attackers gain **immediate access** to the user's account and organizational data without any additional verification.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification", + "https://docs.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 **Security** > **2-Step Verification**\n3. Check **Allow users to turn on 2-Step Verification**\n4. Set **Enforcement** to **On**\n5. Set **New user enrollment period** to **2 weeks**\n6. Under **Frequency**, uncheck **Allow user to trust device**\n7. Under **Methods**, select **Any except verification codes via text, phone call**\n8. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce **2-Step Verification** for all users to require a second authentication factor beyond passwords, protecting accounts from credential-based attacks.", + "Url": "https://hub.prowler.com/check/security_2sv_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_hardware_keys_admins" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py new file mode 100644 index 0000000000..7cf17b348e --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py @@ -0,0 +1,59 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_2sv_enforced(Check): + """Check that 2-Step Verification is enforced for all users. + + This check verifies that the domain-level policy enforces 2-Step + Verification (Multi-Factor Authentication) for all users, reducing + the risk of account compromise through stolen credentials. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + enforced_from = security_client.policies.two_sv_enforced_from + # The API returns "1970-01-01T00:00:00Z" (protobuf zero-value + # Timestamp) when enforcement is OFF, not null or empty. + enforcement_off_epoch = "1970-01-01T00:00:00Z" + + if enforced_from and enforced_from != enforcement_off_epoch: + report.status = "PASS" + report.status_extended = ( + f"2-Step Verification enforcement is active " + f"(enforced from {enforced_from}) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if enforced_from is None: + report.status_extended = ( + f"2-Step Verification enforcement is not configured " + f"in domain {security_client.provider.identity.domain}. " + f"The default is OFF. 2-Step Verification should be " + f"enforced for all users." + ) + else: + report.status_extended = ( + f"2-Step Verification enforcement is set to OFF " + f"in domain {security_client.provider.identity.domain}. " + f"2-Step Verification should be enforced for all users." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/__init__.py b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json new file mode 100644 index 0000000000..4aae5840a5 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_2sv_hardware_keys_admins", + "CheckTitle": "Hardware security keys are required for 2-Step Verification", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level 2-Step Verification policy requires **hardware security keys only** as the allowed sign-in factor, providing the strongest phishing-resistant authentication. **Note**: the Policy API returns domain-wide policies only and cannot verify admin role-specific enforcement.", + "Risk": "When 2SV methods include **SMS, phone calls, or software-based authenticators**, users are vulnerable to **SIM swapping, SS7 attacks, and real-time phishing proxies** that can intercept one-time codes. Hardware security keys are resistant to all known remote phishing techniques.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification", + "https://docs.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 **Security** > **Authentication** > **2-Step Verification**\n3. Select the appropriate group with **ALL ADMIN ROLES** (create this group if needed)\n4. Under **Methods**, select **Only security key**\n5. Under **2-Step Verification policy suspension grace period**, select **1 day**\n6. Under **Security codes**, select **Don't allow users to generate security codes**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require **hardware security keys only** for 2-Step Verification to provide the strongest phishing-resistant authentication for all users, particularly those in administrative roles.", + "Url": "https://hub.prowler.com/check/security_2sv_hardware_keys_admins" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_enforced" + ], + "Notes": "The Cloud Identity Policy API returns domain-wide policies only. It cannot verify that hardware keys are enforced specifically for admin roles versus all users. This check evaluates the customer-level enforcement factor, which applies to all users including administrators." +} diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py new file mode 100644 index 0000000000..5913f448e1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py @@ -0,0 +1,64 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_2sv_hardware_keys_admins(Check): + """Check that 2SV enforcement requires hardware security keys. + + This check verifies that the domain-level 2-Step Verification enforcement + factor is set to security keys only, providing the strongest protection + against phishing attacks. Note: the Cloud Identity Policy API returns + domain-wide policies — it cannot verify enforcement for admin roles + specifically. This check evaluates the customer-level policy which + applies to all users including administrators. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + factor_set = security_client.policies.two_sv_allowed_factor_set + + if factor_set == "PASSKEY_ONLY": + report.status = "PASS" + report.status_extended = ( + f"2-Step Verification enforcement requires security keys only " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if factor_set is None: + report.status_extended = ( + f"2-Step Verification enforcement factor is not configured " + f"in domain {security_client.provider.identity.domain}. " + f"The default allows all methods including SMS and phone call. " + f"Security keys should be required for administrative accounts. " + f"Note: this check evaluates the domain-wide policy, the Policy " + f"API does not expose role-specific 2SV enforcement." + ) + else: + report.status_extended = ( + f"2-Step Verification enforcement factor is set to " + f"{factor_set} " + f"in domain {security_client.provider.identity.domain}. " + f"Only security keys (PASSKEY_ONLY) should be allowed for " + f"administrative accounts. " + f"Note: this check evaluates the domain-wide policy, the Policy " + f"API does not expose role-specific 2SV enforcement." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/__init__.py b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.metadata.json new file mode 100644 index 0000000000..10c5d9e788 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_advanced_protection_configured", + "CheckTitle": "Advanced Protection Program is configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy enables the **Advanced Protection Program** with user self-enrollment and **blocks the use of security codes**. The Advanced Protection Program is Google's strongest account security offering, requiring hardware security keys and applying a curated set of high-security policies.", + "Risk": "Without the Advanced Protection Program, user accounts rely on standard security controls that are vulnerable to **sophisticated phishing attacks, targeted credential theft, and third-party app data access**. Allowing security codes alongside Advanced Protection weakens its protections by providing an alternative authentication path that can be intercepted.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-users-with-the-advanced-protection-program", + "https://docs.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 **Security** > **Advanced Protection Program**\n3. Under **Enrollment**, select **Enable user enrollment**\n4. Under **Security Codes**, select **Do not allow users to generate security codes**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the **Advanced Protection Program** with user self-enrollment and block **security codes** to enforce the strongest available account protection.", + "Url": "https://hub.prowler.com/check/security_advanced_protection_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_enforced", + "security_2sv_hardware_keys_admins" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.py b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.py new file mode 100644 index 0000000000..ab32837515 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.py @@ -0,0 +1,66 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_advanced_protection_configured(Check): + """Check that the Advanced Protection Program is configured. + + This check verifies that the domain-level policy enables Advanced + Protection Program self-enrollment and blocks the use of security codes, + as recommended by CIS 4.1.3.1. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + enrollment = security_client.policies.advanced_protection_enrollment + code_option = ( + security_client.policies.advanced_protection_security_code_option + ) + domain = security_client.provider.identity.domain + + enrollment_ok = enrollment is True + codes_ok = code_option == "CODES_NOT_ALLOWED" + + if enrollment_ok and codes_ok: + report.status = "PASS" + report.status_extended = ( + f"Advanced Protection Program is configured with enrollment " + f"enabled and security codes blocked in domain {domain}." + ) + else: + report.status = "FAIL" + issues = [] + if not enrollment_ok: + issues.append( + "enrollment is not configured" + if enrollment is None + else "enrollment is disabled" + ) + if not codes_ok: + issues.append( + f"security codes are " + f"{code_option or 'using default (allowed without remote access)'} " + f"(should be CODES_NOT_ALLOWED)" + ) + report.status_extended = ( + f"Advanced Protection Program is not properly configured " + f"in domain {domain}: {'; '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_app_access_restricted/__init__.py b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.metadata.json b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.metadata.json new file mode 100644 index 0000000000..0945fccdec --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_app_access_restricted", + "CheckTitle": "Application access to Google services is restricted", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level API controls configuration **restricts third-party app access** to at least one Google service. This check verifies that the administrator has configured API access controls rather than leaving all services at the unrestricted default. The CIS benchmark recommends restricting access to all applicable services, particularly high-risk scopes like Drive and Gmail.", + "Risk": "When application access to Google services is unrestricted, **any third-party app** that users consent to can access sensitive organizational data through Google APIs. This includes apps that may request **broad OAuth scopes** for Drive, Gmail, and other services, potentially leading to **data exfiltration** through unvetted applications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data", + "https://docs.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 **Security** > **Access and Data Control** > **API Controls**\n3. Click **App access control** > **MANAGE GOOGLE SERVICES**\n4. Select **ALL applicable Google Services**\n5. Click **Change access**\n6. Select **Restricted: Only trusted apps can access a service**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict **application access to Google services** to trusted apps only, particularly for high-risk scopes like **Drive and Gmail**, to prevent unvetted third-party apps from accessing sensitive organizational data.", + "Url": "https://hub.prowler.com/check/security_app_access_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "security_internal_apps_trusted" + ], + "Notes": "This check verifies that at least one Google service has API access restricted, serving as a signal that the administrator has configured API access controls. The CIS benchmark recommends restricting access to all applicable services." +} diff --git a/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.py b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.py new file mode 100644 index 0000000000..4db6a95b83 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.py @@ -0,0 +1,62 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_app_access_restricted(Check): + """Check that application access to Google services is restricted. + + This check verifies that at least one Google service has API access + restricted for third-party apps, indicating that the administrator + has reviewed and configured API access controls. The CIS benchmark + recommends restricting access to all applicable services, particularly + high-risk scopes like Drive and Gmail. This check serves as a signal + that API access controls have been configured rather than left at the + unrestricted default. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + restricted = security_client.policies.google_services_restricted + domain = security_client.provider.identity.domain + + if restricted is True: + report.status = "PASS" + report.status_extended = ( + f"Application access to Google services is restricted " + f"in domain {domain}. At least one Google service has " + f"API access limited to trusted apps." + ) + else: + report.status = "FAIL" + if restricted is None: + report.status_extended = ( + f"Application access to Google services is not configured " + f"in domain {domain}. The default is unrestricted. " + f"API access should be restricted for all applicable " + f"Google services, particularly high-risk scopes." + ) + else: + report.status_extended = ( + f"Application access to Google services is unrestricted " + f"in domain {domain}. " + f"API access should be restricted for all applicable " + f"Google services, particularly high-risk scopes." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_client.py b/prowler/providers/googleworkspace/services/security/security_client.py new file mode 100644 index 0000000000..5c8930edf1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.security.security_service import ( + Security, +) + +security_client = Security(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/__init__.py b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json new file mode 100644 index 0000000000..e19c3292b8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_dlp_drive_rules_configured", + "CheckTitle": "DLP policies for Google Drive are configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "At least one active **Data Loss Prevention (DLP) rule** targeting Google Drive file sharing is configured. DLP policies detect and prevent users from sharing sensitive information such as credit card numbers, identity numbers, and other regulated data through Drive.", + "Risk": "Without DLP policies, users can **freely share files containing sensitive information** through Google Drive without any detection or prevention controls. This increases the risk of **accidental data exposure, regulatory non-compliance**, and data breaches through oversharing.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/about-dlp", + "https://docs.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 **Security** > **Access and Data Control** > **Data protection**\n3. Click **Manage Rules**\n4. Click **ADD RULE** and select **New rule** or **New rule from template**\n5. Set the rule name and scope\n6. Set triggers by checking **File modified** under **Google Drive**\n7. Add conditions (Field, Comparison Operator, Content to match)\n8. Under **Actions**, select the desired action for each incident\n9. Under **Alerting**, set severity and select **Send to alert center**\n10. Click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure **DLP policies for Google Drive** to detect and prevent sharing of sensitive information such as credit card numbers, identity numbers, and other regulated data.", + "Url": "https://hub.prowler.com/check/security_dlp_drive_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py new file mode 100644 index 0000000000..37304d0636 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_dlp_drive_rules_configured(Check): + """Check that DLP policies for Google Drive are configured. + + This check verifies that at least one active Data Loss Prevention (DLP) + rule targeting Google Drive file sharing exists, helping to prevent + unintended exposure of sensitive information. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + dlp_exists = security_client.policies.dlp_drive_rules_exist + domain = security_client.provider.identity.domain + + if dlp_exists is True: + report.status = "PASS" + report.status_extended = ( + f"DLP policies for Google Drive are configured " + f"in domain {domain}. At least one active DLP rule " + f"targeting Drive file sharing exists." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"No active DLP policies for Google Drive are configured " + f"in domain {domain}. DLP rules should be configured " + f"to detect and prevent sharing of sensitive information " + f"through Drive." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/__init__.py b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.metadata.json b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.metadata.json new file mode 100644 index 0000000000..e4b055f6e1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_internal_apps_trusted", + "CheckTitle": "Internal apps can access Google Workspace APIs", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level API controls configuration **trusts internal domain-owned apps** to access restricted Google Workspace APIs. This avoids the need to individually trust each internal app.", + "Risk": "When internal apps are not trusted, legitimate **organization-built applications** cannot access restricted Google Workspace API scopes, potentially **breaking internal workflows** and forcing administrators to trust each app individually, which increases administrative overhead.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data", + "https://docs.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 **Security** > **Access and Data Control** > **API Controls**\n3. Click **App access control** > **Settings**\n4. Check **Trust internal, domain-owned apps**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **trust for internal domain-owned apps** so organization-built applications can access restricted Google Workspace APIs without individual trust configuration.", + "Url": "https://hub.prowler.com/check/security_internal_apps_trusted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "security_app_access_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.py b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.py new file mode 100644 index 0000000000..8a62124125 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_internal_apps_trusted(Check): + """Check that internal apps can access Google Workspace APIs. + + This check verifies that the domain-level policy trusts internal + domain-owned apps, allowing them to access restricted Google Workspace + APIs without requiring individual trust configuration. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + trust_internal = security_client.policies.trust_internal_apps + + if trust_internal is True: + report.status = "PASS" + report.status_extended = ( + f"Internal domain-owned apps are trusted to access " + f"Google Workspace APIs " + f"in domain {security_client.provider.identity.domain}." + ) + elif trust_internal is None: + report.status = "PASS" + report.status_extended = ( + f"Internal domain-owned apps use Google's secure default " + f"configuration (trusted) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Internal domain-owned apps are not trusted to access " + f"Google Workspace APIs " + f"in domain {security_client.provider.identity.domain}. " + f"Internal apps should be trusted to access restricted APIs." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/__init__.py b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.metadata.json b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.metadata.json new file mode 100644 index 0000000000..c6c2ff57e9 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_less_secure_apps_disabled", + "CheckTitle": "Less secure app access is disabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **disables access to less secure apps** that do not use modern security standards such as OAuth. Blocking these apps helps keep user accounts and organizational data safe.", + "Risk": "When less secure app access is enabled, users can allow apps that use **basic authentication** (username and password only) to access their Google account. These apps are more vulnerable to **credential theft** and do not support 2-Step Verification, increasing the risk of account compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/control-access-to-less-secure-apps", + "https://docs.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 **Security** > **Access and Data Control** > **Less secure apps**\n3. Select **Disable access to less secure apps (Recommended)**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **less secure app access** to prevent users from allowing apps that do not use modern authentication standards to access their accounts.", + "Url": "https://hub.prowler.com/check/security_less_secure_apps_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.py b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.py new file mode 100644 index 0000000000..a430f9d1e9 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_less_secure_apps_disabled(Check): + """Check that less secure app access is disabled. + + This check verifies that the domain-level policy prevents users from + allowing access to apps that use less secure sign-in technology, + reducing the risk of credential compromise. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + less_secure_allowed = security_client.policies.less_secure_apps_allowed + + if less_secure_allowed is False: + report.status = "PASS" + report.status_extended = ( + f"Less secure app access is disabled " + f"in domain {security_client.provider.identity.domain}." + ) + elif less_secure_allowed is None: + report.status = "PASS" + report.status_extended = ( + f"Less secure app access uses Google's secure default " + f"configuration (disabled) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Less secure app access is enabled " + f"in domain {security_client.provider.identity.domain}. " + f"Less secure app access should be disabled to prevent " + f"credential compromise through apps that do not use modern " + f"security standards." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/__init__.py b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json new file mode 100644 index 0000000000..ec945474fc --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_login_challenges_configured", + "CheckTitle": "Login challenges are configured correctly", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level login challenges configuration has the **employee ID challenge disabled**. CIS 4.1.4.1 also requires Post-SSO verification to be enabled, but that setting is **not exposed by the Cloud Identity Policy API**. This check only covers the employee ID challenge portion of the control.", + "Risk": "When the employee ID login challenge is enabled without proper configuration, it may create a **false sense of security** or interfere with the login flow. The employee ID challenge is a supplementary verification method that should only be used when specifically required by the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-google-workspace-accounts-with-security-challenges", + "https://docs.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 **Security** > **Login Challenges**\n3. Under **Login challenges**, uncheck **Use employee ID to keep my users more secure**\n4. Click **Save**\n5. Under **Post-SSO verification**, check **Logins using SSO are subject to additional verifications (if appropriate) and 2-Step Verification (if configured)** (this setting cannot be verified via the Policy API)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable the **employee ID login challenge** and manually verify that **Post-SSO verification** is enabled in the Admin Console, as the Post-SSO setting is not exposed by the Policy API.", + "Url": "https://hub.prowler.com/check/security_login_challenges_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_enforced" + ], + "Notes": "This check is partial — it only verifies the employee ID challenge setting. CIS 4.1.4.1 also requires Post-SSO verification to be enabled, which is not exposed by the Cloud Identity Policy API and must be verified manually." +} diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py new file mode 100644 index 0000000000..e3732053b8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py @@ -0,0 +1,62 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_login_challenges_configured(Check): + """Check that login challenges are configured correctly. + + This check verifies that the employee ID login challenge is disabled, + as recommended by CIS. Note: CIS 4.1.4.1 also requires Post-SSO + verification to be enabled, but that setting is not exposed by the + Cloud Identity Policy API. This check only covers the employee ID + challenge portion of the control. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + employee_id_enabled = security_client.policies.login_challenge_employee_id + + if employee_id_enabled is False: + report.status = "PASS" + report.status_extended = ( + f"Employee ID login challenge is disabled " + f"in domain {security_client.provider.identity.domain}. " + f"Note: Post-SSO verification status cannot be verified " + f"via the Policy API." + ) + elif employee_id_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"Employee ID login challenge uses Google's secure default " + f"configuration (disabled) " + f"in domain {security_client.provider.identity.domain}. " + f"Note: Post-SSO verification status cannot be verified " + f"via the Policy API." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Employee ID login challenge is enabled " + f"in domain {security_client.provider.identity.domain}. " + f"The employee ID challenge should be disabled per CIS " + f"recommendations. Note: Post-SSO verification status " + f"cannot be verified via the Policy API." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/__init__.py b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.metadata.json b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.metadata.json new file mode 100644 index 0000000000..776c9745e8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_password_policy_strong", + "CheckTitle": "Password policy is configured for enhanced security", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level password policy is configured with **enhanced security settings**: minimum length of 14 characters, strong passwords enforced, password reuse disallowed, enforcement at next sign-in enabled, and password expiration set to 365 days.", + "Risk": "Weak password policies allow users to set **short, simple, or previously compromised passwords** that are vulnerable to brute-force attacks, credential stuffing, and password spraying. Without enforcement at sign-in, users may continue using weak passwords indefinitely.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/enforce-and-monitor-password-requirements-for-users", + "https://docs.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 **Security** > **Password management**\n3. Under **Strength**, check **Enforce strong passwords**\n4. Under **Length**, set **Minimum Length** to **14** or greater\n5. Under **Strength and Length enforcement**, check **Enforce password policy at next sign-in**\n6. Under **Reuse**, uncheck **Allow password reuse**\n7. Under **Expiration**, set **Password reset frequency** to **365 Days**\n8. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **strong password policy** with minimum 14-character length, strong password enforcement, reuse prevention, enforcement at next sign-in, and annual password expiration.", + "Url": "https://hub.prowler.com/check/security_password_policy_strong" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py new file mode 100644 index 0000000000..33448aa536 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py @@ -0,0 +1,76 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_password_policy_strong(Check): + """Check that password policy is configured for enhanced security. + + This check verifies that the domain-level password policy meets CIS + requirements: minimum length of 14 characters, strong passwords enforced, + password reuse disallowed, enforcement at next sign-in, and password + expiration configured. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + policies = security_client.policies + domain = security_client.provider.identity.domain + issues = [] + + min_length = policies.password_minimum_length + if min_length is None or min_length < 14: + issues.append( + "minimum length is not configured (requires 14+)" + if min_length is None + else f"minimum length is {min_length} (requires 14+)" + ) + + if policies.password_allowed_strength != "STRONG": + issues.append( + "password strength is not configured (requires STRONG)" + if policies.password_allowed_strength is None + else f"password strength is {policies.password_allowed_strength} (requires STRONG)" + ) + + if policies.password_allow_reuse is True: + issues.append("password reuse is allowed") + + if policies.password_enforce_at_login is not True: + issues.append("password policy is not enforced at next sign-in") + + expiration = policies.password_expiration_duration + if expiration is None or expiration == "0s": + issues.append("password expiration is not configured") + + if not issues: + report.status = "PASS" + report.status_extended = ( + f"Password policy meets CIS requirements " + f"in domain {domain}: minimum length {min_length}, " + f"strong passwords enforced, reuse disallowed, " + f"enforced at next sign-in, expiration configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Password policy does not meet CIS requirements " + f"in domain {domain}: {'; '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_service.py b/prowler/providers/googleworkspace/services/security/security_service.py new file mode 100644 index 0000000000..96f24061f8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_service.py @@ -0,0 +1,281 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Security(GoogleWorkspaceService): + """Google Workspace Security service for auditing domain-level security policies. + + Uses the Cloud Identity Policy API v1 to read authentication, password, + session, recovery, API control, and DLP settings configured in the + Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = SecurityPolicies() + self.policies_fetched = False + self._fetch_security_policies() + + def _fetch_security_policies(self): + """Fetch security policies from the Cloud Identity Policy API v1.""" + logger.info("Security - Fetching security policies...") + + try: + service = self._build_service("cloudidentity", "v1") + + if not service: + logger.error("Failed to build Cloud Identity service") + return + + fetch_succeeded = True + + # Fetch 1: security.* settings + fetch_succeeded = self._fetch_namespace( + service, 'setting.type.matches("security.*")', fetch_succeeded + ) + + # Fetch 2: api_controls.* settings + fetch_succeeded = self._fetch_namespace( + service, 'setting.type.matches("api_controls.*")', fetch_succeeded + ) + + # Fetch 3: rule.dlp for DLP existence check + fetch_succeeded = self._fetch_namespace( + service, 'setting.type.matches("rule.dlp")', fetch_succeeded + ) + + self.policies_fetched = fetch_succeeded + + if fetch_succeeded: + logger.info("Security policies fetched successfully.") + else: + logger.warning( + "Security policies fetched with partial failures; " + "some checks may be skipped." + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching security policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + def _fetch_namespace(self, service, filter_str: str, fetch_succeeded: bool) -> bool: + """Fetch policies for a single namespace filter.""" + try: + request = service.policies().list( + pageSize=100, + filter=filter_str, + ) + + 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/") + value = setting.get("value", {}) + + self._process_setting(setting_type, value) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + f"fetching policies with filter {filter_str}", + self.provider.identity.customer_id, + ) + return False + + except Exception as error: + self._handle_api_error( + error, + f"listing policies with filter {filter_str}", + self.provider.identity.customer_id, + ) + return False + + return fetch_succeeded + + def _process_setting(self, setting_type: str, value: dict): + """Process a single policy setting and populate the model.""" + + # 2-Step Verification settings + if setting_type == "security.two_step_verification_enrollment": + self.policies.two_sv_allow_enrollment = value.get("allowEnrollment") + logger.debug(f"2SV enrollment: {self.policies.two_sv_allow_enrollment}") + + elif setting_type == "security.two_step_verification_enforcement": + self.policies.two_sv_enforced_from = value.get("enforcedFrom") + logger.debug(f"2SV enforcement: {self.policies.two_sv_enforced_from}") + + elif setting_type == "security.two_step_verification_enforcement_factor": + self.policies.two_sv_allowed_factor_set = value.get( + "allowedSignInFactorSet" + ) + logger.debug(f"2SV factor set: {self.policies.two_sv_allowed_factor_set}") + + elif setting_type == "security.two_step_verification_device_trust": + self.policies.two_sv_allow_trusting_device = value.get( + "allowTrustingDevice" + ) + logger.debug( + f"2SV device trust: {self.policies.two_sv_allow_trusting_device}" + ) + + elif setting_type == "security.two_step_verification_grace_period": + self.policies.two_sv_enrollment_grace_period = value.get( + "enrollmentGracePeriod" + ) + logger.debug( + f"2SV grace period: {self.policies.two_sv_enrollment_grace_period}" + ) + + elif setting_type == "security.two_step_verification_sign_in_code": + self.policies.two_sv_backup_code_exception_period = value.get( + "backupCodeExceptionPeriod" + ) + logger.debug( + f"2SV backup code period: {self.policies.two_sv_backup_code_exception_period}" + ) + + # Account recovery + elif setting_type == "security.super_admin_account_recovery": + self.policies.super_admin_recovery_enabled = value.get( + "enableAccountRecovery" + ) + logger.debug( + f"Super admin recovery: {self.policies.super_admin_recovery_enabled}" + ) + + elif setting_type == "security.user_account_recovery": + self.policies.user_recovery_enabled = value.get("enableAccountRecovery") + logger.debug(f"User recovery: {self.policies.user_recovery_enabled}") + + # Advanced Protection Program + elif setting_type == "security.advanced_protection_program": + self.policies.advanced_protection_enrollment = value.get( + "enableAdvancedProtectionSelfEnrollment" + ) + self.policies.advanced_protection_security_code_option = value.get( + "securityCodeOption" + ) + logger.debug("Advanced Protection Program settings fetched.") + + # Login challenges + elif setting_type == "security.login_challenges": + self.policies.login_challenge_employee_id = value.get( + "enableEmployeeIdChallenge" + ) + logger.debug("Login challenges settings fetched.") + + # Password policy + elif setting_type == "security.password": + self.policies.password_minimum_length = value.get("minimumLength") + self.policies.password_maximum_length = value.get("maximumLength") + self.policies.password_allowed_strength = value.get("allowedStrength") + self.policies.password_allow_reuse = value.get("allowReuse") + self.policies.password_enforce_at_login = value.get( + "enforceRequirementsAtLogin" + ) + self.policies.password_expiration_duration = value.get("expirationDuration") + logger.debug("Password policy settings fetched.") + + # Less secure apps + elif setting_type == "security.less_secure_apps": + self.policies.less_secure_apps_allowed = value.get("allowLessSecureApps") + logger.debug(f"Less secure apps: {self.policies.less_secure_apps_allowed}") + + # Session controls + elif setting_type == "security.session_controls": + self.policies.web_session_duration = value.get("webSessionDuration") + logger.debug(f"Web session duration: {self.policies.web_session_duration}") + + # Passkeys restriction + elif setting_type == "security.passkeys_restriction": + self.policies.passkeys_type = value.get("allowedPasskeysType") + logger.debug(f"Passkeys type: {self.policies.passkeys_type}") + + # API controls - internal apps + elif setting_type == "api_controls.internal_apps": + self.policies.trust_internal_apps = value.get("trustInternalApps") + logger.debug(f"Trust internal apps: {self.policies.trust_internal_apps}") + + # API controls - google services + elif setting_type == "api_controls.google_services": + services = value.get("services", []) + for svc in services: + if svc.get("isEnabled") is False: + self.policies.google_services_restricted = True + break + if self.policies.google_services_restricted is None: + self.policies.google_services_restricted = False + logger.debug( + f"Google services restricted: {self.policies.google_services_restricted}" + ) + + # DLP rules + elif setting_type == "rule.dlp": + state = value.get("state") + triggers = value.get("triggers", []) + if state == "ACTIVE" and any( + trigger.startswith("google.workspace.drive.") for trigger in triggers + ): + self.policies.dlp_drive_rules_exist = True + logger.debug(f"DLP rule: state={state}, triggers={triggers}") + + +class SecurityPolicies(BaseModel): + """Model for domain-level Security policy settings.""" + + # security.two_step_verification_enrollment + two_sv_allow_enrollment: Optional[bool] = None + # security.two_step_verification_enforcement + two_sv_enforced_from: Optional[str] = None + # security.two_step_verification_enforcement_factor + two_sv_allowed_factor_set: Optional[str] = None + # security.two_step_verification_device_trust + two_sv_allow_trusting_device: Optional[bool] = None + # security.two_step_verification_grace_period + two_sv_enrollment_grace_period: Optional[str] = None + # security.two_step_verification_sign_in_code + two_sv_backup_code_exception_period: Optional[str] = None + # security.super_admin_account_recovery + super_admin_recovery_enabled: Optional[bool] = None + # security.user_account_recovery + user_recovery_enabled: Optional[bool] = None + # security.advanced_protection_program + advanced_protection_enrollment: Optional[bool] = None + advanced_protection_security_code_option: Optional[str] = None + # security.login_challenges + login_challenge_employee_id: Optional[bool] = None + # security.password + password_minimum_length: Optional[int] = None + password_maximum_length: Optional[int] = None + password_allowed_strength: Optional[str] = None + password_allow_reuse: Optional[bool] = None + password_enforce_at_login: Optional[bool] = None + password_expiration_duration: Optional[str] = None + # security.less_secure_apps + less_secure_apps_allowed: Optional[bool] = None + # security.session_controls + web_session_duration: Optional[str] = None + # security.passkeys_restriction + passkeys_type: Optional[str] = None + # api_controls.internal_apps + trust_internal_apps: Optional[bool] = None + # api_controls.google_services + google_services_restricted: Optional[bool] = None + # rule.dlp + dlp_drive_rules_exist: Optional[bool] = None diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/__init__.py b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json new file mode 100644 index 0000000000..83e89527d8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_session_duration_limited", + "CheckTitle": "Google session control is configured to 12 hours or less", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level Google session control limits web session duration to **12 hours or less**. When a session expires, users must re-authenticate, reducing the window of opportunity for session hijacking.", + "Risk": "The default 14-day session duration means that a compromised session token provides an attacker with **two weeks of uninterrupted access** without re-authentication. Shorter session durations limit the impact of **stolen cookies, session hijacking, and unauthorized access** from shared or untrusted devices.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/set-session-length-for-google-services", + "https://docs.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 **Security** > **Access and Data Control** > **Google session control**\n3. Set **Web session duration** to **12 hours** or less\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set **Google session control** web session duration to **12 hours or less** to limit the window of access from compromised sessions.", + "Url": "https://hub.prowler.com/check/security_session_duration_limited" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py new file mode 100644 index 0000000000..1b72310ee6 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py @@ -0,0 +1,75 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + +MAX_SESSION_DURATION_SECONDS = 43200 # 12 hours + + +class security_session_duration_limited(Check): + """Check that Google session control is configured to 12 hours or less. + + This check verifies that the domain-level web session duration is set + to 12 hours or less, requiring users to re-authenticate more frequently + than the default 14-day session length. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + duration_str = security_client.policies.web_session_duration + domain = security_client.provider.identity.domain + + if duration_str is None: + report.status = "FAIL" + report.status_extended = ( + f"Google session control is not explicitly configured " + f"in domain {domain}. The default is 14 days. " + f"Web session duration should be 12 hours or less." + ) + findings.append(report) + return findings + + try: + duration_seconds = int(duration_str.removesuffix("s")) + except ValueError: + logger.error(f"Unparseable web session duration: {duration_str!r}") + report.status = "FAIL" + report.status_extended = ( + f"Web session duration value {duration_str!r} is not parseable " + f"in domain {domain}." + ) + findings.append(report) + return findings + + duration_hours = duration_seconds / 3600 + + if duration_seconds <= MAX_SESSION_DURATION_SECONDS: + report.status = "PASS" + report.status_extended = ( + f"Google session control is set to {duration_hours:.0f} hours " + f"in domain {domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Google session control is set to {duration_hours:.0f} hours " + f"in domain {domain}. " + f"Web session duration should be 12 hours or less." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/__init__.py b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.metadata.json b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.metadata.json new file mode 100644 index 0000000000..540faddac9 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_super_admin_recovery_disabled", + "CheckTitle": "Super Admin account recovery is disabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **disables self-service account recovery for Super Admin accounts**. When disabled, Super Admins cannot use recovery options (phone, email) to regain access, reducing the risk of account takeover through compromised recovery channels.", + "Risk": "If Super Admin account recovery is enabled, an attacker who compromises a Super Admin's **recovery phone or email** could use the self-service recovery flow to take over the account, gaining **full administrative control** over the entire Google Workspace organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/allow-super-administrators-to-recover-their-password", + "https://docs.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 **Security** > **Authentication** > **Account recovery**\n3. Select **Super admin account recovery**\n4. Uncheck **Allow super admins to recover their account**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Super Admin account recovery** to prevent attackers from exploiting the self-service recovery flow to take over privileged accounts.", + "Url": "https://hub.prowler.com/check/security_super_admin_recovery_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_user_recovery_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.py b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.py new file mode 100644 index 0000000000..5c201e7b5d --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_super_admin_recovery_disabled(Check): + """Check that Super Admin account recovery is disabled. + + This check verifies that the domain-level policy prevents Super Admin + users from recovering their account through self-service, reducing the + risk of account takeover through the recovery flow. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + recovery_enabled = security_client.policies.super_admin_recovery_enabled + + if recovery_enabled is False: + report.status = "PASS" + report.status_extended = ( + f"Super Admin account recovery is disabled " + f"in domain {security_client.provider.identity.domain}." + ) + elif recovery_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"Super Admin account recovery uses Google's secure default " + f"configuration (disabled) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Super Admin account recovery is enabled " + f"in domain {security_client.provider.identity.domain}. " + f"Super Admin account recovery should be disabled to prevent " + f"account takeover through the recovery flow." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/__init__.py b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.metadata.json b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.metadata.json new file mode 100644 index 0000000000..0a2de745db --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_user_recovery_enabled", + "CheckTitle": "User account recovery is enabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **enables self-service account recovery for non-Super Admin users**. When enabled, users can recover access to their accounts if their password is forgotten, reducing helpdesk burden and downtime.", + "Risk": "When user account recovery is disabled, users who lose access to their accounts must **contact an administrator** to regain access. This increases helpdesk burden, extends downtime, and may lead to users adopting **insecure workarounds** like sharing credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/set-up-password-recovery-for-users", + "https://docs.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 **Security** > **Authentication** > **Account recovery**\n3. Select **User account recovery**\n4. Check **Allow users and non-super admins to recover their account**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **user account recovery** to allow non-admin users to regain access to their accounts through self-service, reducing helpdesk burden and user downtime.", + "Url": "https://hub.prowler.com/check/security_user_recovery_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_super_admin_recovery_disabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.py b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.py new file mode 100644 index 0000000000..99aabcb842 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_user_recovery_enabled(Check): + """Check that user account recovery is enabled. + + This check verifies that the domain-level policy allows non-Super Admin + users to recover their accounts through self-service, reducing helpdesk + burden while maintaining access to their accounts. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + recovery_enabled = security_client.policies.user_recovery_enabled + + if recovery_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"User account recovery is enabled " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if recovery_enabled is None: + report.status_extended = ( + f"User account recovery is not explicitly configured " + f"in domain {security_client.provider.identity.domain}. " + f"The default is disabled. User account recovery should be " + f"enabled to reduce helpdesk burden." + ) + else: + report.status_extended = ( + f"User account recovery is disabled " + f"in domain {security_client.provider.identity.domain}. " + f"User account recovery should be enabled to reduce " + f"helpdesk burden." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/sites/__init__.py b/prowler/providers/googleworkspace/services/sites/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/sites/sites_client.py b/prowler/providers/googleworkspace/services/sites/sites_client.py new file mode 100644 index 0000000000..d57e537c5d --- /dev/null +++ b/prowler/providers/googleworkspace/services/sites/sites_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.sites.sites_service import ( + Sites, +) + +sites_client = Sites(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/sites/sites_service.py b/prowler/providers/googleworkspace/services/sites/sites_service.py new file mode 100644 index 0000000000..456c2aca65 --- /dev/null +++ b/prowler/providers/googleworkspace/services/sites/sites_service.py @@ -0,0 +1,88 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Sites(GoogleWorkspaceService): + """Google Workspace Sites service for auditing domain-level Sites policies. + + Uses the Cloud Identity Policy API v1 to read the Sites service status + configured in the Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = SitesPolicies() + self.policies_fetched = False + self._fetch_sites_policies() + + def _fetch_sites_policies(self): + """Fetch Sites policies from the Cloud Identity Policy API v1.""" + logger.info("Sites - Fetching sites 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("sites.*")', + ) + 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/") + value = setting.get("value", {}) + + if setting_type == "sites.service_status": + self.policies.service_state = value.get("serviceState") + logger.debug( + "Sites service state: " f"{self.policies.service_state}" + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching Sites policies", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + self.policies_fetched = fetch_succeeded + + logger.info( + f"Sites policies fetched - " + f"Service state: {self.policies.service_state}" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching Sites policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + +class SitesPolicies(BaseModel): + """Model for domain-level Sites policy settings.""" + + # sites.service_status + service_state: Optional[str] = None diff --git a/prowler/providers/googleworkspace/services/sites/sites_service_disabled/__init__.py b/prowler/providers/googleworkspace/services/sites/sites_service_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled.metadata.json b/prowler/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled.metadata.json new file mode 100644 index 0000000000..3871b358a2 --- /dev/null +++ b/prowler/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "sites_service_disabled", + "CheckTitle": "Service status for Google Sites is set to off", + "CheckType": [], + "ServiceName": "sites", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The domain-wide Google Sites configuration has the service **disabled for all users**. Google Sites allows users to create internal and external websites, which may expose sensitive information or create unmanaged content outside the organization's control.", + "Risk": "When Google Sites is enabled, users can create websites that may **inadvertently expose internal information** to external parties. These sites can be difficult to track and manage, creating potential **data leakage vectors** outside the organization's standard content management controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/advanced/turn-a-service-on-or-off-for-google-workspace-users", + "https://docs.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** > **Sites**\n3. Click **Service status**\n4. Select **OFF for everyone**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Google Sites** for all users to reduce the organization's attack surface. If specific users require access, enable it by exception for those users or groups only.", + "Url": "https://hub.prowler.com/check/sites_service_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled.py b/prowler/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled.py new file mode 100644 index 0000000000..2acd84fb7c --- /dev/null +++ b/prowler/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.sites.sites_client import sites_client + + +class sites_service_disabled(Check): + """Check that the Google Sites service is disabled for all users. + + This check verifies that the domain-level policy disables the Google Sites + service, reducing the organization's attack surface by preventing users + from creating internal or external websites. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if sites_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=sites_client.policies, + resource_id="sitesPolicies", + resource_name="Sites Policies", + customer_id=sites_client.provider.identity.customer_id, + ) + + service_state = sites_client.policies.service_state + + if service_state == "DISABLED": + report.status = "PASS" + report.status_extended = ( + f"Google Sites service is disabled " + f"in domain {sites_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if service_state is None: + report.status_extended = ( + f"Google Sites service is not explicitly configured " + f"in domain {sites_client.provider.identity.domain}. " + f"The default is ON for everyone. Google Sites should be disabled." + ) + else: + report.status_extended = ( + f"Google Sites service is enabled " + f"in domain {sites_client.provider.identity.domain}. " + f"Google Sites should be disabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index 5b3d898fb3..df9114e033 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -18,12 +18,17 @@ from prowler.config.config import ( from prowler.lib.check.models import CheckReportIAC from prowler.lib.logger import logger from prowler.lib.utils.utils import print_boxes +from prowler.lib.utils.vulnerability_references import ( + build_finding_reference_url, + resolve_vulnerability_reference_urls, +) from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider class IacProvider(Provider): _type: str = "iac" + sdk_only: bool = False audit_metadata: Audit_Metadata def __init__( @@ -189,14 +194,28 @@ class IacProvider(Provider): finding_id = finding["VulnerabilityID"] finding_description = finding["Description"] finding_status = finding.get("Status", "FAIL") + recommendation_url, additional_urls = ( + resolve_vulnerability_reference_urls( + vulnerability_id=finding_id, + references=finding.get("References"), + primary_url=finding.get("PrimaryURL", ""), + ) + ) + if not recommendation_url: + recommendation_url = build_finding_reference_url(finding_id) + additional_urls = [recommendation_url] elif "RuleID" in finding: finding_id = finding["RuleID"] finding_description = finding["Title"] finding_status = finding.get("Status", "FAIL") + recommendation_url = build_finding_reference_url(finding_id) + additional_urls = [recommendation_url] else: finding_id = finding["ID"] finding_description = finding["Description"] finding_status = finding["Status"] + recommendation_url = build_finding_reference_url(finding_id) + additional_urls = [recommendation_url] metadata_dict = { "Provider": "iac", @@ -210,7 +229,7 @@ class IacProvider(Provider): "ResourceType": "iac", "Description": finding_description, "Risk": "This provider has not defined a risk for this check.", - "RelatedUrl": finding.get("PrimaryURL", ""), + "RelatedUrl": "", "Remediation": { "Code": { "NativeIaC": "", @@ -220,11 +239,11 @@ class IacProvider(Provider): }, "Recommendation": { "Text": finding.get("Resolution", ""), - "Url": finding.get("PrimaryURL", ""), + "Url": recommendation_url, }, }, "Categories": [], - "AdditionalURLs": [], + "AdditionalURLs": additional_urls, "DependsOn": [], "RelatedTo": [], "Notes": "", diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index d724542e28..7a245e4125 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -18,6 +18,9 @@ from prowler.config.config import ( from prowler.lib.check.models import CheckReportImage from prowler.lib.logger import logger from prowler.lib.utils.utils import print_boxes +from prowler.lib.utils.vulnerability_references import ( + resolve_vulnerability_reference_urls, +) from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.image.exceptions.exceptions import ( @@ -56,6 +59,7 @@ class ImageProvider(Provider): """ _type: str = "image" + sdk_only: bool = False FINDING_BATCH_SIZE: int = 100 MAX_IMAGE_LIST_LINES: int = 10_000 MAX_IMAGE_NAME_LENGTH: int = 500 @@ -163,42 +167,50 @@ class ImageProvider(Provider): # Registry scan mode: enumerate images from registry if self.registry: self._enumerate_registry() - if self._listing_only: - return - for image in self.images: - self._validate_image_name(image) - - if not self.images: - raise ImageNoImagesProvidedError( - file=__file__, - message="No images provided for scanning.", - ) - - # Audit Config - if config_content: - self._audit_config = config_content - else: - if not config_path: - config_path = default_config_file_path - self._audit_config = load_and_validate_config_file(self._type, config_path) - - # Fixer Config - self._fixer_config = fixer_config if fixer_config is not None else {} - - # Mutelist (not needed for Image provider since Trivy has its own logic) + # Safe defaults for listing-only mode (overwritten below in scan mode) + self._audit_config = {} + self._fixer_config = {} self._mutelist = None + self.audit_metadata = None - self.audit_metadata = Audit_Metadata( - provider=self._type, - account_id=self.audited_account, - account_name="image", - region=self.region, - services_scanned=0, - expected_checks=[], - completed_checks=0, - audit_progress=0, - ) + # Skip scan setup for listing-only mode + if not self._listing_only: + for image in self.images: + self._validate_image_name(image) + + if not self.images: + raise ImageNoImagesProvidedError( + file=__file__, + message="No images provided for scanning.", + ) + + # Audit Config + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file( + self._type, config_path + ) + + # Fixer Config + self._fixer_config = fixer_config if fixer_config is not None else {} + + # Mutelist (not needed for Image provider since Trivy has its own logic) + self._mutelist = None + + self.audit_metadata = Audit_Metadata( + provider=self._type, + account_id=self.audited_account, + account_name="image", + region=self.region, + services_scanned=0, + expected_checks=[], + completed_checks=0, + audit_progress=0, + ) Provider.set_global_provider(self) @@ -321,12 +333,21 @@ class ImageProvider(Provider): """Image provider doesn't need a session since it uses Trivy directly""" return None + @staticmethod + def _strip_scheme(value: str) -> str: + """Remove a leading http:// or https:// scheme from a registry input.""" + for prefix in ("https://", "http://"): + if value.lower().startswith(prefix): + return value[len(prefix) :] + return value + @staticmethod def _extract_registry(image: str) -> str | None: """Extract registry hostname from an image reference. Returns None for Docker Hub images (no registry prefix). """ + image = ImageProvider._strip_scheme(image) parts = image.split("/") if len(parts) >= 2 and ("." in parts[0] or ":" in parts[0]): return parts[0] @@ -340,6 +361,7 @@ class ImageProvider(Provider): or "myregistry.com:5000" are registry URLs (dots in host, no slash). Image references like "alpine:3.18" or "nginx" are not. """ + image_uid = ImageProvider._strip_scheme(image_uid) if "/" not in image_uid: host_part = image_uid.split(":")[0] if "." in host_part: @@ -377,6 +399,8 @@ class ImageProvider(Provider): """ try: # Determine finding ID and category based on type + recommendation_url = "" + additional_urls: list[str] = [] if "VulnerabilityID" in finding: finding_id = finding["VulnerabilityID"] finding_description = finding.get( @@ -384,17 +408,30 @@ class ImageProvider(Provider): ) finding_status = "FAIL" finding_categories = ["vulnerabilities"] + recommendation_url, additional_urls = ( + resolve_vulnerability_reference_urls( + vulnerability_id=finding_id, + references=finding.get("References"), + primary_url=finding.get("PrimaryURL", ""), + ) + ) elif "RuleID" in finding: # Secret finding finding_id = finding["RuleID"] finding_description = finding.get("Title", "Secret detected") finding_status = "FAIL" finding_categories = ["secrets"] + additional_urls = ( + [url] if (url := finding.get("PrimaryURL", "")) else [] + ) else: finding_id = finding.get("ID", "UNKNOWN") finding_description = finding.get("Description", "") finding_status = finding.get("Status", "FAIL") finding_categories = [] + additional_urls = ( + [url] if (url := finding.get("PrimaryURL", "")) else [] + ) # Build remediation text for vulnerabilities remediation_text = "" @@ -433,13 +470,11 @@ class ImageProvider(Provider): }, "Recommendation": { "Text": remediation_text, - "Url": "", + "Url": recommendation_url, }, }, "Categories": finding_categories, - "AdditionalURLs": ( - [url] if (url := finding.get("PrimaryURL", "")) else [] - ), + "AdditionalURLs": additional_urls, "DependsOn": [], "RelatedTo": [], "Notes": "", @@ -827,11 +862,9 @@ class ImageProvider(Provider): image_ref = f"{repo}:{tag}" else: # OCI registries need the full host/repo:tag reference - registry_host = self.registry.rstrip("/") - for prefix in ("https://", "http://"): - if registry_host.startswith(prefix): - registry_host = registry_host[len(prefix) :] - break + registry_host = ImageProvider._strip_scheme( + self.registry.rstrip("/") + ) image_ref = f"{registry_host}/{repo}:{tag}" discovered_images.append(image_ref) @@ -969,6 +1002,8 @@ class ImageProvider(Provider): if not image: return Connection(is_connected=False, error="Image name is required") + image = ImageProvider._strip_scheme(image) + # Registry URL (bare hostname) → test via OCI catalog if ImageProvider._is_registry_url(image): return ImageProvider._test_registry_connection( diff --git a/prowler/providers/image/lib/registry/base.py b/prowler/providers/image/lib/registry/base.py index 1bb26ccbfb..298583620f 100644 --- a/prowler/providers/image/lib/registry/base.py +++ b/prowler/providers/image/lib/registry/base.py @@ -2,21 +2,51 @@ from __future__ import annotations +import ipaddress import re +import socket import time from abc import ABC, abstractmethod from urllib.parse import urlparse import requests +import tldextract from prowler.config.config import prowler_version from prowler.lib.logger import logger -from prowler.providers.image.exceptions.exceptions import ImageRegistryNetworkError +from prowler.providers.image.exceptions.exceptions import ( + ImageRegistryAuthError, + ImageRegistryNetworkError, +) _MAX_RETRIES = 3 _BACKOFF_BASE = 1 _USER_AGENT = f"Prowler/{prowler_version} (registry-adapter)" +_NON_PUBLIC_IP_PROPERTIES = ( + "is_private", + "is_loopback", + "is_link_local", + "is_multicast", + "is_reserved", + "is_unspecified", +) + + +def _ip_is_non_public(ip_str: str) -> bool: + try: + addr = ipaddress.ip_address(ip_str) + except ValueError: + return False + return any(getattr(addr, prop) for prop in _NON_PUBLIC_IP_PROPERTIES) + + +def _registrable_domain(host: str) -> str | None: + ext = tldextract.extract(host) + if not ext.domain or not ext.suffix: + return None + return f"{ext.domain}.{ext.suffix}" + class RegistryAdapter(ABC): """Abstract base class for registry adapters.""" @@ -68,6 +98,107 @@ class RegistryAdapter(ABC): """Enumerate all tags for a repository.""" ... + def _origin_url(self) -> str: + """The URL whose host the validator compares against when enforce_origin=True. + + Subclasses can override if the effective registry origin differs from + ``registry_url`` (e.g., Docker Hub talks to ``registry-1.docker.io``). + """ + return self.registry_url + + def _validate_outbound_url( + self, + url: str, + *, + enforce_origin: bool = True, + origin_url: str | None = None, + ) -> str: + """Validate a URL before it is passed to ``requests``. + + Defenses against parser-mismatch SSRF (PRWLRHELP-2103): + - canonicalise via ``requests.PreparedRequest`` so validator and connector + parse the same string the same way; + - reject schemes other than http/https; + - reject literal non-public IPs (private, loopback, link-local, ...); + - reject hostnames whose A/AAAA records resolve to non-public IPs; + - when ``enforce_origin=True``, reject hosts that don't share the + registry's registrable domain. + + Returns the canonical URL the caller should pass to ``requests``. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ImageRegistryAuthError( + file=__file__, + message=( + f"Disallowed URL scheme: {parsed.scheme!r}. Only http/https are allowed." + ), + ) + + try: + prepared = requests.Request("GET", url).prepare() + except ( + requests.exceptions.InvalidURL, + requests.exceptions.MissingSchema, + ValueError, + ) as exc: + raise ImageRegistryAuthError( + file=__file__, + message=f"Malformed URL {url!r}: {exc}", + ) + + canonical_url = prepared.url + canonical = urlparse(canonical_url) + host = canonical.hostname or "" + if not host: + raise ImageRegistryAuthError( + file=__file__, + message=f"URL has no host: {canonical_url}", + ) + + try: + addr = ipaddress.ip_address(host) + except ValueError: + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror: + infos = [] + for *_, sockaddr in infos: + resolved_ip = sockaddr[0] + if _ip_is_non_public(resolved_ip): + raise ImageRegistryAuthError( + file=__file__, + message=( + f"Host {host!r} resolves to non-public address {resolved_ip}. " + "This may indicate an SSRF attempt." + ), + ) + else: + if any(getattr(addr, prop) for prop in _NON_PUBLIC_IP_PROPERTIES): + raise ImageRegistryAuthError( + file=__file__, + message=( + f"URL targets a non-public address: {host}. " + "This may indicate an SSRF attempt." + ), + ) + + if enforce_origin: + registry_host = urlparse(origin_url or self._origin_url()).hostname or "" + if registry_host and host != registry_host: + target_d = _registrable_domain(host) + registry_d = _registrable_domain(registry_host) + if not (target_d and registry_d and target_d == registry_d): + raise ImageRegistryAuthError( + file=__file__, + message=( + f"URL host {host!r} is unrelated to registry host " + f"{registry_host!r}; refusing to follow." + ), + ) + + return canonical_url + def _request_with_retry(self, method: str, url: str, **kwargs) -> requests.Response: context_label = kwargs.pop("context_label", None) or self.registry_url kwargs.setdefault("timeout", 30) @@ -131,16 +262,15 @@ class RegistryAdapter(ABC): original_exception=last_exception, ) - @staticmethod - def _next_page_url(resp: requests.Response) -> str | None: + def _next_page_url(self, resp: requests.Response) -> str | None: link_header = resp.headers.get("Link", "") if not link_header: return None match = re.search(r'<([^>]+)>;\s*rel="next"', link_header) - if match: - url = match.group(1) - if url.startswith("/"): - parsed = urlparse(resp.url) - return f"{parsed.scheme}://{parsed.netloc}{url}" - return url - return None + if not match: + return None + url = match.group(1) + if url.startswith("/"): + parsed = urlparse(resp.url) + url = f"{parsed.scheme}://{parsed.netloc}{url}" + return self._validate_outbound_url(url) diff --git a/prowler/providers/image/lib/registry/dockerhub_adapter.py b/prowler/providers/image/lib/registry/dockerhub_adapter.py index 949de2d9cb..5cd0c32b9b 100644 --- a/prowler/providers/image/lib/registry/dockerhub_adapter.py +++ b/prowler/providers/image/lib/registry/dockerhub_adapter.py @@ -207,15 +207,14 @@ class DockerHubAdapter(RegistryAdapter): message=f"Unexpected error during {context} on Docker Hub (HTTP {resp.status_code}): {resp.text[:200]}", ) - @staticmethod - def _next_tag_page_url(resp: requests.Response) -> str | None: + def _next_tag_page_url(self, resp: requests.Response) -> str | None: link_header = resp.headers.get("Link", "") if not link_header: return None match = re.search(r'<([^>]+)>;\s*rel="next"', link_header) - if match: - next_url = match.group(1) - if next_url.startswith("/"): - return f"{_REGISTRY_HOST}{next_url}" - return next_url - return None + if not match: + return None + next_url = match.group(1) + if next_url.startswith("/"): + next_url = f"{_REGISTRY_HOST}{next_url}" + return self._validate_outbound_url(next_url, origin_url=_REGISTRY_HOST) diff --git a/prowler/providers/image/lib/registry/oci_adapter.py b/prowler/providers/image/lib/registry/oci_adapter.py index b7eb4c780b..878525bd04 100644 --- a/prowler/providers/image/lib/registry/oci_adapter.py +++ b/prowler/providers/image/lib/registry/oci_adapter.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 -import ipaddress import re from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -43,6 +42,9 @@ class OciRegistryAdapter(RegistryAdapter): url = f"https://{url}" return url + def _origin_url(self) -> str: + return self._base_url + def list_repositories(self) -> list[str]: self._ensure_auth() repositories: list[str] = [] @@ -127,8 +129,9 @@ class OciRegistryAdapter(RegistryAdapter): file=__file__, message=f"Cannot parse token endpoint from registry {self.registry_url}. Www-Authenticate: {www_authenticate[:200]}", ) - realm = match.group(1) - self._validate_realm_url(realm) + realm = self._validate_outbound_url(match.group(1)) + if urlparse(realm).scheme == "http": + logger.warning(f"Bearer token realm uses HTTP (not HTTPS): {realm}") params: dict = {} service_match = re.search(r'service="([^"]+)"', www_authenticate) if service_match: @@ -156,27 +159,6 @@ class OciRegistryAdapter(RegistryAdapter): ) return token - @staticmethod - def _validate_realm_url(realm: str) -> None: - parsed = urlparse(realm) - if parsed.scheme not in ("http", "https"): - raise ImageRegistryAuthError( - file=__file__, - message=f"Bearer token realm has disallowed scheme: {parsed.scheme}. Only http/https are allowed.", - ) - if parsed.scheme == "http": - logger.warning(f"Bearer token realm uses HTTP (not HTTPS): {realm}") - hostname = parsed.hostname or "" - try: - addr = ipaddress.ip_address(hostname) - if addr.is_private or addr.is_loopback or addr.is_link_local: - raise ImageRegistryAuthError( - file=__file__, - message=f"Bearer token realm points to a private/loopback address: {hostname}. This may indicate an SSRF attempt.", - ) - except ValueError: - pass - def _resolve_basic_credentials(self) -> tuple[str | None, str | None]: """Decode pre-encoded base64 auth tokens (e.g., from aws ecr get-authorization-token). @@ -206,14 +188,24 @@ class OciRegistryAdapter(RegistryAdapter): def _do_authed_request(self, method: str, url: str, **kwargs) -> requests.Response: headers = kwargs.pop("headers", {}) - if self._bearer_token: - headers["Authorization"] = f"Bearer {self._bearer_token}" - elif self.username and self.password: - user, pwd = self._resolve_basic_credentials() - kwargs.setdefault("auth", (user, pwd)) + if self._is_same_origin_as_registry(url): + if self._bearer_token: + headers["Authorization"] = f"Bearer {self._bearer_token}" + elif self.username and self.password: + user, pwd = self._resolve_basic_credentials() + kwargs.setdefault("auth", (user, pwd)) kwargs["headers"] = headers return self._request_with_retry(method, url, **kwargs) + def _is_same_origin_as_registry(self, url: str) -> bool: + target = urlparse(url) + origin = urlparse(self._base_url) + return ( + target.scheme == origin.scheme + and (target.hostname or "") == (origin.hostname or "") + and target.port == origin.port + ) + def _check_response(self, resp: requests.Response, context: str) -> None: if resp.status_code == 200: return diff --git a/prowler/providers/kubernetes/kubernetes_provider.py b/prowler/providers/kubernetes/kubernetes_provider.py index 2572b5be88..b350a18ebc 100644 --- a/prowler/providers/kubernetes/kubernetes_provider.py +++ b/prowler/providers/kubernetes/kubernetes_provider.py @@ -58,6 +58,7 @@ class KubernetesProvider(Provider): """ _type: str = "kubernetes" + sdk_only: bool = False _session: KubernetesSession _namespaces: list _audit_config: dict diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/__init__.py b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json new file mode 100644 index 0000000000..5831cb2abd --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_cpu_limits_set", + "CheckTitle": "Pod containers have CPU limits set", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure CPU limits are set for containers to prevent noisy neighbors and resource exhaustion.", + "Risk": "Missing CPU limits can allow containers to consume unbounded CPU leading to performance issues.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -n --containers= --limits=cpu=500m", + "NativeIaC": "# Example: set cpu limits in container resources\nresources:\n limits:\n cpu: \"500m\"", + "Other": "1. Edit the Pod/Deployment manifest and set `resources.limits.cpu` for each container.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define CPU limits to bound a container's CPU usage and protect node stability.", + "Url": "https://hub.prowler.com/check/core_cpu_limits_set" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Limits should be tuned per workload and monitored." +} diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py new file mode 100644 index 0000000000..8f22fa6b28 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_cpu_limits_set(Check): + """Check whether regular pod containers have CPU limits configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod CPU limits check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} regular containers have CPU limits configured." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + limits = ( + resources.get("limits") if isinstance(resources, dict) else None + ) + cpu = limits.get("cpu") if limits and isinstance(limits, dict) else None + if not cpu: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a CPU limit configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/__init__.py b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json new file mode 100644 index 0000000000..9f3972e271 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_cpu_requests_set", + "CheckTitle": "Pod containers have CPU requests set", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure CPU requests are set for containers to enable proper scheduling and resource guarantees.", + "Risk": "Missing CPU requests can lead to scheduling and resource contention issues.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -n --containers= --requests=cpu=100m", + "NativeIaC": "# Example: set cpu requests in container resources\nresources:\n requests:\n cpu: \"100m\"", + "Other": "1. Edit the Pod/Deployment manifest and set `resources.requests.cpu` for each container.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define sensible CPU requests to enable efficient scheduling and fair resource allocation.", + "Url": "https://hub.prowler.com/check/core_cpu_requests_set" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requests should be tuned per workload and cluster capacity." +} diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py new file mode 100644 index 0000000000..6e7f9f8020 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_cpu_requests_set(Check): + """Check whether regular pod containers have CPU requests configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod CPU requests check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} regular containers have CPU requests configured." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + requests = ( + resources.get("requests") if isinstance(resources, dict) else None + ) + cpu = ( + requests.get("cpu") + if requests and isinstance(requests, dict) + else None + ) + if not cpu: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a CPU request configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/__init__.py b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json new file mode 100644 index 0000000000..3a395caad1 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_image_tag_fixed", + "CheckTitle": "Container images use fixed tags", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers using images without a **fixed version tag**, such as `latest` or no tag at all, which results in unpredictable image versions being pulled.", + "Risk": "Using **`latest` or untagged images** breaks reproducibility and introduces risk:\n- Deployments may pull different image versions across nodes (integrity)\n- Rollbacks become unreliable when the exact image is unknown\n- Supply-chain attacks can inject malicious code via mutable tags\nCompromised or unexpected images can lead to **data breaches**, **lateral movement**, and **service disruption**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/containers/images/", + "https://kubernetes.io/docs/concepts/configuration/overview/#container-images" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set image deployment/ =: -n \n# Example: kubectl set image deployment/nginx nginx=nginx:1.25.3 -n default\n# For maximum immutability, use an image digest instead: @sha256:", + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., cloud provider console or Dashboard)\n2. Navigate to the failing workload (Deployment/StatefulSet/DaemonSet) or Pod and choose Edit YAML/Manifest\n3. Update the image field for each affected container to include a specific version tag or digest:\n - Example: change `nginx` or `nginx:latest` to `nginx:1.25.3` or `nginx@sha256:`\n4. Save changes (controllers will roll out new Pods). If it is a standalone Pod, delete and recreate it to apply the change", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"nginx:1.25.3\" # Critical: use a fixed version tag, never 'latest' or untagged\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Pin all container images to a **specific version tag** or **digest** (`@sha256:...`) to ensure reproducible, auditable deployments.\n\n- Avoid `latest` and untagged images in production\n- Use **image digests** for maximum immutability\n- Enforce tag policies with **admission controllers** (e.g., OPA Gatekeeper, Kyverno)\n- Integrate image scanning into CI/CD pipelines for **defense in depth**", + "Url": "https://hub.prowler.com/check/core_image_tag_fixed" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Using fixed image tags ensures reproducible deployments and reduces supply-chain attack surface." +} diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py new file mode 100644 index 0000000000..5167d09f2c --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +def _has_fixed_image_tag(image: str) -> bool: + if "@" in image: + return True + + image_name = image.rsplit("/", 1)[-1] + if ":" not in image_name: + return False + + tag = image_name.rsplit(":", 1)[-1] + return bool(tag) and tag.lower() != "latest" + + +class core_image_tag_fixed(Check): + """Ensure that image tag is not set to latest or blank.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has fixed image tags on all containers." + ) + + for containers in ( + pod.containers, + pod.init_containers, + pod.ephemeral_containers, + ): + for container in (containers or {}).values(): + image = container.image + if not _has_fixed_image_tag(image): + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} has container {container.name} with image '{image}' that does not use a fixed tag." + break + if report.status == "FAIL": + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/__init__.py b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json new file mode 100644 index 0000000000..668551bca4 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_liveness_probe_configured", + "CheckTitle": "Pod containers have liveness probes configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure each regular pod container has a liveness probe configured to detect and restart unhealthy containers.", + "Risk": "Without liveness probes, failed containers may remain running causing degraded service or resource waste.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/", + "https://sre.google/workbook/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/livenessProbe\",\"value\":{\"httpGet\":{\"path\":\"/healthz\",\"port\":8080},\"initialDelaySeconds\":10,\"periodSeconds\":10}}]'", + "NativeIaC": "# Example: add a livenessProbe to your container spec\nlivenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 10\n periodSeconds: 10", + "Other": "1. Edit the Pod/Deployment manifest and add a `livenessProbe` to each container.\n2. Tune `initialDelaySeconds`, `periodSeconds` and failure thresholds to your app.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add and tune liveness probes for containers to allow Kubernetes to detect and restart unhealthy containers.", + "Url": "https://hub.prowler.com/check/core_liveness_probe_configured" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Presence of a probe is checked; adjust thresholds for app behavior to avoid false positives." +} diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py new file mode 100644 index 0000000000..0bfbc618d2 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_liveness_probe_configured(Check): + """Check whether regular pod containers have liveness probes configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod liveness probe check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has liveness probes configured for all regular containers." + + for container in (pod.containers or {}).values(): + if not container.liveness_probe: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a liveness probe configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/__init__.py b/prowler/providers/kubernetes/services/core/core_memory_limits_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json new file mode 100644 index 0000000000..8b9a121bf2 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_memory_limits_set", + "CheckTitle": "Container memory limits are configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers without **memory limits** configured in `resources.limits.memory`, indicating unbounded memory consumption is permitted.", + "Risk": "Without **memory limits**, a single container can consume all available node memory, causing:\n- **OOM kills** of other workloads (availability)\n- Node instability and cascading failures\n- Noisy-neighbor problems in multi-tenant clusters\nAttackers exploiting a vulnerability can amplify impact by exhausting node resources, enabling **denial of service** across co-located workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + "https://kubernetes.io/docs/tasks/administer-cluster/manage-resources/memory-default-namespace/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -c --limits=memory= -n \n# Example: kubectl set resources deployment/nginx -c nginx --limits=memory=128Mi -n default", + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., cloud provider console or Dashboard)\n2. Navigate to the failing workload (Deployment/StatefulSet/DaemonSet) or Pod and choose Edit YAML/Manifest\n3. Set resources.limits.memory for each affected container:\n - For controllers: spec.template.spec.containers[].resources.limits.memory\n - For standalone Pods: spec.containers[].resources.limits.memory\n4. Save changes (controllers will roll out new Pods). If it is a standalone Pod, delete and recreate it to apply the change", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"\"\n resources {\n limits = {\n memory = \"128Mi\" # Critical: sets a memory ceiling to prevent unbounded consumption\n }\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set **memory limits** on every container to prevent unbounded consumption and protect node stability.\n\n- Start with observed usage plus headroom; tune with **VPA** recommendations\n- Combine with **LimitRanges** and **ResourceQuotas** at the namespace level for **defense in depth**\n- Monitor memory usage and OOMKill events to right-size limits over time", + "Url": "https://hub.prowler.com/check/core_memory_limits_set" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Memory limits prevent containers from consuming unbounded memory and protect node stability." +} diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py new file mode 100644 index 0000000000..2fd623c8d5 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_memory_limits_set(Check): + """Ensure that memory limits are set on all containers.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has memory limits set on all containers." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + limits = ( + resources.get("limits") if isinstance(resources, dict) else None + ) + memory = ( + limits.get("memory") + if limits and isinstance(limits, dict) + else None + ) + if not memory: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} does not have memory limits set on container {container.name}." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/__init__.py b/prowler/providers/kubernetes/services/core/core_memory_requests_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json new file mode 100644 index 0000000000..1871884621 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_memory_requests_set", + "CheckTitle": "Container memory requests are configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers without **memory requests** configured in `resources.requests.memory`, indicating the scheduler cannot guarantee memory allocation.", + "Risk": "Without **memory requests**, the scheduler cannot make informed placement decisions, leading to:\n- Overcommitted nodes that trigger **OOM kills** (availability)\n- Unpredictable performance under load\n- Inability to enforce **ResourceQuotas** effectively\nMissing requests weaken cluster stability and make capacity planning unreliable, increasing blast radius during incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + "https://kubernetes.io/docs/tasks/administer-cluster/manage-resources/memory-default-namespace/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -c --requests=memory= -n \n# Example: kubectl set resources deployment/nginx -c nginx --requests=memory=64Mi -n default", + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., cloud provider console or Dashboard)\n2. Navigate to the failing workload (Deployment/StatefulSet/DaemonSet) or Pod and choose Edit YAML/Manifest\n3. Set resources.requests.memory for each affected container:\n - For controllers: spec.template.spec.containers[].resources.requests.memory\n - For standalone Pods: spec.containers[].resources.requests.memory\n4. Save changes (controllers will roll out new Pods). If it is a standalone Pod, delete and recreate it to apply the change", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"\"\n resources {\n requests = {\n memory = \"64Mi\" # Critical: guarantees memory for the scheduler to make informed placement\n }\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set **memory requests** on every container so the scheduler can guarantee memory allocation and place pods effectively.\n\n- Base requests on observed steady-state usage; use **VPA** for data-driven sizing\n- Enforce minimum requests via **LimitRanges** at the namespace level\n- Combine with **ResourceQuotas** for **defense in depth** against overcommitment", + "Url": "https://hub.prowler.com/check/core_memory_requests_set" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Memory requests enable the scheduler to guarantee memory allocation and prevent overcommitment." +} diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py new file mode 100644 index 0000000000..a560365e32 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_memory_requests_set(Check): + """Ensure that memory requests are set on all containers.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has memory requests set on all containers." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + requests = ( + resources.get("requests") if isinstance(resources, dict) else None + ) + memory = ( + requests.get("memory") + if requests and isinstance(requests, dict) + else None + ) + if not memory: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} does not have memory requests set on container {container.name}." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/__init__.py b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json new file mode 100644 index 0000000000..5c61381064 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_readiness_probe_configured", + "CheckTitle": "Regular pod containers have readiness probes configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure each regular pod container has a readiness probe configured to signal when it is ready to serve traffic.", + "Risk": "Without readiness probes, services may receive traffic before containers are ready, causing errors.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/readinessProbe\",\"value\":{\"httpGet\":{\"path\":\"/ready\",\"port\":8080},\"initialDelaySeconds\":5,\"periodSeconds\":5}}]'", + "NativeIaC": "# Example readiness probe\nreadinessProbe:\n httpGet:\n path: /ready\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 5", + "Other": "1. Add a `readinessProbe` to the container spec in Deployments/Pods.\n2. Ensure the probe accurately reflects service readiness.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add readiness probes to prevent routing traffic to unready containers.", + "Url": "https://hub.prowler.com/check/core_readiness_probe_configured" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Readiness and liveness probes serve different purposes; implement both where appropriate." +} diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py new file mode 100644 index 0000000000..847f9b4b97 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_readiness_probe_configured(Check): + """Check whether regular pod containers have readiness probes configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod readiness probe check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has readiness probes configured for all regular containers." + + for container in (pod.containers or {}).values(): + if not container.readiness_probe: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a readiness probe configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_service.py b/prowler/providers/kubernetes/services/core/core_service.py index e685140a87..b53a81779c 100644 --- a/prowler/providers/kubernetes/services/core/core_service.py +++ b/prowler/providers/kubernetes/services/core/core_service.py @@ -27,45 +27,11 @@ class Core(KubernetesService): for namespace in self.namespaces: pods = self.client.list_namespaced_pod(namespace) for pod in pods.items: - pod_containers = {} - containers = pod.spec.containers if pod.spec.containers else [] - init_containers = ( - pod.spec.init_containers if pod.spec.init_containers else [] - ) - ephemeral_containers = ( + containers = self._build_containers(pod.spec.containers) + init_containers = self._build_containers(pod.spec.init_containers) + ephemeral_containers = self._build_containers( pod.spec.ephemeral_containers - if pod.spec.ephemeral_containers - else [] ) - for container in ( - containers + init_containers + ephemeral_containers - ): - pod_containers[container.name] = Container( - name=container.name, - image=container.image, - command=container.command if container.command else None, - ports=( - [ - {"containerPort": port.container_port} - for port in container.ports - ] - if container.ports - else None - ), - env=( - [ - {"name": env.name, "value": env.value} - for env in container.env - ] - if container.env - else None - ), - security_context=( - container.security_context.to_dict() - if container.security_context - else {} - ), - ) self.pods[pod.metadata.uid] = Pod( name=pod.metadata.name, uid=pod.metadata.uid, @@ -85,13 +51,56 @@ class Core(KubernetesService): if pod.spec.security_context else {} ), - containers=pod_containers, + containers=containers, + init_containers=init_containers, + ephemeral_containers=ephemeral_containers, ) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + @staticmethod + def _build_containers(containers) -> dict: + pod_containers = {} + for container in containers or []: + pod_containers[container.name] = Container( + name=container.name, + image=container.image, + command=container.command if container.command else None, + ports=( + [{"containerPort": port.container_port} for port in container.ports] + if container.ports + else None + ), + env=( + [{"name": env.name, "value": env.value} for env in container.env] + if container.env + else None + ), + security_context=( + container.security_context.to_dict() + if container.security_context + else {} + ), + resources=( + container.resources.to_dict() + if getattr(container, "resources", None) + else None + ), + liveness_probe=( + container.liveness_probe.to_dict() + if getattr(container, "liveness_probe", None) + else None + ), + readiness_probe=( + container.readiness_probe.to_dict() + if getattr(container, "readiness_probe", None) + else None + ), + ) + return pod_containers + def _list_config_maps(self): try: response = self.client.list_config_map_for_all_namespaces() @@ -156,6 +165,9 @@ class Container(BaseModel): ports: Optional[List[dict]] env: Optional[List[dict]] security_context: dict + resources: Optional[dict] = None + liveness_probe: Optional[dict] = None + readiness_probe: Optional[dict] = None class Pod(BaseModel): @@ -174,6 +186,8 @@ class Pod(BaseModel): host_network: Optional[bool] security_context: Optional[dict] containers: Optional[dict] + init_containers: Optional[dict] = None + ephemeral_containers: Optional[dict] = None class ConfigMap(BaseModel): diff --git a/prowler/providers/kubernetes/services/rbac/lib/role_permissions.py b/prowler/providers/kubernetes/services/rbac/lib/role_permissions.py index c3db38b2f7..04bf9b4c9c 100644 --- a/prowler/providers/kubernetes/services/rbac/lib/role_permissions.py +++ b/prowler/providers/kubernetes/services/rbac/lib/role_permissions.py @@ -1,36 +1,37 @@ -def is_rule_allowing_permissions(rules, resources, verbs): +def is_rule_allowing_permissions(rules, resources, verbs, api_groups=("",)): """ - Check Kubernetes role permissions. + Check whether any RBAC rule grants the specified verbs on the specified + resources within the specified API groups. - This function takes in Kubernetes role rules, resources, and verbs, - and checks if any of the rules grant permissions on the specified - resources with the specified verbs. + A rule matches when its `apiGroups` includes any of `api_groups` (or "*"), + its `resources` includes any of `resources` (or "*"), and its `verbs` + includes any of `verbs` (or "*"). Args: - rules (List[Rule]): The list of Kubernetes role rules. - resources (List[str]): The list of resources to check permissions for. - verbs (List[str]): The list of verbs to check permissions for. + rules (List[Rule]): RBAC rules from a Role or ClusterRole. + resources (List[str]): Resources (or sub-resources) to check. + verbs (List[str]): Verbs to check. + api_groups (Iterable[str]): API groups the resources live in. Defaults + to ("",), the core API group, which matches the most common case. + Pass an explicit value for resources outside the core group, e.g. + ("admissionregistration.k8s.io",) for webhook configurations. Returns: - bool: True if any of the rules grant permissions, False otherwise. + bool: True if any rule grants the permission, False otherwise. """ - if rules: - # Iterate through each rule in the list of rules - for rule in rules: - # Ensure apiGroups are relevant ("" or "v1" for secrets) - if rule.apiGroups and all(api not in ["", "v1"] for api in rule.apiGroups): - continue # Skip rules with unrelated apiGroups - # Check if the rule has resources, verbs, and matches any of the specified resources and verbs - if ( - rule.resources - and ( - any(resource in rule.resources for resource in resources) - or "*" in rule.resources - ) - and rule.verbs - and (any(verb in rule.verbs for verb in verbs) or "*" in rule.verbs) - ): - # If the rule matches, return True - return True - # If no rule matches, return False + if not rules: + return False + for rule in rules: + rule_api_groups = rule.apiGroups or [""] + if not ( + any(g in rule_api_groups for g in api_groups) or "*" in rule_api_groups + ): + continue + if ( + rule.resources + and (any(r in rule.resources for r in resources) or "*" in rule.resources) + and rule.verbs + and (any(v in rule.verbs for v in verbs) or "*" in rule.verbs) + ): + return True return False diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.py b/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.py index f2527b4606..2b86f0cafe 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.py +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.py @@ -6,29 +6,40 @@ from prowler.providers.kubernetes.services.rbac.rbac_client import rbac_client verbs = ["update", "patch"] resources = ["certificatesigningrequests/approval"] +api_groups = ["certificates.k8s.io"] class rbac_minimize_csr_approval_access(Check): def execute(self) -> Check_Report_Kubernetes: findings = [] + # Collect unique subjects and the ClusterRole names bound to them + subjects_bound_roles = {} for crb in rbac_client.cluster_role_bindings.values(): for subject in crb.subjects: + # CIS benchmarks scope these checks to human identities only if subject.kind in ["User", "Group"]: - report = Check_Report_Kubernetes( - metadata=self.metadata(), resource=subject - ) - report.status = "PASS" - report.status_extended = f"User or group '{subject.name}' does not have access to update the CSR approval sub-resource." - for cr in rbac_client.cluster_roles.values(): - if cr.metadata.name == crb.roleRef.name: - if is_rule_allowing_permissions( - cr.rules, - resources, - verbs, - ): - report.status = "FAIL" - report.status_extended = f"User or group '{subject.name}' has access to update the CSR approval sub-resource." - break - findings.append(report) + key = (subject.kind, subject.name, subject.namespace) + if key not in subjects_bound_roles: + subjects_bound_roles[key] = (subject, set()) + subjects_bound_roles[key][1].add(crb.roleRef.name) + + cluster_roles_by_name = { + cr.metadata.name: cr for cr in rbac_client.cluster_roles.values() + } + for _, (subject, role_names) in subjects_bound_roles.items(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject) + report.resource_name = f"{subject.kind}:{subject.name}" + report.resource_id = f"{subject.kind}/{subject.name}" + report.status = "PASS" + report.status_extended = f"User or group '{subject.name}' does not have access to update the CSR approval sub-resource." + for role_name in role_names: + cr = cluster_roles_by_name.get(role_name) + if cr and is_rule_allowing_permissions( + cr.rules, resources, verbs, api_groups + ): + report.status = "FAIL" + report.status_extended = f"User or group '{subject.name}' has access to update the CSR approval sub-resource." + break + findings.append(report) return findings diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.py b/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.py index 913d968f31..377e5345da 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.py +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.py @@ -11,20 +11,32 @@ resources = ["nodes/proxy"] class rbac_minimize_node_proxy_subresource_access(Check): def execute(self) -> Check_Report_Kubernetes: findings = [] + # Collect unique subjects and the ClusterRole names bound to them + subjects_bound_roles = {} for crb in rbac_client.cluster_role_bindings.values(): for subject in crb.subjects: + # CIS benchmarks scope these checks to human identities only if subject.kind in ["User", "Group"]: - report = Check_Report_Kubernetes( - metadata=self.metadata(), resource=subject - ) - report.status = "PASS" - report.status_extended = f"User or group '{subject.name}' does not have access to the node proxy sub-resource." - for cr in rbac_client.cluster_roles.values(): - if cr.metadata.name == crb.roleRef.name: - if is_rule_allowing_permissions(cr.rules, resources, verbs): - report.status = "FAIL" - report.status_extended = f"User or group '{subject.name}' has access to the node proxy sub-resource." - break - findings.append(report) + key = (subject.kind, subject.name, subject.namespace) + if key not in subjects_bound_roles: + subjects_bound_roles[key] = (subject, set()) + subjects_bound_roles[key][1].add(crb.roleRef.name) + + cluster_roles_by_name = { + cr.metadata.name: cr for cr in rbac_client.cluster_roles.values() + } + for _, (subject, role_names) in subjects_bound_roles.items(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject) + report.resource_name = f"{subject.kind}:{subject.name}" + report.resource_id = f"{subject.kind}/{subject.name}" + report.status = "PASS" + report.status_extended = f"User or group '{subject.name}' does not have access to the node proxy sub-resource." + for role_name in role_names: + cr = cluster_roles_by_name.get(role_name) + if cr and is_rule_allowing_permissions(cr.rules, resources, verbs): + report.status = "FAIL" + report.status_extended = f"User or group '{subject.name}' has access to the node proxy sub-resource." + break + findings.append(report) return findings diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.py b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.py index 204942c57e..2fb76bbed7 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.py +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.py @@ -11,21 +11,32 @@ resources = ["persistentvolumes"] class rbac_minimize_pv_creation_access(Check): def execute(self) -> Check_Report_Kubernetes: findings = [] - # Check each ClusterRoleBinding for access to create PersistentVolumes + # Collect unique subjects and the ClusterRole names bound to them + subjects_bound_roles = {} for crb in rbac_client.cluster_role_bindings.values(): for subject in crb.subjects: + # CIS benchmarks scope these checks to human identities only if subject.kind in ["User", "Group"]: - report = Check_Report_Kubernetes( - metadata=self.metadata(), resource=subject - ) - report.status = "PASS" - report.status_extended = f"User or group '{subject.name}' does not have access to create PersistentVolumes." - for cr in rbac_client.cluster_roles.values(): - if cr.metadata.name == crb.roleRef.name: - if is_rule_allowing_permissions(cr.rules, resources, verbs): - report.status = "FAIL" - report.status_extended = f"User or group '{subject.name}' has access to create PersistentVolumes." - break - findings.append(report) + key = (subject.kind, subject.name, subject.namespace) + if key not in subjects_bound_roles: + subjects_bound_roles[key] = (subject, set()) + subjects_bound_roles[key][1].add(crb.roleRef.name) + + cluster_roles_by_name = { + cr.metadata.name: cr for cr in rbac_client.cluster_roles.values() + } + for _, (subject, role_names) in subjects_bound_roles.items(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject) + report.resource_name = f"{subject.kind}:{subject.name}" + report.resource_id = f"{subject.kind}/{subject.name}" + report.status = "PASS" + report.status_extended = f"User or group '{subject.name}' does not have access to create PersistentVolumes." + for role_name in role_names: + cr = cluster_roles_by_name.get(role_name) + if cr and is_rule_allowing_permissions(cr.rules, resources, verbs): + report.status = "FAIL" + report.status_extended = f"User or group '{subject.name}' has access to create PersistentVolumes." + break + findings.append(report) return findings diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.py b/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.py index 9b1318c92f..8e492309db 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.py +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.py @@ -11,20 +11,32 @@ resources = ["serviceaccounts/token"] class rbac_minimize_service_account_token_creation(Check): def execute(self) -> Check_Report_Kubernetes: findings = [] + # Collect unique subjects and the ClusterRole names bound to them + subjects_bound_roles = {} for crb in rbac_client.cluster_role_bindings.values(): for subject in crb.subjects: + # CIS benchmarks scope these checks to human identities only if subject.kind in ["User", "Group"]: - report = Check_Report_Kubernetes( - metadata=self.metadata(), resource=subject - ) - report.status = "PASS" - report.status_extended = f"User or group '{subject.name}' does not have access to create service account tokens." - for cr in rbac_client.cluster_roles.values(): - if cr.metadata.name == crb.roleRef.name: - if is_rule_allowing_permissions(cr.rules, resources, verbs): - report.status = "FAIL" - report.status_extended = f"User or group '{subject.name}' has access to create service account tokens." - break - findings.append(report) + key = (subject.kind, subject.name, subject.namespace) + if key not in subjects_bound_roles: + subjects_bound_roles[key] = (subject, set()) + subjects_bound_roles[key][1].add(crb.roleRef.name) + + cluster_roles_by_name = { + cr.metadata.name: cr for cr in rbac_client.cluster_roles.values() + } + for _, (subject, role_names) in subjects_bound_roles.items(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject) + report.resource_name = f"{subject.kind}:{subject.name}" + report.resource_id = f"{subject.kind}/{subject.name}" + report.status = "PASS" + report.status_extended = f"User or group '{subject.name}' does not have access to create service account tokens." + for role_name in role_names: + cr = cluster_roles_by_name.get(role_name) + if cr and is_rule_allowing_permissions(cr.rules, resources, verbs): + report.status = "FAIL" + report.status_extended = f"User or group '{subject.name}' has access to create service account tokens." + break + findings.append(report) return findings diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.py b/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.py index 2da9893dab..e646efeeef 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.py +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.py @@ -9,29 +9,40 @@ resources = [ "mutatingwebhookconfigurations", ] verbs = ["create", "update", "delete"] +api_groups = ["admissionregistration.k8s.io"] class rbac_minimize_webhook_config_access(Check): def execute(self) -> Check_Report_Kubernetes: findings = [] + # Collect unique subjects and the ClusterRole names bound to them + subjects_bound_roles = {} for crb in rbac_client.cluster_role_bindings.values(): for subject in crb.subjects: + # CIS benchmarks scope these checks to human identities only if subject.kind in ["User", "Group"]: - report = Check_Report_Kubernetes( - metadata=self.metadata(), resource=subject - ) - report.status = "PASS" - report.status_extended = f"User or group '{subject.name}' does not have access to create, update, or delete webhook configurations." - for cr in rbac_client.cluster_roles.values(): - if cr.metadata.name == crb.roleRef.name: - if is_rule_allowing_permissions( - cr.rules, - resources, - verbs, - ): - report.status = "FAIL" - report.status_extended = f"User or group '{subject.name}' has access to create, update, or delete webhook configurations." - break - findings.append(report) + key = (subject.kind, subject.name, subject.namespace) + if key not in subjects_bound_roles: + subjects_bound_roles[key] = (subject, set()) + subjects_bound_roles[key][1].add(crb.roleRef.name) + + cluster_roles_by_name = { + cr.metadata.name: cr for cr in rbac_client.cluster_roles.values() + } + for _, (subject, role_names) in subjects_bound_roles.items(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=subject) + report.resource_name = f"{subject.kind}:{subject.name}" + report.resource_id = f"{subject.kind}/{subject.name}" + report.status = "PASS" + report.status_extended = f"User or group '{subject.name}' does not have access to create, update, or delete webhook configurations." + for role_name in role_names: + cr = cluster_roles_by_name.get(role_name) + if cr and is_rule_allowing_permissions( + cr.rules, resources, verbs, api_groups + ): + report.status = "FAIL" + report.status_extended = f"User or group '{subject.name}' has access to create, update, or delete webhook configurations." + break + findings.append(report) return findings diff --git a/prowler/providers/linode/__init__.py b/prowler/providers/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/__init__.py b/prowler/providers/linode/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/exceptions.py b/prowler/providers/linode/exceptions/exceptions.py new file mode 100644 index 0000000000..6c39f477db --- /dev/null +++ b/prowler/providers/linode/exceptions/exceptions.py @@ -0,0 +1,106 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 18000 to 18099 are reserved for Linode exceptions +class LinodeBaseException(ProwlerException): + """Base class for Linode errors.""" + + LINODE_ERROR_CODES = { + (18000, "LinodeCredentialsError"): { + "message": "Linode credentials not found or invalid", + "remediation": "Provide a valid Personal Access Token for Linode via the LINODE_TOKEN environment variable.", + }, + (18001, "LinodeAuthenticationError"): { + "message": "Linode authentication failed", + "remediation": "Verify the Linode Personal Access Token and ensure it has the required scopes (linodes:read_only, firewalls:read_only, account:read_only).", + }, + (18002, "LinodeSessionError"): { + "message": "Linode session setup failed", + "remediation": "Review the Linode SDK initialization parameters and credentials.", + }, + (18003, "LinodeIdentityError"): { + "message": "Unable to retrieve Linode identity or account information", + "remediation": "Ensure the Personal Access Token allows access to the Linode account and profile APIs.", + }, + (18004, "LinodeMissingPermissionError"): { + "message": "Linode token is missing a required permission scope", + "remediation": "Grant the Personal Access Token the read-only scope required for the affected service (account:read_only, linodes:read_only, firewall:read_only).", + }, + (18005, "LinodeInvalidRegionError"): { + "message": "One or more requested Linode regions are invalid", + "remediation": "Pass a valid Linode region id to --region. See https://www.linode.com/global-infrastructure/ or the API /v4/regions endpoint for the current list.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Linode" + error_info = self.LINODE_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Linode error", + "remediation": "Check the Linode API documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class LinodeCredentialsError(LinodeBaseException): + """Exception for Linode credential errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18000, file=file, original_exception=original_exception, message=message + ) + + +class LinodeAuthenticationError(LinodeBaseException): + """Exception for Linode authentication errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18001, file=file, original_exception=original_exception, message=message + ) + + +class LinodeSessionError(LinodeBaseException): + """Exception for Linode session setup errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18002, file=file, original_exception=original_exception, message=message + ) + + +class LinodeIdentityError(LinodeBaseException): + """Exception for Linode identity errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18003, file=file, original_exception=original_exception, message=message + ) + + +class LinodeMissingPermissionError(LinodeBaseException): + """Exception for Linode missing permission scope errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18004, file=file, original_exception=original_exception, message=message + ) + + +class LinodeInvalidRegionError(LinodeBaseException): + """Exception for invalid Linode region filters.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18005, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/linode/lib/__init__.py b/prowler/providers/linode/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/__init__.py b/prowler/providers/linode/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/arguments.py b/prowler/providers/linode/lib/arguments/arguments.py new file mode 100644 index 0000000000..b8af36dba9 --- /dev/null +++ b/prowler/providers/linode/lib/arguments/arguments.py @@ -0,0 +1,24 @@ +def init_parser(self): + """Init the Linode provider CLI parser.""" + linode_parser = self.subparsers.add_parser( + "linode", parents=[self.common_providers_parser], help="Linode Provider" + ) + + # Authentication + # Credentials are read exclusively from the standard Linode environment + # variable (LINODE_TOKEN) to avoid leaking secrets into shell history and + # process listings. There are no credential CLI flags. + + # Regions + regions_subparser = linode_parser.add_argument_group("Regions") + regions_subparser.add_argument( + "--region", + "--filter-region", + "-f", + nargs="+", + default=None, + metavar="REGION", + help="Linode region(s) to scan (e.g. eu-central us-east). Region-less " + "resources (account, networking) are always scanned. If omitted, all " + "regions are scanned.", + ) diff --git a/prowler/providers/linode/lib/mutelist/__init__.py b/prowler/providers/linode/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/mutelist/mutelist.py b/prowler/providers/linode/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..c7da04a58a --- /dev/null +++ b/prowler/providers/linode/lib/mutelist/mutelist.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import CheckReportLinode +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class LinodeMutelist(Mutelist): + """Linode-specific mutelist helper.""" + + def is_finding_muted( + self, + finding: CheckReportLinode, + account_id: str, + ) -> bool: + """ + Check if a Linode finding is muted. + + Args: + finding: CheckReportLinode instance containing check metadata, region, resource info, and tags. + account_id: Linode account identifier. + + Returns: + True if the finding is muted, False otherwise. + """ + return self.is_muted( + account_id, + finding.check_metadata.CheckID, + finding.region or "global", + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/linode/lib/service/__init__.py b/prowler/providers/linode/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/service/service.py b/prowler/providers/linode/lib/service/service.py new file mode 100644 index 0000000000..d1a9de65db --- /dev/null +++ b/prowler/providers/linode/lib/service/service.py @@ -0,0 +1,86 @@ +import os +from concurrent.futures import ThreadPoolExecutor, as_completed + +from prowler.lib.logger import logger +from prowler.providers.linode.exceptions.exceptions import LinodeMissingPermissionError +from prowler.providers.linode.linode_provider import LinodeProvider + +MAX_WORKERS = 10 + + +class LinodeService: + """Base class for Linode services to share provider context.""" + + def __init__(self, service: str, provider: LinodeProvider): + """ + Initialize the Linode service with provider context. + + Args: + service: The Linode service name (e.g., administration, compute, networking). + provider: LinodeProvider instance containing session, audit config, and fixer config. + """ + self.provider = provider + self.client = provider.session.client + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + + self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + def _log_fetch_error( + self, resource_label: str, required_scope: str, error: Exception + ) -> None: + """Log a resource-fetch failure, distinguishing an insufficient-scope + (HTTP 401/403) error from a generic API error. + + This never raises: a single service's missing permission must not abort + the rest of the scan. When the token lacks the required scope, the log + names the exact scope to grant via ``LinodeMissingPermissionError``. + + Args: + resource_label: Human-readable resource name (e.g. "firewalls"). + required_scope: The Linode OAuth scope needed (e.g. "firewall:read_only"). + error: The exception raised by the SDK call. + """ + service_name = getattr(self, "service", "linode") + status = getattr(error, "status", None) + if status in (401, 403) or "not authorized to use this endpoint" in str(error): + logger.error( + str( + LinodeMissingPermissionError( + file=os.path.basename(__file__), + message=( + f"{service_name} - unable to list {resource_label}: the Linode " + f"token lacks the '{required_scope}' scope; skipping these checks." + ), + original_exception=error, + ) + ) + ) + else: + logger.error( + f"{service_name} - Error fetching {resource_label}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def __threading_call__(self, call, iterator): + """Execute a function across multiple items using threading.""" + items = list(iterator) if not isinstance(iterator, list) else iterator + + futures = {self.thread_pool.submit(call, item): item for item in items} + results = [] + + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception as error: + item = futures[future] + item_id = getattr(item, "id", str(item)) + logger.error( + f"{self.service} - Threading error processing {item_id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return results diff --git a/prowler/providers/linode/linode_provider.py b/prowler/providers/linode/linode_provider.py new file mode 100644 index 0000000000..ab069fab3d --- /dev/null +++ b/prowler/providers/linode/linode_provider.py @@ -0,0 +1,343 @@ +import logging +import os + +from colorama import Fore, Style +from linode_api4 import LinodeClient + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeIdentityError, + LinodeInvalidRegionError, + LinodeSessionError, +) +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + + +class LinodeProvider(Provider): + """Linode provider.""" + + _type: str = "linode" + _session: LinodeSession + _identity: LinodeIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: LinodeMutelist + _regions: set + audit_metadata: Audit_Metadata + + def __init__( + self, + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict | None = None, + mutelist_path: str = None, + mutelist_content: dict = None, + token: str = None, + regions: list = None, + ): + """ + Initializes the LinodeProvider instance. + + Args: + config_path (str): Path to the configuration file. + config_content (dict): Audit configuration content. + fixer_config (dict): Fixer configuration. + mutelist_path (str): Path to the mutelist file. + mutelist_content (dict): Mutelist content. + token (str): Linode Personal Access Token (falls back to LINODE_TOKEN env var). + regions (list): Region(s) to scan regional resources in. Region-less + resources are always scanned. ``None`` scans all regions. + + Raises: + LinodeCredentialsError: If no token is provided. + LinodeSessionError: If the Linode session cannot be established. + LinodeIdentityError: If user or account identity cannot be retrieved. + """ + logger.info("Instantiating Linode provider...") + + # Mute noisy HTTP client logs + logging.getLogger("urllib3").setLevel(logging.WARNING) + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._session = LinodeProvider.setup_session(token=token) + + # Region filter for regional resources, validated against the live + # Linode regions list. None means scan all regions. + self._regions = LinodeProvider.validate_regions(self._session, regions) + + self._identity = LinodeProvider.setup_identity(self._session) + + self._fixer_config = fixer_config if fixer_config is not None else {} + + if mutelist_content: + self._mutelist = LinodeMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> LinodeMutelist: + return self._mutelist + + @property + def regions(self): + """Set of regions to scan for regional resources, or None for all.""" + return self._regions + + def validate_arguments(self) -> None: + """Linode provider has no provider-specific arguments to validate.""" + return None + + @staticmethod + def setup_session(token: str = None) -> LinodeSession: + """Initialize Linode SDK client. + + Credentials can be provided as argument or read from environment variable: + - LINODE_TOKEN (Personal Access Token) + + Args: + token: Linode Personal Access Token (optional, falls back to env var). + + Returns: + LinodeSession: The initialized Linode session. + + Raises: + LinodeCredentialsError: If no credentials are provided. + LinodeSessionError: If session setup fails. + """ + token = token or os.environ.get("LINODE_TOKEN", "") + + if not token: + raise LinodeCredentialsError( + file=os.path.basename(__file__), + message="Linode credentials not found. Set the LINODE_TOKEN environment variable.", + ) + + try: + client = LinodeClient(token) + return LinodeSession(client=client, token=token) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeSessionError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def validate_regions(session: LinodeSession, regions: list = None): + """Validate the requested regions against the live Linode regions list. + + The ``/v4/regions`` endpoint is public, so this works regardless of the + token's scope. Validating against the live list (instead of a static + file) avoids rejecting newly added Linode regions. + + Args: + session: The Linode session. + regions: The region ids requested via --region (or None). + + Returns: + The validated set of region ids, or None when no filter is given. + + Raises: + LinodeInvalidRegionError: If any requested region id is unknown. + """ + if not regions: + return None + + requested = set(regions) + try: + available = {region.id for region in session.client.regions()} + except Exception as error: + # Do not block a scan if the regions list cannot be fetched. + logger.warning( + f"Unable to validate Linode regions: {error}. " + "Proceeding with the requested regions without validation." + ) + return requested + + invalid = requested - available + if invalid: + raise LinodeInvalidRegionError( + file=os.path.basename(__file__), + message=( + f"Invalid Linode region(s): {', '.join(sorted(invalid))}. " + f"Valid regions are: {', '.join(sorted(available))}." + ), + ) + return requested + + @staticmethod + def setup_identity(session: LinodeSession) -> LinodeIdentityInfo: + """Fetch user and account metadata for Linode. + + The authenticated user's profile is retrieved first to validate the + token. Any valid token can read its own profile, so a failure here + (for example a ``401 Invalid Token``) means the credentials are invalid + and the scan is aborted instead of silently returning empty results. + + Args: + session: The Linode session. + + Returns: + LinodeIdentityInfo: The identity information. + + Raises: + LinodeAuthenticationError: If the token is invalid. + LinodeIdentityError: If identity setup fails unexpectedly. + """ + try: + client = session.client + username = None + email = None + account_id = None + + # Validate the token by reading the authenticated user's profile. + # A failure here means the credentials are invalid, so abort. + try: + profile = client.profile() + username = profile.username + email = profile.email + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + + # The account endpoint requires the account:read_only scope. A + # token without that scope is still valid, so continue without the + # account ID instead of failing the scan. + try: + account = client.account() + account_id = getattr(account, "euuid", None) + except Exception as error: + logger.warning( + f"Unable to retrieve Linode account info: {error}. Continuing without account ID." + ) + + return LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + except LinodeAuthenticationError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeIdentityError( + file=os.path.basename(__file__), + original_exception=error, + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Linode credentials below:{Style.RESET_ALL}" + ) + report_lines = [] + + report_lines.append( + f"Authentication: {Fore.YELLOW}Personal Access Token{Style.RESET_ALL}" + ) + + if self.identity.username: + report_lines.append( + f"Username: {Fore.YELLOW}{self.identity.username}{Style.RESET_ALL}" + ) + + if self.identity.email: + report_lines.append( + f"Email: {Fore.YELLOW}{self.identity.email}{Style.RESET_ALL}" + ) + + if self.identity.account_id: + report_lines.append( + f"Account ID: {Fore.YELLOW}{self.identity.account_id}{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + token: str = None, + raise_on_exception: bool = True, + ) -> Connection: + """Test connection to Linode. + + Args: + token: Linode Personal Access Token. + raise_on_exception: Flag indicating whether to raise an exception if the connection fails. + + Returns: + Connection: Connection object with is_connected status. + """ + try: + session = LinodeProvider.setup_session(token=token) + # Validate by fetching profile + session.client.profile() + return Connection(is_connected=True) + except LinodeCredentialsError as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise + return Connection(is_connected=False, error=error) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + return Connection(is_connected=False, error=error) diff --git a/prowler/providers/linode/models.py b/prowler/providers/linode/models.py new file mode 100644 index 0000000000..8e41e881a7 --- /dev/null +++ b/prowler/providers/linode/models.py @@ -0,0 +1,38 @@ +from typing import Any, Optional + +from pydantic import BaseModel + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class LinodeSession(BaseModel): + """Linode session information.""" + + client: Any + token: Optional[str] = None + + +class LinodeIdentityInfo(BaseModel): + """Linode identity and scoping information.""" + + username: Optional[str] = None + email: Optional[str] = None + account_id: Optional[str] = None + + +class LinodeOutputOptions(ProviderOutputOptions): + """Customize output filenames for Linode scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: LinodeIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + account_fragment = identity.account_id or identity.username or "linode" + self.output_filename = ( + f"prowler-output-{account_fragment}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/linode/services/__init__.py b/prowler/providers/linode/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/__init__.py b/prowler/providers/linode/services/administration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_client.py b/prowler/providers/linode/services/administration/administration_client.py new file mode 100644 index 0000000000..99a242e9ee --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + +administration_client = AdministrationService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/administration/administration_service.py b/prowler/providers/linode/services/administration/administration_service.py new file mode 100644 index 0000000000..7704cf11b2 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_service.py @@ -0,0 +1,46 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class User(BaseModel): + """Model for a Linode account user.""" + + username: str + email: str = "" + tfa_enabled: bool = False + restricted: bool = False + + +class AdministrationService(LinodeService): + """Service to interact with Linode Account Users.""" + + def __init__(self, provider): + super().__init__("administration", provider) + self.users: List[User] = [] + self._describe_users() + + def _describe_users(self): + """Fetch all Linode account users.""" + try: + raw_users = self.client.account.users() + for user in raw_users: + try: + self.users.append( + User( + username=getattr(user, "username", "") or "", + email=getattr(user, "email", "") or "", + tfa_enabled=getattr(user, "tfa_enabled", False) or False, + restricted=getattr(user, "restricted", False) or False, + ) + ) + except Exception as error: + logger.error( + f"account - Error processing user {getattr(user, 'username', 'unknown')}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("account users", "account:read_only", error) diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json new file mode 100644 index 0000000000..c16e26f56d --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "administration_user_2fa_enabled", + "CheckTitle": "Linode account user has two-factor authentication enabled", + "CheckType": [], + "ServiceName": "administration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Linode account users** are assessed for **two-factor authentication (2FA)** enablement. 2FA adds a second verification factor (a TOTP code or security key) on top of the password, so a stolen or guessed password alone is not enough to sign in to the **Linode Cloud Manager**.", + "Risk": "Without **2FA**, a single compromised password — through phishing, credential stuffing, or brute force — grants an attacker full access to the user's Linode account. This can lead to **data exfiltration**, destruction of instances and backups, and resource abuse for **cryptomining**, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-2fa" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to My Profile > Login & Authentication\n3. Under Security Settings, configure all 3 security questions\n4. Enable Two-Factor Authentication", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `2FA` for all Linode account users. Use an authenticator app (`TOTP`) for the second factor and store backup codes securely.", + "Url": "https://hub.prowler.com/check/administration_user_2fa_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py new file mode 100644 index 0000000000..2bb2c92729 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.administration.administration_client import ( + administration_client, +) + + +class administration_user_2fa_enabled(Check): + """Check if Linode account users have two-factor authentication enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the administration_user_2fa_enabled check. + + Iterates over all account users and checks whether two-factor + authentication is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each user. + """ + findings = [] + + for user in administration_client.users: + report = CheckReportLinode( + metadata=self.metadata(), + resource=user, + resource_name=user.username, + resource_id=user.username, + region="global", + ) + + if user.tfa_enabled: + report.status = "PASS" + report.status_extended = ( + f"User '{user.username}' has two-factor authentication enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"User '{user.username}' does not have two-factor authentication enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/__init__.py b/prowler/providers/linode/services/compute/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_client.py b/prowler/providers/linode/services/compute/compute_client.py new file mode 100644 index 0000000000..8dc03fa36d --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.compute.compute_service import ComputeService + +compute_client = ComputeService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json new file mode 100644 index 0000000000..b425e5c5b0 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_backups_enabled", + "CheckTitle": "Linode Instance has the Backup service enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for enrollment in the **Linode Backup service**, which performs automatic daily and weekly snapshots of an instance's disks. Without it enabled, there is no managed recovery point to restore from after a data-loss event.", + "Risk": "With the **Backup service** disabled, there is no automated recovery point for the instance. Accidental deletion, **ransomware**, disk corruption, or operator error can result in **permanent data loss** and extended **downtime**, directly impacting **availability** and data **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/enable-backups", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#configure-additional-options" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes backups-enable ", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Backups tab\n4. Click 'Enable Backups'", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the `Linode Backup service` on this instance to ensure automated recovery points are available. Consider supplementing it with manual snapshots before critical changes.", + "Url": "https://hub.prowler.com/check/compute_instance_backups_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py new file mode 100644 index 0000000000..c7ae903fbf --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_backups_enabled(Check): + """Check if Linode instances have the Backup service enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_backups_enabled check. + + Iterates over all Linode instances and checks whether the Backup + service is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.backups_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has the Backup service enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have the Backup service enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json new file mode 100644 index 0000000000..878030dc25 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_disk_encryption_enabled", + "CheckTitle": "Linode Instance has disk encryption enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **disk encryption** status. Disk encryption protects **data at rest** on the instance's underlying disks, keeping stored data unreadable without the encryption keys even if the physical media is accessed.", + "Risk": "Without **disk encryption**, data at rest on the instance's disks is stored unprotected. This increases the risk of **data exposure** through physical access to decommissioned or stolen storage media, or via certain **hypervisor-level** vulnerabilities, compromising the **confidentiality** of sensitive data.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/local-disk-encryption", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#enable-or-disable-disk-encryption" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Disk encryption must be enabled at instance creation time or by rebuilding the instance\n2. Create a new instance with disk_encryption set to 'enabled'\n3. Migrate data from the unencrypted instance to the new encrypted instance", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `disk encryption` for this instance. Because it cannot be toggled on existing instances, rebuild or migrate to a new instance with encryption enabled.", + "Url": "https://hub.prowler.com/check/compute_instance_disk_encryption_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py new file mode 100644 index 0000000000..abe364d0b4 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_disk_encryption_enabled(Check): + """Check if Linode instances have disk encryption enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_disk_encryption_enabled check. + + Iterates over all Linode instances and checks whether disk encryption + is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.disk_encryption == "enabled": + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has disk encryption enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Instance {instance.label} does not have disk encryption enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json new file mode 100644 index 0000000000..42e3c8f46f --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_watchdog_enabled", + "CheckTitle": "Linode Instance has Watchdog (Lassie) enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **Watchdog (Lassie)** status. Watchdog is Linode's automatic recovery feature that monitors an instance and reboots it if it powers off unexpectedly, helping maintain availability without manual intervention.", + "Risk": "With **Watchdog (Lassie)** disabled, an instance that crashes or shuts down unexpectedly stays offline until it is manually restarted. This prolongs **downtime**, delays detection of availability incidents, and weakens the **availability** of services running on the instance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/recover-from-unexpected-shutdowns-with-lassie" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes update --watchdog_enabled true", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Settings tab\n4. Enable the Shutdown Watchdog (Lassie) toggle", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Watchdog (Lassie)` on this instance to automatically recover from unexpected shutdowns and improve availability protection.", + "Url": "https://hub.prowler.com/check/compute_instance_watchdog_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py new file mode 100644 index 0000000000..498deb970c --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_watchdog_enabled(Check): + """Check if Linode instances have Watchdog (Lassie) enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_watchdog_enabled check. + + Iterates over all Linode instances and checks whether Watchdog + (Lassie) is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.watchdog_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has Watchdog (Lassie) enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have Watchdog (Lassie) enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_service.py b/prowler/providers/linode/services/compute/compute_service.py new file mode 100644 index 0000000000..51d38aa834 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_service.py @@ -0,0 +1,99 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class Instance(BaseModel): + """Model for a Linode Instance.""" + + id: int + label: str + region: str + status: str + backups_enabled: bool = False + disk_encryption: str = "disabled" # "enabled" or "disabled" + watchdog_enabled: bool = False + tags: List[str] = [] + + +class ComputeService(LinodeService): + """Service to interact with Linode Instances.""" + + def __init__(self, provider): + super().__init__("compute", provider) + self.instances: List[Instance] = [] + self._describe_instances() + + def _describe_instances(self): + """Fetch all Linode instances with firewall and IP details.""" + # Optional --region filter. None scans all regions. Region-less services + # do not call this, so they are always scanned. + regions_filter = getattr(getattr(self, "provider", None), "regions", None) + try: + raw_instances = self.client.linode.instances() + for inst in raw_instances: + try: + region = ( + inst.region.id + if hasattr(inst.region, "id") + else str(inst.region) + ) + if regions_filter and region not in regions_filter: + continue + + # Get backup status + backups_enabled = False + try: + backups = getattr(inst, "backups", None) + if backups: + backups_enabled = getattr(backups, "enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch backup status for instance " + f"{inst.id}: {error}" + ) + + # Get disk encryption status + disk_encryption = "disabled" + try: + de = getattr(inst, "disk_encryption", None) + if de: + disk_encryption = str(de) + except Exception as error: + logger.warning( + f"instance - Unable to fetch disk encryption status for " + f"instance {inst.id}: {error}" + ) + + # Get watchdog status + watchdog_enabled = False + try: + watchdog_enabled = getattr(inst, "watchdog_enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch watchdog status for instance " + f"{inst.id}: {error}" + ) + + self.instances.append( + Instance( + id=inst.id, + label=inst.label or f"linode-{inst.id}", + region=region, + status=inst.status or "unknown", + backups_enabled=backups_enabled, + disk_encryption=disk_encryption, + watchdog_enabled=watchdog_enabled, + tags=inst.tags or [], + ) + ) + except Exception as error: + logger.error( + f"instance - Error processing instance {inst.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("instances", "linodes:read_only", error) diff --git a/prowler/providers/linode/services/networking/__init__.py b/prowler/providers/linode/services/networking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_client.py b/prowler/providers/linode/services/networking/networking_client.py new file mode 100644 index 0000000000..0f0406048b --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + +networking_client = NetworkingService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json new file mode 100644 index 0000000000..475601e5b3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_assigned_to_devices", + "CheckTitle": "Linode Cloud Firewall is assigned to devices", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to verify each one is attached to at least one device, such as a **Linode instance** or **NodeBalancer**. A firewall only filters traffic for the resources it is assigned to, so an unassigned firewall enforces no protection at all.", + "Risk": "An **unassigned firewall** provides no protection to running workloads, leaving their network exposure governed only by default behavior. This commonly signals a **misconfigured control** where operators assume traffic is filtered when it is not, raising the risk of **unauthorized network access** to unprotected instances.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/apply-firewall-rules-to-a-service" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click on either the Linodes or Nodebalancers tab and add those devices to the firewall", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach each firewall to applicable Linode devices, such as `Linodes` or `NodeBalancers`, to enforce intended network controls.", + "Url": "https://hub.prowler.com/check/networking_firewall_assigned_to_devices" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_status_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py new file mode 100644 index 0000000000..f245dd168e --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py @@ -0,0 +1,52 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.lib.logger import logger +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_assigned_to_devices(Check): + """Check if Linode Cloud Firewalls are assigned to at least one device.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_assigned_to_devices check. + + Iterates over all Cloud Firewalls and checks whether each one is + assigned to at least one device. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + # When the device count could not be determined (the devices fetch + # failed) skip the firewall instead of reporting a false FAIL. + if fw.attached_devices_count is None: + logger.warning( + f"firewall - Skipping firewall '{fw.label}' ({fw.id}): " + "device assignment could not be determined." + ) + continue + + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.attached_devices_count > 0: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is assigned to {fw.attached_devices_count} device(s)." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not assigned to any device." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json new file mode 100644 index 0000000000..16f0666f7d --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_inbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default inbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default inbound policy** to ingress traffic that does not match any explicit rule. This check verifies the default is set to `DROP`, enforcing a **default-deny** posture so that only traffic intentionally permitted by a rule is allowed in.", + "Risk": "When the default inbound policy is `ACCEPT` instead of `DROP`, any traffic not explicitly denied by a rule is permitted. This **default-allow** posture can silently expose services, management ports, and unintended endpoints to the internet, enlarging the attack surface and increasing the risk of **unauthorized access**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default inbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default inbound policy to `DROP` and allow only explicitly required inbound traffic.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_inbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_inbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py new file mode 100644 index 0000000000..2f5c8cb6c3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_inbound_policy_drop(Check): + """Check if Linode Cloud Firewall default inbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_inbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + inbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.inbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default inbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default inbound policy set to {fw.inbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json new file mode 100644 index 0000000000..5de3985464 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_outbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default outbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default outbound policy** to egress traffic that matches no explicit rule. This check verifies the default is set to `DROP`, enforcing **default-deny egress** so that only approved outbound connections are allowed to leave the instance.", + "Risk": "An outbound default of `ACCEPT` permits unrestricted egress from the instance. If a host is compromised, this eases **data exfiltration** and **command-and-control (C2)** communication and lets malware reach arbitrary external endpoints, harming **confidentiality** and enabling lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default outbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default outbound policy to `DROP` and allow only explicit outbound destinations and services.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_outbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_outbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py new file mode 100644 index 0000000000..011693aee9 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_outbound_policy_drop(Check): + """Check if Linode Cloud Firewall default outbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_outbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + outbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.outbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default outbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default outbound policy set to {fw.outbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json new file mode 100644 index 0000000000..5e54dc6241 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_inbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall inbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **inbound rules** are configured. Defined ingress rules express the intended allow-list of sources, ports, and protocols instead of relying solely on the firewall's default policy.", + "Risk": "Without explicit **inbound rules**, ingress filtering depends entirely on the default policy and no intentional allow-list is documented. This makes the firewall's behavior ambiguous and harder to audit, raising the chance of **overly permissive access** or gaps in the protection operators expect.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Inbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `inbound rules` to define allowed ingress sources, ports, and protocols and enforce expected security boundaries.", + "Url": "https://hub.prowler.com/check/networking_firewall_inbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_inbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py new file mode 100644 index 0000000000..9eba9ddeb6 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_inbound_rules_configured(Check): + """Check if Linode Cloud Firewall has inbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_inbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit inbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.inbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no inbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.inbound_rules)} inbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json new file mode 100644 index 0000000000..9bedf854cc --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_outbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall outbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **outbound rules** are configured. Defined egress rules express which destinations, ports, and protocols an instance is permitted to reach, rather than depending only on the firewall's default policy.", + "Risk": "Without explicit **outbound rules**, egress behavior depends solely on the default policy and no intended destination allow-list exists. Unconstrained or undocumented egress complicates auditing and can enable **data exfiltration** or connections to malicious endpoints if a host is compromised.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Outbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `outbound rules` to control allowed egress destinations, ports, and protocols.", + "Url": "https://hub.prowler.com/check/networking_firewall_outbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_outbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py new file mode 100644 index 0000000000..1d97dca075 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_outbound_rules_configured(Check): + """Check if Linode Cloud Firewall has outbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_outbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit outbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.outbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no outbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.outbound_rules)} outbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json new file mode 100644 index 0000000000..dd1e760d4c --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_status_enabled", + "CheckTitle": "Linode Cloud Firewall status is enabled", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** can be in an `enabled` or `disabled` state. This check verifies the firewall status is `enabled`, because a firewall must be active for its inbound and outbound policies and rules to actually filter network traffic.", + "Risk": "A `disabled` firewall enforces **none** of its configured rules or default policies, leaving attached instances with no network filtering. This can unexpectedly expose management ports and services to the internet, significantly increasing the risk of **unauthorized access** and exploitation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/update-cloud-firewall-status" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Click on the Enable button from the options corresponding to the firewall whose status you would like to update", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Linode Cloud Firewalls` so configured network filtering controls are actively enforced.", + "Url": "https://hub.prowler.com/check/networking_firewall_status_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_assigned_to_devices" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py new file mode 100644 index 0000000000..603a088bd0 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_status_enabled(Check): + """Check if Linode Cloud Firewalls are enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_status_enabled check. + + Iterates over all Cloud Firewalls and checks whether each one has + an enabled status. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.status == "enabled": + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is enabled." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not enabled (status: {fw.status})." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_service.py b/prowler/providers/linode/services/networking/networking_service.py new file mode 100644 index 0000000000..97f811a98a --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_service.py @@ -0,0 +1,127 @@ +from typing import List, Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class FirewallRule(BaseModel): + """Model for a single firewall rule.""" + + protocol: str = "TCP" + ports: str = "" # e.g. "22", "1-65535", "" + addresses_ipv4: List[str] = [] + addresses_ipv6: List[str] = [] + action: str = "ACCEPT" # ACCEPT or DROP + label: str = "" + + +class Firewall(BaseModel): + """Model for a Linode Cloud Firewall.""" + + id: int + label: str + status: str + inbound_rules: List[FirewallRule] = [] + outbound_rules: List[FirewallRule] = [] + inbound_policy: str + outbound_policy: str + # None means the device count could not be determined (fetch failed), as + # opposed to 0 which means the firewall genuinely has no devices attached. + attached_devices_count: Optional[int] = None + tags: List[str] = [] + + +class NetworkingService(LinodeService): + """Service to interact with Linode Cloud Firewalls.""" + + def __init__(self, provider): + super().__init__("networking", provider) + self.firewalls: List[Firewall] = [] + self._describe_firewalls() + + def _describe_firewalls(self): + """Fetch all Linode Cloud Firewalls with their rules.""" + try: + raw_firewalls = self.client.networking.firewalls() + for fw in raw_firewalls: + try: + inbound_rules = [] + outbound_rules = [] + inbound_policy = "" + outbound_policy = "" + attached_devices_count = None + + try: + attached_devices_count = len(fw.devices) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch devices for firewall {fw.id}: {error}" + ) + + try: + # linode_api4 Firewall objects expose rules as a mapped object. + rules = fw.rules + inbound_policy = getattr(rules, "inbound_policy", "") + outbound_policy = getattr(rules, "outbound_policy", "") + inbound = getattr(rules, "inbound", []) + outbound = getattr(rules, "outbound", []) + + for rule in inbound: + addresses = getattr(rule, "addresses", None) + inbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + for rule in outbound: + addresses = getattr(rule, "addresses", None) + outbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch rules for firewall {fw.id}: {error}" + ) + + self.firewalls.append( + Firewall( + id=fw.id, + label=fw.label or f"firewall-{fw.id}", + status=fw.status or "unknown", + inbound_rules=inbound_rules, + outbound_rules=outbound_rules, + inbound_policy=inbound_policy, + outbound_policy=outbound_policy, + attached_devices_count=attached_devices_count, + tags=fw.tags or [], + ) + ) + except Exception as error: + logger.error( + f"firewall - Error processing firewall {fw.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("firewalls", "firewall:read_only", error) diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 9cc7207f20..4dae094d90 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -104,8 +104,11 @@ class M365PowerShell(PowerShellSession): authentication information. Note: - The credentials are sanitized to prevent command injection and - stored securely in the PowerShell session. + ``client_id`` and ``tenant_id`` are sanitized via ``sanitize()`` since + they are UUIDs. ``client_secret`` is assigned with a single-quoted + string (escaping any embedded single quote as ``''``) following + PowerShell best practices for literal values, so its content is taken + verbatim with no variable expansion or subexpression evaluation. """ # Certificate Auth if credentials.certificate_content and credentials.client_id: @@ -135,9 +138,16 @@ class M365PowerShell(PowerShellSession): else: # Application Auth - self.execute(f'$clientID = "{credentials.client_id}"') - self.execute(f'$clientSecret = "{credentials.client_secret}"') - self.execute(f'$tenantID = "{credentials.tenant_id}"') + sanitized_client_id = self.sanitize(credentials.client_id) + sanitized_tenant_id = self.sanitize(credentials.tenant_id) + self.execute(f"$clientID = '{sanitized_client_id}'") + # Single-quoted strings are the PowerShell convention for literals: + # the content is taken verbatim with no variable expansion. Escape any + # embedded single quote as '' and do not sanitize() so the value is + # preserved exactly. + sanitized_secret = (credentials.client_secret or "").replace("'", "''") + self.execute(f"$clientSecret = '{sanitized_secret}'") + self.execute(f"$tenantID = '{sanitized_tenant_id}'") self.execute( '$graphtokenBody = @{ Grant_Type = "client_credentials"; Scope = "https://graph.microsoft.com/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }' ) @@ -196,7 +206,7 @@ class M365PowerShell(PowerShellSession): """Test Exchange Online API connection and raise exception if it fails.""" try: self.execute( - '$SecureSecret = ConvertTo-SecureString "$clientSecret" -AsPlainText -Force' + "$SecureSecret = ConvertTo-SecureString $clientSecret -AsPlainText -Force" ) self.execute( '$exchangeToken = Get-MsalToken -clientID "$clientID" -tenantID "$tenantID" -clientSecret $SecureSecret -Scopes "https://outlook.office365.com/.default"' @@ -940,6 +950,32 @@ class M365PowerShell(PowerShellSession): "Get-TeamsProtectionPolicy | ConvertTo-Json -Depth 10", json_parse=True ) + def get_mailboxes(self) -> dict: + """ + Get Exchange Online Recipient-Facing Mailboxes. + + Retrieves all recipient-facing mailboxes from Exchange Online with the + properties needed to evaluate primary SMTP domain policy. + + Returns: + dict: Mailbox information in JSON format. + + Example: + >>> get_mailboxes() + [ + { + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox" + } + ] + """ + return self.execute( + "Get-EXOMailbox -ResultSize Unlimited | Select-Object Identity, DisplayName, PrimarySmtpAddress, RecipientTypeDetails | ConvertTo-Json -Depth 10", + json_parse=True, + ) + def get_shared_mailboxes(self) -> dict: """ Get Exchange Online Shared Mailboxes. diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index 0e3a6c2b26..040e390658 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -99,6 +99,7 @@ class M365Provider(Provider): """ _type: str = "m365" + sdk_only: bool = False _session: DefaultAzureCredential # Must be used besides being named for Azure _identity: M365IdentityInfo _audit_config: dict @@ -1073,7 +1074,7 @@ class M365Provider(Provider): organization_info = await client.organization.get() identity.tenant_id = organization_info.value[0].id - asyncio.get_event_loop().run_until_complete(get_m365_identity(identity)) + asyncio.run(get_m365_identity(identity)) return identity @staticmethod @@ -1261,9 +1262,7 @@ class M365Provider(Provider): result = await client.domains.get() return result.value - result = asyncio.get_event_loop().run_until_complete( - verify_certificate() - ) + result = asyncio.run(verify_certificate()) if not result: raise M365NotValidCertificateContentError( file=os.path.basename(__file__), @@ -1284,9 +1283,7 @@ class M365Provider(Provider): result = await client.domains.get() return result.value - result = asyncio.get_event_loop().run_until_complete( - verify_certificate() - ) + result = asyncio.run(verify_certificate()) if not result: raise M365NotValidCertificatePathError( file=os.path.basename(__file__), 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..3899dced67 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -175,16 +175,23 @@ class AdminCenter(M365Service): try: groups_list = await self.client.groups.get() groups.update({}) - for group in groups_list.value: - groups.update( - { - group.id: Group( - id=group.id, - name=getattr(group, "display_name", ""), - visibility=getattr(group, "visibility", ""), - ) - } - ) + while groups_list: + for group in getattr(groups_list, "value", []) or []: + groups.update( + { + group.id: Group( + id=group.id, + name=getattr(group, "display_name", ""), + visibility=getattr(group, "visibility", ""), + group_types=getattr(group, "group_types", []) or [], + ) + } + ) + + next_link = getattr(groups_list, "odata_next_link", None) + if not next_link: + break + groups_list = await self.client.groups.with_url(next_link).get() except Exception as error: logger.error( @@ -237,6 +244,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_app_registration_client_secret_unused/__init__.py b/prowler/providers/m365/services/entra/entra_app_registration_client_secret_unused/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused.metadata.json b/prowler/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused.metadata.json new file mode 100644 index 0000000000..56e2b0ec5f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_app_registration_client_secret_unused", + "CheckTitle": "Application registrations should not use client secrets", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **application registrations** should not have any **secret credentials** configured. This check audits the current state of every application registration and reports those that hold one or more **secrets**. Both **expired** and **active** secrets are reported, since expired entries are credential clutter that should be cleaned up.", + "Risk": "**Secrets** are bearer credentials that are easy to leak (committed to repositories, copied into CI variables, shared via chat). Once leaked, a **secret** can be used from anywhere on the internet until it is rotated. Both **expired** and **active** secrets increase the attack surface and represent credential clutter.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials", + "https://learn.microsoft.com/en-us/graph/api/resources/passwordcredential?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation", + "https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Applications > App registrations\n3. Select the flagged application\n4. Go to Certificates & secrets\n5. Delete all client secrets under the 'Client secrets' tab\n6. Add a certificate or configure federated identity credentials instead", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove every **secret** from the affected **application registrations** so they no longer rely on long-lived shared secrets. Enable the **default app management policy** to block new secrets from being added.", + "Url": "https://hub.prowler.com/check/entra_app_registration_client_secret_unused" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_default_app_management_policy_enabled" + ], + "Notes": "This check audits the current state of password credentials on app registrations. The related check entra_default_app_management_policy_enabled audits the tenant-wide policy that prevents new secrets from being added. Both expired and active password credentials trigger a FAIL." +} diff --git a/prowler/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused.py b/prowler/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused.py new file mode 100644 index 0000000000..a759eda75a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused.py @@ -0,0 +1,58 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_app_registration_client_secret_unused(Check): + """ + Ensure that application registrations do not use password credentials (client secrets). + + This check evaluates application registrations in Microsoft Entra ID to identify + those with password credentials (client secrets). Applications should authenticate + using certificates, federated identity credentials, or managed identities instead. + Both expired and active password credentials are flagged, since expired entries are + credential clutter that should be cleaned up. + + - PASS: The application has no password credentials. + - FAIL: The application has one or more password credentials that should be removed. + """ + + def execute(self) -> list[CheckReportM365]: + findings = [] + + for app_id, app in entra_client.app_registrations.items(): + report = CheckReportM365( + metadata=self.metadata(), + resource=app, + resource_name=app.name or app.app_id, + resource_id=app_id, + ) + + num_secrets = len(app.password_credentials) + if num_secrets > 0: + report.status = "FAIL" + secret_details = [] + for cred in app.password_credentials: + detail = cred.display_name or cred.key_id + if cred.end_date_time: + detail += f" (expires: {cred.end_date_time})" + secret_details.append(detail) + + if num_secrets > 5: + displayed = ", ".join(secret_details[:5]) + displayed += f" (and {num_secrets - 5} more)" + else: + displayed = ", ".join(secret_details) + + report.status_extended = ( + f"App registration {app.name} has {num_secrets} " + f"password credential(s) (client secrets): {displayed}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"App registration {app.name} does not use password credentials." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py index de973e9ffa..e90eed0023 100644 --- a/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py +++ b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py @@ -85,6 +85,15 @@ class entra_break_glass_account_fido2_security_key_registered(Check): resource_id=user.id, ) + if entra_client.user_registration_details_error: + report.status = "FAIL" + report.status_extended = ( + f"Cannot verify FIDO2 security key registration for break glass account {user.name}: " + f"{entra_client.user_registration_details_error}." + ) + findings.append(report) + continue + auth_methods = set(user.authentication_methods) has_fido2 = "fido2SecurityKey" in auth_methods has_passkey_device_bound = "passKeyDeviceBound" in auth_methods diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users.metadata.json new file mode 100644 index 0000000000..3df70aea46 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_all_apps_all_users", + "CheckTitle": "Conditional Access policy covers all cloud apps and all users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Helper check that verifies whether **Microsoft Entra Conditional Access** includes at least one **enabled** policy scoped to **all cloud applications** and **all users**. Policies that only require a **password change** and **report-only** policies are excluded from passing. The result is intended to support other, more specific Conditional Access checks rather than enforce a baseline on its own.", + "Risk": "Without a Conditional Access policy that targets **all cloud apps** and **all users**, newly added applications or user accounts may not be covered by existing access controls until policies are explicitly updated.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/plan-conditional-access", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **Include** > **All users**. Exclude break-glass accounts as needed.\n5. Under **Target resources**, select **Include** > **All cloud apps**.\n6. Under **Grant**, select the desired access controls.\n7. Set the policy to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure there is at least one **enabled** Conditional Access policy that targets **all cloud apps** and **all users** so that other Conditional Access checks have broad coverage to evaluate. Exclude only **break-glass accounts**.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_all_apps_all_users" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Generic helper check intended to support other Conditional Access checks. Conditional Access policies require Microsoft Entra ID P1 or P2 licenses." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users.py new file mode 100644 index 0000000000..ecf0968066 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users.py @@ -0,0 +1,121 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, +) + + +class entra_conditional_access_policy_all_apps_all_users(Check): + """Check if at least one Conditional Access policy covers all cloud apps and all users. + + This check verifies that at least one enabled Conditional Access policy + targets all cloud applications and all users, ensuring baseline protection + across the entire tenant. Policies that only require a password change are + excluded because they do not provide meaningful access control. + + - PASS: An enabled Conditional Access policy covers all apps and all users with no exclusions. + - MANUAL: A policy targets all apps and all users but includes exclusions that require review. + - FAIL: No Conditional Access policy provides coverage for all apps and all users. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check to verify Conditional Access coverage for all apps and all users. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy covers all cloud apps and all users." + ) + + manual_policy = None + reporting_only_policy = None + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + application_conditions = policy.conditions.application_conditions + user_conditions = policy.conditions.user_conditions + if not application_conditions or not user_conditions: + continue + + if "All" not in application_conditions.included_applications: + continue + + if "All" not in user_conditions.included_users: + continue + + # Exclude policies that only require a password change, + # as they do not provide meaningful access control. + if policy.grant_controls.built_in_controls == [ + ConditionalAccessGrantControl.PASSWORD_CHANGE + ]: + continue + + has_exclusions = bool( + application_conditions.excluded_applications + or user_conditions.excluded_users + or user_conditions.excluded_groups + or user_conditions.excluded_roles + ) + + if has_exclusions: + if manual_policy is None: + manual_policy = policy + continue + + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + if reporting_only_policy is None: + reporting_only_policy = policy + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} covers all cloud apps and all users." + findings.append(report) + return findings + + if manual_policy is not None: + report = CheckReportM365( + metadata=self.metadata(), + resource=manual_policy, + resource_name=manual_policy.display_name, + resource_id=manual_policy.id, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Conditional Access Policy {manual_policy.display_name} " + "targets all cloud apps and all users but includes exclusions. " + "Review excluded users/groups/roles/applications and verify " + "compensating policies protect excluded identities and apps." + ) + elif reporting_only_policy is not None: + report = CheckReportM365( + metadata=self.metadata(), + resource=reporting_only_policy, + resource_name=reporting_only_policy.display_name, + resource_id=reporting_only_policy.id, + ) + report.status = "FAIL" + report.status_extended = ( + f"Conditional Access Policy {reporting_only_policy.display_name} " + "covers all cloud apps and all users but is only in report-only mode." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.py index dd865064c2..e9560f01ee 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.py +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.py @@ -23,13 +23,6 @@ class entra_conditional_access_policy_approved_client_app_required_for_mobile(Ch ConditionalAccessGrantControl.COMPLIANT_APPLICATION, } - @staticmethod - def _normalize_platform(platform: object) -> str: - normalized_platform = getattr(platform, "value", platform) - return ( - normalized_platform.lower() if isinstance(normalized_platform, str) else "" - ) - def execute(self) -> list[CheckReportM365]: """Execute the check logic. @@ -54,22 +47,12 @@ class entra_conditional_access_policy_approved_client_app_required_for_mobile(Ch if not policy.conditions.platform_conditions: continue - included_platforms = { - normalized_platform - for normalized_platform in map( - self._normalize_platform, - policy.conditions.platform_conditions.include_platforms, - ) - if normalized_platform - } - excluded_platforms = { - normalized_platform - for normalized_platform in map( - self._normalize_platform, - policy.conditions.platform_conditions.exclude_platforms, - ) - if normalized_platform - } + included_platforms = set( + policy.conditions.platform_conditions.include_platforms + ) + excluded_platforms = set( + policy.conditions.platform_conditions.exclude_platforms + ) targets_mobile_platforms = ( "all" in included_platforms @@ -102,10 +85,10 @@ class entra_conditional_access_policy_approved_client_app_required_for_mobile(Ch ) if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: report.status = "FAIL" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports the requirement of approved client apps or app protection for mobile devices but does not enforce it." + report.status_extended = f"Conditional Access Policy {policy.display_name} reports the requirement of approved client apps or app protection for mobile devices but does not enforce it." else: report.status = "PASS" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' requires approved client apps or app protection for mobile devices." + report.status_extended = f"Conditional Access Policy {policy.display_name} requires approved client apps or app protection for mobile devices." break findings.append(report) diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms.metadata.json new file mode 100644 index 0000000000..e4b06e2bce --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_block_unknown_device_platforms", + "CheckTitle": "Conditional Access policy blocks access from unknown or unsupported device platforms", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** can block sign-ins from device platforms that the organization does not recognize or support. A policy that includes **all** platforms and excludes the known ones (Android, iOS, Windows, macOS, Linux) effectively blocks any **unknown or unsupported** platform, reducing the attack surface from unmanaged or unexpected devices.", + "Risk": "Without blocking unknown device platforms, attackers may authenticate from **spoofed or uncommon operating systems** that bypass platform-specific security controls. This increases the risk of **unauthorized access** and makes it harder to enforce device compliance, potentially leading to **credential theft** or **data exfiltration** from unmanaged environments.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-policy-unknown-unsupported-device", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions#device-platforms" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Select **New policy**.\n4. Under **Users**, include the desired scope. Microsoft recommends **All users** with appropriate exclusions.\n5. Under **Target resources**, include the applications or resources you want to protect. Microsoft recommends **All resources / All cloud apps**.\n6. Under **Conditions** > **Device platforms**, set **Configure** to **Yes**.\n7. Under **Include**, select **Any device**.\n8. Under **Exclude**, select **Android**, **iOS**, **Windows**, **macOS**, and **Linux**.\n9. Under **Grant**, select **Block access**.\n10. Set **Enable policy** to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a Conditional Access policy that includes **all device platforms** and excludes the known ones (Android, iOS, Windows, macOS, Linux), then set the grant control to **Block access**. This ensures that only recognized platforms can authenticate, following a **zero-trust** approach to device management.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_block_unknown_device_platforms" + } + }, + "Categories": [ + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The policy must include all platforms and exclude the five known platforms (Android, iOS, Windows, macOS, Linux) so that only unknown or unsupported platforms are blocked. The check requires the policy to be fully enabled; report-only policies are treated as non-compliant. Microsoft recommends applying this policy broadly to **All users** and **All resources / All cloud apps**, but that broader scope is guidance rather than a requirement enforced by this check." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms.py new file mode 100644 index 0000000000..b53abb56b0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms.py @@ -0,0 +1,86 @@ +"""Check for Conditional Access policy blocking unknown or unsupported device platforms.""" + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, +) + + +class entra_conditional_access_policy_block_unknown_device_platforms(Check): + """Ensure a Conditional Access policy blocks access from unknown or unsupported device platforms. + + This check verifies that at least one enabled Conditional Access policy + blocks access when the device platform is unknown or unsupported. The + recommended configuration includes all device platforms and excludes the + known platforms (Android, iOS, Windows, macOS, Linux), so only + unrecognised platforms are blocked. + + - PASS: An enabled policy blocks access from unknown or unsupported device platforms. + - FAIL: No policy blocks access from unknown or unsupported device platforms. + """ + + KNOWN_PLATFORMS = {"android", "ios", "windows", "macos", "linux"} + + def execute(self) -> list[CheckReportM365]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy blocks access from unknown or unsupported device platforms." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not policy.conditions.platform_conditions: + continue + + if "all" not in policy.conditions.platform_conditions.include_platforms: + continue + + if not self.KNOWN_PLATFORMS.issubset( + set(policy.conditions.platform_conditions.exclude_platforms) + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + not in policy.grant_controls.built_in_controls + ): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = ( + f"Conditional Access Policy {policy.display_name} reports " + "blocking unknown or unsupported device platforms but does not enforce it." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Conditional Access Policy {policy.display_name} blocks " + "access from unknown or unsupported device platforms." + ) + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.metadata.json new file mode 100644 index 0000000000..8e9b110a89 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", + "CheckTitle": "Conditional Access policy enforces sign-in frequency for non-corporate devices", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** policy with **sign-in frequency** controls how often users must re-authenticate when accessing resources from non-corporate devices.\n\nThis ensures that sessions on unmanaged devices are time-limited, reducing the window of opportunity for unauthorized access through stale sessions.", + "Risk": "Without sign-in frequency enforcement on non-corporate devices, user sessions may persist indefinitely on unmanaged devices.\n\n- **Session hijacking** on shared or public devices becomes more likely\n- **Stolen session tokens** remain valid for extended periods\n- **Unauthorized access** from compromised personal devices", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **All users**.\n5. Under **Target resources**, select **All cloud apps**.\n6. Under **Conditions** > **Filter for devices**, select **Include filtered devices**.\n7. Set the rule to `device.isCompliant -eq False`.\n8. Under **Session**, enable **Sign-in frequency** and set it to **1 hour**.\n9. Set the policy to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a Conditional Access policy to enforce **sign-in frequency** on non-corporate devices. Use device filters to target unmanaged endpoints and set a time-based re-authentication interval to limit session duration.\n\nThis aligns with **zero trust** principles by ensuring sessions on untrusted devices are regularly validated.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced" + } + }, + "Categories": [ + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_app_enforced_restrictions" + ], + "Notes": "A qualifying policy must target all users, all applications, enforce time-based sign-in frequency, and include a device filter targeting non-corporate devices." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.py new file mode 100644 index 0000000000..360901569a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.py @@ -0,0 +1,109 @@ +import re + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicyState, + DeviceFilterMode, + SignInFrequencyInterval, +) + + +class entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced( + Check +): + """Check if at least one Conditional Access policy enforces sign-in frequency for non-corporate devices. + + This check verifies that the tenant has at least one enabled Conditional Access policy + that enforces time-based sign-in frequency targeting all users and all applications, + with a device filter scoping the policy to non-corporate (unmanaged) devices. + + - PASS: At least one enabled policy enforces sign-in frequency with a device filter + targeting non-corporate devices, for all users and all applications. + - FAIL: No enabled policy meets the sign-in frequency enforcement criteria for + non-corporate devices. + """ + + NON_CORPORATE_INCLUDE_PATTERNS = ( + r"device\.iscompliant\s*-ne\s*true", + r"device\.iscompliant\s*-eq\s*false", + r'device\.trusttype\s*-ne\s*"serverad"', + r"device\.trusttype\s*-ne\s*'serverad'", + ) + CORPORATE_EXCLUDE_PATTERNS = ( + r"device\.iscompliant\s*-eq\s*true", + r'device\.trusttype\s*-eq\s*"serverad"', + r"device\.trusttype\s*-eq\s*'serverad'", + ) + + def execute(self) -> list[CheckReportM365]: + """Execute the check for sign-in frequency enforcement in Conditional Access policies. + + Returns: + list[CheckReportM365]: A list containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy enforces sign-in frequency for non-corporate devices." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + sign_in_freq = policy.session_controls.sign_in_frequency + if not ( + sign_in_freq.is_enabled + and sign_in_freq.interval == SignInFrequencyInterval.TIME_BASED + ): + continue + + device_conditions = policy.conditions.device_conditions + if ( + not device_conditions + or not device_conditions.device_filter_mode + or not device_conditions.device_filter_rule + ): + continue + + rule = device_conditions.device_filter_rule.lower() + if device_conditions.device_filter_mode == DeviceFilterMode.INCLUDE: + patterns = self.NON_CORPORATE_INCLUDE_PATTERNS + elif device_conditions.device_filter_mode == DeviceFilterMode.EXCLUDE: + patterns = self.CORPORATE_EXCLUDE_PATTERNS + else: + continue + + if not any(re.search(pattern, rule) for pattern in patterns): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy {policy.display_name} reports sign-in frequency for non-corporate devices but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} enforces sign-in frequency for non-corporate devices." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.metadata.json new file mode 100644 index 0000000000..b7266f1f56 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_directory_sync_account_excluded", + "CheckTitle": "Conditional Access policy excludes Directory Synchronization Accounts to protect Entra Connect sync", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Conditional Access policies scoped to **all users** and **all cloud applications** are evaluated to confirm the **Directory Synchronization Accounts** role is explicitly excluded. The Microsoft Entra Connect Sync Account does not support multifactor authentication, so it must be excluded from restrictive policies to maintain directory synchronization.", + "Risk": "If the Directory Synchronization Accounts role is not excluded from Conditional Access policies requiring MFA or blocking access, the Entra Connect Sync Account will be unable to authenticate. This breaks hybrid identity synchronization between on-premises Active Directory and Entra ID, potentially causing authentication failures and identity inconsistencies.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa", + "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/reference-connect-accounts-permissions" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Open each policy that targets **All users** and **All cloud apps**.\n4. Under **Users** > **Exclude**, select **Directory roles** and add the **Directory Synchronization Accounts** role.\n5. Save the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Exclude the Directory Synchronization Accounts role from all Conditional Access policies that target all users and all cloud applications. This prevents breaking Entra Connect directory synchronization while maintaining security controls for interactive users.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_directory_sync_account_excluded" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_require_mfa_for_management_api" + ], + "Notes": "The Directory Synchronization Accounts role template ID is d29b2b05-8046-44ba-8758-1e26182fcf32." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.py new file mode 100644 index 0000000000..ee37e57121 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.py @@ -0,0 +1,91 @@ +"""Check if Conditional Access policies exclude the Directory Synchronization Account.""" + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicyState, +) + +# The Directory Synchronization Accounts built-in role template ID in Entra ID. +# This role is assigned to the Microsoft Entra Connect Sync service account and +# does not support multifactor authentication. +DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32" + + +class entra_conditional_access_policy_directory_sync_account_excluded(Check): + """Check that Conditional Access policies exclude the Directory Synchronization Account. + + The Microsoft Entra Connect Sync Account cannot support MFA. Conditional + Access policies scoped to all users and all cloud apps must explicitly + exclude the Directory Synchronization Accounts role to prevent breaking + directory synchronization. + + - PASS: The policy excludes the Directory Synchronization Accounts role. + - FAIL: The policy does not exclude the Directory Synchronization Accounts role. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check for Directory Sync Account exclusion from Conditional Access policies. + + Iterates through all enabled Conditional Access policies that target + all users and all cloud applications, verifying each one excludes the + Directory Synchronization Accounts role. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not policy.conditions.user_conditions: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if not policy.conditions.application_conditions: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + + if ( + DIRECTORY_SYNC_ROLE_TEMPLATE_ID + in policy.conditions.user_conditions.excluded_roles + ): + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} excludes the Directory Synchronization Accounts role." + else: + report.status = "FAIL" + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status_extended = f"Conditional Access Policy {policy.display_name} reports excluding the Directory Synchronization Accounts role but does not enforce it." + else: + report.status_extended = f"Conditional Access Policy {policy.display_name} does not exclude the Directory Synchronization Accounts role, which may break Entra Connect sync." + + findings.append(report) + + if not findings: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "PASS" + report.status_extended = "No Conditional Access Policy targets all users and all cloud apps, so no Directory Synchronization Accounts exclusion is needed." + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json new file mode 100644 index 0000000000..c352854003 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_explicitly_targets_azure_devops", + "CheckTitle": "Conditional Access Policy explicitly targets Azure DevOps", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **enabled** policy that explicitly includes the **Azure DevOps** cloud application. Policies targeting **All** cloud apps do not satisfy this check because the goal is to verify that Azure DevOps has been deliberately considered.", + "Risk": "Without an explicit Conditional Access policy for Azure DevOps, organizations may rely on broad policies that do not account for Azure DevOps-specific access patterns such as CLI, IDE plug-ins, PAT-based workflows, source code access, build pipelines, secrets, and service connections.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessapplications?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/manage-conditional-access", + "https://learn.microsoft.com/en-us/troubleshoot/entra/entra-id/governance/verify-first-party-apps-sign-in" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com).\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Create or edit a policy for Azure DevOps.\n4. Under **Target resources**, select **Include** > **Select apps** and choose **Azure DevOps**.\n5. Configure the required grant or session controls for your organization.\n6. Set the policy to **Report-only** until validated, then enable it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create and enable a Conditional Access policy that explicitly includes the Azure DevOps cloud application, then configure the appropriate access controls for your organization.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_explicitly_targets_azure_devops" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_all_apps_all_users" + ], + "Notes": "Azure DevOps Services uses appId 499b84ac-1321-427f-aa17-267ca6975798." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.py new file mode 100644 index 0000000000..f1e874fc91 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.py @@ -0,0 +1,57 @@ +"""Check if a Conditional Access policy explicitly targets Azure DevOps.""" + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicyState, +) + +AZURE_DEVOPS_APP_ID = "499b84ac-1321-427f-aa17-267ca6975798" + + +class entra_conditional_access_policy_explicitly_targets_azure_devops(Check): + """Check that an enabled Conditional Access policy explicitly targets Azure DevOps.""" + + def execute(self) -> list[CheckReportM365]: + """Execute the check for explicit Azure DevOps targeting. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + for policy in entra_client.conditional_access_policies.values(): + if policy.state != ConditionalAccessPolicyState.ENABLED: + continue + + if not policy.conditions.application_conditions: + continue + + if ( + AZURE_DEVOPS_APP_ID + not in policy.conditions.application_conditions.included_applications + ): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} explicitly targets Azure DevOps." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.metadata.json new file mode 100644 index 0000000000..f99c94b7a7 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_mfa_enforced_for_guest_users", + "CheckTitle": "Conditional Access Policy enforces MFA for guest and external users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **enabled** policy that requires **multifactor authentication** for all **guest and external user types** across all cloud applications. This includes internal guests, B2B collaboration guests and members, B2B direct connect users, other external users, and service providers.", + "Risk": "Without MFA for guest users, compromised external accounts can access tenant resources using only a password. Attackers may exploit **B2B collaboration**, **direct connect**, or **service provider** accounts to exfiltrate data, escalate privileges, or move laterally across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-alt-require-mfa-guest-access" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com).\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **Include** > **Select users and groups** > check **Guest or external users** > select all guest user types.\n5. Under **Target resources**, select **Include** > **All cloud apps**.\n6. Under **Grant**, select **Grant access** > check **Require multifactor authentication** > click **Select**.\n7. Set the policy to **Report-only** until validated, then enable it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce **MFA** via **Conditional Access** for all **guest and external user types** across all cloud applications. Prefer **phishing-resistant** methods, monitor guest sign-ins, and regularly review external collaboration settings.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_mfa_enforced_for_guest_users" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_dynamic_group_for_guests_created" + ], + "Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.py new file mode 100644 index 0000000000..7d27741df9 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.py @@ -0,0 +1,143 @@ +"""Check if at least one Conditional Access policy requires MFA for guest users.""" + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ALL_GUEST_USER_TYPES, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + ExternalTenantsMembershipKind, +) + + +class entra_conditional_access_policy_mfa_enforced_for_guest_users(Check): + """Check if at least one enabled Conditional Access policy requires MFA for guest users. + + This check verifies that the Microsoft Entra tenant has at least one + enabled Conditional Access policy that requires multifactor authentication + (MFA) for all guest and external user types across all cloud applications. + + - PASS: At least one enabled CA policy requires MFA for all guest user types. + - FAIL: No enabled CA policy enforces MFA for guest users. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy requires MFA for guest users." + ) + + reporting_policy = None + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + # Policy must require MFA (built-in control or authentication strength) + # and must not only require password change. + has_mfa = ( + ConditionalAccessGrantControl.MFA + in policy.grant_controls.built_in_controls + ) + has_auth_strength = ( + policy.grant_controls.authentication_strength is not None + ) + only_password_change = policy.grant_controls.built_in_controls == [ + ConditionalAccessGrantControl.PASSWORD_CHANGE + ] + + if not (has_mfa or has_auth_strength) or only_password_change: + continue + + # Policy must target all cloud applications. + if not policy.conditions.application_conditions: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + # Policy must target guest users: either include all users, or + # specifically include all guest/external user types. + targets_all_users = ( + "All" in policy.conditions.user_conditions.included_users + ) + targets_guests_via_include = ( + "GuestsOrExternalUsers" + in policy.conditions.user_conditions.included_users + ) + excludes_all_guests = ( + "GuestsOrExternalUsers" + in policy.conditions.user_conditions.excluded_users + ) + + included_guests = ( + policy.conditions.user_conditions.included_guests_or_external_users + ) + targets_all_guest_types = included_guests is not None and ( + ALL_GUEST_USER_TYPES + <= set(included_guests.guest_or_external_user_types) + and included_guests.external_tenants_membership_kind + in (None, ExternalTenantsMembershipKind.ALL) + ) + + if not ( + targets_all_users + or targets_guests_via_include + or targets_all_guest_types + ): + continue + + # Policy must not exclude guest/external user types. + excluded_guests = ( + policy.conditions.user_conditions.excluded_guests_or_external_users + ) + if excludes_all_guests or ( + excluded_guests is not None + and excluded_guests.guest_or_external_user_types + ): + continue + + if policy.state == ConditionalAccessPolicyState.ENABLED: + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} requires MFA for guest users." + break + + if ( + policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING + and reporting_policy is None + ): + reporting_policy = policy + + if report.status == "FAIL" and reporting_policy: + report = CheckReportM365( + metadata=self.metadata(), + resource=reporting_policy, + resource_name=reporting_policy.display_name, + resource_id=reporting_policy.id, + ) + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy {reporting_policy.display_name} targets guest users with MFA but is only in report-only mode." + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json new file mode 100644 index 0000000000..42b3acaeb6 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_no_deleted_object_references", + "CheckTitle": "Conditional Access policies must not reference deleted users, groups, or roles", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every object identifier referenced by any Conditional Access policy under conditions.users (includeUsers, excludeUsers, includeGroups, excludeGroups, includeRoles, excludeRoles) must resolve to an existing Microsoft Entra object. This check audits all Conditional Access policies regardless of state and reports any whose user, group, or role references no longer resolve in the directory.", + "Risk": "When a user, group, or directory role referenced by a Conditional Access policy stops resolving (account or group deleted, role template removed), the reference becomes orphaned. include* references silently shrink the policy's enforcement scope; exclude* references can cause the policy to evaluate unexpectedly. This is a common root cause of MFA-not-applied incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/group-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/unifiedroledefinition-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Navigate to Protection > Conditional Access > Policies\n3. Open each policy reported by this check\n4. Under Assignments > Users, remove every user, group, or role identifier reported as deleted\n5. Save the policy and re-run the audit\n6. For ongoing hygiene, audit Conditional Access policies after any bulk user/group/role cleanup", + "Terraform": "" + }, + "Recommendation": { + "Text": "Audit each Conditional Access policy quarterly and remove references to deleted users, groups, or directory roles. Stale references in include collections silently shrink enforcement scope; stale references in exclude collections can cause policies to behave unexpectedly. Treat both as misconfigurations regardless of policy state.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_no_deleted_object_references" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_directory_sync_account_excluded" + ], + "Notes": "The check runs against all Conditional Access policies regardless of state (enabled, disabled, enabledForReportingButNotEnforced) — stale references in disabled policies are a misconfiguration that becomes live the moment the policy is re-enabled. Only HTTP 404 responses flag an identifier as deleted (FAIL). Transient Graph errors (5xx, throttling, insufficient permissions) are not treated as deletions; a policy whose references could not be resolved for those reasons is reported as MANUAL so it is not silently considered clean." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py new file mode 100644 index 0000000000..e5c5eff389 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py @@ -0,0 +1,157 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + CONDITIONAL_ACCESS_SENTINEL_IDS, + ConditionalAccessPolicyState, +) + + +class entra_conditional_access_policy_no_deleted_object_references(Check): + """ + Ensure Conditional Access policies do not reference deleted directory objects. + + Stale references to deleted users, groups, or directory roles silently change + the runtime behavior of a Conditional Access policy: include* references + shrink enforcement scope, exclude* references can change exemption logic. + Either way, the policy stops behaving the way the operator believes it does. + + The directory-object existence check runs once at service init time and is + cached on the entra client. This check reads from that cache and reports any + policy whose users/groups/roles inclusion or exclusion collections name an + identifier that no longer resolves in Microsoft Entra ID. + + Identifiers whose Graph lookup failed with a non-404 error (5xx, throttling, + insufficient permissions) are cached separately: they could not be verified + as present or deleted, so the policy is reported as MANUAL rather than being + silently treated as clean. + + - PASS: The policy references no deleted users, groups, or roles. + - FAIL: The policy references at least one deleted user, group, or role. + - MANUAL: At least one referenced identifier could not be resolved due to a + transient Graph error, so the policy could not be fully evaluated. + """ + + def execute(self) -> list[CheckReportM365]: + findings = [] + unresolved = entra_client.unresolved_directory_object_references + errored = entra_client.errored_directory_object_references + + for policy_id, policy in entra_client.conditional_access_policies.items(): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy_id, + ) + + orphans = self._collect_references_in(policy, unresolved) + unverified = self._collect_references_in(policy, errored) + + if orphans: + # A confirmed deletion takes precedence over unverified ones. + report.status = "FAIL" + report.status_extended = self._format_failure( + policy.display_name, orphans, unverified, policy.state + ) + elif unverified: + # Nothing confirmed deleted, but we could not verify every + # reference — do not claim the policy is clean. + report.status = "MANUAL" + report.status_extended = self._format_manual( + policy.display_name, unverified + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Conditional Access policy {policy.display_name} references no " + f"deleted directory objects." + ) + + findings.append(report) + + return findings + + @staticmethod + def _collect_references_in(policy, id_set): + """Walk the six identifier collections and return references in ``id_set``. + + Args: + policy: The Conditional Access policy to inspect. + id_set: Set of ``(type, id)`` pairs to match references against. + + Returns: + list[tuple[str, str, str]]: ``(type, id, side)`` tuples where + ``type`` is one of ``user|group|role``, ``id`` is the Graph + identifier, and ``side`` is one of ``include|exclude``. + """ + if not policy.conditions or not policy.conditions.user_conditions: + return [] + + uc = policy.conditions.user_conditions + collections = ( + ("user", "include", uc.included_users), + ("user", "exclude", uc.excluded_users), + ("group", "include", uc.included_groups), + ("group", "exclude", uc.excluded_groups), + ("role", "include", uc.included_roles), + ("role", "exclude", uc.excluded_roles), + ) + + matches = [] + for type_, side, identifiers in collections: + for identifier in identifiers: + if identifier in CONDITIONAL_ACCESS_SENTINEL_IDS: + continue + if (type_, identifier) in id_set: + matches.append((type_, identifier, side)) + return matches + + @staticmethod + def _group_by_type(references): + """Group ``(type, id, side)`` references into a deterministic message part.""" + by_type = {"user": [], "group": [], "role": []} + for type_, identifier, side in references: + by_type[type_].append(f"{identifier} ({side})") + + parts = [] + for type_ in ("user", "group", "role"): + if by_type[type_]: + joined = ", ".join(sorted(by_type[type_])) + parts.append(f"{type_}s: {joined}") + return "; ".join(parts) + + @classmethod + def _format_failure(cls, display_name, orphans, unverified, state=None): + # Surface report-only mode explicitly: the stale references are not yet + # enforced, but become live the moment the policy is turned on. + report_only = ( + " The policy is in report-only mode, so these references are not " + "enforced yet but will take effect once it is enabled." + if state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING + else "" + ) + + # If some references also could not be resolved, say so rather than + # implying the rest of the policy is fully clean. + unverified_note = ( + f" Additionally, {len(unverified)} reference(s) could not be verified " + f"due to transient Microsoft Graph errors." + if unverified + else "" + ) + + return ( + f"Conditional Access policy {display_name} references " + f"{len(orphans)} deleted directory object(s) — " + f"{cls._group_by_type(orphans)}.{report_only}{unverified_note}" + ) + + @classmethod + def _format_manual(cls, display_name, unverified): + return ( + f"Conditional Access policy {display_name} could not be fully evaluated: " + f"{len(unverified)} reference(s) could not be resolved due to transient " + f"Microsoft Graph errors (5xx, throttling, or insufficient permissions) — " + f"{cls._group_by_type(unverified)}. Re-run the scan or review the policy " + f"manually." + ) diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.metadata.json new file mode 100644 index 0000000000..35c80b8054 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_no_exclusion_gaps", + "CheckTitle": "Conditional Access exclusions are covered by another policy (no exclusion gaps)", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Verifies that every object excluded from an enabled Microsoft Entra **Conditional Access** policy (users, groups, roles, or applications) is still included by at least one enabled policy, so the exclusion keeps a compensating control. The Directory Synchronization Accounts role and confirmed emergency access (break glass) accounts are treated as intentional and not reported.", + "Risk": "An object excluded from a Conditional Access policy but never included by any other enabled policy sits completely outside Conditional Access enforcement. This creates a silent **MFA bypass** and **lateral movement** path: a principal exempted as a one-off remains permanently uncontrolled if no compensating policy covers it.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/plan-conditional-access", + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Protection > Conditional Access > Policies in the Microsoft Entra admin center.\n2. For each object reported as an exclusion gap, decide whether the exclusion is still required.\n3. If the exclusion must stay, add the object to the Include scope of another enabled Conditional Access policy that enforces compensating controls (for example MFA).\n4. If the exclusion is no longer required, remove it so the object falls back under the original policy.\n5. Re-run the check to confirm no exclusion gaps remain.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure every object excluded from a Conditional Access policy is included by at least one other enabled policy that applies compensating controls. Reserve exclusions for break-glass accounts and the Directory Synchronization Accounts role, and review exclusion lists regularly so that exempted principals never drift outside Conditional Access enforcement.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_no_exclusion_gaps" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_directory_sync_account_excluded", + "entra_emergency_access_exclusion" + ], + "Notes": "Covers user, group, role, and application exclusions. Platform and location exclusions are out of scope because they are scoping conditions rather than principals removed from enforcement. Service-principal exclusions require additional fields on the ConditionalAccessPolicy service model." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.py new file mode 100644 index 0000000000..0974581759 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps.py @@ -0,0 +1,262 @@ +"""Check that Conditional Access exclusions do not create coverage gaps.""" + +from collections import Counter, defaultdict + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, +) + +# Directory Synchronization Accounts built-in role template ID. Prowler enforces +# excluding this role (see entra_conditional_access_policy_directory_sync_account_excluded); +# it is intended to have no fallback, so it never counts as a gap here. +DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32" + + +class entra_conditional_access_policy_no_exclusion_gaps(Check): + """Check that objects excluded from Conditional Access policies remain covered. + + Excluding a principal from a Conditional Access (CA) policy is only safe when + that principal is still covered by *some* enabled CA policy that enforces + compensating controls. An object excluded everywhere and included nowhere + sits completely outside CA enforcement, which is how MFA bypass and lateral + movement against admin accounts happen in real incidents. + + For every enabled CA policy this check walks each exclusion collection and + verifies the excluded object is still in scope of another enabled policy: one + that includes it (explicitly, or via the "All" wildcard) and does not itself + exclude it. A wildcard belonging to the policy that excludes the object does + not count, so a one-off exclusion with no compensating policy is reported as + a gap. + + Only principals and target apps are evaluated (users, groups, roles, + applications). Platform and location exclusions are scoping conditions rather + than principals removed from enforcement, so they are out of scope. + + - PASS: Every excluded object stays in scope of another enabled policy, or no + enabled policy uses any exclusion. + - FAIL: At least one excluded object is in scope of no other enabled policy. + """ + + # (label, conditions attribute, included attr, excluded attr, wildcard token). + # The wildcard token, when present in an include collection, scopes a policy + # to every object of that type. Groups and roles have no wildcard: they are + # always explicit identifiers and transitive group/role expansion is out of + # scope for v1, so an excluded group/role is only "covered" when the same + # identifier is explicitly included by another enabled policy. + _COLLECTIONS = [ + ("users", "user_conditions", "included_users", "excluded_users", "All"), + ("groups", "user_conditions", "included_groups", "excluded_groups", None), + ("roles", "user_conditions", "included_roles", "excluded_roles", None), + ( + "applications", + "application_conditions", + "included_applications", + "excluded_applications", + "All", + ), + ] + + def execute(self) -> list[CheckReportM365]: + """Execute the Conditional Access exclusion-gap check. + + Returns: + list[CheckReportM365]: A single-element list with the aggregate result. + """ + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + + enabled_policies = [ + policy + for policy in entra_client.conditional_access_policies.values() + if policy.state == ConditionalAccessPolicyState.ENABLED + ] + + if not enabled_policies: + report.status = "PASS" + report.status_extended = ( + "No enabled Conditional Access policies found; " + "no exclusion coverage gaps are possible." + ) + return [report] + + emergency_users, emergency_groups = self._emergency_access_objects() + + # gaps: type label -> set of excluded object IDs with no compensating policy + gaps = defaultdict(set) + any_exclusion = False + + for policy in enabled_policies: + for ( + label, + conditions_attr, + included_attr, + excluded_attr, + wildcard, + ) in self._COLLECTIONS: + conditions = getattr(policy.conditions, conditions_attr) + if not conditions: + continue + for object_id in getattr(conditions, excluded_attr): + any_exclusion = True + if self._is_expected_exclusion( + label, object_id, emergency_users, emergency_groups + ): + continue + if not self._is_covered( + object_id, + conditions_attr, + included_attr, + excluded_attr, + wildcard, + enabled_policies, + ): + gaps[label].add(object_id) + + if not any_exclusion: + report.status = "PASS" + report.status_extended = ( + "No enabled Conditional Access policy uses exclusions; " + "no coverage gaps are possible." + ) + return [report] + + if not gaps: + report.status = "PASS" + report.status_extended = ( + "Every object excluded from an enabled Conditional Access policy is " + "still in scope of another enabled policy, so a compensating control " + "remains in effect." + ) + return [report] + + report.status = "FAIL" + report.status_extended = ( + "Conditional Access exclusion gaps found " + f"({self._format_gaps(gaps, self._build_name_index())}). These objects " + "are excluded but in scope of no other enabled policy, leaving them " + "outside CA enforcement." + ) + return [report] + + def _build_name_index(self) -> dict: + """Map excluded object IDs to display names per type, for readable findings. + + Users, groups, and applications resolve to their display name; roles have + no loaded name catalog, so role template IDs are shown as-is. Unresolved + IDs (for example deleted principals still referenced by a policy) fall + back to the raw identifier. + """ + users = { + uid: user.name + for uid, user in (getattr(entra_client, "users", {}) or {}).items() + if getattr(user, "name", None) + } + groups = { + group.id: group.name + for group in (getattr(entra_client, "groups", []) or []) + if getattr(group, "name", None) + } + applications = { + sp.app_id: sp.name + for sp in (getattr(entra_client, "service_principals", {}) or {}).values() + if getattr(sp, "app_id", None) and getattr(sp, "name", None) + } + return {"users": users, "groups": groups, "applications": applications} + + def _is_covered( + self, + object_id, + conditions_attr, + included_attr, + excluded_attr, + wildcard, + enabled_policies, + ) -> bool: + """Return True if any enabled policy keeps ``object_id`` in scope. + + A policy keeps the object in scope when it includes it —explicitly or via + the type's wildcard token— and does not also exclude it. The wildcard of a + policy that itself excludes the object does not count, which is what makes + a one-off exclusion with no compensating policy a real gap. + """ + for policy in enabled_policies: + conditions = getattr(policy.conditions, conditions_attr) + if not conditions: + continue + if object_id in getattr(conditions, excluded_attr): + continue + included = getattr(conditions, included_attr) + if object_id in included or (wildcard is not None and wildcard in included): + return True + return False + + def _emergency_access_objects(self) -> tuple[set, set]: + """Return user and group IDs that act as emergency access (break-glass). + + Objects excluded from *every* enabled (enforced) Conditional Access policy + with a Block grant control are intended, compensating gaps and must not be + reported here. Only ENABLED policies count: report-only policies are not + enforced, so including them would dilute the "excluded everywhere" check + and could hide a genuine break-glass account (consistent with execute()). + """ + blocking_policies = [ + policy + for policy in entra_client.conditional_access_policies.values() + if policy.state == ConditionalAccessPolicyState.ENABLED + and ConditionalAccessGrantControl.BLOCK + in policy.grant_controls.built_in_controls + ] + if not blocking_policies: + return set(), set() + + total = len(blocking_policies) + excluded_users = Counter() + excluded_groups = Counter() + for policy in blocking_policies: + user_conditions = policy.conditions.user_conditions + if not user_conditions: + continue + for user_id in user_conditions.excluded_users: + excluded_users[user_id] += 1 + for group_id in user_conditions.excluded_groups: + excluded_groups[group_id] += 1 + + emergency_users = {uid for uid, n in excluded_users.items() if n == total} + emergency_groups = {gid for gid, n in excluded_groups.items() if n == total} + return emergency_users, emergency_groups + + def _is_expected_exclusion( + self, label, object_id, emergency_users, emergency_groups + ) -> bool: + """Exclusions that are intentional by design and must not count as gaps.""" + if label == "roles" and object_id == DIRECTORY_SYNC_ROLE_TEMPLATE_ID: + return True + if label == "users" and object_id in emergency_users: + return True + if label == "groups" and object_id in emergency_groups: + return True + return False + + def _format_gaps(self, gaps, name_index) -> str: + """Render the orphaned objects grouped by type, by display name when known. + + Each ID is shown as its display name when resolvable; unresolved IDs (and + all roles, which have no name catalog) fall back to the raw identifier. + """ + parts = [] + for label in ("users", "groups", "roles", "applications"): + if label not in gaps: + continue + names = name_index.get(label, {}) + rendered = sorted( + names.get(object_id, object_id) for object_id in gaps[label] + ) + parts.append(f"{label}: {', '.join(rendered)}") + return " | ".join(parts) diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json new file mode 100644 index 0000000000..0cc14e2965 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_directory_sync_object_takeover_blocked", + "CheckTitle": "Microsoft Entra directory sync must block object takeover (soft- and hard-matching)", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "When on-premises directory synchronization is enabled, both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled must be true. Without these blocks, an attacker who can write to on-premises AD can craft an object that matches a privileged cloud account and take it over.", + "Risk": "An attacker with write access to on-premises Active Directory can create an object whose UPN, SMTP address, or ImmutableID matches an existing cloud-only account (e.g. Global Administrator). When the sync engine processes this object, it merges the on-premises identity into the cloud account, effectively granting the attacker full control of that privileged account.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronization?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronizationfeature?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-syncservice-features" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open Microsoft Entra admin center\n2. Navigate to Identity > Hybrid management > Microsoft Entra Connect > Connect Sync\n3. Enable 'Block soft match' and 'Block cloud object takeover through hard match'\n4. Alternatively, use Microsoft Graph API to set both features to true on the onPremisesDirectorySynchronization resource", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled on the on-premises directory synchronization configuration. These should remain enabled permanently except during time-boxed migration windows.", + "Url": "https://hub.prowler.com/check/entra_directory_sync_object_takeover_blocked" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_password_hash_sync_enabled", + "entra_seamless_sso_disabled" + ], + "Notes": "This check only applies to hybrid tenants with on-premises directory synchronization enabled. Cloud-only tenants receive a PASS since the attack path does not exist." +} diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py new file mode 100644 index 0000000000..d1f00a70ff --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py @@ -0,0 +1,118 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_directory_sync_object_takeover_blocked(Check): + """Check that directory sync blocks object takeover via soft-match and hard-match. + + When on-premises directory synchronization is enabled, an attacker who can + write to on-premises AD can craft an object that matches a privileged cloud + account and take it over. Both blockSoftMatchEnabled and + blockCloudObjectTakeoverThroughHardMatchEnabled must be true to prevent this. + + The attack path only exists on hybrid tenants, so the tenant's + organization.onPremisesSyncEnabled is evaluated first. Microsoft Graph + returns an onPremisesSynchronization object (with all features disabled) even + for cloud-only tenants, so the directory sync features must not be evaluated + unless on-premises synchronization is actually enabled. + + - PASS: The tenant is cloud-only, or both block flags are enabled. + - FAIL: On-premises sync is enabled and either block flag is disabled. + - MANUAL: On-premises sync is enabled but the settings cannot be read + (insufficient permissions) or were not returned by Microsoft Graph. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + organizations = entra_client.organizations or [] + on_premises_sync_enabled = any( + organization.on_premises_sync_enabled for organization in organizations + ) + + # Cloud-only tenant: the object takeover attack path does not exist, so + # the directory sync features are not evaluated even if Microsoft Graph + # returns an (all-disabled) onPremisesSynchronization object. + if organizations and not on_premises_sync_enabled: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "PASS" + report.status_extended = ( + f"Entra organization {organization.name} is cloud-only " + "(no on-premises sync), object takeover protection is not " + "applicable." + ) + findings.append(report) + return findings + + # Hybrid tenant but the directory sync settings could not be read. + if entra_client.directory_sync_error: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot verify object takeover protection for " + f"{organization.name}: {entra_client.directory_sync_error}." + ) + findings.append(report) + return findings + + for sync_settings in entra_client.directory_sync_settings: + report = CheckReportM365( + self.metadata(), + resource=sync_settings, + resource_id=sync_settings.id, + resource_name=f"Directory Sync {sync_settings.id}", + ) + + disabled_flags = [] + if not sync_settings.block_soft_match_enabled: + disabled_flags.append("blockSoftMatchEnabled") + if not sync_settings.block_cloud_object_takeover_through_hard_match_enabled: + disabled_flags.append("blockCloudObjectTakeoverThroughHardMatchEnabled") + + if not disabled_flags: + report.status = "PASS" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} blocks both soft-match " + "and hard-match object takeover." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} does not block object " + f"takeover: {', '.join(disabled_flags)} disabled." + ) + + findings.append(report) + + # Hybrid tenant that reported on-premises sync but returned no settings. + if not entra_client.directory_sync_settings: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Entra organization {organization.name} has on-premises sync " + "enabled, but no directory sync settings were returned. Review " + "the tenant configuration manually." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json index cd7efd39e3..7d594a4975 100644 --- a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json +++ b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json @@ -9,8 +9,8 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **emergency access** (break glass) account or group excluded from all policies. Emergency access accounts provide a fallback mechanism when normal administrative access is blocked due to misconfigured policies.", - "Risk": "Without emergency access accounts excluded from Conditional Access policies, a misconfiguration could lock out all administrators from the tenant. This creates a **critical availability risk** where legitimate administrators cannot access or remediate issues in the environment.", + "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **emergency access** (break glass) account or group excluded from every enabled Conditional Access policy with a **Block** grant control. Emergency access accounts provide a fallback mechanism when normal administrative access is blocked due to misconfigured blocking policies.", + "Risk": "Without emergency access accounts excluded from every blocking Conditional Access policy, a misconfigured Block policy can lock out all administrators from the tenant. This creates a **critical availability risk** where legitimate administrators cannot access or remediate issues in the environment.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access", @@ -20,11 +20,11 @@ "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Create dedicated emergency access accounts or a security group in Microsoft Entra admin center.\n2. Navigate to Protection > Conditional Access > Policies.\n3. For each Conditional Access policy, add the emergency access account or group to the exclusion list under Users > Exclude.\n4. Ensure the emergency accounts are protected with strong credentials and limited usage.", + "Other": "1. Create dedicated emergency access accounts or a security group in Microsoft Entra admin center.\n2. Navigate to Protection > Conditional Access > Policies.\n3. For every Conditional Access policy whose grant control is **Block**, add the emergency access account or group to the exclusion list under Users > Exclude.\n4. Ensure the emergency accounts are protected with strong credentials and limited usage.", "Terraform": "" }, "Recommendation": { - "Text": "Create and maintain at least two emergency access accounts that are excluded from all Conditional Access policies. Store credentials securely offline, monitor usage, and test access regularly. Follow **least privilege** principles for these accounts while ensuring they can recover tenant access when needed.", + "Text": "Create and maintain at least two emergency access accounts that are excluded from every Conditional Access policy with a **Block** grant control so they can never be denied access by a misconfiguration. Store credentials securely offline, monitor usage, and test access regularly. Follow **least privilege** principles for these accounts while ensuring they can recover tenant access when needed.", "Url": "https://hub.prowler.com/check/entra_emergency_access_exclusion" } }, diff --git a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py index 45f9bda05d..28ad2a3378 100644 --- a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py +++ b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py @@ -3,87 +3,38 @@ from collections import Counter from prowler.lib.check.models import Check, CheckReportM365 from prowler.providers.m365.services.entra.entra_client import entra_client from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessGrantControl, ConditionalAccessPolicyState, ) class entra_emergency_access_exclusion(Check): - """Check if at least one emergency access account or group is excluded from all Conditional Access policies. + """Check that at least one emergency access account or group is excluded + from every enabled Conditional Access policy with a `Block` grant control. - This check ensures that the tenant has at least one emergency/break glass account - or account exclusion group that is excluded from all Conditional Access policies. - This prevents accidental lockout scenarios where misconfigured CA policies could - block all administrative access to the tenant. + Emergency access (break glass) accounts are, by definition, accounts that + cannot be blocked by Conditional Access. Membership of an account in the + exclusion list of every enabled blocking policy is therefore the necessary + condition for it to act as a true emergency account: if any enabled + blocking policy applies to it, a misconfiguration of that policy can lock + out the tenant. - - PASS: At least one user or group is excluded from all enabled Conditional Access policies, - or there are no enabled policies. - - FAIL: No user or group is excluded from all enabled Conditional Access policies. + - PASS: At least one user or group is excluded from every enabled + Conditional Access policy with a `Block` grant control, or no + enabled blocking Conditional Access policy exists. + - FAIL: One or more enabled blocking Conditional Access policies exist and + no user or group is excluded from all of them. """ def execute(self) -> list[CheckReportM365]: - """Execute the check for emergency access account exclusions. + """Execute the check for emergency access account exclusions from + blocking Conditional Access policies. Returns: list[CheckReportM365]: A list containing the result of the check. """ findings = [] - # Get all enabled CA policies (excluding disabled ones) - enabled_policies = [ - policy - for policy in entra_client.conditional_access_policies.values() - if policy.state != ConditionalAccessPolicyState.DISABLED - ] - - # If there are no enabled policies, there's nothing to exclude from - if not enabled_policies: - report = CheckReportM365( - metadata=self.metadata(), - resource={}, - resource_name="Conditional Access Policies", - resource_id="conditionalAccessPolicies", - ) - report.status = "PASS" - report.status_extended = "No enabled Conditional Access policies found. Emergency access exclusions are not required." - findings.append(report) - return findings - - total_policy_count = len(enabled_policies) - - # Count how many policies exclude each user - excluded_users_counter = Counter() - for policy in enabled_policies: - user_conditions = policy.conditions.user_conditions - if user_conditions: - for user_id in user_conditions.excluded_users: - excluded_users_counter[user_id] += 1 - - # Count how many policies exclude each group - excluded_groups_counter = Counter() - for policy in enabled_policies: - user_conditions = policy.conditions.user_conditions - if user_conditions: - for group_id in user_conditions.excluded_groups: - excluded_groups_counter[group_id] += 1 - - # Find users excluded from ALL policies - users_excluded_from_all = [ - user_id - for user_id, count in excluded_users_counter.items() - if count == total_policy_count - ] - - # Find groups excluded from ALL policies - groups_excluded_from_all = [ - group_id - for group_id, count in excluded_groups_counter.items() - if count == total_policy_count - ] - - has_emergency_exclusion = bool( - users_excluded_from_all or groups_excluded_from_all - ) - report = CheckReportM365( metadata=self.metadata(), resource={}, @@ -91,27 +42,67 @@ class entra_emergency_access_exclusion(Check): resource_id="conditionalAccessPolicies", ) - if has_emergency_exclusion: - report.status = "PASS" - exclusion_details = [] - if users_excluded_from_all: - user_names = [] - for user_id in users_excluded_from_all: - user = entra_client.users.get(user_id) - user_names.append(user.name if user else user_id) - exclusion_details.append(f"user(s): {', '.join(user_names)}") - if groups_excluded_from_all: - group_names = [] - groups_by_id = {g.id: g for g in entra_client.groups} - for group_id in groups_excluded_from_all: - group = groups_by_id.get(group_id) - group_names.append(group.name if group else group_id) - exclusion_details.append(f"group(s): {', '.join(group_names)}") - report.status_extended = f"Emergency access {' and '.join(exclusion_details)} excluded from all {total_policy_count} enabled Conditional Access policies." - else: - report.status = "FAIL" - report.status_extended = f"No user or group is excluded as emergency access from all {total_policy_count} enabled Conditional Access policies." + blocking_policies = [ + policy + for policy in entra_client.conditional_access_policies.values() + if policy.state != ConditionalAccessPolicyState.DISABLED + and ConditionalAccessGrantControl.BLOCK + in policy.grant_controls.built_in_controls + ] + if not blocking_policies: + report.status = "PASS" + report.status_extended = "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required." + findings.append(report) + return findings + + total_blocking_count = len(blocking_policies) + + excluded_users_counter = Counter() + excluded_groups_counter = Counter() + for policy in blocking_policies: + user_conditions = policy.conditions.user_conditions + if not user_conditions: + continue + for user_id in user_conditions.excluded_users: + excluded_users_counter[user_id] += 1 + for group_id in user_conditions.excluded_groups: + excluded_groups_counter[group_id] += 1 + + emergency_user_ids = [ + user_id + for user_id, count in excluded_users_counter.items() + if count == total_blocking_count + ] + emergency_group_ids = [ + group_id + for group_id, count in excluded_groups_counter.items() + if count == total_blocking_count + ] + + if not (emergency_user_ids or emergency_group_ids): + report.status = "FAIL" + report.status_extended = f"No user or group is excluded as emergency access from all {total_blocking_count} enabled Conditional Access policies with a Block grant control." + findings.append(report) + return findings + + exclusion_details = [] + if emergency_user_ids: + user_names = [] + for uid in emergency_user_ids: + user = entra_client.users.get(uid) + user_names.append(user.name if user else uid) + exclusion_details.append(f"user(s): {', '.join(user_names)}") + if emergency_group_ids: + groups_by_id = {g.id: g for g in entra_client.groups} + group_names = [] + for gid in emergency_group_ids: + group = groups_by_id.get(gid) + group_names.append(group.name if group else gid) + exclusion_details.append(f"group(s): {', '.join(group_names)}") + + report.status = "PASS" + report.status_extended = f"Emergency access {' and '.join(exclusion_details)} excluded from all {total_blocking_count} enabled Conditional Access policies with a Block grant control." findings.append(report) return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index b224aabdcc..bbaa80edcc 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -1,20 +1,29 @@ import asyncio import json from asyncio import gather +from datetime import datetime, timezone from enum import Enum -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional, Set, Tuple from uuid import UUID +from kiota_abstractions.base_request_configuration import RequestConfiguration from msgraph.generated.models.o_data_errors.o_data_error import ODataError from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import ( RunHuntingQueryPostRequestBody, ) -from pydantic.v1 import BaseModel +from msgraph.generated.users.users_request_builder import UsersRequestBuilder +from pydantic.v1 import BaseModel, validator from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +# Sentinel identifiers used in Conditional Access ``conditions.users`` +# collections that do not correspond to real directory objects and must not be +# resolved against Graph. Shared by the resolver below and the check that reads +# its result. +CONDITIONAL_ACCESS_SENTINEL_IDS = {"All", "None", "GuestsOrExternalUsers"} + class Entra(M365Service): """ @@ -36,6 +45,7 @@ class Entra(M365Service): user_accounts_status (dict): Dictionary of user account statuses. oauth_apps (dict): Dictionary of OAuth applications from Defender XDR. authentication_method_configurations (dict): Dictionary of authentication method configurations. + service_principals (dict): Dictionary of service principals with credentials and role assignments. """ def __init__(self, provider: M365Provider): @@ -71,6 +81,8 @@ class Entra(M365Service): ) self.tenant_domain = provider.identity.tenant_domain + self.tenant_id = getattr(provider.identity, "tenant_id", None) + self.user_registration_details_error: Optional[str] = None attributes = loop.run_until_complete( gather( self._get_authorization_policy(), @@ -83,6 +95,8 @@ class Entra(M365Service): self._get_oauth_apps(), self._get_directory_sync_settings(), self._get_authentication_method_configurations(), + self._get_service_principals(), + self._get_app_registrations(), ) ) @@ -98,8 +112,27 @@ class Entra(M365Service): self.authentication_method_configurations: Dict[ str, AuthenticationMethodConfiguration ] = attributes[9] + self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10] + self.app_registrations: Dict[str, "AppRegistration"] = attributes[11] self.user_accounts_status = {} + # Resolve directory-object identifiers referenced by Conditional Access + # policies. This runs as a separate phase because it depends on the + # main gather having populated ``conditional_access_policies`` first. + # The result is cached on the instance so sync checks can read it + # without issuing Graph calls of their own. ``unresolved`` holds ids + # confirmed deleted (HTTP 404); ``errored`` holds ids whose lookup + # failed for any other reason (5xx, throttling, permission) and could + # therefore be neither confirmed present nor confirmed deleted. + self.unresolved_directory_object_references: Set[Tuple[str, str]] + self.errored_directory_object_references: Set[Tuple[str, str]] + ( + self.unresolved_directory_object_references, + self.errored_directory_object_references, + ) = loop.run_until_complete( + self._resolve_directory_object_references(self.conditional_access_policies) + ) + if created_loop: asyncio.set_event_loop(None) loop.close() @@ -264,6 +297,20 @@ class Entra(M365Service): [], ) ], + included_guests_or_external_users=self._parse_guests_or_external_users( + getattr( + policy.conditions.users, + "include_guests_or_external_users", + None, + ) + ), + excluded_guests_or_external_users=self._parse_guests_or_external_users( + getattr( + policy.conditions.users, + "exclude_guests_or_external_users", + None, + ) + ), ), client_app_types=[ ClientAppType(client_app_type) @@ -331,6 +378,57 @@ class Entra(M365Service): authentication_flows=self._parse_authentication_flows( raw_auth_flows_map.get(policy.id) ), + device_conditions=DeviceConditions( + device_filter_mode=( + DeviceFilterMode( + getattr( + getattr( + getattr( + policy.conditions, + "devices", + None, + ), + "device_filter", + None, + ), + "mode", + None, + ) + ) + if getattr( + getattr(policy.conditions, "devices", None), + "device_filter", + None, + ) + and getattr( + getattr( + getattr(policy.conditions, "devices", None), + "device_filter", + None, + ), + "mode", + None, + ) + else None + ), + device_filter_rule=( + getattr( + getattr( + getattr(policy.conditions, "devices", None), + "device_filter", + None, + ), + "rule", + None, + ) + if getattr( + getattr(policy.conditions, "devices", None), + "device_filter", + None, + ) + else None + ), + ), ), grant_controls=GrantControls( built_in_controls=( @@ -546,6 +644,56 @@ class Entra(M365Service): return AuthenticationFlows(transfer_methods=transfer_methods) + @staticmethod + def _parse_guests_or_external_users( + sdk_obj, + ) -> "GuestsOrExternalUsers | None": + """Parse guest or external user conditions from the MS Graph SDK object. + + The SDK deserializes ``guestOrExternalUserTypes`` via + ``get_collection_of_enum_values``, returning a list of SDK enum members. + + Args: + sdk_obj: A ``ConditionalAccessGuestsOrExternalUsers`` SDK object, or ``None``. + + Returns: + A ``GuestsOrExternalUsers`` model instance, or ``None`` if the input is absent. + """ + if sdk_obj is None: + return None + + raw_types = getattr(sdk_obj, "guest_or_external_user_types", None) or [] + raw_membership_kind = getattr( + getattr(sdk_obj, "external_tenants", None), + "membership_kind", + None, + ) + membership_kind = None + if raw_membership_kind is not None: + raw_membership_kind = getattr( + raw_membership_kind, + "value", + raw_membership_kind, + ) + try: + membership_kind = ExternalTenantsMembershipKind(raw_membership_kind) + except ValueError: + logger.warning( + f"Unknown external tenants membership kind: {raw_membership_kind}" + ) + + guest_types: list[GuestOrExternalUserType] = [] + for raw_type in raw_types: + try: + guest_types.append(GuestOrExternalUserType(raw_type.value)) + except (ValueError, AttributeError): + logger.warning(f"Unknown guest or external user type: {raw_type}") + + return GuestsOrExternalUsers( + guest_or_external_user_types=guest_types, + external_tenants_membership_kind=membership_kind, + ) + @staticmethod def _parse_app_management_restrictions(restrictions): """Parse credential restrictions from the Graph API response into AppManagementRestrictions.""" @@ -666,6 +814,16 @@ class Entra(M365Service): features, "seamless_sso_enabled", False ) or False, + block_soft_match_enabled=getattr( + features, "block_soft_match_enabled", False + ) + or False, + block_cloud_object_takeover_through_hard_match_enabled=getattr( + features, + "block_cloud_object_takeover_through_hard_match_enabled", + False, + ) + or False, ) ) except ODataError as error: @@ -691,7 +849,30 @@ class Entra(M365Service): logger.info("Entra - Getting users...") users = {} try: - users_response = await self.client.users.get() + # Microsoft Graph's /users endpoint omits accountEnabled, userType and + # onPremisesSyncEnabled from the default property set, so we must request + # them explicitly via $select. Without this, disabled guest users surface + # as account_enabled=True (Pydantic default) and user_type=None, which + # bypasses the guest/disabled filters in checks like + # entra_users_mfa_capable (CIS 5.2.3.4). See issue #10921. + query_parameters = ( + UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + select=[ + "id", + "displayName", + "userType", + "accountEnabled", + "onPremisesSyncEnabled", + "employeeHireDate", + ], + ) + ) + request_configuration = RequestConfiguration( + query_parameters=query_parameters, + ) + users_response = await self.client.users.get( + request_configuration=request_configuration, + ) directory_roles = await self.client.directory_roles.get() async def fetch_role_members(directory_role): @@ -710,11 +891,26 @@ class Entra(M365Service): for member in members: user_roles_map.setdefault(member.id, []).append(role_template_id) - registration_details = await self._get_user_registration_details() + registration_details, self.user_registration_details_error = ( + await self._get_user_registration_details() + ) while users_response: for user in getattr(users_response, "value", []) or []: reg_info = registration_details.get(user.id, {}) + # Prefer Microsoft Graph as the source of truth for + # accountEnabled: it covers every directory user including + # guests, whereas EXO's Get-User only returns mail-enabled + # accounts and silently drops disabled guests. Fall back to + # the EXO PowerShell value only when Graph does not return a + # value (e.g. older tenants or permission-restricted reads). + graph_account_enabled = getattr(user, "account_enabled", None) + if graph_account_enabled is None: + account_enabled = not self.user_accounts_status.get( + user.id, {} + ).get("AccountDisabled", False) + else: + account_enabled = bool(graph_account_enabled) users[user.id] = User( id=user.id, name=user.display_name, @@ -723,12 +919,12 @@ class Entra(M365Service): ), directory_roles_ids=user_roles_map.get(user.id, []), is_mfa_capable=reg_info.get("is_mfa_capable", False), - account_enabled=not self.user_accounts_status.get( - user.id, {} - ).get("AccountDisabled", False), + account_enabled=account_enabled, authentication_methods=reg_info.get( "authentication_methods", [] ), + user_type=getattr(user, "user_type", None), + employee_hire_date=getattr(user, "employee_hire_date", None), ) next_link = getattr(users_response, "odata_next_link", None) @@ -741,18 +937,24 @@ class Entra(M365Service): ) return users - async def _get_user_registration_details(self): + async def _get_user_registration_details( + self, + ) -> Tuple[Dict[str, Dict[str, Any]], Optional[str]]: """Retrieve user authentication method registration details. Fetches registration details from the Microsoft Graph API, including MFA capability and the specific authentication methods each user has registered. Returns: - dict: A dictionary mapping user IDs to their registration details, - where each value is a dict with 'is_mfa_capable' (bool) and - 'authentication_methods' (list of str). + A tuple containing: + - A dictionary mapping user IDs to their registration details, + where each value is a dict with 'is_mfa_capable' (bool) and + 'authentication_methods' (list of str), or an empty dict if + retrieval fails. + - An error message string if there was an access error, None otherwise. """ registration_details = {} + error_message = None try: registration_builder = ( self.client.reports.authentication_methods.user_registration_details @@ -777,16 +979,25 @@ class Entra(M365Service): next_link ).get() - except Exception as error: - if ( - error.__class__.__name__ == "ODataError" - and error.__dict__.get("response_status_code", None) == 403 - ): + except ODataError as error: + error_code = getattr(error.error, "code", None) if error.error else None + if error_code == "Authorization_RequestDenied": + error_message = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}" + ) + else: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + error_message = str(error) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + error_message = f"Failed to retrieve user registration details: {error}" - return registration_details + return registration_details, error_message async def _get_oauth_apps(self) -> Optional[Dict[str, "OAuthApp"]]: """ @@ -939,6 +1150,382 @@ OAuthAppInfo ) return authentication_method_configurations + async def _get_service_principals(self): + """Retrieve service principals owned by the audited tenant. + + Fetches all service principals from Microsoft Graph and keeps only the + ones whose ``appOwnerOrganizationId`` matches the audited tenant. Skips + Microsoft first-party service principals and multi-tenant ISV apps + consented from other publishers: their credentials live in the + publisher's tenant, not this one, so they are out of scope for any + check that evaluates secret hygiene or role assignments managed by the + customer. + + Returns: + Dict[str, ServicePrincipal]: Customer-owned service principals + keyed by service principal ID. + """ + logger.info("Entra - Getting service principals...") + service_principals = {} + tenant_id_normalized = str(self.tenant_id).lower() if self.tenant_id else None + try: + sp_response = await self.client.service_principals.get() + + # Build a map of service principal IDs to their data + while sp_response: + for sp in getattr(sp_response, "value", []) or []: + raw_owner = getattr(sp, "app_owner_organization_id", None) + app_owner_org_id = str(raw_owner).lower() if raw_owner else None + if ( + tenant_id_normalized + and app_owner_org_id != tenant_id_normalized + ): + # Skip Microsoft first-party SPs and consented + # multi-tenant ISV apps; the customer cannot manage + # their credentials. + continue + + password_credentials = [] + for cred in getattr(sp, "password_credentials", []) or []: + password_credentials.append( + PasswordCredential( + key_id=str(getattr(cred, "key_id", "")), + display_name=getattr(cred, "display_name", None), + end_date_time=getattr(cred, "end_date_time", None), + ) + ) + + key_credentials = [] + for cred in getattr(sp, "key_credentials", []) or []: + key_credentials.append( + KeyCredential( + key_id=str(getattr(cred, "key_id", "")), + display_name=getattr(cred, "display_name", None), + ) + ) + + service_principals[sp.id] = ServicePrincipal( + id=sp.id, + name=getattr(sp, "display_name", "") or "", + app_id=getattr(sp, "app_id", "") or "", + app_owner_organization_id=app_owner_org_id, + password_credentials=password_credentials, + key_credentials=key_credentials, + ) + + next_link = getattr(sp_response, "odata_next_link", None) + if not next_link: + break + sp_response = await self.client.service_principals.with_url( + next_link + ).get() + + # Fold in credentials registered on the parent Application objects. + # Microsoft Graph stores secrets and certificates added through + # "Certificates & secrets" on /applications, not on the service + # principal itself, so /servicePrincipals.passwordCredentials is + # almost always empty for normal app registrations. Joining via + # appId is required for the check to see those credentials. + # + # Index service principals by app_id once so the join below is + # O(N+M) instead of scanning all SPs for every Application page. + service_principals_by_app_id = { + sp.app_id: sp for sp in service_principals.values() if sp.app_id + } + # Remember each SP's parent application object ID so the owner + # lookup below can address it directly without re-walking + # /applications. + application_object_id_by_sp_id: Dict[str, str] = {} + app_response = await self.client.applications.get() + while app_response: + for app in getattr(app_response, "value", []) or []: + app_id = getattr(app, "app_id", None) + if not app_id: + continue + target_sp = service_principals_by_app_id.get(app_id) + if target_sp is None: + continue + + app_object_id = getattr(app, "id", None) + if app_object_id: + application_object_id_by_sp_id[target_sp.id] = app_object_id + + for cred in getattr(app, "password_credentials", []) or []: + target_sp.password_credentials.append( + PasswordCredential( + key_id=str(getattr(cred, "key_id", "")), + display_name=getattr(cred, "display_name", None), + end_date_time=getattr(cred, "end_date_time", None), + ) + ) + for cred in getattr(app, "key_credentials", []) or []: + target_sp.key_credentials.append( + KeyCredential( + key_id=str(getattr(cred, "key_id", "")), + display_name=getattr(cred, "display_name", None), + ) + ) + + next_link = getattr(app_response, "odata_next_link", None) + if not next_link: + break + app_response = await self.client.applications.with_url(next_link).get() + + # Identify permanent Tier 0 directory role assignments via the unified + # role management endpoint. ``directoryRoles/{id}/members`` mixes + # permanent direct assignments with PIM-activated temporary ones, so + # using it would mark just-in-time elevations as "permanent" and emit + # false positives. ``roleManagement/directory/roleAssignments`` + # exposes only the durable, statically-assigned principals, which is + # exactly what the Tier 0 check needs. + role_assignments_response = ( + await self.client.role_management.directory.role_assignments.get() + ) + while role_assignments_response: + for assignment in getattr(role_assignments_response, "value", []) or []: + principal_id = getattr(assignment, "principal_id", None) + role_definition_id = getattr(assignment, "role_definition_id", None) + if ( + principal_id in service_principals + and role_definition_id in TIER_0_ROLE_TEMPLATE_IDS + ): + service_principals[ + principal_id + ].directory_role_template_ids.append(role_definition_id) + + next_link = getattr(role_assignments_response, "odata_next_link", None) + if not next_link: + break + role_assignments_response = await self.client.role_management.directory.role_assignments.with_url( + next_link + ).get() + + # Resolve owners only for service principals that hold a permanent + # Tier 0 directory role. Owner ownership of the SP object or its + # parent app registration is a credential-rotation escalation path + # outside PIM and Conditional Access; fetching owners for every + # consented SP would multiply Graph traffic for no benefit. + for sp in service_principals.values(): + if not sp.directory_role_template_ids: + continue + try: + sp_owners_response = ( + await self.client.service_principals.by_service_principal_id( + sp.id + ).owners.get() + ) + sp.sp_owner_ids = [ + getattr(owner, "id", None) + for owner in (getattr(sp_owners_response, "value", []) or []) + if getattr(owner, "id", None) + ] + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + app_object_id = application_object_id_by_sp_id.get(sp.id) + if not app_object_id: + continue + try: + app_owners_response = ( + await self.client.applications.by_application_id( + app_object_id + ).owners.get() + ) + sp.app_owner_ids = [ + getattr(owner, "id", None) + for owner in (getattr(app_owners_response, "value", []) or []) + if getattr(owner, "id", None) + ] + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return service_principals + + async def _get_app_registrations(self) -> Dict[str, "AppRegistration"]: + """Retrieve application registrations from Microsoft Entra. + + Fetches every application object and its password credentials (client + secrets) across all pages. Customer-owned applications should + authenticate using certificates, federated identity credentials, or + managed identities, so any entry in ``passwordCredentials`` is reported + by the related check. + + Returns: + Dict[str, AppRegistration]: Application registrations keyed by the + application object ID. + """ + logger.info("Entra - Getting app registrations...") + app_registrations: Dict[str, AppRegistration] = {} + try: + app_response = await self.client.applications.get() + while app_response: + for app in getattr(app_response, "value", []) or []: + app_id = getattr(app, "app_id", None) + object_id = getattr(app, "id", None) + if not app_id or not object_id: + continue + + password_credentials = [] + for cred in getattr(app, "password_credentials", []) or []: + password_credentials.append( + PasswordCredential( + key_id=str(getattr(cred, "key_id", "")), + display_name=getattr(cred, "display_name", None), + start_date_time=getattr(cred, "start_date_time", None), + end_date_time=getattr(cred, "end_date_time", None), + ) + ) + + app_registrations[object_id] = AppRegistration( + id=object_id, + app_id=app_id, + name=getattr(app, "display_name", "") or "", + password_credentials=password_credentials, + ) + + next_link = getattr(app_response, "odata_next_link", None) + if not next_link: + break + app_response = await self.client.applications.with_url(next_link).get() + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return app_registrations + + async def _resolve_directory_object_references( + self, + policies: Dict[str, "ConditionalAccessPolicy"], + ) -> Tuple[Set[Tuple[str, str]], Set[Tuple[str, str]]]: + """Resolve every user/group/role identifier referenced by CA policies. + + Walks the inclusion/exclusion collections of every loaded Conditional + Access policy, deduplicates the resulting identifiers per type, and + queries Microsoft Graph for each one. Identifiers that return HTTP 404 + are reported as deleted. Non-404 errors (5xx, throttling, permission, + transient network failures) are reported as unresolvable: they must not + be flagged as deletions, but they must also not be silently treated as + clean resolutions, so the downstream check surfaces them as MANUAL. + + The sentinel values ``All``, ``None``, and ``GuestsOrExternalUsers`` + are not directory identifiers and are excluded before any Graph call. + + Args: + policies: Conditional Access policies keyed by policy ID. + + Returns: + Tuple[Set[Tuple[str, str]], Set[Tuple[str, str]]]: A pair of + ``(type, identifier)`` sets. The first holds identifiers that + failed to resolve via Graph with HTTP 404 (deleted); the second + holds identifiers whose lookup failed for any other reason and + could be neither confirmed present nor confirmed deleted. + """ + logger.info( + "Entra - Resolving directory-object references in Conditional " + "Access policies..." + ) + + ids_by_type: Dict[str, Set[str]] = { + "user": set(), + "group": set(), + "role": set(), + } + + for policy in policies.values(): + if not getattr(policy, "conditions", None): + continue + user_conditions = getattr(policy.conditions, "user_conditions", None) + if user_conditions is None: + continue + for ident in (user_conditions.included_users or []) + ( + user_conditions.excluded_users or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["user"].add(ident) + for ident in (user_conditions.included_groups or []) + ( + user_conditions.excluded_groups or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["group"].add(ident) + for ident in (user_conditions.included_roles or []) + ( + user_conditions.excluded_roles or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["role"].add(ident) + + unresolved: Set[Tuple[str, str]] = set() + errored: Set[Tuple[str, str]] = set() + + # Resolve types in parallel; within a type, walk identifiers serially + # to keep concurrent Graph calls bounded and avoid throttling. + await gather( + self._resolve_identifiers_for_type( + "user", ids_by_type["user"], unresolved, errored + ), + self._resolve_identifiers_for_type( + "group", ids_by_type["group"], unresolved, errored + ), + self._resolve_identifiers_for_type( + "role", ids_by_type["role"], unresolved, errored + ), + ) + return unresolved, errored + + async def _resolve_identifiers_for_type( + self, + type_: str, + identifiers: Set[str], + unresolved: Set[Tuple[str, str]], + errored: Set[Tuple[str, str]], + ) -> None: + """Resolve a set of identifiers of a given type, mutating the result sets. + + Only HTTP 404 (or ``Request_ResourceNotFound``) responses add to + ``unresolved``. Every other error (5xx, throttling, permission, or an + unexpected exception) is logged and added to ``errored`` so the check + can report it as unverified instead of silently treating it as clean. + """ + for identifier in identifiers: + try: + if type_ == "user": + await self.client.users.by_user_id(identifier).get() + elif type_ == "group": + await self.client.groups.by_group_id(identifier).get() + elif type_ == "role": + await self.client.role_management.directory.role_definitions.by_unified_role_definition_id( + identifier + ).get() + else: + continue + except ODataError as error: + status_code = getattr(error, "response_status_code", None) + error_code = getattr(error.error, "code", None) if error.error else None + if status_code == 404 or error_code == "Request_ResourceNotFound": + unresolved.add((type_, identifier)) + else: + errored.add((type_, identifier)) + logger.warning( + f"Entra - Could not resolve {type_} '{identifier}' for " + f"Conditional Access reference check: " + f"{error.__class__.__name__}: {error}" + ) + except Exception as error: + errored.add((type_, identifier)) + logger.warning( + f"Entra - Unexpected error resolving {type_} '{identifier}' " + f"for Conditional Access reference check: " + f"{error.__class__.__name__}: {error}" + ) + class ConditionalAccessPolicyState(Enum): ENABLED = "enabled" @@ -957,13 +1544,58 @@ class ApplicationsConditions(BaseModel): included_user_actions: List[UserAction] +class GuestOrExternalUserType(Enum): + """Guest or external user types for Conditional Access policies. + + Reference: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessguestsorexternalusers + """ + + NONE = "none" + INTERNAL_GUEST = "internalGuest" + B2B_COLLABORATION_GUEST = "b2bCollaborationGuest" + B2B_COLLABORATION_MEMBER = "b2bCollaborationMember" + B2B_DIRECT_CONNECT_USER = "b2bDirectConnectUser" + OTHER_EXTERNAL_USER = "otherExternalUser" + SERVICE_PROVIDER = "serviceProvider" + + +class ExternalTenantsMembershipKind(Enum): + """External tenant scope for guest or external user conditions.""" + + ALL = "all" + ENUMERATED = "enumerated" + UNKNOWN_FUTURE_VALUE = "unknownFutureValue" + + +# All guest/external user types that represent actual guest or external users. +ALL_GUEST_USER_TYPES = { + GuestOrExternalUserType.INTERNAL_GUEST, + GuestOrExternalUserType.B2B_COLLABORATION_GUEST, + GuestOrExternalUserType.B2B_COLLABORATION_MEMBER, + GuestOrExternalUserType.B2B_DIRECT_CONNECT_USER, + GuestOrExternalUserType.OTHER_EXTERNAL_USER, + GuestOrExternalUserType.SERVICE_PROVIDER, +} + + +class GuestsOrExternalUsers(BaseModel): + """Model representing guest or external user conditions in Conditional Access policies.""" + + guest_or_external_user_types: List[GuestOrExternalUserType] = [] + external_tenants_membership_kind: Optional[ExternalTenantsMembershipKind] = None + + class UsersConditions(BaseModel): + """Model representing user conditions for Conditional Access policies.""" + included_groups: List[str] excluded_groups: List[str] included_users: List[str] excluded_users: List[str] included_roles: List[str] excluded_roles: List[str] + included_guests_or_external_users: Optional[GuestsOrExternalUsers] = None + excluded_guests_or_external_users: Optional[GuestsOrExternalUsers] = None class RiskLevel(Enum): @@ -992,12 +1624,40 @@ class InsiderRiskLevel(Enum): ELEVATED = "elevated" +class DeviceFilterMode(Enum): + """Mode for device filter in Conditional Access policies.""" + + INCLUDE = "include" + EXCLUDE = "exclude" + + +class DeviceConditions(BaseModel): + """Model representing device conditions for Conditional Access policies.""" + + device_filter_mode: Optional[DeviceFilterMode] = None + device_filter_rule: Optional[str] = None + + class PlatformConditions(BaseModel): """Model representing platform conditions for Conditional Access policies.""" include_platforms: List[str] = [] exclude_platforms: List[str] = [] + @validator("include_platforms", "exclude_platforms", pre=True) + @classmethod + def normalize_platforms(cls, values): # noqa: vulture + if not values: + return [] + + normalized = [] + for platform in values: + value = getattr(platform, "value", platform) + if isinstance(value, str) and value: + normalized.append(value.lower()) + + return normalized + class TransferMethod(Enum): """Transfer methods for authentication flows in Conditional Access policies.""" @@ -1013,6 +1673,8 @@ class AuthenticationFlows(BaseModel): class Conditions(BaseModel): + """Model representing conditions for Conditional Access policies.""" + application_conditions: Optional[ApplicationsConditions] user_conditions: Optional[UsersConditions] client_app_types: Optional[List[ClientAppType]] @@ -1021,6 +1683,7 @@ class Conditions(BaseModel): insider_risk_levels: Optional[InsiderRiskLevel] = None platform_conditions: Optional[PlatformConditions] = None authentication_flows: Optional[AuthenticationFlows] = None + device_conditions: Optional[DeviceConditions] = None class PersistentBrowser(BaseModel): @@ -1131,6 +1794,8 @@ class DirectorySyncSettings(BaseModel): id: str password_sync_enabled: bool = False seamless_sso_enabled: bool = False + block_soft_match_enabled: bool = False + block_cloud_object_takeover_through_hard_match_enabled: bool = False class AuthenticationMethodConfiguration(BaseModel): @@ -1218,6 +1883,10 @@ 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. + employee_hire_date: The user's hire date as reported by Microsoft Graph. """ id: str @@ -1227,6 +1896,8 @@ class User(BaseModel): is_mfa_capable: bool = False account_enabled: bool = True authentication_methods: List[str] = [] + user_type: Optional[str] = None + employee_hire_date: Optional[datetime] = None class InvitationsFrom(Enum): @@ -1290,3 +1961,122 @@ class OAuthApp(BaseModel): is_admin_consented: bool = False last_used_time: Optional[str] = None app_origin: str = "" + + +class PasswordCredential(BaseModel): + """Model representing a password credential (client secret) on a service principal. + + Attributes: + key_id: The unique identifier of the credential. + display_name: The optional display name of the credential. + start_date_time: The time at which the credential becomes valid. + ``None`` when the API does not report it. + end_date_time: The expiration time of the credential. ``None`` indicates + the secret has no recorded expiry and is treated as active. + """ + + key_id: str + display_name: Optional[str] = None + start_date_time: Optional[datetime] = None + end_date_time: Optional[datetime] = None + + def is_active(self, now: Optional[datetime] = None) -> bool: + """Return ``True`` when the credential has not expired. + + A credential with no ``end_date_time`` is assumed to be active, matching + the behavior of the Microsoft Graph API when the field is omitted. + """ + if self.end_date_time is None: + return True + reference = now or datetime.now(timezone.utc) + return self.end_date_time > reference + + +class KeyCredential(BaseModel): + """Model representing a key credential (certificate) on a service principal. + + Attributes: + key_id: The unique identifier of the credential. + display_name: The optional display name of the credential. + """ + + key_id: str + display_name: Optional[str] = None + + +# Control Plane (Tier 0) role template IDs. +# +# Roles included grant tenant-wide control over identity, authentication, or the +# directory itself, so a credential compromise on any of them is equivalent to a +# tenant takeover. References: +# https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/privileged-roles-permissions +# https://learn.microsoft.com/en-us/security/privileged-access-workstations/privileged-access-access-model +TIER_0_ROLE_TEMPLATE_IDS = { + "62e90394-69f5-4237-9190-012177145e10", # Global Administrator + "e8611ab8-c189-46e8-94e1-60213ab1f814", # Privileged Role Administrator + "7be44c8a-adaf-4e2a-84d6-ab2649e08a13", # Privileged Authentication Administrator + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", # Application Administrator + "158c047a-c907-4556-b7ef-446551a6b5f7", # Cloud Application Administrator + "c4e39bd9-1100-46d3-8c65-fb160da0071f", # Authentication Administrator + "0526716b-113d-4c15-b2c8-68e3c22b9f80", # Authentication Policy Administrator + "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", # Conditional Access Administrator + "8329153b-31d0-4727-b945-745eb3bc5f31", # Domain Name Administrator + "be2f45a1-457d-42af-a067-6ec1fa63bc45", # External Identity Provider Administrator + "8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2", # Hybrid Identity Administrator + "194ae4cb-b126-40b2-bd5b-6091b380977d", # Security Administrator + "fe930be7-5e62-47db-91af-98c3a49a38b1", # User Administrator + "d29b2b05-8046-44ba-8758-1e26182fcf32", # Directory Synchronization Accounts + "e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8", # Partner Tier2 Support +} + + +class ServicePrincipal(BaseModel): + """Model representing a Microsoft Entra ID service principal. + + Attributes: + id: The service principal's unique identifier. + name: The service principal's display name. + app_id: The application ID associated with the service principal. + app_owner_organization_id: Tenant ID of the application's publisher. + For customer-owned apps this matches the audited tenant; the + service-layer fetch uses this to filter out Microsoft first-party + and third-party multi-tenant service principals that the customer + cannot manage credentials for. + password_credentials: List of password credentials (client secrets). + key_credentials: List of key credentials (certificates). + directory_role_template_ids: List of directory role template IDs permanently + assigned to this service principal. + sp_owner_ids: Principal IDs that own the service principal object. + Populated only for service principals that hold a permanent Tier 0 + directory role assignment, to keep Graph traffic bounded. + app_owner_ids: Principal IDs that own the parent app registration. + Populated only for service principals that hold a permanent Tier 0 + directory role assignment. + """ + + id: str + name: str + app_id: str = "" + app_owner_organization_id: Optional[str] = None + password_credentials: List[PasswordCredential] = [] + key_credentials: List[KeyCredential] = [] + directory_role_template_ids: List[str] = [] + sp_owner_ids: List[str] = [] + app_owner_ids: List[str] = [] + + +class AppRegistration(BaseModel): + """Model representing a Microsoft Entra ID application registration. + + Attributes: + id: The application object's unique identifier. + app_id: The application (client) ID. + name: The application's display name. + password_credentials: List of password credentials (client secrets) + registered on the application. + """ + + id: str + app_id: str = "" + name: str = "" + password_credentials: List[PasswordCredential] = [] diff --git a/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/__init__.py b/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles.metadata.json b/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles.metadata.json new file mode 100644 index 0000000000..477017c458 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_service_principal_no_secrets_for_permanent_tier0_roles", + "CheckTitle": "Secure credential management prevents client secret usage for service principals with permanent Tier 0 roles", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **service principals** with permanent assignments to **Control Plane (Tier 0)** directory roles are evaluated for the use of **client secrets** (password credentials) instead of more secure authentication methods such as certificates or managed identities.", + "Risk": "A service principal authenticating with a **client secret** while holding a **Tier 0** role creates a high-impact credential theft path. Leaked or brute-forced secrets grant immediate control-plane access, enabling tenant-wide privilege escalation, security control bypass, data exfiltration, and persistent backdoor creation—impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-planning#prefer-certificate-credentials", + "https://learn.microsoft.com/en-us/entra/architecture/security-operations-applications#application-credentials" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > App registrations > select the application\n3. Under Certificates & secrets, remove all client secrets\n4. Under Certificates & secrets > Certificates, upload a certificate for authentication\n5. Alternatively, migrate to a managed identity where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Replace **client secrets** with **certificates** or **managed identities** for service principals holding Control Plane roles. Apply **least privilege** by removing unnecessary Tier 0 assignments. Use **Privileged Identity Management (PIM)** for just-in-time eligible assignments instead of permanent ones. Rotate credentials regularly and monitor sign-in logs for anomalies.", + "Url": "https://hub.prowler.com/check/entra_service_principal_no_secrets_for_permanent_tier0_roles" + } + }, + "Categories": [ + "identity-access", + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Tier 0 roles evaluated follow the Microsoft Entra Control Plane classification, including Global Administrator, Privileged Role Administrator, and Privileged Authentication Administrator." +} diff --git a/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles.py b/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles.py new file mode 100644 index 0000000000..4d5955456c --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles.py @@ -0,0 +1,81 @@ +"""Check for service principals using client secrets with permanent Tier 0 role assignments.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import TIER_0_ROLE_TEMPLATE_IDS + + +class entra_service_principal_no_secrets_for_permanent_tier0_roles(Check): + """ + Service principal with permanent Control Plane role does not use client secrets. + + This check evaluates whether service principals that hold permanent assignments + to Tier 0 (Control Plane) directory roles authenticate using client secrets + instead of more secure alternatives such as certificates or managed identities. + + - PASS: The service principal does not use client secrets or does not hold a + permanent Tier 0 directory role assignment. + - FAIL: The service principal uses client secrets and has a permanent assignment + to at least one Tier 0 directory role. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the service principal secret management check. + + Iterates over service principals and identifies those that combine password + credentials (client secrets) with permanent Tier 0 directory role assignments. + + Returns: + A list of reports containing the result of the check for each service principal. + """ + findings = [] + + for sp in entra_client.service_principals.values(): + report = CheckReportM365( + metadata=self.metadata(), + resource=sp, + resource_name=sp.name, + resource_id=sp.id, + ) + + active_secrets = [ + credential + for credential in sp.password_credentials + if credential.is_active() + ] + has_secrets = len(active_secrets) > 0 + tier0_roles = [ + role_id + for role_id in sp.directory_role_template_ids + if role_id in TIER_0_ROLE_TEMPLATE_IDS + ] + + if has_secrets and tier0_roles: + report.status = "FAIL" + report.status_extended = ( + f"Service principal '{sp.name}' uses client secrets and has " + f"permanent assignment to {len(tier0_roles)} Control Plane " + f"(Tier 0) directory role(s)." + ) + else: + report.status = "PASS" + if not has_secrets and not tier0_roles: + report.status_extended = ( + f"Service principal '{sp.name}' does not use client secrets " + f"and has no Tier 0 directory role assignments." + ) + elif not has_secrets: + report.status_extended = ( + f"Service principal '{sp.name}' does not use client secrets." + ) + else: + report.status_extended = ( + f"Service principal '{sp.name}' has no permanent Tier 0 " + f"directory role assignments." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json new file mode 100644 index 0000000000..4822d8956d --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_service_principal_privileged_role_no_owners", + "CheckTitle": "Service principals with privileged Entra directory roles must have no owners", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **service principals** holding permanent **Control Plane (Tier 0)** directory roles (such as **Global Administrator** or **Privileged Role Administrator**) are evaluated for the presence of **owners** on either the service principal itself or its parent **app registration**.", + "Risk": "An **owner** of a service principal or its parent app registration can **rotate credentials** and sign in as the service principal, inheriting its **Tier 0** role outside **PIM** and **Conditional Access** controls. This is a documented privilege escalation path impacting **confidentiality**, **integrity**, and **availability** of the tenant's control plane.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-owners?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/application-list-owners?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/rbacapplication-list-roleassignments?view=graph-rest-1.0" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > Enterprise applications > select the service principal\n3. Under Owners, remove all owners\n4. Repeat for the parent App Registration under Identity > Applications > App registrations\n5. Use PIM eligible assignments instead of permanent role assignments where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove all owners from service principals that hold privileged Entra directory roles. Manage privileged service principals exclusively via PIM-eligible role assignments and break-glass controls. Ensure no human account can silently inherit control-plane privileges through ownership.", + "Url": "https://hub.prowler.com/check/entra_service_principal_privileged_role_no_owners" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_service_principal_no_secrets_for_permanent_tier0_roles" + ], + "Notes": "Only service principals with permanent Tier 0 directory role assignments are evaluated. Microsoft first-party service principals and multi-tenant ISV apps consented from other publishers are excluded by the service layer." +} diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py new file mode 100644 index 0000000000..f3af26e23f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py @@ -0,0 +1,71 @@ +"""Check for service principals with privileged roles that have owners.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_service_principal_privileged_role_no_owners(Check): + """Service principal with a permanent Tier 0 directory role has no owners. + + Owners of a service principal or its parent app registration can rotate + credentials and sign in as the service principal, inheriting its privileged + directory role outside PIM approval flows and Conditional Access policies + targeting user accounts. + + - PASS: The service principal does not hold a permanent Tier 0 directory + role, or it does but has zero owners on both the service principal and + its parent app registration. + - FAIL: The service principal holds a permanent Tier 0 directory role and + has at least one owner on either the service principal or its parent + app registration. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the privileged service principal owner check. + + Returns: + A list of reports, one per service principal owned by the audited + tenant. + """ + findings = [] + for sp in entra_client.service_principals.values(): + report = CheckReportM365( + metadata=self.metadata(), + resource=sp, + resource_name=sp.name, + resource_id=sp.id, + ) + + if not sp.directory_role_template_ids: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{sp.name}' has no permanent Tier 0 " + f"directory role assignments." + ) + findings.append(report) + continue + + unique_owners = set(sp.sp_owner_ids) | set(sp.app_owner_ids) + tier0_role_count = len(sp.directory_role_template_ids) + + if unique_owners: + report.status = "FAIL" + report.status_extended = ( + f"Service principal '{sp.name}' holds {tier0_role_count} " + f"permanent Tier 0 directory role(s) and has " + f"{len(unique_owners)} owner(s) " + f"({len(sp.sp_owner_ids)} on the service principal, " + f"{len(sp.app_owner_ids)} on the parent app registration)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{sp.name}' holds {tier0_role_count} " + f"permanent Tier 0 directory role(s) and has no owners on " + f"either the service principal or its parent app registration." + ) + + findings.append(report) + return findings 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..d3e75cef7f 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 @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import List from prowler.lib.check.models import Check, CheckReportM365 @@ -6,41 +7,69 @@ 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, disabled accounts, and future hires are excluded from the + evaluation. + + - PASS: The member user is MFA capable. + - FAIL: The member user is not MFA capable, or MFA capability cannot be + verified due to insufficient permissions to read user registration details. """ 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 user.employee_hire_date: + employee_hire_date = user.employee_hire_date + if ( + employee_hire_date.tzinfo is None + or employee_hire_date.utcoffset() is None + ): + employee_hire_date = employee_hire_date.replace(tzinfo=timezone.utc) + if employee_hire_date > datetime.now(timezone.utc): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.id, + ) + + if entra_client.user_registration_details_error: + report.status = "FAIL" + report.status_extended = ( + f"Cannot verify MFA capability for user {user.name}: " + f"{entra_client.user_registration_details_error}." ) + elif 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." - 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) + findings.append(report) return findings diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json new file mode 100644 index 0000000000..66ce055152 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "exchange_mailbox_primary_smtp_uses_custom_domain", + "CheckTitle": "Mailbox primary SMTP address must use a custom domain", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Exchange Online mailboxes** should use a custom domain as their primary SMTP address, not the default **\\*.onmicrosoft.com** routing domain assigned by Microsoft on tenant creation. This check verifies that the **PrimarySmtpAddress** of every user-facing mailbox does not end with `.onmicrosoft.com`.", + "Risk": "Mailboxes still using **.onmicrosoft.com** as their primary SMTP address leak the internal **tenant identifier** in every From: header, helping attackers fingerprint the tenant for spear-phishing. They also bypass **DMARC/DKIM** hardening that organisations deploy on their custom domains and are frequently treated as low-trust by recipient anti-phishing engines.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/add-domain", + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/domains-faq", + "https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailbox", + "https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/manage-user-mailboxes" + ], + "Remediation": { + "Code": { + "CLI": "Get-Mailbox -ResultSize Unlimited | Where-Object { $_.PrimarySmtpAddress -like '*.onmicrosoft.com' } | ForEach-Object { Set-Mailbox -Identity $_.Identity -PrimarySmtpAddress '@' }", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft 365 admin center (https://admin.microsoft.com/)\n2. Go to Users > Active users and select the affected user\n3. Under the Aliases section, add the custom domain email address\n4. Set the custom domain address as the primary SMTP address\n5. Save changes and repeat for all affected mailboxes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Update the primary SMTP address of all affected mailboxes to use a custom domain. Ensure your custom domain is verified in the Microsoft 365 admin center before making this change.", + "Url": "https://hub.prowler.com/check/exchange_mailbox_primary_smtp_uses_custom_domain" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py new file mode 100644 index 0000000000..d34d091145 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py @@ -0,0 +1,79 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.exchange.exchange_client import exchange_client + + +class exchange_mailbox_primary_smtp_uses_custom_domain(Check): + """ + Verify that every Exchange Online mailbox uses a custom domain as its + primary SMTP address, not the default .onmicrosoft.com routing domain. + + The .onmicrosoft.com domain is assigned by Microsoft on tenant creation + and is not intended for ongoing mail. Mailboxes still using it leak the + internal tenant identifier in every From: header (aiding spear-phishing), + bypass DMARC/DKIM hardening on custom domains and are often treated as + low-trust by recipient anti-phishing engines. + + - PASS: Primary SMTP address does not use the .onmicrosoft.com domain. + - FAIL: Primary SMTP address uses the .onmicrosoft.com domain. + - MANUAL: Exchange Online PowerShell unavailable; check cannot run. + """ + + def execute(self) -> List[CheckReportM365]: + """ + Execute the check against all recipient-facing Exchange Online mailboxes. + + Returns: + List[CheckReportM365]: A report for each mailbox with its SMTP + domain status, or a single MANUAL report if PowerShell was + unavailable. + """ + findings = [] + + # mailboxes is None when Exchange Online PowerShell could not be + # reached or the cmdlet raised. An empty list means PowerShell ran + # but the tenant has no recipient-facing mailboxes (no findings). + if exchange_client.mailboxes is None: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Exchange Online Mailboxes", + resource_id="exchange_mailboxes", + ) + report.status = "MANUAL" + report.status_extended = ( + "Exchange Online PowerShell is unavailable. " + "Enable EXO PowerShell access to run this check." + ) + findings.append(report) + return findings + + for mailbox in exchange_client.mailboxes: + report = CheckReportM365( + metadata=self.metadata(), + resource=mailbox, + resource_name=mailbox.name or mailbox.identity, + resource_id=mailbox.identity, + ) + + if mailbox.primary_smtp_address.endswith(".onmicrosoft.com"): + report.status = "FAIL" + report.status_extended = ( + f"Mailbox {mailbox.identity} " + f"({mailbox.recipient_type_details}) has primary SMTP " + f"address {mailbox.primary_smtp_address} using the " + f".onmicrosoft.com domain instead of a custom domain." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Mailbox {mailbox.identity} " + f"({mailbox.recipient_type_details}) has primary SMTP " + f"address {mailbox.primary_smtp_address} using a " + f"custom domain." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/__init__.py b/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled.metadata.json new file mode 100644 index 0000000000..5d46fcde6f --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "m365", + "CheckID": "exchange_organization_delicensing_resiliency_enabled", + "CheckTitle": "Delicensing Resiliency protects Exchange Online mailboxes from immediate access loss during license changes", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Microsoft 365 Exchange Online** Delicensing Resiliency provides a grace period when licenses expire or are reassigned, preventing immediate mailbox access loss.\n\nThis evaluates whether the organization has **Delayed Delicensing** enabled to protect mailbox data during licensing transitions. Note: This feature is only available to tenants with 5000 or more paid licenses.", + "Risk": "Without **Delicensing Resiliency**, removing or reassigning an Exchange Online license causes **immediate mailbox inaccessibility**. This can lead to data loss, business disruption, and inability to recover mailbox contents during organizational changes such as role transitions or license optimizations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/delicensing-resiliency" + ], + "Remediation": { + "Code": { + "CLI": "Set-OrganizationConfig -DelayedDelicensingEnabled $true", + "NativeIaC": "", + "Other": "1. Connect to Exchange Online PowerShell\n2. Run: Set-OrganizationConfig -DelayedDelicensingEnabled $true\n3. Verify with: Get-OrganizationConfig | Format-List DelayedDelicensingEnabled", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Delicensing Resiliency** to ensure mailbox data is preserved during license transitions. This provides a grace period allowing administrators to reassign licenses or export data before access is permanently revoked, maintaining **business continuity** and **data protection**.", + "Url": "https://hub.prowler.com/check/exchange_organization_delicensing_resiliency_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check includes an automated fixer that runs `Set-OrganizationConfig -DelayedDelicensingEnabled $true` via Exchange Online PowerShell. Delicensing Resiliency is only available to tenants with 5000 or more paid licenses; trial licenses also count toward this threshold but cannot be discerned from the SDK, so tenants at or above the threshold (or with an unknown license count) are reported as a preventive FAIL and eligibility can be confirmed by running the fixer, which succeeds on qualifying tenants and fails otherwise." +} diff --git a/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled.py b/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled.py new file mode 100644 index 0000000000..66fe4d4272 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled.py @@ -0,0 +1,79 @@ +"""Check for Exchange Online Delicensing Resiliency configuration.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.exchange.exchange_client import exchange_client + +DELICENSING_LICENSE_THRESHOLD = 5000 + + +class exchange_organization_delicensing_resiliency_enabled(Check): + """ + Check if Delicensing Resiliency is enabled for Exchange Online. + + Delicensing Resiliency provides a grace period when licenses expire or are + reassigned, preventing immediate mailbox access loss and allowing + organizations time to manage licensing transitions. + + This feature is only available to tenants with 5000 or more paid licenses. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + """ + + def execute(self) -> List[CheckReportM365]: + """ + Execute the check for Delicensing Resiliency in Exchange Online. + + Iterates over the Exchange Online organization configuration and + evaluates whether Delicensing Resiliency is enabled, taking into + account the tenant's paid license count. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + organization_config = exchange_client.organization_config + if organization_config: + report = CheckReportM365( + metadata=self.metadata(), + resource=organization_config, + resource_name=organization_config.name, + resource_id=organization_config.guid, + ) + + if organization_config.delayed_delicensing_enabled: + report.status = "PASS" + report.status_extended = ( + "Delicensing Resiliency is enabled for Exchange Online, " + "providing a grace period when licenses are removed." + ) + elif ( + organization_config.total_paid_licenses is not None + and organization_config.total_paid_licenses + < DELICENSING_LICENSE_THRESHOLD + ): + report.status = "PASS" + report.status_extended = ( + f"Delicensing Resiliency is not applicable for this tenant. " + f"The tenant has {organization_config.total_paid_licenses} " + f"total licenses, which is below the " + f"{DELICENSING_LICENSE_THRESHOLD} paid license threshold " + f"required by Microsoft for this feature." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Delicensing Resiliency is not enabled for Exchange Online. " + "This feature requires the tenant to have 5000 or more paid " + "licenses, and trial licenses also count toward this " + "threshold but cannot be discerned from the SDK, so this " + "is reported as a preventive FAIL. Running the fixer will " + "enable the feature when the tenant qualifies and will " + "fail otherwise, confirming eligibility." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_fixer.py b/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_fixer.py new file mode 100644 index 0000000000..c921686655 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_fixer.py @@ -0,0 +1,57 @@ +"""Fixer for Exchange Online Delicensing Resiliency.""" + +from prowler.lib.logger import logger +from prowler.providers.common.provider import Provider +from prowler.providers.m365.lib.powershell.m365_powershell import M365PowerShell + + +def fixer(resource_id: str = "") -> bool: + """Enable Delicensing Resiliency in Exchange Online. + + Args: + resource_id (str): Unused for this organization-level fixer. + + Returns: + bool: True when the fixer command succeeds, False otherwise. + """ + session = None + + try: + provider = Provider.get_global_provider() + if not provider: + logger.error("Unable to load the global M365 provider for Exchange Online.") + return False + + credentials = getattr(provider, "credentials", None) + identity = getattr(provider, "identity", None) + if not credentials or not identity: + logger.error( + "Unable to load the M365 credentials required for Exchange Online." + ) + return False + + session = M365PowerShell(credentials, identity) + if not session.connect_exchange_online(): + logger.error("Unable to connect to Exchange Online PowerShell.") + return False + + result = session.execute( + "Set-OrganizationConfig -DelayedDelicensingEnabled $true", + timeout=30, + ) + if result: + logger.error( + "PowerShell execution failed while running " + '"Set-OrganizationConfig -DelayedDelicensingEnabled $true": ' + f"{result}" + ) + return False + return True + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return False + finally: + if session: + session.close() diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 978a9b2a10..3ce6528aa9 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -1,3 +1,4 @@ +import asyncio from enum import Enum from typing import Optional @@ -7,6 +8,15 @@ from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +SYSTEM_MAILBOX_TYPES = { + "DiscoveryMailbox", + "ArbitrationMailbox", + "AuditLogMailbox", + "MonitoringMailbox", + "AuxAuditLogMailbox", + "SystemMailbox", +} + class Exchange(M365Service): """ @@ -33,6 +43,7 @@ class Exchange(M365Service): self.role_assignment_policies = [] self.mailbox_audit_properties = [] self.shared_mailboxes = [] + self.mailboxes = None if self.powershell: if self.powershell.connect_exchange_online(): @@ -45,8 +56,53 @@ class Exchange(M365Service): self.role_assignment_policies = self._get_role_assignment_policies() self.mailbox_audit_properties = self._get_mailbox_audit_properties() self.shared_mailboxes = self._get_shared_mailboxes() + self.mailboxes = self._get_mailboxes() self.powershell.close() + # Fetch license count via Graph API + created_loop = False + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + created_loop = True + + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + created_loop = True + + if not loop.is_running(): + total_paid_licenses = loop.run_until_complete( + self._get_total_paid_licenses() + ) + + if created_loop: + asyncio.set_event_loop(None) + loop.close() + + if self.organization_config is not None: + self.organization_config.total_paid_licenses = total_paid_licenses + + async def _get_total_paid_licenses(self) -> Optional[int]: + """Fetch total paid license count from Microsoft Graph subscribed SKUs.""" + logger.info("Microsoft365 - Getting total paid license count...") + try: + subscribed_skus = await self.client.subscribed_skus.get() + total = 0 + for sku in getattr(subscribed_skus, "value", []) or []: + prepaid_units = getattr(sku, "prepaid_units", None) + if prepaid_units: + enabled = getattr(prepaid_units, "enabled", 0) or 0 + total += enabled + return total + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + def _get_organization_config(self): logger.info("Microsoft365 - Getting Exchange Organization configuration...") organization_config = None @@ -74,6 +130,9 @@ class Exchange(M365Service): mailtips_large_audience_threshold=organization_configuration.get( "MailTipsLargeAudienceThreshold", 25 ), + delayed_delicensing_enabled=organization_configuration.get( + "DelayedDelicensingEnabled", False + ), ) except Exception as error: logger.error( @@ -307,8 +366,68 @@ class Exchange(M365Service): ) return shared_mailboxes + def _get_mailboxes(self) -> Optional[list["Mailbox"]]: + """ + Get all recipient-facing mailboxes from Exchange Online. + + Retrieves mailboxes of types UserMailbox, SharedMailbox, RoomMailbox + and EquipmentMailbox. System-managed mailbox types are excluded as + they are controlled by Microsoft and are not subject to domain policy. + + Returns: + list[Mailbox]: List of mailboxes with their primary SMTP address + and recipient type details. Returns ``None`` when the + underlying PowerShell cmdlet raises, so callers can + distinguish "PowerShell unavailable" from "empty tenant". + """ + logger.info("Microsoft365 - Getting mailboxes...") + mailboxes = [] + try: + mailboxes_data = self.powershell.get_mailboxes() + if not mailboxes_data: + return mailboxes + # PowerShell can return a single dict instead of a list when only + # one result is returned; normalize to a list for uniform handling. + if isinstance(mailboxes_data, dict): + mailboxes_data = [mailboxes_data] + for mailbox in mailboxes_data: + if mailbox: + recipient_type = mailbox.get("RecipientTypeDetails", "") + if recipient_type in SYSTEM_MAILBOX_TYPES: + continue + mailboxes.append( + Mailbox( + identity=mailbox.get("Identity", ""), + name=mailbox.get("DisplayName", ""), + primary_smtp_address=mailbox.get("PrimarySmtpAddress", ""), + recipient_type_details=recipient_type, + ) + ) + return mailboxes + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + class Organization(BaseModel): + """ + Model for Exchange Online organization configuration. + + Attributes: + name: Organization display name. + guid: Organization unique identifier. + audit_disabled: Whether auditing is disabled for the organization. + oauth_enabled: Whether OAuth 2.0 (Modern Authentication) is enabled. + mailtips_enabled: Whether MailTips are enabled. + mailtips_external_recipient_enabled: Whether MailTips for external recipients are enabled. + mailtips_group_metrics_enabled: Whether MailTips group metrics are enabled. + mailtips_large_audience_threshold: Threshold for large audience MailTips. + delayed_delicensing_enabled: Whether Delicensing Resiliency is enabled. + total_paid_licenses: Total paid licenses in the tenant, or None if unknown. + """ + name: str guid: str audit_disabled: bool @@ -317,6 +436,8 @@ class Organization(BaseModel): mailtips_external_recipient_enabled: bool mailtips_group_metrics_enabled: bool mailtips_large_audience_threshold: int + delayed_delicensing_enabled: bool = False + total_paid_licenses: Optional[int] = None class MailboxAuditConfig(BaseModel): @@ -431,3 +552,22 @@ class SharedMailbox(BaseModel): user_principal_name: str external_directory_object_id: str identity: str + + +class Mailbox(BaseModel): + """ + Model for an Exchange Online recipient-facing mailbox. + + Attributes: + identity: The unique identity of the mailbox in Exchange. + name: Display name of the mailbox. + primary_smtp_address: The primary SMTP address used for outbound mail + and the From: header. This is the address the check evaluates. + recipient_type_details: The mailbox type (e.g., UserMailbox, + SharedMailbox, RoomMailbox, EquipmentMailbox). + """ + + identity: str + name: str + primary_smtp_address: str + recipient_type_details: str diff --git a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/__init__.py b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json new file mode 100644 index 0000000000..0d7fac5ba5 --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "m365", + "CheckID": "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default", + "CheckTitle": "Built-in Device Compliance Policy marks devices without an assigned compliance policy as Not compliant by default", + "CheckType": [], + "ServiceName": "intune", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "Intune has a built-in Device Compliance Policy that governs how devices without an explicit compliance policy are treated. When the default behavior marks those devices as Compliant, unmanaged devices can be treated as compliant and gain access to corporate resources. This check verifies the default is set to Not compliant (secureByDefault = true).", + "Risk": "If the built-in policy marks devices without a compliance policy as Compliant, those devices can bypass Conditional Access policies requiring device compliance, granting unauthorized access to corporate resources from unmanaged or non-compliant endpoints.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/intune-deviceconfig-devicemanagementsettings?view=graph-rest-1.0" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Intune admin center (intune.microsoft.com)\n2. Go to Devices > Compliance\n3. Select Compliance policy settings\n4. Set 'Mark devices with no compliance policy assigned as' to 'Not compliant'\n5. Save the settings", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the built-in Device Compliance Policy default so devices with no compliance policy assigned are marked as Not compliant.", + "Url": "https://hub.prowler.com/check/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The check evaluates the secureByDefault property from the deviceManagement/settings Microsoft Graph endpoint." +} diff --git a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.py b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.py new file mode 100644 index 0000000000..a7f377baea --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.py @@ -0,0 +1,52 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.intune.intune_client import intune_client + + +class intune_device_compliance_policy_unassigned_devices_not_compliant_by_default( + Check +): + """Ensure the built-in Device Compliance Policy marks unassigned devices as Not compliant by default.""" + + def execute(self) -> list[CheckReportM365]: + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource=intune_client.settings or {}, + resource_name="Intune Device Compliance Settings", + resource_id="deviceManagement/settings", + ) + + verification_error = getattr(intune_client, "verification_error", None) + settings = getattr(intune_client, "settings", None) + secure_by_default = getattr(settings, "secure_by_default", None) + + if verification_error: + report.status = "MANUAL" + report.status_extended = ( + "Intune built-in Device Compliance Policy could not be verified. " + f"{verification_error}" + ) + elif settings is None or secure_by_default is None: + report.status = "MANUAL" + report.status_extended = ( + "Intune built-in Device Compliance Policy could not be verified " + "because Microsoft Graph did not return the secure-by-default " + "compliance setting." + ) + elif secure_by_default is True: + report.status = "PASS" + report.status_extended = ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Not compliant." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Compliant. " + "Change the default to Not compliant in Intune settings." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/intune/intune_service.py b/prowler/providers/m365/services/intune/intune_service.py index 4b62f64e99..2f6c0febae 100644 --- a/prowler/providers/m365/services/intune/intune_service.py +++ b/prowler/providers/m365/services/intune/intune_service.py @@ -125,6 +125,15 @@ class Intune(M365Service): request_configuration=request_configuration ) settings = getattr(device_management, "settings", None) + secure_by_default = getattr(settings, "secure_by_default", None) + + # Some tenants/API responses omit nested settings when $select is used. + # Retry without query parameters before concluding the value is unavailable. + if settings is None or secure_by_default is None: + device_management = await self.client.device_management.get() + settings = getattr(device_management, "settings", None) + secure_by_default = getattr(settings, "secure_by_default", None) + if settings is None: return ( IntuneSettings(secure_by_default=None), @@ -132,9 +141,7 @@ class Intune(M365Service): ) return ( - IntuneSettings( - secure_by_default=getattr(settings, "secure_by_default", None) - ), + IntuneSettings(secure_by_default=secure_by_default), None, ) except Exception as error: diff --git a/prowler/providers/mongodbatlas/mongodbatlas_provider.py b/prowler/providers/mongodbatlas/mongodbatlas_provider.py index c40f1ced6e..07ff6f86cc 100644 --- a/prowler/providers/mongodbatlas/mongodbatlas_provider.py +++ b/prowler/providers/mongodbatlas/mongodbatlas_provider.py @@ -36,6 +36,7 @@ class MongodbatlasProvider(Provider): """ _type: str = "mongodbatlas" + sdk_only: bool = False _session: MongoDBAtlasSession _identity: MongoDBAtlasIdentityInfo _audit_config: dict diff --git a/prowler/providers/nhn/lib/arguments/arguments.py b/prowler/providers/nhn/lib/arguments/arguments.py index 8f7c71fd7c..a102665a26 100644 --- a/prowler/providers/nhn/lib/arguments/arguments.py +++ b/prowler/providers/nhn/lib/arguments/arguments.py @@ -13,7 +13,11 @@ def init_parser(self): "--nhn-username", nargs="?", default=None, help="NHN API Username" ) nhn_auth_subparser.add_argument( - "--nhn-password", nargs="?", default=None, help="NHN API Password" + "--nhn-password", + nargs="?", + default=None, + metavar="NHN_PASSWORD", + help="NHN API Password", ) nhn_auth_subparser.add_argument( "--nhn-tenant-id", nargs="?", default=None, help="NHN Tenant ID" diff --git a/prowler/providers/okta/__init__.py b/prowler/providers/okta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/exceptions/__init__.py b/prowler/providers/okta/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/exceptions/exceptions.py b/prowler/providers/okta/exceptions/exceptions.py new file mode 100644 index 0000000000..04dd71bef0 --- /dev/null +++ b/prowler/providers/okta/exceptions/exceptions.py @@ -0,0 +1,123 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 14000 to 14999 are reserved for Okta exceptions +class OktaBaseException(ProwlerException): + """Base class for Okta Errors.""" + + OKTA_ERROR_CODES = { + (14000, "OktaEnvironmentVariableError"): { + "message": "Okta environment variable error", + "remediation": "Check the Okta environment variables and ensure they are properly set.", + }, + (14001, "OktaSetUpSessionError"): { + "message": "Error setting up Okta session", + "remediation": "Check the OAuth credentials (org URL, client ID, private key, scopes) and ensure they are properly configured.", + }, + (14002, "OktaSetUpIdentityError"): { + "message": "Okta identity setup error due to bad credentials", + "remediation": "Check the OAuth credentials and confirm the service app has been granted the required read scopes.", + }, + (14003, "OktaInvalidCredentialsError"): { + "message": "Okta credentials are not valid", + "remediation": "Check the client ID and private key for the Okta service app.", + }, + (14004, "OktaInvalidOrgDomainError"): { + "message": "Okta organization domain is not valid", + "remediation": "Provide an Okta-managed domain such as .okta.com (or .oktapreview.com / .okta-emea.com / .okta-gov.com / .okta.mil / .okta-miltest.com / .trex-govcloud.com), with no scheme and no trailing slash.", + }, + (14005, "OktaPrivateKeyFileError"): { + "message": "Okta private key file could not be read", + "remediation": "Check the file path and permissions, and ensure the file contains a PEM-encoded RSA key or a JWK JSON document.", + }, + (14006, "OktaInsufficientPermissionsError"): { + "message": "Okta service app is missing required scopes", + "remediation": "Have a Super Admin grant the required *.read scopes to the service app and assign the Read-Only Administrator role.", + }, + (14007, "OktaInvalidProviderIdError"): { + "message": "The provided provider_id does not match the credentials org domain", + "remediation": "Check the provider_id (Okta org domain) and ensure it matches the org the service app credentials were issued for.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Okta" + error_info = self.OKTA_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Okta error.", + "remediation": "Check the Okta API documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class OktaCredentialsError(OktaBaseException): + """Base class for Okta credentials errors.""" + + def __init__(self, code, file=None, original_exception=None, message=None): + super().__init__(code, file, original_exception, message) + + +class OktaEnvironmentVariableError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14000, file=file, original_exception=original_exception, message=message + ) + + +class OktaSetUpSessionError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14001, file=file, original_exception=original_exception, message=message + ) + + +class OktaSetUpIdentityError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14002, file=file, original_exception=original_exception, message=message + ) + + +class OktaInvalidCredentialsError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14003, file=file, original_exception=original_exception, message=message + ) + + +class OktaInvalidOrgDomainError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14004, file=file, original_exception=original_exception, message=message + ) + + +class OktaPrivateKeyFileError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14005, file=file, original_exception=original_exception, message=message + ) + + +class OktaInsufficientPermissionsError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14006, file=file, original_exception=original_exception, message=message + ) + + +class OktaInvalidProviderIdError(OktaCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 14007, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/okta/lib/__init__.py b/prowler/providers/okta/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/lib/arguments/__init__.py b/prowler/providers/okta/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/lib/arguments/arguments.py b/prowler/providers/okta/lib/arguments/arguments.py new file mode 100644 index 0000000000..4ed5cfa187 --- /dev/null +++ b/prowler/providers/okta/lib/arguments/arguments.py @@ -0,0 +1,44 @@ +def init_parser(self): + """Init the Okta Provider CLI parser. + + The Okta provider authenticates with OAuth 2.0 (private-key JWT). The + private key is intentionally not exposed as a CLI flag — secrets must + be supplied via the `OKTA_PRIVATE_KEY` or `OKTA_PRIVATE_KEY_FILE` + environment variable. Non-secret values (org URL, client ID, scopes) + are flag-configurable. + """ + okta_parser = self.subparsers.add_parser( + "okta", parents=[self.common_providers_parser], help="Okta Provider" + ) + okta_auth_subparser = okta_parser.add_argument_group("Authentication") + okta_auth_subparser.add_argument( + "--okta-org-domain", + nargs="?", + help=( + "Okta organization domain (e.g. acme.okta.com). Must be an " + "Okta-managed domain (.okta.com / .oktapreview.com / " + ".okta-emea.com / .okta-gov.com / .okta.mil / " + ".okta-miltest.com / .trex-govcloud.com), without scheme or path." + ), + default=None, + metavar="OKTA_ORG_DOMAIN", + ) + okta_auth_subparser.add_argument( + "--okta-client-id", + nargs="?", + help="Okta service app Client ID for OAuth 2.0 (private-key JWT)", + default=None, + metavar="OKTA_CLIENT_ID", + ) + okta_auth_subparser.add_argument( + "--okta-scopes", + nargs="+", + help=( + "OAuth scopes to request, space-separated " + "(e.g. okta.policies.read okta.brands.read okta.apps.read " + "okta.logStreams.read okta.idps.read). " + "Defaults to the read scopes required by the bundled checks." + ), + default=None, + metavar="OKTA_SCOPES", + ) diff --git a/prowler/providers/okta/lib/mutelist/__init__.py b/prowler/providers/okta/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/lib/mutelist/mutelist.py b/prowler/providers/okta/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..26afeb30fc --- /dev/null +++ b/prowler/providers/okta/lib/mutelist/mutelist.py @@ -0,0 +1,14 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class OktaMutelist(Mutelist): + def is_finding_muted(self, finding: CheckReportOkta, org_domain: str) -> bool: + return self.is_muted( + org_domain, + finding.check_metadata.CheckID, + "*", + finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/okta/lib/service/__init__.py b/prowler/providers/okta/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/lib/service/pagination.py b/prowler/providers/okta/lib/service/pagination.py new file mode 100644 index 0000000000..a30bbf9477 --- /dev/null +++ b/prowler/providers/okta/lib/service/pagination.py @@ -0,0 +1,69 @@ +"""Shared pagination helpers for Okta SDK list calls. + +The Okta SDK exposes paginated list endpoints (`list_applications`, +`list_policies`, `list_log_streams`, `list_identity_providers`, …) that +return a tuple `(items, response, error)`. The next page is signalled +through an RFC 5988 `Link: <…>; rel="next"` header carrying an opaque +`after` cursor. + +These helpers are used by every Okta service that needs to drain a +paginated endpoint. They live here so we don't keep copy-pasting them +into each service module. +""" + +from typing import Optional +from urllib.parse import parse_qs, urlparse + + +def next_after_cursor(resp) -> Optional[str]: + """Extract the `after` cursor from a `Link: ...; rel="next"` header. + + Returns None when there is no next page. Header format follows RFC + 5988 and Okta's pagination guide. + """ + if resp is None: + return None + headers = getattr(resp, "headers", None) or {} + link = headers.get("link") or headers.get("Link") or "" + if not link: + return None + for part in link.split(","): + if 'rel="next"' not in part: + continue + url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") + cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] + if cursor: + return cursor + return None + + +async def paginate(fetch): + """Drain all pages of an SDK list call. + + `fetch` is a callable that accepts the `after` cursor (or None for + the first page) and returns the SDK's standard `(items, resp, err)` + tuple — or the 2-tuple early-error shape `(items, err)`. Follows the + `Link: rel="next"` header until exhausted. The returned tuple is + `(all_items, error)` — error is non-None only when a page fails + to fetch. + """ + all_items = [] + result = await fetch(None) + err = result[-1] + if err is not None: + return [], err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + while True: + cursor = next_after_cursor(resp) + if not cursor: + break + result = await fetch(cursor) + err = result[-1] + if err is not None: + return all_items, err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + return all_items, None diff --git a/prowler/providers/okta/lib/service/raw_fetch.py b/prowler/providers/okta/lib/service/raw_fetch.py new file mode 100644 index 0000000000..42e8eede11 --- /dev/null +++ b/prowler/providers/okta/lib/service/raw_fetch.py @@ -0,0 +1,141 @@ +"""Raw-JSON HTTP fetch via the Okta SDK's request executor. + +Some Okta Management API endpoints are not yet exposed as typed methods +on the SDK client (e.g. `/api/v1/automations`), or the typed path's +pydantic deserialization rejects values the API actually returns (e.g. +the `KnowledgeConstraint.types` lowercase issue we hit on +`list_policy_rules`). In both cases we go around the typed layer: +construct the request via `client._request_executor.create_request`, +execute without a response type, and parse the body ourselves. + +`get_json` returns the parsed JSON payload (typically a list or dict) +or raises with a descriptive log line on any of the failure modes — +request build, transport, decode, parse. `get_json_paginated` drains +list endpoints by following the `Link: rel="next"` cursor — without it, +the raw fallback would silently truncate at the per-request `limit`. +Callers are expected to project the JSON onto their own pydantic snapshot. +""" + +import json +from typing import Any, Optional +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import next_after_cursor + + +async def get_json( + client, + path: str, + *, + accept: str = "application/json", + context: Optional[str] = None, +) -> Optional[Any]: + """GET `path` via the SDK's request executor and return parsed JSON. + + Returns the decoded JSON payload on success, or None when the + request, transport, or decode steps fail. Each failure path emits a + `logger.error` line tagged with `context` so the caller can grep + for it. + """ + label = context or path + request, error = await client._request_executor.create_request( + method="GET", + url=path, + body=None, + headers={"Accept": accept}, + ) + if error is not None: + logger.error(f"Raw fetch (create_request) failed for {label}: {error}") + return None + + _response, response_body, error = await client._request_executor.execute(request) + if error is not None: + logger.error(f"Raw fetch (execute) failed for {label}: {error}") + return None + + if isinstance(response_body, (bytes, bytearray)): + try: + response_body = response_body.decode("utf-8") + except UnicodeDecodeError as decode_err: + logger.error(f"Could not decode response for {label}: {decode_err}") + return None + try: + return json.loads(response_body) if response_body else None + except json.JSONDecodeError as decode_err: + logger.error(f"Could not parse JSON for {label}: {decode_err}") + return None + + +async def get_json_paginated( + client, + path: str, + *, + page_size: int = 200, + accept: str = "application/json", + context: Optional[str] = None, +) -> Optional[list]: + """Drain all pages of a raw-JSON list endpoint. + + Mirrors the typed `pagination.paginate` shape but operates on the + SDK's request executor directly. Follows the `Link: rel="next"` + header until exhausted, accumulating items across pages. Returns + the concatenated list, or None if any page fails to fetch or the + response is not a JSON array. + + `page_size` is appended as `limit=N` to the first request; subsequent + requests use the URL Okta returns via the cursor. + """ + label = context or path + all_items: list = [] + current_path = _set_query(path, {"limit": str(page_size)}) + while True: + request, error = await client._request_executor.create_request( + method="GET", + url=current_path, + body=None, + headers={"Accept": accept}, + ) + if error is not None: + logger.error(f"Raw fetch (create_request) failed for {label}: {error}") + return None + + response, response_body, error = await client._request_executor.execute(request) + if error is not None: + logger.error(f"Raw fetch (execute) failed for {label}: {error}") + return None + + if isinstance(response_body, (bytes, bytearray)): + try: + response_body = response_body.decode("utf-8") + except UnicodeDecodeError as decode_err: + logger.error(f"Could not decode response for {label}: {decode_err}") + return None + if not response_body: + break + try: + page = json.loads(response_body) + except json.JSONDecodeError as decode_err: + logger.error(f"Could not parse JSON for {label}: {decode_err}") + return None + if not isinstance(page, list): + logger.error( + f"Unexpected raw payload shape for {label}: " + f"{type(page).__name__}; expected list" + ) + return None + all_items.extend(page) + + cursor = next_after_cursor(response) + if not cursor: + break + current_path = _set_query(path, {"limit": str(page_size), "after": cursor}) + return all_items + + +def _set_query(path: str, params: dict) -> str: + """Return `path` with the given query params merged in (overriding existing).""" + parsed = urlparse(path) + qs = dict(parse_qsl(parsed.query)) + qs.update({k: v for k, v in params.items() if v is not None}) + return urlunparse(parsed._replace(query=urlencode(qs))) diff --git a/prowler/providers/okta/lib/service/service.py b/prowler/providers/okta/lib/service/service.py new file mode 100644 index 0000000000..baaabcb219 --- /dev/null +++ b/prowler/providers/okta/lib/service/service.py @@ -0,0 +1,34 @@ +import asyncio +from typing import TYPE_CHECKING + +from okta.client import Client as OktaSDKClient + +from prowler.providers.okta.models import OktaSession + +if TYPE_CHECKING: + from prowler.providers.okta.okta_provider import OktaProvider + + +class OktaService: + """Base class for Okta service implementations. + + Wraps the async okta-sdk-python `Client` so that subclasses can stay + synchronous like the other Prowler providers. The SDK auto-refreshes + the OAuth access token; nothing to manage here. + """ + + def __init__(self, service: str, provider: "OktaProvider"): + self.provider = provider + self.service = service + self.client = self.__set_client__(provider.session) + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + + @staticmethod + def __set_client__(session: OktaSession) -> OktaSDKClient: + return OktaSDKClient(session.to_sdk_config()) + + @staticmethod + def _run(coro): + """Run an okta-sdk-python coroutine from synchronous code.""" + return asyncio.run(coro) diff --git a/prowler/providers/okta/models.py b/prowler/providers/okta/models.py new file mode 100644 index 0000000000..b3f5c1f1cc --- /dev/null +++ b/prowler/providers/okta/models.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class OktaSession(BaseModel): + org_domain: str + client_id: str + scopes: list[str] + private_key: str + + def to_sdk_config(self) -> dict: + # Shared by the credential probe (OktaProvider.setup_identity) and + # the service-level client (OktaService.__set_client__). Keeping the + # builder in one place stops the two SDK config dicts from drifting. + # The Okta SDK expects a fully-qualified `orgUrl`; we build it from + # the validated domain so user input stays scheme-free. + # DPoP proofs are sent on every token request — required by tenants + # with "Demonstrating Proof of Possession" enabled on the service + # app (or org-wide), harmless on tenants that don't. + return { + "orgUrl": f"https://{self.org_domain}", + "authorizationMode": "PrivateKey", + "clientId": self.client_id, + "scopes": self.scopes, + "privateKey": self.private_key, + "dpopEnabled": True, + } + + +class OktaIdentityInfo(BaseModel): + org_domain: str + client_id: str + # Scopes actually granted in the access token (`scp` claim). Used by + # services to distinguish "no data" from "no permission" so checks can + # surface the missing scope rather than a misleading FAIL. Empty when + # decoding the token was not possible — callers must treat empty as + # "unknown" and fall back to attempting the API call. + granted_scopes: list[str] = [] + + +class OktaOutputOptions(ProviderOutputOptions): + def __init__(self, arguments, bulk_checks_metadata, identity): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + self.output_filename = ( + f"prowler-output-{identity.org_domain}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py new file mode 100644 index 0000000000..f046dd2e35 --- /dev/null +++ b/prowler/providers/okta/okta_provider.py @@ -0,0 +1,467 @@ +import asyncio +import base64 +import json +import os +import re +from os import environ +from typing import Optional, Union + +from colorama import Fore, Style +from okta.client import Client as OktaSDKClient + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.okta.exceptions.exceptions import ( + OktaEnvironmentVariableError, + OktaInsufficientPermissionsError, + OktaInvalidCredentialsError, + OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, + OktaPrivateKeyFileError, + OktaSetUpIdentityError, + OktaSetUpSessionError, +) +from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist +from prowler.providers.okta.models import OktaIdentityInfo, OktaSession + +DEFAULT_SCOPES = [ + "okta.policies.read", + "okta.brands.read", + "okta.apps.read", + "okta.authenticators.read", + "okta.networkZones.read", + "okta.apiTokens.read", + "okta.roles.read", + "okta.groups.read", + "okta.logStreams.read", + "okta.idps.read", +] +# Accept only Okta-managed domains. Custom (vanity) domains are rejected on +# purpose — they're a recurring source of typos and silent misconfig and +# Prowler's audience overwhelmingly uses Okta-managed hosts. The TLDs below +# match the set the Okta SDK whitelists in `okta.config.config_validator`, +# which includes the commercial, preview, EMEA and US gov/mil environments. +# If a customer with a custom domain shows up, lift this guard behind an +# explicit opt-in. +ORG_DOMAIN_RE = re.compile( + r"^[a-z0-9][a-z0-9-]*\.(" + r"okta\.com|oktapreview\.com|okta-emea\.com|" + r"okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com" + r")$" +) + + +class OktaProvider(Provider): + """Okta Provider class. + + Authenticates against an Okta organization using OAuth 2.0 with a + private-key JWT (Client Credentials grant). The SDK requests and + refreshes the access token internally. + + Attributes: + _type (str): The type of the provider. + _auth_method (str): The authentication method used by the provider. + _session (OktaSession): The session object for the provider. + _identity (OktaIdentityInfo): The identity information for the provider. + _audit_config (dict): The audit configuration for the provider. + _fixer_config (dict): The fixer configuration for the provider. + _mutelist (Mutelist): The mutelist for the provider. + audit_metadata (Audit_Metadata): The audit metadata for the provider. + """ + + _type: str = "okta" + sdk_only: bool = False + _auth_method: str = None + _session: OktaSession + _identity: OktaIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: Mutelist + audit_metadata: Audit_Metadata + + def __init__( + self, + okta_org_domain: str = "", + okta_client_id: str = "", + okta_private_key: str = "", + okta_private_key_file: str = "", + okta_scopes: Optional[Union[str, list[str]]] = None, + config_path: str = None, + config_content: dict = None, + fixer_config: dict = {}, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + """Okta Provider constructor.""" + logger.info("Instantiating Okta Provider...") + + OktaProvider.validate_arguments( + okta_org_domain=okta_org_domain, + okta_client_id=okta_client_id, + okta_private_key=okta_private_key, + okta_private_key_file=okta_private_key_file, + ) + self._session = OktaProvider.setup_session( + org_domain=okta_org_domain, + client_id=okta_client_id, + private_key=okta_private_key, + private_key_file=okta_private_key_file, + scopes=okta_scopes, + ) + self._identity = OktaProvider.setup_identity(self._session) + self._auth_method = "OAuth 2.0 (private-key JWT)" + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + self._fixer_config = fixer_config + + if mutelist_content: + self._mutelist = OktaMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = OktaMutelist(mutelist_path=mutelist_path) + + Provider.set_global_provider(self) + + @property + def auth_method(self): + return self._auth_method + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def type(self): + return self._type + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> OktaMutelist: + return self._mutelist + + @staticmethod + def validate_arguments( + okta_org_domain: str = "", + okta_client_id: str = "", + okta_private_key: str = "", + okta_private_key_file: str = "", + ): + """Validate that all required OAuth credentials are provided. + + Falls back to the matching `OKTA_*` environment variables when a CLI + argument is not supplied. The private key may be supplied as raw + content (preferred for API/UI integrations) or as a file path. + Raises a single combined error if any required value is missing. + """ + org_domain = okta_org_domain or environ.get("OKTA_ORG_DOMAIN", "") + client_id = okta_client_id or environ.get("OKTA_CLIENT_ID", "") + private_key = okta_private_key or environ.get("OKTA_PRIVATE_KEY", "") + private_key_file = okta_private_key_file or environ.get( + "OKTA_PRIVATE_KEY_FILE", "" + ) + + missing = [] + if not org_domain: + missing.append("--okta-org-domain / OKTA_ORG_DOMAIN") + if not client_id: + missing.append("--okta-client-id / OKTA_CLIENT_ID") + if not private_key and not private_key_file: + missing.append("OKTA_PRIVATE_KEY (or OKTA_PRIVATE_KEY_FILE)") + if missing: + raise OktaEnvironmentVariableError( + file=os.path.basename(__file__), + message=( + "Okta provider requires all OAuth credentials. Missing: " + + ", ".join(missing) + ), + ) + + @staticmethod + def setup_session( + org_domain: str = "", + client_id: str = "", + private_key: str = "", + private_key_file: str = "", + scopes: Optional[Union[str, list[str]]] = None, + ) -> OktaSession: + """Build an OktaSession from CLI args, falling back to environment variables. + + Accepts the private key as raw content (`private_key` / + `OKTA_PRIVATE_KEY`) or as a file path (`private_key_file` / + `OKTA_PRIVATE_KEY_FILE`). Content takes precedence when both are + supplied — this matches the GitHub provider pattern and keeps the + API/UI integrations from having to write keys to disk. + """ + try: + org_domain = org_domain or environ.get("OKTA_ORG_DOMAIN", "") + client_id = client_id or environ.get("OKTA_CLIENT_ID", "") + private_key = private_key or environ.get("OKTA_PRIVATE_KEY", "") + private_key_file = private_key_file or environ.get( + "OKTA_PRIVATE_KEY_FILE", "" + ) + if not scopes: + scopes = environ.get("OKTA_SCOPES", "") + + org_domain = org_domain.strip().lower() + if not ORG_DOMAIN_RE.match(org_domain): + raise OktaInvalidOrgDomainError( + file=os.path.basename(__file__), + message=( + f"Invalid Okta org domain: '{org_domain}'. Expected " + "an Okta-managed domain such as .okta.com " + "(or .oktapreview.com / .okta-emea.com / " + ".okta-gov.com / .okta.mil / .okta-miltest.com / " + ".trex-govcloud.com), with no scheme and no path." + ), + ) + + if private_key: + private_key = private_key.strip() + else: + try: + with open(private_key_file, "r") as fh: + private_key = fh.read().strip() + except OSError as error: + raise OktaPrivateKeyFileError( + file=os.path.basename(__file__), + original_exception=error, + message=f"Could not read private key file '{private_key_file}': {error}", + ) + if not private_key: + raise OktaPrivateKeyFileError( + file=os.path.basename(__file__), + message=( + f"Private key file '{private_key_file}' is empty." + if private_key_file + else "Private key content is empty." + ), + ) + + # Accept either a CSV string (from env var / legacy callers) or + # a list[str] (from programmatic callers and the CLI's nargs="+"). + # List elements may themselves contain commas (e.g. "a,b") and + # are flattened to support mixed input. + if isinstance(scopes, str): + raw_items = scopes.split(",") + elif isinstance(scopes, list): + raw_items = [item for s in scopes for item in str(s).split(",")] + else: + raw_items = [] + scope_list = [s.strip() for s in raw_items if s and s.strip()] + if not scope_list: + scope_list = list(DEFAULT_SCOPES) + + return OktaSession( + org_domain=org_domain, + client_id=client_id, + scopes=scope_list, + private_key=private_key, + ) + + except (OktaInvalidOrgDomainError, OktaPrivateKeyFileError): + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + raise OktaSetUpSessionError(original_exception=error) + + @staticmethod + def setup_identity(session: OktaSession) -> OktaIdentityInfo: + """Synthesize identity from the session and verify credentials. + + Service apps don't represent a human user, so the identity is the + org URL plus the service-app client ID. We still hit the cheapest + scope-covered endpoint (`list_policies` with limit=1) to fail loud + when credentials, scopes, or the granted admin role are wrong. + + After the probe succeeds, the access token's `scp` claim is + decoded and exposed on the identity. Services compare it against + their required scope so checks can emit "missing scope X" rather + than a misleading "no resources returned" finding. + """ + + async def _probe(): + client = OktaSDKClient(session.to_sdk_config()) + result = await client.list_policies(type="OKTA_SIGN_ON", limit="1") + access_token = None + # The OAuth helper caches the token on `_access_token` after + # the first authenticated call. Reach through `_request_executor` + # — a documented internal but a moving target across SDK + # versions, so any failure here degrades silently to empty + # granted_scopes (services then fall back to attempting calls). + try: + oauth = getattr(client._request_executor, "_oauth", None) + if oauth is not None: + access_token = getattr(oauth, "_access_token", None) + except Exception: + access_token = None + return result, access_token + + try: + result, access_token = asyncio.run(_probe()) + # SDK returns (items, resp, err) on the normal path and (items, err) + # only on early request-creation errors. The error is always last. + err = result[-1] + if err is not None: + err_text = str(err).lower() + # Distinguish scope/role failures from generic credential + # failures — different remediation paths in the docs. + permission_signals = ( + "invalid_scope", + "forbidden", + "not authorized", + "permission", + # Okta emits HTTP 400 `consent_required` when none of the + # requested scopes are consented on the service app — + # semantically a permission gap, not a credential one. + "consent_required", + "not allowed", + ) + if any(signal in err_text for signal in permission_signals): + raise OktaInsufficientPermissionsError( + file=os.path.basename(__file__), + message=( + "Okta rejected the credential probe with a " + f"permission-related error: {err}" + ), + ) + raise OktaInvalidCredentialsError( + file=os.path.basename(__file__), + message=f"Failed to authenticate against Okta: {err}", + ) + return OktaIdentityInfo( + org_domain=session.org_domain, + client_id=session.client_id, + granted_scopes=OktaProvider._decode_token_scopes(access_token), + ) + except (OktaInvalidCredentialsError, OktaInsufficientPermissionsError): + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + raise OktaSetUpIdentityError(original_exception=error) + + @staticmethod + def _decode_token_scopes(access_token: Optional[str]) -> list[str]: + """Return the `scp` claim from a JWT access token, or `[]` on failure. + + No signature verification: the token came from Okta over TLS via + the SDK's OAuth handshake, so the only thing we extract is the + scope claim. Any decode error returns an empty list — callers + must treat empty as "unknown" rather than "no scopes granted". + """ + if not access_token: + return [] + try: + parts = access_token.split(".") + if len(parts) < 2: + return [] + payload_b64 = parts[1] + # Base64url pad to a multiple of 4 — JWT segments are + # unpadded per RFC 7515. + padding = "=" * (-len(payload_b64) % 4) + payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding) + payload = json.loads(payload_bytes) + scp = payload.get("scp") + if isinstance(scp, list): + return [str(s) for s in scp if s] + if isinstance(scp, str): + return [s for s in scp.split(" ") if s] + return [] + except Exception as error: + logger.warning( + f"Could not decode Okta access token scopes: " + f"{error.__class__.__name__}: {error}" + ) + return [] + + def print_credentials(self): + report_lines = [ + f"Okta Domain: {Fore.YELLOW}{self.identity.org_domain}{Style.RESET_ALL}", + f"Okta Client ID: {Fore.YELLOW}{self.identity.client_id}{Style.RESET_ALL}", + f"Authentication Method: {Fore.YELLOW}{self.auth_method}{Style.RESET_ALL}", + ] + report_title = ( + f"{Style.BRIGHT}Using the Okta credentials below:{Style.RESET_ALL}" + ) + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + okta_org_domain: str = "", + okta_client_id: str = "", + okta_private_key: str = "", + okta_private_key_file: str = "", + okta_scopes: Optional[Union[str, list[str]]] = None, + raise_on_exception: bool = True, + provider_id: str = None, + ) -> Connection: + """Test the connection to Okta with the provided OAuth credentials. + + Args: + provider_id: The provider ID (Okta org domain). When supplied, the + authenticated org domain must match it — guards against the + stored provider UID drifting from the org the credentials were + actually issued for. Compared case-insensitively, matching the + normalization applied during session setup. + """ + try: + OktaProvider.validate_arguments( + okta_org_domain=okta_org_domain, + okta_client_id=okta_client_id, + okta_private_key=okta_private_key, + okta_private_key_file=okta_private_key_file, + ) + session = OktaProvider.setup_session( + org_domain=okta_org_domain, + client_id=okta_client_id, + private_key=okta_private_key, + private_key_file=okta_private_key_file, + scopes=okta_scopes, + ) + identity = OktaProvider.setup_identity(session) + + if provider_id and provider_id.strip().lower() != identity.org_domain: + raise OktaInvalidProviderIdError( + file=os.path.basename(__file__), + message=( + f"The provider ID '{provider_id}' does not match the " + f"authenticated Okta org domain '{identity.org_domain}'." + ), + ) + + return Connection(is_connected=True) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise error + return Connection(error=error) diff --git a/prowler/providers/okta/services/__init__.py b/prowler/providers/okta/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/__init__.py b/prowler/providers/okta/services/apitoken/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/api_token_client.py b/prowler/providers/okta/services/apitoken/api_token_client.py new file mode 100644 index 0000000000..fbe10d7c7f --- /dev/null +++ b/prowler/providers/okta/services/apitoken/api_token_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.apitoken.api_token_service import ApiToken + +api_token_client = ApiToken(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/apitoken/api_token_service.py b/prowler/providers/okta/services/apitoken/api_token_service.py new file mode 100644 index 0000000000..5fafd71c35 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/api_token_service.py @@ -0,0 +1,327 @@ +from typing import Optional + +from pydantic import BaseModel, Field, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "api_tokens": "okta.apiTokens.read", + "network_zones": "okta.networkZones.read", + "user_roles": "okta.roles.read", + # Needed to resolve admin roles inherited via group membership. + # `/api/v1/users/{id}/roles` returns only direct role assignments; + # group-inherited Super Admin is invisible without `okta.groups.read` + # to enumerate the user's groups. + "user_groups": "okta.groups.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +def _role_to_string(role) -> str: + """Pick the most specific role identifier from an SDK Role object. + + `list_assigned_roles_for_user` and `list_group_assigned_roles` return + `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper that holds + the real `StandardRole`/`CustomRole` on `.actual_instance`. Reading + `.type`/`.label` from the wrapper returns None and the role silently + disappears, so unwrap first. + """ + inner = getattr(role, "actual_instance", None) or role + return _value(getattr(inner, "type", None)) or _value(getattr(inner, "label", None)) + + +def _raw_value(item, key: str) -> str: + """Return a string value from an SDK model or raw dictionary.""" + if isinstance(item, dict): + return _value(item.get(key)) + return _value(getattr(item, key, None)) + + +class ApiToken(OktaService): + """Fetches Okta API token metadata, token owners' roles, and zones.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + # Per-resource caches keyed on the Okta resource id. API tokens + # commonly share owners (e.g. a service user holding multiple + # tokens) and admin groups frequently overlap across users, so we + # memoize the resolutions within a single service instance. + self._user_roles_cache: dict[str, list[str]] = {} + self._group_roles_cache: dict[str, list[str]] = {} + self.known_network_zone_ids: set[str] = ( + set() + if self.missing_scope["api_tokens"] or self.missing_scope["network_zones"] + else self._list_known_network_zone_ids() + ) + self.api_tokens: dict[str, OktaApiToken] = ( + {} if self.missing_scope["api_tokens"] else self._list_api_tokens() + ) + + def _list_api_tokens(self) -> dict[str, "OktaApiToken"]: + """List active API token metadata and owner roles.""" + logger.info("ApiToken - Listing Okta API tokens...") + try: + return self._run(self._fetch_api_tokens()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_api_tokens(self) -> dict[str, "OktaApiToken"]: + # `list_api_tokens` is non-paginated in the SDK (no `after` + # parameter); we inline the tuple unwrap rather than going + # through `paginate`. Same pattern application_service uses for + # `get_first_party_app_settings`. + result: dict[str, OktaApiToken] = {} + sdk_result = await self.client.list_api_tokens() + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing API tokens: {err}") + return result + items = sdk_result[0] or [] + + for token in items: + token_id = _value(getattr(token, "id", None)) + user_id = _value(getattr(token, "user_id", None)) + roles = ( + await self._fetch_effective_user_role_types(user_id) if user_id else [] + ) + network = getattr(token, "network", None) + token_obj = OktaApiToken( + id=token_id, + name=_value(getattr(token, "name", None)) or token_id, + client_name=_value(getattr(token, "client_name", None)), + user_id=user_id, + network_connection=_value(getattr(network, "connection", None)), + network_includes=list(getattr(network, "include", None) or []), + network_excludes=list(getattr(network, "exclude", None) or []), + owner_roles=roles, + ) + result[token_obj.id] = token_obj + return result + + async def _fetch_effective_user_role_types(self, user_id: str) -> list[str]: + """Return direct + group-inherited admin role types for `user_id`. + + Okta's `/api/v1/users/{userId}/roles` (the SDK's + `list_assigned_roles_for_user`) only returns roles assigned + *directly* to the user. Roles inherited via group membership are + invisible to that endpoint — but they are how Okta normally + grants Super Admin (e.g. the org creator joins the default + "Okta Super Admins" group). Without resolving group-inherited + roles, the Super Admin check would falsely PASS for any token + whose owner gets admin via a group. + + Results are memoized per `user_id` so multiple tokens with the + same owner cost a single resolution. + """ + if user_id in self._user_roles_cache: + return self._user_roles_cache[user_id] + direct = await self._fetch_direct_user_role_types(user_id) + inherited = await self._fetch_group_inherited_role_types(user_id) + # Dedupe while preserving first-seen order (direct first, then + # inherited) so the status_extended reads from most-specific. + seen: set[str] = set() + combined: list[str] = [] + for role in (*direct, *inherited): + if role and role not in seen: + combined.append(role) + seen.add(role) + self._user_roles_cache[user_id] = combined + return combined + + async def _fetch_direct_user_role_types(self, user_id: str) -> list[str]: + """Return roles assigned directly to the user (no group inheritance).""" + if self.missing_scope["user_roles"]: + return [] + # `list_assigned_roles_for_user` is non-paginated in the SDK + # (no `after` parameter); inline the tuple unwrap. + sdk_result = await self.client.list_assigned_roles_for_user(user_id) + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing roles for token owner {user_id}: {err}") + return [] + items = sdk_result[0] or [] + roles = [_role_to_string(role) for role in items if _role_to_string(role)] + if roles or not items: + return roles + + # Belt-and-suspenders: when the SDK's typed parse returns items + # but every projection ends up empty (a discriminator surface we + # don't yet handle, a future schema change, …), fall back to the + # raw JSON. The `_role_to_string` unwrap above already covers the + # known `ListGroupAssignedRoles200ResponseInner` oneOf wrapper + # bug — this fallback exists for whatever the next SDK quirk is. + return await self._fetch_user_role_types_raw(user_id) + + async def _fetch_user_role_types_raw(self, user_id: str) -> list[str]: + """Return user role types from the raw response when typed models are empty. + + Uses the shared `get_json_paginated` helper so any `Link: next` + header the API returns is followed (role lists are typically + small, but the SDK doesn't paginate this endpoint at all so the + only correct way to drain it lives here). + """ + raw_items = await _raw_get_json_paginated( + self.client, + f"/api/v1/users/{user_id}/roles", + context=f"user roles for {user_id}", + ) + if raw_items is None: + return [] + roles = [ + _value(role.get("type")) or _value(role.get("label")) + for role in raw_items + if isinstance(role, dict) + ] + return [role for role in roles if role] + + async def _fetch_group_inherited_role_types(self, user_id: str) -> list[str]: + """Return roles inherited via the user's group memberships. + + Each group's role list is itself memoized — admin groups are + commonly shared across many users. + """ + if self.missing_scope["user_roles"] or self.missing_scope["user_groups"]: + return [] + # Defensive try/except: tenants we've seen in the wild return 403 + # on `/api/v1/users/{id}/groups` even when `okta.groups.read` is + # granted (admin-role on the service app gates the response + # separately). Treat any failure as "no inherited roles" so the + # caller still surfaces direct roles cleanly. + try: + sdk_result = await self.client.list_user_groups(user_id) + except Exception as error: + logger.error( + f"Error listing groups for token owner {user_id}: " + f"{error.__class__.__name__}: {error}" + ) + return [] + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing groups for token owner {user_id}: {err}") + return [] + groups = sdk_result[0] or [] + roles: list[str] = [] + for group in groups: + group_id = _value(getattr(group, "id", None)) + if not group_id: + continue + if group_id in self._group_roles_cache: + roles.extend(self._group_roles_cache[group_id]) + continue + # Per-group try/except: one group's parse or auth failure + # must not erase admin-role coverage for other groups. + try: + group_roles = await self._fetch_group_role_types(group_id) + except Exception as error: + logger.error( + f"Error listing roles for group {group_id} " + f"(owner={user_id}): {error.__class__.__name__}: {error}" + ) + group_roles = [] + self._group_roles_cache[group_id] = group_roles + roles.extend(group_roles) + return roles + + async def _fetch_group_role_types(self, group_id: str) -> list[str]: + """Return role types assigned to `group_id`.""" + sdk_result = await self.client.list_group_assigned_roles(group_id) + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing roles for group {group_id}: {err}") + return [] + items = sdk_result[0] or [] + return [_role_to_string(role) for role in items if _role_to_string(role)] + + def _list_known_network_zone_ids(self) -> set[str]: + """List known Network Zone ids and names for token condition validation.""" + logger.info("ApiToken - Listing Network Zones for token restrictions...") + try: + return self._run(self._fetch_known_network_zone_ids()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return set() + + async def _fetch_known_network_zone_ids(self) -> set[str]: + identifiers: set[str] = set() + items, err = await self._fetch_all_network_zones() + if err is not None: + logger.error(f"Error listing Network Zones for API token checks: {err}") + return identifiers + for zone in items: + zone_id = _raw_value(zone, "id") + zone_name = _raw_value(zone, "name") + if zone_id: + identifiers.add(zone_id) + if zone_name: + identifiers.add(zone_name) + return identifiers + + async def _fetch_all_network_zones(self) -> tuple[list, object]: + """Drain all Network Zone pages for API token reference validation. + + Catches the upstream Okta SDK ↔ Management API schema drift on + Enhanced Dynamic Zones (object-shaped pydantic model where the + API returns a JSON array) the same way `network_zone_service` + does. `(ValueError, ValidationError)` covers both discriminator + misses and model mismatches — matching the `user_service` + precedent. + """ + try: + return await _paginate_shared( + lambda after: self.client.list_network_zones(after=after, limit=200) + ) + except (ValueError, ValidationError) as ex: + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing Network Zones " + "for API token validation — falling back to raw-JSON parse." + ) + return await self._fetch_all_network_zones_raw() + + async def _fetch_all_network_zones_raw(self) -> tuple[list, object]: + """Drain Network Zone pages via the shared raw-JSON helper.""" + items = await _raw_get_json_paginated( + self.client, + "/api/v1/zones", + page_size=200, + context="Network Zones for API token validation", + ) + if items is None: + return [], Exception("raw Network Zones fetch failed; see logs") + return items, None + + +class OktaApiToken(BaseModel): + """Normalized Okta API token metadata used by checks.""" + + id: str + name: str + client_name: str = "" + user_id: str = "" + network_connection: str = "" + network_includes: list[str] = Field(default_factory=list) + network_excludes: list[str] = Field(default_factory=list) + owner_roles: list[str] = Field(default_factory=list) diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json new file mode 100644 index 0000000000..d15c5b4680 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "apitoken_not_super_admin", + "CheckTitle": "Okta API tokens are not owned by Super Admin users", + "CheckType": [], + "ServiceName": "apitoken", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Okta API token ownership** should avoid Super Admin users because API tokens inherit the admin permissions of the user that created them.", + "Risk": "**Super Admin-owned API tokens** become high-impact secrets: if one is exposed, an attacker can perform broad organization administration with the token owner privileges.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/guides/roles", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Create a dedicated service account, assign only required admin roles, rotate the API token, and revoke Super Admin-owned tokens.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Use dedicated Okta service accounts** for API tokens and assign only the least-privilege admin roles required; rotate and revoke tokens created by Super Admin users.", + "Url": "https://hub.prowler.com/check/apitoken_not_super_admin" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py new file mode 100644 index 0000000000..0971af4aab --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py @@ -0,0 +1,70 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_client import api_token_client +from prowler.providers.okta.services.apitoken.lib.api_token_helpers import ( + missing_api_token_scope_finding, + missing_user_roles_scope_for_token_finding, + owner_has_super_admin, +) + + +class apitoken_not_super_admin(Check): + """Ensure Okta API tokens are not owned by Super Admin users.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate every active API token owner's assigned admin roles.""" + org_domain = api_token_client.provider.identity.org_domain + missing_api_token_scope = api_token_client.missing_scope.get("api_tokens") + if missing_api_token_scope: + return [ + missing_api_token_scope_finding( + self.metadata(), + org_domain, + missing_api_token_scope, + additional_required=["okta.roles.read", "okta.groups.read"], + ) + ] + + missing_user_roles_scope = api_token_client.missing_scope.get("user_roles") + # `okta.groups.read` is needed to resolve admin roles inherited via + # group membership. Without it we fall back to direct-only role + # assignments, which Okta returns for `/api/v1/users/{id}/roles` — + # commonly empty for trial accounts where Super Admin is granted + # through the default admin group. The finding stays evaluable but + # is flagged as best-effort so operators know to grant the scope. + missing_user_groups_scope = api_token_client.missing_scope.get("user_groups") + findings: list[CheckReportOkta] = [] + for token in api_token_client.api_tokens.values(): + report = CheckReportOkta( + metadata=self.metadata(), resource=token, org_domain=org_domain + ) + if missing_user_roles_scope: + report = missing_user_roles_scope_for_token_finding( + self.metadata(), org_domain, token, missing_user_roles_scope + ) + elif owner_has_super_admin(token): + report.status = "FAIL" + report.status_extended = ( + f"API token {token.name} is owned by user {token.user_id} " + "with the Super Admin role. Use a dedicated service account " + "with least-privilege admin roles instead." + ) + else: + roles = ( + ", ".join(token.owner_roles) + if token.owner_roles + else "no admin roles returned" + ) + caveat = ( + " Group-inherited roles were not checked because the " + f"`{missing_user_groups_scope}` scope is missing — grant " + "it to detect Super Admin assigned via group membership." + if missing_user_groups_scope + else "" + ) + report.status = "PASS" + report.status_extended = ( + f"API token {token.name} owner {token.user_id} is not " + f"assigned Super Admin ({roles}).{caveat}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json new file mode 100644 index 0000000000..4a088a0c1c --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "apitoken_restricted_to_network_zone", + "CheckTitle": "Okta API tokens are restricted to known Network Zones", + "CheckType": [], + "ServiceName": "apitoken", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Okta API token network restrictions** should prevent token use from Any IP by tying each token to known Okta Network Zones.", + "Risk": "**API tokens allowed from Any IP** can be replayed from attacker-controlled infrastructure if the secret is exposed, removing an important network boundary.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken", + "https://help.okta.com/oie/en-us/content/topics/security/api.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Security > API > Tokens: edit each token Security section and select specific Network Zones instead of Any IP.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict every Okta API token to trusted IP-based Network Zones** and review token network conditions whenever service locations change.", + "Url": "https://hub.prowler.com/check/apitoken_restricted_to_network_zone" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py new file mode 100644 index 0000000000..04cfcf2df7 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py @@ -0,0 +1,55 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_client import api_token_client +from prowler.providers.okta.services.apitoken.lib.api_token_helpers import ( + definite_network_zone_restriction_failure, + missing_api_token_scope_finding, + missing_network_zone_scope_for_token_finding, + network_zone_restriction_status, +) + + +class apitoken_restricted_to_network_zone(Check): + """Ensure Okta API tokens are restricted to known Network Zones.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate every active API token's network condition.""" + org_domain = api_token_client.provider.identity.org_domain + missing_api_token_scope = api_token_client.missing_scope.get("api_tokens") + if missing_api_token_scope: + return [ + missing_api_token_scope_finding( + self.metadata(), + org_domain, + missing_api_token_scope, + additional_required=["okta.networkZones.read"], + ) + ] + + missing_network_zone_scope = api_token_client.missing_scope.get("network_zones") + findings: list[CheckReportOkta] = [] + for token in api_token_client.api_tokens.values(): + if missing_network_zone_scope: + definite_failure = definite_network_zone_restriction_failure(token) + if definite_failure: + report = CheckReportOkta( + metadata=self.metadata(), + resource=token, + org_domain=org_domain, + ) + report.status, report.status_extended = definite_failure + else: + report = missing_network_zone_scope_for_token_finding( + self.metadata(), org_domain, token, missing_network_zone_scope + ) + else: + report = CheckReportOkta( + metadata=self.metadata(), resource=token, org_domain=org_domain + ) + ( + report.status, + report.status_extended, + ) = network_zone_restriction_status( + token, api_token_client.known_network_zone_ids + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/apitoken/lib/__init__.py b/prowler/providers/okta/services/apitoken/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py new file mode 100644 index 0000000000..3a871714ae --- /dev/null +++ b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py @@ -0,0 +1,140 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken + +ANYWHERE_CONNECTIONS = {"", "ANYWHERE", "ANY_IP"} +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def network_zone_restriction_status( + token: OktaApiToken, known_network_zone_ids: set[str] +) -> tuple[str, str]: + """Evaluate whether an API token is restricted to known Network Zones.""" + connection = token.network_connection.upper() + if connection in ANYWHERE_CONNECTIONS: + return ( + "FAIL", + f"API token {token.name} can be used from any IP address. " + "Restrict the token to one or more known Okta Network Zones.", + ) + + if not token.network_includes: + return ( + "FAIL", + f"API token {token.name} does not allowlist a specific Okta " + "Network Zone. Excluded zones do not restrict the token to trusted " + "source networks.", + ) + + unknown_zones = [ + zone for zone in token.network_includes if zone not in known_network_zone_ids + ] + if unknown_zones: + return ( + "FAIL", + f"API token {token.name} references unknown Network Zone(s): " + f"{', '.join(unknown_zones)}.", + ) + + return ( + "PASS", + f"API token {token.name} is restricted to known Okta Network Zone(s): " + f"{', '.join(token.network_includes)}.", + ) + + +def definite_network_zone_restriction_failure( + token: OktaApiToken, +) -> tuple[str, str] | None: + """Return a definite network restriction failure that does not need zone lookup.""" + connection = token.network_connection.upper() + if connection in ANYWHERE_CONNECTIONS or not token.network_includes: + return network_zone_restriction_status(token, set()) + return None + + +def owner_has_super_admin(token: OktaApiToken) -> bool: + """Return True when any token owner role is Super Admin.""" + for role in token.owner_roles: + normalized = role.strip().replace(" ", "_").upper() + if normalized in {"SUPER_ADMIN", "SUPER_ADMINISTRATOR"}: + return True + return False + + +def missing_api_token_scope_finding( + metadata, + org_domain: str, + scope: str, + additional_required: list[str] | None = None, +) -> CheckReportOkta: + """Build the MANUAL finding emitted when API tokens cannot be listed. + + `additional_required` lets the calling check name the secondary + scopes it also needs (e.g. `okta.roles.read` for the Super Admin + check, `okta.networkZones.read` for the zone-restriction check) so + the operator can grant everything in one go instead of re-running + once per missing scope. + """ + resource = OktaApiToken( + id="api-tokens-scope-missing", + name="(scope not granted)", + ) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + if additional_required: + extras = f" This check also requires {_format_scope_list(additional_required)}." + advice = ( + "Grant them on the service app's Okta API Scopes tab in the Okta " + "Admin Console, then re-run the check." + ) + else: + extras = "" + advice = _SCOPE_ADVICE + report.status_extended = ( + f"Could not retrieve Okta API token metadata: the Okta service app " + f"is missing the required `{scope}` API scope.{extras} {advice}" + ) + return report + + +def _format_scope_list(scopes: list[str]) -> str: + """Format a list of scope names as backticked, comma-joined text.""" + formatted = [f"`{scope}`" for scope in scopes] + if len(formatted) == 1: + return formatted[0] + if len(formatted) == 2: + return " and ".join(formatted) + return ", ".join(formatted[:-1]) + f", and {formatted[-1]}" + + +def missing_network_zone_scope_for_token_finding( + metadata, org_domain: str, token: OktaApiToken, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when token zones cannot be validated.""" + report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain) + report.status = "MANUAL" + report.status_extended = ( + f"Could not validate Network Zone restrictions for API token " + f"{token.name}: the Okta service app is missing the required " + f"`{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report + + +def missing_user_roles_scope_for_token_finding( + metadata, org_domain: str, token: OktaApiToken, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when token owner roles cannot be listed.""" + report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve admin roles for API token {token.name} owner " + f"{token.user_id}: the Okta service app is missing the required " + f"`{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/application/__init__.py b/prowler/providers/okta/services/application/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_mfa_required/__init__.py b/prowler/providers/okta/services/application/application_admin_console_mfa_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.metadata.json b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.metadata.json new file mode 100644 index 0000000000..dbe02f23f8 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_admin_console_mfa_required", + "CheckTitle": "Okta Admin Console authentication policy enforces multifactor authentication", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Admin Console** app must require MFA. On its top active rule, *User must authenticate with* must be set to `Password / IdP + Another factor` or `Any 2 factor types` (`factorMode=2FA` in the API).", + "Risk": "Single-factor access to the Okta control plane is the highest-impact identity risk in the tenant.\n\n- **Credential compromise** is enough to take over every administrator account\n- **Lateral movement** into every downstream SaaS that trusts Okta SSO\n- **Privileged configuration changes** with no second-factor barrier", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-app-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication Policies**.\n3. Open the **Okta Admin Console** policy.\n4. Edit the top active rule.\n5. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require MFA on the top active rule of the Okta Admin Console authentication policy. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.", + "Url": "https://hub.prowler.com/check/application_admin_console_mfa_required" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273193 / OKTA-APP-000560." +} diff --git a/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.py b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.py new file mode 100644 index 0000000000..bf21a1a766 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.py @@ -0,0 +1,89 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +ADMIN_CONSOLE_LABEL_HINT = "Okta Admin Console" + + +class application_admin_console_mfa_required(Check): + """STIG V-273193 / OKTA-APP-000560. + + The Authentication Policy bound to the Okta Admin Console app must + require multifactor authentication on its top rule: `User must + authenticate with` set to `Password / IdP + Another factor` or + `Any 2 factor types`. + + The underlying SDK exposes this as `AssuranceMethod.factor_mode` + with values `1FA` / `2FA`. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + ADMIN_CONSOLE_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(ADMIN_CONSOLE_APP_NAME) + if app is None: + findings.append( + app_not_found_finding( + self.metadata(), org_domain, ADMIN_CONSOLE_LABEL_HINT + ) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must set `User must authenticate with` to " + "`Password / IdP + Another factor` or `Any 2 factor types`." + ) + elif rule.factor_mode == "2FA": + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "multifactor authentication (`factorMode=2FA`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + f"enforce multifactor authentication " + f"(`factorMode={rule.factor_mode or 'unset'}`). " + "Set `User must authenticate with` to `Password / IdP + Another " + "factor` or `Any 2 factor types`." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/__init__.py b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.metadata.json b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.metadata.json new file mode 100644 index 0000000000..0c86fdd974 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_admin_console_phishing_resistant_authentication", + "CheckTitle": "Okta Admin Console authentication policy enforces phishing-resistant factors", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Admin Console** app must restrict possession factors to phishing-resistant authenticators (FIDO2/WebAuthn, PIV/CAC, Okta FastPass with biometrics). On the top active rule, *Possession factor constraints are: Phishing resistant* must be checked (`possession.phishingResistant=REQUIRED`).", + "Risk": "Phishable possession factors (SMS, voice, standard push, OTP delivered via reverse-proxy AiTM) leave the most privileged surface of the IdP exposed.\n\n- **Credential phishing** against administrators succeeds despite MFA\n- **Adversary-in-the-Middle attacks** capture session tokens through fake login pages\n- **Account takeover** of the tenant control plane", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/phishing-resistant-auth.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication Policies**.\n3. Open the **Okta Admin Console** policy.\n4. Edit the top active rule.\n5. Under *Possession factor constraints are*, check **Phishing resistant**.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require phishing-resistant possession factors on the top active rule of the Okta Admin Console authentication policy.", + "Url": "https://hub.prowler.com/check/application_admin_console_phishing_resistant_authentication" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273191 / OKTA-APP-000190." +} diff --git a/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.py b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.py new file mode 100644 index 0000000000..237ffe27bb --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +ADMIN_CONSOLE_LABEL_HINT = "Okta Admin Console" + + +class application_admin_console_phishing_resistant_authentication(Check): + """STIG V-273191 / OKTA-APP-000190. + + The Authentication Policy bound to the Okta Admin Console app must + restrict possession factors to phishing-resistant authenticators. + The underlying SDK exposes `phishingResistant` on each + `PossessionConstraint`; at least one constraint object on the top + rule must set `phishingResistant=REQUIRED` (constraints are OR-ed + by Okta semantics). + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + ADMIN_CONSOLE_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(ADMIN_CONSOLE_APP_NAME) + if app is None: + findings.append( + app_not_found_finding( + self.metadata(), org_domain, ADMIN_CONSOLE_LABEL_HINT + ) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must mark " + "`Possession factor constraints are: Phishing resistant`." + ) + elif rule.possession_phishing_resistant_required: + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "phishing-resistant possession factors " + "(`possession.phishingResistant=REQUIRED`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + "enforce phishing-resistant possession factors. Enable " + "`Possession factor constraints are: Phishing resistant` " + "on the rule." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/__init__.py b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.metadata.json b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.metadata.json new file mode 100644 index 0000000000..fda8cd7816 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_admin_console_session_idle_timeout_15min", + "CheckTitle": "Okta Admin Console app session idle timeout is 15 minutes or less", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The integrated **Okta Admin Console** app must close idle privileged sessions. *Maximum app session idle time* on the **Sign On** tab must be `15` minutes or less.\n\nThreshold override: `okta_admin_console_idle_timeout_max_minutes`.", + "Risk": "An unattended administrator workstation leaves the Okta control plane open for session hijacking.\n\n- **Privileged session takeover** by anyone with physical or remote access to the workstation\n- **Tenant-wide configuration changes** under the absent administrator's identity\n- **Bypassed reauthentication** for the most sensitive surface of the IdP", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/guides/configure-signon-policy/main/", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/OktaApplicationSettings/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Applications** > **Applications** > **Okta Admin Console**.\n3. Open the **Sign On** tab.\n4. Under **Okta Admin Console session**, set *Maximum app session idle time* to `15` minutes or less.\n5. Save the changes.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the *Maximum app session idle time* of the Okta Admin Console first-party app to `15` minutes or less so privileged administrator sessions terminate on inactivity.", + "Url": "https://hub.prowler.com/check/application_admin_console_session_idle_timeout_15min" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273187 / OKTA-APP-000025." +} diff --git a/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.py b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.py new file mode 100644 index 0000000000..6103360f07 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.py @@ -0,0 +1,89 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + AdminConsoleAppSettings, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + missing_admin_console_settings_scope_finding, +) + +DEFAULT_THRESHOLD_MINUTES = 15 + + +class application_admin_console_session_idle_timeout_15min(Check): + """STIG V-273187 / OKTA-APP-000025. + + The Okta Admin Console first-party app must set its + `Maximum app session idle time` to 15 minutes (or less) so privileged + administrator sessions terminate on inactivity. Threshold override: + `okta_admin_console_idle_timeout_max_minutes` in the audit config. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + audit_config = application_client.audit_config or {} + threshold = audit_config.get( + "okta_admin_console_idle_timeout_max_minutes", + DEFAULT_THRESHOLD_MINUTES, + ) + org_domain = application_client.provider.identity.org_domain + + missing_scope = application_client.missing_scope.get( + "admin_console_app_settings" + ) + if missing_scope: + findings.append( + missing_admin_console_settings_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + settings = application_client.admin_console_app_settings + if settings is None: + placeholder = AdminConsoleAppSettings() + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve the Okta Admin Console first-party app " + "settings. Okta restricts `GET /api/v1/first-party-app-settings/" + "admin-console` to the Super Administrator role; every other " + "role — including Read-Only Administrator — receives " + "`403 E0000006`. Assign Super Administrator to the service " + f"app to evaluate this check. The `Maximum app session idle " + f"time` must be set to {threshold} minutes or less." + ) + findings.append(report) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=settings, org_domain=org_domain + ) + idle = settings.session_idle_timeout_minutes + if idle is None: + report.status = "FAIL" + report.status_extended = ( + "The Okta Admin Console first-party app does not define a " + "`Maximum app session idle time`. This value must be " + f"{threshold} minutes or less." + ) + elif idle <= threshold: + report.status = "PASS" + report.status_extended = ( + "The Okta Admin Console first-party app sets the maximum " + f"app session idle time to {idle} minutes, meeting the " + f"configured threshold of {threshold} minutes." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "The Okta Admin Console first-party app sets the maximum " + f"app session idle time to {idle} minutes, exceeding the " + f"configured threshold of {threshold} minutes." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/__init__.py b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.metadata.json b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.metadata.json new file mode 100644 index 0000000000..fd9234e0a6 --- /dev/null +++ b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "application_authentication_policy_network_zone_enforced", + "CheckTitle": "Okta application authentication policies enforce Network Zones", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta application must be bound to an **Authentication Policy** that uses **Network Zones**. Each active non-default rule must map *User's IP* to `In zone` or `Not in zone`, and the active built-in *Catch-all Rule* must set *Access is* to `Denied`.", + "Risk": "Applications without network-aware authentication rules can be reached from unauthorized locations and bypass location-based access controls.\n\n- **Unauthorized access paths** from unmanaged or blocked networks\n- **Inconsistent information-flow enforcement** across SSO applications\n- **Residual access** when the fallback rule still allows traffic after policy misses", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-app-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Application/", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Networks** and define the allow-list and deny-list zones required by policy.\n3. For each active application, open **Applications** > **Applications** > *Application* > **Sign On**.\n4. In **User Authentication**, bind the appropriate **Authentication Policy** and open **View Policy Details**.\n5. For each active non-default rule, set *User's IP* to `In zone` or `Not in zone` and select the correct **Network Zone**.\n6. Edit the built-in **Catch-all Rule** and set *Access is* to `Denied`.\n7. Save the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require every active application Authentication Policy to use Network Zones on each active non-default rule and to deny access on the Catch-all Rule.", + "Url": "https://hub.prowler.com/check/application_authentication_policy_network_zone_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-279693 / OKTA-APP-003244." +} diff --git a/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.py b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.py new file mode 100644 index 0000000000..5e6e2c9c76 --- /dev/null +++ b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.py @@ -0,0 +1,151 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + AuthenticationPolicyRule, + OktaBuiltInApp, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + active_apps, + app_label, + missing_integrated_apps_scope_finding, + no_active_apps_finding, + rule_has_network_zone, + rule_label, +) + + +class application_authentication_policy_network_zone_enforced(Check): + """STIG V-279693 / OKTA-APP-003244. + + Every active Okta application must be bound to an Authentication + Policy that uses Network Zones. Each active non-default rule must map + `User's IP` to an allow/deny zone, and the active Catch-all Rule + must deny access. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("integrated_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_integrated_apps_scope_finding( + self.metadata(), + org_domain, + missing_scope, + ) + ) + return findings + + apps = active_apps(application_client.integrated_apps) + if not apps: + findings.append(no_active_apps_finding(self.metadata(), org_domain)) + return findings + + for app in apps: + report = CheckReportOkta( + metadata=self.metadata(), + resource=app, + org_domain=org_domain, + resource_name=app.label or app.name, + resource_id=app.id, + ) + status, status_extended = _evaluate_app(app) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _active_rules(app: OktaBuiltInApp) -> list[AuthenticationPolicyRule]: + if app.access_policy is None: + return [] + return sorted( + [ + rule + for rule in app.access_policy.rules + if not rule.status or rule.status.upper() == "ACTIVE" + ], + key=lambda rule: ( + rule.priority if rule.priority is not None else float("inf"), + rule.name, + ), + ) + + +def _evaluate_app(app: OktaBuiltInApp) -> tuple[str, str]: + label = app_label(app) + if app.access_policy_id is None or app.access_policy is None: + return ( + "FAIL", + f"{label} has no Authentication Policy bound to it. " + "Bind an Access Policy in Security > Authentication Policies.", + ) + + active_rules = _active_rules(app) + if not active_rules: + return ( + "FAIL", + f"{label} has no active rules on its Authentication Policy. " + "Every active non-default rule must enforce a Network Zone " + "condition, and the Catch-all Rule must set `Access is: Denied`.", + ) + + nondefault_rules = [ + rule + for rule in active_rules + if not rule.is_default and rule.name != "Catch-all Rule" + ] + if not nondefault_rules: + return ( + "FAIL", + f"{label} has no active non-default rules on its Authentication " + "Policy. Define at least one non-default rule that maps `User's " + "IP` to a Network Zone, and use the Catch-all Rule only as the " + "final deny path.", + ) + + missing_zone_rules = [ + rule.name for rule in nondefault_rules if not rule_has_network_zone(rule) + ] + if missing_zone_rules: + quoted_rules = ", ".join(f"'{rule_name}'" for rule_name in missing_zone_rules) + return ( + "FAIL", + f"{label} has active non-default rule(s) without Network Zones: " + f"{quoted_rules}. Configure `User's IP` to `In zone` or `Not in zone` " + "for every active non-default rule.", + ) + + catch_all_rule = next( + ( + rule + for rule in active_rules + if rule.is_default or rule.name == "Catch-all Rule" + ), + None, + ) + if catch_all_rule is None: + return ( + "FAIL", + f"{label} has no active Catch-all Rule. The Catch-all Rule must " + "deny access after the zoned non-default rules.", + ) + + if catch_all_rule.access != "DENY": + return ( + "FAIL", + f"Active {rule_label(catch_all_rule)} on {label} does not set " + f"`Access is` to `DENY` (`access={catch_all_rule.access or 'unset'}`). " + "Set the Catch-all Rule to deny access.", + ) + + return ( + "PASS", + f"{label} applies Network Zones on every active non-default rule and " + f"its active {rule_label(catch_all_rule)} denies access.", + ) diff --git a/prowler/providers/okta/services/application/application_client.py b/prowler/providers/okta/services/application/application_client.py new file mode 100644 index 0000000000..63658ea3c6 --- /dev/null +++ b/prowler/providers/okta/services/application/application_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.application.application_service import Application + +application_client = Application(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/application/application_dashboard_mfa_required/__init__.py b/prowler/providers/okta/services/application/application_dashboard_mfa_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.metadata.json b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.metadata.json new file mode 100644 index 0000000000..1211eaec62 --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_dashboard_mfa_required", + "CheckTitle": "Okta Dashboard authentication policy enforces multifactor authentication", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Dashboard** app must require MFA for end users. On its top active rule, *User must authenticate with* must be set to `Password / IdP + Another factor` or `Any 2 factor types` (`factorMode=2FA` in the API).", + "Risk": "Single-factor access to the Okta Dashboard lets an attacker pivot from one compromised password into every downstream SSO app.\n\n- **Credential stuffing** and password reuse attacks succeed in one step\n- **Lateral movement** into every SaaS the user has access to via Okta SSO\n- **Weakened identity assurance** for every user signing in to the end-user portal", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-app-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication Policies**.\n3. Open the **Okta Dashboard** policy.\n4. Edit the top active rule.\n5. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require MFA on the top active rule of the Okta Dashboard authentication policy. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.", + "Url": "https://hub.prowler.com/check/application_dashboard_mfa_required" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273194 / OKTA-APP-000570." +} diff --git a/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.py b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.py new file mode 100644 index 0000000000..48c04083fb --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.py @@ -0,0 +1,85 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +DASHBOARD_LABEL_HINT = "Okta Dashboard" + + +class application_dashboard_mfa_required(Check): + """STIG V-273194 / OKTA-APP-000570. + + The Authentication Policy bound to the Okta Dashboard app must + require multifactor authentication on its top rule for + non-privileged users: `User must authenticate with` set to + `Password / IdP + Another factor` or `Any 2 factor types` + (`AssuranceMethod.factor_mode == "2FA"`). + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + DASHBOARD_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(DASHBOARD_APP_NAME) + if app is None: + findings.append( + app_not_found_finding(self.metadata(), org_domain, DASHBOARD_LABEL_HINT) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must set `User must authenticate with` to " + "`Password / IdP + Another factor` or `Any 2 factor types`." + ) + elif rule.factor_mode == "2FA": + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "multifactor authentication (`factorMode=2FA`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + f"enforce multifactor authentication " + f"(`factorMode={rule.factor_mode or 'unset'}`). " + "Set `User must authenticate with` to `Password / IdP + Another " + "factor` or `Any 2 factor types`." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/__init__.py b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.metadata.json b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.metadata.json new file mode 100644 index 0000000000..1c8996280b --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_dashboard_phishing_resistant_authentication", + "CheckTitle": "Okta Dashboard authentication policy enforces phishing-resistant factors", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Dashboard** app must restrict possession factors to phishing-resistant authenticators. On the top active rule, *Possession factor constraints are: Phishing resistant* must be checked (`possession.phishingResistant=REQUIRED`).", + "Risk": "Phishable possession factors leave end-user SSO sessions exposed to credential phishing and AiTM proxies.\n\n- **Credential phishing** against end users succeeds despite MFA\n- **Session token theft** through reverse-proxy AiTM attacks (Evilginx-class tooling)\n- **Compromise of every downstream SSO app** the user has access to", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/phishing-resistant-auth.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication Policies**.\n3. Open the **Okta Dashboard** policy.\n4. Edit the top active rule.\n5. Under *Possession factor constraints are*, check **Phishing resistant**.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require phishing-resistant possession factors on the top active rule of the Okta Dashboard authentication policy.", + "Url": "https://hub.prowler.com/check/application_dashboard_phishing_resistant_authentication" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273190 / OKTA-APP-000180." +} diff --git a/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.py b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.py new file mode 100644 index 0000000000..11fa310da2 --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.py @@ -0,0 +1,84 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +DASHBOARD_LABEL_HINT = "Okta Dashboard" + + +class application_dashboard_phishing_resistant_authentication(Check): + """STIG V-273190 / OKTA-APP-000180. + + The Authentication Policy bound to the Okta Dashboard app must + restrict possession factors to phishing-resistant authenticators on + its top active rule + (`possession.phishingResistant=REQUIRED`). + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + DASHBOARD_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(DASHBOARD_APP_NAME) + if app is None: + findings.append( + app_not_found_finding(self.metadata(), org_domain, DASHBOARD_LABEL_HINT) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must mark " + "`Possession factor constraints are: Phishing resistant`." + ) + elif rule.possession_phishing_resistant_required: + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "phishing-resistant possession factors " + "(`possession.phishingResistant=REQUIRED`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + "enforce phishing-resistant possession factors. Enable " + "`Possession factor constraints are: Phishing resistant` " + "on the rule." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_service.py b/prowler/providers/okta/services/application/application_service.py new file mode 100644 index 0000000000..fa6f483021 --- /dev/null +++ b/prowler/providers/okta/services/application/application_service.py @@ -0,0 +1,501 @@ +from typing import Optional +from urllib.parse import urlparse + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +# These three keys are Okta-platform constants, not tenant-configurable: +# +# - `saasure` / `okta_enduser` are the `name` fields of the OIN catalog +# templates for the Okta Admin Console and Okta Dashboard built-in apps. +# The Okta SDK's `OINApplication.name` is documented as "the key name for +# the OIN app definition" — tied to the platform-level template, not +# editable by customers. The user-facing field is `label`, which we read +# only for display purposes in finding text. +# - `admin-console` is the Okta-defined URL key for +# `/api/v1/first-party-app-settings/{appName}`; per the SDK's own +# `get_first_party_app_settings` docstring it is the only value Okta +# currently supports on that endpoint. +# +# If Okta introduces a new first-party app or renames one of these at the +# platform level, both the constants and the check coverage need updating +# together. +ADMIN_CONSOLE_APP_NAME = "saasure" +DASHBOARD_APP_NAME = "okta_enduser" +ADMIN_CONSOLE_FIRST_PARTY_APP_KEY = "admin-console" + + +REQUIRED_SCOPES: dict[str, str] = { + "admin_console_app_settings": "okta.apps.read", + "built_in_apps": "okta.apps.read", + "integrated_apps": "okta.apps.read", + "access_policies": "okta.policies.read", +} + + +class Application(OktaService): + """Fetches Okta first-party apps and their bound Authentication Policies. + + Populates: + - `self.admin_console_app_settings` — first-party Admin Console session + knobs (`sessionIdleTimeoutMinutes`, `sessionMaxLifetimeMinutes`). + - `self.built_in_apps` — keyed by canonical `name` (`saasure`, + `okta_enduser`). Each entry carries the resolved Authentication + Policy (Access Policy) and its rules. + - `self.integrated_apps` — lazily populated and keyed by application id. + Used by the per-application network-zone STIG to evaluate every + active app returned by `/api/v1/apps`. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + When a scope is known to be missing, the corresponding fetch is + skipped and recorded in `self.missing_scope` so each check can emit + an explicit MANUAL finding instead of a misleading + "no resources returned". Empty granted_scopes means "unknown" — the + service attempts the fetch and lets the SDK fail loudly. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.admin_console_app_settings: Optional[AdminConsoleAppSettings] = ( + None + if self.missing_scope["admin_console_app_settings"] + else self._get_admin_console_app_settings() + ) + + # Apps and policies share the same SDK round-trips, so fetch them + # together. When either scope is missing we still attempt the + # other, but `built_in_apps` is only populated when both are + # available — checks then look at `missing_scope` to report which + # one is at fault. + if self.missing_scope["built_in_apps"] or self.missing_scope["access_policies"]: + self.built_in_apps: dict[str, OktaBuiltInApp] = {} + else: + self.built_in_apps = self._list_built_in_apps_with_policies() + self._integrated_apps: Optional[dict[str, OktaBuiltInApp]] = None + + @property + def integrated_apps(self) -> dict[str, "OktaBuiltInApp"]: + """List every Okta-integrated app with its Authentication Policy. + + This is fetched lazily because only the V-279693 check needs the + full app inventory; the bundled Admin Console / Dashboard checks + only need the two built-in apps. + """ + if self._integrated_apps is None: + if ( + self.missing_scope["integrated_apps"] + or self.missing_scope["access_policies"] + ): + self._integrated_apps = {} + else: + self._integrated_apps = self._list_integrated_apps_with_policies() + return self._integrated_apps + + def _get_admin_console_app_settings(self) -> Optional["AdminConsoleAppSettings"]: + logger.info("Application - Fetching first-party Admin Console settings...") + try: + return self._run(self._fetch_admin_console_app_settings()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + async def _fetch_admin_console_app_settings( + self, + ) -> Optional["AdminConsoleAppSettings"]: + result = await self.client.get_first_party_app_settings( + ADMIN_CONSOLE_FIRST_PARTY_APP_KEY + ) + err = result[-1] + if err is not None: + # 404 means the org is on Classic engine or the endpoint isn't + # available — fall through to None and checks emit MANUAL. + logger.error(f"Error fetching first-party Admin Console settings: {err}") + return None + data = result[0] + if data is None: + return None + return AdminConsoleAppSettings( + session_idle_timeout_minutes=getattr( + data, "session_idle_timeout_minutes", None + ), + session_max_lifetime_minutes=getattr( + data, "session_max_lifetime_minutes", None + ), + ) + + def _list_built_in_apps_with_policies(self) -> dict: + logger.info("Application - Listing Okta built-in apps and policies...") + try: + return self._run(self._fetch_built_in_apps_and_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + def _list_integrated_apps_with_policies(self) -> dict: + logger.info("Application - Listing integrated Okta apps and policies...") + try: + return self._run(self._fetch_integrated_apps_and_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_built_in_apps_and_policies(self) -> dict: + # Per-app try/except: one app's SDK failure (e.g. ValidationError + # while deserializing its policy rules) must not erase findings + # for the other. + result: dict[str, OktaBuiltInApp] = {} + for app_name in (ADMIN_CONSOLE_APP_NAME, DASHBOARD_APP_NAME): + try: + built_in_app = await self._fetch_built_in_app(app_name) + except Exception as error: + logger.error( + f"Error fetching built-in app {app_name}: " + f"{error.__class__.__name__}: {error}" + ) + continue + if built_in_app is None: + continue + if built_in_app.access_policy_id: + try: + built_in_app.access_policy = await self._fetch_access_policy( + built_in_app.access_policy_id + ) + except Exception as error: + logger.error( + f"Error fetching access policy " + f"{built_in_app.access_policy_id} for {app_name}: " + f"{error.__class__.__name__}: {error}" + ) + built_in_app.access_policy = None + result[app_name] = built_in_app + return result + + async def _fetch_integrated_apps_and_policies(self) -> dict: + all_apps, err = await self._paginate( + lambda after: self.client.list_applications(after=after) + ) + if err is not None: + logger.error(f"Error listing integrated apps: {err}") + return {} + + # Per-app try/except: a single app's policy fetch failure must + # not drop the whole inventory. + result: dict[str, OktaBuiltInApp] = {} + for app in all_apps: + try: + app_model = _to_application_model(app) + except Exception as error: + logger.error( + f"Error projecting Okta app onto pydantic model " + f"(id={getattr(app, 'id', '?')}): " + f"{error.__class__.__name__}: {error}" + ) + continue + if app_model.access_policy_id: + try: + app_model.access_policy = await self._fetch_access_policy( + app_model.access_policy_id + ) + except Exception as error: + logger.error( + f"Error fetching access policy " + f"{app_model.access_policy_id} for app " + f"{app_model.name} ({app_model.id}): " + f"{error.__class__.__name__}: {error}" + ) + app_model.access_policy = None + result[app_model.id] = app_model + return result + + async def _fetch_built_in_app(self, app_name: str) -> Optional["OktaBuiltInApp"]: + # Filter by `name eq` so we don't paginate every app in the org + # for a single match. The two OIN-built-in apps are uniquely + # identified by their internal `name`. + apps, err = await self._paginate( + lambda after: self.client.list_applications( + filter=f'name eq "{app_name}"', after=after + ) + ) + if err is not None: + logger.error(f"Error listing app with name={app_name}: {err}") + return None + if not apps: + return None + return _to_application_model(apps[0]) + + async def _fetch_access_policy( + self, policy_id: str + ) -> Optional["AuthenticationPolicy"]: + # Okta's `list_policy_rules` does not accept an `after` cursor in + # the SDK signature, so we call once with a generous limit. Auth + # policies almost always have <10 rules; a warning is logged if + # the limit is hit. + rule_fetch_limit = 100 + try: + result = await self.client.list_policy_rules( + policy_id, limit=str(rule_fetch_limit) + ) + except ValidationError as ve: + # Upstream Okta SDK ↔ Management API enum drift: the SDK's + # strict pydantic validators (e.g. KnowledgeConstraint.types + # uppercase-only) reject values the API returns lowercase + # (e.g. ["password"]). Fall back to a raw-JSON fetch so the + # STIG evaluation isn't blocked by an upstream SDK bug. + logger.warning( + f"Okta SDK raised ValidationError parsing rules for policy " + f"{policy_id} ({ve.error_count()} error(s)) — falling back " + "to raw-JSON parse. This is an okta-sdk-python deserialization " + "bug; the workaround should be removed once upstream fixes it." + ) + return await self._fetch_access_policy_raw(policy_id, rule_fetch_limit) + + err = result[-1] + if err is not None: + logger.error(f"Error listing rules for access policy {policy_id}: {err}") + return AuthenticationPolicy( + id=policy_id, + name="", + status="", + is_default=False, + rules=[], + ) + all_rules = list(result[0] or []) + if len(all_rules) >= rule_fetch_limit: + logger.warning( + f"Access policy {policy_id} returned {len(all_rules)} rules — " + f"the per-policy fetch limit ({rule_fetch_limit}) was hit; any " + "rules beyond this limit are not evaluated by Prowler. Review " + "the policy in the Okta Admin Console." + ) + rules_out = [_rule_to_model(rule) for rule in all_rules] + return AuthenticationPolicy( + id=policy_id, + name="", + status="", + is_default=False, + rules=rules_out, + ) + + async def _fetch_access_policy_raw( + self, policy_id: str, rule_fetch_limit: int + ) -> Optional["AuthenticationPolicy"]: + """Raw-JSON fallback for `list_policy_rules`. + + Bypasses the Okta SDK's typed deserialization by calling the + request executor directly via the shared `get_json_paginated` + helper, which follows `Link: rel=next` so policies with more + rules than `rule_fetch_limit` are not silently truncated. + Projects the response onto our own pydantic snapshot which only + validates the fields the STIG checks actually read. This keeps + the checks evaluable on tenants where the Management API returns + values the SDK validators reject. + """ + rules_data = await _raw_get_json_paginated( + self.client, + f"/api/v1/policies/{policy_id}/rules", + page_size=rule_fetch_limit, + context=f"access policy {policy_id} rules", + ) + if rules_data is None: + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=[] + ) + rules_out = [_raw_rule_to_model(rule) for rule in rules_data] + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=rules_out + ) + + @staticmethod + async def _paginate(fetch): + return await _paginate_shared(fetch) + + +def _policy_id_from_href(href: Optional[str]) -> Optional[str]: + """Extract the trailing policy id from `.../policies/{id}` URLs.""" + if not href: + return None + path = urlparse(href).path or href + segment = path.rstrip("/").rsplit("/", 1)[-1] + return segment or None + + +def _rule_to_model(rule) -> "AuthenticationPolicyRule": + """Project an SDK `AccessPolicyRule` onto our pydantic snapshot. + + Pulls out the two STIG-relevant fields from the deeply nested + `actions.appSignOn.verificationMethod` tree: the assurance `factor_mode` + and whether any possession constraint requires phishing resistance. + """ + actions = getattr(rule, "actions", None) + app_sign_on = getattr(actions, "app_sign_on", None) if actions else None + verification_method = ( + getattr(app_sign_on, "verification_method", None) if app_sign_on else None + ) + factor_mode = _stringify_enum(getattr(verification_method, "factor_mode", None)) + verification_type = _stringify_enum(getattr(verification_method, "type", None)) + constraints = list(getattr(verification_method, "constraints", None) or []) + phishing_resistant_required = False + for constraint in constraints: + possession = getattr(constraint, "possession", None) + if possession is None: + continue + if ( + _stringify_enum(getattr(possession, "phishing_resistant", None)) + == "REQUIRED" + ): + phishing_resistant_required = True + break + + access_action = getattr(app_sign_on, "access", None) if app_sign_on else None + conditions = getattr(rule, "conditions", None) + network = getattr(conditions, "network", None) if conditions else None + return AuthenticationPolicyRule( + id=getattr(rule, "id", "") or "", + name=getattr(rule, "name", "") or "", + priority=getattr(rule, "priority", None), + status=getattr(rule, "status", "") or "", + is_default=bool(getattr(rule, "system", False)), + factor_mode=factor_mode, + possession_phishing_resistant_required=phishing_resistant_required, + constraints_count=len(constraints), + verification_method_type=verification_type, + access=_stringify_enum(access_action), + network_connection=_stringify_enum(getattr(network, "connection", None)), + network_zones_include=list(getattr(network, "include", None) or []), + network_zones_exclude=list(getattr(network, "exclude", None) or []), + ) + + +def _stringify_enum(value) -> Optional[str]: + """Return the string form of an enum-or-string value, or None.""" + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +def _raw_rule_to_model(rule_dict: dict) -> "AuthenticationPolicyRule": + """Project a raw `/api/v1/policies/{id}/rules` JSON rule onto our model. + + Mirrors `_rule_to_model` but reads camelCase JSON keys (`appSignOn`, + `verificationMethod`, `phishingResistant`) instead of the SDK's + snake_case attribute names. Used by the raw-JSON fallback that + activates when the Okta SDK's strict enum validators reject values + the Management API returns. + """ + actions = rule_dict.get("actions") or {} + app_sign_on = actions.get("appSignOn") or {} + verification_method = app_sign_on.get("verificationMethod") or {} + factor_mode = verification_method.get("factorMode") + verification_type = verification_method.get("type") + constraints = verification_method.get("constraints") or [] + phishing_resistant_required = False + for constraint in constraints: + possession = (constraint or {}).get("possession") or {} + if possession.get("phishingResistant") == "REQUIRED": + phishing_resistant_required = True + break + + access_action = app_sign_on.get("access") + conditions = rule_dict.get("conditions") or {} + network = conditions.get("network") or {} + return AuthenticationPolicyRule( + id=rule_dict.get("id") or "", + name=rule_dict.get("name") or "", + priority=rule_dict.get("priority"), + status=rule_dict.get("status") or "", + is_default=bool(rule_dict.get("system", False)), + factor_mode=factor_mode, + possession_phishing_resistant_required=phishing_resistant_required, + constraints_count=len(constraints), + verification_method_type=verification_type, + access=access_action, + network_connection=network.get("connection"), + network_zones_include=list(network.get("include") or []), + network_zones_exclude=list(network.get("exclude") or []), + ) + + +class AdminConsoleAppSettings(BaseModel): + """First-party Okta Admin Console session settings. + + `id` and `name` are set to fixed sentinels so this can be passed as + the `resource` to `CheckReportOkta`, which reads those attributes. + """ + + id: str = "okta-admin-console-app-settings" + name: str = "Okta Admin Console (first-party app settings)" + session_idle_timeout_minutes: Optional[int] = None + session_max_lifetime_minutes: Optional[int] = None + + +class AuthenticationPolicyRule(BaseModel): + id: str + name: str + priority: Optional[int] = None + status: str = "" + is_default: bool = False + factor_mode: Optional[str] = None + possession_phishing_resistant_required: bool = False + constraints_count: int = 0 + verification_method_type: Optional[str] = None + access: Optional[str] = None + network_connection: Optional[str] = None + network_zones_include: list[str] = [] + network_zones_exclude: list[str] = [] + + +class AuthenticationPolicy(BaseModel): + id: str + name: str = "" + status: str = "" + is_default: bool = False + rules: list[AuthenticationPolicyRule] = [] + + +class OktaBuiltInApp(BaseModel): + # `id` matches the Okta-generated `0oa…` app identifier; `name` is the + # canonical internal name (`saasure`, `okta_enduser`). Both are read + # directly by `CheckReportOkta(resource=…)`. + id: str + name: str + label: str = "" + status: str = "" + access_policy_id: Optional[str] = None + access_policy: Optional[AuthenticationPolicy] = None + + +def _application_access_policy_id(app) -> Optional[str]: + links = getattr(app, "links", None) + access_policy_link = getattr(links, "access_policy", None) if links else None + return _policy_id_from_href( + getattr(access_policy_link, "href", None) if access_policy_link else None + ) + + +def _to_application_model(app) -> OktaBuiltInApp: + return OktaBuiltInApp( + id=getattr(app, "id", "") or "", + name=getattr(app, "name", "") or "", + label=getattr(app, "label", "") or "", + status=getattr(app, "status", "") or "", + access_policy_id=_application_access_policy_id(app), + ) diff --git a/prowler/providers/okta/services/application/lib/__init__.py b/prowler/providers/okta/services/application/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/lib/application_helpers.py b/prowler/providers/okta/services/application/lib/application_helpers.py new file mode 100644 index 0000000000..90cccc98ec --- /dev/null +++ b/prowler/providers/okta/services/application/lib/application_helpers.py @@ -0,0 +1,212 @@ +"""Shared helpers for the OKTA application STIG checks.""" + +from typing import Optional + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.application.application_service import ( + AdminConsoleAppSettings, + AuthenticationPolicyRule, + OktaBuiltInApp, +) + + +def active_apps(apps: dict[str, OktaBuiltInApp]) -> list[OktaBuiltInApp]: + """Return active apps sorted by label/name, id as tiebreaker.""" + return sorted( + [ + app + for app in apps.values() + if not app.status or app.status.upper() == "ACTIVE" + ], + key=lambda app: (app.label or app.name, app.id), + ) + + +def top_active_rule( + app: OktaBuiltInApp, +) -> Optional[AuthenticationPolicyRule]: + """Return the topmost active rule on the app's Authentication Policy. + + Mirrors the STIG fix text — *"Click the 'Actions' button next to the + top rule and select 'Edit'"* — by returning the active rule with the + lowest `priority` value (= highest precedence). Downstream checks + separately reject the rule when it is the built-in Catch-all Rule. + + The priority value itself is intentionally not pinned to a specific + integer. Okta indexes Access Policy rule priorities inconsistently + across tenants and policy types (some responses report `0` for the + top rule, others `1`); the STIG only requires that the topmost rule + satisfy the predicate, not that it carry any specific priority literal. + """ + if app.access_policy is None: + return None + active_rules = sorted( + [ + rule + for rule in app.access_policy.rules + if not rule.status or rule.status.upper() == "ACTIVE" + ], + key=lambda rule: ( + rule.priority if rule.priority is not None else float("inf"), + rule.name, + ), + ) + if not active_rules: + return None + return active_rules[0] + + +def app_label(app: OktaBuiltInApp) -> str: + """Format a human-readable label for an Okta application.""" + label = app.label or app.name + return f"Okta app '{label}' (app={app.name}, id={app.id})" + + +def rule_label(rule: AuthenticationPolicyRule) -> str: + """Format whether a rule is the built-in catch-all or a custom rule.""" + if rule.is_default or rule.name == "Catch-all Rule": + return f"built-in Catch-all Rule '{rule.name}'" + return f"non-default rule '{rule.name}'" + + +def rule_has_network_zone(rule: AuthenticationPolicyRule) -> bool: + """Return True when the rule maps User's IP to at least one Network Zone.""" + return bool(rule.network_zones_include or rule.network_zones_exclude) + + +_SCOPE_ADVICE = ( + "Grant it on the service app's Okta API Scopes tab in the Okta Admin " + "Console, then re-run the check." +) + + +def missing_app_scope_finding( + metadata, org_domain: str, scope: str, app_label_hint: str +) -> CheckReportOkta: + """Build the MANUAL finding when an app/policy scope is not granted.""" + placeholder = OktaBuiltInApp( + id="okta-built-in-app-scope-missing", + name="(scope not granted)", + label=app_label_hint, + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not evaluate the authentication policy for {app_label_hint}: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report + + +def missing_integrated_apps_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the integrated-app inventory scope is not granted.""" + placeholder = OktaBuiltInApp( + id="okta-integrated-apps-scope-missing", + name="(scope not granted)", + label="Okta integrated applications", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.label, + resource_id=placeholder.id, + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta integrated applications and their " + f"authentication policies: the Okta service app is missing the " + f"required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report + + +def missing_admin_console_settings_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding for the Admin Console idle timeout check when scope is missing.""" + placeholder = AdminConsoleAppSettings() + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve the Okta Admin Console first-party app settings: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report + + +def app_not_found_finding( + metadata, org_domain: str, app_label_hint: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when a built-in OIN app isn't returned. + + Okta filters the first-party apps (`saasure`, `okta_enduser`) out of + `/api/v1/apps` for every admin role below Super Administrator, so the + check has no way to resolve the app's bound Authentication Policy. + """ + placeholder = OktaBuiltInApp( + id="okta-built-in-app-missing", + name="(app not found)", + label=app_label_hint, + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"The {app_label_hint} first-party app was not returned by the Okta " + "API. Okta restricts the visibility of first-party apps " + "(`saasure`, `okta_enduser`) to the Super Administrator role; " + "every other role — including Read-Only Administrator — receives " + "an empty result. Assign Super Administrator to the service app " + "to evaluate this check." + ) + return report + + +def no_active_apps_finding(metadata, org_domain: str) -> CheckReportOkta: + """Build the MANUAL finding emitted when no active apps are returned.""" + placeholder = OktaBuiltInApp( + id="okta-apps-missing", + name="(no active apps)", + label="Okta applications", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.label, + resource_id=placeholder.id, + ) + report.status = "MANUAL" + report.status_extended = ( + "No active Okta applications were returned by the API. Verify the " + "tenant exposes applications to the Read-Only Administrator role and " + "review the application inventory manually." + ) + return report + + +def policy_missing_finding( + metadata, org_domain: str, app: OktaBuiltInApp +) -> CheckReportOkta: + """Build the FAIL finding when the built-in app has no bound Access Policy.""" + report = CheckReportOkta(metadata=metadata, resource=app, org_domain=org_domain) + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no Authentication Policy bound to it. " + "Bind an Access Policy in Security > Authentication Policies." + ) + return report diff --git a/prowler/providers/okta/services/authenticator/__init__.py b/prowler/providers/okta/services/authenticator/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_client.py b/prowler/providers/okta/services/authenticator/authenticator_client.py new file mode 100644 index 0000000000..3657a2821e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, +) + +authenticator_client = Authenticator(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json new file mode 100644 index 0000000000..363b95f84a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_okta_verify_fips_compliant", + "CheckTitle": "Okta Verify authenticator is active and restricts enrollment to FIPS-compliant devices", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Okta Verify authenticator** (`okta_verify`) must be present, in the `ACTIVE` status, and configured to require FIPS-compliant devices for enrollment (`settings.compliance.fips == REQUIRED`).\n\nMissing, inactive, and active-but-non-FIPS authenticators surface as distinct FAIL findings so the operator can act on the specific gap.", + "Risk": "Without FIPS-required enrollment, users can authenticate with devices whose cryptographic modules are not FIPS-validated.\n\n- **Regulatory exposure** under frameworks that mandate FIPS-validated cryptography\n- **Inconsistent assurance** across the user population\n- **Weak baseline** if the authenticator is active but the FIPS flag is `OPTIONAL` or unset", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/en-us/content/topics/mobile/ov-admin-config.htm", + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/configure-okta-verify-options.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. Activate the **Okta Verify** authenticator if it is not already `ACTIVE`.\n4. Open the authenticator's settings and set *FIPS Compliance* to **Users enrolling in Okta Verify can use FIPS compliant devices only**.\n5. Save the authenticator.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Okta Verify** authenticator is:\n- Present in the org (`key = okta_verify`)\n- In the `ACTIVE` status\n- Configured so *FIPS Compliance* is **Required** (`settings.compliance.fips == REQUIRED`)\n\nIf the organization does not require FIPS-validated authenticators, mute the check rather than disabling the FIPS toggle on a partial population.", + "Url": "https://hub.prowler.com/check/authenticator_okta_verify_fips_compliant" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273205 / OKTA-APP-001700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py new file mode 100644 index 0000000000..bf3c64b4b8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py @@ -0,0 +1,65 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_okta_verify_fips_compliant(Check): + """STIG V-273205 / OKTA-APP-001700. + + The check requires Okta to restrict Okta Verify enrollment to FIPS-compliant devices. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate Okta Verify FIPS compliance settings.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "okta_verify", + "Okta Verify authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "okta_verify" + ) + resource = authenticator or missing_authenticator_resource( + "okta_verify", "Okta Verify authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if not authenticator: + report.status = "FAIL" + report.status_extended = "Okta Verify authenticator is missing." + elif authenticator.status.upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"Okta Verify authenticator is not active; current status is " + f"{authenticator.status}." + ) + elif authenticator.fips.upper() == "REQUIRED": + report.status = "PASS" + report.status_extended = ( + "Okta Verify authenticator requires FIPS-compliant devices " + "for enrollment." + ) + else: + current_fips = authenticator.fips or "unset" + report.status = "FAIL" + report.status_extended = ( + "Okta Verify authenticator is active but does not require " + "FIPS-compliant devices for enrollment (current value: " + f"{current_fips})." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json new file mode 100644 index 0000000000..f61ccef8a1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_common_password_check", + "CheckTitle": "Every active Okta Password Policy rejects common passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must reject passwords found in Okta's common-password dictionary (the *Restrict use of common passwords* setting).\n\nOkta evaluates Password Policies by group assignment; a custom policy with the check disabled can govern users. The check emits one finding per active policy.", + "Risk": "Without dictionary checking, users can pick passwords known to attackers from public breaches.\n\n- **Credential stuffing** succeeds with the same passwords compromised elsewhere\n- **Trivial guessing** stays viable for top-N lists (`123456`, `password`, …)\n- **Inconsistent baselines** leave users on legacy policies that allow common passwords", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Restrict use of common passwords**.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_dictionary_lookup = true # Critical: enables the common-password dictionary check\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Restrict use of common passwords* is **enabled**\n- Group assignments do not route users to legacy policies that leave the check disabled\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_common_password_check" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273208 / OKTA-APP-002980." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py new file mode 100644 index 0000000000..d90d88afeb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_common_password_check(Check): + """STIG V-273208 / OKTA-APP-002980. + + Every active Okta Password Policy must reject passwords found in the common-password dictionary. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "common-password dictionary checks" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.common_password_check is True: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json new file mode 100644 index 0000000000..42a74b6e71 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_lowercase", + "CheckTitle": "Every active Okta Password Policy requires at least one lowercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **lowercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops lowercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without lowercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Lower case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_lowercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of lowercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable lowercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_lowercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273197 / OKTA-APP-000680." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py new file mode 100644 index 0000000000..e058e27b7b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_lowercase(Check): + """STIG V-273197 / OKTA-APP-000680. + + Every active Okta Password Policy must require at least one lowercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one lowercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_lower_case is not None and policy.min_lower_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json new file mode 100644 index 0000000000..fb3d4a920b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_number", + "CheckTitle": "Every active Okta Password Policy requires at least one numeric character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **numeric** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops numeric complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without numeric complexity, the effective alphabet shrinks and dictionary words remain viable as passwords.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match plain dictionary words without numeric padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Number** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_number = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of numeric characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable numeric complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_number" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273198 / OKTA-APP-000690." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py new file mode 100644 index 0000000000..78ffe6878e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_number(Check): + """STIG V-273198 / OKTA-APP-000690. + + Every active Okta Password Policy must require at least one numeric character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one numeric character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_number is not None and policy.min_number >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json new file mode 100644 index 0000000000..1268780551 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_symbol", + "CheckTitle": "Every active Okta Password Policy requires at least one symbol character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **symbol** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops symbol complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without symbol complexity, the effective alphabet shrinks and passwords stay closer to natural-language phrases.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match dictionary words without symbol padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Symbol** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_symbol = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of symbol characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable symbol complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_symbol" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273199 / OKTA-APP-000700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py new file mode 100644 index 0000000000..208a0f51d3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_symbol(Check): + """STIG V-273199 / OKTA-APP-000700. + + Every active Okta Password Policy must require at least one symbol character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one symbol character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_symbol is not None and policy.min_symbol >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json new file mode 100644 index 0000000000..7e885364ae --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_uppercase", + "CheckTitle": "Every active Okta Password Policy requires at least one uppercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **uppercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops uppercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without uppercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Upper case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_uppercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of uppercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable uppercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_uppercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273196 / OKTA-APP-000670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py new file mode 100644 index 0000000000..1419027980 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_uppercase(Check): + """STIG V-273196 / OKTA-APP-000670. + + Every active Okta Password Policy must require at least one uppercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one uppercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_upper_case is not None and policy.min_upper_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json new file mode 100644 index 0000000000..a7191b0364 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_history_5", + "CheckTitle": "Every active Okta Password Policy remembers at least 5 previous passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must keep at least the last `5` passwords in history so users cannot immediately recycle a recently-used password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that lowers the history depth can govern users even when the default policy is compliant. The check emits one finding per active policy.", + "Risk": "A short password history lets users cycle back to compromised or trivially-guessable values shortly after a forced rotation.\n\n- **Reuse of breached passwords** within the same account\n- **Defeats forced rotation** by letting users return to a previous password\n- **Inconsistent baselines** leave users on legacy policies with shorter history than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password history for last* to `5` passwords or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_history_count = 5 # Critical: STIG-aligned history depth\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password history for last* is `5` passwords or more\n- Group assignments do not route users to legacy policies with shorter history\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_history_5" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273209 / OKTA-APP-003010." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py new file mode 100644 index 0000000000..b2eb6d16ba --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_history_5(Check): + """STIG V-273209 / OKTA-APP-003010. + + Every active Okta Password Policy must remember at least the last 5 previous passwords. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password history of at least 5 previous passwords" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.history_count is not None and policy.history_count >= 5: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(password history count: {policy.history_count})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(password history count: {policy.history_count})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json new file mode 100644 index 0000000000..e287ed38f3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_lockout_threshold_3", + "CheckTitle": "Every active Okta Password Policy locks accounts after 3 or fewer failed attempts", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must lock the account after at most `3` consecutive failed sign-in attempts.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a higher threshold (or no lockout) can govern users even when the default is compliant. The check emits one finding per active policy.", + "Risk": "A high lockout threshold (or no threshold) leaves accounts exposed to online password guessing.\n\n- **Online brute force** retains enough attempts to enumerate common passwords\n- **Credential stuffing** can iterate through breached credentials at scale\n- **Inconsistent baselines** leave users on legacy policies with weaker thresholds than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Lock out user after X unsuccessful attempts* to `3` or fewer.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_lockout_attempts = 3 # Critical: STIG-aligned lockout threshold\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Lock out user after X unsuccessful attempts* is `3` or fewer\n- Group assignments do not route users to legacy policies with higher thresholds or no lockout\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_lockout_threshold_3" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273189 / OKTA-APP-000170. Not applicable when Okta delegates password sourcing to an external directory (AD/LDAP) — mute the check in that case; the directory enforces lockout instead." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py new file mode 100644 index 0000000000..8026725c8a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_lockout_threshold_3(Check): + """STIG V-273189 / OKTA-APP-000170. + + Every active Okta Password Policy must lock accounts after no more than 3 consecutive failed login attempts. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password lockout after 3 or fewer failed attempts" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_attempts is not None and policy.max_attempts <= 3: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json new file mode 100644 index 0000000000..449904303e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_maximum_age_60d", + "CheckTitle": "Every active Okta Password Policy enforces a maximum password age of 60 days or less", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require users to change their password at least every `60` days. The check rejects both unlimited expiration (`0`, which Okta treats as *never expires*) and any value greater than `60`.\n\nOkta evaluates Password Policies by group assignment; a permissive custom policy with a longer window can govern users. The check emits one finding per active policy.", + "Risk": "Long-lived passwords give a compromised credential indefinite usefulness.\n\n- **Stolen passwords** stay valid until the user happens to change them\n- **Breach detection lag** means a leaked password can be in use for months unnoticed\n- **No expiration** is the same risk as an infinite expiration window", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password expiration* to `60` days or less. Do **not** leave it disabled or set to `0` — that disables expiration entirely.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_age_days = 60 # Critical: STIG-aligned maximum age; do not use 0 (disables expiration)\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password expiration* is `60` days or less\n- Never set the value to `0`, which disables expiration\n- Group assignments do not route users to legacy policies with longer windows or disabled expiration\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_maximum_age_60d" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273201 / OKTA-APP-000745." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py new file mode 100644 index 0000000000..2dbc5d9c07 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_maximum_age_60d(Check): + """STIG V-273201 / OKTA-APP-000745. + + Every active Okta Password Policy must enforce a 60-day maximum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "maximum password age of 60 days or less" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_age_days is not None and 0 < policy.max_age_days <= 60: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json new file mode 100644 index 0000000000..8adbf3b5a0 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_age_24h", + "CheckTitle": "Every active Okta Password Policy enforces a minimum password age of 24 hours", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must prevent users from changing their password again for at least `24` hours (`1440` minutes) after the previous change.\n\nA minimum age stops users from cycling through their entire history in one session to land back on a previously-known password. The check emits one finding per active policy.", + "Risk": "Without a minimum password age, users can sidestep history and rotation requirements in minutes.\n\n- **Defeats password history** by walking through new passwords back to a previous one\n- **Defeats forced rotation** by returning to the prior password after the mandatory change\n- **Inconsistent baselines** leave users on legacy policies with shorter minimums than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password age* to `24` hours (`1440` minutes) or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_age_minutes = 1440 # Critical: STIG-aligned 24h minimum age\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password age* is `1440` minutes (`24` hours) or more\n- Group assignments do not route users to legacy policies with shorter minimums or no minimum age\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_age_24h" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273200 / OKTA-APP-000740." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py new file mode 100644 index 0000000000..93ce511458 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_age_24h(Check): + """STIG V-273200 / OKTA-APP-000740. + + Every active Okta Password Policy must enforce a 24-hour minimum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password age of at least 24 hours" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_age_minutes is not None and policy.min_age_minutes >= 1440: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json new file mode 100644 index 0000000000..d7409c6bb1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_length_15", + "CheckTitle": "Every active Okta Password Policy enforces a minimum length of 15 characters", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least `15` characters in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a shorter minimum length can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Short password minimums weaken brute-force and credential-stuffing resistance for every user assigned to the affected policy.\n\n- **Brute force** completes in feasible time against short passwords\n- **Credential stuffing** succeeds with reused passwords from public breaches\n- **Inconsistent baselines** leave users on legacy policies with weaker assurance than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password length* to `15` or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_length = 15 # Critical: STIG-aligned minimum\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password length* is `15` characters or more\n- Group assignments do not route users to legacy policies with shorter minimums\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_length_15" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273195 / OKTA-APP-000650." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py new file mode 100644 index 0000000000..35e0a02b8b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_length_15(Check): + """STIG V-273195 / OKTA-APP-000650. + + Every active Okta Password Policy must enforce a minimum password length of 15 characters. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password length of at least 15 characters" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_length is not None and policy.min_length >= 15: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum length: {policy.min_length})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum length: {policy.min_length})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_service.py b/prowler/providers/okta/services/authenticator/authenticator_service.py new file mode 100644 index 0000000000..86b4084dfb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_service.py @@ -0,0 +1,236 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "password_policies": "okta.policies.read", + "authenticators": "okta.authenticators.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +def _int_or_none(value) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _bool_or_none(value) -> Optional[bool]: + """Coerce common Okta boolean shapes into a real `Optional[bool]`. + + The Okta SDK typed `bool` fields are already real booleans, but the + raw-JSON fallback paths in sibling services have surfaced both + JSON-style booleans (`true`/`false` as Python `bool` after `json.loads`) + and string-flavored ones (`"true"`/`"false"`). `bool("false")` is + `True` — so naive coercion silently flips the meaning. Reject that + explicitly. + """ + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no", ""}: + return False + return None + return bool(value) + + +class Authenticator(OktaService): + """Fetches Okta Password Policies and Authenticators for STIG checks. + + Populates: + - `self.password_policies` — keyed by policy id. Each `PasswordPolicy` + carries the projected fields the 10 password-policy checks read + (length, complexity, age, history, lockout, common-password + dictionary). The complete typed SDK response is collapsed into a + flat dataclass so the checks never reach back into the SDK shape. + - `self.authenticators` — keyed by authenticator id. Used by the + two non-password checks (Smart Card IdP, Okta Verify FIPS). + + Before each fetch the service compares its required OAuth scope + (see `REQUIRED_SCOPES`) against the access token's granted scopes + (`provider.identity.granted_scopes`). When a scope is known to be + missing, the fetch is skipped and recorded in `self.missing_scope` + so each check can emit an explicit MANUAL finding instead of a + misleading "no resources returned". Empty granted_scopes means + "unknown" — the service attempts the fetch and lets the SDK fail + loudly. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.password_policies: dict[str, PasswordPolicy] = ( + {} + if self.missing_scope["password_policies"] + else self._list_password_policies() + ) + self.authenticators: dict[str, OktaAuthenticator] = ( + {} if self.missing_scope["authenticators"] else self._list_authenticators() + ) + + def _list_password_policies(self) -> dict[str, "PasswordPolicy"]: + """List PASSWORD policies with normalized password settings.""" + logger.info("Authenticator - Listing Okta PASSWORD policies...") + try: + return self._run(self._fetch_password_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_password_policies(self) -> dict[str, "PasswordPolicy"]: + result: dict[str, PasswordPolicy] = {} + items, err = await _paginate_shared( + lambda after: self.client.list_policies( + type="PASSWORD", after=after, limit="200" + ) + ) + if err is not None: + logger.error(f"Error listing PASSWORD policies: {err}") + return result + + for policy in items: + policy_obj = self._build_password_policy(policy) + result[policy_obj.id] = policy_obj + return result + + def _list_authenticators(self) -> dict[str, "OktaAuthenticator"]: + """List org authenticators with normalized settings.""" + logger.info("Authenticator - Listing Okta authenticators...") + try: + return self._run(self._fetch_authenticators()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_authenticators(self) -> dict[str, "OktaAuthenticator"]: + # `list_authenticators` is non-paginated in the SDK (no `after` + # parameter); inline the tuple unwrap rather than going through + # `paginate`. Same shape `application_service` uses for + # `get_first_party_app_settings`. + result: dict[str, OktaAuthenticator] = {} + sdk_result = await self.client.list_authenticators() + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing authenticators: {err}") + return result + items = sdk_result[0] or [] + + for authenticator in items: + auth_obj = self._build_authenticator(authenticator) + result[auth_obj.id] = auth_obj + return result + + @staticmethod + def _build_password_policy(policy) -> "PasswordPolicy": + settings = getattr(policy, "settings", None) + password_settings = getattr(settings, "password", None) if settings else None + lockout = ( + getattr(password_settings, "lockout", None) if password_settings else None + ) + complexity = ( + getattr(password_settings, "complexity", None) + if password_settings + else None + ) + dictionary = getattr(complexity, "dictionary", None) if complexity else None + common = getattr(dictionary, "common", None) if dictionary else None + age = getattr(password_settings, "age", None) if password_settings else None + policy_id = _value(getattr(policy, "id", None)) + return PasswordPolicy( + id=policy_id, + name=_value(getattr(policy, "name", None)) or policy_id, + status=_value(getattr(policy, "status", None)), + priority=_int_or_none(getattr(policy, "priority", None)), + is_default=bool(getattr(policy, "system", False)), + max_attempts=_int_or_none(getattr(lockout, "max_attempts", None)), + min_length=_int_or_none(getattr(complexity, "min_length", None)), + min_upper_case=_int_or_none(getattr(complexity, "min_upper_case", None)), + min_lower_case=_int_or_none(getattr(complexity, "min_lower_case", None)), + min_number=_int_or_none(getattr(complexity, "min_number", None)), + min_symbol=_int_or_none(getattr(complexity, "min_symbol", None)), + min_age_minutes=_int_or_none(getattr(age, "min_age_minutes", None)), + max_age_days=_int_or_none(getattr(age, "max_age_days", None)), + history_count=_int_or_none(getattr(age, "history_count", None)), + common_password_check=_bool_or_none(getattr(common, "exclude", None)), + ) + + @staticmethod + def _build_authenticator(authenticator) -> "OktaAuthenticator": + settings = getattr(authenticator, "settings", None) + compliance = getattr(settings, "compliance", None) if settings else None + auth_id = _value(getattr(authenticator, "id", None)) + return OktaAuthenticator( + id=auth_id, + key=_value(getattr(authenticator, "key", None)), + name=_value(getattr(authenticator, "name", None)) or auth_id, + status=_value(getattr(authenticator, "status", None)), + type=_value(getattr(authenticator, "type", None)), + fips=_value(getattr(compliance, "fips", None)), + ) + + +class PasswordPolicy(BaseModel): + """Normalized Okta Password Policy settings used by checks.""" + + id: str + name: str + status: str = "" + priority: Optional[int] = None + is_default: bool = False + max_attempts: Optional[int] = None + min_length: Optional[int] = None + min_upper_case: Optional[int] = None + min_lower_case: Optional[int] = None + min_number: Optional[int] = None + min_symbol: Optional[int] = None + min_age_minutes: Optional[int] = None + max_age_days: Optional[int] = None + history_count: Optional[int] = None + common_password_check: Optional[bool] = None + + +class OktaAuthenticator(BaseModel): + """Normalized Okta Authenticator settings used by checks.""" + + id: str + key: str + name: str + status: str = "" + type: str = "" + fips: str = "" + + +class AuthenticatorSummary(BaseModel): + """Synthetic resource for org-level authenticator findings.""" + + id: str + name: str diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json new file mode 100644 index 0000000000..b8e93ae548 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_smart_card_active", + "CheckTitle": "Okta Smart Card IdP authenticator is configured and active", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Smart Card IdP authenticator** (`smart_card_idp`) must exist in the org and be in the `ACTIVE` status so certificate-based authentication is available to users and apps that require it.\n\nThe check resolves the authenticator by its built-in `key`. Missing and inactive authenticators surface as distinct FAIL findings.", + "Risk": "Without an active Smart Card authenticator, users cannot satisfy mandated certificate-based authentication and may be forced onto weaker fallback paths.\n\n- **Mandated CAC/PIV use** cannot be enforced\n- **Compliance gaps** for environments that require X.509 certificate authentication\n- **Fallback to password-only** sign-in for affected groups", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/oie/en-us/Content/Topics/identity-engine/authenticators/smart-card-authenticator.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. If the **Smart Card IdP** authenticator is not listed, click **Add Authenticator** and add it.\n4. Open the authenticator and switch its status to **ACTIVE**.\n5. Bind it to the Authentication Policies that require certificate-based auth.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Smart Card IdP** authenticator is:\n- Present in the org (`key = smart_card_idp`)\n- In the `ACTIVE` status\n- Referenced by every Authentication Policy that requires certificate-based authentication\n\nIf certificate-based authentication is not in scope for the organization, mute the check rather than disabling the authenticator.", + "Url": "https://hub.prowler.com/check/authenticator_smart_card_active" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273204 / OKTA-APP-001670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py new file mode 100644 index 0000000000..4341db8612 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_smart_card_active(Check): + """STIG V-273204 / OKTA-APP-001670. + + The check requires Okta to configure and activate the Smart Card (PIV) authenticator. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate the Smart Card IdP authenticator status.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "smart_card_idp", + "Smart Card IdP authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "smart_card_idp" + ) + resource = authenticator or missing_authenticator_resource( + "smart_card_idp", "Smart Card IdP authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if authenticator and authenticator.status.upper() == "ACTIVE": + report.status = "PASS" + report.status_extended = "Smart Card IdP authenticator is ACTIVE." + elif authenticator: + report.status = "FAIL" + report.status_extended = ( + f"Smart Card IdP authenticator is not active; current status is " + f"{authenticator.status}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Smart Card IdP authenticator is not active or missing." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/lib/__init__.py b/prowler/providers/okta/services/authenticator/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py new file mode 100644 index 0000000000..851daec5a8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + AuthenticatorSummary, + OktaAuthenticator, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def find_authenticator_by_key( + authenticators: dict[str, OktaAuthenticator], key: str +) -> OktaAuthenticator | None: + """Return the first authenticator with the requested key. + + Okta enforces unique authenticator `key` values per org for built-in + types (`okta_verify`, `smart_card_idp`, etc.), so the "first match" + is the only match in practice. If a future Okta release relaxes + that and returns duplicates, only the first is evaluated — STIG + semantics need refining at that point. + """ + for authenticator in authenticators.values(): + if authenticator.key == key: + return authenticator + return None + + +def missing_authenticator_resource(key: str, name: str) -> AuthenticatorSummary: + """Build a synthetic resource for a missing authenticator.""" + return AuthenticatorSummary(id=f"{key}-missing", name=name) + + +def missing_authenticators_scope_finding( + metadata, org_domain: str, key: str, name: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when authenticators cannot be listed.""" + resource = AuthenticatorSummary(id=f"{key}-scope-missing", name=name) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta authenticators to evaluate {name}: the Okta " + f"service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py new file mode 100644 index 0000000000..08aa64a3a6 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py @@ -0,0 +1,80 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + PasswordPolicy, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def active_password_policies( + password_policies: dict[str, PasswordPolicy], +) -> list[PasswordPolicy]: + """Return active password policies sorted by priority. + + Treats `policy.status == ""` as ACTIVE: the typed Okta SDK + occasionally returns policies without a `status` field populated + (the SDK enum doesn't cover every server-side value Okta has + shipped). Dropping those would silently hide real policies — we + 'd rather evaluate them and let the per-field comparator decide. + """ + return sorted( + [ + policy + for policy in password_policies.values() + if not policy.status or policy.status.upper() == "ACTIVE" + ], + key=lambda policy: ( + policy.priority if policy.priority is not None else float("inf"), + policy.name, + ), + ) + + +def password_policy_label(policy: PasswordPolicy) -> str: + kind = "default" if policy.is_default else "custom" + priority = policy.priority if policy.priority is not None else "unset" + return f"Password Policy {policy.name} (priority {priority}, {kind})" + + +def no_active_password_policies_finding( + metadata, org_domain: str, requirement: str +) -> CheckReportOkta: + """Build the FAIL finding emitted when no active password policies exist.""" + placeholder = PasswordPolicy( + id="password-policies-missing", + name="(no active password policies)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No active Okta Password Policies were returned by the API. " + f"The organization must enforce: {requirement}." + ) + return report + + +def missing_password_policies_scope_finding( + metadata, org_domain: str, scope: str, requirement: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when Password Policies cannot be listed.""" + placeholder = PasswordPolicy( + id="password-policies-scope-missing", + name="(scope not granted)", + status="UNKNOWN", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta Password Policies to evaluate {requirement}: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/idp/__init__.py b/prowler/providers/okta/services/idp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/idp_client.py b/prowler/providers/okta/services/idp/idp_client.py new file mode 100644 index 0000000000..5bbf98c17a --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.idp.idp_service import Idp + +idp_client = Idp(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/idp/idp_service.py b/prowler/providers/okta/services/idp/idp_service.py new file mode 100644 index 0000000000..2ff106cb47 --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_service.py @@ -0,0 +1,118 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.service import OktaService + +# Okta's API value for the "Smart Card" IdP shown in the Admin Console. +# The UI label is "Smart Card IdP" but the `type` field on the API response +# is `X509` (Mutual TLS) — that is the value we filter on. +SMART_CARD_IDP_TYPE = "X509" + +REQUIRED_SCOPES: dict[str, str] = { + "identity_providers": "okta.idps.read", +} + + +class Idp(OktaService): + """Fetches Okta Identity Providers. + + Populates `self.identity_providers` keyed by IdP id. Each entry + captures the minimum fields the bundled checks read: identity + (`id`, `name`), `type`, `status`, and — for `X509` Smart Card IdPs + — the certificate-chain `issuer` and `kid` exposed by Okta's + `protocol.credentials.trust` structure. Reading the issuer DN lets + the check surface it for out-of-band verification against the + DOD-approved CA list. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.identity_providers: dict[str, OktaIdentityProvider] = ( + {} + if self.missing_scope["identity_providers"] + else self._list_identity_providers() + ) + + def _list_identity_providers(self) -> dict: + logger.info("Idp - Listing Okta Identity Providers...") + try: + return self._run(self._fetch_identity_providers()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_identity_providers(self) -> dict: + result: dict[str, OktaIdentityProvider] = {} + all_idps, err = await paginate( + lambda after: self.client.list_identity_providers(after=after) + ) + if err is not None: + logger.error(f"Error listing identity providers: {err}") + return result + + for idp in all_idps: + idp_id = getattr(idp, "id", "") or "" + if not idp_id: + continue + issuer, kid = _trust_fields(idp) + result[idp_id] = OktaIdentityProvider( + id=idp_id, + name=getattr(idp, "name", "") or "", + type=_stringify_enum(getattr(idp, "type", None)) or "", + status=_stringify_enum(getattr(idp, "status", None)) or "", + trust_issuer=issuer, + trust_kid=kid, + ) + return result + + +def _trust_fields(idp) -> tuple[Optional[str], Optional[str]]: + """Extract `issuer` and `kid` from an `X509` IdP's protocol.credentials.trust. + + The SDK exposes `IdentityProvider.protocol` as `IdentityProviderProtocol`, + a Pydantic v2 oneOf wrapper that holds the concrete protocol (ProtocolMtls + for X509 IdPs) on `actual_instance`. `credentials` is not proxied on the + wrapper, so reading it directly returns None — we have to unwrap first. + """ + protocol = getattr(idp, "protocol", None) + if protocol is None: + return None, None + actual_protocol = getattr(protocol, "actual_instance", None) or protocol + credentials = getattr(actual_protocol, "credentials", None) + if credentials is None: + return None, None + trust = getattr(credentials, "trust", None) + if trust is None: + return None, None + return getattr(trust, "issuer", None), getattr(trust, "kid", None) + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class OktaIdentityProvider(BaseModel): + id: str + name: str = "" + type: str = "" + status: str = "" + trust_issuer: Optional[str] = None + trust_kid: Optional[str] = None diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json new file mode 100644 index 0000000000..e7e85294be --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "idp_smart_card_dod_approved_ca", + "CheckTitle": "Okta Smart Card (X509) Identity Provider uses a DOD-approved certificate authority", + "CheckType": [], + "ServiceName": "idp", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every Okta Smart Card (X509) Identity Provider must be `ACTIVE` and its certificate chain must be issued by a DOD-approved CA. The check ships default issuer-DN patterns covering DOD PKI and ECA, and matches them against the chain's `issuer`. Override or extend via `okta_dod_approved_ca_issuer_patterns` in the audit config to recognise tenant-specific DOD CAs.", + "Risk": "An Okta Smart Card IdP whose certificate chain is not issued by a DOD-approved CA can be used to authenticate non-vetted identities.\n\n- **Trust on an unverified CA** allows impersonation of CAC/PIV holders\n- **Bypass of the federal PKI** required for DOD-grade identity assurance\n- **Acceptance of certificates** from a private or unaccredited issuer", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/security/idp-enable-smart-card.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/IdentityProvider/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Identity Providers**.\n3. For each IdP whose **Type** is **Smart Card**, click **Actions** > **Configure**.\n4. Under **Certificate chain**, verify the certificate is from a DOD-approved Certificate Authority (DOD PKI, ECA, JITC, or equivalent).\n5. If the IdP is not **Active**, activate it once the chain is validated.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Verify each Okta Smart Card (X509) Identity Provider is ACTIVE and its certificate chain is issued by a DOD-approved Certificate Authority. Document the issuer for audit evidence.", + "Url": "https://hub.prowler.com/check/idp_smart_card_dod_approved_ca" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273207 / OKTA-APP-001920." +} diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py new file mode 100644 index 0000000000..5d19cca0dd --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py @@ -0,0 +1,148 @@ +import re + +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.idp.idp_client import idp_client +from prowler.providers.okta.services.idp.idp_service import ( + SMART_CARD_IDP_TYPE, + OktaIdentityProvider, +) +from prowler.providers.okta.services.idp.lib.idp_helpers import ( + missing_idps_scope_finding, +) + +# Default issuer-DN substring patterns recognised as DOD-approved Certificate +# Authorities. The DOD PKI publishes canonical DN forms that include +# `O=U.S. Government, OU=DoD` (for DoD Root, DoD ID, DoD EMAIL, DoD SW, DoD +# JITC CAs) and `O=U.S. Government, OU=ECA` for the External Certificate +# Authorities. Customers running an internal CA outside these patterns can +# extend the list via the `okta_dod_approved_ca_issuer_patterns` audit-config +# entry — see the per-check Notes in metadata.json. +DEFAULT_DOD_CA_ISSUER_PATTERNS = ( + # `OU=DoD` is the distinctive DISA DN component for every CA in the DoD + # PKI (Root, ID, EMAIL, SW, JITC). `OU=ECA` is the equivalent for the + # External Certificate Authorities. The trailing `\b` prevents accidental + # matches against superstrings like `OU=DoDExtra`. + r"\bOU=DoD\b", + r"\bOU=ECA\b", +) + + +class idp_smart_card_dod_approved_ca(Check): + """Verifies that Okta Smart Card (X509) IdPs are configured and use a DOD-approved CA. + + PASS when the IdP is `ACTIVE` and its certificate chain's `issuer` + DN matches one of the configured DOD-approved CA patterns. MANUAL + when active but the issuer doesn't match (operator can verify + out-of-band or extend the pattern list). FAIL when no Smart Card + IdP is configured or when the configured IdP is inactive. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = idp_client.provider.identity.org_domain + audit_config = idp_client.audit_config or {} + configured_patterns = audit_config.get("okta_dod_approved_ca_issuer_patterns") + patterns = ( + tuple(configured_patterns) + if configured_patterns + else DEFAULT_DOD_CA_ISSUER_PATTERNS + ) + + missing_scope = idp_client.missing_scope.get("identity_providers") + if missing_scope: + findings.append( + missing_idps_scope_finding(self.metadata(), org_domain, missing_scope) + ) + return findings + + smart_card_idps = [ + idp + for idp in idp_client.identity_providers.values() + if (idp.type or "").upper() == SMART_CARD_IDP_TYPE + ] + + if not smart_card_idps: + placeholder = OktaIdentityProvider( + id="okta-smart-card-idp-missing", + name="(no Smart Card IdP configured)", + type=SMART_CARD_IDP_TYPE, + status="MISSING", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No Smart Card (X509) Identity Providers are configured. " + "Configure a Smart Card IdP in the Admin Console " + "(Security > Identity Providers) with a certificate chain " + "issued by a DOD-approved CA. If CAC/PIV authentication is " + "not required for this tenant, mutelist this check with " + "that documented exception." + ) + findings.append(report) + return findings + + for idp in smart_card_idps: + report = CheckReportOkta( + metadata=self.metadata(), resource=idp, org_domain=org_domain + ) + label = f"Okta Smart Card IdP '{idp.name}' (id={idp.id}, type={idp.type})" + chain_detail = _format_chain_detail(idp) + + if (idp.status or "").upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"{label} is not ACTIVE (status={idp.status or 'unset'}). " + "Activate the IdP from Security > Identity Providers, then " + f"verify the certificate chain. {chain_detail}" + ) + findings.append(report) + continue + + matched_pattern = _matched_issuer_pattern(idp.trust_issuer, patterns) + if matched_pattern is not None: + report.status = "PASS" + report.status_extended = ( + f"{label} is ACTIVE and its chain issuer matches a " + f"DOD-approved CA pattern (`{matched_pattern}`). " + f"{chain_detail}" + ) + else: + report.status = "MANUAL" + report.status_extended = ( + f"{label} is ACTIVE but its chain issuer does not match any " + "configured DOD-approved CA pattern. Verify out-of-band " + "that the certificate chain belongs to a DOD-approved " + "Certificate Authority, or extend " + "`okta_dod_approved_ca_issuer_patterns` in the audit " + f"config. {chain_detail}" + ) + findings.append(report) + return findings + + +def _matched_issuer_pattern(issuer, patterns): + if not issuer: + return None + for pattern in patterns: + try: + if re.search(pattern, issuer): + return pattern + except re.error: + # Skip malformed operator-supplied patterns rather than crashing + # the whole check. + continue + return None + + +def _format_chain_detail(idp: OktaIdentityProvider) -> str: + if idp.trust_issuer or idp.trust_kid: + return ( + f"Chain issuer: {idp.trust_issuer or 'unset'}; " + f"kid: {idp.trust_kid or 'unset'}." + ) + return ( + "Chain issuer and kid were not exposed by the API; inspect the IdP in " + "the Admin Console under Security > Identity Providers > Configure." + ) diff --git a/prowler/providers/okta/services/idp/lib/__init__.py b/prowler/providers/okta/services/idp/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/lib/idp_helpers.py b/prowler/providers/okta/services/idp/lib/idp_helpers.py new file mode 100644 index 0000000000..f1689f34a1 --- /dev/null +++ b/prowler/providers/okta/services/idp/lib/idp_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA idp STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider + + +def missing_idps_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the IdPs scope is not granted.""" + placeholder = OktaIdentityProvider( + id="okta-idps-scope-missing", + name="(scope not granted)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta Identity Providers: the Okta service app is " + f"missing the required `{scope}` API scope. Grant it on the service " + "app's Okta API Scopes tab in the Okta Admin Console, then re-run the " + "check." + ) + return report diff --git a/prowler/providers/okta/services/network/__init__.py b/prowler/providers/okta/services/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/lib/__init__.py b/prowler/providers/okta/services/network/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/lib/network_zone_helpers.py b/prowler/providers/okta/services/network/lib/network_zone_helpers.py new file mode 100644 index 0000000000..bcd906b01a --- /dev/null +++ b/prowler/providers/okta/services/network/lib/network_zone_helpers.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZoneSummary, + OktaNetworkZone, +) + +ANONYMIZER_CATEGORY_MARKERS = ( + "ANONYM", + "PROXY", + "TOR", + "VPN", +) + + +def active_blocklist_zones( + network_zones: dict[str, OktaNetworkZone], +) -> list[OktaNetworkZone]: + """Return active Network Zones configured for blocklist usage.""" + return sorted( + [ + zone + for zone in network_zones.values() + if zone.status.upper() == "ACTIVE" and zone.usage.upper() == "BLOCKLIST" + ], + key=lambda zone: (zone.name, zone.id), + ) + + +def is_ip_blocklist_with_entries(zone: OktaNetworkZone) -> bool: + """Return True when an IP blocklist zone contains gateway/proxy entries.""" + return zone.type.upper() == "IP" and bool(zone.gateways or zone.proxies) + + +def is_enhanced_dynamic_anonymizer_blocklist(zone: OktaNetworkZone) -> bool: + """Return True for active Enhanced Dynamic blocklists covering anonymizers.""" + if zone.type.upper() != "DYNAMIC_V2": + return False + categories = [category.upper() for category in zone.ip_service_categories] + return any( + marker in category + for category in categories + for marker in ANONYMIZER_CATEGORY_MARKERS + ) + + +def compliant_anonymized_proxy_blocklist( + network_zones: dict[str, OktaNetworkZone], +) -> tuple[OktaNetworkZone | None, str]: + """Find the Network Zone that satisfies anonymized-proxy blocklisting.""" + for zone in active_blocklist_zones(network_zones): + if is_enhanced_dynamic_anonymizer_blocklist(zone): + return zone, "active Enhanced Dynamic Zone blocklist for anonymizers" + return None, "" + + +def static_ip_blocklist_evidence( + network_zones: dict[str, OktaNetworkZone], +) -> OktaNetworkZone | None: + """Return static IP blocklist evidence that requires human validation.""" + for zone in active_blocklist_zones(network_zones): + if is_ip_blocklist_with_entries(zone): + return zone + return None + + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def missing_network_zone_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when Network Zones cannot be listed.""" + resource = NetworkZoneSummary( + id="network-zones-scope-missing", + name="(scope not granted)", + ) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta Network Zones: the Okta service app " + f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json new file mode 100644 index 0000000000..d2a4791c17 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "network_zone_block_anonymized_proxies", + "CheckTitle": "Okta uses active Network Zone blocklists for anonymized proxy sources", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Okta Network Zone blocklists** should block anonymized proxy access before authentication. Enhanced Dynamic Zone anonymizer categories provide direct coverage; static IP blocklists show gateway/proxy blocking but cannot prove full anonymizer-provider coverage.", + "Risk": "**Anonymized proxy access** lets attackers hide source networks while attempting credential attacks, session establishment, or policy bypass from untrusted infrastructure.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/networkzone", + "https://help.okta.com/en-us/content/topics/security/network/about-enhanced-dynamic-zones.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Security > Networks: configure BlockedIpZone gateway/proxy IP entries or activate DefaultEnhancedDynamicZone / Enhanced Dynamic Zone blocklisting for anonymizers.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Prefer an active Enhanced Dynamic Zone blocklist** for anonymizers. If you use a static IP Network Zone blocklist, keep gateway/proxy entries actively maintained because Prowler cannot prove full anonymizer-provider coverage from static entries alone.", + "Url": "https://hub.prowler.com/check/network_zone_block_anonymized_proxies" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py new file mode 100644 index 0000000000..3da973b415 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py @@ -0,0 +1,77 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.network.lib.network_zone_helpers import ( + compliant_anonymized_proxy_blocklist, + missing_network_zone_scope_finding, + static_ip_blocklist_evidence, +) +from prowler.providers.okta.services.network.network_zone_client import ( + network_zone_client, +) +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZoneSummary, +) + + +class network_zone_block_anonymized_proxies(Check): + """Ensure Okta actively blocks anonymized proxy sources before auth.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate whether an active blocklist covers anonymized proxies.""" + org_domain = network_zone_client.provider.identity.org_domain + missing_scope = network_zone_client.missing_scope.get("network_zones") + if missing_scope: + return [ + missing_network_zone_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ] + + retrieval_error = getattr(network_zone_client, "retrieval_error", None) + if retrieval_error: + resource = NetworkZoneSummary( + id="network-zones-retrieval-error", + name="(retrieval failed)", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Okta Network Zones could not be retrieved or validated. " + f"Reason: {retrieval_error}" + ) + return [report] + + matching_zone, reason = compliant_anonymized_proxy_blocklist( + network_zone_client.network_zones + ) + manual_zone = ( + None + if matching_zone + else static_ip_blocklist_evidence(network_zone_client.network_zones) + ) + + resource = matching_zone or manual_zone or NetworkZoneSummary() + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if matching_zone: + report.status = "PASS" + report.status_extended = ( + f"Okta Network Zone {matching_zone.name} is an {reason}." + ) + elif manual_zone: + report.status = "MANUAL" + report.status_extended = ( + f"Okta Network Zone {manual_zone.name} is an active manual IP " + "blocklist with gateway or proxy IP entries; Prowler cannot " + "verify full anonymizer coverage for static entries." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "No active Okta Network Zone blocklist was found that blocks " + "anonymized proxies. Existing zones do not actively block gateway " + "or proxy IPs, nor an Enhanced Dynamic Zone anonymizer category." + ) + return [report] diff --git a/prowler/providers/okta/services/network/network_zone_client.py b/prowler/providers/okta/services/network/network_zone_client.py new file mode 100644 index 0000000000..6d4f603f80 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.network.network_zone_service import NetworkZone + +network_zone_client = NetworkZone(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/network/network_zone_service.py b/prowler/providers/okta/services/network/network_zone_service.py new file mode 100644 index 0000000000..550c9b0d49 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_service.py @@ -0,0 +1,234 @@ +from typing import Optional + +from pydantic import BaseModel, Field, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "network_zones": "okta.networkZones.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +class NetworkZone(OktaService): + """Fetches Okta Network Zones for STIG network-zone checks.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.retrieval_error: str | None = None + self.network_zones: dict[str, OktaNetworkZone] = ( + {} if self.missing_scope["network_zones"] else self._list_network_zones() + ) + + def _set_retrieval_error(self, message: str) -> None: + self.retrieval_error = message + logger.error(message) + + def _list_network_zones(self) -> dict[str, "OktaNetworkZone"]: + """List all Network Zones visible to the configured Okta service app.""" + logger.info("NetworkZone - Listing Okta Network Zones...") + try: + return self._run(self._fetch_all()) + except Exception as error: + line_number = getattr(error.__traceback__, "tb_lineno", "unknown") + self._set_retrieval_error( + f"{error.__class__.__name__}[{line_number}]: {error}" + ) + return {} + + async def _fetch_all(self) -> dict[str, "OktaNetworkZone"]: + result: dict[str, OktaNetworkZone] = {} + try: + all_zones, err = await _paginate_shared( + lambda after: self.client.list_network_zones(after=after, limit=200) + ) + except (ValueError, ValidationError) as ex: + # Upstream Okta SDK ↔ Management API schema drift: the SDK + # generates `EnhancedDynamicNetworkZoneAllOfAsnsInclude` as an + # object-shaped pydantic model, but the API returns + # `asns.include` as a JSON array (typically `[]`), so pydantic + # rejects the whole zone with `model_type` errors. Fall back + # to a raw-JSON fetch so STIG evaluation isn't blocked by an + # upstream SDK bug. Same workaround shape as + # `application_service._fetch_access_policy_raw`. The wider + # `(ValueError, ValidationError)` catch matches the + # `user_service` precedent — the SDK raises either depending + # on whether the failure is a discriminator miss or a model + # mismatch. + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing Network Zones — " + "falling back to raw-JSON parse. This is an okta-sdk-python " + "deserialization bug; the workaround should be removed once " + "upstream fixes it." + ) + return await self._fetch_all_raw() + if err is not None: + self._set_retrieval_error(f"Error listing Network Zones: {err}") + return result + + for zone in all_zones: + zone_obj = self._build_zone(zone) + result[zone_obj.id] = zone_obj + return result + + async def _fetch_all_raw(self) -> dict[str, "OktaNetworkZone"]: + """Raw-JSON fallback for `list_network_zones`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper, then projects each zone onto our + own pydantic snapshot — which only validates the fields the + STIG checks actually read. + """ + result: dict[str, OktaNetworkZone] = {} + zones_data = await _raw_get_json_paginated( + self.client, + "/api/v1/zones", + page_size=200, + context="Network Zones", + ) + if zones_data is None: + self._set_retrieval_error( + "Raw Network Zones fetch failed; see logs for details." + ) + return result + for zone_dict in zones_data: + if not isinstance(zone_dict, dict): + continue + zone_obj = _raw_zone_to_model(zone_dict) + result[zone_obj.id] = zone_obj + return result + + @staticmethod + def _build_zone(zone) -> "OktaNetworkZone": + zone_id = _value(getattr(zone, "id", None)) + return OktaNetworkZone( + id=zone_id, + name=_value(getattr(zone, "name", None)) or zone_id, + status=_value(getattr(zone, "status", None)), + type=_value(getattr(zone, "type", None)), + usage=_value(getattr(zone, "usage", None)), + system=bool(getattr(zone, "system", False)), + gateways=_address_values(getattr(zone, "gateways", None)), + proxies=_address_values(getattr(zone, "proxies", None)), + asns=_condition_values(getattr(zone, "asns", None)), + locations=_condition_values(getattr(zone, "locations", None)), + ip_service_categories=_condition_values( + getattr(zone, "ip_service_categories", None) + ), + ) + + +def _raw_zone_to_model(zone_dict: dict) -> "OktaNetworkZone": + """Project a raw `/api/v1/zones` JSON zone onto our model. + + Mirrors `NetworkZone._build_zone` but reads camelCase JSON keys + (`ipServiceCategories`) instead of the SDK's snake_case attributes. + Used by the raw-JSON fallback that activates when the Okta SDK's + strict pydantic validators reject zone payloads the Management API + returns (e.g. Enhanced Dynamic Zones with `asns.include: []`). + """ + zone_id = str(zone_dict.get("id") or "") + categories = _condition_values(zone_dict.get("ipServiceCategories")) + # IP-typed zones return `gateways`/`proxies` as `[{type, value}]` + # arrays; Enhanced Dynamic Zones return `asns`/`locations` and + # `ipServiceCategories` as `{include, exclude}` objects. Keep the + # `list[str]` shape by extracting address values and included + # condition values from both SDK models and raw JSON. + return OktaNetworkZone( + id=zone_id, + name=str(zone_dict.get("name") or zone_id), + status=str(zone_dict.get("status") or ""), + type=str(zone_dict.get("type") or ""), + usage=str(zone_dict.get("usage") or ""), + system=bool(zone_dict.get("system", False)), + gateways=_address_values(zone_dict.get("gateways")), + proxies=_address_values(zone_dict.get("proxies")), + asns=_condition_values(zone_dict.get("asns")), + locations=_condition_values(zone_dict.get("locations")), + ip_service_categories=categories, + ) + + +def _address_values(raw) -> list[str]: + """Return string values from an Okta address-style JSON array. + + Each entry in `gateways`/`proxies` is `{"type": ..., "value": ...}`; + `asns`/`locations` may be a `{include, exclude}` object on Enhanced + Dynamic Zones. Non-list inputs collapse to `[]` so the resulting + list satisfies the pydantic `list[str]` field. + """ + if not isinstance(raw, list): + return [] + out: list[str] = [] + for entry in raw: + if isinstance(entry, dict): + value = entry.get("value") + elif entry is not None: + value = getattr(entry, "value", entry) + else: + value = None + if value is not None: + out.append(_value(value)) + return out + + +def _condition_values(raw) -> list[str]: + """Return string values from Okta include/exclude-style conditions.""" + if raw is None: + return [] + values = ( + raw.get("include") if isinstance(raw, dict) else getattr(raw, "include", raw) + ) + if values is None: + return [] + if not isinstance(values, list): + values = [values] + normalized = [] + for value in values: + if isinstance(value, dict): + value = value.get("value") + if value is not None: + normalized.append(_value(value)) + return normalized + + +class OktaNetworkZone(BaseModel): + """Normalized Okta Network Zone attributes used by checks.""" + + id: str + name: str + status: str = "" + type: str = "" + usage: str = "" + system: bool = False + gateways: list[str] = Field(default_factory=list) + proxies: list[str] = Field(default_factory=list) + asns: list[str] = Field(default_factory=list) + locations: list[str] = Field(default_factory=list) + ip_service_categories: list[str] = Field(default_factory=list) + + +class NetworkZoneSummary(BaseModel): + """Synthetic resource for org-level Network Zone findings.""" + + id: str = "okta-network-zones" + name: str = "Okta Network Zones" diff --git a/prowler/providers/okta/services/signon/__init__.py b/prowler/providers/okta/services/signon/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/lib/__init__.py b/prowler/providers/okta/services/signon/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/lib/signon_helpers.py b/prowler/providers/okta/services/signon/lib/signon_helpers.py new file mode 100644 index 0000000000..7c84fe5733 --- /dev/null +++ b/prowler/providers/okta/services/signon/lib/signon_helpers.py @@ -0,0 +1,146 @@ +"""Shared helpers for the OKTA sign-on STIG checks. + +The four `signon_global_session_*` checks share the same plumbing: +they iterate active Global Session Policies in priority order, locate +each policy's Priority 1 active rule, and emit one finding per policy. +This module centralises that plumbing so each check can stay focused +on its STIG-specific predicate. +""" + +from typing import Optional + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, + SignInPage, +) + + +def active_policies( + global_session_policies: dict[str, GlobalSessionPolicy], +) -> list[GlobalSessionPolicy]: + """Return active policies sorted by priority (ascending, name as tiebreaker). + + A policy with no `status` is treated as ACTIVE because the Okta SDK + sometimes omits the field on default policies. + """ + return sorted( + [ + policy + for policy in global_session_policies.values() + if not policy.status or policy.status.upper() == "ACTIVE" + ], + key=lambda policy: ( + policy.priority if policy.priority is not None else float("inf"), + policy.name, + ), + ) + + +def priority_one_active_rule( + policy: GlobalSessionPolicy, +) -> Optional[GlobalSessionPolicyRule]: + """Return the policy's Priority 1 active rule, or None. + + Okta's evaluator skips inactive rules, so we first filter to active + rules and pick the highest-priority one. If that rule is not at + priority 1 we return None — the policy effectively has no + priority-1 rule for evaluation purposes. + """ + active_rules = sorted( + [ + rule + for rule in policy.rules + if not rule.status or rule.status.upper() == "ACTIVE" + ], + key=lambda rule: ( + rule.priority if rule.priority is not None else float("inf"), + rule.name, + ), + ) + if not active_rules: + return None + candidate = active_rules[0] + if candidate.priority != 1: + return None + return candidate + + +def policy_label(policy: GlobalSessionPolicy) -> str: + kind = "default" if policy.is_default else "custom" + priority = policy.priority if policy.priority is not None else "unset" + return f"Global Session Policy '{policy.name}' (priority {priority}, {kind})" + + +def no_active_policies_finding( + metadata, org_domain: str, status_extended: str +) -> CheckReportOkta: + """Build the FAIL finding emitted when no active sign-on policies exist.""" + placeholder = GlobalSessionPolicy( + id="signon-policies-missing", + name="(no active sign-on policies)", + priority=1, + status="MISSING", + is_default=False, + rules=[], + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = status_extended + return report + + +_SCOPE_ADVICE = ( + "Grant it on the service app's Okta API Scopes tab in the Okta Admin " + "Console, then re-run the check." +) + + +def missing_policy_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding for a sign-on policy check when the API scope is not granted.""" + placeholder = GlobalSessionPolicy( + id="signon-policies-scope-missing", + name="(scope not granted)", + priority=1, + status="MISSING", + is_default=False, + rules=[], + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Global Session Policies: the Okta service app " + f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report + + +def missing_brand_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding for a brand/sign-in-page check when the API scope is not granted.""" + placeholder = SignInPage( + brand_id="signon-brands-scope-missing", + brand_name="(scope not granted)", + is_customized=False, + ) + report = CheckReportOkta( + metadata=metadata, + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.brand_name, + resource_id=placeholder.brand_id, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta brand sign-in pages: the Okta service app " + f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/signon/signon_client.py b/prowler/providers/okta/services/signon/signon_client.py new file mode 100644 index 0000000000..b64a15d8da --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.signon.signon_service import Signon + +signon_client = Signon(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/__init__.py b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json new file mode 100644 index 0000000000..523795c8ac --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "signon_dod_warning_banner_configured", + "CheckTitle": "Okta sign-in page displays the Standard Mandatory DOD Notice and Consent Banner", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "informational", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Each Okta brand's sign-in page must present the **Standard Mandatory DOD Notice and Consent Banner** (`DTM-08-060`) before granting access.\n\nThe check inspects the sign-in page HTML returned by the Okta Management API, using the *customized* page when present and otherwise falling back to the *default* sign-in page.\n\nAligns with **DISA STIG V-273192 / OKTA-APP-000200**.", + "Risk": "Without the **DOD Notice and Consent Banner**, users are not informed that the system is a U.S. Government information system subject to monitoring.\n\n- **Legal basis** for incident response and prosecution is weakened\n- **Alignment** with federal laws, Executive Orders, directives, and standards is broken\n- **Implied consent** to monitoring cannot be asserted on connection", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/settings/settings-customization.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/CustomPages/", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Brands/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Follow the supplemental *Okta DOD Warning Banner Configuration Guide* shipped with the **DISA Okta IDaaS STIG** package.\n2. Sign in to the **Okta Admin Console** as a *Super Admin*.\n3. Navigate to **Customizations** > **Brands** and select the brand.\n4. Edit the **Sign-in page** customization.\n5. Insert the full `DTM-08-060` Standard Mandatory DOD Notice and Consent Banner text into the page content.\n6. Publish the customization and verify the banner is presented before sign-in.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Customize the Okta sign-in page for **each brand** to display the **Standard Mandatory DOD Notice and Consent Banner** (`DTM-08-060`) before users authenticate.\n\nApplies only to Okta tenants under **U.S. Department of Defense** scope; non-DOD organizations can mute this check.", + "Url": "https://hub.prowler.com/check/signon_dod_warning_banner_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Applicable only to Okta tenants under U.S. Department of Defense scope (DISA Okta IDaaS STIG, control V-273192 / OKTA-APP-000200). For non-DOD organizations this check is not applicable and can be muted." +} diff --git a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py new file mode 100644 index 0000000000..347f04f245 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py @@ -0,0 +1,129 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + missing_brand_scope_finding, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import SignInPage + +# Distinctive marker groups drawn from the DTM-08-060 Standard Mandatory +# DOD Notice and Consent Banner. The HTML can vary across brands, so the +# check looks for the banner's core ideas rather than requiring an exact +# string match. +BANNER_MARKER_GROUPS = ( + ("u.s. government", "us government"), + ("information system", "information systems"), + ("authorized use only", "authorized use"), + ( + "subject to monitoring", + "may be intercepted", + "searched, monitored, and recorded", + "consent to monitoring", + ), +) + + +def _matched_banner_groups(content_lower: str) -> list[str]: + matched_markers: list[str] = [] + for marker_group in BANNER_MARKER_GROUPS: + for marker in marker_group: + if marker in content_lower: + matched_markers.append(marker) + break + return matched_markers + + +class signon_dod_warning_banner_configured(Check): + """STIG V-273192 / OKTA-APP-000200. + + Okta must display the Standard Mandatory DOD Notice and Consent + Banner (DTM-08-060) before granting access to the application. The + check inspects each brand's sign-in page HTML returned by the Okta + Management API, using the customized page when present and otherwise + falling back to the default sign-in page. + """ + + def execute(self) -> list[CheckReportOkta]: + org_domain = signon_client.provider.identity.org_domain + findings: list[CheckReportOkta] = [] + + missing_scope = signon_client.missing_scope.get("sign_in_pages") + if missing_scope: + return [ + missing_brand_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + if not signon_client.sign_in_pages: + placeholder = SignInPage( + brand_id="no-brands-detected", + brand_name="(no brands detected)", + is_customized=False, + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.brand_name, + resource_id=placeholder.brand_id, + ) + report.status = "MANUAL" + report.status_extended = ( + "No Okta brands were retrieved from the Brands API. Verify " + "the sign-in page for the organization displays the DOD " + "Notice and Consent Banner (DTM-08-060) in the Admin Console." + ) + findings.append(report) + return findings + + for page in signon_client.sign_in_pages.values(): + report = CheckReportOkta( + metadata=self.metadata(), + resource=page, + org_domain=org_domain, + resource_name=page.brand_name or page.brand_id, + resource_id=page.brand_id, + ) + + if page.fetch_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve the sign-in page for " + f"brand '{page.brand_name or page.brand_id}' ({page.fetch_error}). " + "Inspect the brand manually to confirm the " + "DOD Notice and Consent Banner (DTM-08-060) is displayed." + ) + findings.append(report) + continue + + if not page.page_content: + report.status = "MANUAL" + report.status_extended = ( + f"Sign-in page content for brand " + f"'{page.brand_name or page.brand_id}' could not be " + "retrieved from the Okta API. Verify the DOD Notice and " + "Consent Banner (DTM-08-060) manually in the Admin Console." + ) + findings.append(report) + continue + + page_type = "customized" if page.is_customized else "default" + content_lower = page.page_content.lower() + matches = _matched_banner_groups(content_lower) + + if len(matches) == len(BANNER_MARKER_GROUPS): + report.status = "PASS" + report.status_extended = ( + f"DOD Notice and Consent Banner detected on the {page_type} " + f"sign-in page for brand '{page.brand_name or page.brand_id}' " + f"({len(matches)} of {len(BANNER_MARKER_GROUPS)} required " + "marker groups matched)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{page_type.title()} sign-in page for brand " + f"'{page.brand_name or page.brand_id}' does not contain " + "the DOD Notice and Consent Banner (DTM-08-060)." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.metadata.json new file mode 100644 index 0000000000..c4738fd5bf --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_cookies_not_persistent", + "CheckTitle": "Default Global Session Policy has a Priority 1 non-default rule disabling persistent global session cookies", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta **Global Session Policy** needs a **Priority 1** rule that is **not** the built-in `Default Rule`, setting *Okta global session cookies persist across browser sessions* to `Disabled`.\n\nOkta evaluates policies by group assignment, so a permissive custom policy can govern users. Aligns with **DISA STIG V-273206 / OKTA-APP-001710**.", + "Risk": "Persistent global session cookies keep an authenticated Okta session alive across browser restarts.\n\n- **Surviving sessions** outlive the browsing context the user expected\n- **Cached authorization decisions** remain in effect after the browser closes\n- **Forgotten or shared devices** continue to hold authenticated access until cookies expire on their own", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Global Session Policy**.\n3. Open the **Default Policy** (and repeat for every other active policy).\n4. Add or edit a non-default rule.\n5. Move that rule to **Priority 1** so it is evaluated before the built-in `Default Rule`.\n6. Set *Okta global session cookies persist across browser sessions* to `Disabled`.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1 before the Default Rule\n session_persistent = false # Critical: disable persistent global session cookies\n}\n```" + }, + "Recommendation": { + "Text": "Configure each active **Global Session Policy** so a non-default rule at **Priority 1**:\n- Sets *Okta global session cookies persist across browser sessions* to `Disabled`\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`\n\nReview group assignments to confirm the rule actually governs the intended users.", + "Url": "https://hub.prowler.com/check/signon_global_session_cookies_not_persistent" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.py b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.py new file mode 100644 index 0000000000..c5ffd849b5 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.py @@ -0,0 +1,100 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + + +class signon_global_session_cookies_not_persistent(Check): + """STIG V-273206 / OKTA-APP-001710. + + Every active Global Session Policy must have an active Priority 1 + rule that is not the built-in Default Rule, and that rule must + disable persistent global session cookies so the session does not + survive across browser restarts. + + Okta evaluates sign-on policies in priority order based on group + assignments, so a permissive custom policy can govern a user's + session even when the Default Policy is strict. The check emits one + finding per active policy to surface that risk. + """ + + def execute(self) -> list[CheckReportOkta]: + org_domain = signon_client.provider.identity.org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-273206 requires the policy that governs each user to enforce " + "a Priority 1 non-default rule that disables persistent global " + "session cookies.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-273206 requires " + "a non-default Priority 1 rule that disables persistent global " + "session cookies.", + ) + + if rule.is_default or rule.name == "Default Rule": + return ( + "FAIL", + f"{label} uses '{rule.name}' as its active Priority 1 rule. " + "The STIG requires a non-default Priority 1 rule.", + ) + + use_persistent_cookie = rule.use_persistent_cookie + if use_persistent_cookie is None: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "does not assert the 'Okta global session cookies persist across " + "browser sessions' setting.", + ) + + if use_persistent_cookie is False: + return ( + "PASS", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "disables persistent global session cookies.", + ) + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "allows persistent global session cookies, leaving the session active " + "across browser restarts.", + ) diff --git a/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.metadata.json new file mode 100644 index 0000000000..f31a835864 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_idle_timeout_15min", + "CheckTitle": "Default Global Session Policy has a Priority 1 non-default rule enforcing 15-minute idle timeout", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta **Global Session Policy** needs a **Priority 1** rule that is **not** the built-in `Default Rule`, setting *Maximum Okta global session idle time* to `15` minutes or less.\n\nOkta evaluates policies by group assignment, so a permissive custom policy can govern users. Threshold override: `okta_max_session_idle_minutes`. Aligns with **DISA STIG V-273186**.", + "Risk": "Without a `15`-minute idle timeout, an unattended workstation leaves an authenticated Okta session open indefinitely.\n\n- **Session takeover** of the user's identity by anyone with physical or remote access\n- **Lateral movement** into every downstream application that trusts Okta SSO\n- **Bypassed reauthentication** even after the user has stepped away", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Global Session Policy**.\n3. Open the **Default Policy** (and repeat for every other active policy).\n4. Add or edit a non-default rule.\n5. Move that rule to **Priority 1** so it is evaluated before the built-in `Default Rule`.\n6. Set *Maximum Okta global session idle time* to `15` minutes or less.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1 before the Default Rule\n session_idle = 15 # Critical: enforce idle timeout at 15 minutes or less\n session_persistent = false # Critical: avoid persistent global session cookies\n}\n```" + }, + "Recommendation": { + "Text": "Configure each active **Global Session Policy** so a non-default rule at **Priority 1**:\n- Sets *Maximum Okta global session idle time* to `15` minutes or less\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`\n\nReview group assignments to confirm the rule actually governs the intended users.", + "Url": "https://hub.prowler.com/check/signon_global_session_idle_timeout_15min" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.py b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.py new file mode 100644 index 0000000000..3f20a22adb --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.py @@ -0,0 +1,106 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + +DEFAULT_THRESHOLD_MINUTES = 15 + + +class signon_global_session_idle_timeout_15min(Check): + """STIG V-273186 / OKTA-APP-000020. + + Every active Global Session Policy must have an active Priority 1 + rule that is not the built-in Default Rule, and that rule must set + the maximum Okta global session idle time to the configured + threshold or lower (defaults to 15 minutes per STIG; override via + `okta_max_session_idle_minutes` in the audit config). + + Okta evaluates sign-on policies in priority order based on group + assignments, so a permissive custom policy can govern a user's + session even when the Default Policy is strict. The check emits one + finding per active policy to surface that risk. + """ + + def execute(self) -> list[CheckReportOkta]: + audit_config = signon_client.audit_config or {} + threshold = audit_config.get( + "okta_max_session_idle_minutes", DEFAULT_THRESHOLD_MINUTES + ) + org_domain = signon_client.provider.identity.org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-273186 requires the policy that governs each user to enforce " + "a Priority 1 non-default rule with a 15-minute idle timeout.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy, threshold) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy, threshold: int) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-273186 requires " + f"a non-default Priority 1 rule with idle timeout <= {threshold} " + "minutes.", + ) + + if rule.is_default or rule.name == "Default Rule": + return ( + "FAIL", + f"{label} uses '{rule.name}' as its active Priority 1 rule. " + "The STIG requires a non-default Priority 1 rule.", + ) + + idle_timeout = rule.max_session_idle_minutes + if idle_timeout is None: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "does not define a maximum Okta global session idle time.", + ) + + if idle_timeout <= threshold: + return ( + "PASS", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session idle time to {idle_timeout} " + f"minutes, meeting the configured threshold of {threshold} minutes.", + ) + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session idle time to {idle_timeout} " + f"minutes, exceeding the configured threshold of {threshold} minutes.", + ) diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json new file mode 100644 index 0000000000..84d88b3c76 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_lifetime_18h", + "CheckTitle": "Default Global Session Policy has a Priority 1 non-default rule limiting session lifetime to 18 hours", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta **Global Session Policy** needs a **Priority 1** rule that is **not** the built-in `Default Rule`, setting *Maximum Okta global session lifetime* to `18` hours or less.\n\nOkta evaluates policies by group assignment, so a permissive custom policy can govern users. Threshold override: `okta_max_session_lifetime_minutes` (minutes). Aligns with **DISA STIG V-273203**.", + "Risk": "Without an enforced session lifetime, an authenticated Okta session can be reused indefinitely without reauthentication.\n\n- **Stolen session material** continues to grant access long after sign-in\n- **Authorization changes** (role revocation, group removal) take effect only on the next reauth\n- **Token replay** against downstream apps stays viable for the session window", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Global Session Policy**.\n3. Open the **Default Policy** (and repeat for every other active policy).\n4. Add or edit a non-default rule.\n5. Move that rule to **Priority 1** so it is evaluated before the built-in `Default Rule`.\n6. Set *Maximum Okta global session lifetime* to `18` hours or less. Do **not** set it to `0`, which disables the limit.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1 before the Default Rule\n session_lifetime = 1080 # Critical: 18 hours in minutes; do not use 0 (disables the limit)\n session_persistent = false # Critical: avoid persistent global session cookies\n}\n```" + }, + "Recommendation": { + "Text": "Configure each active **Global Session Policy** so a non-default rule at **Priority 1**:\n- Sets *Maximum Okta global session lifetime* to `18` hours or less (`1080` minutes)\n- Never sets the lifetime to `0`, which disables the limit\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`\n\nReview group assignments to confirm the rule actually governs the intended users.", + "Url": "https://hub.prowler.com/check/signon_global_session_lifetime_18h" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py new file mode 100644 index 0000000000..25003eef90 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py @@ -0,0 +1,114 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + +DEFAULT_THRESHOLD_MINUTES = 18 * 60 + + +class signon_global_session_lifetime_18h(Check): + """STIG V-273203 / OKTA-APP-001665. + + Every active Global Session Policy must have an active Priority 1 + rule that is not the built-in Default Rule, and that rule must set + the maximum Okta global session lifetime to the configured threshold + or lower (defaults to 18 hours per STIG; override via + `okta_max_session_lifetime_minutes` in the audit config). + + Okta evaluates sign-on policies in priority order based on group + assignments, so a permissive custom policy can govern a user's + session even when the Default Policy is strict. The check emits one + finding per active policy to surface that risk. + """ + + def execute(self) -> list[CheckReportOkta]: + audit_config = signon_client.audit_config or {} + threshold = audit_config.get( + "okta_max_session_lifetime_minutes", DEFAULT_THRESHOLD_MINUTES + ) + org_domain = signon_client.provider.identity.org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-273203 requires the policy that governs each user to enforce " + "a Priority 1 non-default rule with an 18-hour session lifetime.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy, threshold) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy, threshold: int) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-273203 requires " + f"a non-default Priority 1 rule with session lifetime <= {threshold} " + "minutes.", + ) + + if rule.is_default or rule.name == "Default Rule": + return ( + "FAIL", + f"{label} uses '{rule.name}' as its active Priority 1 rule. " + "The STIG requires a non-default Priority 1 rule.", + ) + + lifetime = rule.max_session_lifetime_minutes + if lifetime is None: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "does not define a maximum Okta global session lifetime.", + ) + + if lifetime == 0: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "disables the maximum Okta global session lifetime by setting it " + "to 0 minutes.", + ) + + if lifetime <= threshold: + return ( + "PASS", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session lifetime to {lifetime} " + f"minutes, meeting the configured threshold of {threshold} minutes.", + ) + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session lifetime to {lifetime} minutes, " + f"exceeding the configured threshold of {threshold} minutes.", + ) diff --git a/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.metadata.json new file mode 100644 index 0000000000..b1009ba970 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_policy_network_zone_enforced", + "CheckTitle": "Default Global Session Policy applies a Network Zone condition aligned with the Access Control Policy", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta **Global Session Policy** must apply the *IF User's IP is* condition, mapped to a **Network Zone**, on its **Priority 1** active rule.\n\nUnlike the idle / lifetime / cookie STIGs, this control does **not** exclude the built-in `Default Rule`. Okta evaluates policies by group assignment. Aligns with **DISA STIG V-279691**.", + "Risk": "When the Global Session Policy does not restrict access by **Network Zone**, every authenticated entity establishes a session regardless of source IP.\n\n- **Stolen credentials** reach the Okta dashboard from any internet-routable address\n- **Out-of-band sessions** bypass the organization's Access Control Policy\n- **Network anomalies** cannot become deny decisions at sign-on", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/security/network/network-zones.htm", + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Networks** and define the **Network Zones** (allow / deny) that match the organization's Access Control Policy.\n3. Navigate to **Security** > **Global Session Policy**.\n4. Open the **Default Policy** (and repeat for every other active policy).\n5. Edit the rule that sits at **Priority 1**, or add a new one and move it to **Priority 1**.\n6. Under *Conditions*, set *IF User's IP is* to `In zone` (allow) or `Not in zone` (deny) and select the **Network Zone**.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1\n network_connection = \"ZONE\" # Critical: bind the rule to a Network Zone\n network_includes = [okta_network_zone.allowed.id] # Critical: zones that reflect the Access Control Policy\n}\n```" + }, + "Recommendation": { + "Text": "Configure the **Priority 1** active rule in each Global Session Policy so it:\n- Maps the *IF User's IP is* condition to a **Network Zone** aligned with the organization's Access Control Policy\n- Uses `In zone` for allow-list zones and `Not in zone` for deny-list zones\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`, or *is* the `Default Rule` itself\n\nReview group assignments to confirm the rule actually governs the intended users.", + "Url": "https://hub.prowler.com/check/signon_global_session_policy_network_zone_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.py b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.py new file mode 100644 index 0000000000..7a7e51706c --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.py @@ -0,0 +1,95 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + + +class signon_global_session_policy_network_zone_enforced(Check): + """STIG V-279691 / OKTA-APP-003242. + + Every active Global Session Policy must apply an "IF User's IP is" + condition mapped to a Network Zone on its Priority 1 active rule so + access can be allowed or denied per the organization's Access + Control Policy. + + Unlike the idle / lifetime / persistent-cookie STIGs, V-279691 does + not exclude the built-in Default Rule, so a zone condition on the + Default Rule is still effective when no non-default rule sits at + Priority 1. + + The check emits one finding per active policy because Okta evaluates + sign-on policies in priority order based on group assignments, and a + permissive custom policy can govern a user's session even when the + Default Policy is strict. + """ + + def execute(self) -> list[CheckReportOkta]: + org_domain = signon_client.provider.identity.org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-279691 requires the policy that governs each user to map " + "User's IP to a Network Zone on its Priority 1 active rule.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-279691 requires " + "the policy to apply an IP-based Network Zone condition on its " + "Priority 1 active rule.", + ) + + rule_kind = ( + "built-in Default Rule" + if rule.is_default or rule.name == "Default Rule" + else "non-default rule" + ) + has_zones = bool(rule.network_zones_include or rule.network_zones_exclude) + + if has_zones: + return ( + "PASS", + f"Priority 1 active {rule_kind} '{rule.name}' in {label} maps " + "User's IP to a Network Zone.", + ) + return ( + "FAIL", + f"Priority 1 active {rule_kind} '{rule.name}' in {label} does not " + "map User's IP to a Network Zone. The policy cannot allow or deny " + "access based on the organization's Access Control Policy.", + ) diff --git a/prowler/providers/okta/services/signon/signon_service.py b/prowler/providers/okta/services/signon/signon_service.py new file mode 100644 index 0000000000..663e9cf187 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_service.py @@ -0,0 +1,238 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "global_session_policies": "okta.policies.read", + "sign_in_pages": "okta.brands.read", +} + + +class Signon(OktaService): + """Fetches OKTA_SIGN_ON policies, rules, and brand sign-in pages. + + Populates `self.global_session_policies` keyed by policy id. Each + policy carries its rules; downstream checks read directly from this + structure. + + Also populates `self.sign_in_pages` keyed by brand id with sign-in page + HTML used by the DOD warning-banner check. When a brand has no + customized page, the service falls back to the default sign-in page + exposed by the Okta Management API and tracks it with + `is_customized=False`. + + Before each fetch the service compares its required OAuth scope + (see `REQUIRED_SCOPES`) against the access token's granted scopes + (`provider.identity.granted_scopes`). When a scope is known to be + missing, the fetch is skipped and the resource is recorded in + `self.missing_scope` so checks can report the missing scope explicitly + instead of emitting a misleading "no resources returned" finding. + When granted_scopes is empty (token decode unavailable), the service + treats permissions as unknown and attempts the fetch — preserving + the prior behavior. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.global_session_policies: dict[str, GlobalSessionPolicy] = ( + {} + if self.missing_scope["global_session_policies"] + else self._list_global_session_policies() + ) + self.sign_in_pages: dict[str, SignInPage] = ( + {} if self.missing_scope["sign_in_pages"] else self._list_sign_in_pages() + ) + + def _list_global_session_policies(self) -> dict: + logger.info("Signon - Listing OKTA_SIGN_ON policies and rules...") + try: + return self._run(self._fetch_all()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_all(self) -> dict: + result: dict[str, GlobalSessionPolicy] = {} + all_policies, err = await self._paginate( + lambda after: self.client.list_policies(type="OKTA_SIGN_ON", after=after) + ) + if err is not None: + logger.error(f"Error listing OKTA_SIGN_ON policies: {err}") + return result + + for policy in all_policies: + rules = await self._fetch_rules(policy.id) + result[policy.id] = GlobalSessionPolicy( + id=policy.id, + name=getattr(policy, "name", "") or "", + priority=getattr(policy, "priority", None), + status=getattr(policy, "status", "") or "", + is_default=bool(getattr(policy, "system", False)), + rules=rules, + ) + return result + + async def _fetch_rules(self, policy_id: str) -> list: + # Okta's `list_policy_rules` endpoint does not expose an `after` + # cursor in the SDK signature, so we call once with a generous + # `limit`. Tenants with more rules per policy than the limit would + # silently truncate; this is rare (most policies have <10 rules). + rule_fetch_limit = 100 + rules_out: list[GlobalSessionPolicyRule] = [] + result = await self.client.list_policy_rules( + policy_id, limit=str(rule_fetch_limit) + ) + err = result[-1] + if err is not None: + logger.error(f"Error listing rules for policy {policy_id}: {err}") + return rules_out + all_rules = list(result[0] or []) + if len(all_rules) >= rule_fetch_limit: + logger.warning( + f"Policy {policy_id} returned {len(all_rules)} rules — the " + f"per-policy fetch limit ({rule_fetch_limit}) was hit; any " + "rules beyond this limit are not evaluated by Prowler. " + "Review the policy in the Okta Admin Console." + ) + + for rule in all_rules: + actions = getattr(rule, "actions", None) + signon = getattr(actions, "signon", None) if actions else None + session = getattr(signon, "session", None) if signon else None + conditions = getattr(rule, "conditions", None) + network = getattr(conditions, "network", None) if conditions else None + rules_out.append( + GlobalSessionPolicyRule( + id=getattr(rule, "id", "") or "", + name=getattr(rule, "name", "") or "", + priority=getattr(rule, "priority", None), + status=getattr(rule, "status", "") or "", + is_default=bool(getattr(rule, "system", False)), + max_session_idle_minutes=getattr( + session, "max_session_idle_minutes", None + ), + max_session_lifetime_minutes=getattr( + session, "max_session_lifetime_minutes", None + ), + use_persistent_cookie=getattr( + session, "use_persistent_cookie", None + ), + network_zones_include=list(getattr(network, "include", None) or []), + network_zones_exclude=list(getattr(network, "exclude", None) or []), + ) + ) + return rules_out + + def _list_sign_in_pages(self) -> dict: + logger.info("Signon - Listing brand sign-in pages...") + try: + return self._run(self._fetch_brands_and_pages()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_brands_and_pages(self) -> dict: + result: dict[str, SignInPage] = {} + all_brands, err = await self._paginate( + lambda after: self.client.list_brands(after=after) + ) + if err is not None: + logger.error(f"Error listing brands: {err}") + return result + + for brand in all_brands: + brand_id = getattr(brand, "id", "") or "" + brand_name = getattr(brand, "name", "") or "" + result[brand_id] = await self._fetch_sign_in_page(brand_id, brand_name) + return result + + async def _fetch_sign_in_page(self, brand_id: str, brand_name: str) -> "SignInPage": + page_result = await self.client.get_customized_sign_in_page(brand_id) + page_err = page_result[-1] + page_data = page_result[0] + if page_err is None: + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=True, + page_content=getattr(page_data, "page_content", None), + ) + + if not self._is_missing_customized_page_error(page_err): + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=False, + fetch_error=str(page_err), + ) + + default_page_result = await self.client.get_default_sign_in_page(brand_id) + default_page_err = default_page_result[-1] + default_page_data = default_page_result[0] + if default_page_err is not None: + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=False, + fetch_error=str(default_page_err), + ) + + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=False, + page_content=getattr(default_page_data, "page_content", None), + ) + + @staticmethod + def _is_missing_customized_page_error(error) -> bool: + err_text = str(error).lower() + return "404" in err_text or "not found" in err_text or "e0000007" in err_text + + @staticmethod + async def _paginate(fetch): + return await _paginate_shared(fetch) + + +class GlobalSessionPolicyRule(BaseModel): + id: str + name: str + priority: Optional[int] = None + status: str = "" + is_default: bool = False + max_session_idle_minutes: Optional[int] = None + max_session_lifetime_minutes: Optional[int] = None + use_persistent_cookie: Optional[bool] = None + network_zones_include: list[str] = [] + network_zones_exclude: list[str] = [] + + +class GlobalSessionPolicy(BaseModel): + id: str + name: str + priority: Optional[int] = None + status: str = "" + is_default: bool = False + rules: list[GlobalSessionPolicyRule] = [] + + +class SignInPage(BaseModel): + brand_id: str + brand_name: str = "" + is_customized: bool = False + page_content: Optional[str] = None + fetch_error: Optional[str] = None diff --git a/prowler/providers/okta/services/systemlog/__init__.py b/prowler/providers/okta/services/systemlog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/lib/__init__.py b/prowler/providers/okta/services/systemlog/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py new file mode 100644 index 0000000000..e82cedff5f --- /dev/null +++ b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA systemlog STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream + + +def missing_log_streams_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the log-streams scope is not granted.""" + placeholder = LogStream( + id="okta-log-streams-scope-missing", + name="(scope not granted)", + status="MISSING", + type="", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta Log Streams: the Okta service app is missing " + f"the required `{scope}` API scope. Grant it on the service app's " + "Okta API Scopes tab in the Okta Admin Console, then re-run the check." + ) + return report diff --git a/prowler/providers/okta/services/systemlog/systemlog_client.py b/prowler/providers/okta/services/systemlog/systemlog_client.py new file mode 100644 index 0000000000..3dc0a1a0af --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.systemlog.systemlog_service import SystemLog + +systemlog_client = SystemLog(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/systemlog/systemlog_service.py b/prowler/providers/okta/services/systemlog/systemlog_service.py new file mode 100644 index 0000000000..96c8b6d7ae --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_service.py @@ -0,0 +1,136 @@ +from typing import Optional + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "log_streams": "okta.logStreams.read", +} + + +class SystemLog(OktaService): + """Fetches Okta Log Stream configurations. + + Populates `self.log_streams` keyed by Log Stream id. Each entry + carries `name`, `status`, `type` — enough for the streaming-enabled + check to evaluate whether the tenant has off-loaded audit records + to an external SIEM/event bus. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding instead of a misleading + "no resources returned". + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.log_streams: dict[str, LogStream] = ( + {} if self.missing_scope["log_streams"] else self._list_log_streams() + ) + + def _list_log_streams(self) -> dict: + logger.info("SystemLog - Listing Okta Log Streams...") + try: + return self._run(self._fetch_log_streams()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_log_streams(self) -> dict: + result: dict[str, LogStream] = {} + try: + all_streams, err = await paginate( + lambda after: self.client.list_log_streams(after=after) + ) + except ValidationError as ve: + # Upstream okta-sdk-python bug: e.g. `LogStreamSettingsAws`'s + # `eventSourceName` validator regex is `^[a-zA-Z0-9.\-_]$` — + # missing the `+` quantifier, so it rejects every + # multi-character name. Fall back to raw JSON so the check + # can still evaluate the tenant's actual log-stream state. + # Remove this workaround once okta-sdk-python fixes the + # validator (issue to be filed upstream). + logger.warning( + f"Okta SDK raised ValidationError parsing log streams " + f"({ve.error_count()} error(s)) — falling back to raw-JSON " + "parse. This is an okta-sdk-python deserialization bug." + ) + return await self._fetch_log_streams_raw() + + if err is not None: + logger.error(f"Error listing log streams: {err}") + return result + + for stream in all_streams: + stream_id = getattr(stream, "id", "") or "" + if not stream_id: + continue + result[stream_id] = LogStream( + id=stream_id, + name=getattr(stream, "name", "") or "", + status=getattr(stream, "status", "") or "", + type=_stringify_enum(getattr(stream, "type", None)) or "", + ) + return result + + async def _fetch_log_streams_raw(self) -> dict: + """Raw-JSON fallback for `list_log_streams`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper (which follows the `Link: rel=next` + cursor so tenants with >200 streams are not silently truncated), + and projects the response onto our own pydantic snapshot which + only validates the four fields the check reads. Keeps the check + evaluable on tenants whose Log Stream settings happen to trip + an SDK enum/regex validator. + """ + result: dict[str, LogStream] = {} + data = await raw_get_json_paginated( + self.client, + "/api/v1/logStreams", + page_size=200, + context="log streams", + ) + if data is None: + return result + for item in data: + if not isinstance(item, dict): + continue + stream_id = item.get("id") + if not stream_id: + continue + result[stream_id] = LogStream( + id=stream_id, + name=item.get("name") or "", + status=(item.get("status") or "").upper(), + type=item.get("type") or "", + ) + return result + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class LogStream(BaseModel): + id: str + name: str = "" + status: str = "" + type: str = "" diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json new file mode 100644 index 0000000000..27541ee2fe --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "systemlog_streaming_enabled", + "CheckTitle": "Okta off-loads audit records to a central log server via Log Streaming", + "CheckType": [], + "ServiceName": "systemlog", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "Okta must off-load audit records to a central log server. At least one **Log Stream** (AWS EventBridge, Splunk Cloud, etc.) must be configured and `ACTIVE` in the tenant. Alternatively, an external SIEM pulling the System Log API can satisfy the requirement, but that pull-based path is verified manually.", + "Risk": "Audit records stored only inside the Okta tenant are exposed to accidental or incidental deletion or alteration.\n\n- **No central retention** of authentication events for incident investigations\n- **Single point of failure** for the audit trail\n- **No correlation** with other identity, network, and endpoint telemetry in the SIEM", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/reports/log-streaming/about-log-streams.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/LogStream/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Reports** > **Log Streaming**.\n3. Click **Add Log Stream** and select **AWS EventBridge**, **Splunk Cloud**, or another supported destination.\n4. Complete the connection fields and save.\n5. Activate the stream and verify the destination receives events.\n6. If the destination SIEM is not natively supported, document the pull-based ingestion that uses the System Log API.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure at least one ACTIVE Okta Log Stream that off-loads audit records to a central SIEM (AWS EventBridge, Splunk Cloud, or another supported destination). Document any alternative pull-based ingestion via the System Log API.", + "Url": "https://hub.prowler.com/check/systemlog_streaming_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273202 / OKTA-APP-001430." +} diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py new file mode 100644 index 0000000000..61448aeaf5 --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.systemlog.lib.systemlog_helpers import ( + missing_log_streams_scope_finding, +) +from prowler.providers.okta.services.systemlog.systemlog_client import systemlog_client +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream + + +class systemlog_streaming_enabled(Check): + """Verifies that at least one Okta Log Stream is configured and active. + + Off-loading audit records to a central SIEM (AWS EventBridge, Splunk + Cloud, etc.) is the standard mechanism for centralised retention. + An alternative path — pulling the System Log API into an external + SIEM — is allowed by the requirement, but cannot be verified + automatically; this check emits a MANUAL note in that case. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = systemlog_client.provider.identity.org_domain + + missing_scope = systemlog_client.missing_scope.get("log_streams") + if missing_scope: + findings.append( + missing_log_streams_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + active_streams = [ + stream + for stream in systemlog_client.log_streams.values() + if not stream.status or stream.status.upper() == "ACTIVE" + ] + + if not systemlog_client.log_streams: + placeholder = LogStream( + id="okta-log-streams-missing", + name="(no Log Streams configured)", + status="MISSING", + type="", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No Okta Log Streams are configured. Configure a Log Stream " + "(Reports > Log Streaming) to off-load audit records to a " + "central SIEM. If an external SIEM is already pulling logs " + "via the System Log API, mutelist this check with that " + "evidence." + ) + findings.append(report) + return findings + + if not active_streams: + placeholder = LogStream( + id="okta-log-streams-inactive", + name="(no active Log Streams)", + status="INACTIVE", + type="", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + f"{len(systemlog_client.log_streams)} Okta Log Stream(s) are " + "configured but none are ACTIVE. Activate a Log Stream to " + "off-load audit records to a central SIEM." + ) + findings.append(report) + return findings + + for stream in active_streams: + report = CheckReportOkta( + metadata=self.metadata(), resource=stream, org_domain=org_domain + ) + report.status = "PASS" + report.status_extended = ( + f"Okta Log Stream '{stream.name}' (type={stream.type or 'unset'}) " + "is ACTIVE and off-loads audit records to a central SIEM." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/user/__init__.py b/prowler/providers/okta/services/user/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/lib/__init__.py b/prowler/providers/okta/services/user/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/lib/user_helpers.py b/prowler/providers/okta/services/user/lib/user_helpers.py new file mode 100644 index 0000000000..423801f2d8 --- /dev/null +++ b/prowler/providers/okta/services/user/lib/user_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA user STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.user.user_service import UserAutomation + + +def missing_user_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when an OAuth scope is not granted.""" + placeholder = UserAutomation( + id="okta-user-scope-missing", + name="(scope not granted)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta user lifecycle automations: the Okta service " + f"app is missing the required `{scope}` API scope. Grant it on the " + "service app's Okta API Scopes tab in the Okta Admin Console, then " + "re-run the check." + ) + return report diff --git a/prowler/providers/okta/services/user/user_client.py b/prowler/providers/okta/services/user/user_client.py new file mode 100644 index 0000000000..9a49fbdbac --- /dev/null +++ b/prowler/providers/okta/services/user/user_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.user.user_service import User + +user_client = User(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json new file mode 100644 index 0000000000..64f24d95ae --- /dev/null +++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "okta", + "CheckID": "user_inactivity_automation_35d_enabled", + "CheckTitle": "Okta automation suspends or deactivates users after 35 days of inactivity", + "CheckType": [], + "ServiceName": "user", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An Okta **Workflows Automation** must disable inactive user accounts. The automation must be `ACTIVE`, on an `ACTIVE` schedule, evaluate `User Inactivity = 35 days` (or less), apply to a group covering every user, and trigger `Suspended` / `Deactivated` / `Deprovisioned`. Threshold override: `okta_user_inactivity_max_days`. N/A when user sourcing is delegated to Active Directory or LDAP.", + "Risk": "Inactive Okta accounts retained indefinitely give an attacker who exploits one undetected access to downstream applications.\n\n- **Account takeover via dormant identities** that no one is monitoring\n- **Lateral movement** through SSO sessions of forgotten users\n- **Stale entitlements** that survive role and policy reorganisations", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/automation-hooks/automations-main.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Workflow** > **Automations** and click **Add Automation**.\n3. Name the automation (e.g., `User Inactivity`).\n4. Add a condition: select **User Inactivity in Okta** and enter `35` days.\n5. Configure the schedule to run daily and activate it.\n6. Apply the automation to a group that covers every user — typically `Everyone`.\n7. Add an action: **Change User lifecycle state in Okta** and choose `Suspended` (or `Deactivated`/`Deprovisioned`).\n8. Activate the automation.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create an active Okta Workflows automation that runs daily, evaluates `User Inactivity in Okta = 35 days`, applies to a group covering every user, and changes the user lifecycle state to Suspended/Deactivated. If user sourcing is delegated to Active Directory or LDAP, document that the connected directory enforces this requirement instead.", + "Url": "https://hub.prowler.com/check/user_inactivity_automation_35d_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273188 / OKTA-APP-000090." +} diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py new file mode 100644 index 0000000000..79e77c3f73 --- /dev/null +++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py @@ -0,0 +1,204 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.user.lib.user_helpers import ( + missing_user_scope_finding, +) +from prowler.providers.okta.services.user.user_client import user_client +from prowler.providers.okta.services.user.user_service import UserAutomation + +DEFAULT_INACTIVITY_DAYS = 35 +SUSPENSION_LIFECYCLE_ACTIONS = {"SUSPENDED", "DEACTIVATED", "DEPROVISIONED"} + + +class user_inactivity_automation_35d_enabled(Check): + """Verifies that Okta suspends/deactivates users after 35 days of inactivity. + + A Workflows Automation must exist with: + - status ACTIVE, + - schedule active, + - condition `User Inactivity in Okta = 35 days`, + - action that changes the user state to Suspended / Deactivated, + - applied to a group covering every user (typically `Everyone`). + + When user sourcing is delegated to an external directory (Active + Directory or LDAP), the requirement is N/A on the Okta side — the + connected directory is expected to enforce inactivity-based + deactivation instead. Threshold override: + `okta_user_inactivity_max_days` in the audit config. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + audit_config = user_client.audit_config or {} + threshold_days = audit_config.get( + "okta_user_inactivity_max_days", DEFAULT_INACTIVITY_DAYS + ) + org_domain = user_client.provider.identity.org_domain + + for scope_key in ("automations", "identity_providers"): + missing_scope = user_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_user_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + # External-directory N/A path. + if user_client.external_directory_idps: + idp_names = ", ".join( + f"'{idp.name}' (type={idp.type})" + for idp in user_client.external_directory_idps.values() + ) + placeholder = UserAutomation( + id="okta-user-inactivity-na-external-directory", + name="(external directory enforces inactivity)", + status="N/A", + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + ) + report.status = "MANUAL" + report.status_extended = ( + "User sourcing is delegated to an external directory " + f"({idp_names}). The 35-day inactivity disable requirement is " + "expected to be enforced by the connected directory rather " + "than by an Okta automation. Confirm out-of-band that the " + "external directory disables accounts after " + f"{threshold_days} days of inactivity." + ) + findings.append(report) + return findings + + compliant_automations = [ + automation + for automation in user_client.automations.values() + if _is_compliant(automation, threshold_days) + ] + + if not user_client.automations: + placeholder = UserAutomation( + id="okta-user-inactivity-no-automations", + name="(no automations configured)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + ) + report.status = "FAIL" + report.status_extended = ( + "No Okta Workflows automations are configured. Create an " + "automation that suspends or deactivates users after " + f"{threshold_days} days of inactivity, scoped to a group " + "covering every user (typically 'Everyone'), with an active " + "schedule." + ) + findings.append(report) + return findings + + if compliant_automations: + for automation in compliant_automations: + report = CheckReportOkta( + metadata=self.metadata(), + resource=automation, + org_domain=org_domain, + ) + report.status = "PASS" + groups_label = ", ".join(automation.applies_to_groups) + report.status_extended = ( + f"Okta automation '{automation.name}' is ACTIVE with an " + f"active schedule, triggers after " + f"{automation.inactivity_days} days of inactivity, and " + f"changes the user state to " + f"{automation.lifecycle_action or 'unset'}. " + f"Applied to group(s): {groups_label}. Verify that these " + "group(s) cover every user. Okta has no built-in " + "'Everyone' group ID, so tenant-wide coverage cannot be " + "asserted automatically." + ) + findings.append(report) + return findings + + # Automations exist but none satisfy the predicate — surface the + # closest candidate for the auditor. + candidate = _closest_candidate(user_client.automations.values()) + report = CheckReportOkta( + metadata=self.metadata(), + resource=candidate + or UserAutomation( + id="okta-user-inactivity-noncompliant", + name="(no compliant automation)", + status="MISSING", + ), + org_domain=org_domain, + ) + report.status = "FAIL" + report.status_extended = _failure_message(candidate, threshold_days) + findings.append(report) + return findings + + +def _is_compliant(automation: UserAutomation, threshold_days: int) -> bool: + # `applies_to_groups` must be non-empty — Okta USER_LIFECYCLE policies + # do not implicitly cover every user; the scope is whatever group IDs + # the operator put in `people.groups.include`. An empty scope means + # the automation runs against nobody. Operator must still verify those + # group(s) cover the intended user population (surfaced in the PASS + # status_extended). + return bool( + automation.status.upper() == "ACTIVE" + and automation.schedule_status.upper() == "ACTIVE" + and automation.inactivity_days is not None + and automation.inactivity_days <= threshold_days + and (automation.lifecycle_action or "").upper() in SUSPENSION_LIFECYCLE_ACTIONS + and bool(automation.applies_to_groups) + ) + + +def _closest_candidate(automations): + automations = list(automations) + if not automations: + return None + automations.sort( + key=lambda a: ( + 0 if a.status.upper() == "ACTIVE" else 1, + 0 if a.schedule_status.upper() == "ACTIVE" else 1, + ( + abs(a.inactivity_days - DEFAULT_INACTIVITY_DAYS) + if a.inactivity_days is not None + else 10_000 + ), + a.name, + ) + ) + return automations[0] + + +def _failure_message(automation, threshold_days): + if automation is None: + return f"No Okta automation enforces {threshold_days}-day inactivity disable." + issues = [] + if automation.status.upper() != "ACTIVE": + issues.append(f"status {automation.status or 'unset'}") + if automation.schedule_status.upper() != "ACTIVE": + issues.append(f"schedule {automation.schedule_status or 'unset'}") + if automation.inactivity_days is None: + issues.append("no inactivity condition") + elif automation.inactivity_days > threshold_days: + issues.append( + f"inactivity {automation.inactivity_days}d (max {threshold_days}d)" + ) + action = (automation.lifecycle_action or "").upper() + if action not in SUSPENSION_LIFECYCLE_ACTIONS: + issues.append(f"action {automation.lifecycle_action or 'unset'}") + if not automation.applies_to_groups: + issues.append("no group scope") + detail = ", ".join(issues) if issues else "incomplete" + return ( + f"Okta automation '{automation.name}' fails {threshold_days}d " + f"inactivity: {detail}." + ) diff --git a/prowler/providers/okta/services/user/user_service.py b/prowler/providers/okta/services/user/user_service.py new file mode 100644 index 0000000000..c816bd38ee --- /dev/null +++ b/prowler/providers/okta/services/user/user_service.py @@ -0,0 +1,455 @@ +from typing import Optional + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +# External-directory IdP `type` values that delegate user sourcing to a +# separate identity store. When any of these is present and ACTIVE, the +# STIG's 35-day inactivity disable requirement is N/A on the Okta side — +# the connected directory is expected to enforce it instead. +EXTERNAL_DIRECTORY_IDP_TYPES = {"ACTIVE_DIRECTORY", "LDAP"} + +# Okta exposes "Workflow > Automations" as USER_LIFECYCLE policies with +# inactivity rule conditions, not as a standalone `/api/v1/automations` +# resource. The SDK's `UserPolicyRuleCondition.inactivity` and +# `ScheduledUserLifecycleAction` models confirm this; the API rejects +# every other `type` candidate. +USER_LIFECYCLE_POLICY_TYPE = "USER_LIFECYCLE" + +REQUIRED_SCOPES: dict[str, str] = { + "automations": "okta.policies.read", + "identity_providers": "okta.idps.read", +} + + +class User(OktaService): + """Fetches Okta User Lifecycle Automations and external-directory IdPs. + + Populates: + - `self.automations` — keyed by USER_LIFECYCLE policy rule id. Each + entry projects the fields the 35-day inactivity check evaluates: + identity (`id`, `name` — taken from the rule), `status`, + `schedule_status` (inherited from the parent policy), the + `inactivity_days` condition and `applies_to_groups` scope from the + parent policy, and the `lifecycle_action` from the rule. + - `self.external_directory_idps` — keyed by IdP id. Used to short + circuit the STIG to N/A when user sourcing is delegated to an + external directory (Active Directory, LDAP). + + The Okta Admin Console's "Workflow > Automations" page is rendered + on top of `USER_LIFECYCLE` policies in the Management API + (`list_policies(type='USER_LIFECYCLE')` + `list_policy_rules(...)`). + There is no standalone `/api/v1/automations` GET endpoint; the SDK's + `InactivityPolicyRuleCondition`, `UserPolicyRuleCondition`, and + `ScheduledUserLifecycleAction` models all hang off the policy API. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.automations: dict[str, UserAutomation] = ( + {} if self.missing_scope["automations"] else self._list_automations() + ) + self.external_directory_idps: dict[str, ExternalDirectoryIdp] = ( + {} + if self.missing_scope["identity_providers"] + else self._list_external_directory_idps() + ) + + def _list_automations(self) -> dict: + logger.info("User - Listing USER_LIFECYCLE policies and rules...") + try: + return self._run(self._fetch_automations()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_automations(self) -> dict: + result: dict[str, UserAutomation] = {} + try: + all_policies, err = await paginate( + lambda after: self.client.list_policies( + type=USER_LIFECYCLE_POLICY_TYPE, after=after + ) + ) + except (ValueError, ValidationError) as ex: + # Upstream okta-sdk-python bug: `Policy.from_dict` uses a + # discriminator dispatch that maps `type` → concrete Policy + # subclass, and `USER_LIFECYCLE` is not in the map. The SDK + # raises ValueError ("failed to lookup discriminator value") + # even though the API returns a valid policy. Fall back to + # raw JSON. Remove once okta-sdk-python adds + # USER_LIFECYCLE → UserLifecyclePolicy to the mapping. + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing USER_LIFECYCLE " + "policies — falling back to raw-JSON parse. This is an " + "okta-sdk-python deserialization bug " + "(missing discriminator mapping)." + ) + return await self._fetch_automations_raw() + + if err is not None: + logger.error(f"Error listing USER_LIFECYCLE policies: {err}") + return result + + for policy in all_policies: + policy_id = getattr(policy, "id", "") or "" + if not policy_id: + continue + policy_status = _stringify_enum(getattr(policy, "status", None)) or "" + policy_name = getattr(policy, "name", "") or "" + rules = await self._fetch_rules(policy_id) + if rules is None: + # Rule typed parsing tripped an SDK validator. Re-run the + # whole automation discovery via raw JSON so we don't lose + # the rule data for this — or any other — policy. Cheaper + # than mixing typed and raw projections. + logger.warning( + f"Rule typed parsing failed for USER_LIFECYCLE policy " + f"{policy_id} — re-running all automations via raw-JSON." + ) + return await self._fetch_automations_raw() + if not rules: + # A policy with no rules exists in the Admin Console UI as + # an "Automation" the operator hasn't finished configuring + # (no conditions, no actions). Emit a placeholder so the + # check FAILs with a specific message naming every missing + # piece, instead of pretending the policy doesn't exist. + result[policy_id] = _shell_automation( + policy_id, policy_name, policy_status + ) + continue + for rule in rules: + automation = _rule_to_automation(rule, policy) + if automation is None: + continue + result[automation.id] = automation + return result + + async def _fetch_rules(self, policy_id: str) -> Optional[list]: + """Return the policy's typed rules, or None to signal raw fallback. + + The Okta SDK's `list_policy_rules` shares the same brittle typed + deserialization as `list_policies` (strict pydantic validators + rejecting values the API actually returns). When that happens the + caller can't reuse any of the typed projection for this policy — + we return None as a sentinel and the caller re-runs the whole + discovery via `_fetch_automations_raw`. Returning `[]` would + otherwise misclassify the policy as an "unfinished automation" + and FAIL it. + """ + rule_fetch_limit = 100 + try: + result = await self.client.list_policy_rules( + policy_id, limit=str(rule_fetch_limit) + ) + except (ValueError, ValidationError) as ex: + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing rules for " + f"USER_LIFECYCLE policy {policy_id} — signaling raw fallback." + ) + return None + err = result[-1] + if err is not None: + logger.error( + f"Error listing rules for USER_LIFECYCLE policy {policy_id}: {err}" + ) + return [] + rules = list(result[0] or []) + if len(rules) >= rule_fetch_limit: + logger.warning( + f"USER_LIFECYCLE policy {policy_id} returned {len(rules)} rules — " + f"the per-policy fetch limit ({rule_fetch_limit}) was hit; any " + "rules beyond this limit are not evaluated." + ) + return rules + + async def _fetch_automations_raw(self) -> dict: + """Raw-JSON fallback for `list_policies(type='USER_LIFECYCLE')`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper, then drains each policy's rules + via the same path. Projects everything onto our `UserAutomation` + snapshot which only validates the fields the check reads. + """ + result: dict[str, UserAutomation] = {} + policies_data = await raw_get_json_paginated( + self.client, + f"/api/v1/policies?type={USER_LIFECYCLE_POLICY_TYPE}", + page_size=200, + context="USER_LIFECYCLE policies", + ) + if policies_data is None: + return result + + for policy_dict in policies_data: + if not isinstance(policy_dict, dict): + continue + policy_id = policy_dict.get("id") + if not policy_id: + continue + policy_status = (policy_dict.get("status") or "").upper() + policy_name = policy_dict.get("name") or "" + + rules_data = await raw_get_json_paginated( + self.client, + f"/api/v1/policies/{policy_id}/rules", + page_size=100, + context=f"USER_LIFECYCLE policy {policy_id} rules", + ) + if not rules_data: + # No rules under the policy → emit placeholder. Same + # rationale as the typed path: surface the unfinished + # automation so the check can name what's missing. + result[policy_id] = _shell_automation( + policy_id, policy_name, policy_status + ) + continue + for rule_dict in rules_data: + automation = _raw_rule_to_automation( + rule_dict, policy_dict, policy_id, policy_name, policy_status + ) + if automation is None: + continue + result[automation.id] = automation + return result + + def _list_external_directory_idps(self) -> dict: + logger.info("User - Listing Okta IdPs for external-directory detection...") + try: + return self._run(self._fetch_external_directory_idps()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_external_directory_idps(self) -> dict: + result: dict[str, ExternalDirectoryIdp] = {} + all_idps, err = await paginate( + lambda after: self.client.list_identity_providers(after=after) + ) + if err is not None: + logger.error(f"Error listing identity providers: {err}") + return result + + for idp in all_idps: + idp_type = _stringify_enum(getattr(idp, "type", None)) or "" + if idp_type.upper() not in EXTERNAL_DIRECTORY_IDP_TYPES: + continue + idp_status = _stringify_enum(getattr(idp, "status", None)) or "" + if idp_status.upper() != "ACTIVE": + continue + idp_id = getattr(idp, "id", "") or "" + if not idp_id: + continue + result[idp_id] = ExternalDirectoryIdp( + id=idp_id, + name=getattr(idp, "name", "") or "", + type=idp_type, + status=idp_status, + ) + return result + + +def _rule_to_automation(rule, policy) -> Optional["UserAutomation"]: + """Project a typed USER_LIFECYCLE policy + rule pair onto our snapshot. + + Important: in the actual API response, an Okta "Automation" is split + across two resources — the **inactivity condition + group scope** + live on the *policy* (`policy.conditions.people.users.inactivity`, + `policy.conditions.people.groups.include`), and the **lifecycle + action** lives on the *rule* (`rule.actions.user_lifecycle.action` + on the typed model; `updateUserLifecycle.targetStatus` on raw JSON). + The rule's own `conditions` is typically empty. Projecting requires + both — kept aligned with `_raw_rule_to_automation` so the two paths + yield identical snapshots. + """ + rule_id = getattr(rule, "id", "") or "" + if not rule_id: + return None + + policy_id = getattr(policy, "id", "") or "" + policy_name = getattr(policy, "name", "") or "" + policy_status = (_stringify_enum(getattr(policy, "status", None)) or "").upper() + + # Inactivity + groups live on the POLICY in the API response. + inactivity_days: Optional[int] = None + applies_to_groups: list[str] = [] + conditions = getattr(policy, "conditions", None) + people = getattr(conditions, "people", None) if conditions else None + users = getattr(people, "users", None) if people else None + inactivity = getattr(users, "inactivity", None) if users else None + if inactivity is not None: + number = getattr(inactivity, "number", None) + unit = (_stringify_enum(getattr(inactivity, "unit", None)) or "").upper() + if isinstance(number, int) and unit in {"DAYS", "DAY"}: + inactivity_days = number + groups = getattr(people, "groups", None) if people else None + include_groups = getattr(groups, "include", None) if groups else None + if include_groups: + applies_to_groups = [str(g) for g in include_groups if g] + + # Lifecycle action lives on the RULE. + actions = getattr(rule, "actions", None) + user_lifecycle = ( + getattr(actions, "user_lifecycle", None) if actions else None + ) or (getattr(actions, "userLifecycle", None) if actions else None) + lifecycle_action: Optional[str] = None + if user_lifecycle is not None: + for attr in ("action", "status"): + value = _stringify_enum(getattr(user_lifecycle, attr, None)) + if value: + lifecycle_action = value.upper() + break + + rule_name = getattr(rule, "name", "") or policy_name or "(unnamed)" + rule_status = _stringify_enum(getattr(rule, "status", None)) or "" + + return UserAutomation( + id=rule_id, + name=rule_name, + status=rule_status.upper(), + schedule_status=policy_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=applies_to_groups, + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _raw_rule_to_automation( + rule_dict, + policy_dict, + policy_id: str, + policy_name: str, + policy_status: str, +) -> Optional["UserAutomation"]: + """Project a raw USER_LIFECYCLE policy+rule pair onto our snapshot. + + Important: in the actual API response, an Okta "Automation" is split + across two resources — the **inactivity condition + group scope** + live on the *policy* (`policy.conditions.people.users.inactivity`, + `policy.conditions.people.groups.include`), and the **lifecycle + action** lives on the *rule* + (`rule.actions.updateUserLifecycle.targetStatus`). The rule's own + `conditions` is typically empty `{}`. Projecting requires both. + + Schedule isn't exposed by the API on either resource. Okta runs an + automation on its UI-configured schedule iff the policy is ACTIVE, + so we treat `policy.status` as the schedule proxy. + """ + if not isinstance(rule_dict, dict): + return None + rule_id = rule_dict.get("id") + if not rule_id: + return None + + # Inactivity + groups live on the POLICY in the API response. + inactivity_days: Optional[int] = None + applies_to_groups: list[str] = [] + if isinstance(policy_dict, dict): + policy_conditions = policy_dict.get("conditions") or {} + people = policy_conditions.get("people") or {} + users = people.get("users") or {} + inactivity = users.get("inactivity") + if isinstance(inactivity, dict): + number = inactivity.get("number") + unit = (inactivity.get("unit") or "").upper() + if isinstance(number, int) and unit in {"DAYS", "DAY"}: + inactivity_days = number + groups = people.get("groups") or {} + include_groups = groups.get("include") + if isinstance(include_groups, list): + applies_to_groups = [str(g) for g in include_groups if g] + + # Lifecycle action lives on the RULE under + # `actions.updateUserLifecycle.targetStatus` (the API uses + # "updateUserLifecycle" rather than the SDK's `user_lifecycle`). + rule_actions = rule_dict.get("actions") or {} + update_user_lifecycle = rule_actions.get("updateUserLifecycle") or {} + lifecycle_action: Optional[str] = None + if isinstance(update_user_lifecycle, dict): + target = update_user_lifecycle.get("targetStatus") + if isinstance(target, str) and target: + lifecycle_action = target.upper() + + return UserAutomation( + id=rule_id, + name=(rule_dict.get("name") or policy_name or "(unnamed)"), + status=(rule_dict.get("status") or "").upper(), + schedule_status=policy_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=applies_to_groups, + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _shell_automation( + policy_id: str, policy_name: str, policy_status: str +) -> "UserAutomation": + """Placeholder UserAutomation for a USER_LIFECYCLE policy with no rules. + + Surfaces the unfinished automation in `self.automations` so the check + can list every missing piece in its FAIL message (no inactivity + condition, no lifecycle action, status inactive, etc.) instead of + silently dropping the policy. + """ + upper_status = (policy_status or "").upper() + return UserAutomation( + id=policy_id, + name=policy_name or "(unnamed automation)", + status=upper_status, + schedule_status=upper_status, + inactivity_days=None, + lifecycle_action=None, + applies_to_groups=[], + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class UserAutomation(BaseModel): + id: str + name: str = "" + status: str = "" + schedule_status: str = "" + inactivity_days: Optional[int] = None + lifecycle_action: Optional[str] = None + applies_to_groups: list[str] = [] + policy_id: str = "" + policy_name: str = "" + + +class ExternalDirectoryIdp(BaseModel): + id: str + name: str = "" + type: str = "" + status: str = "" diff --git a/prowler/providers/openstack/exceptions/exceptions.py b/prowler/providers/openstack/exceptions/exceptions.py index c78e35c475..f5b7dc9a7d 100644 --- a/prowler/providers/openstack/exceptions/exceptions.py +++ b/prowler/providers/openstack/exceptions/exceptions.py @@ -1,56 +1,56 @@ from prowler.exceptions.exceptions import ProwlerException -# Exceptions codes from 10000 to 10999 are reserved for OpenStack exceptions +# Exceptions codes from 17000 to 17999 are reserved for OpenStack exceptions class OpenStackBaseException(ProwlerException): """Base class for OpenStack Errors.""" OPENSTACK_ERROR_CODES = { - (10000, "OpenStackCredentialsError"): { + (17000, "OpenStackCredentialsError"): { "message": "OpenStack credentials not found or invalid", "remediation": "Check the OpenStack API credentials and ensure they are properly set.", }, - (10001, "OpenStackAuthenticationError"): { + (17001, "OpenStackAuthenticationError"): { "message": "OpenStack authentication failed", "remediation": "Check the OpenStack API credentials and ensure they are valid.", }, - (10002, "OpenStackSessionError"): { + (17002, "OpenStackSessionError"): { "message": "OpenStack session setup failed", "remediation": "Check the session setup and ensure it is properly configured.", }, - (10003, "OpenStackIdentityError"): { + (17003, "OpenStackIdentityError"): { "message": "OpenStack identity setup failed", "remediation": "Check credentials and ensure they are properly set up for OpenStack.", }, - (10004, "OpenStackAPIError"): { + (17004, "OpenStackAPIError"): { "message": "OpenStack API call failed", "remediation": "Check the API request and ensure it is properly formatted.", }, - (10005, "OpenStackRateLimitError"): { + (17005, "OpenStackRateLimitError"): { "message": "OpenStack API rate limit exceeded", "remediation": "Reduce the number of API requests or wait before making more requests.", }, - (10006, "OpenStackConfigFileNotFoundError"): { + (17006, "OpenStackConfigFileNotFoundError"): { "message": "OpenStack clouds.yaml configuration file not found", "remediation": "Check that the clouds.yaml file exists at the specified path or in standard locations (~/.config/openstack/clouds.yaml, /etc/openstack/clouds.yaml, ./clouds.yaml).", }, - (10007, "OpenStackCloudNotFoundError"): { + (17007, "OpenStackCloudNotFoundError"): { "message": "Specified cloud not found in clouds.yaml configuration", "remediation": "Check that the cloud name exists in your clouds.yaml file and is properly configured.", }, - (10008, "OpenStackInvalidConfigError"): { + (17008, "OpenStackInvalidConfigError"): { "message": "Invalid or malformed clouds.yaml configuration file", "remediation": "Check that the clouds.yaml file is valid YAML and follows the OpenStack configuration format.", }, - (10009, "OpenStackInvalidProviderIdError"): { + (17009, "OpenStackInvalidProviderIdError"): { "message": "Provider ID does not match the project_id in clouds.yaml", "remediation": "Ensure the provider_id matches the project_id configured in your clouds.yaml file.", }, - (10010, "OpenStackNoRegionError"): { + (17010, "OpenStackNoRegionError"): { "message": "No region configuration found in clouds.yaml", "remediation": "Add either 'region_name' (single region) or 'regions' (list of regions) to your cloud configuration in clouds.yaml.", }, - (10011, "OpenStackAmbiguousRegionError"): { + (17011, "OpenStackAmbiguousRegionError"): { "message": "Ambiguous region configuration in clouds.yaml", "remediation": "Use either 'region_name' or 'regions' in your cloud configuration, not both.", }, @@ -75,7 +75,7 @@ class OpenStackCredentialsError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10000, + code=17000, file=file, original_exception=original_exception, message=message, @@ -87,7 +87,7 @@ class OpenStackAuthenticationError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10001, + code=17001, file=file, original_exception=original_exception, message=message, @@ -99,7 +99,7 @@ class OpenStackSessionError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10002, + code=17002, file=file, original_exception=original_exception, message=message, @@ -111,7 +111,7 @@ class OpenStackIdentityError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10003, + code=17003, file=file, original_exception=original_exception, message=message, @@ -123,7 +123,7 @@ class OpenStackAPIError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10004, + code=17004, file=file, original_exception=original_exception, message=message, @@ -135,7 +135,7 @@ class OpenStackRateLimitError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10005, + code=17005, file=file, original_exception=original_exception, message=message, @@ -147,7 +147,7 @@ class OpenStackConfigFileNotFoundError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10006, + code=17006, file=file, original_exception=original_exception, message=message, @@ -159,7 +159,7 @@ class OpenStackCloudNotFoundError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10007, + code=17007, file=file, original_exception=original_exception, message=message, @@ -171,7 +171,7 @@ class OpenStackInvalidConfigError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10008, + code=17008, file=file, original_exception=original_exception, message=message, @@ -183,7 +183,7 @@ class OpenStackInvalidProviderIdError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10009, + code=17009, file=file, original_exception=original_exception, message=message, @@ -195,7 +195,7 @@ class OpenStackNoRegionError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10010, + code=17010, file=file, original_exception=original_exception, message=message, @@ -207,7 +207,7 @@ class OpenStackAmbiguousRegionError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10011, + code=17011, file=file, original_exception=original_exception, message=message, diff --git a/prowler/providers/openstack/lib/arguments/arguments.py b/prowler/providers/openstack/lib/arguments/arguments.py index 459012c4ec..68674528b6 100644 --- a/prowler/providers/openstack/lib/arguments/arguments.py +++ b/prowler/providers/openstack/lib/arguments/arguments.py @@ -46,6 +46,7 @@ def init_parser(self): "--os-password", nargs="?", default=None, + metavar="OS_PASSWORD", help="OpenStack password for authentication. Can also be set via OS_PASSWORD environment variable", ) openstack_explicit_subparser.add_argument( diff --git a/prowler/providers/openstack/openstack_provider.py b/prowler/providers/openstack/openstack_provider.py index e81a399478..e11c4f7909 100644 --- a/prowler/providers/openstack/openstack_provider.py +++ b/prowler/providers/openstack/openstack_provider.py @@ -36,6 +36,7 @@ class OpenstackProvider(Provider): """OpenStack provider responsible for bootstrapping the SDK session.""" _type: str = "openstack" + sdk_only: bool = False _session: OpenStackSession _identity: OpenStackIdentityInfo _audit_config: dict diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json index 597d3ab4d4..bd83049d82 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.metadata.json @@ -36,5 +36,5 @@ "RelatedTo": [ "blockstorage_volume_metadata_sensitive_data" ], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns." } diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py index 95de628871..51e25bc5eb 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.blockstorage.blockstorage_client import ( blockstorage_client, ) @@ -16,30 +20,42 @@ class blockstorage_snapshot_metadata_sensitive_data(Check): secrets_ignore_patterns = blockstorage_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = blockstorage_client.audit_config.get("secrets_validate", False) + snapshots = list(blockstorage_client.snapshots) - for snapshot in blockstorage_client.snapshots: + # Collect one payload per snapshot (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per snapshot. + def payloads(): + for index, snapshot in enumerate(snapshots): + if snapshot.metadata: + yield index, json.dumps(dict(snapshot.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, snapshot in enumerate(snapshots): report = CheckReportOpenStack(metadata=self.metadata(), resource=snapshot) report.status = "PASS" report.status_extended = f"Snapshot {snapshot.name} ({snapshot.id}) metadata does not contain sensitive data." if snapshot.metadata: - # Build metadata dict and parallel list of keys - dump_metadata = {} - original_metadata_keys = [] - for key, value in snapshot.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=blockstorage_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan snapshot {snapshot.name} ({snapshot.id}) " + f"metadata for secrets: {scan_error}; manual review is " + "required." + ) + findings.append(report) + continue + original_metadata_keys = list(snapshot.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -54,6 +70,7 @@ class blockstorage_snapshot_metadata_sensitive_data(Check): ) report.status = "FAIL" report.status_extended = f"Snapshot {snapshot.name} ({snapshot.id}) metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Snapshot {snapshot.name} ({snapshot.id}) has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json index ec17ee02d1..79874db214 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.metadata.json @@ -34,5 +34,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns." } diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py index 1bfa84c3df..48e064642a 100644 --- a/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.blockstorage.blockstorage_client import ( blockstorage_client, ) @@ -16,30 +20,41 @@ class blockstorage_volume_metadata_sensitive_data(Check): secrets_ignore_patterns = blockstorage_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = blockstorage_client.audit_config.get("secrets_validate", False) + volumes = list(blockstorage_client.volumes) - for volume in blockstorage_client.volumes: + # Collect one payload per volume (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per volume. + def payloads(): + for index, volume in enumerate(volumes): + if volume.metadata: + yield index, json.dumps(dict(volume.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, volume in enumerate(volumes): report = CheckReportOpenStack(metadata=self.metadata(), resource=volume) report.status = "PASS" report.status_extended = f"Volume {volume.name} ({volume.id}) metadata does not contain sensitive data." if volume.metadata: - # Build metadata dict and parallel list of keys - dump_metadata = {} - original_metadata_keys = [] - for key, value in volume.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=blockstorage_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan volume {volume.name} ({volume.id}) metadata " + f"for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + original_metadata_keys = list(volume.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -54,6 +69,7 @@ class blockstorage_volume_metadata_sensitive_data(Check): ) report.status = "FAIL" report.status_extended = f"Volume {volume.name} ({volume.id}) metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Volume {volume.name} ({volume.id}) has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json index 015a00986d..c7f3e41f8e 100644 --- a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.metadata.json @@ -34,5 +34,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection. Metadata is world-readable within instance via 169.254.169.254." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns. Metadata is world-readable within instance via 169.254.169.254." } diff --git a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py index 5df151c939..0627862da9 100644 --- a/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.compute.compute_client import compute_client @@ -14,30 +18,42 @@ class compute_instance_metadata_sensitive_data(Check): secrets_ignore_patterns = compute_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = compute_client.audit_config.get("secrets_validate", False) + instances = list(compute_client.instances) - for instance in compute_client.instances: + # Collect one payload per instance (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per instance. + def payloads(): + for index, instance in enumerate(instances): + if instance.metadata: + yield index, json.dumps(dict(instance.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, instance in enumerate(instances): report = CheckReportOpenStack(metadata=self.metadata(), resource=instance) report.status = "PASS" report.status_extended = f"Instance {instance.name} ({instance.id}) metadata does not contain sensitive data." if instance.metadata: - # Build metadata dict and parallel list of keys (similar to AWS ECS pattern) - dump_metadata = {} - original_metadata_keys = [] - for key, value in instance.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=compute_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan instance {instance.name} ({instance.id}) " + f"metadata for secrets: {scan_error}; manual review is " + "required." + ) + findings.append(report) + continue + original_metadata_keys = list(instance.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -45,11 +61,14 @@ class compute_instance_metadata_sensitive_data(Check): [ f"{secret['type']} in metadata key '{original_metadata_keys[secret['line_number'] - 2]}'" for secret in detect_secrets_output - if secret["line_number"] - 2 < len(original_metadata_keys) + if 0 + <= secret["line_number"] - 2 + < len(original_metadata_keys) ] ) report.status = "FAIL" report.status_extended = f"Instance {instance.name} ({instance.id}) metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Instance {instance.name} ({instance.id}) has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json index b3a39bd3f8..37e7563c27 100644 --- a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json @@ -35,5 +35,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check uses the detect-secrets library to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns and detect_secrets_plugins to customize detection." + "Notes": "This check uses Kingfisher to scan for credentials. May produce false positives on metadata keys containing secret-like keywords. Findings should be reviewed manually. The audit_config allows configuring secrets_ignore_patterns to exclude specific patterns." } diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py index 94d281bf32..549be81046 100644 --- a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py @@ -2,7 +2,11 @@ import json from typing import List from prowler.lib.check.models import Check, CheckReportOpenStack -from prowler.lib.utils.utils import detect_secrets_scan +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( objectstorage_client, ) @@ -16,8 +20,26 @@ class objectstorage_container_metadata_sensitive_data(Check): secrets_ignore_patterns = objectstorage_client.audit_config.get( "secrets_ignore_patterns", [] ) + validate = objectstorage_client.audit_config.get("secrets_validate", False) + containers = list(objectstorage_client.containers) - for container in objectstorage_client.containers: + # Collect one payload per container (its metadata) and scan them all in + # batched Kingfisher invocations instead of one subprocess per container. + def payloads(): + for index, container in enumerate(containers): + if container.metadata: + yield index, json.dumps(dict(container.metadata), indent=2) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for index, container in enumerate(containers): report = CheckReportOpenStack(metadata=self.metadata(), resource=container) report.status = "PASS" report.status_extended = ( @@ -25,23 +47,16 @@ class objectstorage_container_metadata_sensitive_data(Check): ) if container.metadata: - # Build metadata dict and parallel list of keys - dump_metadata = {} - original_metadata_keys = [] - for key, value in container.metadata.items(): - dump_metadata[key] = value - original_metadata_keys.append(key) - - # Convert metadata dict to JSON string for detect-secrets scanning - metadata_json = json.dumps(dump_metadata, indent=2) - detect_secrets_output = detect_secrets_scan( - data=metadata_json, - excluded_secrets=secrets_ignore_patterns, - detect_secrets_plugins=objectstorage_client.audit_config.get( - "detect_secrets_plugins" - ), - ) - + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan container {container.name} metadata for " + f"secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + original_metadata_keys = list(container.metadata.keys()) + detect_secrets_output = batch_results.get(index) if detect_secrets_output: # Map line numbers back to metadata keys using the parallel list # Line numbering: line 1 = "{", line 2 = first key-value, etc. @@ -56,6 +71,7 @@ class objectstorage_container_metadata_sensitive_data(Check): ) report.status = "FAIL" report.status_extended = f"Container {container.name} metadata contains potential secrets -> {secrets_string}." + annotate_verified_secrets(report, detect_secrets_output) else: report.status_extended = f"Container {container.name} has no metadata (no sensitive data exposure risk)." diff --git a/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index 1fbb10483a..b498bbb502 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -59,6 +59,7 @@ class OraclecloudProvider(Provider): """ _type: str = "oraclecloud" + sdk_only: bool = False _identity: OCIIdentityInfo _session: OCISession _audit_config: dict @@ -66,6 +67,7 @@ class OraclecloudProvider(Provider): _compartments: list = [] _mutelist: OCIMutelist audit_metadata: Audit_Metadata + _home_region: str = "us-ashburn-1" def __init__( self, @@ -160,6 +162,15 @@ class OraclecloudProvider(Provider): # Get regions self._regions = self.get_regions_to_audit(region) + # Determine the tenancy home region from the full subscription list, independent of + # the --region filter, so tenancy-level APIs (e.g. the Audit configuration) always + # target the home region instead of a filtered, non-home region. + all_subscribed_regions = self.get_regions_to_audit() + self._home_region = next( + (region.key for region in all_subscribed_regions if region.is_home_region), + self._regions[0].key if self._regions else "us-ashburn-1", + ) + logger.info(f"Home region is: {self._home_region}") # Get compartments self._compartments = self.get_compartments_to_audit( @@ -217,6 +228,10 @@ class OraclecloudProvider(Provider): def regions(self): return self._regions + @property + def home_region(self): + return self._home_region + @property def compartments(self): return self._compartments @@ -351,7 +366,6 @@ class OraclecloudProvider(Provider): try: config = oci.config.from_file(oci_config_file, profile) - oci.config.validate_config(config) # Check if using security token authentication if ( @@ -374,6 +388,9 @@ class OraclecloudProvider(Provider): token=token, private_key=private_key ) else: + # Only validate full config for API key auth + # (session auth doesn't require 'user' field) + oci.config.validate_config(config) logger.info( f"Using profile '{profile}' with API key authentication" ) diff --git a/prowler/providers/oraclecloud/services/audit/audit_service.py b/prowler/providers/oraclecloud/services/audit/audit_service.py index fe7751a10e..38022d1d87 100644 --- a/prowler/providers/oraclecloud/services/audit/audit_service.py +++ b/prowler/providers/oraclecloud/services/audit/audit_service.py @@ -19,9 +19,15 @@ class Audit(OCIService): def __get_configuration__(self): """Get Audit configuration.""" try: - audit_client = self._create_oci_client(oci.audit.AuditClient) + home_region = self.provider.home_region + audit_client = self._create_oci_client( + oci.audit.AuditClient, + config_overrides={"region": home_region}, + ) - logger.info("Audit - Getting Configuration...") + logger.info( + f"Audit - Getting Configuration from home region ({home_region})..." + ) try: config = audit_client.get_configuration( diff --git a/prowler/providers/oraclecloud/services/identity/identity_service.py b/prowler/providers/oraclecloud/services/identity/identity_service.py index a0932bd54f..d36ed5c0ac 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_service.py +++ b/prowler/providers/oraclecloud/services/identity/identity_service.py @@ -1,6 +1,7 @@ """OCI Identity Service Module.""" from datetime import datetime +from threading import Lock from typing import Optional import oci @@ -26,6 +27,7 @@ class Identity(OCIService): self.policies = [] self.dynamic_groups = [] self.domains = [] + self._domains_lock = Lock() self.password_policy = None self.root_compartment_resources = [] self.active_non_root_compartments = [] @@ -61,8 +63,8 @@ class Identity(OCIService): regional_client: Regional OCI client """ try: - # Identity is a global service, use home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global users + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -312,7 +314,8 @@ class Identity(OCIService): def __list_groups__(self, regional_client): """List all IAM groups.""" try: - if regional_client.region not in self.provider.identity.region: + # Only use one region for global groups + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -355,7 +358,8 @@ class Identity(OCIService): def __list_policies__(self, regional_client): """List all IAM policies.""" try: - if regional_client.region not in self.provider.identity.region: + # Only use one region for global policies + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -399,8 +403,8 @@ class Identity(OCIService): def __list_dynamic_groups__(self, regional_client): """List all dynamic groups in the tenancy.""" try: - # Dynamic groups are only in the home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global dynamic groups + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -447,10 +451,6 @@ class Identity(OCIService): def __list_domains__(self, regional_client): """List all identity domains.""" try: - # Domains are only in the home region - if regional_client.region not in self.provider.identity.region: - return - identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Listing Identity Domains...") @@ -458,6 +458,7 @@ class Identity(OCIService): try: # List all domains in the tenancy for compartment in self.audited_compartments: + domains = oci.pagination.list_call_get_all_results( identity_client.list_domains, compartment_id=compartment.id, @@ -465,20 +466,38 @@ class Identity(OCIService): ).data for domain in domains: - self.domains.append( - IdentityDomain( - id=domain.id, - display_name=domain.display_name, - description=domain.description or "", - url=domain.url, - home_region=domain.home_region, - compartment_id=compartment.id, - lifecycle_state=domain.lifecycle_state, - time_created=domain.time_created, - region=regional_client.region, - password_policies=[], + + # Threads run __list_domains__ concurrently per + # region; serialize the dedupe-then-append so two + # regions returning the same domain cannot race + # past each other and produce duplicates or lose + # the home-region preference. + with self._domains_lock: + existing = next( + (d for d in self.domains if d.id == domain.id), + None, + ) + if existing is not None: + # Prefer the entry from the domain's home region + if domain.home_region == regional_client.region: + self.domains.remove(existing) + else: + continue + + self.domains.append( + IdentityDomain( + id=domain.id, + display_name=domain.display_name, + description=domain.description or "", + url=domain.url, + home_region=domain.home_region, + compartment_id=compartment.id, + lifecycle_state=domain.lifecycle_state, + time_created=domain.time_created, + region=regional_client.region, + password_policies=[], + ) ) - ) except Exception as error: logger.error( @@ -493,8 +512,8 @@ class Identity(OCIService): def __list_domain_password_policies__(self, regional_client): """List password policies for all identity domains.""" try: - # Password policies are only in the home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for all domain scan + if regional_client.region != self.provider.home_region: return logger.info("Identity - Listing Domain Password Policies...") @@ -551,7 +570,8 @@ class Identity(OCIService): def __get_password_policy__(self, regional_client): """Get the password policy for the tenancy.""" try: - if regional_client.region not in self.provider.identity.region: + # Only use one region for global password policies + if regional_client.region != self.provider.home_region: return identity_client = self.__get_client__(regional_client.region) @@ -578,8 +598,8 @@ class Identity(OCIService): def __search_root_compartment_resources__(self, regional_client): """Search for resources in the root compartment using OCI Resource Search.""" try: - # Search is a global service, use home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global search + if regional_client.region != self.provider.home_region: return logger.info("Identity - Searching for resources in root compartment...") @@ -626,10 +646,9 @@ class Identity(OCIService): def __search_active_non_root_compartments__(self, regional_client): """Search for active non-root compartments using OCI Resource Search.""" try: - # Search is a global service, use home region - if regional_client.region not in self.provider.identity.region: + # Only use one region for global search + if regional_client.region != self.provider.home_region: return - logger.info("Identity - Searching for active non-root compartments...") # Create search client using the helper method for proper authentication diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/__init__.py b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json new file mode 100644 index 0000000000..4e8f905658 --- /dev/null +++ b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "oraclecloud", + "CheckID": "identity_storage_service_level_admins_scoped", + "CheckTitle": "OCI IAM storage service-level admin policies exclude delete permissions", + "CheckType": [], + "ServiceName": "identity", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Policy", + "ResourceGroup": "IAM", + "Description": "**OCI IAM policies** are reviewed to ensure storage service-level administrator statements that grant `manage` permissions exclude the relevant storage delete permissions with `request.permission`. This supports CIS OCI 3.1 control 1.15 separation of duties for Block Volume, File Storage, and Object Storage administrators.", + "Risk": "Storage service-level administrators with unrestricted `manage` permissions can delete the resources they administer, including volumes, backups, file systems, mount targets, export sets, objects, or buckets. This weakens separation of duties and can lead to data loss, service disruption, or destructive insider activity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/policyreference/policyreference.htm", + "https://docs.oracle.com/en-us/iaas/Content/Block/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/File/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/Object/home.htm" + ], + "Remediation": { + "Code": { + "CLI": "oci iam policy update --policy-id --statements \"[\\\"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\\\"]\"", + "NativeIaC": "", + "Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open each active policy that grants storage service-level administrators `manage` permissions\n3. Edit storage manage statements to exclude the relevant delete permission with `request.permission`\n4. Example: Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\n5. Save changes", + "Terraform": "```hcl\nresource \"oci_identity_policy\" \"storage_admins\" {\n compartment_id = var.compartment_id\n name = \"storage-admins\"\n description = \"Storage administrators without delete permissions\"\n\n statements = [\n \"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\",\n \"Allow group VolumeUsers to manage volume-backups in tenancy where request.permission!='VOLUME_BACKUP_DELETE'\",\n \"Allow group FileUsers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'\",\n \"Allow group FileUsers to manage mount-targets in tenancy where request.permission!='MOUNT_TARGET_DELETE'\",\n \"Allow group FileUsers to manage export-sets in tenancy where request.permission!='EXPORT_SET_DELETE'\",\n \"Allow group BucketUsers to manage objects in tenancy where request.permission!='OBJECT_DELETE'\",\n \"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\"\n ]\n}\n```" + }, + "Recommendation": { + "Text": "Exclude delete permissions from storage service-level administrator policies. Use `request.permission!='VOLUME_DELETE'`, `request.permission!='VOLUME_BACKUP_DELETE'`, `request.permission!='FILE_SYSTEM_DELETE'`, `request.permission!='MOUNT_TARGET_DELETE'`, `request.permission!='EXPORT_SET_DELETE'`, `request.permission!='OBJECT_DELETE'`, and `request.permission!='BUCKET_DELETE'` as appropriate for each storage manage statement.", + "Url": "https://hub.prowler.com/check/identity_storage_service_level_admins_scoped" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py new file mode 100644 index 0000000000..49ef16748a --- /dev/null +++ b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py @@ -0,0 +1,176 @@ +"""Check storage service-level administrators cannot delete managed resources.""" + +import re + +from prowler.lib.check.models import Check, Check_Report_OCI +from prowler.providers.oraclecloud.services.identity.identity_client import ( + identity_client, +) + +STORAGE_DELETE_PERMISSIONS_BY_RESOURCE = { + "volumes": {"VOLUME_DELETE"}, + "volume-backups": {"VOLUME_BACKUP_DELETE"}, + "file-systems": {"FILE_SYSTEM_DELETE"}, + "mount-targets": {"MOUNT_TARGET_DELETE"}, + "export-sets": {"EXPORT_SET_DELETE"}, + "objects": {"OBJECT_DELETE"}, + "buckets": {"BUCKET_DELETE"}, + "volume-family": {"VOLUME_DELETE", "VOLUME_BACKUP_DELETE"}, + "file-family": {"FILE_SYSTEM_DELETE", "MOUNT_TARGET_DELETE", "EXPORT_SET_DELETE"}, + "object-family": {"OBJECT_DELETE", "BUCKET_DELETE"}, +} +ALL_STORAGE_DELETE_PERMISSIONS = set().union( + *STORAGE_DELETE_PERMISSIONS_BY_RESOURCE.values() +) +STORAGE_DELETE_PERMISSIONS_BY_RESOURCE["all-resources"] = ALL_STORAGE_DELETE_PERMISSIONS + +MANAGE_STATEMENT_PATTERN = re.compile( + r"\ballow\s+group\b.+?\bto\s+manage\s+(?P[a-z-]+)\b", + re.IGNORECASE, +) +QUOTED_LITERAL_PATTERN = re.compile(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"") + + +def _normalize_statement(statement: str) -> str: + """Collapse whitespace in an OCI policy statement.""" + return " ".join(statement.strip().split()) + + +def _has_disjunctive_condition(statement: str) -> bool: + """Return True when the WHERE condition can allow alternate branches.""" + condition = re.split(r"\bwhere\b", statement, flags=re.IGNORECASE, maxsplit=1) + if len(condition) != 2: + return False + + condition_without_literals = QUOTED_LITERAL_PATTERN.sub("", condition[1]) + return bool( + re.search(r"\b(any|or)\b|\|\|", condition_without_literals, re.IGNORECASE) + ) + + +def _storage_manage_resource(statement: str) -> str | None: + """Return the managed storage resource in a policy statement, if any.""" + normalized_statement = _normalize_statement(statement) + match = MANAGE_STATEMENT_PATTERN.search(normalized_statement) + if not match: + return None + + resource = match.group("resource").lower() + if resource not in STORAGE_DELETE_PERMISSIONS_BY_RESOURCE: + return None + + return resource + + +def _excluded_permissions(statement: str) -> set[str]: + """Return delete permissions explicitly excluded with request.permission != value.""" + if _has_disjunctive_condition(statement): + return set() + + exclusions = set() + for permission in ALL_STORAGE_DELETE_PERMISSIONS: + pattern = re.compile( + rf"\brequest\.permission\s*!=\s*['\"]?{re.escape(permission)}['\"]?\b", + re.IGNORECASE, + ) + if pattern.search(statement): + exclusions.add(permission) + return exclusions + + +def _missing_delete_exclusions(statement: str) -> tuple[str, set[str]] | None: + """Return the storage resource and missing delete exclusions for a statement.""" + normalized_statement = _normalize_statement(statement) + resource = _storage_manage_resource(normalized_statement) + if not resource: + return None + + required_permissions = STORAGE_DELETE_PERMISSIONS_BY_RESOURCE[resource] + + excluded_permissions = _excluded_permissions(normalized_statement) + missing_permissions = required_permissions - excluded_permissions + if not missing_permissions: + return None + + return resource, missing_permissions + + +class identity_storage_service_level_admins_scoped(Check): + """Ensure storage service-level admins cannot delete resources they manage.""" + + def execute(self) -> list[Check_Report_OCI]: + """Execute the storage service-level administrators scoped check. + + Returns: + A list of OCI check reports for active non-tenant-admin policies. + """ + findings = [] + + for policy in identity_client.policies: + if policy.lifecycle_state != "ACTIVE": + continue + + if policy.name.upper() == "TENANT ADMIN POLICY": + continue + + region = policy.region if hasattr(policy, "region") else "global" + violations = [] + has_storage_manage_statement = False + + for statement in policy.statements: + if _storage_manage_resource(statement): + has_storage_manage_statement = True + + missing_result = _missing_delete_exclusions(statement) + if not missing_result: + continue + + resource, missing_permissions = missing_result + violations.append( + f"statement `{_normalize_statement(statement)}` manages {resource} without excluding: {', '.join(sorted(missing_permissions))}" + ) + + if not has_storage_manage_statement: + continue + + report = Check_Report_OCI( + metadata=self.metadata(), + resource=policy, + region=region, + resource_id=policy.id, + resource_name=policy.name, + compartment_id=policy.compartment_id, + ) + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Policy '{policy.name}' allows storage service-level administrators to manage storage resources without explicitly excluding required delete permissions: " + + "; ".join(violations) + + "." + ) + else: + report.status = "PASS" + report.status_extended = f"Policy '{policy.name}' excludes required storage delete permissions from storage manage statements." + + findings.append(report) + + if not findings: + region = ( + identity_client.audited_regions[0].key + if identity_client.audited_regions + else "global" + ) + report = Check_Report_OCI( + metadata=self.metadata(), + resource={}, + region=region, + resource_id=identity_client.audited_tenancy, + resource_name="Tenancy", + compartment_id=identity_client.audited_tenancy, + ) + report.status = "PASS" + report.status_extended = "No active storage service-level administrator policies grant manage permissions without excluding delete permissions." + findings.append(report) + + return findings diff --git a/prowler/providers/scaleway/__init__.py b/prowler/providers/scaleway/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/exceptions/__init__.py b/prowler/providers/scaleway/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/exceptions/exceptions.py b/prowler/providers/scaleway/exceptions/exceptions.py new file mode 100644 index 0000000000..05e1b256ae --- /dev/null +++ b/prowler/providers/scaleway/exceptions/exceptions.py @@ -0,0 +1,99 @@ +# Exceptions codes from 15000 to 15999 are reserved for Scaleway exceptions +from prowler.exceptions.exceptions import ProwlerException + + +class ScalewayBaseException(ProwlerException): + """Base exception for Scaleway provider errors.""" + + SCALEWAY_ERROR_CODES = { + (15000, "ScalewayCredentialsError"): { + "message": "Scaleway credentials not found or invalid.", + "remediation": ( + "Set the SCW_ACCESS_KEY and SCW_SECRET_KEY environment variables " + "with a valid Scaleway API key. Generate one at " + "https://console.scaleway.com/iam/api-keys." + ), + }, + (15001, "ScalewayAuthenticationError"): { + "message": "Authentication to the Scaleway API failed.", + "remediation": ( + "Verify your Scaleway API key is valid, has not expired, and that " + "the bearer has IAM read permissions on the target organization." + ), + }, + (15002, "ScalewaySessionError"): { + "message": "Failed to create a Scaleway API session.", + "remediation": ( + "Check network connectivity and ensure the Scaleway API is " + "reachable at https://api.scaleway.com." + ), + }, + (15003, "ScalewayIdentityError"): { + "message": "Failed to retrieve Scaleway identity information.", + "remediation": ( + "Ensure the API key has permissions to read IAM users and the " + "owning organization metadata." + ), + }, + (15004, "ScalewayAPIError"): { + "message": "An error occurred while calling the Scaleway API.", + "remediation": ( + "Check the Scaleway API status at https://status.scaleway.com " + "and retry. Run with --log-level DEBUG for the full traceback." + ), + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Scaleway" + error_info = self.SCALEWAY_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Scaleway error.", + "remediation": "Check the Scaleway API documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class ScalewayCredentialsError(ScalewayBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 15000, file=file, original_exception=original_exception, message=message + ) + + +class ScalewayAuthenticationError(ScalewayBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 15001, file=file, original_exception=original_exception, message=message + ) + + +class ScalewaySessionError(ScalewayBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 15002, file=file, original_exception=original_exception, message=message + ) + + +class ScalewayIdentityError(ScalewayBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 15003, file=file, original_exception=original_exception, message=message + ) + + +class ScalewayAPIError(ScalewayBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 15004, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/scaleway/lib/__init__.py b/prowler/providers/scaleway/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/lib/arguments/__init__.py b/prowler/providers/scaleway/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/lib/arguments/arguments.py b/prowler/providers/scaleway/lib/arguments/arguments.py new file mode 100644 index 0000000000..7d3fd7dc0c --- /dev/null +++ b/prowler/providers/scaleway/lib/arguments/arguments.py @@ -0,0 +1,36 @@ +def init_parser(self): + """Init the Scaleway provider CLI parser.""" + scaleway_parser = self.subparsers.add_parser( + "scaleway", + parents=[self.common_providers_parser], + help="Scaleway Provider", + ) + + # Authentication + # Credentials are read exclusively from the standard Scaleway environment + # variables (SCW_ACCESS_KEY / SCW_SECRET_KEY) to avoid leaking secrets into + # shell history and process listings. There are no credential CLI flags. + + # Scope + scope_subparser = scaleway_parser.add_argument_group("Scope") + scope_subparser.add_argument( + "--organization-id", + nargs="?", + default=None, + metavar="SCW_DEFAULT_ORGANIZATION_ID", + help="Scaleway organization ID to scope the audit.", + ) + scope_subparser.add_argument( + "--project-id", + nargs="?", + default=None, + metavar="SCW_DEFAULT_PROJECT_ID", + help="Default Scaleway project ID for project-scoped resources.", + ) + scope_subparser.add_argument( + "--region", + nargs="?", + default=None, + metavar="SCW_DEFAULT_REGION", + help="Default Scaleway region (fr-par, nl-ams, pl-waw).", + ) diff --git a/prowler/providers/scaleway/lib/mutelist/__init__.py b/prowler/providers/scaleway/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/lib/mutelist/mutelist.py b/prowler/providers/scaleway/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..d09bc5d435 --- /dev/null +++ b/prowler/providers/scaleway/lib/mutelist/mutelist.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import CheckReportScaleway +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class ScalewayMutelist(Mutelist): + """Scaleway-specific mutelist helper.""" + + def is_finding_muted( + self, + finding: CheckReportScaleway, + organization_id: str, + ) -> bool: + return self.is_muted( + organization_id, + finding.check_metadata.CheckID, + finding.region or "global", + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/scaleway/lib/service/__init__.py b/prowler/providers/scaleway/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/lib/service/service.py b/prowler/providers/scaleway/lib/service/service.py new file mode 100644 index 0000000000..0218bf72ae --- /dev/null +++ b/prowler/providers/scaleway/lib/service/service.py @@ -0,0 +1,44 @@ +from prowler.lib.logger import logger +from prowler.providers.scaleway.exceptions.exceptions import ScalewayAPIError + + +class ScalewayService: + """Base class for Scaleway services. + + Centralizes the provider context (audit/fixer configuration, the + scoping organization, the authenticated ``scaleway.Client``) so each + service only worries about which Scaleway API to call. + """ + + def __init__(self, service: str, provider): + self.provider = provider + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + + # Shared authenticated client and the organization in scope + self.client = provider.session.client + self.organization_id = provider.identity.organization_id + + def _safe_call(self, label: str, fn, *args, **kwargs): + """Run a Scaleway SDK call and surface failures as ScalewayAPIError. + + Args: + label: Human-readable label for the call (used in logs). + fn: SDK function to invoke. + + Returns: + The SDK function result, or ``None`` if the call failed. + """ + try: + return fn(*args, **kwargs) + except Exception as error: + logger.error( + f"{self.service} - {label} failed: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + raise ScalewayAPIError( + file=__file__, + original_exception=error, + message=f"Scaleway API call '{label}' failed.", + ) diff --git a/prowler/providers/scaleway/models.py b/prowler/providers/scaleway/models.py new file mode 100644 index 0000000000..6dc4bb7c4b --- /dev/null +++ b/prowler/providers/scaleway/models.py @@ -0,0 +1,55 @@ +from typing import Any, Literal, Optional + +from pydantic.v1 import BaseModel, Field + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + +ScalewayBearerType = Literal["user", "application"] + + +class ScalewaySession(BaseModel): + """Scaleway API session information. + + Stores the credentials and the underlying ``scaleway.Client`` so every + service can reuse the same authenticated client. + """ + + access_key: str + # Excluded from serialization and repr: the whole authentication relies + # on this secret, so it must never leak through .dict()/.json()/logs. + secret_key: str = Field(exclude=True, repr=False) + organization_id: Optional[str] = None + default_project_id: Optional[str] = None + default_region: Optional[str] = None + client: Any = Field(default=None, exclude=True) + + class Config: + arbitrary_types_allowed = True + + +class ScalewayIdentityInfo(BaseModel): + """Scaleway identity and scoping information.""" + + organization_id: str + bearer_id: Optional[str] = None + bearer_type: Optional[ScalewayBearerType] = None + bearer_email: Optional[str] = None + account_root_user_id: Optional[str] = None + + +class ScalewayOutputOptions(ProviderOutputOptions): + """Customize output filenames for Scaleway scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: ScalewayIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + account_fragment = identity.organization_id or "scaleway" + self.output_filename = ( + f"prowler-output-{account_fragment}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/scaleway/scaleway_provider.py b/prowler/providers/scaleway/scaleway_provider.py new file mode 100644 index 0000000000..5c113483dd --- /dev/null +++ b/prowler/providers/scaleway/scaleway_provider.py @@ -0,0 +1,378 @@ +import os + +from colorama import Fore, Style +from scaleway import Client +from scaleway.iam.v1alpha1 import IamV1Alpha1API + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.scaleway.exceptions.exceptions import ( + ScalewayAuthenticationError, + ScalewayCredentialsError, + ScalewayIdentityError, + ScalewaySessionError, +) +from prowler.providers.scaleway.lib.mutelist.mutelist import ScalewayMutelist +from prowler.providers.scaleway.models import ( + ScalewayIdentityInfo, + ScalewaySession, +) + + +class ScalewayProvider(Provider): + """Scaleway provider. + + Authenticates against the Scaleway API using an API key (access key + + secret key) and exposes a single global session that every service + reuses. Scaleway scopes everything to an organization, so the + organization ID is the audit identity. + """ + + _type: str = "scaleway" + _session: ScalewaySession + _identity: ScalewayIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: ScalewayMutelist + audit_metadata: Audit_Metadata + + def __init__( + self, + # Authentication credentials + access_key: str = None, + secret_key: str = None, + organization_id: str = None, + project_id: str = None, + region: str = None, + # Provider configuration + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict = {}, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + logger.info("Instantiating Scaleway provider...") + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._session = ScalewayProvider.setup_session( + access_key=access_key, + secret_key=secret_key, + organization_id=organization_id, + project_id=project_id, + region=region, + ) + + self._identity = ScalewayProvider.setup_identity(self._session) + + self._fixer_config = fixer_config + + if mutelist_content: + self._mutelist = ScalewayMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = ScalewayMutelist(mutelist_path=mutelist_path) + + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> ScalewayMutelist: + return self._mutelist + + @staticmethod + def setup_session( + access_key: str = None, + secret_key: str = None, + organization_id: str = None, + project_id: str = None, + region: str = None, + ) -> ScalewaySession: + """Initialize the Scaleway API session. + + Credentials can be provided as arguments (for API/SDK use) or read + from the official Scaleway environment variables: + + - ``SCW_ACCESS_KEY`` + - ``SCW_SECRET_KEY`` + - ``SCW_DEFAULT_ORGANIZATION_ID`` + - ``SCW_DEFAULT_PROJECT_ID`` + - ``SCW_DEFAULT_REGION`` + + Args: + access_key: Scaleway API access key. + secret_key: Scaleway API secret key. + organization_id: Default organization ID to scope the audit. + project_id: Default project ID for project-scoped resources. + region: Default region. + + Returns: + ScalewaySession: The initialized session, holding the + authenticated ``scaleway.Client``. + + Raises: + ScalewayCredentialsError: Access or secret key missing. + ScalewaySessionError: Client instantiation failed. + """ + access = access_key or os.environ.get("SCW_ACCESS_KEY", "") + secret = secret_key or os.environ.get("SCW_SECRET_KEY", "") + org = organization_id or os.environ.get("SCW_DEFAULT_ORGANIZATION_ID") or None + project = project_id or os.environ.get("SCW_DEFAULT_PROJECT_ID") or None + default_region = region or os.environ.get("SCW_DEFAULT_REGION") or "fr-par" + + if not access or not secret: + raise ScalewayCredentialsError( + file=os.path.basename(__file__), + message=( + "Scaleway credentials not found. Provide access_key and " + "secret_key or set the SCW_ACCESS_KEY and SCW_SECRET_KEY " + "environment variables." + ), + ) + + try: + client = Client( + access_key=access, + secret_key=secret, + default_organization_id=org, + default_project_id=project, + default_region=default_region, + ) + return ScalewaySession( + access_key=access, + secret_key=secret, + organization_id=org, + default_project_id=project, + default_region=default_region, + client=client, + ) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise ScalewaySessionError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def setup_identity(session: ScalewaySession) -> ScalewayIdentityInfo: + """Resolve the audit identity by calling Scaleway IAM. + + Uses ``iam.get_api_key`` on the current access key to discover the + bearer (user vs application). When the bearer is a user, the + owning organization is read from the user record; otherwise we + require ``SCW_DEFAULT_ORGANIZATION_ID``. + """ + try: + iam = IamV1Alpha1API(session.client) + current_key = iam.get_api_key(access_key=session.access_key) + + bearer_id = current_key.user_id or current_key.application_id + bearer_type = ( + "user" + if current_key.user_id + else ("application" if current_key.application_id else None) + ) + + organization_id = session.organization_id + bearer_email = None + account_root_user_id = None + + # If the bearer is a user, resolve the org from the user record + # and surface the email + root user id for the credentials banner. + if current_key.user_id: + user = iam.get_user(user_id=current_key.user_id) + organization_id = organization_id or user.organization_id + bearer_email = user.email + account_root_user_id = user.account_root_user_id + elif current_key.application_id and not organization_id: + # Application keys do not expose the org directly without an + # extra call. The default org from env is preferred. + logger.warning( + "Scaleway application-scoped API key without " + "SCW_DEFAULT_ORGANIZATION_ID. Resource discovery may fail." + ) + # NOTE: application-scoped keys never resolve account_root_user_id + # here (the IAM API does not expose it for an application bearer). + # The IAM service falls back to the org's user list to recover it; + # if that is unavailable, iam_api_keys_no_root_owned degrades to + # MANUAL rather than silently PASSing root-owned keys. + + if not organization_id: + raise ScalewayIdentityError( + file=os.path.basename(__file__), + message=( + "Could not determine the Scaleway organization ID. " + "Set SCW_DEFAULT_ORGANIZATION_ID or use a user-scoped " + "API key." + ), + ) + + return ScalewayIdentityInfo( + organization_id=organization_id, + bearer_id=bearer_id, + bearer_type=bearer_type, + bearer_email=bearer_email, + account_root_user_id=account_root_user_id, + ) + except ScalewayIdentityError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise ScalewayIdentityError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def validate_credentials(session: ScalewaySession) -> None: + """Smoke-test credentials by resolving the current API key. + + Uses ``iam.get_api_key`` because it does not require any prior + knowledge of the bearer or the owning organization. + + Args: + session: The Scaleway session to validate. + + Raises: + ScalewayAuthenticationError: Authentication or authorization + failed against the Scaleway IAM API. + """ + try: + iam = IamV1Alpha1API(session.client) + iam.get_api_key(access_key=session.access_key) + except Exception as error: + raise ScalewayAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Scaleway credentials below:{Style.RESET_ALL}" + ) + report_lines = [ + f"Authentication: {Fore.YELLOW}API Key{Style.RESET_ALL}", + f"Access Key: {Fore.YELLOW}{self._session.access_key}{Style.RESET_ALL}", + f"Organization ID: {Fore.YELLOW}{self._identity.organization_id}{Style.RESET_ALL}", + ] + if self._identity.bearer_type: + report_lines.append( + f"Bearer: {Fore.YELLOW}{self._identity.bearer_type}" + f" ({self._identity.bearer_email or self._identity.bearer_id})" + f"{Style.RESET_ALL}" + ) + if self._session.default_region: + report_lines.append( + f"Default Region: {Fore.YELLOW}{self._session.default_region}{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + access_key: str = None, + secret_key: str = None, + organization_id: str = None, + raise_on_exception: bool = True, + provider_id: str = None, + ) -> Connection: + """Test connection to Scaleway. + + Args: + access_key: Scaleway access key (falls back to SCW_ACCESS_KEY). + secret_key: Scaleway secret key (falls back to SCW_SECRET_KEY). + organization_id: Organization ID to scope the audit. + raise_on_exception: Whether to raise or return errors. + provider_id: Expected Scaleway organization ID. When provided, + the resolved identity must match it; otherwise the test + fails with ``ScalewayAuthenticationError``. + + Returns: + Connection: Connection object with is_connected status. + """ + try: + session = ScalewayProvider.setup_session( + access_key=access_key, + secret_key=secret_key, + organization_id=organization_id, + ) + ScalewayProvider.validate_credentials(session) + + # Guard for API callers that already know the expected + # organization: the credentials must point to that exact org. + if provider_id: + identity = ScalewayProvider.setup_identity(session) + if identity.organization_id != provider_id: + raise ScalewayAuthenticationError( + file=os.path.basename(__file__), + message=( + "The provided credentials do not have access to " + f"the Scaleway organization with ID: {provider_id}" + ), + ) + + return Connection(is_connected=True) + + except ( + ScalewayCredentialsError, + ScalewaySessionError, + ScalewayAuthenticationError, + ScalewayIdentityError, + ) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise error + return Connection(is_connected=False, error=error) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + formatted_error = ScalewayAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + if raise_on_exception: + raise formatted_error + return Connection(is_connected=False, error=formatted_error) + + def validate_arguments(self) -> None: + return None diff --git a/prowler/providers/scaleway/services/__init__.py b/prowler/providers/scaleway/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/services/iam/__init__.py b/prowler/providers/scaleway/services/iam/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/__init__.py b/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned.metadata.json b/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned.metadata.json new file mode 100644 index 0000000000..15c6e800e0 --- /dev/null +++ b/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "scaleway", + "CheckID": "iam_api_keys_no_root_owned", + "CheckTitle": "Scaleway IAM API keys must not be owned by the account root user", + "CheckType": [], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Scaleway API keys** are checked to ensure none is bound to the **account root user**. The account root user is the original Scaleway account owner; its credentials bypass IAM policies and grant unrestricted access to the entire organization.", + "Risk": "API keys owned by the **account root user** cannot be scoped down with IAM policies. Leaking one of these keys yields immediate full control over every project, resource and billing setting in the organization, and rotating them disrupts every automation depending on root credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.scaleway.com/en/docs/identity-and-access-management/iam/concepts/#root-account", + "https://www.scaleway.com/en/docs/identity-and-access-management/iam/how-to/create-api-keys/" + ], + "Remediation": { + "Code": { + "CLI": "scw iam api-key delete ", + "NativeIaC": "", + "Other": "1. Sign in to the Scaleway console as a user with IAM admin permissions.\n2. Create a dedicated IAM user or application scoped with the minimum required policy.\n3. Generate a new API key for that bearer and roll it out to the workloads currently using the root key.\n4. Delete the API key owned by the account root user from the IAM > API keys page.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Never use API keys owned by the **account root user** for automation. Create scoped **IAM users** or **applications**, attach **least-privilege policies**, and rotate any existing root API keys to that new bearer.", + "Url": "https://hub.prowler.com/check/iam_api_keys_no_root_owned" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned.py b/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned.py new file mode 100644 index 0000000000..5e98762758 --- /dev/null +++ b/prowler/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned.py @@ -0,0 +1,94 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportScaleway +from prowler.providers.scaleway.services.iam.iam_client import iam_client +from prowler.providers.scaleway.services.iam.iam_service import ( + ScalewayIAMDataUnavailable, +) + + +class iam_api_keys_no_root_owned(Check): + """Ensure no Scaleway IAM API key is owned by the account root user. + + The account root user is the original Scaleway account owner. API keys + bound to that bearer bypass IAM policies and grant unrestricted access + to the entire organization; rotating or losing them is a critical + incident. Day-to-day automation should rely on IAM users or + applications scoped through policies instead. + """ + + def execute(self) -> List[CheckReportScaleway]: + """Iterate over the API keys cached by the IAM service. + + The check degrades to ``MANUAL`` when the IAM service could not + load the prerequisite data (users or API keys) — emitting ``PASS`` + in those cases would silently mask the very condition the check + exists to detect. + + Returns: + One ``CheckReportScaleway`` per discovered API key. ``FAIL`` + when the bearer is the account root user, ``PASS`` otherwise. + A single ``MANUAL`` report is emitted when underlying IAM data + is unavailable. + """ + findings: List[CheckReportScaleway] = [] + + # If we could not even load the users we cannot tell who the root + # bearer is, so every API key would falsely PASS. Surface MANUAL + # explicitly so the operator investigates. + if not iam_client.users_loaded or not iam_client.api_keys_loaded: + placeholder = ScalewayIAMDataUnavailable( + organization_id=iam_client.organization_id + ) + report = CheckReportScaleway(metadata=self.metadata(), resource=placeholder) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Scaleway IAM users or API keys for " + f"organization {iam_client.organization_id}. Verify the " + "API key has the IAMReadOnly policy and rerun." + ) + findings.append(report) + return findings + + root_user_id = iam_client.account_root_user_id + + # The account root user could not be resolved (typically an + # application-scoped API key with no IAM users visible). Without it + # every key would fall through to PASS, masking root-owned keys, so + # surface MANUAL instead of a silent clean result. + if not root_user_id: + placeholder = ScalewayIAMDataUnavailable( + organization_id=iam_client.organization_id + ) + report = CheckReportScaleway(metadata=self.metadata(), resource=placeholder) + report.status = "MANUAL" + report.status_extended = ( + "Could not determine the Scaleway account root user for " + f"organization {iam_client.organization_id}. This typically " + "happens with application-scoped API keys when no IAM users " + "are visible. Verify the API key has the IAMReadOnly policy " + "and rerun." + ) + findings.append(report) + return findings + + for api_key in iam_client.api_keys: + report = CheckReportScaleway(metadata=self.metadata(), resource=api_key) + + if api_key.user_id == root_user_id: + report.status = "FAIL" + report.status_extended = ( + f"Scaleway API key {api_key.access_key} is owned by the " + f"account root user ({root_user_id}). Replace it with an " + f"API key bound to a dedicated IAM user or application." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Scaleway API key {api_key.access_key} is not owned by " + f"the account root user." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/scaleway/services/iam/iam_client.py b/prowler/providers/scaleway/services/iam/iam_client.py new file mode 100644 index 0000000000..af0704e629 --- /dev/null +++ b/prowler/providers/scaleway/services/iam/iam_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.scaleway.services.iam.iam_service import IAM + +iam_client = IAM(Provider.get_global_provider()) diff --git a/prowler/providers/scaleway/services/iam/iam_service.py b/prowler/providers/scaleway/services/iam/iam_service.py new file mode 100644 index 0000000000..f37b5a84f3 --- /dev/null +++ b/prowler/providers/scaleway/services/iam/iam_service.py @@ -0,0 +1,166 @@ +from typing import Optional + +from pydantic.v1 import BaseModel +from scaleway.iam.v1alpha1 import IamV1Alpha1API + +from prowler.lib.logger import logger +from prowler.providers.scaleway.lib.service.service import ScalewayService + + +class IAM(ScalewayService): + """Scaleway IAM service. + + Loads the users in scope plus every API key tied to the current + organization. Checks consume the materialized lists; nothing in this + class is lazy. Each load operation tracks success/failure separately + so checks can degrade to ``MANUAL`` when data is incomplete instead of + falsely passing. + """ + + def __init__(self, provider): + super().__init__("iam", provider) + self._api = IamV1Alpha1API(self.client) + + # Cached state — populated eagerly during construction + self.users: list[ScalewayUser] = [] + self.api_keys: list[ScalewayAPIKey] = [] + + # Load status flags — checks consult these to surface MANUAL when + # the underlying API call failed rather than reporting empty lists + # as a clean PASS. + self.users_loaded: bool = False + self.api_keys_loaded: bool = False + + self._load_users() + self._load_api_keys() + + # Prefer the root user id resolved at authentication time from the + # audit identity. Application-scoped API keys do not expose it on + # the identity, so fall back to the loaded user list (every user + # record carries the org's account_root_user_id). When neither is + # available the root-key check degrades to MANUAL instead of + # silently PASSing root-owned keys. + self.account_root_user_id: Optional[str] = ( + provider.identity.account_root_user_id + or next( + (u.account_root_user_id for u in self.users if u.account_root_user_id), + None, + ) + ) + + def _load_users(self) -> None: + """List every IAM user in the audited organization.""" + try: + users = self._api.list_users_all(organization_id=self.organization_id) + for user in users: + self.users.append( + ScalewayUser( + id=user.id, + email=user.email, + username=user.username, + organization_id=user.organization_id, + account_root_user_id=user.account_root_user_id, + mfa=bool(getattr(user, "mfa", False)), + type_=( + str(user.type_) if getattr(user, "type_", None) else None + ), + status=( + str(user.status) if getattr(user, "status", None) else None + ), + ) + ) + + self.users_loaded = True + + except Exception as error: + logger.error( + f"{self.service} - Error listing users: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _load_api_keys(self) -> None: + """List every API key in the audited organization.""" + try: + api_keys = self._api.list_api_keys_all(organization_id=self.organization_id) + for key in api_keys: + self.api_keys.append( + ScalewayAPIKey( + access_key=key.access_key, + description=key.description, + user_id=key.user_id, + application_id=key.application_id, + default_project_id=key.default_project_id, + editable=bool(key.editable), + managed=bool(getattr(key, "managed", False)), + creation_ip=key.creation_ip, + created_at=str(key.created_at) if key.created_at else None, + updated_at=str(key.updated_at) if key.updated_at else None, + expires_at=str(key.expires_at) if key.expires_at else None, + ) + ) + + self.api_keys_loaded = True + + except Exception as error: + logger.error( + f"{self.service} - Error listing API keys: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class ScalewayUser(BaseModel): + """Subset of a Scaleway IAM user surface that the checks need.""" + + id: str + email: Optional[str] = None + username: Optional[str] = None + organization_id: Optional[str] = None + account_root_user_id: Optional[str] = None + mfa: bool = False + type_: Optional[str] = None + status: Optional[str] = None + # Provide name/id for CheckReportScaleway + name: str = "" + + def __init__(self, **data): + super().__init__(**data) + self.name = self.email or self.username or self.id + + +class ScalewayAPIKey(BaseModel): + """Subset of a Scaleway IAM API key surface that the checks need.""" + + access_key: str + description: Optional[str] = None + user_id: Optional[str] = None + application_id: Optional[str] = None + default_project_id: Optional[str] = None + editable: bool = False + managed: bool = False + creation_ip: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + expires_at: Optional[str] = None + # Provide name/id for CheckReportScaleway + name: str = "" + id: str = "" + + def __init__(self, **data): + super().__init__(**data) + self.id = self.access_key + self.name = self.description or self.access_key + + +class ScalewayIAMDataUnavailable(BaseModel): + """Stand-in resource used when the IAM service failed to load. + + Lets checks materialize a ``MANUAL`` finding (instead of a silent + ``PASS``) when users or API keys could not be retrieved. + ``CheckReportScaleway`` reads ``name``/``id``/``organization_id``/ + ``region`` off the resource, so exposing those is enough. + """ + + organization_id: str + name: str = "iam-data-unavailable" + id: str = "iam-data-unavailable" + region: str = "global" diff --git a/prowler/providers/stackit/__init__.py b/prowler/providers/stackit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/exceptions/__init__.py b/prowler/providers/stackit/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/exceptions/exceptions.py b/prowler/providers/stackit/exceptions/exceptions.py new file mode 100644 index 0000000000..246abb337c --- /dev/null +++ b/prowler/providers/stackit/exceptions/exceptions.py @@ -0,0 +1,99 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 16000 to 16999 are reserved for StackIT exceptions +class StackITBaseException(ProwlerException): + """Base class for StackIT Errors.""" + + STACKIT_ERROR_CODES = { + (16001, "StackITNonExistentTokenError"): { + "message": "A StackIT service account key file is required to authenticate against StackIT", + "remediation": "Set --stackit-service-account-key-path or the STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable to a valid service account key JSON file.", + }, + (16002, "StackITInvalidTokenError"): { + "message": "StackIT service account key was rejected or lacks permissions", + "remediation": "Verify the service account key file is current, has not been revoked, and that the service account has the required roles on the project.", + }, + (16003, "StackITSetUpSessionError"): { + "message": "Error setting up StackIT session", + "remediation": "Check the session setup and ensure the StackIT SDK is properly configured.", + }, + (16004, "StackITSetUpIdentityError"): { + "message": "StackIT identity setup error due to bad credentials", + "remediation": "Check credentials and ensure they are properly set up for StackIT.", + }, + (16005, "StackITInvalidProjectIdError"): { + "message": "The provided project ID is not valid or not accessible", + "remediation": "Check the project ID and ensure you have access to it with the provided credentials.", + }, + (16006, "StackITAPIError"): { + "message": "Error calling StackIT API", + "remediation": "Check the API endpoint and ensure the service is accessible. Verify network connectivity.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "StackIT" + # Clone the catalog entry so per-instance message overrides do not + # mutate the class-level dict and bleed into later exceptions raised + # in the same process. + base_info = self.STACKIT_ERROR_CODES.get((code, self.__class__.__name__)) + error_info = dict(base_info) if base_info else None + if message and error_info is not None: + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class StackITCredentialsError(StackITBaseException): + """Base class for StackIT credentials errors.""" + + def __init__(self, code, file=None, original_exception=None, message=None): + super().__init__(code, file, original_exception, message) + + +class StackITNonExistentTokenError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16001, file=file, original_exception=original_exception, message=message + ) + + +class StackITInvalidTokenError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16002, file=file, original_exception=original_exception, message=message + ) + + +class StackITSetUpSessionError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16003, file=file, original_exception=original_exception, message=message + ) + + +class StackITSetUpIdentityError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16004, file=file, original_exception=original_exception, message=message + ) + + +class StackITInvalidProjectIdError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16005, file=file, original_exception=original_exception, message=message + ) + + +class StackITAPIError(StackITBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16006, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/stackit/lib/__init__.py b/prowler/providers/stackit/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/lib/arguments/__init__.py b/prowler/providers/stackit/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/lib/arguments/arguments.py b/prowler/providers/stackit/lib/arguments/arguments.py new file mode 100644 index 0000000000..afabc41fe8 --- /dev/null +++ b/prowler/providers/stackit/lib/arguments/arguments.py @@ -0,0 +1,60 @@ +from prowler.providers.stackit.stackit_provider import StackitProvider + +SENSITIVE_ARGUMENTS = frozenset({"--stackit-service-account-key"}) + + +def init_parser(self): + """Init the StackIT Provider CLI parser""" + stackit_parser = self.subparsers.add_parser( + "stackit", parents=[self.common_providers_parser], help="StackIT Provider" + ) + + # Authentication + stackit_auth_subparser = stackit_parser.add_argument_group("Authentication") + stackit_auth_subparser.add_argument( + "--stackit-project-id", + nargs="?", + default=None, + help="StackIT Project ID to audit (alternatively set via STACKIT_PROJECT_ID environment variable)", + ) + stackit_auth_subparser.add_argument( + "--stackit-service-account-key-path", + nargs="?", + default=None, + help=( + "Path to a StackIT service account key JSON file. The SDK signs the RSA " + "challenge in the key and mints/refreshes access tokens internally for " + "the life of the scan. Alternatively set via the " + "STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable." + ), + ) + stackit_auth_subparser.add_argument( + "--stackit-service-account-key", + nargs="?", + default=None, + help=( + "Inline content of a StackIT service account key (JSON). Useful in " + "CI/CD where the secret comes from a secret manager and you do not " + "want to write it to disk. Prefer the STACKIT_SERVICE_ACCOUNT_KEY " + "environment variable over this flag to avoid leaking the key " + "through process listings or shell history." + ), + ) + + stackit_parser.add_argument( + "--stackit-region", + "-r", + nargs="+", + help="STACKIT region(s) to scan (default: all available regions)", + choices=StackitProvider.get_regions(), + default=None, + ) + + scan_unused_services_subparser = stackit_parser.add_argument_group( + "Scan Unused Services" + ) + scan_unused_services_subparser.add_argument( + "--scan-unused-services", + action="store_true", + help="Scan unused services", + ) diff --git a/prowler/providers/stackit/lib/mutelist/__init__.py b/prowler/providers/stackit/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/lib/mutelist/mutelist.py b/prowler/providers/stackit/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..5711392849 --- /dev/null +++ b/prowler/providers/stackit/lib/mutelist/mutelist.py @@ -0,0 +1,23 @@ +from prowler.lib.check.models import CheckReportStackIT +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class StackITMutelist(Mutelist): + def is_finding_muted(self, finding: CheckReportStackIT) -> bool: + """ + Determines if a StackIT finding is muted based on mutelist rules. + + Args: + finding: A CheckReportStackIT finding object + + Returns: + bool: True if the finding is muted, False otherwise + """ + return self.is_muted( + finding.project_id, + finding.check_metadata.CheckID, + finding.location, + finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/stackit/lib/service/__init__.py b/prowler/providers/stackit/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/models.py b/prowler/providers/stackit/models.py new file mode 100644 index 0000000000..d069011083 --- /dev/null +++ b/prowler/providers/stackit/models.py @@ -0,0 +1,48 @@ +from pydantic.v1 import BaseModel + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class StackITIdentityInfo(BaseModel): + """ + StackITIdentityInfo holds basic identity fields for the StackIT provider. + + Attributes: + - project_id (str): The StackIT project ID being audited. + - project_name (str): The name of the StackIT project (fetched from Resource Manager API). + """ + + project_id: str + project_name: str = "" + audited_regions: set = set() + + +class StackITOutputOptions(ProviderOutputOptions): + """ + StackITOutputOptions overrides ProviderOutputOptions for StackIT-specific output logic. + Generates a filename that includes the StackIT project_id. + + Attributes inherited from ProviderOutputOptions: + - output_filename (str): The base filename used for generated reports. + - output_directory (str): The directory to store the output files. + - ... see ProviderOutputOptions for more details. + + Methods: + - __init__: Customizes the output filename logic for StackIT. + """ + + def __init__(self, arguments, bulk_checks_metadata, identity: StackITIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + + # If --output-filename is not specified, build a default name. + if not getattr(arguments, "output_filename", None): + # If project_id exists, include it in the filename (e.g., prowler-output-stackit--20230101) + if identity.project_id: + self.output_filename = f"prowler-output-stackit-{identity.project_id}-{output_file_timestamp}" + # Otherwise just 'prowler-output-stackit-' + else: + self.output_filename = f"prowler-output-stackit-{output_file_timestamp}" + # If --output-filename was explicitly given, respect that + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/stackit/services/__init__.py b/prowler/providers/stackit/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/__init__.py b/prowler/providers/stackit/services/iaas/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_client.py b/prowler/providers/stackit/services/iaas/iaas_client.py new file mode 100644 index 0000000000..eca20c46b4 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.iaas.iaas_service import IaaSService + +iaas_client = IaaSService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json new file mode 100644 index 0000000000..a6bcd648bd --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_all_traffic_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted access to all ports", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted access to all ports from the public internet (`0.0.0.0/0` or `::/0`). This includes rules with no port range specified or rules covering the full port range (`0-65535` or `1-65535`). Allowing all ports **exposes every service** running on the instances to potential attacks.", + "Risk": "Allowing unrestricted access to all ports from the internet exposes **all services and applications** to potential attacks, **unauthorized access**, and security breaches. This effectively **bypasses the security group's purpose** as a firewall.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows all ports and protocols from 0.0.0.0/0 or ::/0. 3. Delete the broad rule and replace it with granular rules that open only the specific ports each service needs. 4. Restrict the source of every rule to trusted IP ranges following least privilege. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Follow the **principle of least privilege** by only allowing the specific ports that are required. Create **granular rules** for each service and restrict access to trusted IP addresses or ranges.", + "Url": "https://hub.prowler.com/check/iaas_security_group_all_traffic_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check identifies security groups that allow all ports from the public internet. It flags rules with no port range specified or rules covering the full port range (0-65535 or 1-65535), regardless of the specific protocol. Security groups should implement specific rules for each required service rather than allowing all ports." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py new file mode 100644 index 0000000000..1b2a7d02f3 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py @@ -0,0 +1,67 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + + +class iaas_security_group_all_traffic_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted access to all ports. + + This check verifies that security groups do not allow all ports + from the public internet (0.0.0.0/0 or ::/0). This includes rules + with no port range specified or rules covering the full port range + (0-65535 or 1-65535), regardless of the specific protocol. + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + unrestricted_rules = [] + + # Check each ingress rule + for rule in security_group.rules: + # Only check ingress rules that are unrestricted + if rule.is_ingress() and rule.is_unrestricted(): + # Check if rule allows all traffic (no port restrictions or all protocols) + if rule.port_range_min is None or rule.port_range_max is None: + # No port range specified - allows all ports + unrestricted_rules.append( + f"Rule {rule.get_rule_display_name()} allows all ports ({rule.protocol or 'all protocols'}) from {rule.get_ip_range_display()}" + ) + elif ( + rule.port_range_min == 0 or rule.port_range_min == 1 + ) and rule.port_range_max >= 65535: + # Port range covers all or nearly all ports + unrestricted_rules.append( + f"Rule {rule.get_rule_display_name()} allows all ports (1-65535) ({rule.protocol or 'all protocols'}) from {rule.get_ip_range_display()}" + ) + + # Create a finding report for this security group + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + + if unrestricted_rules: + report.status = "FAIL" + rules_list = "; ".join(unrestricted_rules) + report.status_extended = f"Security group {security_group.name} allows unrestricted access to all traffic: {rules_list}." + else: + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted access to all traffic." + + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json new file mode 100644 index 0000000000..2337e2e01c --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_database_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted database access", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted access to database ports from the public internet (`0.0.0.0/0` or `::/0`). This includes MySQL (`3306`), PostgreSQL (`5432`), MongoDB (`27017`), Redis (`6379`), SQL Server (`1433`), and CouchDB (`5984`). Unrestricted database access can lead to **data breaches** and **unauthorized access**.", + "Risk": "Allowing unrestricted database access from the internet exposes sensitive data to potential **breaches**, **unauthorized access**, **data exfiltration**, and malicious attacks. Databases often contain critical business data and should **never** be directly accessible from the public internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rules that allow database ports (3306, 5432, 27017, 6379, 1433, 5984) from 0.0.0.0/0 or ::/0. 3. Delete those rules or restrict their source to the specific application servers or trusted IP ranges. 4. Prefer private networking for database connectivity and do not expose database ports to the internet. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict database access** to specific application servers or trusted IP ranges. Use **private networks** for database connectivity and implement additional security layers such as VPNs, private endpoints, or application-level authentication.", + "Url": "https://hub.prowler.com/check/iaas_security_group_database_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check covers common database ports: MySQL (3306), PostgreSQL (5432), MongoDB (27017), Redis (6379), SQL Server (1433), and CouchDB (5984). Databases should always be placed in private networks and accessed through secure channels." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py new file mode 100644 index 0000000000..e777cbb17f --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py @@ -0,0 +1,74 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + +# Database ports to check +DATABASE_PORTS = { + 3306: "MySQL", + 5432: "PostgreSQL", + 27017: "MongoDB", + 6379: "Redis", + 1433: "SQL Server", + 5984: "CouchDB", +} + + +class iaas_security_group_database_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted database access. + + This check verifies that security groups do not allow database ports + (MySQL, PostgreSQL, MongoDB, Redis, SQL Server, CouchDB) access + from the public internet (0.0.0.0/0 or ::/0). + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + exposed_databases = set() + exposing_rule = None + + # Check each ingress rule + for rule in security_group.rules: + # Only check ingress TCP rules that are unrestricted + if rule.is_ingress() and rule.is_tcp() and rule.is_unrestricted(): + # Check if rule allows any database ports + for port, db_name in DATABASE_PORTS.items(): + if rule.includes_port(port): + exposed_databases.add(f"{db_name} (port {port})") + # Track the first exposing rule for the message + if exposed_databases and not exposing_rule: + exposing_rule = rule + + # Create a finding report for this security group + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + + if exposed_databases: + report.status = "FAIL" + databases_list = ", ".join(sorted(exposed_databases)) + report.status_extended = ( + f"Security group {security_group.name} allows unrestricted database access " + f"to: {databases_list} from {exposing_rule.get_ip_range_display()} via rule {exposing_rule.get_rule_display_name()}." + ) + else: + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted database access." + + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json new file mode 100644 index 0000000000..e2b7cc527c --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_rdp_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted RDP access", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted RDP access (port `3389`) from the public internet (`0.0.0.0/0` or `::/0`). Unrestricted RDP access increases the attack surface and can lead to **unauthorized access**, **brute force attacks**, and potential system compromise.", + "Risk": "Allowing unrestricted RDP access from the internet exposes Windows servers to potential **unauthorized access attempts**, **brute force attacks**, **ransomware**, and security breaches. Attackers can exploit weak credentials or vulnerabilities.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows TCP port 3389 from 0.0.0.0/0 or ::/0. 3. Delete that rule or edit its source to the specific IP ranges that require RDP access. 4. If administration from anywhere is required, expose RDP only through a bastion host or VPN instead of the public internet. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict RDP access** to specific IP addresses or ranges that require administrative access. Use **bastion hosts** or **VPN connections** for secure remote access instead of exposing RDP directly to the internet.", + "Url": "https://hub.prowler.com/check/iaas_security_group_rdp_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check focuses on ingress rules that allow RDP (port 3389) from 0.0.0.0/0 or ::/0. Consider implementing network-level authentication and regular security audits." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py new file mode 100644 index 0000000000..839fdfabe3 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py @@ -0,0 +1,51 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + + +class iaas_security_group_rdp_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted RDP access. + + This check verifies that security groups do not allow RDP (port 3389) + access from the public internet (0.0.0.0/0 or ::/0). + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted RDP access." + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + for rule in security_group.rules: + if ( + rule.is_ingress() + and rule.is_tcp() + and rule.is_unrestricted() + and rule.includes_port(3389) + ): + report.status = "FAIL" + report.status_extended = ( + f"Security group {security_group.name} allows unrestricted RDP access (port 3389) " + f"from {rule.get_ip_range_display()} via rule {rule.get_rule_display_name()}." + ) + break + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json new file mode 100644 index 0000000000..df18970ddf --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_ssh_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted SSH access", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted SSH access (port `22`) from the public internet (`0.0.0.0/0` or `::/0`). Unrestricted SSH access increases the attack surface and can lead to **unauthorized access**, **brute force attacks**, and potential system compromise.", + "Risk": "Allowing unrestricted SSH access from the internet exposes servers to potential **unauthorized access attempts**, **brute force attacks**, and security breaches. Attackers can scan for and exploit weak credentials or vulnerabilities.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows TCP port 22 from 0.0.0.0/0 or ::/0. 3. Delete that rule or edit its source to the specific IP ranges that require SSH access. 4. If administration from anywhere is required, expose SSH only through a bastion host or VPN instead of the public internet. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict SSH access** to specific IP addresses or ranges that require administrative access. Use **bastion hosts** or **VPN connections** for secure remote access instead of exposing SSH directly to the internet.", + "Url": "https://hub.prowler.com/check/iaas_security_group_ssh_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check focuses on ingress rules that allow SSH (port 22) from 0.0.0.0/0 or ::/0. Consider implementing additional controls such as multi-factor authentication and regular security audits." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py new file mode 100644 index 0000000000..27b9265c15 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py @@ -0,0 +1,51 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + + +class iaas_security_group_ssh_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted SSH access. + + This check verifies that security groups do not allow SSH (port 22) + access from the public internet (0.0.0.0/0 or ::/0). + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted SSH access." + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + for rule in security_group.rules: + if ( + rule.is_ingress() + and rule.is_tcp() + and rule.is_unrestricted() + and rule.includes_port(22) + ): + report.status = "FAIL" + report.status_extended = ( + f"Security group {security_group.name} allows unrestricted SSH access (port 22) " + f"from {rule.get_ip_range_display()} via rule {rule.get_rule_display_name()}." + ) + break + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_service.py b/prowler/providers/stackit/services/iaas/iaas_service.py new file mode 100644 index 0000000000..2cea664241 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_service.py @@ -0,0 +1,477 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr + + +class IaaSService: + """ + StackIT IaaS Service class to handle security group operations. + + This service uses the StackIT Python SDK to access IaaS resources. + Authentication is delegated to the SDK, which signs the RSA challenge + in the configured service account key and refreshes access tokens + internally for the life of the scan. + """ + + def __init__(self, provider: StackitProvider): + """ + Initialize the IaaS service. + + Args: + provider: The StackIT provider instance + """ + self.provider = provider + self.project_id = provider.identity.project_id + self.service_account_key_path = provider.session.get("service_account_key_path") + self.scan_unused_services = provider.scan_unused_services + + # Generate regional clients (AWS pattern) + self.regional_clients = provider.generate_regional_clients("iaas") + self.audited_regions = provider.identity.audited_regions + + # Initialize security groups list + self.security_groups: list[SecurityGroup] = [] + + # Initialize server NICs list and used security group IDs + self.server_nics: list = [] + self.in_use_sg_ids: set[str] = set() + + # Fetch resources from all regions + self._fetch_all_regions() + self._log_skipped_security_groups() + + def _log_skipped_security_groups(self): + """Explain an empty report when every security group is skipped. + + Following the same convention as the rest of Prowler, security group + checks only evaluate groups that are in use (attached to a network + interface) unless ``--scan-unused-services`` is set. When a project + has security groups but none are attached, every check returns no + finding, which looks like "nothing was scanned". Emit an explicit + hint so the empty report is not mistaken for a failure. + """ + if ( + not self.scan_unused_services + and self.security_groups + and not any(sg.in_use for sg in self.security_groups) + ): + logger.info( + f"{len(self.security_groups)} StackIT security group(s) were " + f"found but none are attached to a network interface, so all " + f"of them are skipped and no finding is produced. Re-run with " + f"--scan-unused-services to audit security groups that are " + f"not currently in use." + ) + + def _fetch_all_regions(self): + """Fetch resources from all audited regions. + + A project is not necessarily provisioned in every StackIT region. A + region where the project does not exist answers the IaaS endpoints + with HTTP 404 (``resource not found: project``). That is expected, so + the region is skipped and the scan continues with the remaining + regions instead of aborting (which previously left every check + failing to load and produced an empty, misleading report). + + Credential and permission failures (401/403) still propagate via + ``handle_api_error`` so a misconfigured account fails loudly. + """ + for region, client in self.regional_clients.items(): + try: + self._list_server_nics(client, region) + self._list_security_groups(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no IaaS " + f"presence in region {region} (404 resource not " + f"found); skipping this region." + ) + continue + raise + + @staticmethod + def _extract_items(response, endpoint_name: str) -> list: + """Extract the items list from a StackIT SDK response. + + Handles three response shapes safely: + - SDK model exposing an ``items`` attribute (not the ``dict.items`` method) + - Raw ``dict`` with an ``"items"`` key + - Plain ``list`` + + ``isinstance(response, dict)`` is checked first because ``dict`` has an + ``items`` *method*; ``hasattr(response, "items")`` is otherwise True for + plain dicts and silently returns the bound method. + """ + if isinstance(response, dict): + return response.get("items", []) + if isinstance(response, list): + return response + items_attr = getattr(response, "items", None) + if items_attr is not None and not callable(items_attr): + return items_attr + logger.warning( + f"Unexpected response type from {endpoint_name}: {type(response)}" + ) + return [] + + def _handle_api_call(self, api_function, *args, **kwargs): + """ + Centralized API call handler with authentication error detection. + + Args: + api_function: The API function to call + *args: Positional arguments to pass to the API function + **kwargs: Keyword arguments to pass to the API function + + Returns: + The API response + + Raises: + StackITInvalidTokenError: If authentication fails (401) + """ + try: + # Suppress StackIT SDK stderr messages during API calls + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + # Use centralized error handler from provider + self.provider.handle_api_error(e) + raise + + def _list_security_groups(self, client, region: str): + """ + List all security groups in the StackIT project and fetch their rules. + + This method populates the self.security_groups list with SecurityGroup + objects containing information about each security group and its rules. + """ + if not client: + logger.warning( + f"Cannot list security groups in {region}: StackIT IaaS client not available" + ) + return + + # Call the list security groups API with centralized error handling + response = self._handle_api_call( + client.list_security_groups, project_id=self.project_id, region=region + ) + + # Extract security groups from response + security_groups_list = self._extract_items(response, "list_security_groups") + + # Process each security group + for sg_data in security_groups_list: + try: + # Extract security group information + if hasattr(sg_data, "id"): + sg_id = sg_data.id + sg_name = getattr(sg_data, "name", sg_id) + elif isinstance(sg_data, dict): + sg_id = sg_data.get("id", "") + sg_name = sg_data.get("name", sg_id) + else: + logger.warning( + f"Unexpected security group data type: {type(sg_data)}" + ) + continue + + except Exception as e: + logger.error(f"Error processing security group: {e}") + continue + + # Get security group rules after local parsing succeeds so API errors + # from the rules endpoint propagate instead of being downgraded. + rules = self._list_security_group_rules(client, region, sg_id) + + security_group = SecurityGroup( + id=sg_id, + name=sg_name, + project_id=self.project_id, + region=region, + rules=rules, + # in_use_sg_ids is normalized to str; the SDK returns the NIC + # security group references as uuid.UUID while the security + # group id is a str, so compare on the string form. + in_use=str(sg_id) in self.in_use_sg_ids, + ) + self.security_groups.append(security_group) + + logger.info( + f"Successfully listed {len(security_groups_list)} security groups in {region}" + ) + + def _list_security_group_rules( + self, client, region: str, security_group_id: str + ) -> list["SecurityGroupRule"]: + """ + List all rules for a specific security group. + + Args: + client: The StackIT IaaS client + region: The region of the security group + security_group_id: The ID of the security group + + Returns: + list: List of SecurityGroupRule objects + """ + rules = [] + # Get security group rules via SDK + response = self._handle_api_call( + client.list_security_group_rules, + project_id=self.project_id, + region=region, + security_group_id=security_group_id, + ) + + # Extract rules from response + rules_list = self._extract_items(response, "list_security_group_rules") + + # Process each rule + for rule_data in rules_list: + try: + if hasattr(rule_data, "id"): + # Extract protocol name from Protocol object + protocol_obj = getattr(rule_data, "protocol", None) + protocol_name = None + if protocol_obj and hasattr(protocol_obj, "name"): + protocol_name = protocol_obj.name + + # Extract port range from PortRange object + port_range_obj = getattr(rule_data, "port_range", None) + port_min = None + port_max = None + if port_range_obj: + if hasattr(port_range_obj, "min"): + port_min = port_range_obj.min + if hasattr(port_range_obj, "max"): + port_max = port_range_obj.max + + rule = SecurityGroupRule( + id=getattr(rule_data, "id", ""), + direction=getattr(rule_data, "direction", ""), + protocol=protocol_name, + ip_range=getattr(rule_data, "ip_range", None), + port_range_min=port_min, + port_range_max=port_max, + description=getattr(rule_data, "description", None), + remote_security_group_id=getattr( + rule_data, "remote_security_group_id", None + ), + ) + elif isinstance(rule_data, dict): + # Handle dict response (if API returns dict instead of objects) + protocol_data = rule_data.get("protocol") + protocol_name = None + if isinstance(protocol_data, dict): + protocol_name = protocol_data.get("name") + elif isinstance(protocol_data, str): + protocol_name = protocol_data + + port_range_data = rule_data.get("port_range") + port_min = None + port_max = None + if isinstance(port_range_data, dict): + port_min = port_range_data.get("min") + port_max = port_range_data.get("max") + + rule = SecurityGroupRule( + id=rule_data.get("id", ""), + direction=rule_data.get("direction", ""), + protocol=protocol_name, + ip_range=rule_data.get("ip_range"), + port_range_min=port_min, + port_range_max=port_max, + description=rule_data.get("description"), + remote_security_group_id=rule_data.get( + "remote_security_group_id" + ), + ) + else: + continue + + rules.append(rule) + logger.debug( + f"Parsed rule: id={rule.id}, direction={rule.direction}, " + f"protocol={rule.protocol}, ip_range={rule.ip_range}, " + f"ports={rule.port_range_min}-{rule.port_range_max}, " + f"remote_sg={rule.remote_security_group_id}" + ) + + except Exception as e: + logger.debug(f"Error processing rule: {e}") + continue + + return rules + + def _list_server_nics(self, client, region: str): + """ + List all server network interfaces (NICs) in the StackIT project. + + This method fetches all NICs and determines which security groups are + actively in use by checking which security groups are attached to any NIC. + """ + if not client: + logger.warning( + f"Cannot list server NICs in {region}: StackIT IaaS client not available" + ) + return + + # Call the list project NICs API with centralized error handling + response = self._handle_api_call( + client.list_project_nics, project_id=self.project_id, region=region + ) + + # Extract NICs from response + nics_list = self._extract_items(response, "list_project_nics") + + self.server_nics.extend(nics_list) + + # A security group is "in use" when attached to any NIC + used_sg_ids = self._get_used_security_group_ids(nics_list) + self.in_use_sg_ids.update(used_sg_ids) + + logger.info( + f"Successfully listed {len(nics_list)} NICs in {region}. " + f"Found {len(used_sg_ids)} security groups attached to NICs." + ) + + def _get_used_security_group_ids(self, nics_list) -> set[str]: + """ + Get the set of security group IDs that are actively attached to any NIC. + + Returns: + set[str]: Set of security group IDs that are attached to at least one NIC + """ + used_sg_ids = set() + + for nic in nics_list: + try: + # Extract security groups from NIC. The SDK model exposes them + # as ``security_groups``; a raw dict uses the camelCase + # ``securityGroups`` key (falling back to snake_case). + if hasattr(nic, "security_groups"): + sg_list = nic.security_groups + elif isinstance(nic, dict): + sg_list = nic.get("securityGroups", nic.get("security_groups", [])) + else: + continue + + if sg_list: + for sg_id in sg_list: + if sg_id: + # The SDK returns these references as uuid.UUID + # while the security group id is a str; normalize + # to str so the membership test in + # _list_security_groups matches. + used_sg_ids.add(str(sg_id)) + + except Exception as e: + logger.debug(f"Error extracting security groups from NIC: {e}") + continue + + return used_sg_ids + + +class SecurityGroupRule(BaseModel): + """ + Represents a Security Group Rule. + + Attributes: + id: The unique identifier of the rule + direction: The direction of the rule (ingress/egress) + protocol: The protocol (tcp/udp/icmp/all) - can be None for some rules + ip_range: The IP range (CIDR notation) - can be None for some rules + port_range_min: The minimum port number + port_range_max: The maximum port number + description: The user-defined description/name of the rule (optional) + remote_security_group_id: The ID of a security group to allow traffic from (optional) + """ + + id: str + direction: str + protocol: Optional[str] = None + ip_range: Optional[str] = None + port_range_min: Optional[int] = None + port_range_max: Optional[int] = None + description: Optional[str] = None + remote_security_group_id: Optional[str] = None + + def is_unrestricted(self) -> bool: + """Check if the rule allows access from anywhere (0.0.0.0/0, ::/0, or None for unrestricted).""" + # If remote_security_group_id is set, the rule only allows traffic from that security group + # This is NOT unrestricted access - it's restricted to instances in the same security group + if self.remote_security_group_id is not None: + return False + + # None means no IP restriction (allows all sources) - this is unrestricted! + if self.ip_range is None: + return True + # Explicit unrestricted ranges + return self.ip_range in ["0.0.0.0/0", "::/0"] + + def is_ingress(self) -> bool: + """Check if the rule is an ingress rule.""" + if not self.direction: + return False + return self.direction.lower() == "ingress" + + def is_tcp(self) -> bool: + """Check if the rule is TCP protocol.""" + # None means all protocols (including TCP) - treat as TCP-applicable + if self.protocol is None: + return True + return self.protocol.lower() in ["tcp", "all"] + + def includes_port(self, port: int) -> bool: + """Check if the rule includes a specific port.""" + if self.port_range_min is None or self.port_range_max is None: + # If no port range specified, rule applies to all ports + return True + return self.port_range_min <= port <= self.port_range_max + + def get_ip_range_display(self) -> str: + """ + Get a user-friendly display string for the IP range. + + Returns: + str: Human-readable IP range description + """ + if self.ip_range is None: + return "anywhere (0.0.0.0/0, ::/0)" + return self.ip_range + + def get_rule_display_name(self) -> str: + """ + Get a user-friendly display name for the rule. + + Returns: + str: Rule description if available, otherwise rule ID + """ + if self.description: + return f"{self.description} ({self.id})" + return f"{self.id}" + + +class SecurityGroup(BaseModel): + """ + Represents a StackIT IaaS Security Group. + + Attributes: + id: The unique identifier of the security group + name: The name of the security group + project_id: The StackIT project ID containing the security group + region: The region where the security group is located + rules: List of security group rules + in_use: Whether the security group is actively attached to any resources + """ + + id: str + name: str + project_id: str + region: str + rules: list[SecurityGroupRule] = [] + in_use: bool = False diff --git a/prowler/providers/stackit/services/objectstorage/__init__.py b/prowler/providers/stackit/services/objectstorage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json new file mode 100644 index 0000000000..38da16c5fa --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_access_key_expiration", + "CheckTitle": "ObjectStorage access keys should have an expiration date", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**ObjectStorage access keys** should have an explicit expiration date. Long-lived credentials increase the blast radius of a credential compromise because they cannot expire on their own. Setting an expiration date enforces periodic rotation and limits the exposure window if a key is leaked.", + "Risk": "If an **ObjectStorage access key** is leaked, stolen, or forgotten without an expiration date, it remains usable indefinitely. An attacker can retain persistent access to object storage resources until the key is manually revoked.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/create-and-delete-object-storage-credentials/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the STACKIT Portal navigate to Object Storage > Access Keys. 2. Delete the non-expiring access key. 3. Create a new access key with an expiration date appropriate for your rotation policy (e.g. 90 days). 4. Update all applications and services that use the old key with the new credentials.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage access keys** with an explicit expiration date and establish a rotation process. Delete non-expiring keys and replace them with time-limited credentials. A rotation period of **90 days or less** is recommended.", + "Url": "https://hub.prowler.com/check/objectstorage_access_key_expiration" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Access keys are scoped to credentials groups. This check evaluates all access keys across all credentials groups in the project." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py new file mode 100644 index 0000000000..2fb49ac725 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_access_key_expiration(Check): + def execute(self): + findings = [] + for key in objectstorage_client.access_keys: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=key, + ) + report.resource_id = key.key_id + report.resource_name = key.display_name + report.location = key.region + + if key.has_expiration(): + report.status = "PASS" + report.status_extended = f"Access key {key.display_name} has an expiration date set ({key.expires})." + else: + report.status = "FAIL" + report.status_extended = f"Access key {key.display_name} has no expiration date and never rotates." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json new file mode 100644 index 0000000000..cd587c72da --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_object_lock_enabled", + "CheckTitle": "ObjectStorage buckets should have S3 Object Lock enabled", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**S3 Object Lock** prevents objects from being deleted or overwritten for a fixed period or indefinitely. Enabling it protects against accidental deletion and ransomware by enforcing a **write-once-read-many (WORM)** model. Object Lock can only be enabled when the bucket is created.", + "Risk": "Without **Object Lock**, objects can be deleted or overwritten at any time, increasing the risk of data loss from accidental deletion, malicious actors, or ransomware. Backups and compliance data are particularly vulnerable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-bucket/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Object Lock must be enabled at bucket creation time and cannot be enabled on an existing bucket. Create a new bucket with Object Lock enabled and migrate your data to it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage buckets** with S3 Object Lock enabled for workloads that require data immutability, compliance archiving, or ransomware protection. Object Lock cannot be retroactively enabled on existing buckets.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_object_lock_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Object Lock can only be activated at bucket creation. Buckets without Object Lock are not necessarily misconfigured — evaluate based on the sensitivity and compliance requirements of the stored data." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py new file mode 100644 index 0000000000..89a3c1d3fb --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_object_lock_enabled(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.object_lock_enabled: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has S3 Object Lock enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Bucket {bucket.name} does not have S3 Object Lock enabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json new file mode 100644 index 0000000000..44088e5889 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_retention_policy", + "CheckTitle": "ObjectStorage buckets should have a default retention policy configured", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "An **ObjectStorage default retention policy** automatically applies a minimum retention period to every object uploaded to the bucket, preventing deletion or overwriting before the period expires. Without it, objects can be removed immediately after upload, undermining compliance and data durability requirements.", + "Risk": "Buckets without a **default retention policy** offer no automatic protection against premature object deletion. Compliance data, audit logs, and backups may be deleted before their required retention period elapses.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-default-retention/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Use the STACKIT Object Storage API or Portal to set a default retention policy on the bucket. Choose COMPLIANCE mode for strict immutability or GOVERNANCE mode to allow privileged users to override the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **default retention policy** on every bucket that stores compliance-relevant or sensitive data. Choose `COMPLIANCE` mode for regulatory requirements and `GOVERNANCE` mode when administrative overrides are acceptable.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_retention_policy" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "objectstorage_bucket_object_lock_enabled" + ], + "Notes": "A default retention policy requires Object Lock to be enabled on the bucket. Buckets without Object Lock cannot have a retention policy." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py new file mode 100644 index 0000000000..a9dbc7ed2b --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_retention_policy(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.retention_days and bucket.retention_days > 0: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has a default retention policy of " + f"{bucket.retention_days} day(s) in {bucket.retention_mode} mode." + ) + else: + report.status = "FAIL" + report.status_extended = f"Bucket {bucket.name} does not have a default retention policy configured." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_client.py b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py new file mode 100644 index 0000000000..56cdbfd0bf --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + ObjectStorageService, +) + +objectstorage_client = ObjectStorageService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_service.py b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py new file mode 100644 index 0000000000..e37545ec24 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py @@ -0,0 +1,306 @@ +import json +from datetime import datetime, timezone +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr + + +class ObjectStorageService: + def __init__(self, provider: StackitProvider): + self.provider = provider + self.project_id = provider.identity.project_id + self.regional_clients = provider.generate_regional_clients("objectstorage") + + self.buckets: list[Bucket] = [] + self.access_keys: list[AccessKey] = [] + + self._fetch_all_regions() + + def _fetch_all_regions(self): + for region, client in self.regional_clients.items(): + try: + self._list_buckets(client, region) + self._list_access_keys(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no ObjectStorage " + f"presence in region {region}; skipping." + ) + continue + raise + + def _handle_api_call(self, api_function, *args, **kwargs): + try: + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + self.provider.handle_api_error(e) + raise + + def _list_buckets(self, client, region: str): + response = self._handle_api_call( + client.list_buckets, project_id=self.project_id, region=region + ) + + buckets_list = getattr(response, "buckets", None) or [] + if isinstance(response, dict): + buckets_list = response.get("buckets", []) + + for bucket_data in buckets_list: + try: + if hasattr(bucket_data, "name"): + name = bucket_data.name + object_lock_enabled = getattr( + bucket_data, "object_lock_enabled", False + ) + elif isinstance(bucket_data, dict): + name = bucket_data.get("name", "") + object_lock_enabled = bucket_data.get("objectLockEnabled", False) + else: + continue + except Exception as e: + logger.error(f"Error processing bucket: {e}") + continue + + retention_days, retention_mode = self._get_default_retention( + client, region, name + ) + + self.buckets.append( + Bucket( + name=name, + region=region, + project_id=self.project_id, + object_lock_enabled=object_lock_enabled, + retention_days=retention_days, + retention_mode=retention_mode, + ) + ) + + logger.info(f"Listed {len(buckets_list)} buckets in {region}") + + def _get_default_retention( + self, client, region: str, bucket_name: str + ) -> tuple[Optional[int], Optional[str]]: + try: + response = self._handle_api_call( + client.get_default_retention, + project_id=self.project_id, + region=region, + bucket_name=bucket_name, + ) + days = getattr(response, "days", None) + mode = getattr(response, "mode", None) + if isinstance(response, dict): + days = response.get("days") + mode = response.get("mode") + return days, str(mode) if mode else None + except Exception as e: + if getattr(e, "status", None) == 404: + return None, None + raise + + def _list_access_keys(self, client, region: str): + credentials_groups_response = self._handle_api_call( + client.list_credentials_groups, project_id=self.project_id, region=region + ) + + credentials_groups = ( + getattr(credentials_groups_response, "credentials_groups", None) or [] + ) + if isinstance(credentials_groups_response, dict): + credentials_groups = credentials_groups_response.get( + "credentialsGroups", + credentials_groups_response.get("credentials_groups", []), + ) + + total_keys = 0 + + for credentials_group_data in credentials_groups: + try: + if isinstance(credentials_group_data, dict): + credentials_group_id = credentials_group_data.get( + "id", + credentials_group_data.get( + "groupId", + credentials_group_data.get("credentialsGroupId", ""), + ), + ) + credentials_group_name = credentials_group_data.get( + "displayName", + credentials_group_data.get("name", credentials_group_id), + ) + else: + credentials_group_id = ( + getattr(credentials_group_data, "id", None) + or getattr(credentials_group_data, "group_id", None) + or getattr(credentials_group_data, "credentials_group_id", "") + ) + credentials_group_name = getattr( + credentials_group_data, + "display_name", + getattr(credentials_group_data, "name", credentials_group_id), + ) + except Exception as e: + logger.error(f"Error processing credentials group: {e}") + continue + + if not credentials_group_id: + continue + + response = self._list_access_keys_response( + client, region, credentials_group_id + ) + keys_list = self._extract_access_keys(response) + + for key_data in keys_list: + try: + if hasattr(key_data, "key_id"): + key_id = key_data.key_id + display_name = getattr(key_data, "display_name", key_id) + expires = getattr(key_data, "expires", None) + elif isinstance(key_data, dict): + key_id = key_data.get("keyId", key_data.get("key_id", "")) + display_name = key_data.get( + "displayName", key_data.get("display_name", key_id) + ) + expires = key_data.get("expires") + else: + continue + + if not key_id: + continue + + self.access_keys.append( + AccessKey( + key_id=key_id, + display_name=display_name, + expires=expires, + region=region, + project_id=self.project_id, + credentials_group_id=credentials_group_id, + credentials_group_name=credentials_group_name, + ) + ) + except Exception as e: + logger.error(f"Error processing access key: {e}") + continue + + total_keys += len(keys_list) + + logger.info(f"Listed {total_keys} access keys in {region}") + + def _list_access_keys_response( + self, client, region: str, credentials_group_id: str + ): + raw_method = None + if callable( + getattr(type(client), "list_access_keys_without_preload_content", None) + ): + raw_method = client.list_access_keys_without_preload_content + elif callable(vars(client).get("list_access_keys_without_preload_content")): + raw_method = vars(client)["list_access_keys_without_preload_content"] + + if raw_method: + response = self._handle_api_call( + raw_method, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + self._raise_for_raw_response_status(response) + return response + + return self._handle_api_call( + client.list_access_keys, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + + def _raise_for_raw_response_status(self, response): + status = getattr(response, "status", None) + if status is None: + status = getattr(response, "status_code", None) + if isinstance(status, int) and status >= 400: + error = Exception( + f"StackIT ObjectStorage list_access_keys failed with status {status}" + ) + error.status = status + self.provider.handle_api_error(error) + raise error + + @staticmethod + def _extract_access_keys(response) -> list: + payload = response + if not isinstance(payload, (dict, list)): + json_method = getattr(response, "json", None) + if callable(json_method): + payload = json_method() + elif hasattr(response, "data"): + payload = ObjectStorageService._parse_raw_json(response.data) + elif hasattr(response, "text"): + payload = ObjectStorageService._parse_raw_json(response.text) + + if isinstance(payload, dict): + return payload.get("accessKeys", payload.get("access_keys", [])) + if isinstance(payload, list): + return payload + return getattr(response, "access_keys", None) or [] + + @staticmethod + def _parse_raw_json(raw): + if raw in (None, b"", ""): + return {} + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + if isinstance(raw, str): + return json.loads(raw) + return raw + + +class Bucket(BaseModel): + name: str + region: str + project_id: str + object_lock_enabled: bool = False + retention_days: Optional[int] = None + retention_mode: Optional[str] = None + + +class AccessKey(BaseModel): + key_id: str + display_name: str + # None or a sentinel year-0001 date string means the key never expires. + expires: Optional[str] = None + region: str + project_id: str + credentials_group_id: Optional[str] = None + credentials_group_name: Optional[str] = None + + def has_expiration(self) -> bool: + """Return True if the key has a real (non-sentinel) expiration date.""" + if not self.expires: + return False + try: + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + # Year 0001 (or earlier) is the SDK sentinel for "never expires" + return dt.year > 1 + except (ValueError, AttributeError): + return False + + def expires_within_days(self, days: int) -> bool: + """Return True if the key expires within the given number of days from now.""" + if not self.has_expiration(): + return False + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = dt - datetime.now(tz=timezone.utc) + return delta.days <= days diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py new file mode 100644 index 0000000000..4c34afd31b --- /dev/null +++ b/prowler/providers/stackit/stackit_provider.py @@ -0,0 +1,624 @@ +import contextlib +import io +import os +import pathlib +from typing import Optional +from uuid import UUID + +from colorama import Style + +# The StackIT SDK is a hard dependency of the provider (declared in +# pyproject.toml). Import it at module level, like every other Prowler +# provider, so a missing SDK fails immediately when the provider module is +# imported and is reported by Provider.init_global_provider as a critical +# error and a non-zero exit, instead of being swallowed later by the check +# loader and surfacing as a misleading empty report. +from stackit.core.configuration import Configuration +from stackit.iaas import DefaultApi as IaasDefaultApi +from stackit.objectstorage import DefaultApi as ObjectStorageDefaultApi +from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.exceptions.exceptions import ( + StackITAPIError, + StackITInvalidProjectIdError, + StackITInvalidTokenError, + StackITNonExistentTokenError, + StackITSetUpIdentityError, + StackITSetUpSessionError, +) +from prowler.providers.stackit.lib.mutelist.mutelist import StackITMutelist +from prowler.providers.stackit.models import StackITIdentityInfo + +STACKIT_REGIONS_JSON_FILE = "stackit_regions_by_service.json" + + +@contextlib.contextmanager +def suppress_stderr(): + with contextlib.redirect_stderr(io.StringIO()): + yield + + +class StackitProvider(Provider): + """ + StackIT Provider class to handle the StackIT provider + + Attributes: + - _type: str -> The type of the provider, which is set to "stackit". + - _project_id: str -> The StackIT project ID to audit. + - _service_account_key_path: str -> Path to a StackIT service account key + JSON file. The SDK mints and refreshes access tokens internally. + - _service_account_key: str -> Inline JSON content of a StackIT service + account key, for secret-manager driven deployments that do not write + the key to disk. Takes precedence over the key path when both are set. + - _identity: StackITIdentityInfo -> The identity information for the StackIT provider. + - _audit_config: dict -> The audit configuration for the StackIT provider. + - _mutelist: StackITMutelist -> The mutelist object associated with the StackIT provider. + - audit_metadata: Audit_Metadata -> The audit metadata for the StackIT provider. + + Methods: + - __init__: Initializes the StackIT provider. + - type: Returns the type of the StackIT provider. + - identity: Returns the identity of the StackIT provider (ex: project_id). + - session: Returns the session/configuration for API calls. + - audit_config: Returns the audit configuration for the StackIT provider. + - fixer_config: Returns the fixer configuration. + - mutelist: Returns the mutelist object associated with the StackIT provider. + - validate_arguments: Validates the StackIT provider arguments (key path, project_id). + - print_credentials: Prints the StackIT credentials information (ex: project_id). + - setup_session: Set up the StackIT session with the specified authentication method. + - test_connection: Tests the provider connection. + """ + + _type: str = "stackit" + _project_id: Optional[str] + _service_account_key_path: Optional[str] + _service_account_key: Optional[str] + _session: Optional[dict] + _identity: StackITIdentityInfo + _audit_config: dict + _mutelist: StackITMutelist + _scan_unused_services: bool = False + audit_metadata: Audit_Metadata + + def __init__( + self, + project_id: str = None, + service_account_key_path: str = None, + service_account_key: str = None, + regions: set = None, + scan_unused_services: bool = False, + config_path: str = None, + fixer_config: dict = None, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + """ + Initializes the StackIT provider. + + Args: + - project_id: The StackIT project ID to audit. + - service_account_key_path: Path to a StackIT service account key + JSON file. The SDK mints and refreshes access tokens internally + from this key. Read from ``STACKIT_SERVICE_ACCOUNT_KEY_PATH`` + when not provided. + - service_account_key: Inline JSON content of a StackIT service + account key, intended for CI/CD where the secret is fetched + from a secret manager and not persisted to disk. Read from + ``STACKIT_SERVICE_ACCOUNT_KEY`` when not provided. Takes + precedence over ``service_account_key_path`` when both are set. + - regions: The list of regions to audit. + - config_path: The path to the configuration file. + - fixer_config: The fixer configuration. + - mutelist_path: The path to the mutelist file. + - mutelist_content: The mutelist content. + """ + logger.info("Initializing StackIT Provider...") + + # 1) Store argument values + self._project_id = project_id or os.getenv("STACKIT_PROJECT_ID") + self._service_account_key_path = service_account_key_path or os.getenv( + "STACKIT_SERVICE_ACCOUNT_KEY_PATH" + ) + self._service_account_key = service_account_key or os.getenv( + "STACKIT_SERVICE_ACCOUNT_KEY" + ) + self._audited_regions = regions if regions else self.get_regions() + self._scan_unused_services = scan_unused_services + + # 2) Validate credentials format (following Azure's validation pattern) + try: + self.validate_arguments( + self._project_id, + self._service_account_key_path, + self._service_account_key, + ) + except StackITNonExistentTokenError: + logger.critical( + "StackIT service account credentials are required. Provide the " + "key file path via --stackit-service-account-key-path / " + "STACKIT_SERVICE_ACCOUNT_KEY_PATH, or the key content via " + "--stackit-service-account-key / STACKIT_SERVICE_ACCOUNT_KEY." + ) + raise + except StackITInvalidProjectIdError: + logger.critical( + "StackIT project ID must be a valid UUID. Provide it via --stackit-project-id or STACKIT_PROJECT_ID environment variable." + ) + raise + + # 3) Load audit_config, fixer_config, mutelist + self._fixer_config = fixer_config if fixer_config else {} + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + if mutelist_content: + self._mutelist = StackITMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self._type) + self._mutelist = StackITMutelist(mutelist_path=mutelist_path) + + # 4) Initialize session configuration + self._session = None + try: + self.setup_session() + except Exception as e: + logger.critical(f"Error setting up StackIT session: {e}") + raise StackITSetUpSessionError( + original_exception=e, + message=f"Failed to set up StackIT session: {str(e)}", + ) + + # 5) Create StackITIdentityInfo object and fetch project name + try: + project_name = self._get_project_name() + self._identity = StackITIdentityInfo( + project_id=self._project_id, + project_name=project_name, + audited_regions=self._audited_regions, + ) + except StackITInvalidTokenError: + # Re-raise authentication errors without wrapping to avoid verbose output + raise + except Exception as e: + logger.critical(f"Error setting up StackIT identity: {e}") + raise StackITSetUpIdentityError( + original_exception=e, + message=f"Failed to set up StackIT identity: {str(e)}", + ) + + # 6) Register as global provider + Provider.set_global_provider(self) + + @staticmethod + def read_stackit_regions_file() -> dict: + """Read the STACKIT regions JSON file.""" + actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + with open_file(f"{actual_directory}/{STACKIT_REGIONS_JSON_FILE}") as f: + return parse_json_file(f) + + @staticmethod + def get_regions() -> set: + """Get all available STACKIT regions from the JSON file.""" + regions = set() + data = StackitProvider.read_stackit_regions_file() + for service in data["services"].values(): + regions.update(service["regions"]) + return regions + + @staticmethod + def get_available_service_regions(service: str, audited_regions: set = None) -> set: + """Get available regions for a specific service, filtered by audited_regions.""" + data = StackitProvider.read_stackit_regions_file() + json_regions = set(data["services"].get(service, {}).get("regions", [])) + if audited_regions: + return json_regions.intersection(audited_regions) + return json_regions + + _SERVICE_API_CLASS = { + "iaas": IaasDefaultApi, + "objectstorage": ObjectStorageDefaultApi, + } + + def generate_regional_clients(self, service: str = "iaas") -> dict: + """Generate regional API clients for the given service. + + Returns dict: {"eu01": DefaultApi_client, "eu02": DefaultApi_client} + """ + api_class = self._SERVICE_API_CLASS.get(service, IaasDefaultApi) + regional_clients = {} + service_regions = self.get_available_service_regions( + service, self._audited_regions + ) + + for region in service_regions: + with suppress_stderr(): + config = self._build_sdk_configuration( + self._service_account_key_path, + self._service_account_key, + ) + client = api_class(config) + client.region = region # Attach region attribute + regional_clients[region] = client + + return regional_clients + + @staticmethod + def _build_sdk_configuration( + service_account_key_path: str, service_account_key: str = None + ): + """Build a ``stackit.core.configuration.Configuration`` from the + configured service account credentials. + + Prefer the inlined key content over the path so secret-manager + deployments (where the path may also be set as a default) work + without writing the secret to disk. In both cases the SDK signs + the RSA challenge and refreshes access tokens internally for the + life of the scan. + + Kept as a static helper so ``test_connection`` (which has no provider + instance) can reuse it. + """ + if service_account_key: + return Configuration(service_account_key=service_account_key) + return Configuration(service_account_key_path=service_account_key_path) + + @property + def type(self) -> str: + """ + Returns the type of the provider ("stackit"). + """ + return self._type + + @property + def identity(self) -> StackITIdentityInfo: + """ + Returns the StackITIdentityInfo object, which contains project_id, etc. + """ + return self._identity + + @property + def session(self) -> dict: + """ + Returns the session configuration for StackIT API calls. + This includes the API token and project ID needed for SDK initialization. + """ + return self._session + + @property + def audit_config(self) -> dict: + """ + Returns the audit configuration loaded from file or default settings. + """ + return self._audit_config + + @property + def fixer_config(self) -> dict: + """ + Returns any fixer configuration provided to the StackIT provider. + """ + return self._fixer_config + + @property + def scan_unused_services(self) -> bool: + return self._scan_unused_services + + @property + def mutelist(self) -> StackITMutelist: + """ + Returns the StackITMutelist object for handling any muted checks. + """ + return self._mutelist + + @staticmethod + def validate_arguments( + project_id: str, + service_account_key_path: str, + service_account_key: str = None, + ) -> None: + """ + Validates StackIT static arguments format before use. + + Either the service account key file path or the inline key content + must be supplied, and the project ID is always required and must be + a valid UUID. This mirrors Azure's pattern of failing fast on input + format issues before making any API calls. + + Args: + project_id: The StackIT project ID (must be valid UUID format) + service_account_key_path: Path to a service account key JSON file + (optional when ``service_account_key`` is provided) + service_account_key: Inline JSON content of a service account key + (optional when ``service_account_key_path`` is provided) + + Raises: + StackITNonExistentTokenError: If both ``service_account_key_path`` + and ``service_account_key`` are missing or empty + StackITInvalidProjectIdError: If ``project_id`` is missing or not a + valid UUID + """ + has_path = bool(service_account_key_path and service_account_key_path.strip()) + has_key = bool(service_account_key and service_account_key.strip()) + if not has_path and not has_key: + raise StackITNonExistentTokenError( + message=( + "StackIT service account credentials are required: provide " + "the key file path (--stackit-service-account-key-path or " + "STACKIT_SERVICE_ACCOUNT_KEY_PATH) or the inline key content " + "(--stackit-service-account-key or STACKIT_SERVICE_ACCOUNT_KEY)" + ) + ) + + # Validate project_id is not empty + if not project_id or not project_id.strip(): + raise StackITInvalidProjectIdError( + message="StackIT project ID is required for auditing" + ) + + # Validate project_id is a valid UUID format + # StackIT uses UUIDs for project IDs, similar to Azure subscription IDs + try: + UUID(project_id) + except ValueError as e: + raise StackITInvalidProjectIdError( + original_exception=e, + message=f"StackIT project ID must be a valid UUID format, got: {project_id}", + ) + + @property + def auth_method(self) -> str: + """Auth method label used for findings and credentials box. + + StackIT authenticates with a service account key; the SDK signs + the RSA challenge and refreshes access tokens internally. + """ + return "service_account_key" + + def print_credentials(self) -> None: + """ + Prints the StackIT credentials in a simple box format. + """ + # Build credential lines + lines = [] + if self._identity.project_name: + lines.append(f" Project Name: {self._identity.project_name}") + lines.append(f" Project ID: {self._project_id}") + if self._service_account_key: + lines.append(" Service Account Key: ***REDACTED*** (inline)") + else: + lines.append(f" Service Account Key: {self._service_account_key_path}") + lines.append(" Auth Method: service account key (auto-refresh)") + + report_lines = ["\n".join(lines)] + + report_title = ( + f"{Style.BRIGHT}Using the StackIT credentials below:{Style.RESET_ALL}" + ) + print_boxes(report_lines, report_title) + + def setup_session(self) -> None: + """ + Set up the StackIT session configuration. + + This creates a session dictionary containing the credentials + used by service clients to build SDK ``Configuration`` objects. + """ + try: + self._session = { + "project_id": self._project_id, + "service_account_key_path": self._service_account_key_path, + "service_account_key": self._service_account_key, + } + logger.info("StackIT session configuration set up successfully.") + except Exception as e: + logger.critical(f"Error in setup_session: {e}") + raise e + + def _get_project_name(self) -> str: + """ + Fetch the project name from the StackIT Resource Manager API. + + The project name is cosmetic (shown in the credentials box and in + the ``account_name`` field of findings); a missing or unauthorized + Resource Manager endpoint must not abort an otherwise valid IaaS + scan. A service account can legitimately hold IaaS roles on a + project without holding Resource Manager roles on it. + + Failure semantics: + - HTTP 401 -> hard failure via :class:`StackITInvalidTokenError` + (the credentials cannot mint a usable token). + - HTTP 403 (or any other non-401 error) -> warning, returns + an empty project name; the scan continues and per-service + ``handle_api_error`` will abort later if the IaaS endpoints + are also forbidden. + + Returns: + str: The project name, or empty string if unavailable. + + Raises: + StackITInvalidTokenError: If the credentials are rejected with a + 401 response when contacting Resource Manager. + """ + try: + with suppress_stderr(): + config = self._build_sdk_configuration( + self._service_account_key_path, + self._service_account_key, + ) + client = ResourceManagerDefaultApi(config) + + # Fetch project details - validates that the credentials + # can mint a token; permission to read this specific + # project is checked but optional. + response = client.get_project(id=self._project_id) + + # Extract project name from response + if hasattr(response, "name"): + project_name = response.name + elif isinstance(response, dict): + project_name = response.get("name", "") + else: + project_name = "" + + logger.info(f"Successfully retrieved project name: {project_name}") + return project_name + + except Exception as e: + status = getattr(e, "status", None) + if status == 401: + # Bad credentials are a hard failure even at the cosmetic + # Resource Manager lookup; keep the same behaviour as + # ``handle_api_error`` so the user sees the same message. + logger.critical( + "StackIT service account key was rejected by Resource " + "Manager (401). Verify the key file is current and has " + "not been revoked in the StackIT portal." + ) + raise StackITInvalidTokenError( + file="stackit_provider.py", + original_exception=None, + message="StackIT service account key was rejected (401)", + ) + if status == 403: + logger.warning( + f"StackIT service account lacks the Resource Manager " + f"role on project {self._project_id} (403). The project " + f"name will not be displayed in reports, but IaaS " + f"checks will still run if the service account has the " + f"relevant IaaS role." + ) + return "" + logger.warning( + f"Unable to fetch project name from StackIT API: {e}. " + f"Project name will not be displayed in reports." + ) + return "" + + @staticmethod + def handle_api_error(exception: Exception) -> None: + """ + Centralized handler for StackIT API errors across all services. + + Detects credential and permission errors (HTTP 401 and 403) and raises + ``StackITInvalidTokenError`` so the scan aborts instead of continuing + with partial data. All other exceptions are re-raised unchanged so + callers can decide how to handle them (e.g. per-resource ``continue``). + + Args: + exception: The exception caught from a StackIT API call + + Raises: + StackITInvalidTokenError: If the error is a 401 Unauthorized or + a 403 Forbidden response + Exception: Re-raises the original exception otherwise + """ + status = getattr(exception, "status", None) + if status == 401: + logger.critical( + "StackIT service account key was rejected. Verify the key " + "file referenced by STACKIT_SERVICE_ACCOUNT_KEY_PATH is the " + "current one and has not been revoked in the StackIT portal." + ) + raise StackITInvalidTokenError( + file="stackit_provider.py", + original_exception=None, # Don't include verbose HTTP details + message="StackIT service account key was rejected (401)", + ) + if status == 403: + logger.critical( + "StackIT service account lacks the required permissions on this project. " + "Ensure the service account has the necessary IAM roles." + ) + raise StackITInvalidTokenError( + file="stackit_provider.py", + original_exception=None, # Don't include verbose HTTP details + message="Service account lacks required permissions on this project", + ) + # Re-raise other exceptions unchanged + raise exception + + @staticmethod + def test_connection( + project_id: str = None, + service_account_key_path: str = None, + service_account_key: str = None, + raise_on_exception: bool = True, + ) -> Connection: + """ + Test connection to StackIT by validating credentials. + + This method validates the service account credentials and project ID + by making a Resource Manager ``get_project`` call. Pass either the + key file path or the inline key content; the SDK signs the RSA + challenge and mints a short-lived access token internally. + + Args: + project_id (str): StackIT project ID + service_account_key_path (str): Path to a StackIT service account + key JSON file (optional when ``service_account_key`` is given) + service_account_key (str): Inline JSON content of a StackIT + service account key (optional when + ``service_account_key_path`` is given) + raise_on_exception (bool): If True, raise the caught exception; + if False, return Connection(error=exception). + + Returns: + Connection: + Connection(is_connected=True) if success, + otherwise Connection(error=Exception or custom error). + """ + try: + StackitProvider.validate_arguments( + project_id, service_account_key_path, service_account_key + ) + + with suppress_stderr(): + config = StackitProvider._build_sdk_configuration( + service_account_key_path, + service_account_key, + ) + client = ResourceManagerDefaultApi(config) + client.get_project(id=project_id) + + logger.info( + "StackIT test_connection: Successfully connected using StackIT Resource Manager." + ) + return Connection(is_connected=True) + except (StackITNonExistentTokenError, StackITInvalidProjectIdError) as error: + logger.error(f"StackIT test_connection error: {error}") + if raise_on_exception: + raise error + return Connection(error=error) + except Exception as test_error: + try: + StackitProvider.handle_api_error(test_error) + except StackITInvalidTokenError as auth_error: + if raise_on_exception: + raise auth_error + return Connection(error=auth_error) + except Exception as api_error: + error_msg = ( + "Failed to connect to StackIT using Resource Manager: " + f"{str(api_error)}" + ) + logger.error(error_msg) + connection_error = StackITAPIError( + original_exception=api_error, message=error_msg + ) + if raise_on_exception: + raise connection_error + return Connection(error=connection_error) + + if raise_on_exception: + raise test_error + return Connection(error=test_error) diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json new file mode 100644 index 0000000000..5841d4e32d --- /dev/null +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -0,0 +1,16 @@ +{ + "services": { + "iaas": { + "regions": [ + "eu01", + "eu02" + ] + }, + "objectstorage": { + "regions": [ + "eu01", + "eu02" + ] + } + } +} diff --git a/prowler/providers/vercel/lib/billing.py b/prowler/providers/vercel/lib/billing.py new file mode 100644 index 0000000000..4e7170dbe6 --- /dev/null +++ b/prowler/providers/vercel/lib/billing.py @@ -0,0 +1,27 @@ +from typing import Optional + + +def extract_billing_plan(data: Optional[dict]) -> Optional[str]: + """Return the Vercel billing plan from a user or team payload. + + Vercel's REST API consistently returns the plan identifier at + ``data["billing"]["plan"]`` (e.g. ``"hobby"``, ``"pro"``, ``"enterprise"``) + on both ``GET /v2/user`` and ``GET /v2/teams`` responses, even though the + field is not part of the public OpenAPI schema. + """ + if not isinstance(data, dict): + return None + billing = data.get("billing") + if not isinstance(billing, dict): + return None + plan = billing.get("plan") + return plan.lower() if isinstance(plan, str) else None + + +def plan_reason_suffix( + billing_plan: Optional[str], unsupported_plans: set[str], explanation: str +) -> str: + """Return a plan-based explanation suffix only when the plan proves it.""" + if billing_plan in unsupported_plans: + return f" This may be expected because {explanation}" + return "" diff --git a/prowler/providers/vercel/lib/service/service.py b/prowler/providers/vercel/lib/service/service.py index aaf4d2625c..7fd9ced264 100644 --- a/prowler/providers/vercel/lib/service/service.py +++ b/prowler/providers/vercel/lib/service/service.py @@ -84,10 +84,10 @@ class VercelService: ) if response.status_code == 403: - # Plan limitation or permission error — return None for graceful handling - logger.warning( + # Endpoint unavailable for this token/scope; let checks handle it gracefully + logger.info( f"{self.service} - Access denied for {path} (403). " - "This may be a plan limitation." + "This may be caused by plan or permission restrictions." ) return None diff --git a/prowler/providers/vercel/models.py b/prowler/providers/vercel/models.py index 5f0e207f19..d83e043d1e 100644 --- a/prowler/providers/vercel/models.py +++ b/prowler/providers/vercel/models.py @@ -9,7 +9,7 @@ from prowler.providers.common.models import ProviderOutputOptions class VercelSession(BaseModel): """Vercel API session information.""" - token: str + token: str = Field(exclude=True, repr=False) team_id: Optional[str] = None base_url: str = "https://api.vercel.com" http_session: Any = Field(default=None, exclude=True) @@ -21,6 +21,7 @@ class VercelTeamInfo(BaseModel): id: str name: str slug: str + billing_plan: Optional[str] = None class VercelIdentityInfo(BaseModel): @@ -29,9 +30,27 @@ class VercelIdentityInfo(BaseModel): user_id: Optional[str] = None username: Optional[str] = None email: Optional[str] = None + billing_plan: Optional[str] = None team: Optional[VercelTeamInfo] = None teams: list[VercelTeamInfo] = Field(default_factory=list) + def get_billing_plan_for(self, scope_id: Optional[str]) -> Optional[str]: + """Return the billing plan for an explicit user or team scope.""" + if not scope_id: + return None + + if self.team and self.team.id == scope_id and self.team.billing_plan: + return self.team.billing_plan + + for team in self.teams: + if team.id == scope_id: + return team.billing_plan + + if self.user_id == scope_id: + return self.billing_plan + + return None + class VercelOutputOptions(ProviderOutputOptions): """Customize output filenames for Vercel scans.""" diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json index d94e12f0b9..d2292216f3 100644 --- a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json +++ b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Regularly audit API tokens and revoke any that have not been used within 90 days. Implement a token lifecycle management process that includes periodic reviews, automatic expiration dates, and documentation of each token's purpose and owner.", - "Url": "https://hub.prowler.com/checks/vercel/authentication_no_stale_tokens" + "Url": "https://hub.prowler.com/check/authentication_no_stale_tokens" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json index dca48e73bf..c3288d968d 100644 --- a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json +++ b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Remove expired tokens and create new ones with appropriate expiration dates. Implement a token rotation schedule to ensure tokens are refreshed before they expire. Update all integrations and automation that depend on the replaced tokens.", - "Url": "https://hub.prowler.com/checks/vercel/authentication_token_not_expired" + "Url": "https://hub.prowler.com/check/authentication_token_not_expired" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json index 8a7cc70d40..bb9b1a3b60 100644 --- a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json +++ b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Configure the production branch to main or master and ensure all production deployments go through the standard merge workflow. Use branch protection rules in your Git provider to prevent direct pushes to the production branch.", - "Url": "https://hub.prowler.com/checks/vercel/deployment_production_uses_stable_target" + "Url": "https://hub.prowler.com/check/deployment_production_uses_stable_target" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json index f9104ee960..28f151b2e6 100644 --- a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json +++ b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Update DNS records at your domain registrar to correctly point to Vercel. Use a CNAME record for subdomains or an A record for apex domains. Verify the configuration in the Vercel dashboard after making changes.", - "Url": "https://hub.prowler.com/checks/vercel/domain_dns_properly_configured" + "Url": "https://hub.prowler.com/check/domain_dns_properly_configured" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json index f15892df61..ae8d2750a8 100644 --- a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json +++ b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Ensure domain DNS records are properly configured to point to Vercel. Once DNS is validated, Vercel automatically provisions and renews SSL/TLS certificates. Check the domain configuration in the Vercel dashboard if the certificate is not being issued.", - "Url": "https://hub.prowler.com/checks/vercel/domain_ssl_certificate_valid" + "Url": "https://hub.prowler.com/check/domain_ssl_certificate_valid" } }, "Categories": [ - "encryption" + "encryption", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json index f520fb00d8..f5f1aace08 100644 --- a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json +++ b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Complete domain verification by configuring the required DNS records at your domain registrar. Remove any domains that are no longer needed to reduce the attack surface. Regularly audit domain configurations to ensure all domains remain verified.", - "Url": "https://hub.prowler.com/checks/vercel/domain_verified" + "Url": "https://hub.prowler.com/check/domain_verified" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json index bf7adc2832..36e128caae 100644 --- a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json +++ b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Disable automatic exposure of system environment variables and explicitly define only the variables required by your application. This follows the principle of least privilege and reduces the risk of leaking internal infrastructure details through client-side code.", - "Url": "https://hub.prowler.com/checks/vercel/project_auto_expose_system_env_disabled" + "Url": "https://hub.prowler.com/check/project_auto_expose_system_env_disabled" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json index c704b42784..55b5fc917c 100644 --- a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json +++ b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Enable deployment protection on preview deployments to require authentication before visitors can access preview URLs. Use 'Standard Protection' for Vercel Authentication or configure trusted IP ranges for more granular control.", - "Url": "https://hub.prowler.com/checks/vercel/project_deployment_protection_enabled" + "Url": "https://hub.prowler.com/check/project_deployment_protection_enabled" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json index b9de49aa0e..a2558ed667 100644 --- a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json +++ b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Disable directory listing to prevent visitors from browsing the file structure of your deployments. Ensure that all directories either contain an index file or return a 404 response when accessed directly.", - "Url": "https://hub.prowler.com/checks/vercel/project_directory_listing_disabled" + "Url": "https://hub.prowler.com/check/project_directory_listing_disabled" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json index 2467c1b104..5dc15b12aa 100644 --- a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json +++ b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Follow the **principle of least privilege** for environment variable targeting.\n- Assign each variable to only the environments where it is actually needed\n- Use different credentials for production, preview, and development environments\n- Non-sensitive configuration (e.g. feature flags, public URLs) may be acceptable in multiple environments but should still be reviewed\n- Regularly audit environment variable targets to prevent scope creep", - "Url": "https://hub.prowler.com/checks/vercel/project_environment_no_overly_broad_target" + "Url": "https://hub.prowler.com/check/project_environment_no_overly_broad_target" } }, "Categories": [ - "secrets" + "secrets", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json index f8d5621914..0e3e654f93 100644 --- a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json +++ b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Use the **Sensitive** type for all environment variables that contain secrets, keys, tokens, or passwords.\n- Sensitive variables are never exposed in the dashboard or API responses after creation\n- Rotate all credentials that were previously stored as plain text\n- Implement naming conventions that make it easy to identify secret variables", - "Url": "https://hub.prowler.com/checks/vercel/project_environment_no_secrets_in_plain_type" + "Url": "https://hub.prowler.com/check/project_environment_no_secrets_in_plain_type" } }, "Categories": [ - "secrets" + "secrets", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json index 55f468fa8a..5b99fe00d6 100644 --- a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json +++ b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Maintain strict **environment separation** between production and preview deployments.\n- Use dedicated, limited-scope credentials for preview environments\n- Never share production database credentials, API keys, or signing keys with preview builds\n- Enable Vercel's deployment protection features to further restrict access to preview deployments\n- Regularly audit which environment variables target multiple environments", - "Url": "https://hub.prowler.com/checks/vercel/project_environment_production_vars_not_in_preview" + "Url": "https://hub.prowler.com/check/project_environment_production_vars_not_in_preview" } }, "Categories": [ - "secrets" + "secrets", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json index 744a227d61..37bbc7b8c6 100644 --- a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json +++ b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Enable Git fork protection to require explicit authorization before pull requests from forked repositories can trigger deployments. This prevents untrusted contributors from accessing environment variables and secrets through the build process. For open-source projects, review fork PRs manually before allowing builds.", - "Url": "https://hub.prowler.com/checks/vercel/project_git_fork_protection_enabled" + "Url": "https://hub.prowler.com/check/project_git_fork_protection_enabled" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json index 58e3e46e41..edd12c67f0 100644 --- a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json +++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json @@ -24,15 +24,16 @@ }, "Recommendation": { "Text": "Enable password protection to add a shared-password gate to your deployments. This is especially recommended for preview deployments shared with external clients or stakeholders who do not have Vercel accounts. Combine with Vercel Authentication for defense-in-depth.", - "Url": "https://hub.prowler.com/checks/vercel/project_password_protection_enabled" + "Url": "https://hub.prowler.com/check/project_password_protection_enabled" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "project_deployment_protection_enabled" ], - "Notes": "" + "Notes": "Required billing plan: Enterprise, or as a paid add-on for Pro plans." } diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py index 86c0778408..e5313c6182 100644 --- a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py +++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.project.project_client import project_client @@ -38,6 +39,7 @@ class project_password_protection_enabled(Check): report.status_extended = ( f"Project {project.name} does not have password protection " f"configured for deployments." + f"{plan_reason_suffix(project.billing_plan, {'hobby'}, 'password protection is not available on the Vercel Hobby plan.')}" ) findings.append(report) diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json index 213bc51d07..20c1fac713 100644 --- a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json +++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json @@ -24,15 +24,16 @@ }, "Recommendation": { "Text": "Enable deployment protection on production deployments for applications that should not be publicly accessible. This is critical for internal tools, admin dashboards, and pre-launch applications where unauthorized access could lead to data exposure or system compromise.", - "Url": "https://hub.prowler.com/checks/vercel/project_production_deployment_protection_enabled" + "Url": "https://hub.prowler.com/check/project_production_deployment_protection_enabled" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "project_deployment_protection_enabled" ], - "Notes": "" + "Notes": "Protecting production deployments requires Enterprise, or Pro plans with supported paid deployment protection options." } diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py index ccbfd15325..bb0924266e 100644 --- a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py +++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.project.project_client import project_client @@ -38,6 +39,7 @@ class project_production_deployment_protection_enabled(Check): report.status_extended = ( f"Project {project.name} does not have deployment protection " f"enabled on production deployments." + f"{plan_reason_suffix(project.billing_plan, {'hobby'}, 'protecting production deployments is not available on the Vercel Hobby plan.')}" ) findings.append(report) diff --git a/prowler/providers/vercel/services/project/project_service.py b/prowler/providers/vercel/services/project/project_service.py index 9b3b24859d..6cc3a36418 100644 --- a/prowler/providers/vercel/services/project/project_service.py +++ b/prowler/providers/vercel/services/project/project_service.py @@ -20,6 +20,7 @@ class Project(VercelService): """List all projects, optionally filtered by --project argument.""" try: raw_projects = self._paginate("/v9/projects", "projects") + identity = getattr(self.provider, "identity", None) filter_projects = self.provider.filter_projects seen_ids: set[str] = set() @@ -55,11 +56,19 @@ class Project(VercelService): # Parse password protection pwd_protection = proj.get("passwordProtection") + security = proj.get("security", {}) or {} + + project_team_id = proj.get("accountId") or self.provider.session.team_id self.projects[project_id] = VercelProject( id=project_id, name=project_name, - team_id=proj.get("accountId") or self.provider.session.team_id, + team_id=project_team_id, + billing_plan=( + identity.get_billing_plan_for(project_team_id) + if identity + else None + ), framework=proj.get("framework"), node_version=proj.get("nodeVersion"), auto_expose_system_envs=proj.get("autoExposeSystemEnvs", False), @@ -75,6 +84,16 @@ class Project(VercelService): git_fork_protection=proj.get("gitForkProtection", True), git_repository=proj.get("link"), secure_compute=proj.get("secureCompute"), + firewall_enabled=security.get("firewallEnabled"), + firewall_config_version=( + str(security.get("firewallConfigVersion")) + if security.get("firewallConfigVersion") is not None + else None + ), + managed_rules=security.get( + "managedRules", security.get("managedRulesets") + ), + bot_id_enabled=security.get("botIdEnabled"), ) logger.info(f"Project - Found {len(self.projects)} project(s)") @@ -149,6 +168,7 @@ class VercelProject(BaseModel): id: str name: str team_id: Optional[str] = None + billing_plan: Optional[str] = None framework: Optional[str] = None node_version: Optional[str] = None auto_expose_system_envs: bool = False @@ -160,4 +180,8 @@ class VercelProject(BaseModel): git_fork_protection: bool = True git_repository: Optional[dict] = None secure_compute: Optional[dict] = None + firewall_enabled: Optional[bool] = None + firewall_config_version: Optional[str] = None + managed_rules: Optional[dict] = None + bot_id_enabled: Optional[bool] = None environment_variables: list[VercelEnvironmentVariable] = Field(default_factory=list) diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json index b73aaa6991..01e38c03c4 100644 --- a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json +++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json @@ -24,13 +24,14 @@ }, "Recommendation": { "Text": "Enable skew protection to ensure that all client requests during a deployment rollout are routed to the same deployment version that served the initial page. This prevents version mismatch errors and ensures a consistent user experience during deployments.", - "Url": "https://hub.prowler.com/checks/vercel/project_skew_protection_enabled" + "Url": "https://hub.prowler.com/check/project_skew_protection_enabled" } }, "Categories": [ - "resilience" + "resilience", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "Required billing plan: Pro or Enterprise." } diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py index f4e878ba5c..3f7a2d0ddb 100644 --- a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py +++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.project.project_client import project_client @@ -34,6 +35,7 @@ class project_skew_protection_enabled(Check): report.status_extended = ( f"Project {project.name} does not have skew protection enabled, " f"which may cause version mismatches during deployments." + f"{plan_reason_suffix(project.billing_plan, {'hobby'}, 'skew protection is not available on the Vercel Hobby plan.')}" ) findings.append(report) diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json index c3f986b173..615f40843a 100644 --- a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json +++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json @@ -24,15 +24,16 @@ }, "Recommendation": { "Text": "Configure custom firewall rules to protect application-specific endpoints and enforce security policies. Focus on protecting admin panels, API routes, authentication endpoints, and any paths that handle sensitive data.", - "Url": "https://hub.prowler.com/checks/vercel/security_custom_rules_configured" + "Url": "https://hub.prowler.com/check/security_custom_rules_configured" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "security_waf_enabled" ], - "Notes": "" + "Notes": "Required billing plan: Pro or Enterprise." } diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py index 6ec7f8834f..a525c93f0a 100644 --- a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py +++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.security.security_client import security_client @@ -24,7 +25,16 @@ class security_custom_rules_configured(Check): for config in security_client.firewall_configs.values(): report = CheckReportVercel(metadata=self.metadata(), resource=config) - if config.custom_rules: + if not config.firewall_config_accessible: + report.status = "MANUAL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"could not be assessed for custom firewall rules because the " + f"firewall configuration endpoint was not accessible. " + f"Manual verification is required." + f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'custom firewall rules are not available on the Vercel Hobby plan.')}" + ) + elif config.custom_rules: report.status = "PASS" report.status_extended = ( f"Project {config.project_name} ({config.project_id}) " diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json index cc7712d4ec..88c609e742 100644 --- a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json +++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json @@ -24,15 +24,16 @@ }, "Recommendation": { "Text": "Configure IP blocking rules to deny traffic from known malicious sources. Maintain a blocklist of IPs identified through security monitoring, threat intelligence feeds, or incident investigation. Regularly review and update the blocklist.", - "Url": "https://hub.prowler.com/checks/vercel/security_ip_blocking_rules_configured" + "Url": "https://hub.prowler.com/check/security_ip_blocking_rules_configured" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "security_waf_enabled" ], - "Notes": "" + "Notes": "Required billing plan: Pro or Enterprise." } diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py index 0b89c8d111..443c052354 100644 --- a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py +++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.security.security_client import security_client @@ -25,7 +26,16 @@ class security_ip_blocking_rules_configured(Check): for config in security_client.firewall_configs.values(): report = CheckReportVercel(metadata=self.metadata(), resource=config) - if config.ip_blocking_rules: + if not config.firewall_config_accessible: + report.status = "MANUAL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"could not be assessed for IP blocking rules because the " + f"firewall configuration endpoint was not accessible. " + f"Manual verification is required." + f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'IP blocking rules are not available on the Vercel Hobby plan.')}" + ) + elif config.ip_blocking_rules: report.status = "PASS" report.status_extended = ( f"Project {config.project_name} ({config.project_id}) " diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json index 95d7db6d20..cbd956cd52 100644 --- a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json +++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "**Vercel projects** are assessed for **managed WAF ruleset** enablement. Managed rulesets are curated by Vercel and provide protection against known attack patterns including **OWASP Top 10** threats. This feature requires an Enterprise plan and reports MANUAL status when unavailable.", + "Description": "**Vercel projects** are assessed for **managed WAF ruleset** enablement. Managed rulesets are curated by Vercel and provide protection against known attack patterns including **OWASP Top 10** threats. Availability varies by ruleset, and the check reports MANUAL when the firewall configuration cannot be assessed from the API.", "Risk": "Without **managed rulesets** enabled, the firewall lacks curated protection rules against well-known attack patterns. The application relies solely on custom rules, which may miss **new or evolving threats** that managed rulesets are designed to detect and block automatically.", "RelatedUrl": "", "AdditionalURLs": [ @@ -19,20 +19,21 @@ "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Enable managed rulesets from the available options\n4. Review and configure ruleset sensitivity levels\n5. Note: This feature requires an Enterprise plan", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Enable the managed rulesets that are available for your plan\n4. Review and configure ruleset sensitivity levels\n5. If the API does not expose firewall configuration for the project, verify the rulesets manually in the dashboard", "Terraform": "" }, "Recommendation": { - "Text": "Enable managed WAF rulesets to benefit from Vercel-curated protection against common attack patterns. If you are on a plan that does not support managed rulesets, consider upgrading to the Enterprise plan for enhanced security features.", - "Url": "https://hub.prowler.com/checks/vercel/security_managed_rulesets_enabled" + "Text": "Enable the managed WAF rulesets that are available for your Vercel plan to benefit from curated protection against common attack patterns. If the API does not expose firewall configuration for the project, verify the rulesets manually in the dashboard.", + "Url": "https://hub.prowler.com/check/security_managed_rulesets_enabled" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [ "security_waf_enabled" ], - "Notes": "This check is plan-gated. If the Vercel API returns a 403 for managed rulesets, the check reports MANUAL status indicating that an Enterprise plan is required." + "Notes": "Managed ruleset availability varies by ruleset. OWASP Core Ruleset requires Enterprise, while Bot Protection and AI Bots managed rulesets are available on all plans." } diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py index ead4623f4c..f7f476ccad 100644 --- a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py +++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.security.security_client import security_client @@ -17,8 +18,8 @@ class security_managed_rulesets_enabled(Check): """Execute the Vercel Managed Rulesets Enabled check. Iterates over all firewall configurations and checks if managed - rulesets are enabled. Reports MANUAL status when the feature is - not available due to plan limitations. + rulesets are enabled. Reports MANUAL status when the firewall + configuration cannot be assessed from the API. Returns: List[CheckReportVercel]: A list of reports for each project. @@ -27,12 +28,14 @@ class security_managed_rulesets_enabled(Check): for config in security_client.firewall_configs.values(): report = CheckReportVercel(metadata=self.metadata(), resource=config) - if config.managed_rulesets is None: + if not config.firewall_config_accessible: report.status = "MANUAL" report.status_extended = ( f"Project {config.project_name} ({config.project_id}) " - f"could not be assessed for managed rulesets. " - f"Enterprise plan required to access this feature." + f"could not be assessed for managed rulesets because the " + f"firewall configuration endpoint was not accessible. " + f"Manual verification is required." + f"{plan_reason_suffix(config.billing_plan, {'hobby', 'pro'}, 'some managed WAF rulesets, including the OWASP Core Ruleset, are only available on Vercel Enterprise plans.')}" ) elif config.managed_rulesets: report.status = "PASS" diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json index 28a9c44d5f..637502ab6f 100644 --- a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json +++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json @@ -24,15 +24,16 @@ }, "Recommendation": { "Text": "Configure rate limiting rules to protect critical endpoints such as authentication, API routes, and form submissions. Start with conservative thresholds and adjust based on traffic patterns to avoid blocking legitimate users.", - "Url": "https://hub.prowler.com/checks/vercel/security_rate_limiting_configured" + "Url": "https://hub.prowler.com/check/security_rate_limiting_configured" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "security_waf_enabled" ], - "Notes": "" + "Notes": "Required billing plan: Pro or Enterprise." } diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py index 3e6419666c..6e37fd2779 100644 --- a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py +++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.security.security_client import security_client @@ -24,7 +25,16 @@ class security_rate_limiting_configured(Check): for config in security_client.firewall_configs.values(): report = CheckReportVercel(metadata=self.metadata(), resource=config) - if config.rate_limiting_rules: + if not config.firewall_config_accessible: + report.status = "MANUAL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"could not be assessed for rate limiting rules because the " + f"firewall configuration endpoint was not accessible. " + f"Manual verification is required." + f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'rate limiting rules are not available on the Vercel Hobby plan.')}" + ) + elif config.rate_limiting_rules: report.status = "PASS" report.status_extended = ( f"Project {config.project_name} ({config.project_id}) " diff --git a/prowler/providers/vercel/services/security/security_service.py b/prowler/providers/vercel/services/security/security_service.py index eadac856b9..b472dcc811 100644 --- a/prowler/providers/vercel/services/security/security_service.py +++ b/prowler/providers/vercel/services/security/security_service.py @@ -26,17 +26,16 @@ class Security(VercelService): def _fetch_firewall_config(self, project): """Fetch WAF/Firewall config for a single project.""" try: - data = self._get( - "/v1/security/firewall/config", - params={"projectId": project.id}, - ) + data = self._read_firewall_config(project) if data is None: - # 403 — plan limitation, store with managed_rulesets=None + # Firewall config endpoint unavailable for this project/token self.firewall_configs[project.id] = VercelFirewallConfig( project_id=project.id, project_name=project.name, team_id=project.team_id, + billing_plan=project.billing_plan, + firewall_config_accessible=False, firewall_enabled=False, managed_rulesets=None, name=project.name, @@ -44,39 +43,64 @@ class Security(VercelService): ) return - # Parse firewall config - fw = data.get("firewallConfig", data) if isinstance(data, dict) else {} + fw = self._normalize_firewall_config(data) - # Determine if firewall is enabled - rules = fw.get("rules", []) or [] - managed = fw.get("managedRules", fw.get("managedRulesets")) + if not fw: + fallback_firewall_enabled = self._fallback_firewall_enabled(project) + self.firewall_configs[project.id] = VercelFirewallConfig( + project_id=project.id, + project_name=project.name, + team_id=project.team_id, + billing_plan=project.billing_plan, + firewall_config_accessible=True, + firewall_enabled=( + fallback_firewall_enabled + if fallback_firewall_enabled is not None + else False + ), + managed_rulesets=self._fallback_managed_rulesets(project), + name=project.name, + id=project.id, + ) + return + + rules = [ + rule for rule in (fw.get("rules", []) or []) if self._is_active(rule) + ] + managed = self._active_managed_rulesets( + fw.get("managedRules", fw.get("managedRulesets", fw.get("crs"))) + ) custom_rules = [] - ip_blocking = [] + ip_blocking = list(fw.get("ips", []) or []) rate_limiting = [] for rule in rules: - rule_action = rule.get("action", {}) - action_type = ( - rule_action.get("type", "") - if isinstance(rule_action, dict) - else str(rule_action) - ) + mitigate_action = self._mitigate_action(rule) - if action_type == "rate_limit" or rule.get("rateLimit"): + if self._is_rate_limiting_rule(rule, mitigate_action): rate_limiting.append(rule) - elif action_type in ("deny", "block") and self._is_ip_rule(rule): + elif self._is_ip_rule(rule): ip_blocking.append(rule) else: custom_rules.append(rule) - firewall_enabled = bool(rules) or bool(managed) + firewall_enabled = fw.get("firewallEnabled") + if firewall_enabled is None: + firewall_enabled = self._fallback_firewall_enabled(project) + if firewall_enabled is None: + firewall_enabled = bool(rules) or bool(ip_blocking) or bool(managed) + + if not managed: + managed = self._fallback_managed_rulesets(project) self.firewall_configs[project.id] = VercelFirewallConfig( project_id=project.id, project_name=project.name, team_id=project.team_id, + billing_plan=project.billing_plan, + firewall_config_accessible=True, firewall_enabled=firewall_enabled, - managed_rulesets=managed if managed is not None else {}, + managed_rulesets=managed, custom_rules=custom_rules, ip_blocking_rules=ip_blocking, rate_limiting_rules=rate_limiting, @@ -95,6 +119,117 @@ class Security(VercelService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _read_firewall_config(self, project): + """Read the deployed firewall config via the documented endpoint. + + See: https://vercel.com/docs/rest-api/security/read-firewall-configuration + """ + params = self._firewall_params(project) + config_version = getattr(project, "firewall_config_version", None) + + endpoints = [] + if config_version: + endpoints.append(f"/v1/security/firewall/config/{config_version}") + endpoints.append("/v1/security/firewall/config/active") + + last_error = None + for endpoint in endpoints: + try: + return self._get(endpoint, params=params) + except Exception as error: + last_error = error + logger.warning( + f"Security - Firewall config read failed for project " + f"{project.id} (team={getattr(project, 'team_id', None)}) " + f"on {endpoint} with params={params}: " + f"{error.__class__.__name__}: {error}" + ) + + if last_error is not None: + logger.debug( + f"Security - Falling back to firewall config wrapper for " + f"{project.id} after {last_error.__class__.__name__}: {last_error}" + ) + + return self._get("/v1/security/firewall/config", params=params) + + @staticmethod + def _firewall_params(project) -> dict: + """Build firewall request params, preserving team scope for team projects.""" + params = {"projectId": project.id} + team_id = getattr(project, "team_id", None) + + if isinstance(team_id, str) and team_id: + params["teamId"] = team_id + + return params + + @staticmethod + def _normalize_firewall_config(data: dict) -> dict: + """Normalize firewall responses across Vercel endpoint variants.""" + if not isinstance(data, dict): + return {} + + if "firewallConfig" in data and isinstance(data["firewallConfig"], dict): + return data["firewallConfig"] + + if any(key in data for key in ("active", "draft", "versions")): + return data.get("active") or {} + + return data + + @staticmethod + def _active_managed_rulesets(managed_rules: dict | None) -> dict: + """Return only active managed rulesets.""" + if not isinstance(managed_rules, dict): + return {} + + return { + ruleset: config + for ruleset, config in managed_rules.items() + if not isinstance(config, dict) or config.get("active", False) + } + + @classmethod + def _fallback_managed_rulesets(cls, project) -> dict: + """Return active managed rulesets from project metadata.""" + return cls._active_managed_rulesets(getattr(project, "managed_rules", None)) + + @staticmethod + def _fallback_firewall_enabled(project) -> bool | None: + """Return firewall enabled state from project metadata when available.""" + return getattr(project, "firewall_enabled", None) + + @staticmethod + def _mitigate_action(rule: dict) -> dict: + """Extract the nested Vercel mitigation action payload for a rule.""" + action = rule.get("action", {}) + if not isinstance(action, dict): + return {} + + mitigate = action.get("mitigate") + return mitigate if isinstance(mitigate, dict) else action + + @staticmethod + def _is_active(rule: dict) -> bool: + """Treat missing active flags as enabled for backwards compatibility.""" + return rule.get("active", True) is not False + + @classmethod + def _is_rate_limiting_rule( + cls, rule: dict, mitigate_action: dict | None = None + ) -> bool: + """Check if a firewall rule enforces rate limiting.""" + if rule.get("rateLimit"): + return True + + mitigate = ( + mitigate_action + if isinstance(mitigate_action, dict) + else cls._mitigate_action(rule) + ) + return bool(mitigate.get("rateLimit")) or mitigate.get("action") == "rate_limit" + @staticmethod def _is_ip_rule(rule: dict) -> bool: """Check if a rule is an IP blocking rule based on conditions.""" @@ -117,8 +252,10 @@ class VercelFirewallConfig(BaseModel): project_id: str project_name: Optional[str] = None team_id: Optional[str] = None + billing_plan: Optional[str] = None + firewall_config_accessible: bool = True firewall_enabled: bool = False - managed_rulesets: Optional[dict] = None # None means plan-gated (403) + managed_rulesets: Optional[dict] = None # None means config endpoint unavailable custom_rules: list[dict] = Field(default_factory=list) ip_blocking_rules: list[dict] = Field(default_factory=list) rate_limiting_rules: list[dict] = Field(default_factory=list) diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json index c758c82f18..467edbc66c 100644 --- a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json +++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json @@ -24,16 +24,17 @@ }, "Recommendation": { "Text": "Enable the Vercel Web Application Firewall to protect your application against common web attacks. Start with managed rulesets for baseline protection and add custom rules as needed based on your application's threat model.", - "Url": "https://hub.prowler.com/checks/vercel/security_waf_enabled" + "Url": "https://hub.prowler.com/check/security_waf_enabled" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "security_managed_rulesets_enabled", "security_custom_rules_configured" ], - "Notes": "" + "Notes": "Required billing plan: Pro or Enterprise." } diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py index 82859a4f5c..9ab93b87ca 100644 --- a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py +++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.security.security_client import security_client @@ -24,13 +25,15 @@ class security_waf_enabled(Check): for config in security_client.firewall_configs.values(): report = CheckReportVercel(metadata=self.metadata(), resource=config) - if config.managed_rulesets is None: - # 403 — plan limitation, cannot determine WAF status + if not config.firewall_config_accessible: + # Firewall config could not be retrieved for this project report.status = "MANUAL" report.status_extended = ( f"Project {config.project_name} ({config.project_id}) " - f"could not be checked for WAF status due to plan limitations. " + f"could not be checked for WAF status because the firewall " + f"configuration endpoint was not accessible. " f"Manual verification is required." + f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'the Web Application Firewall is not available on the Vercel Hobby plan.')}" ) elif config.firewall_enabled: report.status = "PASS" diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json index 37019b79da..ed0ce1470d 100644 --- a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json +++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json @@ -25,15 +25,16 @@ }, "Recommendation": { "Text": "Enable directory sync (SCIM) to automate user lifecycle management. This ensures that team membership stays synchronized with your identity provider, automatically provisioning new members and revoking access when employees leave or change roles.", - "Url": "https://hub.prowler.com/checks/vercel/team_directory_sync_enabled" + "Url": "https://hub.prowler.com/check/team_directory_sync_enabled" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-enterprise-plan" ], "DependsOn": [], "RelatedTo": [ "team_saml_sso_enabled" ], - "Notes": "" + "Notes": "Required billing plan: Enterprise." } diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py index 101922b6ed..d185de4971 100644 --- a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py +++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.team.team_client import team_client @@ -40,6 +41,7 @@ class team_directory_sync_enabled(Check): report.status_extended = ( f"Team {team.name} does not have directory sync (SCIM) enabled. " f"User provisioning and deprovisioning must be managed manually." + f"{plan_reason_suffix(team.billing_plan, {'hobby', 'pro'}, 'directory sync (SCIM) is only available on Vercel Enterprise plans.')}" ) findings.append(report) diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json index 0e4750d703..d648769c1f 100644 --- a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json +++ b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Limit the number of team owners to the minimum required for administration. Assign the least privileged role necessary for each member's responsibilities. Use MEMBER, DEVELOPER, or VIEWER roles for non-administrative team members.", - "Url": "https://hub.prowler.com/checks/vercel/team_member_role_least_privilege" + "Url": "https://hub.prowler.com/check/team_member_role_least_privilege" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json index d05461c5c1..606f9d863b 100644 --- a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json +++ b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json @@ -24,11 +24,12 @@ }, "Recommendation": { "Text": "Regularly review and revoke stale team invitations. Establish a process to follow up on pending invitations within a reasonable timeframe and revoke those that are no longer needed to reduce the risk of unauthorized access.", - "Url": "https://hub.prowler.com/checks/vercel/team_no_stale_invitations" + "Url": "https://hub.prowler.com/check/team_no_stale_invitations" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-hobby-plan" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json index ebbe85ebc9..fe66ed48bc 100644 --- a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json +++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json @@ -25,15 +25,16 @@ }, "Recommendation": { "Text": "Enable SAML SSO for the Vercel team to centralize authentication through your organization's identity provider. This ensures consistent security policies, simplifies user lifecycle management, and enables enforcement of MFA and other access controls.", - "Url": "https://hub.prowler.com/checks/vercel/team_saml_sso_enabled" + "Url": "https://hub.prowler.com/check/team_saml_sso_enabled" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "team_saml_sso_enforced" ], - "Notes": "" + "Notes": "Required billing plan: Pro or Enterprise." } diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py index 8a979ec66d..38960efa8c 100644 --- a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py +++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.team.team_client import team_client @@ -38,6 +39,7 @@ class team_saml_sso_enabled(Check): report.status = "FAIL" report.status_extended = ( f"Team {team.name} does not have SAML SSO enabled." + f"{plan_reason_suffix(team.billing_plan, {'hobby'}, 'SAML SSO is not available on the Vercel Hobby plan.')}" ) findings.append(report) diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json index f4de8e7ebe..3e69d9da49 100644 --- a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json +++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json @@ -25,15 +25,16 @@ }, "Recommendation": { "Text": "Enforce SAML SSO for all team members to ensure authentication is managed exclusively through your identity provider. This prevents credential bypass, enforces MFA policies, and provides centralized access control and audit capabilities.", - "Url": "https://hub.prowler.com/checks/vercel/team_saml_sso_enforced" + "Url": "https://hub.prowler.com/check/team_saml_sso_enforced" } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "vercel-pro-plan" ], "DependsOn": [], "RelatedTo": [ "team_saml_sso_enabled" ], - "Notes": "" + "Notes": "Required billing plan: Pro or Enterprise." } diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py index 746ebba387..564f0b5d46 100644 --- a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py +++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py @@ -1,6 +1,7 @@ from typing import List from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.lib.billing import plan_reason_suffix from prowler.providers.vercel.services.team.team_client import team_client @@ -43,6 +44,7 @@ class team_saml_sso_enforced(Check): else: report.status_extended = ( f"Team {team.name} does not have SAML SSO enforced." + f"{plan_reason_suffix(team.billing_plan, {'hobby'}, 'SAML SSO is not available on the Vercel Hobby plan.')}" ) findings.append(report) diff --git a/prowler/providers/vercel/services/team/team_service.py b/prowler/providers/vercel/services/team/team_service.py index 916374ea87..7d8b119def 100644 --- a/prowler/providers/vercel/services/team/team_service.py +++ b/prowler/providers/vercel/services/team/team_service.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import BaseModel, Field from prowler.lib.logger import logger +from prowler.providers.vercel.lib.billing import extract_billing_plan from prowler.providers.vercel.lib.service.service import VercelService @@ -67,6 +68,7 @@ class Team(VercelService): id=team_data.get("id", team_id), name=team_data.get("name", ""), slug=team_data.get("slug", ""), + billing_plan=extract_billing_plan(team_data), saml=saml_config, directory_sync_enabled=dir_sync, created_at=created_at, @@ -151,6 +153,7 @@ class VercelTeam(BaseModel): id: str name: str slug: str + billing_plan: Optional[str] = None saml: Optional[SAMLConfig] = None directory_sync_enabled: bool = False members: list[VercelTeamMember] = Field(default_factory=list) diff --git a/prowler/providers/vercel/vercel_provider.py b/prowler/providers/vercel/vercel_provider.py index 731adef6ed..3de89becbe 100644 --- a/prowler/providers/vercel/vercel_provider.py +++ b/prowler/providers/vercel/vercel_provider.py @@ -20,6 +20,7 @@ from prowler.providers.vercel.exceptions.exceptions import ( VercelRateLimitError, VercelSessionError, ) +from prowler.providers.vercel.lib.billing import extract_billing_plan from prowler.providers.vercel.lib.mutelist.mutelist import VercelMutelist from prowler.providers.vercel.models import ( VercelIdentityInfo, @@ -32,6 +33,7 @@ class VercelProvider(Provider): """Vercel provider.""" _type: str = "vercel" + sdk_only: bool = False _session: VercelSession _identity: VercelIdentityInfo _audit_config: dict @@ -195,6 +197,7 @@ class VercelProvider(Provider): user_id = user_data.get("id") username = user_data.get("username") email = user_data.get("email") + billing_plan = extract_billing_plan(user_data) # Get team info team_info = None @@ -214,6 +217,7 @@ class VercelProvider(Provider): id=team_data.get("id", session.team_id), name=team_data.get("name", ""), slug=team_data.get("slug", ""), + billing_plan=extract_billing_plan(team_data), ) all_teams = [team_info] elif team_response.status_code in (404, 403): @@ -239,6 +243,7 @@ class VercelProvider(Provider): id=t.get("id", ""), name=t.get("name", ""), slug=t.get("slug", ""), + billing_plan=extract_billing_plan(t), ) ) if all_teams: @@ -253,6 +258,7 @@ class VercelProvider(Provider): user_id=user_id, username=username, email=email, + billing_plan=billing_plan, team=team_info, teams=all_teams, ) diff --git a/pyproject.toml b/pyproject.toml index c9bf4864e9..a645bd0d10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,29 @@ [build-system] -build-backend = "poetry.core.masonry.api" -requires = ["poetry-core>=2.0"] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[dependency-groups] +dev = [ + "bandit==1.8.3", + "black==26.3.1", + "coverage==7.6.12", + "docker==7.1.0", + "filelock==3.20.3", + "flake8==7.1.2", + "freezegun==1.5.1", + "mock==5.2.0", + "moto[all]==5.1.11", + "openapi-schema-validator==0.6.3", + "openapi-spec-validator==0.7.1", + "prek==0.3.9", + "pylint==3.3.4", + "pytest==9.0.3", + "pytest-cov==6.0.0", + "pytest-env==1.1.5", + "pytest-randomly==3.16.0", + "pytest-xdist==3.6.1", + "vulture==2.14" +] # https://peps.python.org/pep-0621/ [project] @@ -10,10 +33,9 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "License :: OSI Approved :: Apache Software License" + "Programming Language :: Python :: 3.13" ] dependencies = [ - "awsipranges==0.3.3", "alive-progress==3.3.0", "azure-identity==1.21.0", "azure-keyvault-keys==4.10.0", @@ -31,7 +53,7 @@ dependencies = [ "azure-mgmt-postgresqlflexibleservers==1.1.0", "azure-mgmt-recoveryservices==3.1.0", "azure-mgmt-recoveryservicesbackup==9.2.0", - "azure-mgmt-resource==23.3.0", + "azure-mgmt-resource==24.0.0", "azure-mgmt-search==9.1.0", "azure-mgmt-security==7.0.0", "azure-mgmt-sql==3.0.1", @@ -46,39 +68,45 @@ dependencies = [ "boto3==1.40.61", "botocore==1.40.61", "colorama==0.4.6", - "cryptography==44.0.3", + "cryptography==46.0.7", "dash==3.1.1", "dash-bootstrap-components==2.0.3", - "defusedxml>=0.7.1", - "detect-secrets==1.5.0", - "dulwich==0.23.0", + "defusedxml==0.7.1", + "dulwich==1.2.5", "google-api-python-client==2.163.0", - "google-auth-httplib2>=0.1,<0.3", + "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", + "kingfisher-bin==1.104.0", "kubernetes==32.0.1", + "linode-api4==5.45.0", "markdown==3.10.2", - "microsoft-kiota-abstractions==1.9.2", - "msgraph-sdk==1.23.0", - "numpy==2.0.2", + "microsoft-kiota-abstractions==1.9.9", + "numpy==2.2.6", + "msgraph-sdk==1.55.0", + "okta==3.4.2", "openstacksdk==4.2.0", "pandas==2.2.3", "py-ocsf-models==0.8.1", - "pydantic (>=2.0,<3.0)", + "pydantic==2.12.5", "pygithub==2.8.0", - "python-dateutil (>=2.9.0.post0,<3.0.0)", + "python-dateutil==2.9.0.post0", "pytz==2025.1", "schema==0.7.5", "shodan==1.31.0", "slack-sdk==3.39.0", + "stackit-core==0.2.0", + "stackit-iaas==1.4.0", + "stackit-objectstorage==1.4.0", + "stackit-resourcemanager==0.8.0", "tabulate==0.9.0", "tzlocal==5.3.1", "uuid6==2024.7.10", - "py-iam-expand==0.1.0", + "py-iam-expand==0.3.0", "h2==4.3.0", - "oci==2.160.3", + "oci==2.169.0", "alibabacloud_credentials==1.0.3", "alibabacloud_ram20150501==1.2.0", - "alibabacloud_tea_openapi==0.4.1", + "alibabacloud_tea_openapi==0.4.4", "alibabacloud_sts20150401==1.1.6", "alibabacloud_vpc20160428==6.13.0", "alibabacloud_ecs20140526==7.2.5", @@ -88,15 +116,16 @@ dependencies = [ "alibabacloud_actiontrail20200706==2.4.1", "alibabacloud_cs20151215==6.1.0", "alibabacloud-rds20140815==12.0.0", - "alibabacloud-sls20201230==5.9.0" + "alibabacloud-sls20201230==5.9.0", + "scaleway==2.10.3" ] description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks." license = "Apache-2.0" maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] name = "prowler" readme = "README.md" -requires-python = ">=3.10,<3.13" -version = "5.23.0" +requires-python = ">=3.10,<3.14" +version = "5.32.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -107,42 +136,11 @@ prowler = "prowler.__main__:prowler" "Homepage" = "https://github.com/prowler-cloud/prowler" "Issue tracker" = "https://github.com/prowler-cloud/prowler/issues" -[tool.poetry] -packages = [ - {include = "prowler"}, - {include = "dashboard"} -] -requires-poetry = ">=2.0" +[tool.hatch.build.targets.sdist] +include = ["prowler", "dashboard"] -[tool.poetry.group.dev.dependencies] -bandit = "1.8.3" -black = "25.1.0" -coverage = "7.6.12" -docker = "7.1.0" -filelock = "3.20.3" -flake8 = "7.1.2" -freezegun = "1.5.1" -marshmallow = "==3.26.2" -mock = "5.2.0" -moto = {extras = ["all"], version = "5.1.11"} -openapi-schema-validator = "0.6.3" -openapi-spec-validator = "0.7.1" -pre-commit = "4.2.0" -pylint = "3.3.4" -pytest = "8.3.5" -pytest-cov = "6.0.0" -pytest-env = "1.1.5" -pytest-randomly = "3.16.0" -pytest-xdist = "3.6.1" -safety = "3.7.0" -vulture = "2.14" - -[tool.poetry-version-plugin] -source = "init" - -[tool.poetry_bumpversion.file."prowler/config/config.py"] -replace = 'prowler_version = "{new_version}"' -search = 'prowler_version = "{current_version}"' +[tool.hatch.build.targets.wheel] +packages = ["prowler", "dashboard"] [tool.pytest.ini_options] pythonpath = [ @@ -156,3 +154,227 @@ AWS_DEFAULT_REGION = 'us-east-1' AWS_SECRET_ACCESS_KEY = 'testing' AWS_SECURITY_TOKEN = 'testing' AWS_SESSION_TOKEN = 'testing' + +[tool.uv] +# Transitive pins matching the current lock to prevent silent drift on `uv lock` +# (e.g. supply chain hijacks via newer releases). Bump deliberately. +constraint-dependencies = [ + "about-time==4.2.1", + "aenum==3.1.17", + "aiofiles==24.1.0", + "aiohappyeyeballs==2.6.1", + "aiohttp==3.14.0", + "aiosignal==1.4.0", + "alibabacloud-actiontrail20200706==2.4.1", + "alibabacloud-credentials==1.0.3", + "alibabacloud-credentials-api==1.0.0", + "alibabacloud-cs20151215==6.1.0", + "alibabacloud-darabonba-array==0.1.0", + "alibabacloud-darabonba-encode-util==0.0.2", + "alibabacloud-darabonba-map==0.0.1", + "alibabacloud-darabonba-signature-util==0.0.4", + "alibabacloud-darabonba-string==0.0.4", + "alibabacloud-darabonba-time==0.0.1", + "alibabacloud-ecs20140526==7.2.5", + "alibabacloud-endpoint-util==0.0.4", + "alibabacloud-gateway-oss==0.0.17", + "alibabacloud-gateway-sls==0.4.2", + "alibabacloud-gateway-sls-util==0.4.1", + "alibabacloud-gateway-spi==0.0.3", + "alibabacloud-openapi-util==0.2.4", + "alibabacloud-oss-util==0.0.6", + "alibabacloud-oss20190517==1.0.6", + "alibabacloud-ram20150501==1.2.0", + "alibabacloud-sas20181203==6.1.0", + "alibabacloud-sts20150401==1.1.6", + "alibabacloud-tea==0.4.3", + "alibabacloud-tea-openapi==0.4.4", + "alibabacloud-tea-util==0.3.14", + "alibabacloud-tea-xml==0.0.3", + "alibabacloud-vpc20160428==6.13.0", + "aliyun-log-fastpb==0.3.0", + "annotated-types==0.7.0", + "antlr4-python3-runtime==4.13.2", + "anyio==4.13.0", + "apscheduler==3.11.2", + "astroid==3.3.11", + "async-timeout==5.0.1", + "attrs==26.1.0", + "aws-sam-translator==1.109.0", + "aws-xray-sdk==2.15.0", + "azure-common==1.1.28", + "azure-core==1.41.0", + "azure-mgmt-core==1.6.0", + "bandit==1.8.3", + "black==26.3.1", + "blinker==1.9.0", + "certifi==2026.4.22", + "cffi==2.0.0", + "cfn-lint==1.51.0", + "charset-normalizer==3.4.7", + "circuitbreaker==2.1.3", + "click==8.3.3", + "click-plugins==1.1.1.2", + "contextlib2==21.6.0", + "coverage==7.6.12", + "darabonba-core==1.0.5", + "decorator==5.2.1", + "deprecated==1.3.1", + "dill==0.4.1", + "distro==1.9.0", + "dnspython==2.8.0", + "docker==7.1.0", + "dogpile-cache==1.5.0", + "durationpy==0.10", + "email-validator==2.2.0", + "exceptiongroup==1.3.1", + "execnet==2.1.2", + "filelock==3.20.3", + "flake8==7.1.2", + "flask==3.1.3", + "freezegun==1.5.1", + "frozenlist==1.8.0", + "google-api-core==2.30.3", + "google-auth==2.52.0", + "googleapis-common-protos==1.75.0", + "graphemeu==0.7.2", + "graphql-core==3.2.8", + "h11==0.16.0", + "hpack==4.1.0", + "httpcore==1.0.9", + "httplib2==0.31.2", + "httpx==0.28.1", + "hyperframe==6.1.0", + "iamdata==0.1.202605131", + "idna==3.15", + "importlib-metadata==8.7.1", + "iniconfig==2.3.0", + "iso8601==2.1.0", + "isodate==0.7.2", + "isort==6.1.0", + "itsdangerous==2.2.0", + "jinja2==3.1.6", + "jmespath==1.1.0", + "joserfc==1.6.5", + "jsonpatch==1.33", + "jsonpath-ng==1.8.0", + "jsonpointer==3.1.1", + "jsonschema-path==0.3.4", + "jsonschema-specifications==2025.9.1", + "jwcrypto==1.5.7", + "keystoneauth1==5.14.0", + "lazy-object-proxy==1.12.0", + "lz4==4.4.5", + "markdown-it-py==4.2.0", + "markupsafe==3.0.3", + "mccabe==0.7.0", + "mdurl==0.1.2", + "microsoft-kiota-authentication-azure==1.9.9", + "microsoft-kiota-http==1.9.9", + "microsoft-kiota-serialization-form==1.9.9", + "microsoft-kiota-serialization-json==1.9.9", + "microsoft-kiota-serialization-multipart==1.9.9", + "microsoft-kiota-serialization-text==1.9.9", + "mock==5.2.0", + "moto==5.1.11", + "mpmath==1.3.0", + "msal==1.36.0", + "msal-extensions==1.3.1", + "msgraph-core==1.3.8", + "msrest==0.7.1", + "multidict==6.7.1", + "multipart==1.3.1", + "mypy-extensions==1.1.0", + "narwhals==2.21.0", + "nest-asyncio==1.6.0", + "networkx==3.4.2", + "oauthlib==3.3.1", + "openapi-schema-validator==0.6.3", + "openapi-spec-validator==0.7.1", + "opentelemetry-api==1.41.1", + "opentelemetry-sdk==1.41.1", + "opentelemetry-semantic-conventions==0.62b1", + "os-service-types==1.8.2", + "packaging==26.2", + "pathable==0.4.4", + "pathspec==1.1.1", + "pbr==7.0.3", + "platformdirs==4.9.6", + "plotly==6.7.0", + "pluggy==1.6.0", + "polling==0.3.2", + "prek==0.3.9", + "propcache==0.5.2", + "proto-plus==1.28.0", + "protobuf==7.34.1", + "psutil==7.2.2", + "py-partiql-parser==0.6.1", + "pyasn1==0.6.3", + "pyasn1-modules==0.4.2", + "pycodestyle==2.12.1", + "pycparser==3.0", + "pycryptodomex==3.23.0", + "pydantic-core==2.41.5", + "pydash==8.0.6", + "pyflakes==3.2.0", + "pygments==2.20.0", + "pyjwt==2.13.0", + "pylint==3.3.4", + "pynacl==1.6.2", + "pyopenssl==26.2.0", + "pyparsing==3.3.2", + "pytest==9.0.3", + "pytest-cov==6.0.0", + "pytest-env==1.1.5", + "pytest-randomly==3.16.0", + "pytest-xdist==3.6.1", + "pytokens==0.4.1", + "pywin32==311", + "pyyaml==6.0.3", + "referencing==0.36.2", + "regex==2026.5.9", + "requests==2.34.0", + "requests-file==3.0.1", + "requests-oauthlib==2.0.0", + "requestsexceptions==1.4.0", + "responses==0.26.0", + "retrying==1.4.2", + "rfc3339-validator==0.1.4", + "rich==15.0.0", + "rpds-py==0.30.0", + "s3transfer==0.14.0", + "setuptools==82.0.1", + "six==1.17.0", + "sniffio==1.3.1", + "std-uritemplate==2.0.8", + "stevedore==5.7.0", + "sympy==1.14.0", + "tldextract==5.3.1", + "tomli==2.4.1", + "tomlkit==0.15.0", + "typing-extensions==4.15.0", + "typing-inspection==0.4.2", + "tzdata==2026.2", + "uritemplate==4.2.0", + "urllib3==2.7.0", + "vulture==2.14", + "websocket-client==1.9.0", + "werkzeug==3.1.8", + "wrapt==2.1.2", + "xlsxwriter==3.2.9", + "xmltodict==1.0.4", + "yarl==1.23.0", + "zipp==3.23.1", + "zstd==1.5.7.3" +] +override-dependencies = ["okta==3.4.2"] + +[tool.vulture] +# Suppress known false positives. The CI command only passes --exclude and +# --min-confidence on the CLI, so ignore_names from here still applies (vulture +# only overrides the keys set on the CLI). +# - mock_* : pytest fixtures injected as test params but unused in the body +# (e.g. mock_sensitive_args in tests/lib/cli/redact_test.py) +# - view : DRF BasePermission.has_object_permission(self, request, view, obj) +# framework-required signature param in skills/django-drf template assets +ignore_names = ["mock_*", "view"] diff --git a/scripts/development/dev-local.sh b/scripts/development/dev-local.sh new file mode 100755 index 0000000000..d193b81532 --- /dev/null +++ b/scripts/development/dev-local.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +# +# Local dev for Prowler API + worker. +# Postgres / Valkey / Neo4j run in Docker via docker-compose-dev.yml; +# Django and Celery run natively. +# +# Quick start: +# make dev-setup first-time bootstrap (deps, migrations, fixtures) +# make dev launch api + worker + postgres logs in tmux +# make dev-attach attach to the tmux session in the current terminal pane +# make dev-launch use fixed ports and attach interactive local dev +# make dev-stop stop everything: tmux + stop + remove containers +# make dev-clean remove stopped containers only (data preserved) +# make dev-wipe full nuke: kill + clean + delete ./_data/ +# +# Agent / non-interactive usage: 'make dev' is idempotent, runs +# everything detached, blocks until the API answers HTTP, and ends with +# parseable key=value lines (api_url=, api_log=, worker_log=, +# postgres_log=, attach_cmd=, stop_cmd=). +# Every pane is teed ANSI-stripped to _data/logs/{api,worker,postgres}.log +# (truncated per run), so logs are readable with tail -f, no tmux needed. +# +# 'attach' attaches to tmux inside the current terminal pane. +# +# Inside the tmux session "prowler-dev-" the prefix key is Ctrl+b. After it: +# d detach (everything keeps running; reattach: make dev-attach) +# move between panes +# z zoom current pane (toggle) +# [ scrollback mode (q to exit) +# x kill current pane (asks for confirmation) +# & kill current window +# :kill-session end the whole session +# +# How to stop everything from inside tmux: +# 1) Ctrl+b then d detach back to your shell +# 2) make dev-stop tears down tmux + containers +# Alternative without detaching: open a new tmux window with Ctrl+b then c +# and run make dev-stop there; the session will close itself. +# +# Stop just the python procs (keep containers up to skip a slow neo4j boot next time): +# ./scripts/development/dev-local.sh down +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +path_hash() { + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$REPO_ROOT" | shasum | awk '{print substr($1, 1, 8)}' + else + printf '%s' "$REPO_ROOT" | cksum | awk '{print $1}' + fi +} + +compose_project_default() { + local base hash + base="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]+/-/g; s/^-+//; s/-+$//')" + hash="$(path_hash)" + printf '%s-%s' "${base:-prowler}" "$hash" +} + +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$(compose_project_default)}" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +export POSTGRES_HOST=localhost +export POSTGRES_PORT="${POSTGRES_PORT:-5432}" +export VALKEY_HOST=localhost +export VALKEY_PORT="${VALKEY_PORT:-6379}" +export VALKEY_SCHEME="${VALKEY_SCHEME:-redis}" +export NEO4J_HOST=localhost +export NEO4J_PORT="${NEO4J_PORT:-7687}" +export NEO4J_HTTP_PORT="${NEO4J_HTTP_PORT:-7474}" +export DJANGO_SETTINGS_MODULE=config.django.devel +export DJANGO_DEBUG="${DJANGO_DEBUG:-True}" +export DJANGO_PORT="${DJANGO_PORT:-8080}" +export DJANGO_LOGGING_FORMATTER="${DJANGO_LOGGING_FORMATTER:-human_readable}" +export DJANGO_LOGGING_LEVEL="${DJANGO_LOGGING_LEVEL:-info}" +export DJANGO_MANAGE_DB_PARTITIONS="${DJANGO_MANAGE_DB_PARTITIONS:-False}" + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +else + COMPOSE=(docker-compose) +fi +COMPOSE_FILE="docker-compose-dev.yml" + +log() { printf '\033[1;34m→\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!\033[0m %s\n' "$*"; } + +require_uv() { + if ! command -v uv >/dev/null 2>&1; then + warn "uv not found in PATH. Install: https://docs.astral.sh/uv/getting-started/installation/" + exit 1 + fi +} + +services_up() { + log "Starting postgres + valkey + neo4j via Docker (waits for healthchecks)..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" up -d --wait postgres valkey neo4j + ok "Services ready: pg:${POSTGRES_PORT} / valkey:${VALKEY_PORT} / neo4j:${NEO4J_PORT} / neo4j-http:${NEO4J_HTTP_PORT}" +} + +kill_tmux_session() { + command -v tmux >/dev/null 2>&1 || return 0 + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Killing tmux session '$TMUX_SESSION'" + tmux kill-session -t "$TMUX_SESSION" + fi +} + +tmux_pane_count() { + tmux list-panes -t "$TMUX_SESSION:" 2>/dev/null | wc -l | tr -d ' ' +} + +# Tee a tmux pane's output to _data/logs/.log so non-interactive +# consumers (scripts, agents) can follow the stack without attaching. +# ANSI codes are stripped; the file is truncated on session creation so +# its content always matches the current run. +pipe_pane_log() { + local pane="$1" name="$2" + local logfile="$REPO_ROOT/_data/logs/$name.log" + : > "$logfile" + tmux pipe-pane -t "$pane" -o "perl -pe 'BEGIN{\$|=1} s/\\e\\[[0-9;?]*[A-Za-z]//g' >> '$logfile'" +} + +# Stop native dev processes (api/worker/beat) launched outside tmux. Scoped by +# cwd under REPO_ROOT so other prowler clones running their own stacks are not +# touched. +kill_native_procs() { + command -v pgrep >/dev/null 2>&1 || return 0 + command -v lsof >/dev/null 2>&1 || return 0 + + local pids="" pid pcwd pat + for pat in "manage.py runserver" "celery -A config.celery worker" "celery -A config.celery beat"; do + while IFS= read -r pid; do + [ -n "$pid" ] || continue + pcwd="$(lsof -a -d cwd -p "$pid" 2>/dev/null | awk 'NR>1 {print $NF; exit}')" + case "$pcwd" in + "$REPO_ROOT"|"$REPO_ROOT"/*) pids="$pids $pid" ;; + esac + done < <(pgrep -f "$pat" 2>/dev/null) + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -z "$pids" ] && return 0 + + log "Stopping native dev procs ($pids) under $REPO_ROOT" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +services_down() { + kill_tmux_session + kill_native_procs + log "Stopping postgres + valkey + neo4j..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" stop postgres valkey neo4j +} + +services_status() { + "${COMPOSE[@]}" -f "$COMPOSE_FILE" ps postgres valkey neo4j +} + +deps() { + require_uv + log "uv sync (api/)..." + (cd api && uv sync) +} + +migrate() { + require_uv + log "Applying migrations (admin DB)..." + ( + cd api/src/backend + uv run python manage.py check_and_fix_socialaccount_sites_migration --database admin + uv run python manage.py migrate --database admin + ) + ok "Migrations applied" +} + +fixtures() { + require_uv + log "Loading dev fixtures..." + ( + cd api/src/backend + for fixture in api/fixtures/dev/*.json; do + [ -f "$fixture" ] || continue + echo " loading $(basename "$fixture")" + uv run python manage.py loaddata "$fixture" --database admin + done + ) + ok "Fixtures loaded" +} + +api_run() { + require_uv + log "Starting Django API on [::]:${DJANGO_PORT} (IPv4 + IPv6 dual-stack)" + cd api/src/backend + exec uv run python manage.py runserver "[::]:${DJANGO_PORT}" +} + +worker_run() { + require_uv + log "Starting Celery worker" + cd api/src/backend + exec uv run python -m celery -A config.celery worker \ + -l "${DJANGO_LOGGING_LEVEL}" \ + -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ + -E +} + +beat_run() { + require_uv + log "Starting Celery beat (DatabaseScheduler)" + cd api/src/backend + exec uv run python -m celery -A config.celery beat \ + -l "${DJANGO_LOGGING_LEVEL}" \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler +} + +TMUX_SESSION="prowler-dev-${COMPOSE_PROJECT_NAME}" +SCRIPT_PATH="$REPO_ROOT/scripts/development/dev-local.sh" + +require_tmux() { + if ! command -v tmux >/dev/null 2>&1; then + warn "tmux not installed. Run: brew install tmux" + exit 1 + fi +} + +remove_repo_compose_containers() { + command -v docker >/dev/null 2>&1 || return 0 + local ids + ids="$( + docker ps -aq 2>/dev/null \ + | while IFS= read -r id; do + docker inspect --format '{{.ID}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null || true + done \ + | awk -v repo="$REPO_ROOT" '$2 == repo {print $1}' + )" + [ -n "$ids" ] || return 0 + warn "Removing existing Docker containers for this repo before launch" + # shellcheck disable=SC2086 + docker rm -f $ids >/dev/null +} + +remove_docker_containers_on_ports() { + command -v docker >/dev/null 2>&1 || return 0 + local all_ids ids_to_remove="" id port published_ports + all_ids="$(docker ps -aq 2>/dev/null || true)" + [ -n "$all_ids" ] || return 0 + + for id in $all_ids; do + published_ports="$(docker inspect --format '{{range $containerPort, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}' "$id" 2>/dev/null || true)" + [ -n "$published_ports" ] || continue + for port in "$@"; do + if printf '%s\n' "$published_ports" | grep -qx "$port"; then + ids_to_remove="$ids_to_remove $id" + break + fi + done + done + + ids_to_remove="$(printf '%s\n' "$ids_to_remove" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$ids_to_remove" ] || return 0 + warn "Removing Docker containers publishing fixed dev ports: $ids_to_remove" + # shellcheck disable=SC2086 + docker rm -f $ids_to_remove >/dev/null +} + +kill_listeners_on_ports() { + command -v lsof >/dev/null 2>&1 || return 0 + local pids="" port port_pids + for port in "$@"; do + port_pids="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" + [ -n "$port_pids" ] && pids="$pids $port_pids" + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$pids" ] || return 0 + warn "Killing local processes listening on fixed dev ports: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +clear_dev_port_conflicts() { + local ports=("$DJANGO_PORT" "$POSTGRES_PORT" "$VALKEY_PORT" "$NEO4J_PORT" "$NEO4J_HTTP_PORT") + log "Ensuring fixed dev ports are free: api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + remove_docker_containers_on_ports "${ports[@]}" + kill_listeners_on_ports "${ports[@]}" +} + +# Block until the API answers HTTP on DJANGO_PORT (any status code counts). +wait_for_api() { + local timeout="${1:-90}" waited=0 + log "Waiting for API on :${DJANGO_PORT} (timeout ${timeout}s)..." + until curl -s -o /dev/null --max-time 2 "http://localhost:${DJANGO_PORT}/"; do + waited=$((waited + 1)) + if [ "$waited" -ge "$timeout" ]; then + warn "API not responding after ${timeout}s. Check _data/logs/api.log" + return 1 + fi + sleep 1 + done + ok "API responding on :${DJANGO_PORT}" +} + +needs_db_bootstrap() { + ! "${COMPOSE[@]}" -f "$COMPOSE_FILE" exec -T postgres \ + psql -U prowler -d prowler_db -c 'select 1' >/dev/null 2>&1 +} + +bootstrap_db_if_needed() { + if needs_db_bootstrap; then + warn "App DB user not ready. Bootstrapping (deps + migrate + fixtures)..." + deps + migrate + fixtures + ok "DB bootstrap complete" + fi +} + +all_run() { + require_tmux + require_uv + services_up + bootstrap_db_if_needed + + # Wrap a command so it auto-runs as the pane's foreground process and, on + # exit (e.g. Ctrl+C), drops into the user's login shell instead of closing. + # Avoids the `send-keys` race where keys arrive before zsh+starship are ready. + local user_shell="${SHELL:-/bin/zsh}" + pane_cmd() { + printf 'bash -c %q' "$1; exec ${user_shell}" + } + local api_cmd worker_cmd pg_cmd + api_cmd="$(pane_cmd "$SCRIPT_PATH api")" + worker_cmd="$(pane_cmd "$SCRIPT_PATH worker")" + pg_cmd="$(pane_cmd "${COMPOSE[*]} -f $COMPOSE_FILE logs -f postgres")" + + # If a tmux server is already running (e.g. another project's session), new + # sessions inherit THAT server's env, not the launcher shell's env. Pass our + # overrides explicitly so each pane sees the right project name, ports, etc. + local -a env_args=() + local var + for var in COMPOSE_PROJECT_NAME \ + POSTGRES_HOST POSTGRES_PORT \ + VALKEY_HOST VALKEY_PORT VALKEY_SCHEME \ + NEO4J_HOST NEO4J_PORT NEO4J_HTTP_PORT \ + DJANGO_SETTINGS_MODULE DJANGO_DEBUG DJANGO_PORT \ + DJANGO_LOGGING_FORMATTER DJANGO_LOGGING_LEVEL \ + DJANGO_MANAGE_DB_PARTITIONS; do + env_args+=(-e "${var}=${!var-}") + done + + local expected_panes=3 + + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt "$expected_panes" ]; then + warn "Session '$TMUX_SESSION' has $current_panes pane(s), expected $expected_panes. Rebuilding it." + tmux kill-session -t "$TMUX_SESSION" + else + log "Session '$TMUX_SESSION' already exists" + fi + fi + + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Creating tmux session '$TMUX_SESSION' (api / worker / db logs)" + tmux new-session -d -s "$TMUX_SESSION" -n services -c "$REPO_ROOT" "${env_args[@]}" "$api_cmd" + tmux split-window -t "$TMUX_SESSION:0.0" -v -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$worker_cmd" + tmux split-window -t "$TMUX_SESSION:0.1" -h -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$pg_cmd" + tmux select-pane -t "$TMUX_SESSION:0.0" + tmux set-option -t "$TMUX_SESSION" -g mouse on >/dev/null + tmux set-option -t "$TMUX_SESSION" -g bell-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g activity-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g silence-action none >/dev/null + tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux set-option -t "$TMUX_SESSION" -g status-right " API:${DJANGO_PORT} | DB:${POSTGRES_PORT} | Broker:${VALKEY_PORT} " >/dev/null + + mkdir -p "$REPO_ROOT/_data/logs" + pipe_pane_log "$TMUX_SESSION:0.0" api + pipe_pane_log "$TMUX_SESSION:0.1" worker + pipe_pane_log "$TMUX_SESSION:0.2" postgres + fi + + wait_for_api 90 + + ok "Dev stack ready (detached)" + cat </dev/null; then + warn "No session '$TMUX_SESSION'. Starting it with: $0 all" + all_run + else + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt 3 ]; then + warn "Session '$TMUX_SESSION' only has $current_panes pane(s). Rebuilding with db logs." + tmux kill-session -t "$TMUX_SESSION" + all_run + fi + fi + if [ -n "${TMUX:-}" ]; then + tmux switch-client -t "$TMUX_SESSION" + else + tmux attach-session -t "$TMUX_SESSION" + fi +} + +clean_run() { + log "Removing stopped containers from the compose project..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" rm -f + ok "Stopped containers removed (data volumes under ./_data/ untouched)" +} + +wipe_run() { + warn "DESTRUCTIVE: this will tear down everything AND delete ./_data/" + warn " postgres database, valkey state, neo4j graph, api jwt keys - all gone." + warn " Next start will require 'make dev-setup' from scratch." + kill_run + if [ -d ./_data ]; then + log "Removing ./_data/ ..." + rm -rf ./_data + fi + ok "Wipe complete. Run 'make dev-setup' for a fresh environment." +} + +kill_run() { + kill_tmux_session + services_down + clean_run + ok "Dev stack stopped (tmux killed, containers stopped + removed)" +} + +launch_run() { + require_uv + kill_tmux_session + remove_repo_compose_containers + clear_dev_port_conflicts + + log "Launching dev stack in tmux inside the current terminal" + log " api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + all_run + attach_run +} + +setup() { + services_up + deps + migrate + fixtures + ok "Setup complete." + cat < + +One-window dev (tmux inside the terminal): + all api + worker + postgres logs in 3 panes (detached, + blocks until API responds, + ends with parseable api_url= / *_log= / attach_cmd= / stop_cmd= lines) + attach Reattach to the existing dev session + Attaches tmux inside the current terminal pane. + Pane output is also written to _data/logs/{api,worker,postgres}.log + (ANSI-stripped, truncated per run) - usable without attaching + kill Stop tmux + stop containers + remove them (full teardown) + launch Use fixed ports, clear conflicts, then run 'all' and attach tmux + +Platform support: + macOS Supported + Linux Should work when Docker, tmux, and uv are available + Windows Requires script changes before it can be supported + +State (containers postgres + valkey + neo4j): + up Start postgres + valkey + neo4j (waits for healthchecks) + down Stop tmux + postgres + valkey + neo4j (keeps containers around) + status Show container status + clean Remove all stopped containers in the project (data volumes preserved) + wipe Full nuke: kill + clean + delete ./_data/ + +Python (native, foreground - usually launched via 'all'): + api Run Django API (runserver) + worker Run Celery worker + beat Run Celery beat scheduler + +One-shots: + setup up + deps + migrate + fixtures (first-time / fresh DB) + deps uv sync inside api/ + migrate Apply migrations to admin DB + fixtures Load dev fixtures + +Typical flow: + make dev-setup # first time only + make dev # daily dev + make dev-stop # when done +EOF +} + +case "${1:-help}" in + all) shift; if [ "$#" -gt 0 ]; then warn "'all $*' is not supported. Use: $0 all"; exit 1; fi; all_run ;; + attach) attach_run ;; + kill) kill_run ;; + launch) launch_run ;; + clean) clean_run ;; + wipe) wipe_run ;; + up) services_up ;; + down) services_down ;; + status) services_status ;; + api) api_run ;; + worker) worker_run ;; + beat) beat_run ;; + setup) setup ;; + deps) deps ;; + migrate) migrate ;; + fixtures) fixtures ;; + help|-h|--help|*) usage ;; +esac diff --git a/scripts/setup-git-hooks.sh b/scripts/setup-git-hooks.sh index ab7c01479d..c1d930b20f 100755 --- a/scripts/setup-git-hooks.sh +++ b/scripts/setup-git-hooks.sh @@ -1,7 +1,8 @@ #!/bin/bash # Setup Git Hooks for Prowler -# This script installs pre-commit hooks using the project's Poetry environment +# This script installs prek hooks using the project's uv-managed environment +# or a system-wide prek installation set -e @@ -23,43 +24,50 @@ if ! git rev-parse --git-dir >/dev/null 2>&1; then exit 1 fi -# Check if Poetry is installed -if ! command -v poetry &>/dev/null; then - echo -e "${RED}❌ Poetry is not installed${NC}" - echo -e "${YELLOW} Install Poetry: https://python-poetry.org/docs/#installation${NC}" - exit 1 -fi - -# Check if pyproject.toml exists -if [ ! -f "pyproject.toml" ]; then - echo -e "${RED}❌ pyproject.toml not found${NC}" - echo -e "${YELLOW} Please run this script from the repository root${NC}" - exit 1 -fi - -# Check if dependencies are already installed -if ! poetry run python -c "import pre_commit" 2>/dev/null; then - echo -e "${YELLOW}📦 Installing project dependencies (including pre-commit)...${NC}" - poetry install --with dev -else - echo -e "${GREEN}✓${NC} Dependencies already installed" -fi - -echo "" -# Clear any existing core.hooksPath to avoid pre-commit conflicts +# Clear any existing core.hooksPath to avoid conflicts if git config --get core.hooksPath >/dev/null 2>&1; then echo -e "${YELLOW}🧹 Clearing existing core.hooksPath configuration...${NC}" git config --unset-all core.hooksPath fi -echo -e "${YELLOW}🔗 Installing pre-commit hooks...${NC}" -poetry run pre-commit install +echo "" + +# Full setup requires uv for system hooks (pylint, bandit, vulture, trufflehog) +# These are installed as Python dev dependencies and used by local hooks in .pre-commit-config.yaml +if command -v uv &>/dev/null && [ -f "pyproject.toml" ]; then + if uv run prek --version &>/dev/null 2>&1; then + echo -e "${GREEN}✓${NC} prek and dependencies found via uv" + else + echo -e "${YELLOW}📦 Installing project dependencies (including prek)...${NC}" + uv sync + fi + echo -e "${YELLOW}🔗 Installing prek hooks...${NC}" + uv run prek install --overwrite +elif command -v prek &>/dev/null; then + # prek is available system-wide but without uv dev deps + echo -e "${GREEN}✓${NC} prek found in PATH" + echo -e "${YELLOW}🔗 Installing prek hooks...${NC}" + prek install --overwrite + echo "" + echo -e "${YELLOW}⚠️ Warning: Some hooks require Python tools installed via uv:${NC}" + echo -e " pylint, bandit, vulture, trufflehog" + echo -e " These hooks will be skipped unless you install them or run:" + echo -e " ${GREEN}uv sync${NC}" +else + echo -e "${RED}❌ prek is not installed${NC}" + echo -e "${YELLOW} Install prek using one of these methods:${NC}" + echo -e " • brew install prek" + echo -e " • pnpm add -g @j178/prek" + echo -e " • pip install prek" + echo -e " • See https://prek.j178.dev/installation/ for more options" + exit 1 +fi echo "" echo -e "${GREEN}✅ Git hooks successfully configured!${NC}" echo "" -echo -e "${YELLOW}📋 Pre-commit system:${NC}" -echo -e " • Python pre-commit manages all git hooks" +echo -e "${YELLOW}📋 Prek hook system:${NC}" +echo -e " • Prek manages all git hooks" echo -e " • API files: Python checks (black, flake8, bandit, etc.)" echo -e " • UI files: UI checks (TypeScript, ESLint, Claude Code validation)" echo "" diff --git a/skills/README.md b/skills/README.md index 1d689962af..cfc65527a9 100644 --- a/skills/README.md +++ b/skills/README.md @@ -23,12 +23,12 @@ Run the setup script to configure skills for all supported AI coding assistants: This creates symlinks so each tool finds skills in its expected location: -| Tool | Symlink Created | -|------|-----------------| -| Claude Code / OpenCode | `.claude/skills/` | -| Codex (OpenAI) | `.codex/skills/` | -| GitHub Copilot | `.github/skills/` | -| Gemini CLI | `.gemini/skills/` | +| Tool | Created by setup | +|------|------------------| +| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` | +| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` | +| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) | +| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` | After running setup, restart your AI coding assistant to load the skills. @@ -36,7 +36,7 @@ After running setup, restart your AI coding assistant to load the skills. Skills are automatically discovered by the AI agent. To manually load a skill during a session: -``` +```text Read skills/{skill-name}/SKILL.md ``` @@ -50,7 +50,7 @@ Reusable patterns for common technologies: |-------|-------------| | `typescript` | Const types, flat interfaces, utility types | | `react-19` | React 19 patterns, React Compiler | -| `nextjs-15` | App Router, Server Actions, streaming | +| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | | `tailwind-4` | cn() utility, Tailwind 4 patterns | | `playwright` | Page Object Model, selectors | | `vitest` | Unit testing, React Testing Library | @@ -90,7 +90,7 @@ Patterns tailored for Prowler development: ## Directory Structure -``` +```text skills/ ├── {skill-name}/ │ ├── SKILL.md # Required - main instrunsction and metadata @@ -118,7 +118,7 @@ This reads `metadata.scope` and `metadata.auto_invoke` from each `SKILL.md` and Use the `skill-creator` skill for guidance: -``` +```text Read skills/skill-creator/SKILL.md ``` diff --git a/skills/django-drf/SKILL.md b/skills/django-drf/SKILL.md index 93ea6219f1..7d73ed1543 100644 --- a/skills/django-drf/SKILL.md +++ b/skills/django-drf/SKILL.md @@ -54,7 +54,7 @@ When implementing a new endpoint, review these patterns in order: ## Decision Trees ### Which Serializer? -``` +```text GET list/retrieve → Serializer POST create → CreateSerializer PATCH update → UpdateSerializer @@ -62,7 +62,7 @@ PATCH update → UpdateSerializer ``` ### Which Base Serializer? -``` +```text Read-only serializer → BaseModelSerializerV1 Create with tenant_id → RLSSerializer + BaseWriteSerializer (auto-injects tenant_id on create) Update with validation → BaseWriteSerializer (tenant_id already exists on object) @@ -70,14 +70,14 @@ Non-model data → BaseSerializerV1 ``` ### Which Filter Base? -``` +```text Direct FK to Provider → BaseProviderFilter FK via Scan → BaseScanProviderFilter No provider relation → FilterSet ``` ### Which Base ViewSet? -``` +```text RLS-protected model → BaseRLSViewSet (most common) Tenant operations → BaseTenantViewset User operations → BaseUserViewset @@ -85,7 +85,7 @@ No RLS required → BaseViewSet (rare) ``` ### Resource Name Format? -``` +```text Single word model → plural lowercase (Provider → providers) Multi-word model → plural lowercase kebab (ProviderGroup → provider-groups) Through/join model → parent-child pattern (UserRoleRelationship → user-roles) @@ -456,16 +456,16 @@ def get_object(self): ```bash # Development -cd api && poetry run python src/backend/manage.py runserver -cd api && poetry run python src/backend/manage.py shell +cd api && uv run python src/backend/manage.py runserver +cd api && uv run python src/backend/manage.py shell # Database -cd api && poetry run python src/backend/manage.py makemigrations -cd api && poetry run python src/backend/manage.py migrate +cd api && uv run python src/backend/manage.py makemigrations +cd api && uv run python src/backend/manage.py migrate # Testing -cd api && poetry run pytest -x --tb=short -cd api && poetry run make lint +cd api && uv run pytest -x --tb=short +cd api && uv run make lint ``` --- @@ -490,7 +490,7 @@ When implementing or debugging, query these libraries via `mcp_context7_query-do | **drf-spectacular** | `/tfranzel/drf-spectacular` | OpenAPI schema, `@extend_schema` | **Example queries:** -``` +```text mcp_context7_query-docs(libraryId="/websites/django-rest-framework", query="ViewSet get_queryset best practices") mcp_context7_query-docs(libraryId="/tfranzel/drf-spectacular", query="extend_schema examples for custom actions") mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_2", query="model constraints and indexes") diff --git a/skills/django-drf/references/file-locations.md b/skills/django-drf/references/file-locations.md index 30dab71550..d0f63ad042 100644 --- a/skills/django-drf/references/file-locations.md +++ b/skills/django-drf/references/file-locations.md @@ -16,7 +16,7 @@ ## ViewSet Hierarchy -``` +```text BaseViewSet (minimal - no RLS/auth) │ ├── BaseRLSViewSet (+ tenant filtering, RLS-protected models) @@ -31,7 +31,7 @@ BaseViewSet (minimal - no RLS/auth) ## Serializer Hierarchy -``` +```text BaseModelSerializerV1 (JSON:API defaults, read_only_fields) │ ├── RLSSerializer (auto-injects tenant_id from request) @@ -47,7 +47,7 @@ BaseModelSerializerV1 (JSON:API defaults, read_only_fields) ## Filter Hierarchy -``` +```text FilterSet (django-filter) │ ├── CommonFindingFilters (mixin for date ranges, delta, status) diff --git a/skills/django-drf/references/json-api-conventions.md b/skills/django-drf/references/json-api-conventions.md index c51326b0b2..9a546671fa 100644 --- a/skills/django-drf/references/json-api-conventions.md +++ b/skills/django-drf/references/json-api-conventions.md @@ -2,7 +2,7 @@ ## Content Type -``` +```http Content-Type: application/vnd.api+json Accept: application/vnd.api+json ``` diff --git a/skills/django-migration-psql/SKILL.md b/skills/django-migration-psql/SKILL.md index d292036dd0..bca8949fda 100644 --- a/skills/django-migration-psql/SKILL.md +++ b/skills/django-migration-psql/SKILL.md @@ -364,7 +364,7 @@ Batch utilities: `api/db_utils.py` (`batch_delete`, `create_objects_in_batches`, ## Decision tree -``` +```text Auto-generated migration? ├── Yes → Split it following the rules below └── No → Review it against the rules below @@ -420,7 +420,7 @@ When implementing or debugging migration patterns, query these libraries via `mc | django-postgres-extra | `/SectorLabs/django-postgres-extra` | Partitioned models, `PostgresPartitionedModel`, partition management | **Example queries:** -``` +```text mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_1", query="migration operations AddIndex RunPython atomic") mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_1", query="database indexes Meta class concurrently") mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="CREATE INDEX CONCURRENTLY partitioned table") diff --git a/skills/gh-aw/SKILL.md b/skills/gh-aw/SKILL.md index b45d4d151e..d4c3a0c829 100644 --- a/skills/gh-aw/SKILL.md +++ b/skills/gh-aw/SKILL.md @@ -30,7 +30,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch ## File Layout -``` +```text .github/ ├── workflows/ │ ├── {name}.md # Frontmatter + thin context dispatcher @@ -308,7 +308,7 @@ After modifying any `.github/workflows/*.md`: Add to repo root so lock files auto-resolve on merge: -``` +```text .github/workflows/*.lock.yml linguist-generated=true merge=ours ``` diff --git a/skills/jsonapi/SKILL.md b/skills/jsonapi/SKILL.md index a8959a2199..db11146929 100644 --- a/skills/jsonapi/SKILL.md +++ b/skills/jsonapi/SKILL.md @@ -35,7 +35,7 @@ This skill focuses on **spec compliance**. For **implementation patterns** (View If Context7 MCP is available, query the JSON:API spec directly: -``` +```text mcp_context7_resolve-library-id(query="jsonapi specification") mcp_context7_query-docs(libraryId="", query="[specific topic: relationships, errors, etc.]") ``` @@ -44,7 +44,7 @@ mcp_context7_query-docs(libraryId="", query="[specific topic: relat If Context7 is not available, fetch from the official spec: -``` +```text WebFetch(url="https://jsonapi.org/format/", prompt="Extract rules for [specific topic]") ``` diff --git a/skills/nextjs-15/SKILL.md b/skills/nextjs-15/SKILL.md deleted file mode 100644 index 793c1db2e1..0000000000 --- a/skills/nextjs-15/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: nextjs-15 -description: > - Next.js 15 App Router patterns. - Trigger: When working in Next.js App Router (app/), Server Components vs Client Components, Server Actions, Route Handlers, caching/revalidation, and streaming/Suspense. -license: Apache-2.0 -metadata: - author: prowler-cloud - version: "1.0" - scope: [root, ui] - auto_invoke: "App Router / Server Actions" -allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ---- - -## App Router File Conventions - -``` -app/ -├── layout.tsx # Root layout (required) -├── page.tsx # Home page (/) -├── loading.tsx # Loading UI (Suspense) -├── error.tsx # Error boundary -├── not-found.tsx # 404 page -├── (auth)/ # Route group (no URL impact) -│ ├── login/page.tsx # /login -│ └── signup/page.tsx # /signup -├── api/ -│ └── route.ts # API handler -└── _components/ # Private folder (not routed) -``` - -## Server Components (Default) - -```typescript -// No directive needed - async by default -export default async function Page() { - const data = await db.query(); - return ; -} -``` - -## Server Actions - -```typescript -// app/actions.ts -"use server"; - -import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; - -export async function createUser(formData: FormData) { - const name = formData.get("name") as string; - - await db.users.create({ data: { name } }); - - revalidatePath("/users"); - redirect("/users"); -} - -// Usage -
    - - -
    -``` - -## Data Fetching - -```typescript -// Parallel -async function Page() { - const [users, posts] = await Promise.all([ - getUsers(), - getPosts(), - ]); - return ; -} - -// Streaming with Suspense -}> - - -``` - -## Route Handlers (API) - -```typescript -// app/api/users/route.ts -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - const users = await db.users.findMany(); - return NextResponse.json(users); -} - -export async function POST(request: NextRequest) { - const body = await request.json(); - const user = await db.users.create({ data: body }); - return NextResponse.json(user, { status: 201 }); -} -``` - -## Middleware - -```typescript -// middleware.ts (root level) -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -export function middleware(request: NextRequest) { - const token = request.cookies.get("token"); - - if (!token && request.nextUrl.pathname.startsWith("/dashboard")) { - return NextResponse.redirect(new URL("/login", request.url)); - } - - return NextResponse.next(); -} - -export const config = { - matcher: ["/dashboard/:path*"], -}; -``` - -## Metadata - -```typescript -// Static -export const metadata = { - title: "My App", - description: "Description", -}; - -// Dynamic -export async function generateMetadata({ params }) { - const product = await getProduct(params.id); - return { title: product.name }; -} -``` - -## server-only Package - -```typescript -import "server-only"; - -// This will error if imported in client component -export async function getSecretData() { - return db.secrets.findMany(); -} -``` diff --git a/skills/nextjs-16/SKILL.md b/skills/nextjs-16/SKILL.md new file mode 100644 index 0000000000..cea38e255d --- /dev/null +++ b/skills/nextjs-16/SKILL.md @@ -0,0 +1,160 @@ +--- +name: nextjs-16 +description: > + Next.js 16 App Router patterns. + Trigger: When working in Next.js App Router (app/), Server Components vs Client Components, Server Actions, Route Handlers, proxy.ts, caching/revalidation, Cache Components, and streaming/Suspense. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, ui] + auto_invoke: "App Router / Server Actions" +allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task +--- + +## App Router File Conventions + +```text +app/ +├── layout.tsx # Root layout (required) +├── page.tsx # Home page (/) +├── loading.tsx # Loading UI (Suspense) +├── error.tsx # Error boundary +├── not-found.tsx # 404 page +├── (auth)/ # Route group (no URL impact) +│ ├── login/page.tsx # /login +│ └── signup/page.tsx # /signup +├── api/ +│ └── route.ts # API handler +└── _components/ # Private folder (not routed) +``` + +## Next.js 16 Notes + +- Use `proxy.ts` for request-boundary logic. `middleware.ts` is deprecated in Next.js 16. +- `proxy.ts` runs on the Node.js runtime and cannot be configured for Edge. +- Keep `proxy.ts` matchers narrow. Exclude `api`, static files, and image assets unless the route explicitly needs proxy logic. +- Route Handlers in `app/api/**/route.ts` are the right fit for health checks, webhooks, backend-for-frontend endpoints, and server-only proxy calls. + +## Server Components (Default) + +```typescript +// No directive needed - async by default +export default async function Page() { + const data = await db.query(); + return ; +} +``` + +## Server Actions + +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +export async function createUser(formData: FormData) { + const name = formData.get("name") as string; + + await db.users.create({ data: { name } }); + + revalidatePath("/users"); + redirect("/users"); +} +``` + +## Data Fetching + +```typescript +async function Page() { + const [users, posts] = await Promise.all([getUsers(), getPosts()]); + + return ; +} + +}> + +; +``` + +## Caching and Revalidation + +```typescript +import { revalidatePath, revalidateTag } from "next/cache"; + +export async function refreshDashboard() { + "use server"; + + revalidatePath("/"); + revalidateTag("dashboard"); +} +``` + +- Use `revalidatePath` for route-level invalidation after mutations. +- Use `revalidateTag` when data fetches share a cache tag across routes. +- With Cache Components enabled, put `"use cache"` only in pure server-side cached functions. Do not cache auth, tenant-scoped, or per-user responses unless the cache key explicitly isolates them. + +## Route Handlers (API) + +```typescript +// app/api/users/route.ts +import { NextResponse } from "next/server"; + +export async function GET() { + const users = await db.users.findMany(); + return NextResponse.json(users); +} + +export async function POST(request: Request) { + const body = await request.json(); + const user = await db.users.create({ data: body }); + return NextResponse.json(user, { status: 201 }); +} +``` + +## Proxy + +```typescript +// proxy.ts (root level) +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function proxy(request: NextRequest) { + const token = request.cookies.get("token"); + + if (!token && request.nextUrl.pathname.startsWith("/dashboard")) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*"], +}; +``` + +## Metadata + +```typescript +export const metadata = { + title: "My App", + description: "Description", +}; + +export async function generateMetadata() { + const product = await getProduct(); + return { title: product.name }; +} +``` + +## server-only Package + +```typescript +import "server-only"; + +export async function getSecretData() { + return db.secrets.findMany(); +} +``` diff --git a/skills/playwright/SKILL.md b/skills/playwright/SKILL.md index d9009d6f4a..60c1db9fd5 100644 --- a/skills/playwright/SKILL.md +++ b/skills/playwright/SKILL.md @@ -36,7 +36,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## File Structure -``` +```text tests/ ├── base-page.ts # Parent class for ALL pages ├── helpers.ts # Shared utilities @@ -182,14 +182,14 @@ export class SignUpPage extends BasePage { ## Refactoring Guidelines -### Move to `BasePage` when: +### Move to `BasePage` when - ✅ Navigation helpers used by multiple pages (`waitForPageLoad()`, `getCurrentUrl()`) - ✅ Common UI interactions (notifications, modals, theme toggles) - ✅ Verification patterns repeated across pages (`isVisible()`, `waitForVisible()`) - ✅ Error handling that applies to all pages - ✅ Screenshot utilities for debugging -### Move to `helpers.ts` when: +### Move to `helpers.ts` when - ✅ Test data generation (`generateUniqueEmail()`, `generateTestUser()`) - ✅ Setup/teardown utilities (`createTestUser()`, `cleanupTestData()`) - ✅ Custom assertions (`expectNotificationToContain()`) diff --git a/skills/postgresql-indexing/SKILL.md b/skills/postgresql-indexing/SKILL.md index 7fac9f4ecd..615000ad9e 100644 --- a/skills/postgresql-indexing/SKILL.md +++ b/skills/postgresql-indexing/SKILL.md @@ -225,7 +225,7 @@ Rebuild invalid indexes without locking writes: REINDEX INDEX CONCURRENTLY index_name; ``` -### Understanding _ccnew and _ccold artifacts +### Understanding _ccnew and_ccold artifacts When `CREATE INDEX CONCURRENTLY` or `REINDEX INDEX CONCURRENTLY` is interrupted, temporary indexes may remain: @@ -377,7 +377,8 @@ VACUUM (ANALYZE) table_name; | PostgreSQL | `/websites/postgresql_org_docs_current` | Index types, EXPLAIN, partitioned table indexing, REINDEX | **Example queries:** -``` + +```text mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="CREATE INDEX CONCURRENTLY partitioned table") mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="EXPLAIN ANALYZE BUFFERS query plan") mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="partial index WHERE clause") diff --git a/skills/prowler-api/SKILL.md b/skills/prowler-api/SKILL.md index 1e654a3e03..0c7dc86222 100644 --- a/skills/prowler-api/SKILL.md +++ b/skills/prowler-api/SKILL.md @@ -61,7 +61,7 @@ Provider.objects.filter(connected=True) # Requires rls_transaction context ### RLS Transaction Flow -``` +```text Request → Authentication → BaseRLSViewSet.initial() │ ├─ Extract tenant_id from JWT @@ -92,7 +92,7 @@ When implementing Prowler-specific API features: ## Decision Trees ### Which Base Model? -``` +```text Tenant-scoped data → RowLevelSecurityProtectedModel Global/shared data → models.Model + BaseSecurityConstraint (rare) Partitioned time-series → PostgresPartitionedModel + RowLevelSecurityProtectedModel @@ -100,14 +100,14 @@ Soft-deletable → Add is_deleted + ActiveProviderManager ``` ### Which Manager? -``` +```text Normal queries → Model.objects (excludes deleted) Include deleted records → Model.all_objects Celery task context → Must use rls_transaction() first ``` ### Which Database? -``` +```text Standard API queries → default (automatic via ViewSet) Read-only operations → replica (automatic for GET in BaseRLSViewSet) Auth/admin operations → MainRouter.admin_db @@ -115,7 +115,7 @@ Cross-tenant lookups → MainRouter.admin_db (use sparingly!) ``` ### Celery Task Decorator Order? -``` +```python @shared_task(base=RLSTask, name="...", queue="...") @set_tenant # First: sets tenant context @handle_provider_deletion # Second: handles deleted providers @@ -432,7 +432,7 @@ def process_finding(tenant_id, finding_uid, data): Run before every production deployment: ```bash -cd api && poetry run python src/backend/manage.py check --deploy +cd api && uv run python src/backend/manage.py check --deploy ``` ### Critical Settings @@ -454,18 +454,18 @@ cd api && poetry run python src/backend/manage.py check --deploy ```bash # Development -cd api && poetry run python src/backend/manage.py runserver -cd api && poetry run python src/backend/manage.py shell +cd api && uv run python src/backend/manage.py runserver +cd api && uv run python src/backend/manage.py shell # Celery -cd api && poetry run celery -A config.celery worker -l info -Q scans,overview -cd api && poetry run celery -A config.celery beat -l info +cd api && uv run celery -A config.celery worker -l info -Q scans,overview +cd api && uv run celery -A config.celery beat -l info # Testing -cd api && poetry run pytest -x --tb=short +cd api && uv run pytest -x --tb=short # Production checks -cd api && poetry run python src/backend/manage.py check --deploy +cd api && uv run python src/backend/manage.py check --deploy ``` --- @@ -496,7 +496,7 @@ When implementing or debugging Prowler-specific patterns, query these libraries | **Django** | `/websites/djangoproject_en_5_2` | Models, ORM, constraints, indexes | **Example queries:** -``` +```text mcp_context7_query-docs(libraryId="/websites/celeryq_dev_en_stable", query="shared_task decorator retry patterns") mcp_context7_query-docs(libraryId="/celery/django-celery-beat", query="periodic task database scheduler") mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_2", query="model constraints CheckConstraint UniqueConstraint") diff --git a/skills/prowler-api/references/configuration.md b/skills/prowler-api/references/configuration.md index 677f999cc4..0a68d58951 100644 --- a/skills/prowler-api/references/configuration.md +++ b/skills/prowler-api/references/configuration.md @@ -2,7 +2,7 @@ ## Settings File Structure -``` +```text api/src/backend/config/ ├── django/ │ ├── base.py # Base settings (all environments) diff --git a/skills/prowler-api/references/modeling-decisions.md b/skills/prowler-api/references/modeling-decisions.md index 68923c4ef4..c11ed585ec 100644 --- a/skills/prowler-api/references/modeling-decisions.md +++ b/skills/prowler-api/references/modeling-decisions.md @@ -247,7 +247,7 @@ class JSONAPIMeta: ## Decision Tree: New Model -``` +```text Is it tenant-scoped data? ├── Yes → Inherit RowLevelSecurityProtectedModel │ Add RowLevelSecurityConstraint diff --git a/skills/prowler-api/references/production-settings.md b/skills/prowler-api/references/production-settings.md index 12b64483bf..15425f624f 100644 --- a/skills/prowler-api/references/production-settings.md +++ b/skills/prowler-api/references/production-settings.md @@ -3,7 +3,7 @@ ## Django Deployment Checklist Command ```bash -cd api && poetry run python src/backend/manage.py check --deploy +cd api && uv run python src/backend/manage.py check --deploy ``` This command checks for common deployment issues and missing security settings. diff --git a/skills/prowler-attack-paths-query/SKILL.md b/skills/prowler-attack-paths-query/SKILL.md index fb25d4fa43..9fedff4472 100644 --- a/skills/prowler-attack-paths-query/SKILL.md +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -2,13 +2,14 @@ name: prowler-attack-paths-query description: > Creates Prowler Attack Paths openCypher queries using the Cartography schema as the source of truth - for node labels, properties, and relationships. Also covers Prowler-specific additions (Internet node, - ProwlerFinding, internal isolation labels) and $provider_uid scoping for predefined queries. + for node labels, properties, and relationships. Covers Prowler-specific additions (Internet node, + ProwlerFinding, internal isolation labels), $provider_uid scoping, and list-property item nodes + with typed `HAS_*` edges that run efficiently on both Neo4j and Amazon Neptune sinks. Trigger: When creating or updating Attack Paths queries. license: Apache-2.0 metadata: author: prowler-cloud - version: "2.0" + version: "3.0" scope: [root, api] auto_invoke: - "Creating Attack Paths queries" @@ -19,36 +20,30 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, Task ## Overview -Attack Paths queries are openCypher queries that analyze cloud infrastructure graphs (ingested via Cartography) to detect security risks like privilege escalation paths, network exposure, and misconfigurations. - -Queries are written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. +Attack Paths queries are read-only openCypher queries over a Cartography-ingested cloud graph that detect privilege escalation chains, network exposure, and other graph-shaped security risks. Queries are written in openCypher Version 9 so they run on both Neo4j and Amazon Neptune sinks. --- ## Two query audiences -This skill covers two types of queries with different isolation mechanisms: +| | Predefined queries | Custom queries | +| ------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- | +| Where they live | `api/src/backend/api/attack_paths/queries/{provider}.py` | User-supplied via the custom query API endpoint | +| Provider isolation | `AWSAccount {id: $provider_uid}` anchor + path connectivity | Automatic `_Provider_{uuid}` label injection by `cypher_sanitizer.py` | +| What to write | Chain every MATCH from the `aws` variable | Plain Cypher, no isolation boilerplate | +| Internal labels | Never use | Never use (system-injected) | -| | Predefined queries | Custom queries | -|---|---|---| -| **Where they live** | `api/src/backend/api/attack_paths/queries/{provider}.py` | User/LLM-supplied via the custom query API endpoint | -| **Provider isolation** | `AWSAccount {id: $provider_uid}` anchor + path connectivity | Automatic `_Provider_{uuid}` label injection via `cypher_sanitizer.py` | -| **What to write** | Chain every MATCH from the `aws` variable | Plain Cypher, no isolation boilerplate needed | -| **Internal labels** | Never use (`_ProviderResource`, `_Tenant_*`, `_Provider_*`) | Never use (injected automatically by the system) | +**Predefined queries**: every node must be reachable from the `AWSAccount` root via graph traversal. That is the isolation boundary. -**For predefined queries**: every node must be reachable from the `AWSAccount` root via graph traversal. This is the isolation boundary. - -**For custom queries**: write natural Cypher without isolation concerns. The query runner injects a `_Provider_{uuid}` label into every node pattern before execution, and a post-query filter catches edge cases. +**Custom queries**: write natural Cypher. The runner injects a `_Provider_{uuid}` label into every node pattern, and a post-query filter handles edge cases. --- -## Input Sources +## Input sources -Queries can be created from: +Two sources for new queries: -1. **pathfinding.cloud ID** (e.g., `ECS-001`, `GLUE-001`) - - Reference: https://github.com/DataDog/pathfinding.cloud - - The aggregated `paths.json` is too large for WebFetch. Use Bash: +1. **pathfinding.cloud ID** (e.g. `ECS-001`, `GLUE-001`), the Datadog research catalogue. The aggregated `paths.json` is too large for WebFetch: ```bash # Fetch a single path by ID @@ -64,28 +59,24 @@ Queries can be created from: | jq -r '.[] | select(.id | startswith("ecs")) | "\(.id): \(.name)"' ``` - If `jq` is not available, use `python3 -c "import json,sys; ..."` as a fallback. + If `jq` is unavailable, use `python3 -c "import json,sys; ..."`. -2. **Natural language description** from the user +2. **Natural language description** from the requester. --- -## Query Structure +## Query structure ### Provider scoping parameter -One parameter is injected automatically by the query runner: +| Parameter | Property | Used on | Purpose | +| --------------- | -------- | ------------ | -------------------------------------- | +| `$provider_uid` | `id` | `AWSAccount` | Scopes the query to a specific account | -| Parameter | Property it matches | Used on | Purpose | -| --------------- | ------------------- | ------------ | -------------------------------- | -| `$provider_uid` | `id` | `AWSAccount` | Scopes to a specific AWS account | - -All other nodes are isolated by path connectivity from the `AWSAccount` anchor. +The runner binds `$provider_uid` automatically. Every other node is isolated by path connectivity from the `AWSAccount` anchor. ### Imports -All query files start with these imports: - ```python from api.attack_paths.queries.types import ( AttackPathsQueryAttribution, @@ -95,29 +86,33 @@ from api.attack_paths.queries.types import ( from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL ``` -The `PROWLER_FINDING_LABEL` constant (value: `"ProwlerFinding"`) is used via f-string interpolation in all queries. Never hardcode the label string. +Always use `PROWLER_FINDING_LABEL` via f-string interpolation, never hardcode `"ProwlerFinding"`. -### Privilege escalation sub-patterns +### Definition fields -There are four distinct privilege escalation patterns. Choose based on the attack type: +- **id**: kebab-case `{provider}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`. +- **name**: short, human-friendly label. Sourced queries append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. +- **short_description**: one sentence, no technical permissions. +- **description**: full technical explanation, plain text. +- **provider**: `aws`, `azure`, `gcp`, `kubernetes`, or `github`. +- **cypher**: f-string Cypher body. Literal `{` / `}` are escaped as `{{` / `}}`. +- **parameters**: `parameters=[]` if none. +- **attribution**: optional `AttackPathsQueryAttribution(text, link)` for sourced queries. `link` uses the lowercase ID. -| Sub-pattern | Target | `path_target` shape | Example | -|---|---|---|---| -| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 | -| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 | -| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 | -| PassRole + service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(...)` | EC2-001 | +Append the constant to the `{PROVIDER}_QUERIES` list at the bottom of the provider file. -#### Self-escalation (e.g., IAM-001) +--- -The principal modifies resources attached to itself. `path_target` loops back to `principal`: +## Predefined query template + +The canonical shape combines a principal walk, an optional target walk, deduplicated nodes, and a typed finding overlay: ```python AWS_{QUERY_NAME} = AttackPathsQueryDefinition( id="aws-{kebab-case-name}", - name="{Human-friendly label} ({REFERENCE_ID})", - short_description="{Brief explanation, no technical permissions.}", - description="{Detailed description of the attack vector and impact.}", + name="{Label} ({REFERENCE_ID})", + short_description="{One sentence.}", + description="{Full technical explanation.}", attribution=AttackPathsQueryAttribution( text="pathfinding.cloud - {REFERENCE_ID} - {permission}", link="https://pathfinding.cloud/paths/{reference_id_lowercase}", @@ -125,29 +120,27 @@ AWS_{QUERY_NAME} = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find principals with {permission} - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) - WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = '{permission_lowercase}' - OR toLower(action) = '{service}:*' - OR action = '*' - ) + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['{permission_lowercase}', '{service}:*'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal - // Find target resources attached to the same principal + // Target resources attached to the same principal (sub-patterns below) MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) WHERE target_policy.arn CONTAINS $provider_uid - AND any(resource IN stmt.resource WHERE - resource = '*' - OR target_policy.arn CONTAINS resource - ) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR target_policy.arn CONTAINS res.value + WITH DISTINCT path_principal, path_target WITH collect(path_principal) + collect(path_target) AS paths UNWIND paths AS p UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, @@ -155,158 +148,145 @@ AWS_{QUERY_NAME} = AttackPathsQueryDefinition( ) ``` -#### Other sub-pattern `path_target` shapes +Key points: -The other 3 sub-patterns share the same `path_principal`, deduplication tail, and RETURN as self-escalation. Only the `path_target` MATCH differs: +- The principal walk types the `POLICY` and `STATEMENT` hops. Both are low-fan-out (each principal has a handful of policies; each policy a handful of statements), so the typed edge lets the planner cost a cheap inline filter. +- The `(aws)--` hub hops stay anonymous. `AWSAccount` is a high-degree node that fans out to every principal, role, policy, and resource in the account; typing those edges forces the planner to enumerate from the hub and collapses performance on multi-tenant Neptune. +- Other relationship types appear only where the file's existing queries already use one (`TRUSTS_AWS_PRINCIPAL`, `STS_ASSUMEROLE_ALLOW`, `MEMBER_AWS_GROUP`, `HAS_EXECUTION_ROLE`). +- The finding probe is typed `:HAS_FINDING` and left undirected. The type lets Neptune apply an inline edge filter; the lack of direction matches the convention of the rest of the file. +- Collapse duplicate rows after each permission gate with `WITH DISTINCT`, carrying only the variables needed by later clauses. +- Each `HAS_*` traversal is its own `MATCH` clause with a `WHERE` on the child item node. `WITH DISTINCT path_principal, path_target` precedes `collect(path...)` to dedupe the row multiplication produced by the joins. +- The `RETURN` shape `paths, dpf, dpfr` is the contract the serializer and visualiser depend on. Do not change it. + +--- + +## Privilege escalation sub-patterns + +Four `path_target` shapes cover the common attack types. Each shares the canonical template's `path_principal`, deduplication tail, and `RETURN`; only the `path_target` MATCH and its resource predicate differ. + +| Sub-pattern | Target | `path_target` shape | Example | +| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | ------- | +| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 | +| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 | +| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 | +| PassRole + service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: '{service}.amazonaws.com'})` | EC2-001 | + +**Multi-permission queries** (e.g. PassRole plus a service-create action) add permission gates before `path_target`. Reuse the per-query counter for new variables (`act2`, `policy2`, `stmt2`) and collapse rows after each gate: ```cypher -// Lateral to user (e.g., IAM-002) - targets other IAM users -MATCH path_target = (aws)--(target_user:AWSUser) -WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_user.arn CONTAINS resource OR resource CONTAINS target_user.name) - -// Assume-role lateral (e.g., IAM-014) - targets roles the principal can assume -MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) -WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name) - -// PassRole + service (e.g., EC2-001) - targets roles trusting a service -MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: '{service}.amazonaws.com'}) -WHERE any(resource IN stmt.resource WHERE resource = '*' OR target_role.arn CONTAINS resource OR resource CONTAINS target_role.name) +MATCH (principal)-[:POLICY]->(policy2:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) +WHERE toLower(act2.value) IN ['service:*', 'service:createsomething'] + OR act2.value = '*' +WITH DISTINCT aws, principal, stmt, stmt2, path_principal ``` -**Multi-permission**: PassRole queries require a second permission. Add `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` with its own WHERE before `path_target`, then check BOTH `stmt.resource` AND `stmt2.resource` against the target. See IAM-015 or EC2-001 in `aws.py` for examples. +If a permission is an existence-only gate whose statement resource is not checked later, keep the policy and statement anonymous and carry only the variables still needed: -### Network exposure pattern +```cypher +MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {effect: 'Allow'})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) +WHERE toLower(act3.value) IN ['service:*', 'service:othersomething'] + OR act3.value = '*' +WITH DISTINCT aws, principal, stmt, path_principal +``` -The Internet node is reached via `CAN_ACCESS` through the already-scoped resource, not via a standalone lookup: +When all matching principals can target the same independent resource set, collect principal paths before expanding targets instead of creating one row per principal-target pair: + +```cypher +WITH aws, collect(DISTINCT path_principal) AS principal_paths +MATCH path_target = (aws)--(target) +WITH principal_paths + collect(DISTINCT path_target) AS paths +``` + +Statements that constrain a target are still checked via `HAS_RESOURCE` traversals (`res`, `res2`). See IAM-015 or EC2-001 in `aws.py`. + +--- + +## Network exposure pattern + +The Internet node is reached via `CAN_ACCESS` through an already-scoped resource, never as a standalone lookup: ```python -AWS_{QUERY_NAME} = AttackPathsQueryDefinition( - id="aws-{kebab-case-name}", - name="{Human-friendly label}", - short_description="{Brief explanation.}", - description="{Detailed description.}", - provider="aws", - cypher=f""" - // Match exposed resources (MUST chain from `aws`) - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance) - WHERE resource.exposed_internet = true +cypher=f""" + // Resource scoped through the account anchor + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance) + WHERE resource.exposed_internet = true - // Internet node reached via path connectivity through the resource - OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) + // Internet node reached via path connectivity through the resource + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) - WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access - UNWIND paths AS p - UNWIND nodes(p) AS n + WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access + UNWIND paths AS p + UNWIND nodes(p) AS n - WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes - UNWIND unique_nodes AS n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) - RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, - internet, can_access - """, - parameters=[], -) + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, + internet, can_access +""" ``` -### Register in query list - -Add to the `{PROVIDER}_QUERIES` list at the bottom of the file: - -```python -AWS_QUERIES: list[AttackPathsQueryDefinition] = [ - # ... existing queries ... - AWS_{NEW_QUERY_NAME}, # Add here -] -``` +The `CAN_ACCESS` edge stays typed and directed (`-[:CAN_ACCESS]->`); that is its canonical sync-time orientation. --- -## Step-by-step creation process +## List-typed properties as child nodes -### 1. Read the queries module +Some Cartography node properties carry a list of values: `AWSPolicyStatement.action`, `AWSPolicyStatement.resource`, `KMSKey.encryption_algorithms`, `CloudFrontDistribution.aliases`, and many others. The graph models each such property as a set of child item nodes connected to the parent by a typed edge. Queries reach the values by traversing the edge; the parent does not carry the list as a single field. -**FIRST**, read all files in the queries module to understand the structure, type definitions, registration, and existing style: +### Naming convention -``` -api/src/backend/api/attack_paths/queries/ -├── __init__.py # Module exports -├── types.py # AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition -├── registry.py # Query registry logic -└── {provider}.py # Provider-specific queries (e.g., aws.py) +For a list-typed parent property the sink stores: + +- **Child label**: `Item`. Example: `AWSPolicyStatement.resource` → `AWSPolicyStatementResourceItem`. +- **Edge type**: `HAS_`. Example: `resource` → `HAS_RESOURCE`. +- **Child property**: `value` (a single scalar string) for scalar-list properties. For list-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) the child carries the dict keys as named fields per the catalog's `field_map`. + +### Variable naming for child-item matches + +`aws.py` uses a per-query counter for each `HAS_*` traversal so chained matches stay unambiguous: + +| Edge | First | Second | Third | +| ----------------- | ------ | ------- | ------- | +| `HAS_ACTION` | `act` | `act2` | `act3` | +| `HAS_RESOURCE` | `res` | `res2` | `res3` | +| `HAS_NOTACTION` | `nact` | `nact2` | `nact3` | +| `HAS_NOTRESOURCE` | `nres` | `nres2` | `nres3` | + +The counter resets at the top of every query. + +### Example - action match + +Find statements that grant `iam:PassRole`, `iam:*`, or `*`. Traverse the `HAS_ACTION` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`: + +```cypher +MATCH (stmt:AWSPolicyStatement {effect: 'Allow'}) +MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) +WHERE toLower(act.value) IN ['iam:passrole', 'iam:*'] + OR act.value = '*' ``` -**DO NOT** use generic templates. Match the exact style of existing queries in the file. +The literal-action list is case-folded with `toLower(act.value)` because IAM authors mix case (`iam:PassRole`, `iam:passrole`); the `*` wildcard never lower-cases. -### 2. Fetch and consult the Cartography schema +### Example - resource ARN match -**This is the most important step.** Every node label, property, and relationship in the query must exist in the Cartography schema for the pinned version. Do not guess or rely on memory. +Find statements whose resource can target a specific role: -Check `api/pyproject.toml` for the Cartography dependency, then fetch the schema: - -```bash -grep cartography api/pyproject.toml +```cypher +MATCH path_target = (aws)--(target_role:AWSRole) +MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) +WHERE res.value = '*' + OR res.value CONTAINS target_role.name + OR target_role.arn CONTAINS res.value ``` -Build the schema URL (ALWAYS use the specific tag, not master/main): +Three predicates cover the cases: full wildcard (`*`), pattern containing the role name (`arn:aws:iam::*:role/admin*`), and pattern that is a prefix or component of the actual ARN. -``` -# Git dependency (prowler-cloud/cartography@0.126.1): -https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/0.126.1/docs/root/modules/{provider}/schema.md +### Catalog of list properties -# PyPI dependency (cartography = "^0.126.0"): -https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/{provider}/schema.md -``` - -Read the schema to discover available node labels, properties, and relationships for the target resources. Internal labels (`_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*`) exist for isolation but should never appear in queries. - -### 4. Create query definition - -Use the appropriate pattern (privilege escalation or network exposure) with: - -- **id**: `{provider}-{kebab-case-description}` -- **name**: Short, human-friendly label. For sourced queries, append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. -- **short_description**: Brief explanation, no technical permissions. -- **description**: Full technical explanation. Plain text only. -- **provider**: Provider identifier (aws, azure, gcp, kubernetes, github) -- **cypher**: The openCypher query with proper escaping -- **parameters**: Optional list of user-provided parameters (`parameters=[]` if none) -- **attribution**: Optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `text` includes source, reference ID, and permissions. The `link` uses a lowercase ID. Omit for non-sourced queries. - -### 5. Add query to provider list - -Add the constant to the `{PROVIDER}_QUERIES` list. - ---- - -## Query naming conventions - -### Query ID - -``` -{provider}-{category}-{description} -``` - -Examples: `aws-ec2-privesc-passrole-iam`, `aws-ec2-instances-internet-exposed` - -### Query constant name - -``` -{PROVIDER}_{CATEGORY}_{DESCRIPTION} -``` - -Examples: `AWS_EC2_PRIVESC_PASSROLE_IAM`, `AWS_EC2_INSTANCES_INTERNET_EXPOSED` - ---- - -## Query categories - -| Category | Description | Example | -| -------------------- | ------------------------------ | ------------------------- | -| Basic Resource | List resources with properties | RDS instances, S3 buckets | -| Network Exposure | Internet-exposed resources | EC2 with public IPs | -| Privilege Escalation | IAM privilege escalation paths | PassRole + RunInstances | -| Data Access | Access to sensitive data | EC2 with S3 access | +The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_config.py` (`AWS_NORMALIZED_LISTS`). Beyond policy statements it includes KMS algorithms, ECS container-definition lists (`entry_point`, `command`, `links`, `dns_servers`, ...), CloudFront aliases, Inspector finding URL and vulnerability lists, RDS event-subscription categories, and others. To query a list property that is not in the catalog, add an entry there first so the sync layer materialises it. --- @@ -315,53 +295,42 @@ Examples: `AWS_EC2_PRIVESC_PASSROLE_IAM`, `AWS_EC2_INSTANCES_INTERNET_EXPOSED` ### Match account and principal ```cypher -MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) +MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'}) ``` -### Check IAM action permissions +The `(aws)--(principal)` hop stays anonymous; the `POLICY` and `STATEMENT` hops are typed. + +### Roles trusting a service ```cypher -WHERE stmt.effect = 'Allow' - AND any(action IN stmt.action WHERE - toLower(action) = 'iam:passrole' - OR toLower(action) = 'iam:*' - OR action = '*' - ) +MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) ``` -### Find roles trusting a service +### Roles a principal can assume ```cypher -MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) +MATCH path_target = (aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal) ``` -### Find roles the principal can assume +### JSON-encoded properties -Note the arrow direction - `STS_ASSUMEROLE_ALLOW` points from the role to the principal: +Object-typed Cartography properties (most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement`) are stored as JSON-encoded strings, e.g. `'{"StringEquals":{"aws:SourceAccount":"123456789012"}}'`. There is no JSON parser at query time, so use `CONTAINS` for substring checks: ```cypher -MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) +WHERE stmt.condition CONTAINS '"aws:SourceAccount"' ``` -### Check resource scope - -```cypher -WHERE any(resource IN stmt.resource WHERE - resource = '*' - OR target_role.arn CONTAINS resource - OR resource CONTAINS target_role.name -) -``` +For structured inspection, fetch the rows and parse in Python. Cypher cannot navigate JSON object keys. ### Internet node via path connectivity -The Internet node is reached through `CAN_ACCESS` relationships to already-scoped resources. No standalone lookup needed: - ```cypher OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) ``` -### Multi-label OR (match multiple resource types) +`resource` must already be bound by the account-anchored pattern above. + +### Multi-label OR (multiple resource types) ```cypher MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x)-[q]-(y) @@ -373,7 +342,7 @@ WHERE (x:EC2PrivateIp AND x.public_ip = $ip) ### Include Prowler findings -Deduplicate nodes before the ProwlerFinding lookup to avoid redundant OPTIONAL MATCH calls on nodes that appear in multiple paths: +Deduplicate nodes before the typed finding probe to avoid one `OPTIONAL MATCH` per path-occurrence of the same node: ```cypher WITH collect(path_principal) + collect(path_target) AS paths @@ -382,12 +351,12 @@ UNWIND nodes(p) AS n WITH paths, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n -OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) +OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr ``` -For network exposure queries, aggregate the internet node and relationship alongside paths: +For network-exposure queries, aggregate the Internet node and its edge alongside paths: ```cypher WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access @@ -396,7 +365,7 @@ UNWIND nodes(p) AS n WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes UNWIND unique_nodes AS n -OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) +OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access @@ -406,22 +375,22 @@ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, ## Prowler-specific labels and relationships -These are added by the sync task, not part of the Cartography schema. For all other node labels, properties, and relationships, **always consult the Cartography schema** (see step 2 below). +Added by the sync task, not part of the Cartography schema. For everything else, consult the pinned Cartography schema (see "Creation steps"). -| Label/Relationship | Description | -| ---------------------- | -------------------------------------------------- | -| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) | -| `Internet` | Internet sentinel node | -| `CAN_ACCESS` | Internet-to-resource exposure (relationship) | -| `HAS_FINDING` | Resource-to-finding link (relationship) | -| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | -| `STS_ASSUMEROLE_ALLOW` | Can assume role (direction: role -> principal) | +| Label / Relationship | Description | +| ---------------------- | ----------------------------------------------------------- | +| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) | +| `Internet` | Internet sentinel node | +| `CAN_ACCESS` | `(Internet)-[:CAN_ACCESS]->(resource)` exposure edge | +| `HAS_FINDING` | `(resource)-[:HAS_FINDING]->(:ProwlerFinding)` finding link | +| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | +| `STS_ASSUMEROLE_ALLOW` | Can assume role | --- ## Parameters -For queries requiring user input: +For queries that take user input: ```python parameters=[ @@ -438,50 +407,83 @@ parameters=[ --- -## Best practices +## openCypher compatibility -1. **Chain all MATCHes from the root account node**: Every `MATCH` clause must connect to the `aws` variable (or another variable already bound to the account's subgraph). An unanchored `MATCH` would return nodes from all providers. +Queries must run on both Neo4j and Amazon Neptune. Avoid these constructs: - ```cypher - // WRONG: matches ALL AWSRoles across all providers - MATCH (role:AWSRole) WHERE role.name = 'admin' +| Feature | Use instead | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph | +| Neptune extensions | Standard openCypher | +| `reduce()` | `UNWIND` + `collect()` | +| `FOREACH` | `WITH` + `UNWIND` + `SET` | +| Regex `=~` | `toLower()` + exact match, or `STARTS WITH` / `CONTAINS` | +| `CALL () { UNION }` | Multi-label `OR` in `WHERE` (see pattern above) | +| `any(x IN list ...)` | `size([x IN list WHERE pred]) > 0` | +| `all(x IN list ...)` | `size([x IN list WHERE pred]) = size(list)` | +| `none(x IN list ...)` | `size([x IN list WHERE pred]) = 0` | +| `EXISTS { MATCH (pattern) WHERE pred }` | Standalone `MATCH (pattern)` + `WHERE pred`; precede the downstream `collect(path...)` with `WITH DISTINCT ` to dedupe the joins | - // CORRECT: scoped to the specific account's subgraph - MATCH (aws)--(role:AWSRole) WHERE role.name = 'admin' - ``` - - **Exception**: A second-permission MATCH like `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account's subgraph by the first MATCH. It does not need to chain from `aws` again. - -2. **Include Prowler findings**: Always add `OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})` with `collect(DISTINCT pf)`. - -3. **Comment the query purpose**: Add inline comments explaining each MATCH clause. - -4. **Never use internal labels in queries**: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*` are for system isolation. They should never appear in predefined or custom query text. - -6. **Internet node uses path connectivity**: Reach it via `OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource)` where `resource` is already scoped by the account anchor. No standalone lookup. +For list-typed properties in the catalog (action, resource, and so on), traverse the `HAS_*` edges to the child item nodes via the multi-`MATCH` shape shown in "List-typed properties as child nodes". The parent node does not carry the list as a single field, so `split(...)` and comma-string predicates do not apply. --- -## openCypher compatibility +## Best practices -Queries must be written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. +1. **Chain every MATCH from the account anchor.** An unanchored `MATCH (role:AWSRole)` returns roles from every provider in the graph; `MATCH (aws)--(role:AWSRole)` is scoped. A second-permission MATCH like `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account's subgraph. +2. **Type the finding probe.** Always `OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})`. The type lets Neptune apply an inline edge filter; an untyped probe scans every incident edge of high-degree nodes. +3. **Comment each MATCH.** One inline `// ...` line per clause explaining its role. +4. **Never use internal labels.** `_ProviderResource`, `_AWSResource`, `_Tenant_*`, `_Provider_*` are system isolation labels and must not appear in query text (predefined or custom). +5. **Reach the Internet node through path connectivity** via `(internet:Internet)-[:CAN_ACCESS]->(resource)`, never as a standalone match. +6. **Preserve the `RETURN` contract.** `paths, dpf, dpfr` for the standard shape; add `internet, can_access` for network-exposure queries. The serializer and visualiser depend on these names. -### Avoid these (not in openCypher spec) +--- -| Feature | Use instead | -| -------------------------- | ------------------------------------------------------ | -| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph | -| Neptune extensions | Standard openCypher | -| `reduce()` function | `UNWIND` + `collect()` | -| `FOREACH` clause | `WITH` + `UNWIND` + `SET` | -| Regex operator (`=~`) | `toLower()` + exact match, or `CONTAINS`/`STARTS WITH`. One legacy query uses `=~` - do not add new usages | -| `CALL () { UNION }` | Multi-label OR in WHERE (see patterns section) | +## Naming conventions + +- **ID**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`. +- **Constant**: SHOUTING*SNAKE_CASE `{PROVIDER}*{CATEGORY}\_{DESCRIPTION}`, e.g. `AWS_EC2_PRIVESC_PASSROLE_IAM`. + +--- + +## Creation steps + +1. **Read the queries module first** to match the existing style: + + ```text + api/src/backend/api/attack_paths/queries/ + ├── __init__.py + ├── types.py # dataclass definitions + ├── registry.py + └── {provider}.py + ``` + +2. **Fetch the Cartography schema for the pinned version.** Do not guess labels, properties, or relationships. Read the dependency pin: + + ```bash + grep cartography api/pyproject.toml + ``` + + Then fetch the schema for that exact tag: + + ```text + # Git pin (prowler-cloud/cartography@): + https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags//docs/root/modules/{provider}/schema.md + + # PyPI pin (cartography==): + https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags//docs/root/modules/{provider}/schema.md + ``` + +3. **Build the query** using the canonical predefined template plus the appropriate sub-pattern (privilege escalation or network exposure). For list-typed properties (action/resource/etc.), traverse the exploded child nodes via `[:HAS_ACTION]->(:AWSPolicyStatementActionItem)` etc. (see "List-typed properties as child nodes" and the `AWS_NORMALIZED_LISTS` catalog). + +4. **Register** the constant in the `{PROVIDER}_QUERIES` list at the bottom of the provider file. --- ## Reference -- **pathfinding.cloud**: https://github.com/DataDog/pathfinding.cloud (use `curl | jq`, not WebFetch) -- **Cartography schema**: `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md` -- **Neptune openCypher compliance**: https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html -- **openCypher spec**: https://github.com/opencypher/openCypher +- **pathfinding.cloud**: https://github.com/DataDog/pathfinding.cloud (use `curl | jq`; the aggregated `paths.json` is too large for WebFetch). +- **Cartography schema** (per pinned tag): `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{tag}/docs/root/modules/{provider}/schema.md`. +- **Neptune openCypher compliance**: https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html. +- **openCypher spec**: https://github.com/opencypher/openCypher. +- **Sync converter** (`tasks/jobs/attack_paths/sync.py`): list-typed node properties listed in `tasks/jobs/attack_paths/provider_config.py::AWS_NORMALIZED_LISTS` are materialised as child item nodes + `HAS_*` edges. Properties that are not in the catalog are serialised to a comma-delimited string and emit a one-time warning. Dict-typed properties become JSON strings. Same shape on both sinks. diff --git a/skills/prowler-changelog/SKILL.md b/skills/prowler-changelog/SKILL.md index 94f119e2ba..67e853b0fd 100644 --- a/skills/prowler-changelog/SKILL.md +++ b/skills/prowler-changelog/SKILL.md @@ -71,10 +71,13 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash - **Blank line after section header** before first entry - **Blank line between sections** - Be specific: what changed, not why (that's in the PR) +- Keep entries readable: use spaces around inline code and product names, and wrap endpoints, commands, errors, task names, and file paths in backticks +- Avoid long run-on sentences; split complex changes into one concise result plus one concise context clause - One entry per PR (can link multiple PRs for related changes) - No period at the end - Do NOT start with redundant verbs (section header already provides the action) - **CRITICAL: Preserve section order** — when adding a new section to the UNRELEASED block, insert it in the correct position relative to existing sections (Added → Changed → Deprecated → Removed → Fixed → Security). Never append a new section at the top or bottom without checking order +- **CRITICAL: ALWAYS link to the PR, NEVER to the issue.** Every entry MUST use `https://github.com/prowler-cloud/prowler/pull/N`. Linking to `/issues/N` is FORBIDDEN, even when the PR fixes an issue. The issue↔PR relationship belongs in the PR body (`Fixes #N`), not in the changelog. If a fix has no PR yet, do not add the entry until the PR exists. ### Semantic Versioning Rules @@ -114,6 +117,34 @@ Prowler follows [semver.org](https://semver.org/): --- # Horizontal rule between versions ``` +## Mandatory Changelog Preflight + +Before editing any `CHANGELOG.md`, always inspect the active release boundary: + +1. Read the UNRELEASED block plus the latest three released version blocks: + ```bash + awk '/^## \[/{n++} n<=4 {print}' ui/CHANGELOG.md + ``` +2. Identify the **only writable block**: the block whose header contains `(Prowler UNRELEASED)`. +3. Treat every block whose header contains `(Prowler vX.Y.Z)` as immutable. Do not add, move, reword, reorder, or deduplicate entries there. +4. If your PR's entry appears in any of the latest three released blocks, remove it from the released block and add it to the correct section in the UNRELEASED block. +5. If there is no UNRELEASED block at the top, stop and ask before editing. + +**Do not trust the current topmost matching section name.** A released block can contain the same section heading (`### 🚀 Added`, `### 🔄 Changed`, etc.). Always anchor edits to the `Prowler UNRELEASED` version block first. + +## Mandatory Human Confirmation Gate + +Before creating or editing any changelog file (`CHANGELOG.md`), the agent MUST stop and get explicit user confirmation. This applies even when the changelog gate is failing, the required edit seems obvious, or the user asked to "fix the changelog". + +Present the proposed changelog action before writing: + +1. Target file path. +2. Target version block and section. +3. Exact entry to add, move, remove, or rewrite. +4. Reason the changelog is needed. + +Only proceed after an explicit approval such as "confirm", "approved", "sí", or equivalent. If the user rejects or does not answer, do not edit or create the changelog. Offer alternatives such as adding `no-changelog` when appropriate. + ## Adding a Changelog Entry ### Step 1: Determine Affected Component(s) @@ -146,6 +177,8 @@ git diff main...HEAD --name-only **CRITICAL:** Add new entries at the BOTTOM of each section, NOT at the top. +**CRITICAL:** The link MUST point to the PR (`/pull/N`). Linking to `/issues/N` is FORBIDDEN. If the PR closes an issue, that mapping goes in the PR body via `Fixes #N` — never in the changelog entry. + ```markdown ## [1.17.0] (Prowler UNRELEASED) @@ -175,6 +208,15 @@ This maintains chronological order within each section (oldest at top, newest at - Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) ``` +### Readable Technical Entries + +```markdown +# GOOD - Technical but readable +### 🐞 Fixed +- `POST /api/v1/scans` no longer intermittently fails with `Scan matching query does not exist`; scan dispatch now publishes the `scan-perform` Celery task after the transaction commits [(#11122)](https://github.com/prowler-cloud/prowler/pull/11122) +- `entra_users_mfa_capable` no longer flags disabled guest users; Microsoft Graph is now the source of truth for `account_enabled` because EXO `Get-User` omits guest users [(#11002)](https://github.com/prowler-cloud/prowler/pull/11002) +``` + ### Bad Entries ```markdown @@ -189,6 +231,9 @@ This maintains chronological order within each section (oldest at top, newest at - Added new feature for users # Missing PR link, redundant verb - Add search bar [(#123)] # Redundant verb (section already says "Added") - This PR adds a cool new thing (#123) # Wrong link format, conversational +- Some bug fix [(#123)](https://github.com/prowler-cloud/prowler/issues/123) # FORBIDDEN: must link to /pull/N, never /issues/N +- POST /api/v1/scanswas intermittently failing withScan matching query does not existin thescan-performworker (#11122) # Missing spaces/backticks, unreadable +- entra_users_mfa_capable no longer flags disabled guest users by requesting accountEnabled and userType from Microsoft Graph via $select and using Graph as the source of truth for account_enabled (EXO Get-User does not return guest users) (#11002) # Run-on sentence, identifiers not formatted ``` ## PR Changelog Gate diff --git a/skills/prowler-changelog/assets/entry-templates.md b/skills/prowler-changelog/assets/entry-templates.md index dbca5bf25f..cc74efbb63 100644 --- a/skills/prowler-changelog/assets/entry-templates.md +++ b/skills/prowler-changelog/assets/entry-templates.md @@ -20,6 +20,8 @@ This maintains chronological order: oldest entries at top, newest at bottom. ## Entry Patterns > **Note:** Section headers already provide the verb. Entries describe WHAT, not the action. +> +> **Link target rule:** Every entry MUST link to the PR (`https://github.com/prowler-cloud/prowler/pull/N`). Linking to `/issues/N` is FORBIDDEN — even when the PR fixes an issue. The issue↔PR mapping belongs in the PR body (`Fixes #N`), not here. ### Feature Addition (🚀 Added) ```markdown @@ -40,6 +42,8 @@ This maintains chronological order: oldest entries at top, newest at bottom. - {What was broken} in {component} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) ``` +> When a PR fixes a reported issue, the link still goes to the PR (`/pull/N`), never the issue (`/issues/N`). Reference the issue from the PR body with `Fixes #N`. + ### Security Patch (🔐 Security) ```markdown - Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) diff --git a/skills/prowler-commit/SKILL.md b/skills/prowler-commit/SKILL.md index 0f67cbe882..30dbbc49cd 100644 --- a/skills/prowler-commit/SKILL.md +++ b/skills/prowler-commit/SKILL.md @@ -28,7 +28,7 @@ metadata: ## Commit Format -``` +```text type(scope): concise description - Key change 1 @@ -68,7 +68,7 @@ type(scope): concise description ### Title Line -``` +```text # GOOD - Concise and clear feat(api): add provider connection retry logic fix(ui): resolve dashboard loading state @@ -83,7 +83,7 @@ fix(ui): fix the bug in dashboard component on line 45 ### Body (Bullet Points) -``` +```text # GOOD - High-level changes - Add retry mechanism for failed connections - Document task composition patterns @@ -132,7 +132,7 @@ fix(ui): fix the bug in dashboard component on line 45 ## Decision Tree -``` +```text Single file changed? ├─ Yes → May omit body, title only └─ No → Include body with key changes diff --git a/skills/prowler-compliance-review/SKILL.md b/skills/prowler-compliance-review/SKILL.md index a494858eba..06f371f81b 100644 --- a/skills/prowler-compliance-review/SKILL.md +++ b/skills/prowler-compliance-review/SKILL.md @@ -53,7 +53,7 @@ diff dashboard/compliance/{new_framework}.py \ ## Decision Tree -``` +```text JSON Valid? ├── No → FAIL: Fix JSON syntax errors └── Yes ↓ @@ -168,16 +168,16 @@ After validation passes, test the framework with Prowler: ```bash # Verify framework is detected -poetry run python prowler-cli.py {provider} --list-compliance | grep {framework} +uv run python prowler-cli.py {provider} --list-compliance | grep {framework} # Run a quick test with a single check from the framework -poetry run python prowler-cli.py {provider} --compliance {framework} --check {check_name} +uv run python prowler-cli.py {provider} --compliance {framework} --check {check_name} # Run full compliance scan (dry-run with limited checks) -poetry run python prowler-cli.py {provider} --compliance {framework} --checks-limit 5 +uv run python prowler-cli.py {provider} --compliance {framework} --checks-limit 5 # Generate compliance report in multiple formats -poetry run python prowler-cli.py {provider} --compliance {framework} -M csv json html +uv run python prowler-cli.py {provider} --compliance {framework} -M csv json html ``` --- diff --git a/skills/prowler-compliance/SKILL.md b/skills/prowler-compliance/SKILL.md index 1853d23d8b..f119c7fa9b 100644 --- a/skills/prowler-compliance/SKILL.md +++ b/skills/prowler-compliance/SKILL.md @@ -1,16 +1,28 @@ --- name: prowler-compliance description: > - Creates and manages Prowler compliance frameworks. - Trigger: When working with compliance frameworks (CIS, NIST, PCI-DSS, SOC2, GDPR, ISO27001, ENS, MITRE ATT&CK). + Creates, syncs, audits and manages Prowler compliance frameworks end-to-end. + Covers the four-layer architecture (SDK models → JSON catalogs → output + formatters → API/UI), upstream sync workflows, cloud-auditor check-mapping + reviews, output formatter creation, and framework-specific attribute models. + Trigger: When working with compliance frameworks (CIS, NIST, PCI-DSS, SOC2, + GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, KISA ISMS-P, + Prowler ThreatScore, FedRAMP, HIPAA), syncing with upstream catalogs, + auditing check-to-requirement mappings, adding output formatters, or fixing + compliance JSON bugs (duplicate IDs, empty Version, wrong Section, stale + check refs). license: Apache-2.0 metadata: author: prowler-cloud - version: "1.1" + version: "1.2" scope: [root, sdk] auto_invoke: - "Creating/updating compliance frameworks" - "Mapping checks to compliance controls" + - "Syncing compliance framework with upstream catalog" + - "Auditing check-to-requirement mappings as a cloud auditor" + - "Adding a compliance output formatter (per-provider class + table dispatcher)" + - "Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs)" allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task --- @@ -18,10 +30,82 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task Use this skill when: - Creating a new compliance framework for any provider +- **Syncing an existing framework with an upstream source of truth** (CIS, FINOS CCC, CSA CCM, NIST, ENS, etc.) - Adding requirements to existing frameworks - Mapping checks to compliance controls +- **Auditing existing check mappings as a cloud auditor** (user asks "are these mappings correct?", "which checks apply to this requirement?", "review the mappings") +- **Adding a new output formatter** (new framework needs a table dispatcher + per-provider classes + CSV models) +- **Fixing JSON bugs**: duplicate IDs, empty Version, wrong Section, stale check refs, inconsistent FamilyName, padded tangential check mappings +- **Registering a framework in the CLI table dispatcher or API export map** +- Investigating why a finding/check isn't showing under the expected compliance framework in the UI - Understanding compliance framework structures and attributes +## Four-Layer Architecture (Mental Model) + +Prowler compliance is a **four-layer system** hanging off one Pydantic model tree. Bugs usually happen where one layer doesn't match another, so know all four before touching anything. + +### Layer 1: SDK / Core Models — `prowler/lib/check/` + +- **`compliance_models.py`** — Pydantic **v1** model tree (`from pydantic.v1 import`). One `*_Requirement_Attribute` class per framework type + `Generic_Compliance_Requirement_Attribute` as fallback. +- `Compliance_Requirement.Attributes: list[Union[...]]` — **`Generic_Compliance_Requirement_Attribute` MUST be LAST** in the Union or every framework-specific attribute falls through to Generic (Pydantic v1 tries union members in order). +- **`compliance.py`** — runtime linker. `get_check_compliance()` builds the key as `f"{Framework}-{Version}"` **only if `Version` is non-empty**. An empty Version makes the key just `"{Framework}"` — this breaks downstream filters and tests that expect the versioned key. +- `Compliance.get_bulk(provider)` walks `prowler/compliance/{provider}/` and parses every `.json` file. No central index — just directory scan. + +### Layer 2: JSON Frameworks — `prowler/compliance/{provider}/` + +See "Compliance Framework Location" and "Framework-Specific Attribute Structures" sections below. + +### Layer 3: Output Formatters — `prowler/lib/outputs/compliance/{framework}/` + +**Every framework directory follows this exact convention** — do not deviate: + +```text +{framework}/ +├── __init__.py +├── {framework}.py # ONLY get_{framework}_table() — NO function docstring +├── {framework}_{provider}.py # One class per provider (e.g., CCC_AWS, CCC_Azure, CCC_GCP) +└── models.py # One Pydantic v2 BaseModel per provider (CSV columns) +``` + +- **`{framework}.py`** holds the **table dispatcher function** `get_{framework}_table()`. It prints the pass/fail/muted summary table. **Must NOT import `Finding` or `ComplianceOutput`** — doing so creates a circular import with `prowler/lib/outputs/compliance/compliance.py`. Only imports: `colorama`, `tabulate`, `prowler.config.config.orange_color`. +- **`{framework}_{provider}.py`** holds a per-provider class like `CCC_AWS(ComplianceOutput)` with a `transform()` method that walks findings and emits rows. This file IS allowed to import `Finding` because it's not on the dispatcher import chain. +- **`models.py`** holds one Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (**public API** — renaming breaks downstream consumers). +- **Never collapse per-provider files into a unified parameterized class**, even when DRY-tempting. Every framework in Prowler follows the per-provider file pattern and reviewers will reject the refactor. CSV columns differ per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs `ProjectId`/`Location`) — three classes is the convention. +- **No function docstring on `get_{framework}_table()`** — no other framework has one; stay consistent. +- Register in `prowler/lib/outputs/compliance/compliance.py` → `display_compliance_table()` with an `elif compliance_framework.startswith("{framework}_"):` branch. Import the table function at the top of the file. + +### Layer 4: API / UI + +- **API table dispatcher**: `api/src/backend/tasks/jobs/export.py` → `COMPLIANCE_CLASS_MAP` keyed by provider. Uses `startswith` predicates: `(lambda name: name.startswith("ccc_"), CCC_AWS)`. **Never use exact match** (`name == "ccc_aws"`) — it's inconsistent and breaks versioning. +- **API lazy loader**: `api/src/backend/api/compliance.py` — `LazyComplianceTemplate` and `LazyChecksMapping` load compliance per provider on first access. +- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` routes framework names → per-framework mapper. +- **UI per-framework mapper**: `ui/lib/compliance/{framework}.tsx` flattens `Requirements` into a 3-level tree (Framework → Category → Control → Requirement) for the accordion view. Groups by `Attributes[0].FamilyName` and `Attributes[0].Section`. +- **UI detail panel**: `ui/components/compliance/compliance-custom-details/{framework}-details.tsx`. +- **UI types**: `ui/types/compliance.ts` — TypeScript mirrors of the attribute metadata. + +### The CLI Pipeline (end-to-end) + +```text +prowler aws --compliance ccc_aws + ↓ +Compliance.get_bulk("aws") → parses prowler/compliance/aws/*.json + ↓ +update_checks_metadata_with_compliance() → attaches compliance info to CheckMetadata + ↓ +execute_checks() → runs checks, produces Finding objects + ↓ +get_check_compliance(finding, "aws", bulk_checks_metadata) + → dict "{Framework}-{Version}" → [requirement_ids] + ↓ +CCC_AWS(findings, compliance).transform() → per-provider class builds CSV rows + ↓ +batch_write_data_to_file() → writes {output_filename}_ccc_aws.csv + ↓ +display_compliance_table() → get_ccc_table() → prints stdout summary +``` + +--- + ## Compliance Framework Location Frameworks are JSON files located in: `prowler/compliance/{provider}/{framework_name}_{provider}.json` @@ -399,6 +483,7 @@ Prowler ThreatScore is a custom security scoring framework developed by Prowler ## Available Compliance Frameworks ### AWS (41 frameworks) + | Framework | File Name | |-----------|-----------| | CIS 1.4, 1.5, 2.0, 3.0, 4.0, 5.0 | `cis_{version}_aws.json` | @@ -424,6 +509,7 @@ Prowler ThreatScore is a custom security scoring framework developed by Prowler | NIS2 | `nis2_aws.json` | ### Azure (15+ frameworks) + | Framework | File Name | |-----------|-----------| | CIS 2.0, 2.1, 3.0, 4.0 | `cis_{version}_azure.json` | @@ -434,6 +520,7 @@ Prowler ThreatScore is a custom security scoring framework developed by Prowler | NIST CSF 2.0 | `nist_csf_2.0_azure.json` | ### GCP (15+ frameworks) + | Framework | File Name | |-----------|-----------| | CIS 2.0, 3.0, 4.0 | `cis_{version}_gcp.json` | @@ -444,6 +531,7 @@ Prowler ThreatScore is a custom security scoring framework developed by Prowler | NIST CSF 2.0 | `nist_csf_2.0_gcp.json` | ### Kubernetes (6 frameworks) + | Framework | File Name | |-----------|-----------| | CIS 1.8, 1.10, 1.11 | `cis_{version}_kubernetes.json` | @@ -455,14 +543,453 @@ Prowler ThreatScore is a custom security scoring framework developed by Prowler - **M365:** `cis_4.0_m365.json`, `iso27001_2022_m365.json` - **NHN:** `iso27001_2022_nhn.json` +## Workflow A: Sync a Framework With an Upstream Catalog + +Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA CCM, NIST, ENS, etc.) and Prowler needs to catch up. + +### Step 1 — Cache the upstream source + +Download every upstream file to a local cache so subsequent iterations don't hit the network. For FINOS CCC: + +```bash +mkdir -p /tmp/ccc_upstream +catalogs="core/ccc storage/object management/auditlog management/logging ..." +for p in $catalogs; do + safe=$(echo "$p" | tr '/' '_') + gh api "repos/finos/common-cloud-controls/contents/catalogs/$p/controls.yaml" \ + -H "Accept: application/vnd.github.raw" > "/tmp/ccc_upstream/${safe}.yaml" +done +``` + +### Step 2 — Run the generic sync runner against a framework config + +The sync tooling is split into three layers so adding a new framework only takes a YAML config (and optionally a new parser module for an unfamiliar upstream format): + +```text +skills/prowler-compliance/assets/ +├── sync_framework.py # generic runner — works for any framework +├── configs/ +│ └── ccc.yaml # per-framework config (canonical example) +└── parsers/ + ├── __init__.py + └── finos_ccc.py # parser module for FINOS CCC YAML +``` + +**For frameworks that already have a config + parser** (today: FINOS CCC), run: + +```bash +python skills/prowler-compliance/assets/sync_framework.py \ + skills/prowler-compliance/assets/configs/ccc.yaml +``` + +The runner loads the config, validates it, dynamically imports the parser declared in `parser.module`, calls `parser.parse_upstream(config) -> list[dict]`, then applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation) and writes the provider JSONs. + +**To add a new framework sync**: + +1. **Write a config file** at `skills/prowler-compliance/assets/configs/{framework}.yaml`. See `configs/ccc.yaml` as the canonical example. Required top-level sections: + - `framework` — `name`, `display_name`, `version` (**never empty** — empty Version silently breaks `get_check_compliance()` key construction, so the runner refuses to start), `description_template` (accepts `{provider_display}`, `{provider_key}`, `{framework_name}`, `{framework_display}`, `{version}` placeholders). + - `providers` — list of `{key, display}` pairs, one per Prowler provider the framework targets. + - `output.path_template` — supports `{provider}`, `{framework}`, `{version}` placeholders. Examples: `"prowler/compliance/{provider}/ccc_{provider}.json"` for unversioned file names, `"prowler/compliance/{provider}/cis_{version}_{provider}.json"` for versioned ones. + - `upstream.dir` — local cache directory (populate via Step 1). + - `parser.module` — name of the module under `parsers/` to load (without `.py`). Everything else under `parser.` is opaque to the runner and passed to the parser as config. + - `post_processing.check_preservation.primary_key` — top-level field name for the primary legacy-mapping lookup (almost always `Id`). + - `post_processing.check_preservation.fallback_keys` — **config-driven fallback keys** for preserving check mappings when ids change. Each entry is a list of `Attributes[0]` field names composed into a tuple. Examples: + - CCC: `- [Section, Applicability]` (because `Applicability` is a CCC-only attribute, verified in `compliance_models.py:213`). + - CIS would use `- [Section, Profile]`. + - NIST would use `- [ItemId]`. + - List-valued fields (like `Applicability`) are automatically frozen to `frozenset` so the tuple is hashable. + - `post_processing.family_name_normalization` (optional) — map of raw → canonical `FamilyName` values. The UI groups by `Attributes[0].FamilyName` exactly, so inconsistent upstream variants otherwise become separate tree branches. + +2. **Reuse an existing parser** if the upstream format matches one (currently only `finos_ccc` exists). Otherwise, **write a new parser** at `parsers/{name}.py` implementing: + + ```python + def parse_upstream(config: dict) -> list[dict]: + """Return Prowler-format requirements {Id, Description, Attributes: [...], Checks: []}. + + Ids MUST be unique in the returned list. The runner raises ValueError + on duplicates — it does NOT silently renumber, because mutating a + canonical upstream id (e.g. CIS '1.1.1' or NIST 'AC-2(1)') would be + catastrophic. The parser owns all upstream-format quirks: foreign-prefix + rewriting, genuine collision renumbering, shape handling. + """ + ``` + + The parser reads its own settings from `config['upstream']` and `config['parser']`. It does NOT load existing Prowler JSONs (the runner does that for check preservation) and does NOT write output (the runner does that too). + +**Gotchas the runner already handles for you** (learned from the FINOS CCC v2025.10 sync — they're documented here so you don't re-discover them): + +- **Multiple upstream YAML shapes**. Most FINOS CCC catalogs use `control-families: [...]`, but `storage/object` uses a top-level `controls: [...]` with a `family: "CCC.X.Y"` reference id and no human-readable family name. A parser that only handles shape 1 silently drops the shape-2 catalog — this exact bug dropped ObjStor from Prowler for a full iteration. `parsers/finos_ccc.py` handles both shapes; if you write a new parser for a similar format, test with at least one file of each shape. +- **Whitespace collapse**. Upstream YAML multi-line block scalars (`|`) preserve newlines. Prowler stores descriptions single-line. Collapse with `" ".join(value.split())` before emitting (see `parsers/finos_ccc.py::clean()`). +- **Foreign-prefix AR id rewriting**. Upstream sometimes aliases requirements across catalogs by keeping the original prefix (e.g., `CCC.AuditLog.CN08.AR01` appears nested under `CCC.Logging.CN03`). Rewrite the foreign id to fit its parent control: `CCC.Logging.CN03.AR01`. This logic is parser-specific because the id structure varies per framework (CCC uses 3-dot depth; CIS uses numeric dots; NIST uses `AC-2(1)`). +- **Genuine upstream collision renumbering**. Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number (`.AR03`). The parser handles this; the runner asserts the final list has unique ids as a safety net. +- **Existing check mapping preservation**. The runner uses the `primary_key` + `fallback_keys` declared in config to look up the old `Checks` list for each requirement. For CCC this means primary index by `Id` plus fallback index by `(Section, frozenset(Applicability))` — the fallback recovers mappings for requirements whose ids were rewritten or renumbered by the parser. +- **FamilyName normalization**. Configured via `post_processing.family_name_normalization` — no code changes needed to collapse upstream variants like `"Logging & Monitoring"` → `"Logging and Monitoring"`. +- **Populate `Version`**. The runner refuses to start on empty `framework.version` — fail-fast replaces the silent bug where `get_check_compliance()` would build the key as just `"{Framework}"`. + +### Step 3 — Validate before committing + +```python +from prowler.lib.check.compliance_models import Compliance +for prov in ['aws', 'azure', 'gcp']: + c = Compliance.parse_file(f"prowler/compliance/{prov}/ccc_{prov}.json") + print(f"{prov}: {len(c.Requirements)} reqs, version={c.Version}") +``` + +Any `ValidationError` means the Attribute fields don't match the `*_Requirement_Attribute` model. Either fix the JSON or extend the model in `compliance_models.py` (remember: Generic stays last). + +### Step 4 — Verify every check id exists + +```python +import json +from pathlib import Path +for prov in ['aws', 'azure', 'gcp']: + existing = {p.stem.replace('.metadata','') + for p in Path(f'prowler/providers/{prov}/services').rglob('*.metadata.json')} + with open(f'prowler/compliance/{prov}/ccc_{prov}.json') as f: + data = json.load(f) + refs = {c for r in data['Requirements'] for c in r['Checks']} + missing = refs - existing + assert not missing, f"{prov} missing: {missing}" +``` + +A stale check id silently becomes dead weight — no finding will ever map to it. This pre-validation **must run on every write**; bake it into the generator script. + +### Step 5 — Add an attribute model if needed + +Only if the framework has fields beyond `Generic_Compliance_Requirement_Attribute`. Add the class to `prowler/lib/check/compliance_models.py` and register it in `Compliance_Requirement.Attributes: list[Union[...]]`. **Generic stays last.** + +--- + +## Workflow B: Audit Check Mappings as a Cloud Auditor + +Use when the user asks to review existing mappings ("are these correct?", "verify that the checks apply", "audit the CCC mappings"). This is the highest-value compliance task — it surfaces padded mappings with zero actual coverage and missing mappings for legitimate coverage. + +### The golden rule + +> A Prowler check's title/risk MUST **literally describe what the requirement text says**. "Related" is not enough. If no check actually addresses the requirement, leave `Checks: []` (MANUAL) — **honest MANUAL is worth more than padded coverage**. + +### Audit process + +**Step 1 — Build a per-provider check inventory** (cache in `/tmp/`): + +```python +import json +from pathlib import Path +for provider in ['aws', 'azure', 'gcp']: + inv = {} + for meta in Path(f'prowler/providers/{provider}/services').rglob('*.metadata.json'): + with open(meta) as f: + d = json.load(f) + cid = d.get('CheckID') or meta.stem.replace('.metadata','') + inv[cid] = { + 'service': d.get('ServiceName', ''), + 'title': d.get('CheckTitle', ''), + 'risk': d.get('Risk', ''), + 'description': d.get('Description', ''), + } + with open(f'/tmp/checks_{provider}.json', 'w') as f: + json.dump(inv, f, indent=2) +``` + +**Step 2 — Keyword/service query helper** — see [assets/query_checks.py](assets/query_checks.py): + +```bash +python assets/query_checks.py aws encryption transit # keyword AND-search +python assets/query_checks.py aws --service iam # all iam checks +python assets/query_checks.py aws --id kms_cmk_rotation_enabled # full metadata +``` + +**Step 3 — Dump a framework section with current mappings** — see [assets/dump_section.py](assets/dump_section.py): + +```bash +python assets/dump_section.py ccc "CCC.Core." # all Core ARs across 3 providers +python assets/dump_section.py ccc "CCC.AuditLog." # all AuditLog ARs +``` + +**Step 4 — Encode explicit REPLACE decisions** — see [assets/audit_framework_template.py](assets/audit_framework_template.py). Structure: + +```python +DECISIONS = {} + +DECISIONS["CCC.Core.CN01.AR01"] = { + "aws": [ + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + # ... + ], + "azure": [ + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + # ... + ], + "gcp": [ + "cloudsql_instance_ssl_connections", + ], + # Missing provider key = leave the legacy mapping untouched +} + +# Empty list = EXPLICITLY MANUAL (overwrites legacy) +DECISIONS["CCC.Core.CN01.AR07"] = { + "aws": [], # Prowler has no IANA port/protocol check + "azure": [], + "gcp": [], +} +``` + +**REPLACE, not PATCH.** Encoding every mapping as a full list (not add/remove delta) makes the audit reproducible and surfaces hidden assumptions from the legacy data. + +**Step 5 — Pre-validation**. The audit script MUST validate every check id against the inventory and **abort with stderr listing typos**. Common typos caught during a real audit: + +- `fsx_file_system_encryption_at_rest_using_kms` (doesn't exist) +- `cosmosdb_account_encryption_at_rest_with_cmk` (doesn't exist) +- `sqlserver_geo_replication` (doesn't exist) +- `redshift_cluster_audit_logging` (should be `redshift_cluster_encrypted_at_rest`) +- `postgresql_flexible_server_require_secure_transport` (should be `postgresql_flexible_server_enforce_ssl_enabled`) +- `storage_secure_transfer_required_enabled` (should be `storage_secure_transfer_required_is_enabled`) +- `sqlserver_minimum_tls_version_12` (should be `sqlserver_recommended_minimal_tls_version`) + +**Step 6 — Apply + validate + test**: + +```bash +python /path/to/audit_script.py # applies decisions, pre-validates +python -m pytest tests/lib/outputs/compliance/ tests/lib/check/ -q +``` + +### Audit Reference Table: Requirement Text → Prowler Checks + +Use this table to map CCC-style / NIST-style / ISO-style requirements to the checks that actually verify them. Built from a real audit of 172 CCC ARs × 3 providers. + +| Requirement text | AWS checks | Azure checks | GCP checks | +|---|---|---|---| +| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) | +| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL | +| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` | +| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL | +| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` | +| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` | +| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` | +| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` | +| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` | +| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` | +| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None | +| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` | +| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` | +| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None | +| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` | +| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` | +| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` | +| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` | +| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` | +| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` | +| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` | +| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None | +| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None | +| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None | +| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None | +| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None | +| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None | + +### What Prowler Does NOT Cover (accept MANUAL honestly) + +Don't pad mappings for these — mark `Checks: []` and move on: + +- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version +- **IANA port-protocol consistency** — no check for "protocol running on its assigned port" +- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP +- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion +- **Session cookie expiry** (LB stickiness) +- **HTTP header scrubbing** (Server, X-Powered-By) +- **Certificate transparency verification for imports** +- **Model version pinning, red teaming, AI quality review** +- **Vector embedding validation, dimensional constraints, ANN vs exact search** +- **Secret region replication** (cross-region residency) +- **Lifecycle cleanup policies on container registries** +- **Row-level / column-level security in data warehouses** +- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't) +- **Cross-tenant alert silencing permissions** +- **Field-level masking in logs** +- **Managed view enforcement for database access** +- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though) + +--- + +## Workflow C: Add a New Output Formatter + +Use when a new framework needs its own CSV columns or terminal table. Follow the c5/csa/ens layout exactly: + +```bash +mkdir -p prowler/lib/outputs/compliance/{framework} +touch prowler/lib/outputs/compliance/{framework}/__init__.py +``` + +### Step 1 — Create `{framework}.py` (table dispatcher ONLY) + +Copy from `prowler/lib/outputs/compliance/c5/c5.py` and change the function name + framework string. The `diff` between your file and `c5.py` should be just those two lines. **No function docstring** — other frameworks don't have one, stay consistent. + +### Step 2 — Create `models.py` + +One Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (public API — don't rename later without a migration). + +```python +from typing import Optional +from pydantic import BaseModel + +class {Framework}_AWSModel(BaseModel): + Provider: str + Description: str + AccountId: str + Region: str + AssessmentDate: str + Requirements_Id: str + Requirements_Description: str + # ... provider-specific columns + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool +``` + +### Step 3 — Create `{framework}_{provider}.py` for each provider + +Copy from `prowler/lib/outputs/compliance/c5/c5_aws.py` etc. Contains the `{Framework}_AWS(ComplianceOutput)` class with `transform()` that walks findings and emits model rows. This file IS allowed to import `Finding`. + +### Step 4 — Register everywhere + +**`prowler/lib/outputs/compliance/compliance.py`** (CLI table dispatcher): +```python +from prowler.lib.outputs.compliance.{framework}.{framework} import get_{framework}_table + +def display_compliance_table(...): + ... + elif compliance_framework.startswith("{framework}_"): + get_{framework}_table(findings, bulk_checks_metadata, + compliance_framework, output_filename, + output_directory, compliance_overview) +``` + +**`prowler/__main__.py`** (CLI output writer per provider): +Add imports at the top: +```python +from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS +from prowler.lib.outputs.compliance.{framework}.{framework}_azure import {Framework}_Azure +from prowler.lib.outputs.compliance.{framework}.{framework}_gcp import {Framework}_GCP +``` +Add provider-specific `elif compliance_name.startswith("{framework}_"):` branches that instantiate the class and call `batch_write_data_to_file()`. + +**`api/src/backend/tasks/jobs/export.py`** (API export dispatcher): +```python +from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS +# ... azure, gcp + +COMPLIANCE_CLASS_MAP = { + "aws": [ + # ... + (lambda name: name.startswith("{framework}_"), {Framework}_AWS), + ], + # ... azure, gcp +} +``` + +**Always use `startswith`**, never `name == "framework_aws"`. Exact match is a regression. + +### Step 5 — Add tests + +Create `tests/lib/outputs/compliance/{framework}/` with `{framework}_aws_test.py`, `{framework}_azure_test.py`, `{framework}_gcp_test.py`. See the test template in [references/test_template.md](references/test_template.md). + +Add fixtures to `tests/lib/outputs/compliance/fixtures.py`: one `Compliance` object per provider with 1 evaluated + 1 manual requirement to exercise both code paths in `transform()`. + +### Circular import warning + +**The table dispatcher file (`{framework}.py`) MUST NOT import `Finding`** (directly or transitively). The cycle is: + +```text +compliance.compliance imports get_{framework}_table + → {framework}.py imports ComplianceOutput + → compliance_output imports Finding + → finding imports get_check_compliance from compliance.compliance + → CIRCULAR +``` + +Keep `{framework}.py` bare — only `colorama`, `tabulate`, `prowler.config.config`. Put anything that imports `Finding` in the per-provider `{framework}_{provider}.py` files. + +--- + +## Conventions and Hard-Won Gotchas + +These are lessons from the FINOS CCC v2025.10 sync + 172-AR audit pass (April 2026). Learn them once; save days of debugging. + +1. **Per-provider files are non-negotiable.** Never collapse `{framework}_aws.py`, `{framework}_azure.py`, `{framework}_gcp.py` into a single parameterized class, no matter how DRY-tempting. Every other framework in the codebase follows the per-provider pattern and reviewers will reject the refactor. The CSV column names differ per provider — three classes is the convention. +2. **`{framework}.py` has NO function docstring.** Other frameworks don't have them. Don't add one to be "helpful". +3. **Circular import protection**: the table dispatcher file MUST NOT import `Finding` (directly or transitively). Split the code so `{framework}.py` only has `get_{framework}_table()` with bare imports, and `{framework}_{provider}.py` holds the class that needs `Finding`. +4. **`Generic_Compliance_Requirement_Attribute` is the fallback** — in the `Compliance_Requirement.Attributes` Union in `compliance_models.py`, Generic MUST be LAST because Pydantic v1 tries union members in order. Putting Generic first means every framework-specific attribute falls through to Generic and the specific model is never used. +5. **Pydantic v1 imports.** `from pydantic.v1 import BaseModel` in `compliance_models.py` — not v2. Mixing causes validation errors. Pydantic v2 is used in the CSV models (`models.py`) — that's fine because they're separate trees. +6. **`get_check_compliance()` key format** is `f"{Framework}-{Version}"` ONLY if Version is set. Empty Version → key is `"{Framework}"` (no version suffix). Tests that mock compliance dicts must match this exact format — when a framework ships with `Version: ""`, downstream code and tests break silently. +7. **CSV column names from `models.py` are public API.** Don't rename a field without migrating downstream consumers — CSV headers change. +8. **Upstream YAML multi-line scalars** (`|` block scalars) preserve newlines. Collapse to single-line with `" ".join(value.split())` before writing to JSON. +9. **Upstream catalogs can use multiple shapes.** FINOS CCC uses `control-families: [...]` in most catalogs but `controls: [...]` at the top level in `storage/object`. Any sync script must handle both or silently drop entire catalogs. +10. **Foreign-prefix AR ids.** Upstream sometimes "imports" requirements from one catalog into another by keeping the original id prefix (e.g., `CCC.AuditLog.CN08.AR01` appearing under `CCC.Logging.CN03`). Prowler's compliance model requires unique ids within a catalog — rewrite the foreign id to fit the parent control: `CCC.AuditLog.CN08.AR01` (inside `CCC.Logging.CN03`) → `CCC.Logging.CN03.AR01`. +11. **Genuine upstream id collisions.** Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number. Preserve check mappings by matching on `(Section, frozenset(Applicability))` since the renumbered id won't match by id. +12. **`COMPLIANCE_CLASS_MAP` in `export.py` uses `startswith` predicates** for all modern frameworks. Exact match (`name == "ccc_aws"`) is an anti-pattern — it was present for CCC until April 2026 and was the reason CCC couldn't have versioned variants. +13. **Pre-validate every check id** against the per-provider inventory before writing the JSON. A typo silently creates an unreferenced check that will fail when findings try to map to it. The audit script MUST abort with stderr listing typos, not swallow them. +14. **REPLACE is better than PATCH** for audit decisions. Encoding every mapping explicitly makes the audit reproducible and surfaces hidden assumptions from the legacy data. A PATCH system that adds/removes is too easy to forget. +15. **When no check applies, MANUAL is correct.** Do not pad mappings with tangential checks "just in case". Prowler's compliance reports are meant to be actionable — padding them with noise breaks that. Honest manual reqs can be mapped later when new checks land. +16. **UI groups by `Attributes[0].FamilyName` and `Attributes[0].Section`.** If FamilyName has inconsistent variants within the same JSON (e.g., "Logging & Monitoring" vs "Logging and Monitoring"), the UI renders them as separate categories. Section empty → the requirement falls into an orphan control with label "". Normalize before shipping. +17. **Provider coverage is asymmetric.** AWS has dense coverage (~586 checks across 80+ services): in-transit encryption, IAM, database encryption, backup. Azure (~167 checks) and GCP (~102 checks) are thinner especially for in-transit encryption, mTLS, and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where Prowler genuinely can't verify. + +--- + +## Useful One-Liners + +```bash +# Count requirements per service prefix (CCC, CIS sections, etc.) +jq -r '.Requirements[].Id | split(".")[1]' prowler/compliance/aws/ccc_aws.json | sort | uniq -c + +# Find duplicate requirement IDs +jq -r '.Requirements[].Id' file.json | sort | uniq -d + +# Count manual requirements (no checks) +jq '[.Requirements[] | select((.Checks | length) == 0)] | length' file.json + +# List all unique check references in a framework +jq -r '.Requirements[].Checks[]' file.json | sort -u + +# List all unique Sections (to spot inconsistency) +jq '[.Requirements[].Attributes[0].Section] | unique' file.json + +# List all unique FamilyNames (to spot inconsistency) +jq '[.Requirements[].Attributes[0].FamilyName] | unique' file.json + +# Diff requirement ids between two versions of the same framework +diff <(jq -r '.Requirements[].Id' a.json | sort) <(jq -r '.Requirements[].Id' b.json | sort) + +# Find where a check id is used across all frameworks +grep -rl "my_check_name" prowler/compliance/ + +# Check if a Prowler check exists +find prowler/providers/aws/services -name "{check_id}.metadata.json" + +# Validate a JSON with Pydantic +python -c "from prowler.lib.check.compliance_models import Compliance; print(Compliance.parse_file('prowler/compliance/aws/ccc_aws.json').Framework)" +``` + +--- + ## Best Practices 1. **Requirement IDs**: Follow the original framework numbering exactly (e.g., "1.1", "A.5.1", "T1190", "ac_2_1") -2. **Check Mapping**: Map to existing checks when possible. Use `Checks: []` for manual-only requirements +2. **Check Mapping**: Map to existing checks when possible. Use `Checks: []` for manual-only requirements — honest MANUAL beats padded coverage 3. **Completeness**: Include all framework requirements, even those without automated checks -4. **Version Control**: Include framework version in `Name` and `Version` fields +4. **Version Control**: Include framework version in `Name` and `Version` fields. **Never leave `Version: ""`** — it breaks `get_check_compliance()` key format 5. **File Naming**: Use format `{framework}_{version}_{provider}.json` -6. **Validation**: Prowler validates JSON against Pydantic models at startup - invalid JSON will cause errors +6. **Validation**: Prowler validates JSON against Pydantic models at startup — invalid JSON will cause errors +7. **Pre-validate check ids** against the provider's `*.metadata.json` inventory before every commit +8. **Normalize FamilyName and Section** to avoid inconsistent UI tree branches +9. **Register everywhere**: SDK model (if needed) → `compliance.py` dispatcher → `__main__.py` CLI writer → `export.py` API map → UI mapper. Skipping any layer results in silent failures +10. **Audit, don't pad**: when reviewing mappings, apply the golden rule — the check's title/risk MUST literally describe what the requirement text says. Tangential relation doesn't count ## Commands @@ -482,11 +1009,46 @@ prowler aws --compliance cis_5.0_aws -M csv json html ## Code References -- **Compliance Models:** `prowler/lib/check/compliance_models.py` -- **Compliance Processing:** `prowler/lib/check/compliance.py` -- **Compliance Output:** `prowler/lib/outputs/compliance/` +### Layer 1 — SDK / Core +- **Compliance Models:** `prowler/lib/check/compliance_models.py` (Pydantic v1 model tree) +- **Compliance Processing / Linker:** `prowler/lib/check/compliance.py` (`get_check_compliance`, `update_checks_metadata_with_compliance`) +- **Check Utils:** `prowler/lib/check/utils.py` (`list_compliance_modules`) + +### Layer 2 — JSON Catalogs +- **Framework JSONs:** `prowler/compliance/{provider}/` (auto-discovered via directory walk) + +### Layer 3 — Output Formatters +- **Per-framework folders:** `prowler/lib/outputs/compliance/{framework}/` +- **Shared base class:** `prowler/lib/outputs/compliance/compliance_output.py` (`ComplianceOutput` + `batch_write_data_to_file`) +- **CLI table dispatcher:** `prowler/lib/outputs/compliance/compliance.py` (`display_compliance_table`) +- **Finding model:** `prowler/lib/outputs/finding.py` (**do not import transitively from table dispatcher files — circular import**) +- **CLI writer:** `prowler/__main__.py` (per-provider `elif compliance_name.startswith(...)` branches that instantiate per-provider classes) + +### Layer 4 — API / UI +- **API lazy loader:** `api/src/backend/api/compliance.py` (`LazyComplianceTemplate`, `LazyChecksMapping`) +- **API export dispatcher:** `api/src/backend/tasks/jobs/export.py` (`COMPLIANCE_CLASS_MAP` with `startswith` predicates) +- **UI framework router:** `ui/lib/compliance/compliance-mapper.ts` +- **UI per-framework mapper:** `ui/lib/compliance/{framework}.tsx` +- **UI detail panel:** `ui/components/compliance/compliance-custom-details/{framework}-details.tsx` +- **UI types:** `ui/types/compliance.ts` +- **UI icon:** `ui/components/icons/compliance/{framework}.svg` + registration in `IconCompliance.tsx` + +### Tests +- **Output formatter tests:** `tests/lib/outputs/compliance/{framework}/{framework}_{provider}_test.py` +- **Shared fixtures:** `tests/lib/outputs/compliance/fixtures.py` ## Resources -- **Templates:** See [assets/](assets/) for framework JSON templates +- **JSON Templates:** See [assets/](assets/) for framework JSON templates (cis, ens, iso27001, mitre_attack, prowler_threatscore, generic) +- **Config-driven compliance sync** (any upstream-backed framework): + - [assets/sync_framework.py](assets/sync_framework.py) — generic runner. Loads a YAML config, dynamically imports the declared parser, applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation with config-driven fallback keys), and writes the provider JSONs with Pydantic post-validation. Framework-agnostic — works for any compliance framework. + - [assets/configs/ccc.yaml](assets/configs/ccc.yaml) — canonical config example (FINOS CCC v2025.10). Copy and adapt for new frameworks. + - [assets/parsers/finos_ccc.py](assets/parsers/finos_ccc.py) — FINOS CCC YAML parser. Handles both upstream shapes (`control-families` and top-level `controls`), foreign-prefix AR rewriting, and genuine collision renumbering. Exposes `parse_upstream(config) -> list[dict]`. + - [assets/parsers/](assets/parsers/) — add new parser modules here for unfamiliar upstream formats (NIST OSCAL JSON, MITRE STIX, CIS Benchmarks, etc.). Each parser is a `{name}.py` file implementing `parse_upstream(config) -> list[dict]` with guaranteed-unique ids. +- **Reusable audit tooling** (added April 2026 after the FINOS CCC v2025.10 sync): + - [assets/audit_framework_template.py](assets/audit_framework_template.py) — explicit REPLACE decision ledger with pre-validation against the per-provider inventory. Drop-in template for auditing any framework. + - [assets/query_checks.py](assets/query_checks.py) — keyword/service/id query helper over `/tmp/checks_{provider}.json`. + - [assets/dump_section.py](assets/dump_section.py) — dumps every AR for a given id prefix across all 3 providers with current check mappings. + - [assets/build_inventory.py](assets/build_inventory.py) — generates `/tmp/checks_{provider}.json` from `*.metadata.json` files. - **Documentation:** See [references/compliance-docs.md](references/compliance-docs.md) for additional resources +- **Related skill:** [prowler-compliance-review](../prowler-compliance-review/SKILL.md) — PR review checklist and validator script for compliance framework PRs diff --git a/skills/prowler-compliance/assets/audit_framework_template.py b/skills/prowler-compliance/assets/audit_framework_template.py new file mode 100644 index 0000000000..f2d58603d7 --- /dev/null +++ b/skills/prowler-compliance/assets/audit_framework_template.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Cloud-auditor pass template for any Prowler compliance framework. + +Encode explicit REPLACE decisions per (requirement_id, provider) pair below. +Each decision FULLY overwrites the legacy Checks list for that requirement. + +Workflow: + 1. Run build_inventory.py first to cache per-provider check metadata. + 2. Run dump_section.py to see current mappings for the catalog you're auditing. + 3. Fill in DECISIONS below with explicit check lists. + 4. Run this script — it pre-validates every check id against the inventory + and aborts with stderr listing typos before writing. + +Decision rules (apply as a hostile cloud auditor): + - The Prowler check's title/risk MUST literally describe what the AR text says. + "Related" is not enough. + - If no check actually addresses the requirement, leave `[]` (= MANUAL). + HONEST MANUAL is worth more than padded coverage. + - Missing provider key = leave the legacy mapping untouched. + - Empty list `[]` = explicitly MANUAL (overwrites legacy). + +Usage: + # 1. Copy this file to /tmp/audit_.py and fill in DECISIONS + # 2. Edit FRAMEWORK_KEY below to match your framework file naming + # 3. Run: + python /tmp/audit_.py +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Configure for your framework +# --------------------------------------------------------------------------- + +# Framework file basename inside prowler/compliance/{provider}/. +# If your framework is called "cis_5.0_aws.json", FRAMEWORK_KEY is "cis_5.0". +# If the file is "ccc_aws.json", FRAMEWORK_KEY is "ccc". +FRAMEWORK_KEY = "ccc" + +# Which providers to apply decisions to. +PROVIDERS = ["aws", "azure", "gcp"] + +PROWLER_DIR = Path("prowler/compliance") +CHECK_INV = {prov: Path(f"/tmp/checks_{prov}.json") for prov in PROVIDERS} + + +# --------------------------------------------------------------------------- +# DECISIONS — encode one entry per requirement you want to audit +# --------------------------------------------------------------------------- + +# DECISIONS[requirement_id][provider] = list[str] of check ids +# See SKILL.md → "Audit Reference Table: Requirement Text → Prowler Checks" +# for a comprehensive mapping cheat sheet built from a 172-AR CCC audit. + +DECISIONS: dict[str, dict[str, list[str]]] = {} + +# ---- Example entries (delete and replace with your own) ---- + +# Example 1: TLS in transit enforced (non-SSH traffic) +# DECISIONS["CCC.Core.CN01.AR01"] = { +# "aws": [ +# "cloudfront_distributions_https_enabled", +# "cloudfront_distributions_origin_traffic_encrypted", +# "s3_bucket_secure_transport_policy", +# "elbv2_ssl_listeners", +# "rds_instance_transport_encrypted", +# "kafka_cluster_in_transit_encryption_enabled", +# "redshift_cluster_in_transit_encryption_enabled", +# "opensearch_service_domains_https_communications_enforced", +# ], +# "azure": [ +# "storage_secure_transfer_required_is_enabled", +# "app_minimum_tls_version_12", +# "postgresql_flexible_server_enforce_ssl_enabled", +# "sqlserver_recommended_minimal_tls_version", +# ], +# "gcp": [ +# "cloudsql_instance_ssl_connections", +# ], +# } + +# Example 2: MANUAL — no Prowler check exists +# DECISIONS["CCC.Core.CN01.AR07"] = { +# "aws": [], # no IANA port/protocol check exists in Prowler +# "azure": [], +# "gcp": [], +# } + +# Example 3: Reuse a decision for multiple sibling ARs +# DECISIONS["CCC.ObjStor.CN05.AR02"] = DECISIONS["CCC.ObjStor.CN05.AR01"] + + +# --------------------------------------------------------------------------- +# Driver — do not edit below +# --------------------------------------------------------------------------- + +def load_inventory(provider: str) -> dict: + path = CHECK_INV[provider] + if not path.exists(): + raise SystemExit( + f"Check inventory missing: {path}\n" + f"Run: python skills/prowler-compliance/assets/build_inventory.py {provider}" + ) + with open(path) as f: + return json.load(f) + + +def resolve_json_path(provider: str) -> Path: + """Resolve the JSON file path for a given provider. + + Handles both shapes: {FRAMEWORK_KEY}_{provider}.json (ccc_aws.json) and + cases where FRAMEWORK_KEY already contains the provider suffix. + """ + candidates = [ + PROWLER_DIR / provider / f"{FRAMEWORK_KEY}_{provider}.json", + PROWLER_DIR / provider / f"{FRAMEWORK_KEY}.json", + ] + for c in candidates: + if c.exists(): + return c + raise SystemExit( + f"Could not find framework JSON for provider={provider} " + f"with FRAMEWORK_KEY={FRAMEWORK_KEY}. Tried: {candidates}" + ) + + +def plan_for_provider( + provider: str, +) -> tuple[Path, dict, tuple[int, int, int], list[tuple[str, str]]]: + """Build the updated JSON for one provider without writing it. + + Returns (path, mutated_data, (touched, added, removed), unknowns). + Writing is deferred to a second pass so that a typo in any provider + aborts the whole run before any file on disk changes. + """ + path = resolve_json_path(provider) + with open(path) as f: + data = json.load(f) + inv = load_inventory(provider) + + touched = 0 + add_count = 0 + rm_count = 0 + unknown: list[tuple[str, str]] = [] + + for req in data["Requirements"]: + rid = req["Id"] + if rid not in DECISIONS or provider not in DECISIONS[rid]: + continue + new_checks = list(dict.fromkeys(DECISIONS[rid][provider])) + for c in new_checks: + if c not in inv: + unknown.append((rid, c)) + before = set(req.get("Checks") or []) + after = set(new_checks) + rm_count += len(before - after) + add_count += len(after - before) + req["Checks"] = new_checks + touched += 1 + + return path, data, (touched, add_count, rm_count), unknown + + +def main() -> int: + if not DECISIONS: + print("No DECISIONS encoded. Fill in the DECISIONS dict and re-run.") + return 1 + print(f"Applying {len(DECISIONS)} decisions to framework '{FRAMEWORK_KEY}'...") + + # Pass 1: validate every provider before touching disk. A typo in any + # provider must abort the run before ANY file has been rewritten. + plans: list[tuple[str, Path, dict, tuple[int, int, int]]] = [] + all_unknown: list[tuple[str, str, str]] = [] + for provider in PROVIDERS: + path, data, counts, unknown = plan_for_provider(provider) + for rid, c in unknown: + all_unknown.append((provider, rid, c)) + plans.append((provider, path, data, counts)) + + if all_unknown: + print("\n!! UNKNOWN CHECK IDS (typos?):", file=sys.stderr) + for provider, rid, c in all_unknown: + print(f" {provider} {rid} -> {c}", file=sys.stderr) + print( + "\nAborting: fix the check ids above and re-run. " + "No files were modified.", + file=sys.stderr, + ) + return 2 + + # Pass 2: all providers validated cleanly — write. + for provider, path, data, (touched, added, removed) in plans: + with open(path, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + print( + f" {provider}: touched={touched} added={added} removed={removed}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/build_inventory.py b/skills/prowler-compliance/assets/build_inventory.py new file mode 100644 index 0000000000..f743aa75a5 --- /dev/null +++ b/skills/prowler-compliance/assets/build_inventory.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Build a per-provider check inventory by scanning Prowler's check metadata files. + +Outputs one JSON per provider at /tmp/checks_{provider}.json with the shape: + { + "check_id": { + "service": "...", + "subservice": "...", + "resource": "...", + "severity": "...", + "title": "...", + "description": "...", + "risk": "..." + }, + ... + } + +This is the reference used by audit_framework_template.py for pre-validation +(every check id in the audit ledger must exist in the inventory) and by +query_checks.py for keyword/service lookup. + +Usage: + python skills/prowler-compliance/assets/build_inventory.py + # Or for a specific provider: + python skills/prowler-compliance/assets/build_inventory.py aws + +Output: + /tmp/checks_{provider}.json for every provider discovered under + prowler/providers/ with a services/ directory. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PROVIDERS_ROOT = Path("prowler/providers") + + +def discover_providers() -> list[str]: + """Return every provider that currently has a services/ directory. + + Derived from the filesystem so new providers are picked up automatically + and stale hard-coded lists cannot drift from the repo. + """ + if not PROVIDERS_ROOT.exists(): + return [] + return sorted( + p.name + for p in PROVIDERS_ROOT.iterdir() + if p.is_dir() and (p / "services").is_dir() + ) + + +def build_for_provider(provider: str) -> dict: + inventory: dict[str, dict] = {} + base = Path(f"prowler/providers/{provider}/services") + if not base.exists(): + print(f" skip {provider}: no services directory", file=sys.stderr) + return inventory + for meta_path in base.rglob("*.metadata.json"): + try: + with open(meta_path) as f: + data = json.load(f) + except Exception as exc: + print(f" warn: cannot parse {meta_path}: {exc}", file=sys.stderr) + continue + cid = data.get("CheckID") or meta_path.stem.replace(".metadata", "") + inventory[cid] = { + "service": data.get("ServiceName", ""), + "subservice": data.get("SubServiceName", ""), + "resource": data.get("ResourceType", ""), + "severity": data.get("Severity", ""), + "title": data.get("CheckTitle", ""), + "description": data.get("Description", ""), + "risk": data.get("Risk", ""), + } + return inventory + + +def main() -> int: + providers = sys.argv[1:] or discover_providers() + if not providers: + print( + f"error: no providers found under {PROVIDERS_ROOT}/", + file=sys.stderr, + ) + return 1 + for provider in providers: + inv = build_for_provider(provider) + out_path = Path(f"/tmp/checks_{provider}.json") + with open(out_path, "w") as f: + json.dump(inv, f, indent=2) + print(f" {provider}: {len(inv)} checks → {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/configs/ccc.yaml b/skills/prowler-compliance/assets/configs/ccc.yaml new file mode 100644 index 0000000000..deb757ffc9 --- /dev/null +++ b/skills/prowler-compliance/assets/configs/ccc.yaml @@ -0,0 +1,120 @@ +# FINOS Common Cloud Controls (CCC) sync config for sync_framework.py. +# +# Usage: +# python skills/prowler-compliance/assets/sync_framework.py \ +# skills/prowler-compliance/assets/configs/ccc.yaml +# +# Prerequisite: run the upstream fetch step from SKILL.md Workflow A Step 1 to +# populate upstream.dir with the raw FINOS catalog YAML files. + +framework: + name: CCC + display_name: Common Cloud Controls Catalog (CCC) + version: v2025.10 + # The {provider_display} placeholder is replaced at output time with the + # per-provider display string from the providers list below. + description_template: "Common Cloud Controls Catalog (CCC) for {provider_display}" + +providers: + - key: aws + display: AWS + - key: azure + display: Azure + - key: gcp + display: GCP + +output: + # Supported placeholders: {provider}, {framework}, {version}. + # For versioned frameworks like CIS the template would be + # "prowler/compliance/{provider}/cis_{version}_{provider}.json". + path_template: "prowler/compliance/{provider}/ccc_{provider}.json" + +upstream: + # Directory containing the cached FINOS catalog YAMLs. Populate via + # SKILL.md Workflow A Step 1 (gh api raw download commands). + dir: /tmp/ccc_upstream + fetch_docs: "See SKILL.md Workflow A Step 1 for gh api fetch commands" + +parser: + # Name of the parser module under parsers/ (loaded dynamically by the + # runner). For FINOS CCC YAML this is always finos_ccc. + module: finos_ccc + + # FINOS CCC catalog files in load order. Core first so its ARs render + # first in the output JSON. + catalog_files: + - core_ccc.yaml + - management_auditlog.yaml + - management_logging.yaml + - management_monitoring.yaml + - storage_object.yaml + - networking_loadbalancer.yaml + - networking_vpc.yaml + - crypto_key.yaml + - crypto_secrets.yaml + - database_warehouse.yaml + - database_vector.yaml + - database_relational.yaml + - devtools_build.yaml + - devtools_container-registry.yaml + - identity_iam.yaml + - ai-ml_gen-ai.yaml + - ai-ml_mlde.yaml + - app-integration_message.yaml + - compute_serverless-computing.yaml + + # Shape-2 catalogs (storage/object) reference the family via id only + # (e.g. "CCC.ObjStor.Data") with no human-readable title or description + # in the YAML. Map the suffix (after the last dot) to a canonical title + # and description so the generated JSON has consistent FamilyName fields + # regardless of upstream shape. + family_id_title: + Data: Data + IAM: Identity and Access Management + Identity: Identity and Access Management + Encryption: Encryption + Logging: Logging and Monitoring + Network: Network Security + Availability: Availability + Integrity: Integrity + Confidentiality: Confidentiality + family_id_description: + Data: "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle." + IAM: "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources." + +post_processing: + # Collapse FamilyName variants that appear inconsistently across upstream + # catalogs. The Prowler UI groups by Attributes[0].FamilyName exactly, + # so each variant would otherwise become a separate tree branch. + family_name_normalization: + "Logging & Monitoring": "Logging and Monitoring" + "Logging and Metrics Publication": "Logging and Monitoring" + + # Preserve existing Checks lists from the legacy Prowler JSON when + # regenerating. The runner builds two lookup tables from the legacy + # output: a primary index by Id, and fallback indexes composed of + # attribute field names. + # + # primary_key: the top-level requirement field to use as the primary + # lookup key (almost always "Id") + # fallback_keys: a list of composite keys. Each composite key is a list + # of Attributes[0] field names to join into a tuple. List-valued fields + # (like Applicability) are frozen to frozenset so the tuple is hashable. + # + # CCC uses (Section, Applicability) because Applicability is a CCC-only + # top-level attribute field. CIS would use (Section, Profile). NIST would + # use (ItemId,). The fallback is how renumbered or rewritten ids still + # recover their check mappings. + # + # legacy_path_template (optional): path to read legacy Checks FROM. + # Defaults to output.path_template, which is correct for unversioned + # frameworks (like CCC) where regeneration overwrites the same file. + # For versioned frameworks that write to a new file on each version + # bump (e.g. cis_5.1_aws.json while the legacy mappings live in + # cis_5.0_aws.json), set this to the previous-version path so Checks + # are preserved instead of lost: + # legacy_path_template: "prowler/compliance/{provider}/cis_5.0_{provider}.json" + check_preservation: + primary_key: Id + fallback_keys: + - [Section, Applicability] diff --git a/skills/prowler-compliance/assets/dump_section.py b/skills/prowler-compliance/assets/dump_section.py new file mode 100644 index 0000000000..ca2fff0e1b --- /dev/null +++ b/skills/prowler-compliance/assets/dump_section.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Dump every requirement of a compliance framework for a given id prefix across +providers, with their current Check mappings. + +Useful for reviewing a whole control family in one pass before encoding audit +decisions in audit_framework_template.py. + +Usage: + # Dump all CCC.Core requirements across aws/azure/gcp + python skills/prowler-compliance/assets/dump_section.py ccc "CCC.Core." + + # Dump all CIS 5.0 section 1 requirements for AWS only + python skills/prowler-compliance/assets/dump_section.py cis_5.0_aws "1." + +Arguments: + framework_key: file prefix inside prowler/compliance/{provider}/ without + the provider suffix. Examples: + - "ccc" → loads ccc_aws.json / ccc_azure.json / ccc_gcp.json + - "cis_5.0_aws" → loads only that one file + - "iso27001_2022" → loads all providers + id_prefix: Requirement id prefix to filter by (e.g. "CCC.Core.", + "1.1.", "A.5."). +""" +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from pathlib import Path + +PROWLER_COMPLIANCE_DIR = Path("prowler/compliance") + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__) + return 1 + + framework_key = sys.argv[1] + id_prefix = sys.argv[2] + + # Find matching JSON files across all providers + candidates: list[tuple[str, Path]] = [] + for prov_dir in sorted(PROWLER_COMPLIANCE_DIR.iterdir()): + if not prov_dir.is_dir(): + continue + for json_path in prov_dir.glob("*.json"): + stem = json_path.stem + if stem == framework_key or stem.startswith(f"{framework_key}_") \ + or stem == f"{framework_key}_{prov_dir.name}": + candidates.append((prov_dir.name, json_path)) + + if not candidates: + print(f"No files matching '{framework_key}'", file=sys.stderr) + return 2 + + discovered_providers = sorted({prov for prov, _ in candidates}) + + by_id: dict[str, dict] = defaultdict(dict) + for prov, path in candidates: + with open(path) as f: + data = json.load(f) + for req in data["Requirements"]: + if req["Id"].startswith(id_prefix): + by_id[req["Id"]][prov] = { + "desc": req.get("Description", ""), + "sec": (req.get("Attributes") or [{}])[0].get("Section", ""), + "obj": (req.get("Attributes") or [{}])[0].get( + "SubSectionObjective", "" + ), + "checks": req.get("Checks") or [], + } + + for ar_id in sorted(by_id): + rows = by_id[ar_id] + sample = next(iter(rows.values())) + print(f"\n### {ar_id}") + print(f" desc: {sample['desc']}") + if sample["sec"]: + print(f" sec : {sample['sec']}") + if sample["obj"]: + print(f" obj : {sample['obj']}") + for prov in discovered_providers: + if prov in rows: + checks = rows[prov]["checks"] + print(f" {prov}: ({len(checks)}) {checks}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/parsers/__init__.py b/skills/prowler-compliance/assets/parsers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/skills/prowler-compliance/assets/parsers/finos_ccc.py b/skills/prowler-compliance/assets/parsers/finos_ccc.py new file mode 100644 index 0000000000..a613b15857 --- /dev/null +++ b/skills/prowler-compliance/assets/parsers/finos_ccc.py @@ -0,0 +1,223 @@ +""" +FINOS Common Cloud Controls (CCC) YAML parser. + +Reads cached upstream YAML files and emits Prowler-format requirements +(``{Id, Description, Attributes: [...], Checks: []}``). This module is +agnostic to providers, JSON output paths, framework metadata and legacy +check-mapping preservation — those are handled by ``sync_framework.py``. + +Contract +-------- +``parse_upstream(config: dict) -> list[dict]`` + Returns a list of Prowler-format requirement dicts with **guaranteed + unique ids**. Foreign-prefix AR rewriting and genuine collision + renumbering both happen inside this module — the runner treats id + uniqueness as a contract violation, not as something to fix. + +Config keys consumed +-------------------- +This parser reads the following config entries (the rest of the config is +opaque to it): + +- ``upstream.dir`` — directory containing the cached YAMLs +- ``parser.catalog_files`` — ordered list of YAML filenames to load +- ``parser.family_id_title`` — suffix → canonical family title (shape 2) +- ``parser.family_id_description`` — suffix → family description (shape 2) + +Upstream shapes +--------------- +FINOS CCC catalogs come in two shapes: + +1. ``control-families: [{title, description, controls: [...]}]`` + (used by most catalogs) +2. ``controls: [{id, family: "CCC.X.Y", ...}]`` (no families wrapper; used + by ``storage/object``). The ``family`` field references a family id with + no human-readable title in the file — the title/description come from + ``config.parser.family_id_title`` / ``family_id_description``. + +Id rewriting rules +------------------ +- **Foreign-prefix rewriting**: upstream intentionally aliases requirements + across catalogs by keeping the original prefix (e.g. ``CCC.AuditLog.CN08.AR01`` + appears nested under ``CCC.Logging.CN03``). Prowler requires unique ids + within a catalog file, so we rename the AR to fit its parent control: + ``CCC.Logging.CN03.AR01``. See ``rewrite_ar_id()``. +- **Genuine collision renumbering**: sometimes upstream has a real typo + where two distinct requirements share the same id (e.g. + ``CCC.Core.CN14.AR02`` appears twice for 30-day and 14-day backup variants). + The second copy is renumbered to the next free AR number within the + control. See the ``seen_ids`` logic in ``emit_requirement()``. +""" +from __future__ import annotations + +from pathlib import Path + +import yaml + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def clean(value: str | None) -> str: + """Trim and collapse internal whitespace/newlines into single spaces. + + Upstream YAML uses ``|`` block scalars that preserve newlines; Prowler + stores descriptions as single-line text. + """ + if not value: + return "" + return " ".join(value.split()) + + +def flatten_mappings(mappings): + """Convert upstream ``{reference-id, entries: [{reference-id, ...}]}`` to + Prowler's ``{ReferenceId, Identifiers: [...]}``. + """ + if not mappings: + return [] + out = [] + for m in mappings: + ids = [] + for entry in m.get("entries") or []: + eid = entry.get("reference-id") + if eid: + ids.append(eid) + out.append({"ReferenceId": m.get("reference-id", ""), "Identifiers": ids}) + return out + + +def ar_prefix(ar_id: str) -> str: + """Return the first three dot-segments of an AR id (the parent control). + + e.g. ``CCC.Core.CN01.AR01`` -> ``CCC.Core.CN01``. + """ + return ".".join(ar_id.split(".")[:3]) + + +def rewrite_ar_id(parent_control_id: str, original_ar_id: str, ar_index: int) -> str: + """If an AR's id doesn't share its parent control's prefix, rename it. + + Example + ------- + parent ``CCC.Logging.CN03`` + AR id ``CCC.AuditLog.CN08.AR01`` with + index 0 -> ``CCC.Logging.CN03.AR01``. + """ + if ar_prefix(original_ar_id) == parent_control_id: + return original_ar_id + return f"{parent_control_id}.AR{ar_index + 1:02d}" + + +def emit_requirement( + control: dict, + family_name: str, + family_desc: str, + seen_ids: set[str], + requirements: list[dict], +) -> None: + """Translate one FINOS control + its assessment-requirements into + Prowler-format requirement dicts and append them to ``requirements``. + + Applies foreign-prefix rewriting and genuine-collision renumbering so + the final list is guaranteed to have unique ids. + """ + control_id = clean(control.get("id")) + control_title = clean(control.get("title")) + section = f"{control_id} {control_title}".strip() + objective = clean(control.get("objective")) + threat_mappings = flatten_mappings(control.get("threat-mappings")) + guideline_mappings = flatten_mappings(control.get("guideline-mappings")) + ars = control.get("assessment-requirements") or [] + for idx, ar in enumerate(ars): + raw_id = clean(ar.get("id")) + if not raw_id: + continue + new_id = rewrite_ar_id(control_id, raw_id, idx) + # Renumber on genuine upstream collision (find next free AR number) + if new_id in seen_ids: + base = ".".join(new_id.split(".")[:-1]) + n = 1 + while f"{base}.AR{n:02d}" in seen_ids: + n += 1 + new_id = f"{base}.AR{n:02d}" + seen_ids.add(new_id) + + requirements.append( + { + "Id": new_id, + "Description": clean(ar.get("text")), + "Attributes": [ + { + "FamilyName": family_name, + "FamilyDescription": family_desc, + "Section": section, + "SubSection": "", + "SubSectionObjective": objective, + "Applicability": list(ar.get("applicability") or []), + "Recommendation": clean(ar.get("recommendation")), + "SectionThreatMappings": threat_mappings, + "SectionGuidelineMappings": guideline_mappings, + } + ], + "Checks": [], + } + ) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + + +def parse_upstream(config: dict) -> list[dict]: + """Walk upstream YAMLs and emit Prowler-format requirements. + + Handles both top-level shapes (``control-families`` and ``controls``). + Ids are guaranteed unique in the returned list. + """ + upstream_dir = Path(config["upstream"]["dir"]) + parser_cfg = config.get("parser") or {} + catalog_files = parser_cfg.get("catalog_files") or [] + family_id_title = parser_cfg.get("family_id_title") or {} + family_id_description = parser_cfg.get("family_id_description") or {} + + requirements: list[dict] = [] + seen_ids: set[str] = set() + + for filename in catalog_files: + path = upstream_dir / filename + if not path.exists(): + # parser.catalog_files is the closed set of upstream catalogs + # that define the framework. Silently skipping a missing file + # would emit valid-looking JSON with part of the framework + # dropped, defeating the whole point of a canonical sync. + raise FileNotFoundError( + f"upstream catalog file not found: {path}\n" + f" hint: refresh the upstream cache (see SKILL.md Workflow A " + f"Step 1), or remove {filename!r} from parser.catalog_files " + f"if it has been retired upstream." + ) + with open(path) as f: + doc = yaml.safe_load(f) or {} + + # Shape 1: control-families wrapper + for family in doc.get("control-families") or []: + family_name = clean(family.get("title")) + family_desc = clean(family.get("description")) + for control in family.get("controls") or []: + emit_requirement( + control, family_name, family_desc, seen_ids, requirements + ) + + # Shape 2: top-level controls with family reference id + for control in doc.get("controls") or []: + family_ref = clean(control.get("family")) + suffix = family_ref.split(".")[-1] if family_ref else "" + family_name = family_id_title.get(suffix, suffix or "Data") + family_desc = family_id_description.get(suffix, "") + emit_requirement( + control, family_name, family_desc, seen_ids, requirements + ) + + return requirements diff --git a/skills/prowler-compliance/assets/query_checks.py b/skills/prowler-compliance/assets/query_checks.py new file mode 100644 index 0000000000..46405be982 --- /dev/null +++ b/skills/prowler-compliance/assets/query_checks.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Keyword/service/id lookup over a Prowler check inventory produced by +build_inventory.py. + +Usage: + # Keyword AND-search across id + title + risk + description + python skills/prowler-compliance/assets/query_checks.py aws encryption transit + + # Show all checks for a service + python skills/prowler-compliance/assets/query_checks.py aws --service iam + + # Show full metadata for one check id + python skills/prowler-compliance/assets/query_checks.py aws --id kms_cmk_rotation_enabled +""" +from __future__ import annotations + +import json +import sys + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__) + return 1 + + provider = sys.argv[1] + try: + with open(f"/tmp/checks_{provider}.json") as f: + inv = json.load(f) + except FileNotFoundError: + print( + f"No inventory for {provider}. Run build_inventory.py first.", + file=sys.stderr, + ) + return 2 + + if sys.argv[2] == "--service": + if len(sys.argv) < 4: + print("usage: --service ") + return 1 + svc = sys.argv[3] + hits = [cid for cid in sorted(inv) if inv[cid].get("service") == svc] + for cid in hits: + print(f" {cid}") + print(f" {inv[cid].get('title', '')}") + print(f"\n{len(hits)} checks in service '{svc}'") + elif sys.argv[2] == "--id": + if len(sys.argv) < 4: + print("usage: --id ") + return 1 + cid = sys.argv[3] + if cid not in inv: + print(f"NOT FOUND: {cid}") + return 3 + m = inv[cid] + print(f"== {cid} ==") + print(f"service : {m.get('service')}") + print(f"severity: {m.get('severity')}") + print(f"resource: {m.get('resource')}") + print(f"title : {m.get('title')}") + print(f"desc : {m.get('description', '')[:500]}") + print(f"risk : {m.get('risk', '')[:500]}") + else: + keywords = [k.lower() for k in sys.argv[2:]] + hits = 0 + for cid in sorted(inv): + m = inv[cid] + blob = " ".join( + [ + cid, + m.get("title", ""), + m.get("risk", ""), + m.get("description", ""), + ] + ).lower() + if all(k in blob for k in keywords): + hits += 1 + print(f" {cid} [{m.get('service', '')}]") + print(f" {m.get('title', '')[:120]}") + print(f"\n{hits} matches for {' + '.join(keywords)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/sync_framework.py b/skills/prowler-compliance/assets/sync_framework.py new file mode 100644 index 0000000000..9e070f2691 --- /dev/null +++ b/skills/prowler-compliance/assets/sync_framework.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +""" +Generic, config-driven compliance framework sync runner. + +Usage: + python skills/prowler-compliance/assets/sync_framework.py \ + skills/prowler-compliance/assets/configs/ccc.yaml + +Pipeline: + 1. Load and validate the YAML config (fail fast on missing or empty + required fields — notably ``framework.version``, which silently + breaks ``get_check_compliance()`` key construction if empty). + 2. Dynamically import the parser module declared in ``parser.module`` + (resolved as ``parsers.{name}`` under this script's directory). + 3. Call ``parser.parse_upstream(config) -> list[dict]`` to get raw + Prowler-format requirements. The parser owns all upstream-format + quirks (foreign-prefix AR rewriting, collision renumbering, shape + handling) and MUST return ids that are unique within the returned + list. + 4. **Safety net**: assert id uniqueness. The runner raises + ``ValueError`` on any duplicate — it does NOT silently renumber, + because mutating a canonical upstream id (e.g. CIS ``1.1.1`` or + NIST ``AC-2(1)``) would be catastrophic. + 5. Apply generic ``FamilyName`` normalization from + ``post_processing.family_name_normalization`` (optional). + 6. Preserve legacy ``Checks`` lists from the existing Prowler JSON + using a config-driven primary key + fallback key chain. CCC uses + ``(Section, Applicability)`` as fallback; CIS would use + ``(Section, Profile)``; NIST would use ``(ItemId,)``. + For versioned frameworks (e.g. ``cis__.json``) + where a version bump writes to a brand-new file, set + ``post_processing.check_preservation.legacy_path_template`` to + point at the previous version's file so its Checks are preserved + instead of silently lost. Defaults to ``output.path_template`` + when omitted, which is correct for unversioned frameworks. + 7. Wrap each provider's requirements in the framework metadata dict + built from the config templates. + 8. Write each provider's JSON to the path resolved from + ``output.path_template`` (supports ``{framework}``, ``{version}`` + and ``{provider}`` placeholders). + 9. Pydantic-validate the written JSON via ``Compliance.parse_file()`` + and report the load counts per provider. + +The runner is strictly generic — it never mentions CCC, knows nothing +about YAML shapes, and can handle any upstream-backed framework given a +parser module and a config file. +""" +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path +from typing import Any + +import yaml + +# Make sibling `parsers/` package importable regardless of the runner's +# invocation directory. +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + + +# --------------------------------------------------------------------------- +# Config loading and validation +# --------------------------------------------------------------------------- + + +class ConfigError(ValueError): + """Raised when the sync config is malformed or missing required fields.""" + + +def _require(cfg: dict, dotted_path: str) -> Any: + """Fetch a dotted-path key from nested dicts. Raises ConfigError on + missing or empty values (empty-string, empty-list, None).""" + current: Any = cfg + parts = dotted_path.split(".") + for i, part in enumerate(parts): + if not isinstance(current, dict) or part not in current: + raise ConfigError(f"config: missing required field '{dotted_path}'") + current = current[part] + if current in ("", None, [], {}): + raise ConfigError(f"config: field '{dotted_path}' must not be empty") + return current + + +def load_config(path: Path) -> dict: + if not path.exists(): + raise ConfigError(f"config file not found: {path}") + with open(path) as f: + cfg = yaml.safe_load(f) or {} + if not isinstance(cfg, dict): + raise ConfigError(f"config root must be a mapping, got {type(cfg).__name__}") + + # Required fields — fail fast. Empty Version in particular silently + # breaks get_check_compliance() key construction. + _require(cfg, "framework.name") + _require(cfg, "framework.display_name") + _require(cfg, "framework.version") + _require(cfg, "framework.description_template") + _require(cfg, "providers") + _require(cfg, "output.path_template") + _require(cfg, "upstream.dir") + _require(cfg, "parser.module") + _require(cfg, "post_processing.check_preservation.primary_key") + + providers = cfg["providers"] + if not isinstance(providers, list) or not providers: + raise ConfigError("config: 'providers' must be a non-empty list") + for idx, p in enumerate(providers): + if not isinstance(p, dict) or "key" not in p or "display" not in p: + raise ConfigError( + f"config: providers[{idx}] must have 'key' and 'display' fields" + ) + + return cfg + + +# --------------------------------------------------------------------------- +# Parser loading +# --------------------------------------------------------------------------- + + +def load_parser(parser_module_name: str): + try: + return importlib.import_module(f"parsers.{parser_module_name}") + except ImportError as exc: + raise ConfigError( + f"cannot import parser 'parsers.{parser_module_name}': {exc}" + ) from exc + + +# --------------------------------------------------------------------------- +# Post-processing: id uniqueness safety net +# --------------------------------------------------------------------------- + + +def assert_unique_ids(requirements: list[dict]) -> None: + """Enforce the parser contract: every requirement must have a unique Id. + + The runner never renumbers silently — a duplicate is a parser bug. + """ + seen: set[str] = set() + dups: list[str] = [] + for req in requirements: + rid = req.get("Id") + if not rid: + raise ValueError(f"requirement missing Id: {req}") + if rid in seen: + dups.append(rid) + seen.add(rid) + if dups: + raise ValueError( + f"parser returned duplicate requirement ids: {sorted(set(dups))}" + ) + + +# --------------------------------------------------------------------------- +# Post-processing: FamilyName normalization +# --------------------------------------------------------------------------- + + +def normalize_family_names(requirements: list[dict], norm_map: dict[str, str]) -> None: + """Apply ``Attributes[0].FamilyName`` normalization in place.""" + if not norm_map: + return + for req in requirements: + for attr in req.get("Attributes") or []: + name = attr.get("FamilyName") + if name in norm_map: + attr["FamilyName"] = norm_map[name] + + +# --------------------------------------------------------------------------- +# Post-processing: legacy check-mapping preservation +# --------------------------------------------------------------------------- + + +def _freeze(value: Any) -> Any: + """Make a value hashable for use in composite lookup keys. + + Lists become frozensets (order-insensitive match). Scalars pass through. + """ + if isinstance(value, list): + return frozenset(value) + return value + + +def _build_fallback_key(attrs: dict, field_names: list[str]) -> tuple | None: + """Build a composite tuple key from the given attribute field names. + + Returns None if any field is missing or falsy — that key will be + skipped (the lookup table just won't have an entry for it). + """ + parts = [] + for name in field_names: + if name not in attrs: + return None + value = attrs[name] + if value in ("", None, [], {}): + return None + parts.append(_freeze(value)) + return tuple(parts) + + +def load_legacy_check_maps( + legacy_path: Path, + primary_key: str, + fallback_keys: list[list[str]], +) -> tuple[dict[str, list[str]], list[dict[tuple, list[str]]]]: + """Read the existing Prowler JSON and build lookup tables for check + preservation. + + Fails fast on ambiguous preservation keys. If two distinct legacy + requirements share the same primary value or the same fallback tuple, + merging their ``Checks`` silently would corrupt the preserved mapping + for unrelated requirements. Raises ``ValueError`` listing every + conflict so the user can either dedupe the legacy data or strengthen + ``check_preservation`` in the sync config. + + Returns + ------- + by_primary : dict + ``{primary_value: [checks]}`` — e.g. ``{ar_id: [checks]}``. + by_fallback : list[dict] + One lookup dict per entry in ``fallback_keys``. Each maps a + composite tuple key to its preserved checks list. + """ + by_primary: dict[str, list[str]] = {} + by_fallback: list[dict[tuple, list[str]]] = [{} for _ in fallback_keys] + + if not legacy_path.exists(): + return by_primary, by_fallback + + with open(legacy_path) as f: + data = json.load(f) + + # Track which legacy requirement Ids contributed to each bucket so we + # can surface ambiguity after the scan completes. + primary_sources: dict[str, list[str]] = {} + fallback_sources: list[dict[tuple, list[str]]] = [{} for _ in fallback_keys] + + for req in data.get("Requirements") or []: + legacy_id = req.get("Id") or "" + checks = req.get("Checks") or [] + + pv = req.get(primary_key) + if pv: + primary_sources.setdefault(pv, []).append(legacy_id) + bucket = by_primary.setdefault(pv, []) + for c in checks: + if c not in bucket: + bucket.append(c) + + attributes = req.get("Attributes") or [] + if not attributes: + continue + attrs = attributes[0] + for i, field_names in enumerate(fallback_keys): + key = _build_fallback_key(attrs, field_names) + if key is None: + continue + fallback_sources[i].setdefault(key, []).append(legacy_id) + bucket = by_fallback[i].setdefault(key, []) + for c in checks: + if c not in bucket: + bucket.append(c) + + conflicts: list[str] = [] + for pv, ids in primary_sources.items(): + if len(ids) > 1: + conflicts.append( + f"primary_key={primary_key!r} value={pv!r} shared by {ids}" + ) + for i, field_names in enumerate(fallback_keys): + for key, ids in fallback_sources[i].items(): + if len(ids) > 1: + conflicts.append( + f"fallback_key={field_names} value={key!r} shared by {ids}" + ) + if conflicts: + details = "\n - ".join(conflicts) + raise ValueError( + f"ambiguous preservation keys in {legacy_path} — cannot " + f"faithfully preserve Checks across distinct requirements:\n" + f" - {details}\n" + f"Fix: dedupe the legacy JSON, or strengthen " + f"'post_processing.check_preservation' in the sync config " + f"(e.g. add a more discriminating field to fallback_keys)." + ) + + return by_primary, by_fallback + + +def lookup_preserved_checks( + req: dict, + by_primary: dict, + by_fallback: list[dict], + primary_key: str, + fallback_keys: list[list[str]], +) -> list[str]: + """Return preserved check ids for a requirement, trying the primary + key first then each fallback in order.""" + pv = req.get(primary_key) + if pv and pv in by_primary: + return list(by_primary[pv]) + attributes = req.get("Attributes") or [] + if not attributes: + return [] + attrs = attributes[0] + for i, field_names in enumerate(fallback_keys): + key = _build_fallback_key(attrs, field_names) + if key and key in by_fallback[i]: + return list(by_fallback[i][key]) + return [] + + +# --------------------------------------------------------------------------- +# Provider output assembly +# --------------------------------------------------------------------------- + + +def resolve_output_path(template: str, framework: dict, provider_key: str) -> Path: + return Path( + template.format( + provider=provider_key, + framework=framework["name"].lower(), + version=framework["version"], + ) + ) + + +def build_provider_json( + config: dict, + provider: dict, + base_requirements: list[dict], +) -> tuple[dict, dict[str, int]]: + """Produce the provider-specific JSON dict ready to dump. + + Returns ``(json_dict, counts)`` where ``counts`` tracks how each + requirement's checks were resolved (primary, fallback, or none). + """ + framework = config["framework"] + preservation = config["post_processing"]["check_preservation"] + primary_key = preservation["primary_key"] + fallback_keys = preservation.get("fallback_keys") or [] + + # For versioned frameworks, the file we WRITE (output.path_template + # resolved at the new version) is not the file we want to READ legacy + # Checks from. Allow the config to override the legacy source path so + # a version bump can still preserve mappings from the previous file. + legacy_template = ( + preservation.get("legacy_path_template") + or config["output"]["path_template"] + ) + legacy_path = resolve_output_path( + legacy_template, framework, provider["key"] + ) + by_primary, by_fallback = load_legacy_check_maps( + legacy_path, primary_key, fallback_keys + ) + + counts = {"primary": 0, "fallback": 0, "none": 0} + enriched: list[dict] = [] + for req in base_requirements: + # Try primary key first + pv = req.get(primary_key) + checks: list[str] = [] + source = "none" + if pv and pv in by_primary: + checks = list(by_primary[pv]) + source = "primary" + else: + attributes = req.get("Attributes") or [] + if attributes: + attrs = attributes[0] + for i, field_names in enumerate(fallback_keys): + key = _build_fallback_key(attrs, field_names) + if key and key in by_fallback[i]: + checks = list(by_fallback[i][key]) + source = "fallback" + break + counts[source] += 1 + enriched.append( + { + "Id": req["Id"], + "Description": req["Description"], + # Shallow-copy attribute dicts so providers don't share refs + "Attributes": [dict(a) for a in req.get("Attributes") or []], + "Checks": checks, + } + ) + + description = framework["description_template"].format( + provider_display=provider["display"], + provider_key=provider["key"], + framework_name=framework["name"], + framework_display=framework["display_name"], + version=framework["version"], + ) + out = { + "Framework": framework["name"], + "Version": framework["version"], + "Provider": provider["display"], + "Name": framework["display_name"], + "Description": description, + "Requirements": enriched, + } + return out, counts + + +# --------------------------------------------------------------------------- +# Pydantic post-validation +# --------------------------------------------------------------------------- + + +def pydantic_validate(json_path: Path) -> int: + """Import Prowler lazily so the runner still works without Prowler + installed (validation step is skipped in that case).""" + try: + from prowler.lib.check.compliance_models import Compliance + except ImportError: + print( + " note: prowler package not importable — skipping Pydantic validation", + file=sys.stderr, + ) + return -1 + try: + parsed = Compliance.parse_file(str(json_path)) + except Exception as exc: + raise RuntimeError( + f"Pydantic validation failed for {json_path}: {exc}" + ) from exc + return len(parsed.Requirements) + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: sync_framework.py ", file=sys.stderr) + return 1 + + config_path = Path(sys.argv[1]) + try: + config = load_config(config_path) + except ConfigError as exc: + print(f"config error: {exc}", file=sys.stderr) + return 2 + + framework_name = config["framework"]["name"] + upstream_dir = Path(config["upstream"]["dir"]) + if not upstream_dir.exists(): + print( + f"error: upstream cache dir {upstream_dir} not found\n" + f" hint: {config['upstream'].get('fetch_docs', '(see SKILL.md Workflow A Step 1)')}", + file=sys.stderr, + ) + return 3 + + parser_module_name = config["parser"]["module"] + print( + f"Sync: framework={framework_name} version={config['framework']['version']} " + f"parser={parser_module_name}" + ) + + try: + parser = load_parser(parser_module_name) + except ConfigError as exc: + print(f"parser error: {exc}", file=sys.stderr) + return 4 + + print(f"Parsing upstream from {upstream_dir}...") + try: + base_requirements = parser.parse_upstream(config) + except FileNotFoundError as exc: + # A missing catalog declared in parser.catalog_files is a hard + # failure: emitting JSON with part of the framework silently + # dropped would violate the canonical-sync contract. + print(f"upstream error: {exc}", file=sys.stderr) + return 6 + print(f" parser returned {len(base_requirements)} requirements") + + # Safety-net: parser contract + try: + assert_unique_ids(base_requirements) + except ValueError as exc: + print(f"parser contract violation: {exc}", file=sys.stderr) + return 5 + + # Post-processing: family name normalization + norm_map = ( + config.get("post_processing", {}) + .get("family_name_normalization") + or {} + ) + normalize_family_names(base_requirements, norm_map) + + # Per-provider output + print() + for provider in config["providers"]: + provider_json, counts = build_provider_json( + config, provider, base_requirements + ) + out_path = resolve_output_path( + config["output"]["path_template"], + config["framework"], + provider["key"], + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w") as f: + json.dump(provider_json, f, indent=2, ensure_ascii=False) + f.write("\n") + + validated = pydantic_validate(out_path) + validated_msg = ( + f" pydantic_reqs={validated}" if validated >= 0 else " pydantic=skipped" + ) + print( + f" {provider['key']}: total={len(provider_json['Requirements'])} " + f"matched_primary={counts['primary']} " + f"matched_fallback={counts['fallback']} " + f"new_or_unmatched={counts['none']}{validated_msg}" + ) + print(f" wrote {out_path}") + + print("\nDone.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/references/compliance-docs.md b/skills/prowler-compliance/references/compliance-docs.md index 62d619953f..a8d11484a9 100644 --- a/skills/prowler-compliance/references/compliance-docs.md +++ b/skills/prowler-compliance/references/compliance-docs.md @@ -46,7 +46,7 @@ Each framework type has a specific Pydantic model in `compliance_models.py`: ## File Naming Convention -``` +```text {framework}_{version}_{provider}.json ``` diff --git a/skills/prowler-docs/SKILL.md b/skills/prowler-docs/SKILL.md index 41a97f5ff9..4c4c64967f 100644 --- a/skills/prowler-docs/SKILL.md +++ b/skills/prowler-docs/SKILL.md @@ -104,7 +104,7 @@ Reference without articles: ## Documentation Structure -``` +```text docs/ ├── getting-started/ ├── tutorials/ diff --git a/skills/prowler-provider/SKILL.md b/skills/prowler-provider/SKILL.md index 994d134776..8042cd9f5a 100644 --- a/skills/prowler-provider/SKILL.md +++ b/skills/prowler-provider/SKILL.md @@ -25,7 +25,7 @@ Use this skill when: Every provider MUST follow this structure: -``` +```text prowler/providers/{provider}/ ├── __init__.py ├── {provider}_provider.py # Main provider class @@ -45,6 +45,34 @@ prowler/providers/{provider}/ └── {check_name}.metadata.json ``` +## Sensitive CLI Arguments + +Flags that accept secrets (tokens, passwords, API keys) MUST follow these rules: + +1. **Use `nargs="?"` with `default=None`** — the flag accepts an optional value for backward compatibility; the recommended path is environment variables. +2. **Set `metavar` to the environment variable name** users should use (e.g., `metavar="GITHUB_PERSONAL_ACCESS_TOKEN"`). +3. **Add the flag to the `SENSITIVE_ARGUMENTS` frozenset** at the top of the provider's `arguments.py`. This set is used to redact values in HTML output and warn users who pass secrets directly. +4. **Do not add new arguments that require passing secrets as CLI values** — secrets should come from environment variables. The flag accepts a value for backward compatibility, but CLI warns users to prefer env vars. + +### Pattern + +```python +# prowler/providers/{provider}/lib/arguments/arguments.py + +SENSITIVE_ARGUMENTS = frozenset({"--my-api-key", "--my-password"}) + + +def init_parser(self): + auth_subparser = parser.add_argument_group("Authentication Modes") + auth_subparser.add_argument( + "--my-api-key", + nargs="?", + default=None, + metavar="MY_API_KEY", + help="API key for authentication. Use MY_API_KEY env var instead of passing directly.", + ) +``` + ## Provider Class Template ```python @@ -127,19 +155,19 @@ Current providers: ```bash # Run provider -poetry run python prowler-cli.py {provider} +uv run python prowler-cli.py {provider} # List services for provider -poetry run python prowler-cli.py {provider} --list-services +uv run python prowler-cli.py {provider} --list-services # List checks for provider -poetry run python prowler-cli.py {provider} --list-checks +uv run python prowler-cli.py {provider} --list-checks # Run specific service -poetry run python prowler-cli.py {provider} --services {service} +uv run python prowler-cli.py {provider} --services {service} # Debug mode -poetry run python prowler-cli.py {provider} --log-level DEBUG +uv run python prowler-cli.py {provider} --log-level DEBUG ``` ## Resources diff --git a/skills/prowler-readme-table/SKILL.md b/skills/prowler-readme-table/SKILL.md index d6f9c6e780..6cea125ca0 100644 --- a/skills/prowler-readme-table/SKILL.md +++ b/skills/prowler-readme-table/SKILL.md @@ -34,7 +34,7 @@ python3 prowler-cli.py --list- The CLI output ends with a summary line like: -``` +```text There are 572 available checks. There is 1 available Compliance Framework. ``` diff --git a/skills/prowler-sdk-check/SKILL.md b/skills/prowler-sdk-check/SKILL.md index c9319f705c..1901e996d4 100644 --- a/skills/prowler-sdk-check/SKILL.md +++ b/skills/prowler-sdk-check/SKILL.md @@ -16,7 +16,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## Check Structure -``` +```text prowler/providers/{provider}/services/{service}/{check_name}/ ├── __init__.py ├── {check_name}.py @@ -73,13 +73,13 @@ For detailed field documentation, see `references/metadata-docs.md`. ### 5. Verify Check Detection ```bash -poetry run python prowler-cli.py {provider} --list-checks | grep {check_name} +uv run python prowler-cli.py {provider} --list-checks | grep {check_name} ``` ### 6. Run Check Locally ```bash -poetry run python prowler-cli.py {provider} --log-level ERROR --verbose --check {check_name} +uv run python prowler-cli.py {provider} --log-level ERROR --verbose --check {check_name} ``` ### 7. Create Tests @@ -90,7 +90,7 @@ See `prowler-test-sdk` skill for test patterns (PASS, FAIL, no resources, error ## Check Naming Convention -``` +```text {service}_{resource}_{security_control} ``` @@ -243,16 +243,16 @@ class ec2_instance_hardened(Check): ```bash # Verify detection -poetry run python prowler-cli.py {provider} --list-checks | grep {check_name} +uv run python prowler-cli.py {provider} --list-checks | grep {check_name} # Run check -poetry run python prowler-cli.py {provider} --log-level ERROR --verbose --check {check_name} +uv run python prowler-cli.py {provider} --log-level ERROR --verbose --check {check_name} # Run with specific profile/credentials -poetry run python prowler-cli.py aws --profile myprofile --check {check_name} +uv run python prowler-cli.py aws --profile myprofile --check {check_name} # Run multiple checks -poetry run python prowler-cli.py {provider} --check {check1} {check2} {check3} +uv run python prowler-cli.py {provider} --check {check1} {check2} {check3} ``` ## Resources diff --git a/skills/prowler-test-api/SKILL.md b/skills/prowler-test-api/SKILL.md index fc00ed31cc..5b3e2ae6a1 100644 --- a/skills/prowler-test-api/SKILL.md +++ b/skills/prowler-test-api/SKILL.md @@ -28,7 +28,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## 1. Fixture Dependency Chain -``` +```text create_test_user (session) ─► tenants_fixture (function) ─► authenticated_client │ └─► providers_fixture ─► scans_fixture ─► findings_fixture @@ -153,9 +153,9 @@ api_key = "sk-fake-test-key-for-unit-testing-only" ## Commands ```bash -cd api && poetry run pytest -x --tb=short -cd api && poetry run pytest -k "test_provider" -cd api && poetry run pytest api/src/backend/api/tests/test_rbac.py +cd api && uv run pytest -x --tb=short +cd api && uv run pytest -k "test_provider" +cd api && uv run pytest api/src/backend/api/tests/test_rbac.py ``` --- diff --git a/skills/prowler-test-api/references/test-api-docs.md b/skills/prowler-test-api/references/test-api-docs.md index 0e7d02821a..0150fbfe87 100644 --- a/skills/prowler-test-api/references/test-api-docs.md +++ b/skills/prowler-test-api/references/test-api-docs.md @@ -14,7 +14,7 @@ ## Fixture Dependency Graph -``` +```text create_test_user (session) │ └─► tenants_fixture (function) @@ -171,28 +171,28 @@ from conftest import ( ```bash # Full test suite -cd api && poetry run pytest +cd api && uv run pytest # Fast fail on first error -cd api && poetry run pytest -x +cd api && uv run pytest -x # Short traceback -cd api && poetry run pytest --tb=short +cd api && uv run pytest --tb=short # Specific file -cd api && poetry run pytest api/src/backend/api/tests/test_views.py +cd api && uv run pytest api/src/backend/api/tests/test_views.py # Pattern match -cd api && poetry run pytest -k "Provider" +cd api && uv run pytest -k "Provider" # Verbose with print output -cd api && poetry run pytest -v -s +cd api && uv run pytest -v -s # With coverage -cd api && poetry run pytest --cov=api --cov-report=html +cd api && uv run pytest --cov=api --cov-report=html # Parallel execution -cd api && poetry run pytest -n auto +cd api && uv run pytest -n auto ``` --- diff --git a/skills/prowler-test-sdk/SKILL.md b/skills/prowler-test-sdk/SKILL.md index b165381540..ccf9ecdf03 100644 --- a/skills/prowler-test-sdk/SKILL.md +++ b/skills/prowler-test-sdk/SKILL.md @@ -265,7 +265,7 @@ from tests.providers.kubernetes.kubernetes_fixtures import set_mocked_kubernetes ## Test File Structure -``` +```text tests/providers/{provider}/services/{service}/ ├── {service}_service_test.py # Service tests └── {check_name}/ @@ -309,16 +309,16 @@ assert result[0].project_id == GCP_PROJECT_ID # GCP ```bash # All SDK tests -poetry run pytest -n auto -vvv tests/ +uv run pytest -n auto -vvv tests/ # Specific provider -poetry run pytest tests/providers/{provider}/ -v +uv run pytest tests/providers/{provider}/ -v # Specific check -poetry run pytest tests/providers/{provider}/services/{service}/{check_name}/ -v +uv run pytest tests/providers/{provider}/services/{service}/{check_name}/ -v # Stop on first failure -poetry run pytest -x tests/ +uv run pytest -x tests/ ``` ## Resources diff --git a/skills/prowler-test-ui/SKILL.md b/skills/prowler-test-ui/SKILL.md index 558525932d..2cd583eb32 100644 --- a/skills/prowler-test-ui/SKILL.md +++ b/skills/prowler-test-ui/SKILL.md @@ -19,7 +19,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## Prowler UI Test Structure -``` +```text ui/tests/ ├── base-page.ts # Prowler-specific base page ├── helpers.ts # Prowler test utilities @@ -35,13 +35,13 @@ ui/tests/ **⚠️ ALWAYS verify BEFORE completing any E2E task:** -### When CREATING new tests: +### When CREATING new tests - [ ] `{page-name}-page.ts` - Page Object created/updated - [ ] `{page-name}.spec.ts` - Tests added with correct tags (@TEST-ID) - [ ] `{page-name}.md` - Documentation created with ALL test cases - [ ] Test IDs in `.md` match tags in `.spec.ts` -### When MODIFYING existing tests: +### When MODIFYING existing tests - [ ] `{page-name}.md` MUST be updated if: - Test cases were added/removed - Test flow changed (steps) @@ -49,7 +49,7 @@ ui/tests/ - Tags or priorities changed - [ ] Test IDs synchronized between `.md` and `.spec.ts` -### Quick validation: +### Quick validation ```bash # Verify .md exists for each test folder ls ui/tests/{feature}/{feature}.md @@ -59,7 +59,8 @@ grep -o "@[A-Z]*-E2E-[0-9]*" ui/tests/{feature}/{feature}.spec.ts | sort -u grep -o "\`[A-Z]*-E2E-[0-9]*\`" ui/tests/{feature}/{feature}.md | sort -u ``` -**❌ An E2E change is NOT considered complete without updating the corresponding .md file** +> [!IMPORTANT] +> ❌ An E2E change is NOT considered complete without updating the corresponding `.md` file. --- diff --git a/skills/prowler-tour/SKILL.md b/skills/prowler-tour/SKILL.md new file mode 100644 index 0000000000..8511581a47 --- /dev/null +++ b/skills/prowler-tour/SKILL.md @@ -0,0 +1,99 @@ +--- +name: prowler-tour +description: > + Keeps product-tour definitions aligned with the UI features they describe. + Trigger: When modifying UI components that have associated tours, editing tour + definition files, or renaming data-tour-id attributes. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, ui] + auto_invoke: + - "Editing a UI file containing data-tour-id attributes" + - "Adding, updating, or removing a tour definition (*.tour.ts)" + - "Renaming or removing a data-tour-id attribute value" + - "Changing button labels or section headings on a tour-covered page" + - "Restructuring routes or layouts covered by a tour" +allowed-tools: Read, Glob, Grep +--- + +# prowler-tour + +**Report-only.** This skill never edits tour files or UI files; it inspects +the change, reports drift it finds between tours and the covered UI, and +recommends actions for the developer to apply. + +## Early-exit rule + +Run this check first. Most UI edits are not tour-related — exit cheaply. + +1. Glob `ui/lib/tours/*.tour.ts`. +2. For each tour, check whether any `coversFiles` glob pattern matches any + file in the current change. +3. If no tour matches, respond **exactly**: + + > No tour affected — skipping alignment check + + and exit. Do not proceed to the checklist. +4. If at least one tour matches, continue to "Drift checklist" for that tour. + +## Drift checklist + +For each affected tour, evaluate every item. Skip items that obviously do +not apply, but list explicitly which items were checked. + +1. **Orphan selectors** — every step's `target` (which composes to + `data-tour-id="-"`) must resolve to a real element + in the codebase. Grep `ui/` for the expected attribute value; report + any step whose target is missing. +2. **Renamed selectors** — a `data-tour-id` attribute was edited in this + change. Match it back to any tour step referencing the old value. +3. **Outdated copy** — a popover `title`/`description` references a button + label, heading, or term that no longer exists on the covered page. +4. **Obsolete steps** — a step describes a section, panel, or workflow + that was removed. +5. **Missing steps** — a new feature was added on the covered surface + without a corresponding step (e.g. a new panel, a new primary action, + a new wizard stage). +6. **Reordered flow** — the user's path through the feature changed (e.g. + query builder moved before scan selection) and the step order no + longer reflects it. + +## Version-bump decision tree + +Apply per tour after listing drift: + +- **NO bump** when the change is cosmetic. Examples: fix a typo, soften + copy, rename a `data-tour-id` selector while keeping the same step, + swap one screenshot for another, tighten wording. +- **BUMP `version`** when the user-visible flow changes materially. + Examples: a new step was added or removed; the order changed; an + anchored target was retargeted to a different panel; the tour now + covers a new feature on the surface. + +When in doubt, ask: "Would a user who already saw the previous version +miss something useful by not seeing this one?" If yes, bump. + +## Output format + +When emitting a report, follow the exact structure in +`references/output-format.md`. The structure is mandatory because the +report is consumed downstream and tolerates no field reordering. + +## What this skill MUST NOT do + +- Do not edit `*.tour.ts` files. This skill is report-only. +- Do not edit UI files to add or rename `data-tour-id` attributes. +- Do not invent new tours. Authoring a new tour is a separate, deliberate + decision — the developer makes it, not the skill. +- Do not flag drift in tours whose `coversFiles` do not match any file + in the current change. Stick to the early-exit rule. + +## See also + +- `references/output-format.md` — exact report template (read when + emitting a report). +- `references/tours-architecture.md` — code map for the tour abstraction + under `ui/lib/tours/`. +- `assets/tour-template.ts` — boilerplate for authoring a new `*.tour.ts`. diff --git a/skills/prowler-tour/assets/tour-template.ts b/skills/prowler-tour/assets/tour-template.ts new file mode 100644 index 0000000000..cc264a4678 --- /dev/null +++ b/skills/prowler-tour/assets/tour-template.ts @@ -0,0 +1,51 @@ +// @ts-nocheck -- template only; resolves once copied into `ui/lib/tours/` +/** + * Tour template — copy this file to `ui/lib/tours/.tour.ts` and + * fill in the placeholders. See `references/tours-architecture.md` for the + * design context. + * + * Conventions: + * - Declare via `defineTour({...})` (NOT `: TourDefinition`) so TS + * preserves the literal union of `target` values. `useDriverTour` uses + * that union to validate `stepHandlers` keys and `waitForStep` args. + * - `id` is kebab-case and unique across all tours. + * - Anchored steps reference DOM via `data-tour-id="-"`; + * the hook composes the CSS selector automatically. + * - `coversFiles` lists the globs that describe the tour's surface; the + * `prowler-tour` skill consumes this to decide whether to evaluate + * drift on a given change. + * - Material flow changes bump `version`; cosmetic edits do not. + */ +import { + defineTour, + TOUR_STEP_ALIGNMENTS, + TOUR_STEP_SIDES, +} from "@/lib/tours/tour-types"; + +export const yourTour = defineTour({ + id: "your-tour-id", + version: 1, + coversFiles: [ + // List the UI files this tour describes, using globs under `ui/`. + // Example: "ui/app/(prowler)/your-feature/**" + ], + steps: [ + { + // Modal step — no anchor. Use for intros, outros, and any step + // that does not point at a specific DOM element. + title: "Welcome", + description: "Short, plain-English description.", + }, + { + // Anchored step. The hook resolves + // `[data-tour-id="your-tour-id-step-name"]` lazily, so the element + // can be conditionally rendered as long as it exists when the step + // becomes active. + target: "step-name", + side: TOUR_STEP_SIDES.BOTTOM, + align: TOUR_STEP_ALIGNMENTS.START, + title: "Where the action is", + description: "Tell the user what to look at here and why.", + }, + ], +}); diff --git a/skills/prowler-tour/references/output-format.md b/skills/prowler-tour/references/output-format.md new file mode 100644 index 0000000000..ecd82c4d8e --- /dev/null +++ b/skills/prowler-tour/references/output-format.md @@ -0,0 +1,31 @@ +# Tour Alignment Report — output format + +The report is consumed downstream. Field names, order, and headings are +load-bearing — do not rename, reorder, or omit them. + +## Template + +```text +## Tour Alignment Report +**Tour:** `@v` +**Files touched:** + +### Drift detected +- + +### Recommended actions +1. + +### Version bump verdict +- +``` + +## Rules + +- One report per affected tour. If multiple tours are affected, separate + reports with a `---` line. +- If no drift is detected for an affected tour, still emit the report: + put "No drift detected." under "Drift detected" and "None required." + under "Recommended actions". The verdict line is still mandatory. +- The verdict is exactly one of `BUMP` or `NO bump` — see the + version-bump decision tree in `SKILL.md`. diff --git a/skills/prowler-tour/references/tours-architecture.md b/skills/prowler-tour/references/tours-architecture.md new file mode 100644 index 0000000000..9a5c8f49e0 --- /dev/null +++ b/skills/prowler-tour/references/tours-architecture.md @@ -0,0 +1,44 @@ +# Tours Architecture + +The product-tour abstraction lives under [`ui/lib/tours/`](../../../ui/lib/tours/). +This skill operates on tour definitions that follow this architecture. + +## Code map + +| File | Purpose | +|---|---| +| `ui/lib/tours/tour-types.ts` | Public type surface: `TourDefinition`, `TourStep`, `TourId`, `TourCompletionRecord`, completion-state const map. Also exports `defineTour(...)` — the required authoring helper that preserves literal step `target`s so `useDriverTour` can type-check `stepHandlers` keys and `waitForStep` arguments. | +| `ui/lib/tours/tour-config.ts` | `baseDriverConfig`, `getDriverConfig(theme, overrides?)`, overlay-color map. | +| `ui/lib/tours/store/tour-completion-store.ts` | Persistence interface — the swap point for future API adapters. | +| `ui/lib/tours/store/local-storage-adapter.ts` | The only adapter in the PoC. Key format: `prowler.tour..v`. | +| `ui/lib/tours/use-driver-tour.ts` | React hook. Initializes driver.js, derives `overlayColor` from `useTheme()`, persists completion. | +| `ui/lib/tours/.tour.ts` | One file per tour. Declared via `defineTour({...})` (not `: TourDefinition`) and imported by the page that opts the user in. | +| `ui/styles/tours.css` | `.driver-popover.prowler-theme` — every color resolved via `var(--...)` from `globals.css`. | + +## Selector convention + +Tour steps anchor via `data-tour-id="-"`. The hook +composes the CSS selector at runtime; tour authors only provide the step +name in `step.target`. Class-based, ID-based, structural selectors are +forbidden — they couple tours to styling decisions that legitimately +change. + +## Identity and versioning + +A tour is `{ id, version }`. The localStorage key composes both. A +**material content change** bumps `version`; cosmetic edits do not. The +decision tree lives in the parent SKILL.md. + +## Persistence scope + +Per-user, cross-tenant. A user who completed `attack-paths@v1` in tenant +A does not see the tour again in tenant B, even if they can access the +feature there. The future `UserTourState` model (documented in +`design.md`, not built) is FK to `User`, not `Membership`. + +## Drift = #1 risk + +Without the maintenance skill + the optional CI gate +(`ui/scripts/check-tour-alignment.mjs`), tours decay silently as the +covered UI evolves. The parent SKILL.md enumerates the six drift +categories the skill checks for. diff --git a/skills/prowler-ui/SKILL.md b/skills/prowler-ui/SKILL.md index 3e6407889e..f0f2bae08e 100644 --- a/skills/prowler-ui/SKILL.md +++ b/skills/prowler-ui/SKILL.md @@ -1,7 +1,7 @@ --- name: prowler-ui description: > - Prowler UI-specific patterns. For generic patterns, see: typescript, react-19, nextjs-15, tailwind-4. + Prowler UI-specific patterns. For generic patterns, see: typescript, react-19, nextjs-16, tailwind-4. Trigger: When working inside ui/ on Prowler-specific conventions (shadcn vs HeroUI legacy, folder placement, actions/adapters, shared types/hooks/lib). license: Apache-2.0 metadata: @@ -18,7 +18,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task - `typescript` - Const types, flat interfaces - `react-19` - No useMemo/useCallback, compiler -- `nextjs-15` - App Router, Server Actions +- `nextjs-16` - App Router, Server Actions - `tailwind-4` - cn() utility, styling rules - `zod-4` - Schema validation - `zustand-5` - State management @@ -27,8 +27,8 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## Tech Stack (Versions) -``` -Next.js 15.5.9 | React 19.2.2 | Tailwind 4.1.13 | shadcn/ui +```text +Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 NextAuth 5.0.0-beta.30 | Recharts 2.15.4 HeroUI 2.8.4 (LEGACY - do not add new components) @@ -43,7 +43,7 @@ HeroUI 2.8.4 (LEGACY - do not add new components) ### Component Placement -``` +```text New feature UI? → shadcn/ui + Tailwind Existing HeroUI feature? → Keep HeroUI (don't mix) Used 1 feature? → features/{feature}/components/ @@ -54,7 +54,7 @@ Server component? → No directive needed ### Code Location -``` +```text Server action → actions/{feature}/{feature}.ts Data transform → actions/{feature}/{feature}.adapter.ts Types (shared 2+) → types/{domain}.ts @@ -69,7 +69,7 @@ HeroUI components → components/ui/ (LEGACY) ### Styling Decision -``` +```text Tailwind class exists? → className Dynamic value? → style prop Conditional styles? → cn() @@ -85,7 +85,7 @@ Recharts/library? → CHART_COLORS constant + var() ## Project Structure -``` +```text ui/ ├── app/ │ ├── (auth)/ # Auth pages (login, signup) diff --git a/skills/prowler/SKILL.md b/skills/prowler/SKILL.md index 667ad0cc4b..bb49447249 100644 --- a/skills/prowler/SKILL.md +++ b/skills/prowler/SKILL.md @@ -16,22 +16,22 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task | Component | Stack | Location | |-----------|-------|----------| -| SDK | Python 3.10+, Poetry | `prowler/` | +| SDK | Python 3.10+, uv | `prowler/` | | API | Django 5.1, DRF, Celery | `api/` | -| UI | Next.js 15, React 19, Tailwind 4 | `ui/` | +| UI | Next.js 16, React 19, Tailwind 4 | `ui/` | | MCP | FastMCP 2.13.1 | `mcp_server/` | ## Quick Commands ```bash # SDK -poetry install --with dev -poetry run python prowler-cli.py aws --check check_name -poetry run pytest tests/ +uv sync +uv run python prowler-cli.py aws --check check_name +uv run pytest tests/ # API -cd api && poetry run python src/backend/manage.py runserver -cd api && poetry run pytest +cd api && uv run python src/backend/manage.py runserver +cd api && uv run pytest # UI cd ui && pnpm run dev diff --git a/skills/react-19/SKILL.md b/skills/react-19/SKILL.md index 519ae9f15e..645292aa83 100644 --- a/skills/react-19/SKILL.md +++ b/skills/react-19/SKILL.md @@ -2,7 +2,7 @@ name: react-19 description: > React 19 patterns with React Compiler. - Trigger: When writing React 19 components/hooks in .tsx (React Compiler rules, hook patterns, refs as props). If using Next.js App Router/Server Actions, also use nextjs-15. + Trigger: When writing React 19 components/hooks in .tsx (React Compiler rules, hook patterns, refs as props). If using Next.js App Router/Server Actions, also use nextjs-16. license: Apache-2.0 metadata: author: prowler-cloud diff --git a/skills/setup.sh b/skills/setup.sh index ec5512e8c5..c24706d718 100755 --- a/skills/setup.sh +++ b/skills/setup.sh @@ -1,10 +1,10 @@ #!/bin/bash # Setup AI Skills for Prowler development # Configures AI coding assistants that follow agentskills.io standard: -# - Claude Code: .claude/skills/ symlink + CLAUDE.md copies -# - Gemini CLI: .gemini/skills/ symlink + GEMINI.md copies +# - Claude Code: .claude/skills/ symlink + CLAUDE.md symlink +# - Gemini CLI: .gemini/skills/ symlink + GEMINI.md symlink # - Codex (OpenAI): .codex/skills/ symlink + AGENTS.md (native) -# - GitHub Copilot: .github/copilot-instructions.md copy +# - GitHub Copilot: .github/copilot-instructions.md symlink # # Usage: # ./setup.sh # Interactive mode (select AI assistants) @@ -37,6 +37,28 @@ SETUP_COPILOT=false # HELPER FUNCTIONS # ============================================================================= +add_to_gitignore() { + local pattern="$1" + local gitignore_file="$REPO_ROOT/.gitignore" + local header="# AI Coding assistants assets" + + # Create .gitignore if it doesn't exist + if [ ! -f "$gitignore_file" ]; then + touch "$gitignore_file" + fi + + # Check if pattern exists (exact match or at end of file) + if ! grep -qxF "$pattern" "$gitignore_file"; then + # Check if header exists + if ! grep -qxF "$header" "$gitignore_file"; then + echo -e "\n\n$header" >> "$gitignore_file" + fi + + echo "$pattern" >> "$gitignore_file" + echo -e "${GREEN} ✓ Added $pattern to .gitignore${NC}" + fi +} + show_help() { echo "Usage: $0 [OPTIONS]" echo "" @@ -109,6 +131,7 @@ setup_claude() { if [ ! -d "$REPO_ROOT/.claude" ]; then mkdir -p "$REPO_ROOT/.claude" fi + add_to_gitignore ".claude/skills" if [ -L "$target" ]; then rm "$target" @@ -119,8 +142,9 @@ setup_claude() { ln -s "$SKILLS_SOURCE" "$target" echo -e "${GREEN} ✓ .claude/skills -> skills/${NC}" - # Copy AGENTS.md to CLAUDE.md - copy_agents_md "CLAUDE.md" + # Link AGENTS.md to CLAUDE.md + link_agents_md "CLAUDE.md" + add_to_gitignore "CLAUDE.md" } setup_gemini() { @@ -129,6 +153,7 @@ setup_gemini() { if [ ! -d "$REPO_ROOT/.gemini" ]; then mkdir -p "$REPO_ROOT/.gemini" fi + add_to_gitignore ".gemini/skills" if [ -L "$target" ]; then rm "$target" @@ -139,8 +164,9 @@ setup_gemini() { ln -s "$SKILLS_SOURCE" "$target" echo -e "${GREEN} ✓ .gemini/skills -> skills/${NC}" - # Copy AGENTS.md to GEMINI.md - copy_agents_md "GEMINI.md" + # Link AGENTS.md to GEMINI.md + link_agents_md "GEMINI.md" + add_to_gitignore "GEMINI.md" } setup_codex() { @@ -149,6 +175,7 @@ setup_codex() { if [ ! -d "$REPO_ROOT/.codex" ]; then mkdir -p "$REPO_ROOT/.codex" fi + add_to_gitignore ".codex/skills" if [ -L "$target" ]; then rm "$target" @@ -164,12 +191,19 @@ setup_codex() { setup_copilot() { if [ -f "$REPO_ROOT/AGENTS.md" ]; then mkdir -p "$REPO_ROOT/.github" - cp "$REPO_ROOT/AGENTS.md" "$REPO_ROOT/.github/copilot-instructions.md" + + # Link AGENTS.md -> .github/copilot-instructions.md + local target="$REPO_ROOT/.github/copilot-instructions.md" + ln -sf "../AGENTS.md" "$target" + echo -e "${GREEN} ✓ AGENTS.md -> .github/copilot-instructions.md${NC}" + + # Add specifically the file, NOT the .github folder + add_to_gitignore ".github/copilot-instructions.md" fi } -copy_agents_md() { +link_agents_md() { local target_name="$1" local agents_files local count=0 @@ -179,11 +213,15 @@ copy_agents_md() { for agents_file in $agents_files; do local agents_dir agents_dir=$(dirname "$agents_file") - cp "$agents_file" "$agents_dir/$target_name" + + # Create relative symlink + # Since files are in same dir, we can just link to basename + (cd "$agents_dir" && ln -sf "$(basename "$agents_file")" "$target_name") + count=$((count + 1)) done - echo -e "${GREEN} ✓ Copied $count AGENTS.md -> $target_name${NC}" + echo -e "${GREEN} ✓ Linked $count AGENTS.md -> $target_name${NC}" } # ============================================================================= @@ -302,4 +340,4 @@ echo "Configured:" [ "$SETUP_COPILOT" = true ] && echo " • GitHub Copilot: .github/copilot-instructions.md" echo "" echo -e "${BLUE}Note: Restart your AI assistant to load the skills.${NC}" -echo -e "${BLUE} AGENTS.md is the source of truth - edit it, then re-run this script.${NC}" +echo -e "${BLUE} AGENTS.md is the source of truth - changes are reflected automatically via symlinks.${NC}" diff --git a/skills/setup_test.sh b/skills/setup_test.sh index c0e80afe99..db4749ed3f 100755 --- a/skills/setup_test.sh +++ b/skills/setup_test.sh @@ -201,40 +201,40 @@ test_symlink_not_created_without_flag() { } # ============================================================================= -# TESTS: AGENTS.md COPYING +# TESTS: AGENTS.md LINKING # ============================================================================= -test_copy_claude_agents_md() { +test_link_claude_agents_md() { run_setup --claude > /dev/null - assert_file_exists "$TEST_DIR/CLAUDE.md" "Root CLAUDE.md should exist" && \ - assert_file_exists "$TEST_DIR/api/CLAUDE.md" "api/CLAUDE.md should exist" && \ - assert_file_exists "$TEST_DIR/ui/CLAUDE.md" "ui/CLAUDE.md should exist" + assert_symlink_exists "$TEST_DIR/CLAUDE.md" "Root CLAUDE.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/api/CLAUDE.md" "api/CLAUDE.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/ui/CLAUDE.md" "ui/CLAUDE.md should be a symlink" } -test_copy_gemini_agents_md() { +test_link_gemini_agents_md() { run_setup --gemini > /dev/null - assert_file_exists "$TEST_DIR/GEMINI.md" "Root GEMINI.md should exist" && \ - assert_file_exists "$TEST_DIR/api/GEMINI.md" "api/GEMINI.md should exist" && \ - assert_file_exists "$TEST_DIR/ui/GEMINI.md" "ui/GEMINI.md should exist" + assert_symlink_exists "$TEST_DIR/GEMINI.md" "Root GEMINI.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/api/GEMINI.md" "api/GEMINI.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/ui/GEMINI.md" "ui/GEMINI.md should be a symlink" } -test_copy_copilot_to_github() { +test_link_copilot_to_github() { run_setup --copilot > /dev/null - assert_file_exists "$TEST_DIR/.github/copilot-instructions.md" "Copilot instructions should exist" + assert_symlink_exists "$TEST_DIR/.github/copilot-instructions.md" "Copilot instructions should be a symlink" } -test_copy_codex_no_extra_files() { +test_link_codex_no_extra_files() { run_setup --codex > /dev/null assert_file_not_exists "$TEST_DIR/CODEX.md" "CODEX.md should not be created" } -test_copy_not_created_without_flag() { +test_link_not_created_without_flag() { run_setup --codex > /dev/null - assert_file_not_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should not exist" && \ - assert_file_not_exists "$TEST_DIR/GEMINI.md" "GEMINI.md should not exist" + assert_symlink_not_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should not exist" && \ + assert_symlink_not_exists "$TEST_DIR/GEMINI.md" "GEMINI.md should not exist" } -test_copy_content_matches_source() { +test_link_content_matches_source() { run_setup --claude > /dev/null local source_content target_content source_content=$(cat "$TEST_DIR/AGENTS.md") @@ -272,7 +272,7 @@ test_idempotent_multiple_runs() { run_setup --claude > /dev/null run_setup --claude > /dev/null assert_symlink_exists "$TEST_DIR/.claude/skills" "Symlink should still exist after second run" && \ - assert_file_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should still exist after second run" + assert_symlink_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should still be a symlink after second run" } # ============================================================================= diff --git a/skills/skill-creator/SKILL.md b/skills/skill-creator/SKILL.md index 11787abe2b..41b8e7e67d 100644 --- a/skills/skill-creator/SKILL.md +++ b/skills/skill-creator/SKILL.md @@ -29,7 +29,7 @@ Create a skill when: ## Skill Structure -``` +```text skills/{skill-name}/ ├── SKILL.md # Required - main skill file ├── assets/ # Optional - templates, schemas, examples @@ -43,7 +43,7 @@ skills/{skill-name}/ ## SKILL.md Template -```markdown +````markdown --- name: {skill-name} description: > @@ -77,7 +77,7 @@ metadata: - **Templates**: See [assets/](assets/) for {description} - **Documentation**: See [references/](references/) for local docs -``` +```` --- @@ -94,7 +94,7 @@ metadata: ## Decision: assets/ vs references/ -``` +```text Need code templates? → assets/ Need JSON schemas? → assets/ Need example configs? → assets/ @@ -108,7 +108,7 @@ Link to external guides? → references/ (with local path) ## Decision: Prowler-Specific vs Generic -``` +```text Patterns apply to ANY project? → Generic skill (e.g., pytest, typescript) Patterns are Prowler-specific? → prowler-{name} skill Generic skill needs Prowler info? → Add references/ pointing to Prowler docs diff --git a/skills/skill-creator/assets/SKILL-TEMPLATE.md b/skills/skill-creator/assets/SKILL-TEMPLATE.md index 7639240245..581cafd9c4 100644 --- a/skills/skill-creator/assets/SKILL-TEMPLATE.md +++ b/skills/skill-creator/assets/SKILL-TEMPLATE.md @@ -38,7 +38,7 @@ Use this skill when: ## Decision Tree -``` +```text {Question 1}? → {Action A} {Question 2}? → {Action B} Otherwise → {Default action} diff --git a/skills/tailwind-4/SKILL.md b/skills/tailwind-4/SKILL.md index 51f57576af..e67d2ea944 100644 --- a/skills/tailwind-4/SKILL.md +++ b/skills/tailwind-4/SKILL.md @@ -14,7 +14,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## Styling Decision Tree -``` +```text Tailwind class exists? → className="..." Dynamic value? → style={{ width: `${x}%` }} Conditional styles? → cn("base", condition && "variant") diff --git a/skills/tdd/SKILL.md b/skills/tdd/SKILL.md index 60887c342c..d62d359053 100644 --- a/skills/tdd/SKILL.md +++ b/skills/tdd/SKILL.md @@ -20,7 +20,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, Task ## TDD Cycle (MANDATORY) -``` +```text +-----------------------------------------+ | RED -> GREEN -> REFACTOR | | ^ | | @@ -28,7 +28,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, Task +-----------------------------------------+ ``` -**The question is NOT "should I write tests?" but "what tests do I need?"** +The question is NOT "should I write tests?" but "what tests do I need?" --- @@ -75,7 +75,7 @@ pnpm test:coverage -- components/feature/ fd "*_test.py" tests/providers/aws/services/ec2/ # 2. Run specific test -poetry run pytest tests/providers/aws/services/ec2/ec2_ami_public/ -v +uv run pytest tests/providers/aws/services/ec2/ec2_ami_public/ -v # 3. Read existing tests ``` @@ -87,14 +87,14 @@ poetry run pytest tests/providers/aws/services/ec2/ec2_ami_public/ -v fd "test_*.py" api/src/backend/api/tests/ # 2. Run specific test -poetry run pytest api/src/backend/api/tests/test_models.py -v +uv run pytest api/src/backend/api/tests/test_models.py -v # 3. Read existing tests ``` ### Decision Tree (All Stacks) -``` +```text +------------------------------------------+ | Does test file exist for this code? | +----------+-----------------------+-------+ @@ -122,7 +122,7 @@ poetry run pytest api/src/backend/api/tests/test_models.py -v ### For NEW Functionality -**UI (Vitest)** +#### UI (Vitest) ```typescript describe("PriceCalculator", () => { @@ -139,7 +139,7 @@ describe("PriceCalculator", () => { }); ``` -**SDK (pytest)** +#### SDK (pytest) ```python class Test_ec2_ami_public: @@ -159,7 +159,7 @@ class Test_ec2_ami_public: assert len(result) == 0 ``` -**API (pytest-django)** +#### API (pytest-django) ```python @pytest.mark.django_db @@ -192,18 +192,18 @@ Write a test that **reproduces the bug** first: **API:** `assert response.status_code == 403 # Currently returns 200` -**Run -> Should FAIL (reproducing the bug)** +Run -> Should FAIL (reproducing the bug). ### For REFACTORING Capture ALL current behavior BEFORE refactoring: -``` +```text # Any stack: run ALL existing tests, they should PASS # This is your safety net - if any fail after refactoring, you broke something ``` -**Run -> All should PASS (baseline)** +Run -> All should PASS (baseline). --- @@ -288,13 +288,13 @@ Tests GREEN -> Improve code quality WITHOUT changing behavior. - Add types/validation - Reduce duplication -**Run tests after EACH change -> Must stay GREEN** +Run tests after EACH change -> Must stay GREEN. --- ## Quick Reference -``` +```text +------------------------------------------------+ | TDD WORKFLOW | +------------------------------------------------+ @@ -320,7 +320,7 @@ Tests GREEN -> Improve code quality WITHOUT changing behavior. ## Anti-Patterns (NEVER DO) -``` +```python # ANY language: # 1. Code first, tests after @@ -356,16 +356,16 @@ pnpm test ComponentName # Filter by name ### SDK (`prowler/`) ```bash -poetry run pytest tests/path/ -v # Run specific tests -poetry run pytest tests/path/ -v -k "test_name" # Filter by name -poetry run pytest -n auto tests/ # Parallel run -poetry run pytest --cov=./prowler tests/ # Coverage +uv run pytest tests/path/ -v # Run specific tests +uv run pytest tests/path/ -v -k "test_name" # Filter by name +uv run pytest -n auto tests/ # Parallel run +uv run pytest --cov=./prowler tests/ # Coverage ``` ### API (`api/`) ```bash -poetry run pytest -x --tb=short # Run all (stop on first fail) -poetry run pytest api/src/backend/api/tests/test_file.py # Specific file -poetry run pytest -k "test_name" -v # Filter by name +uv run pytest -x --tb=short # Run all (stop on first fail) +uv run pytest api/src/backend/api/tests/test_file.py # Specific file +uv run pytest -k "test_name" -v # Filter by name ``` diff --git a/skills/vitest/SKILL.md b/skills/vitest/SKILL.md index 11190cbaef..29ad237a3e 100644 --- a/skills/vitest/SKILL.md +++ b/skills/vitest/SKILL.md @@ -181,7 +181,7 @@ expect(screen.getByRole("button")).toBeDisabled(); ## File Organization -``` +```text components/ ├── Button/ │ ├── Button.tsx diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 08b1f7d5e0..365efbc0c9 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -6,6 +6,7 @@ from unittest import mock from requests import Response from prowler.config.config import ( + Provider, check_current_version, get_available_compliance_frameworks, load_and_validate_config_file, @@ -17,7 +18,13 @@ MOCK_OLD_PROWLER_VERSION = "0.0.0" MOCK_PROWLER_MASTER_VERSION = "3.4.0" -def mock_prowler_get_latest_release(_, **kwargs): +def test_provider_enum_includes_stackit_for_global_discovery(): + providers = [provider.value for provider in Provider] + + assert "stackit" in providers + + +def mock_prowler_get_latest_release(_, **_kwargs): """Mock requests.get() to get the Prowler latest release""" response = Response() response._content = b'[{"name":"3.3.0"}]' @@ -75,6 +82,7 @@ config_aws = { "mute_non_default_regions": False, "max_unused_access_keys_days": 45, "max_console_access_days": 45, + "max_unused_sagemaker_access_days": 90, "shodan_api_key": None, "max_security_group_rules": 50, "max_ec2_instance_age_in_days": 180, @@ -394,6 +402,7 @@ class Test_Config: def test_get_available_compliance_frameworks(self): compliance_frameworks = [ + "csa_ccm_4.0", "cisa_aws", "soc2_aws", "cis_1.4_aws", @@ -428,6 +437,92 @@ class Test_Config: get_available_compliance_frameworks().sort() == compliance_frameworks.sort() ) + def test_get_available_compliance_frameworks_filters_universal_by_provider(self): + aws_frameworks = get_available_compliance_frameworks("aws") + kubernetes_frameworks = get_available_compliance_frameworks("kubernetes") + + assert "csa_ccm_4.0" in aws_frameworks + assert "csa_ccm_4.0" not in kubernetes_frameworks + + def test_get_available_compliance_frameworks_no_provider_includes_universals(self): + """Regression test for the variable shadowing bug. + + Previously, the inner ``for provider in providers`` loop shadowed + the outer ``provider`` parameter. When called without a provider, + the post-loop ``if provider:`` branch wrongly applied + ``framework.supports_provider()`` and + excluded universal frameworks from the result. + + Result: the parser-level ``available_compliance_frameworks`` + constant was missing universal frameworks like ``csa_ccm_4.0``, + which made ``--compliance csa_ccm_4.0`` reject the choice. + """ + all_frameworks = get_available_compliance_frameworks() + assert "csa_ccm_4.0" in all_frameworks + + def test_get_available_compliance_frameworks_does_not_mutate_provider_param(self): + """Calling with a specific provider must not affect a subsequent + call without provider. Validates that the loop variable rename + prevents leaking state between calls.""" + # Force an iteration over multiple providers first + get_available_compliance_frameworks("kubernetes") + # Then a no-provider call must still include universals supported + # by ANY provider (not filtered by some leaked value) + all_frameworks = get_available_compliance_frameworks() + assert "csa_ccm_4.0" in all_frameworks + + @mock.patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_frameworks_dedupes_ep_collisions_with_builtins( + self, mock_dirs + ): + """Entry-point compliance frameworks that collide with a built-in + name must appear only once in the available frameworks list. + Built-in wins silently — same policy as the universal frameworks + loop and as Compliance.get_bulk.""" + import json + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + # cis_2.0_aws ships as a built-in under prowler/compliance/aws/ + json_path = os.path.join(tmpdir, "cis_2.0_aws.json") + with open(json_path, "w") as f: + json.dump({"Framework": "CIS", "Provider": "aws"}, f) + + mock_dirs.return_value = {"aws": [tmpdir]} + + frameworks = get_available_compliance_frameworks("aws") + + assert frameworks.count("cis_2.0_aws") == 1, ( + f"Expected cis_2.0_aws to appear exactly once, got " + f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}" + ) + + @mock.patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_frameworks_merges_multiple_ep_dirs_same_provider( + self, mock_dirs + ): + """Frameworks from every package contributing the same provider must + surface, not just the last directory discovered.""" + import json + import tempfile + + with ( + tempfile.TemporaryDirectory() as pkg_a, + tempfile.TemporaryDirectory() as pkg_b, + ): + with open(os.path.join(pkg_a, "cis_1.0_template.json"), "w") as f: + json.dump({"Framework": "CIS", "Provider": "template"}, f) + with open(os.path.join(pkg_b, "nis2_1.0_template.json"), "w") as f: + json.dump({"Framework": "NIS2", "Provider": "template"}, f) + + # Two packages register `prowler.compliance` with the same name. + mock_dirs.return_value = {"template": [pkg_a, pkg_b]} + + frameworks = get_available_compliance_frameworks("template") + + assert "cis_1.0_template" in frameworks + assert "nis2_1.0_template" in frameworks + def test_load_and_validate_config_file_aws(self): path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) config_test_file = f"{path}/fixtures/config.yaml" @@ -465,6 +560,32 @@ class Test_Config: assert load_and_validate_config_file("azure", config_test_file) == {} assert load_and_validate_config_file("kubernetes", config_test_file) == {} + def test_load_and_validate_config_file_namespaced_non_listed_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # github is a built-in not in the legacy hardcoded list; namespaced format must unwrap it. + assert load_and_validate_config_file("github", config_test_file) == { + "token": "abc", + "org": "prowler-cloud", + } + + def test_load_and_validate_config_file_namespaced_external_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # External plug-in provider: namespaced format must unwrap its block. + assert load_and_validate_config_file("custom_plugin", config_test_file) == { + "setting": "value", + "nested": {"key": 42}, + } + + def test_load_and_validate_config_file_namespaced_missing_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # Provider with no section in a namespaced file must return empty config, + # not the full file (prevents cross-provider config leakage). + assert load_and_validate_config_file("aws", config_test_file) == {} + assert load_and_validate_config_file("gcp", config_test_file) == {} + def test_load_and_validate_config_file_invalid_config_file_path(self, caplog): provider = "aws" config_file_path = "invalid/path/to/fixer_config.yaml" diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index 1b63e2387f..39cba5f27d 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -20,6 +20,8 @@ aws: max_unused_access_keys_days: 45 # aws.iam_user_console_access_unused --> CIS recommends 45 days max_console_access_days: 45 + # aws.iam_user_access_not_stale_to_sagemaker --> default 90 days + max_unused_sagemaker_access_days: 90 # AWS EC2 Configuration # aws.ec2_elastic_ip_shodan @@ -486,3 +488,11 @@ m365: # Exchange Mailbox Settings # m365.exchange_mailbox_properties_auditing_enabled audit_log_age: 90 # maximum number of days to keep audit logs + +okta: + # Okta Sign-On Policies + # okta.signon_global_session_idle_timeout_15min + okta_max_session_idle_minutes: 15 + # Okta Applications + # okta.application_admin_console_session_idle_timeout_15min + okta_admin_console_idle_timeout_max_minutes: 15 diff --git a/tests/config/fixtures/config_namespaced_external.yaml b/tests/config/fixtures/config_namespaced_external.yaml new file mode 100644 index 0000000000..ec9f75c698 --- /dev/null +++ b/tests/config/fixtures/config_namespaced_external.yaml @@ -0,0 +1,8 @@ +# Namespaced config covering a non-listed built-in (github) and an external plugin. +github: + token: abc + org: prowler-cloud +custom_plugin: + setting: value + nested: + key: 42 diff --git a/tests/config/schema/__init__.py b/tests/config/schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/config/schema/aws_schema_test.py b/tests/config/schema/aws_schema_test.py new file mode 100644 index 0000000000..8731e08ba9 --- /dev/null +++ b/tests/config/schema/aws_schema_test.py @@ -0,0 +1,177 @@ +"""AWS-specific schema coverage — the biggest provider, with the richest +constraint surface (CIDRs, account IDs, port ranges, enums, thresholds).""" + +import pytest + +from prowler.config.schema.aws import AWSProviderConfig +from prowler.config.schema.validator import validate_provider_config + + +def _validate(raw): + return validate_provider_config("aws", raw, AWSProviderConfig) + + +class Test_AWS_Threat_Detection_Thresholds: + """All threat detection thresholds are documented as fractions in 0..1. + The biggest risk of mistyping them is silently disabling the check.""" + + @pytest.mark.parametrize( + "key", + [ + "threat_detection_privilege_escalation_threshold", + "threat_detection_enumeration_threshold", + "threat_detection_llm_jacking_threshold", + ], + ) + def test_valid_boundary_values(self, key): + assert _validate({key: 0.0}) == {key: 0.0} + assert _validate({key: 1.0}) == {key: 1.0} + assert _validate({key: 0.5}) == {key: 0.5} + + @pytest.mark.parametrize( + "key", + [ + "threat_detection_privilege_escalation_threshold", + "threat_detection_enumeration_threshold", + "threat_detection_llm_jacking_threshold", + ], + ) + def test_invalid_values_are_dropped(self, key): + # 20 instead of 0.2 — would never trigger + assert _validate({key: 20}) == {} + # negative + assert _validate({key: -0.1}) == {} + # string + assert _validate({key: "high"}) == {} + + +class Test_AWS_Trusted_Account_Ids: + def test_valid_twelve_digit_ids(self): + ids = ["123456789012", "098765432109"] + assert _validate({"trusted_account_ids": ids}) == {"trusted_account_ids": ids} + + def test_empty_list_is_valid(self): + assert _validate({"trusted_account_ids": []}) == {"trusted_account_ids": []} + + def test_short_id_is_dropped(self): + assert _validate({"trusted_account_ids": ["12345"]}) == {} + + def test_non_numeric_id_is_dropped(self): + assert _validate({"trusted_account_ids": ["1234abcd5678"]}) == {} + + def test_id_with_dashes_is_dropped(self): + # Some users format account IDs as "1234-5678-9012" + assert _validate({"trusted_account_ids": ["1234-5678-9012"]}) == {} + + +class Test_AWS_Trusted_Ips: + def test_single_ipv4_address(self): + assert _validate({"trusted_ips": ["1.2.3.4"]}) == {"trusted_ips": ["1.2.3.4"]} + + def test_ipv4_cidr(self): + assert _validate({"trusted_ips": ["10.0.0.0/8"]}) == { + "trusted_ips": ["10.0.0.0/8"] + } + + def test_ipv6_address(self): + assert _validate({"trusted_ips": ["2001:db8::1"]}) == { + "trusted_ips": ["2001:db8::1"] + } + + def test_ipv6_cidr(self): + assert _validate({"trusted_ips": ["2001:db8::/32"]}) == { + "trusted_ips": ["2001:db8::/32"] + } + + def test_mixed_list(self): + ips = ["1.2.3.4", "10.0.0.0/8", "2001:db8::1"] + assert _validate({"trusted_ips": ips}) == {"trusted_ips": ips} + + def test_garbage_entry_is_dropped(self): + assert _validate({"trusted_ips": ["definitely-not-an-ip"]}) == {} + + def test_cidr_with_host_bits_is_accepted(self): + # We use strict=False so "10.0.0.5/8" is accepted. This matches the + # behaviour of most security tools and avoids surprising users who + # paste real-world allowlists with non-canonical CIDR notation. + assert _validate({"trusted_ips": ["10.0.0.5/8"]}) == { + "trusted_ips": ["10.0.0.5/8"] + } + + +class Test_AWS_Ports: + def test_valid_ports_in_range(self): + ports = [25, 80, 443, 65535, 1] + assert _validate({"ec2_high_risk_ports": ports}) == { + "ec2_high_risk_ports": ports + } + + def test_port_zero_is_dropped(self): + # Port 0 is reserved and not a valid security signal. + assert _validate({"ec2_high_risk_ports": [0]}) == {} + + def test_out_of_range_port_is_dropped(self): + assert _validate({"ec2_high_risk_ports": [70000]}) == {} + + def test_negative_port_is_dropped(self): + assert _validate({"ec2_high_risk_ports": [-1]}) == {} + + +class Test_AWS_Enums: + @pytest.mark.parametrize("level", ["CRITICAL", "HIGH", "MEDIUM", "LOW"]) + def test_valid_severity_levels(self, level): + assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == { + "ecr_repository_vulnerability_minimum_severity": level + } + + @pytest.mark.parametrize("level", ["critical", "Medium", "ANY", "", "X"]) + def test_invalid_severity_levels_are_dropped(self, level): + assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {} + + +class Test_AWS_Booleans: + @pytest.mark.parametrize( + "key", + [ + "mute_non_default_regions", + "verify_premium_support_plans", + "check_rds_instance_replicas", + ], + ) + def test_true_and_false_round_trip(self, key): + assert _validate({key: True}) == {key: True} + assert _validate({key: False}) == {key: False} + + def test_yaml_style_boolean_coercion(self): + # YAML can produce Python str "true"/"yes" if the user quoted it. + # Pydantic v2 deterministically coerces "yes"/"no"/"true"/"false" to a + # real bool in lax mode, so the value is normalized rather than passed + # through as a string (which would be dangerous for + # verify_premium_support_plans). + out = _validate({"verify_premium_support_plans": "yes"}) + assert "verify_premium_support_plans" in out + assert isinstance(out["verify_premium_support_plans"], bool) + assert out["verify_premium_support_plans"] is True + + +class Test_AWS_Full_Default_Config_Round_Trips: + """Loading the real shipped defaults through the schema must produce + exactly the same dict. This is the regression sentinel for backwards + compatibility.""" + + def test_full_default_config_round_trip(self): + # Subset that mirrors the shipped config.yaml semantics. + raw = { + "mute_non_default_regions": False, + "disallowed_regions": ["me-south-1", "me-central-1"], + "max_unused_access_keys_days": 45, + "max_ec2_instance_age_in_days": 180, + "trusted_account_ids": [], + "trusted_ips": [], + "ecr_repository_vulnerability_minimum_severity": "MEDIUM", + "threat_detection_privilege_escalation_threshold": 0.2, + "threat_detection_enumeration_threshold": 0.3, + "threat_detection_llm_jacking_threshold": 0.4, + "ec2_high_risk_ports": [25, 110, 8088], + } + assert _validate(raw) == raw diff --git a/tests/config/schema/bounds_test.py b/tests/config/schema/bounds_test.py new file mode 100644 index 0000000000..4d8d49bab2 --- /dev/null +++ b/tests/config/schema/bounds_test.py @@ -0,0 +1,378 @@ +"""Boundary tests for the safety bounds added on top of the upstream schemas. + +Each parametrised case checks (a) the min and max values are accepted and +(b) one step outside the range is rejected. Custom validators (semver, +EKS minor, dotted version, port range, account IDs, IPs) get focused +positive/negative tests. + +Tests use the public adapter ``prowler.config.scan_config_schema``: a +schema violation surfaces as a list of ``{"path", "message"}`` entries. +This keeps the contract the Prowler App backend depends on under test. +""" + +import pytest + +from prowler.config.scan_config_schema import validate_scan_config + + +def _has_error_for(errors: list[dict], path_substr: str) -> bool: + return any(path_substr in e["path"] for e in errors) + + +# Each tuple: (provider, key, min_allowed, max_allowed) +INT_BOUND_CASES = [ + # AWS + ("aws", "max_unused_access_keys_days", 30, 180), + ("aws", "max_console_access_days", 30, 180), + ("aws", "max_unused_sagemaker_access_days", 7, 180), + ("aws", "max_security_group_rules", 1, 1000), + ("aws", "max_ec2_instance_age_in_days", 1, 1095), + ("aws", "recommended_cdk_bootstrap_version", 1, 100), + ("aws", "max_idle_disconnect_timeout_in_seconds", 60, 1800), + ("aws", "max_disconnect_timeout_in_seconds", 60, 3600), + ("aws", "max_session_duration_seconds", 600, 86400), + ("aws", "lambda_min_azs", 1, 6), + ("aws", "threat_detection_privilege_escalation_minutes", 5, 43200), + ("aws", "threat_detection_enumeration_minutes", 5, 43200), + ("aws", "threat_detection_llm_jacking_minutes", 5, 43200), + ("aws", "days_to_expire_threshold", 7, 365), + ("aws", "elb_min_azs", 1, 6), + ("aws", "elbv2_min_azs", 1, 6), + ("aws", "minimum_snapshot_retention_period", 1, 35), + ("aws", "max_days_secret_unused", 7, 365), + ("aws", "max_days_secret_unrotated", 1, 180), + ("aws", "min_kinesis_stream_retention_hours", 24, 8760), + # Azure + ("azure", "vm_backup_min_daily_retention_days", 7, 9999), + ("azure", "apim_threat_detection_llm_jacking_minutes", 5, 43200), + # GCP + ("gcp", "mig_min_zones", 1, 5), + ("gcp", "max_snapshot_age_days", 1, 1095), + ("gcp", "max_unused_account_days", 30, 365), + ("gcp", "storage_min_retention_days", 1, 3650), + # Kubernetes + ("kubernetes", "audit_log_maxbackup", 2, 1000), + ("kubernetes", "audit_log_maxsize", 10, 10000), + ("kubernetes", "audit_log_maxage", 7, 3650), + # M365 + ("m365", "sign_in_frequency", 1, 168), + ("m365", "recommended_mailtips_large_audience_threshold", 5, 10000), + ("m365", "audit_log_age", 30, 3650), + # GitHub + ("github", "inactive_not_archived_days_threshold", 30, 3650), + # MongoDB Atlas + ("mongodbatlas", "max_service_account_secret_validity_hours", 1, 720), + # Cloudflare + ("cloudflare", "max_retries", 0, 10), + # Vercel + ("vercel", "days_to_expire_threshold", 7, 365), + ("vercel", "stale_token_threshold_days", 30, 3650), + ("vercel", "stale_invitation_threshold_days", 7, 365), + ("vercel", "max_owner_percentage", 1, 50), + ("vercel", "max_owners", 1, 1000), + # Okta + ("okta", "okta_max_session_idle_minutes", 1, 1440), + ("okta", "okta_max_session_lifetime_minutes", 1, 43200), + ("okta", "okta_admin_console_idle_timeout_max_minutes", 1, 1440), + ("okta", "okta_user_inactivity_max_days", 1, 3650), + # Alibaba Cloud + ("alibabacloud", "max_cluster_check_days", 1, 365), + ("alibabacloud", "max_console_access_days", 30, 180), + ("alibabacloud", "min_log_retention_days", 1, 3650), + ("alibabacloud", "min_rds_audit_retention_days", 1, 3650), + # OpenStack + ("openstack", "image_sharing_threshold", 1, 1000), +] + + +FLOAT_THRESHOLD_FIELDS = [ + ("aws", "threat_detection_privilege_escalation_threshold"), + ("aws", "threat_detection_enumeration_threshold"), + ("aws", "threat_detection_llm_jacking_threshold"), + ("azure", "apim_threat_detection_llm_jacking_threshold"), +] + + +class TestIntegerBounds: + """Each int field accepts both ends of its range and rejects ±1 outside.""" + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_min_accepted(self, provider, key, lo, hi): + assert validate_scan_config({provider: {key: lo}}) == [] + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_max_accepted(self, provider, key, lo, hi): + assert validate_scan_config({provider: {key: hi}}) == [] + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_below_min_rejected(self, provider, key, lo, hi): + errors = validate_scan_config({provider: {key: lo - 1}}) + assert _has_error_for(errors, f"{provider}.{key}"), errors + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_above_max_rejected(self, provider, key, lo, hi): + errors = validate_scan_config({provider: {key: hi + 1}}) + assert _has_error_for(errors, f"{provider}.{key}"), errors + + +class TestFloatThresholds: + """Threshold floats must stay within 0..1 inclusive.""" + + @pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS) + def test_zero_and_one_accepted(self, provider, key): + assert validate_scan_config({provider: {key: 0.0}}) == [] + assert validate_scan_config({provider: {key: 1.0}}) == [] + assert validate_scan_config({provider: {key: 0.5}}) == [] + + @pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS) + def test_negative_rejected(self, provider, key): + errors = validate_scan_config({provider: {key: -0.01}}) + assert _has_error_for(errors, f"{provider}.{key}") + + @pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS) + def test_above_one_rejected(self, provider, key): + errors = validate_scan_config({provider: {key: 1.01}}) + assert _has_error_for(errors, f"{provider}.{key}") + + +class TestCloudWatchRetention: + """`log_group_retention_days` only accepts the AWS-approved enum values.""" + + @pytest.mark.parametrize("value", [1, 7, 30, 365, 731, 3653]) + def test_valid_values_accepted(self, value): + assert validate_scan_config({"aws": {"log_group_retention_days": value}}) == [] + + @pytest.mark.parametrize("value", [0, 2, 42, 500, 999, 4000]) + def test_invalid_values_rejected(self, value): + errors = validate_scan_config({"aws": {"log_group_retention_days": value}}) + assert _has_error_for(errors, "aws.log_group_retention_days") + + +class TestSemverValidator: + """AWS Fargate platform versions: X.Y.Z.""" + + @pytest.mark.parametrize("value", ["1.4.0", "1.0.0", "0.0.1", "10.20.30"]) + def test_accepts_semver(self, value): + assert ( + validate_scan_config({"aws": {"fargate_linux_latest_version": value}}) == [] + ) + + @pytest.mark.parametrize("value", ["1.4", "1", "v1.4.0", "1.4.0-beta", "a.b.c", ""]) + def test_rejects_non_semver(self, value): + errors = validate_scan_config({"aws": {"fargate_linux_latest_version": value}}) + assert _has_error_for(errors, "aws.fargate_linux_latest_version") + + +class TestEksVersionValidator: + """`eks_cluster_oldest_version_supported` expects MAJOR.MINOR.""" + + @pytest.mark.parametrize("value", ["1.28", "1.29", "1.30", "2.0"]) + def test_accepts_minor(self, value): + assert ( + validate_scan_config( + {"aws": {"eks_cluster_oldest_version_supported": value}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["1.28.0", "v1.28", "1", "1.x", ""]) + def test_rejects_invalid(self, value): + errors = validate_scan_config( + {"aws": {"eks_cluster_oldest_version_supported": value}} + ) + assert _has_error_for(errors, "aws.eks_cluster_oldest_version_supported") + + +class TestEksLogTypesEnum: + """Only the documented log types are accepted.""" + + def test_full_enum_accepted(self): + assert ( + validate_scan_config( + { + "aws": { + "eks_required_log_types": [ + "api", + "audit", + "authenticator", + "controllerManager", + "scheduler", + ] + } + } + ) + == [] + ) + + def test_unknown_type_rejected(self): + errors = validate_scan_config( + {"aws": {"eks_required_log_types": ["api", "telemetry"]}} + ) + assert _has_error_for(errors, "aws.eks_required_log_types") + + +class TestAzureDottedVersion: + """App Service versions accept 'X' and 'X.Y' but not 'X.Y.Z' or junk.""" + + @pytest.mark.parametrize("value", ["8.2", "3.12", "17"]) + def test_accepts(self, value): + assert validate_scan_config({"azure": {"php_latest_version": value}}) == [] + assert validate_scan_config({"azure": {"python_latest_version": value}}) == [] + assert validate_scan_config({"azure": {"java_latest_version": value}}) == [] + + @pytest.mark.parametrize("value", ["8.2.0", "v8", "8.x", ""]) + def test_rejects(self, value): + errors = validate_scan_config({"azure": {"php_latest_version": value}}) + assert _has_error_for(errors, "azure.php_latest_version") + + +class TestAzureTlsLiteralEnum: + """Only TLS 1.2 and 1.3 are tolerated by the recommended list.""" + + def test_accepted_versions(self): + assert ( + validate_scan_config( + {"azure": {"recommended_minimal_tls_versions": ["1.2", "1.3"]}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["1.0", "1.1", "2.0", ""]) + def test_unknown_version_rejected(self, value): + errors = validate_scan_config( + {"azure": {"recommended_minimal_tls_versions": [value]}} + ) + assert _has_error_for(errors, "azure.recommended_minimal_tls_versions") + + +class TestAzureRiskLevelLiteral: + """Defender attack-path risk level is a closed enum.""" + + @pytest.mark.parametrize("value", ["Low", "Medium", "High", "Critical"]) + def test_accepted(self, value): + assert ( + validate_scan_config( + {"azure": {"defender_attack_path_minimal_risk_level": value}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["low", "CRITICAL", "Severe", ""]) + def test_rejected(self, value): + errors = validate_scan_config( + {"azure": {"defender_attack_path_minimal_risk_level": value}} + ) + assert _has_error_for(errors, "azure.defender_attack_path_minimal_risk_level") + + +class TestECRSeverityLiteral: + """ECR severity is a closed enum (with INFORMATIONAL allowed).""" + + @pytest.mark.parametrize( + "value", + ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"], + ) + def test_accepted(self, value): + assert ( + validate_scan_config( + {"aws": {"ecr_repository_vulnerability_minimum_severity": value}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["URGENT", "low", "Crit", ""]) + def test_rejected(self, value): + errors = validate_scan_config( + {"aws": {"ecr_repository_vulnerability_minimum_severity": value}} + ) + assert _has_error_for( + errors, "aws.ecr_repository_vulnerability_minimum_severity" + ) + + +class TestPortRangeValidator: + """Each entry of `ec2_high_risk_ports` must be 1..65535 (0 is reserved).""" + + def test_valid_ports(self): + assert ( + validate_scan_config({"aws": {"ec2_high_risk_ports": [1, 22, 8080, 65535]}}) + == [] + ) + + @pytest.mark.parametrize("value", [-1, 0, 65536, 99999]) + def test_invalid_port_rejected(self, value): + errors = validate_scan_config({"aws": {"ec2_high_risk_ports": [80, value]}}) + assert _has_error_for(errors, "aws.ec2_high_risk_ports") + + +class TestAccountIdsValidator: + """AWS account IDs are 12-digit strings.""" + + def test_valid(self): + assert ( + validate_scan_config( + {"aws": {"trusted_account_ids": ["123456789012", "098765432109"]}} + ) + == [] + ) + + @pytest.mark.parametrize( + "value", ["12345", "12345678901", "1234567890123", "12345678901a"] + ) + def test_invalid_rejected(self, value): + errors = validate_scan_config({"aws": {"trusted_account_ids": [value]}}) + assert _has_error_for(errors, "aws.trusted_account_ids") + + +class TestTrustedIpsValidator: + """Trusted IPs accept IPv4, IPv6, and CIDR; reject junk.""" + + @pytest.mark.parametrize( + "value", + ["1.2.3.4", "10.0.0.0/8", "2001:db8::1", "2001:db8::/32"], + ) + def test_valid(self, value): + assert validate_scan_config({"aws": {"trusted_ips": [value]}}) == [] + + @pytest.mark.parametrize( + "value", ["not.an.ip", "1.2.3.300", "10.0.0.0/40", "::ffff:::"] + ) + def test_invalid_rejected(self, value): + errors = validate_scan_config({"aws": {"trusted_ips": [value]}}) + assert _has_error_for(errors, "aws.trusted_ips") + + +class TestAdapterRobustness: + """Top-level adapter behaviour the Prowler App backend depends on.""" + + def test_non_dict_payload(self): + errors = validate_scan_config([1, 2, 3]) + assert len(errors) == 1 + assert errors[0]["path"] == "" + + def test_unknown_provider_section_tolerated(self): + # additionalProperties: True at the root level by design. + assert validate_scan_config({"newprovider": {"foo": "bar"}}) == [] + + def test_unknown_key_tolerated_by_pydantic_extra_allow(self): + # ProviderConfigBase has extra="allow" for forward compatibility. + assert validate_scan_config({"aws": {"completely_new_knob": 1}}) == [] + + def test_provider_section_must_be_mapping(self): + errors = validate_scan_config({"aws": "not a mapping"}) + assert _has_error_for(errors, "aws") + + def test_multiple_errors_surfaced(self): + errors = validate_scan_config( + { + "aws": { + "max_unused_access_keys_days": 5, # below min 30 + "max_security_group_rules": 99999, # above max 1000 + "ec2_high_risk_ports": [80, 70000], # port out of range + } + } + ) + # All three should surface independently. + assert _has_error_for(errors, "aws.max_unused_access_keys_days") + assert _has_error_for(errors, "aws.max_security_group_rules") + assert _has_error_for(errors, "aws.ec2_high_risk_ports") diff --git a/tests/config/schema/loader_integration_test.py b/tests/config/schema/loader_integration_test.py new file mode 100644 index 0000000000..fa995fb9df --- /dev/null +++ b/tests/config/schema/loader_integration_test.py @@ -0,0 +1,124 @@ +"""End-to-end tests that exercise the real ``load_and_validate_config_file`` +through a temp YAML file. Anything that breaks here would break the actual +``prowler aws -c …`` code path.""" + +import logging +import os +import pathlib +from typing import Callable + +import pytest + +from prowler.config.config import load_and_validate_config_file + + +@pytest.fixture +def write_config(tmp_path: pathlib.Path) -> Callable[[str], str]: + def _write(content: str) -> str: + path = tmp_path / "config.yaml" + path.write_text(content) + return str(path) + + return _write + + +class Test_Loader_With_Schema_Integration: + def test_shipped_default_config_loads_without_warnings(self, caplog): + """The default ``prowler/config/config.yaml`` must round-trip every + provider WITHOUT emitting any schema warnings. If this fails, + someone added a key to the YAML without updating the schema.""" + repo_root = pathlib.Path(os.path.dirname(os.path.realpath(__file__))).parents[2] + shipped = repo_root / "prowler" / "config" / "config.yaml" + with caplog.at_level(logging.WARNING, logger="prowler"): + for provider in [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "mongodbatlas", + "cloudflare", + "vercel", + ]: + cfg = load_and_validate_config_file(provider, str(shipped)) + # Provider always exists in the shipped file → non-empty. + assert cfg, f"{provider} returned an empty config" + + offending = [ + r.getMessage() + for r in caplog.records + if "prowler.config[" in r.getMessage() + ] + assert not offending, ( + "Shipped config.yaml triggered schema warnings — schema or YAML out of sync:\n" + + "\n".join(offending) + ) + + def test_user_config_with_bad_threshold_falls_back(self, write_config, caplog): + path = write_config( + "aws:\n" + " threat_detection_privilege_escalation_threshold: 5.0\n" + " lambda_min_azs: 2\n" + ) + with caplog.at_level(logging.WARNING, logger="prowler"): + cfg = load_and_validate_config_file("aws", path) + assert cfg == {"lambda_min_azs": 2} + assert any( + "threat_detection_privilege_escalation_threshold" in r.getMessage() + for r in caplog.records + ) + + def test_old_format_config_still_works(self, write_config): + # Old format = flat keys, no provider header. + path = write_config( + "max_ec2_instance_age_in_days: 90\n" + "ecr_repository_vulnerability_minimum_severity: HIGH\n" + ) + cfg = load_and_validate_config_file("aws", path) + assert cfg == { + "max_ec2_instance_age_in_days": 90, + "ecr_repository_vulnerability_minimum_severity": "HIGH", + } + + def test_unknown_keys_pass_through_via_loader(self, write_config): + path = write_config( + "aws:\n" " third_party_plugin_setting: hello\n" " lambda_min_azs: 2\n" + ) + cfg = load_and_validate_config_file("aws", path) + assert cfg == { + "third_party_plugin_setting": "hello", + "lambda_min_azs": 2, + } + + def test_quoted_numeric_is_coerced_via_loader(self, write_config): + # YAML quotes the number: ``"180"`` arrives as a Python str. + # The schema must coerce it to int so downstream comparisons work. + path = write_config('aws:\n max_ec2_instance_age_in_days: "180"\n') + cfg = load_and_validate_config_file("aws", path) + assert cfg == {"max_ec2_instance_age_in_days": 180} + assert isinstance(cfg["max_ec2_instance_age_in_days"], int) + + def test_invalid_yaml_shape_list_as_string_drops_key(self, write_config, caplog): + path = write_config( + "aws:\n" + " disallowed_regions: me-south-1\n" # forgot list dashes + " lambda_min_azs: 2\n" + ) + with caplog.at_level(logging.WARNING, logger="prowler"): + cfg = load_and_validate_config_file("aws", path) + assert cfg == {"lambda_min_azs": 2} + assert any("disallowed_regions" in r.getMessage() for r in caplog.records) + + def test_other_providers_unaffected_by_aws_block(self, write_config): + path = write_config( + "aws:\n max_ec2_instance_age_in_days: 90\n" "gcp:\n mig_min_zones: 5\n" + ) + assert load_and_validate_config_file("aws", path) == { + "max_ec2_instance_age_in_days": 90 + } + assert load_and_validate_config_file("gcp", path) == {"mig_min_zones": 5} + + def test_missing_provider_block_returns_empty(self, write_config): + path = write_config("aws:\n max_ec2_instance_age_in_days: 90\n") + assert load_and_validate_config_file("azure", path) == {} diff --git a/tests/config/schema/other_providers_schema_test.py b/tests/config/schema/other_providers_schema_test.py new file mode 100644 index 0000000000..c3fc0605c2 --- /dev/null +++ b/tests/config/schema/other_providers_schema_test.py @@ -0,0 +1,200 @@ +"""Smaller-provider schema coverage. One happy path + one invalid path +per field is enough to lock in the contract; the validator behaviour +itself is covered exhaustively in validator_test.py.""" + +import pytest + +from prowler.config.schema.registry import SCHEMAS +from prowler.config.schema.validator import validate_provider_config + + +def _validate(provider, raw): + return validate_provider_config(provider, raw, SCHEMAS[provider]) + + +class Test_Azure_Schema: + @pytest.mark.parametrize("level", ["Low", "Medium", "High", "Critical"]) + def test_defender_risk_level_valid_values(self, level): + assert _validate( + "azure", {"defender_attack_path_minimal_risk_level": level} + ) == {"defender_attack_path_minimal_risk_level": level} + + def test_defender_risk_level_lowercase_dropped(self): + # Case matters: the matching check uses Title-case comparison. + assert ( + _validate("azure", {"defender_attack_path_minimal_risk_level": "high"}) + == {} + ) + + def test_apim_threshold_in_range(self): + out = _validate("azure", {"apim_threat_detection_llm_jacking_threshold": 0.1}) + assert out == {"apim_threat_detection_llm_jacking_threshold": 0.1} + + def test_apim_threshold_out_of_range(self): + out = _validate("azure", {"apim_threat_detection_llm_jacking_threshold": 1.5}) + assert out == {} + + def test_vm_backup_retention_must_be_positive(self): + assert _validate("azure", {"vm_backup_min_daily_retention_days": 7}) == { + "vm_backup_min_daily_retention_days": 7 + } + assert _validate("azure", {"vm_backup_min_daily_retention_days": 0}) == {} + assert _validate("azure", {"vm_backup_min_daily_retention_days": -1}) == {} + + +class Test_GCP_Schema: + def test_valid_values_round_trip(self): + raw = { + "mig_min_zones": 2, + "max_snapshot_age_days": 90, + "max_unused_account_days": 180, + "storage_min_retention_days": 90, + } + assert _validate("gcp", raw) == raw + + def test_zero_zone_count_dropped(self): + assert _validate("gcp", {"mig_min_zones": 0}) == {} + + +class Test_Kubernetes_Schema: + def test_valid_values_round_trip(self): + raw = { + "audit_log_maxbackup": 10, + "audit_log_maxsize": 100, + "audit_log_maxage": 30, + } + assert _validate("kubernetes", raw) == raw + + def test_negative_audit_log_dropped(self): + assert _validate("kubernetes", {"audit_log_maxage": -1}) == {} + + +class Test_M365_Schema: + def test_valid_values_round_trip(self): + raw = { + "sign_in_frequency": 4, + "recommended_mailtips_large_audience_threshold": 25, + "audit_log_age": 90, + } + assert _validate("m365", raw) == raw + + def test_negative_audit_log_age_dropped(self): + assert _validate("m365", {"audit_log_age": -10}) == {} + + +class Test_GitHub_Schema: + def test_valid_threshold(self): + assert _validate("github", {"inactive_not_archived_days_threshold": 180}) == { + "inactive_not_archived_days_threshold": 180 + } + + def test_zero_threshold_dropped(self): + assert _validate("github", {"inactive_not_archived_days_threshold": 0}) == {} + + +class Test_MongoDBAtlas_Schema: + def test_valid(self): + assert _validate( + "mongodbatlas", {"max_service_account_secret_validity_hours": 8} + ) == {"max_service_account_secret_validity_hours": 8} + + def test_invalid_negative(self): + assert ( + _validate("mongodbatlas", {"max_service_account_secret_validity_hours": -1}) + == {} + ) + + +class Test_Cloudflare_Schema: + def test_zero_retries_allowed(self): + # 0 is explicitly documented as "disable retries" in config.yaml. + assert _validate("cloudflare", {"max_retries": 0}) == {"max_retries": 0} + + def test_positive_retries_allowed(self): + assert _validate("cloudflare", {"max_retries": 3}) == {"max_retries": 3} + + def test_negative_retries_dropped(self): + assert _validate("cloudflare", {"max_retries": -1}) == {} + + +class Test_Vercel_Schema: + def test_owner_percentage_in_range(self): + assert _validate("vercel", {"max_owner_percentage": 20}) == { + "max_owner_percentage": 20 + } + assert _validate("vercel", {"max_owner_percentage": 1}) == { + "max_owner_percentage": 1 + } + assert _validate("vercel", {"max_owner_percentage": 50}) == { + "max_owner_percentage": 50 + } + + def test_owner_percentage_over_max_dropped(self): + # Tightened to 1..50 — anything above (incl. previous 100) is dropped. + assert _validate("vercel", {"max_owner_percentage": 51}) == {} + assert _validate("vercel", {"max_owner_percentage": 150}) == {} + + def test_owner_percentage_zero_or_negative_dropped(self): + # 0 is no longer a valid configuration (defeats PoLP signal). + assert _validate("vercel", {"max_owner_percentage": 0}) == {} + assert _validate("vercel", {"max_owner_percentage": -1}) == {} + + def test_full_default_config_round_trip(self): + raw = { + "stable_branches": ["main", "master"], + "days_to_expire_threshold": 7, + "stale_token_threshold_days": 90, + "stale_invitation_threshold_days": 30, + "max_owner_percentage": 20, + "max_owners": 3, + "secret_suffixes": ["_KEY", "_SECRET", "_TOKEN"], + } + assert _validate("vercel", raw) == raw + + +class Test_Okta_Schema: + def test_valid_values_round_trip(self): + raw = { + "okta_max_session_idle_minutes": 15, + "okta_max_session_lifetime_minutes": 18 * 60, + "okta_admin_console_idle_timeout_max_minutes": 15, + "okta_user_inactivity_max_days": 35, + "okta_dod_approved_ca_issuer_patterns": [r"\bOU=DoD\b", r"\bOU=ECA\b"], + } + assert _validate("okta", raw) == raw + + def test_zero_idle_minutes_dropped(self): + assert _validate("okta", {"okta_max_session_idle_minutes": 0}) == {} + + def test_negative_inactivity_days_dropped(self): + assert _validate("okta", {"okta_user_inactivity_max_days": -1}) == {} + + +class Test_AlibabaCloud_Schema: + def test_valid_values_round_trip(self): + raw = { + "max_cluster_check_days": 7, + "max_console_access_days": 90, + "min_log_retention_days": 365, + "min_rds_audit_retention_days": 180, + } + assert _validate("alibabacloud", raw) == raw + + def test_zero_cluster_check_days_dropped(self): + assert _validate("alibabacloud", {"max_cluster_check_days": 0}) == {} + + def test_console_access_below_min_dropped(self): + # 30 is the documented floor; anything below produces false positives. + assert _validate("alibabacloud", {"max_console_access_days": 29}) == {} + + +class Test_OpenStack_Schema: + def test_valid_values_round_trip(self): + raw = { + "image_sharing_threshold": 5, + "secrets_ignore_patterns": ["AKIA[0-9A-Z]{16}"], + } + assert _validate("openstack", raw) == raw + + def test_zero_threshold_dropped(self): + assert _validate("openstack", {"image_sharing_threshold": 0}) == {} diff --git a/tests/config/schema/validator_test.py b/tests/config/schema/validator_test.py new file mode 100644 index 0000000000..398a0d10e8 --- /dev/null +++ b/tests/config/schema/validator_test.py @@ -0,0 +1,175 @@ +"""Behavioural tests for ``validate_provider_config``. + +The validator is the gatekeeper for every provider schema: its job is to +keep backwards-compatible behaviour (no exceptions, drop only the bad +keys) while loudly logging type mistakes. +""" + +import logging + +import pytest + +from prowler.config.schema.aws import AWSProviderConfig +from prowler.config.schema.registry import SCHEMAS +from prowler.config.schema.validator import validate_provider_config + + +class Test_Validate_Provider_Config_Contract: + """Generic invariants that must hold for any schema.""" + + def test_returns_empty_dict_when_raw_is_not_a_dict(self): + assert validate_provider_config("aws", None, AWSProviderConfig) == {} + assert validate_provider_config("aws", "string", AWSProviderConfig) == {} + assert validate_provider_config("aws", 42, AWSProviderConfig) == {} + assert validate_provider_config("aws", [], AWSProviderConfig) == {} + + def test_returns_raw_unchanged_when_no_schema_registered(self): + raw = {"anything": "goes", "even": [1, 2, 3]} + assert validate_provider_config("mystery_provider", raw, None) == raw + + def test_unknown_keys_pass_through_for_plugin_compatibility(self): + # Third-party plugins inject arbitrary keys; the schema must NOT + # filter them. This is the contract that lets the plugin ecosystem + # keep working when we add validation. + raw = {"plugin_custom_key": "foo", "lambda_min_azs": 2} + assert validate_provider_config("aws", raw, AWSProviderConfig) == { + "plugin_custom_key": "foo", + "lambda_min_azs": 2, + } + + def test_empty_dict_returns_empty_dict(self): + assert validate_provider_config("aws", {}, AWSProviderConfig) == {} + + def test_known_valid_value_passes_through_unchanged(self): + raw = {"max_ec2_instance_age_in_days": 180} + assert validate_provider_config("aws", raw, AWSProviderConfig) == { + "max_ec2_instance_age_in_days": 180 + } + + +class Test_Validate_Provider_Config_Coercion: + """Pydantic v2 coerces common type-mistakes automatically. We want to + keep that behaviour so quoted numerics in user configs ``Just Work``.""" + + def test_string_numeric_is_coerced_to_int(self): + out = validate_provider_config( + "aws", {"max_ec2_instance_age_in_days": "180"}, AWSProviderConfig + ) + assert out == {"max_ec2_instance_age_in_days": 180} + assert isinstance(out["max_ec2_instance_age_in_days"], int) + + def test_string_numeric_is_coerced_to_float(self): + out = validate_provider_config( + "aws", + {"threat_detection_privilege_escalation_threshold": "0.4"}, + AWSProviderConfig, + ) + assert out == {"threat_detection_privilege_escalation_threshold": 0.4} + + +class Test_Validate_Provider_Config_Drops_Invalid_Keys: + """When a field fails validation, only that key is dropped from the + returned dict. The rest of the user's config is preserved so the + consumer's ``audit_config.get(key, default)`` falls back to its own + built-in default for the offending field and uses user values for + everything else.""" + + def test_out_of_range_threshold_is_dropped(self, caplog): + with caplog.at_level(logging.WARNING): + out = validate_provider_config( + "aws", + { + "threat_detection_privilege_escalation_threshold": 2.0, + "lambda_min_azs": 2, + }, + AWSProviderConfig, + ) + assert out == {"lambda_min_azs": 2} + assert any( + "threat_detection_privilege_escalation_threshold" in r.getMessage() + for r in caplog.records + ) + + def test_invalid_enum_is_dropped(self): + out = validate_provider_config( + "aws", + {"ecr_repository_vulnerability_minimum_severity": "medum"}, + AWSProviderConfig, + ) + assert out == {} + + def test_wrong_shape_list_as_string_is_dropped(self): + # Classic YAML mistake: ``disallowed_regions: me-south-1`` without dashes. + # Pydantic refuses to silently treat a str as a single-element list, + # which is exactly the safety guarantee we want. + out = validate_provider_config( + "aws", + {"disallowed_regions": "me-south-1", "lambda_min_azs": 2}, + AWSProviderConfig, + ) + assert out == {"lambda_min_azs": 2} + + def test_negative_positive_int_is_dropped(self): + out = validate_provider_config( + "aws", {"max_ec2_instance_age_in_days": -1}, AWSProviderConfig + ) + assert out == {} + + def test_zero_is_dropped_for_strictly_positive_field(self): + # max_ec2_instance_age_in_days is gt=0. Zero would silently cause every + # instance to FAIL the age check. + out = validate_provider_config( + "aws", {"max_ec2_instance_age_in_days": 0}, AWSProviderConfig + ) + assert out == {} + + def test_multiple_invalid_keys_yield_multiple_warnings(self, caplog): + with caplog.at_level(logging.WARNING): + out = validate_provider_config( + "aws", + { + "max_ec2_instance_age_in_days": "nope", + "ecr_repository_vulnerability_minimum_severity": "medum", + "valid_extra_key": "kept", + }, + AWSProviderConfig, + ) + assert out == {"valid_extra_key": "kept"} + messages = " ".join(r.getMessage() for r in caplog.records) + assert "max_ec2_instance_age_in_days" in messages + assert "ecr_repository_vulnerability_minimum_severity" in messages + + def test_warning_message_includes_provider_and_field(self, caplog): + with caplog.at_level(logging.WARNING): + validate_provider_config( + "aws", + {"threat_detection_privilege_escalation_threshold": 5.0}, + AWSProviderConfig, + ) + assert any( + "prowler.config[aws.threat_detection_privilege_escalation_threshold]" + in r.getMessage() + for r in caplog.records + ) + + +class Test_Schemas_Registry: + """Every provider mentioned in the YAML config must have a schema.""" + + @pytest.mark.parametrize( + "provider", + [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "mongodbatlas", + "cloudflare", + "vercel", + ], + ) + def test_schema_registered_for_provider(self, provider): + assert provider in SCHEMAS + assert SCHEMAS[provider] is not None diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/common_methods_test.py b/tests/dashboard/common_methods_test.py new file mode 100644 index 0000000000..b2137589c5 --- /dev/null +++ b/tests/dashboard/common_methods_test.py @@ -0,0 +1,81 @@ +import pandas as pd +from dash import dash_table + +from dashboard.common_methods import get_section_containers_generic + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +def _df(**extra): + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123"], + "RESOURCEID": ["res1"], + } + data.update(extra) + return pd.DataFrame(data) + + +class TestGetSectionContainersGeneric: + def test_one_container_per_section(self): + """One outer container per distinct section value.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2", "req3"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + assert len(result.children) == 2 + + def test_inner_title_includes_id_and_description(self): + """Inner accordion title is ' - '.""" + df = _df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Ensure MFA"], + ) + rendered = str( + get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + ) + assert "req1 - Ensure MFA" in rendered + + def test_arbitrary_ids_do_not_crash(self): + """Non-numeric ids are sorted lexicographically without raising.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A"] * 3, + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + tables = _datatable_column_ids(result) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/compliance/__init__.py b/tests/dashboard/compliance/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/compliance/generic_test.py b/tests/dashboard/compliance/generic_test.py new file mode 100644 index 0000000000..4e36833ada --- /dev/null +++ b/tests/dashboard/compliance/generic_test.py @@ -0,0 +1,204 @@ +import pandas as pd +from dash import dash_table, html + +from dashboard.compliance.generic import get_table + + +def _make_minimal_df(**extra_cols): + """Create a minimal valid DataFrame for get_table tests.""" + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123456789"], + "RESOURCEID": ["res1"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +class TestGetTable: + def test_groups_by_section(self): + """SC-001a: df with REQUIREMENTS_ATTRIBUTES_SECTION returns Div grouped by section.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": [ + "Section A", + "Section A", + "Section A", + "Section B", + "Section B", + ], + "REQUIREMENTS_ID": [ + "ctrl-alpha", + "ctrl-alpha", + "ctrl-alpha", + "ctrl-beta", + "ctrl-beta", + ], + "STATUS": ["PASS", "FAIL", "PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3", "check4", "check5"], + "REGION": ["us-east-1"] * 5, + "ACCOUNTID": ["123"] * 5, + "RESOURCEID": ["res1", "res2", "res3", "res4", "res5"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + assert len(result.children) == 2 # one container per distinct section + + def test_flat_fallback_no_attributes(self): + """SC-001b: No REQUIREMENTS_ATTRIBUTES_* cols → grouped by REQUIREMENTS_ID.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + # 2 distinct REQUIREMENTS_ID values → 2 group containers + assert len(result.children) == 2 + + def test_arbitrary_ids_no_crash(self): + """ADR-2 / R1 regression guard: non-numeric REQUIREMENTS_IDs must not raise ValueError. + + get_section_containers_cis sorts by version_tuple which calls int() on each + dotted/dashed segment and crashes on IDs like 'AC-2(1)'. Selecting format4 + (no version sort) is the fix. This test is a permanent guard against regression. + """ + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + # Must not raise ValueError + result = get_table(data) + assert isinstance(result, html.Div) + + def test_discovers_multiple_attribute_columns(self): + """SC-005a: Multiple REQUIREMENTS_ATTRIBUTES_* cols present → no AttributeError; + component tree is non-empty.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ATTRIBUTES_CATEGORY": ["Cat 1", "Cat 2"], + "REQUIREMENTS_ATTRIBUTES_CONTROL_ID": ["C1", "C2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.children # non-empty component tree + + def test_novel_attribute_column_names(self): + """SC-005b: Novel attr col names without a SECTION col → first attr col used as + grouping; returns a valid html.Div without any code change required.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_DOMAIN": ["Domain A", "Domain B"], + "REQUIREMENTS_ATTRIBUTES_SUBDOMAIN": ["Sub 1", "Sub 2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert len(result.children) > 0 + + def test_manual_only_requirements(self): + """SC-008a: All rows have STATUS='MANUAL' → returns html.Div with non-empty + children; result is not the 'No data found' string.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["MANUAL", "MANUAL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert not isinstance(result, str) + assert result.children # non-empty + + def test_empty_dataframe(self): + """SC-009a: Zero rows with correct column schema → valid html.Div; no exception.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": pd.Series([], dtype=str), + "REQUIREMENTS_ID": pd.Series([], dtype=str), + "STATUS": pd.Series([], dtype=str), + "CHECKID": pd.Series([], dtype=str), + "REGION": pd.Series([], dtype=str), + "ACCOUNTID": pd.Series([], dtype=str), + "RESOURCEID": pd.Series([], dtype=str), + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + + def test_get_table_returns_html_div(self): + """SC-012a: Smoke test — isinstance(get_table(df), html.Div) is True.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + ) + result = get_table(data) + assert isinstance(result, html.Div) + + +class TestNestedRendering: + def test_section_and_requirement_id_are_separate_levels(self): + """Section is the outer level; requirement id + description the inner.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["3 Compute Services"], + REQUIREMENTS_DESCRIPTION=["Ensure only MFA enabled identities"], + ) + rendered = str(get_table(data)) + assert "3 Compute Services" in rendered + assert "req1 - Ensure only MFA enabled identities" in rendered + + def test_checks_table_is_nested_under_requirement(self): + """The checks table sits at the innermost level.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Some requirement"], + ) + tables = _datatable_column_ids(get_table(data)) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/pages/__init__.py b/tests/dashboard/pages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/pages/compliance_dispatch_test.py b/tests/dashboard/pages/compliance_dispatch_test.py new file mode 100644 index 0000000000..78bd88ce68 --- /dev/null +++ b/tests/dashboard/pages/compliance_dispatch_test.py @@ -0,0 +1,179 @@ +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest +from dash import html + +from dashboard.pages.compliance import _dispatch_compliance_renderer + + +def _make_dispatch_df(**extra_cols): + """Minimal DataFrame with the columns required by the dedup step.""" + data = { + "REQUIREMENTS_ID": ["req1", "req2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +class TestDispatchComplianceRenderer: + def test_builtin_name_uses_builtin_module(self): + """SC-002a: analytics_input='cis_4_0_aws' resolves real builtin module; + returns (html.Div, DataFrame) 2-tuple.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Description 1", "Description 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["Pass", "Fail"], + } + ) + table, result_data = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_unknown_name_falls_back_to_generic(self): + """SC-003a: Unknown analytics_input raises ModuleNotFoundError → generic + fallback is called with the deduped dataframe.""" + data = _make_dispatch_df() + sentinel = MagicMock( + return_value=html.Div([], className="compliance-data-layout") + ) + + with patch("dashboard.compliance.generic.get_table", sentinel): + table, result_data = _dispatch_compliance_renderer(data, "myfw_dynprovider") + + sentinel.assert_called_once() + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_import_error_is_not_swallowed(self): + """SC-003b: ImportError (NOT ModuleNotFoundError) is re-raised; except clause + is exact — only ModuleNotFoundError routes to generic.""" + data = _make_dispatch_df() + + with patch( + "dashboard.pages.compliance.importlib.import_module", + side_effect=ImportError("custom error"), + ): + with pytest.raises(ImportError, match="custom error"): + _dispatch_compliance_renderer(data, "anything") + + def test_get_table_error_in_generic_surfaces(self): + """SC-004a: ValueError from generic.get_table propagates (not swallowed); + get_table is called OUTSIDE the try block.""" + data = _make_dispatch_df() + + with patch( + "dashboard.compliance.generic.get_table", + side_effect=ValueError("boom"), + ): + with pytest.raises(ValueError, match="boom"): + _dispatch_compliance_renderer(data, "myfw_dynprovider") + + def test_get_table_error_in_builtin_surfaces(self): + """REQ-004 / ADR-1: RuntimeError from a builtin get_table propagates; + proving get_table is called outside the try block.""" + data = _make_dispatch_df() + mock_module = MagicMock() + mock_module.get_table.side_effect = RuntimeError("table error") + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + with pytest.raises(RuntimeError, match="table error"): + _dispatch_compliance_renderer(data, "some_builtin_fw") + + def test_dedup_applied_before_get_table(self): + """ADR-1: Duplicate rows (identical CHECKID/STATUS/RESOURCEID/STATUSEXTENDED) + are dropped; returned data has the deduplicated row count.""" + # Row 0 and row 1 are identical in all dedup-key columns; row 2 is unique. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "PASS", "FAIL"], + "CHECKID": ["check1", "check1", "check2"], + "RESOURCEID": ["res1", "res1", "res2"], + "STATUSEXTENDED": ["", "", ""], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + assert len(result_data) == 2 # one duplicate removed + + def test_muted_column_added_to_dedup_when_present(self): + """ADR-1 edge case: When MUTED column is present, it is included in the dedup + subset at index 2; rows differing only in MUTED are kept as distinct rows.""" + # Both rows share CHECKID/STATUS/RESOURCEID/STATUSEXTENDED but differ in MUTED. + # With MUTED in dedup_columns, both rows are kept (2 rows after dedup). + # Without MUTED in dedup_columns, they would be collapsed to 1 row. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "REQUIREMENTS_ID": ["req1", "req1"], + "STATUS": ["PASS", "PASS"], + "CHECKID": ["check1", "check1"], + "RESOURCEID": ["res1", "res1"], + "STATUSEXTENDED": ["", ""], + "MUTED": ["True", "False"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123", "123"], + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + # MUTED at idx 2 means these two rows have different dedup keys → both kept + assert len(result_data) == 2 + + def test_returns_table_and_data_tuple(self): + """ADR-1 interface contract: _dispatch_compliance_renderer returns a + 2-tuple (table, deduped_data).""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Desc 1", "Desc 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + } + ) + result = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(result, tuple) + assert len(result) == 2 + table, deduped_data = result + assert isinstance(table, html.Div) + assert isinstance(deduped_data, pd.DataFrame) diff --git a/tests/dashboard/pages/conftest.py b/tests/dashboard/pages/conftest.py new file mode 100644 index 0000000000..a5c674c7de --- /dev/null +++ b/tests/dashboard/pages/conftest.py @@ -0,0 +1,7 @@ +import dash + +# Initialize a minimal Dash app so that dashboard page modules can call +# dash.register_page() during import without raising PageError. +# This module-level initialization runs during pytest collection, before +# any test file in this directory is imported. +_test_app = dash.Dash("prowler_test_app", use_pages=True, pages_folder="") diff --git a/tests/dashboard/pages/scope_columns_test.py b/tests/dashboard/pages/scope_columns_test.py new file mode 100644 index 0000000000..a8aea34221 --- /dev/null +++ b/tests/dashboard/pages/scope_columns_test.py @@ -0,0 +1,60 @@ +import pandas as pd + +from dashboard.pages.compliance import _ensure_scope_columns + + +def _df(columns): + """Build a one-row DataFrame preserving the given column order.""" + return pd.DataFrame({col: ["x"] for col in columns}) + + +class TestEnsureScopeColumns: + def test_aws_account_and_region_preserved(self): + """A provider that already emits ACCOUNTID and REGION is left untouched.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "REGION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "REGION" in result.columns + assert result["ACCOUNTID"].iloc[0] == "x" + + def test_okta_single_scope_column_becomes_accountid(self): + """Okta's ORGANIZATIONDOMAIN becomes ACCOUNTID; REGION falls back.""" + df = _df(["PROVIDER", "DESCRIPTION", "ORGANIZATIONDOMAIN", "ASSESSMENTDATE"]) + df["ORGANIZATIONDOMAIN"] = ["trial-123.okta.com"] + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "ORGANIZATIONDOMAIN" not in result.columns + assert result["ACCOUNTID"].iloc[0] == "trial-123.okta.com" + assert result["REGION"].iloc[0] == "-" + + def test_two_unknown_scope_columns_map_to_account_and_region(self): + """Two scope columns map positionally to ACCOUNTID and REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "TENANCYID", "LOCATION", "ASSESSMENTDATE"]) + df["TENANCYID"] = ["tenant-1"] + df["LOCATION"] = ["eu-west-1"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "tenant-1" + assert result["REGION"].iloc[0] == "eu-west-1" + + def test_no_scope_columns_fall_back_to_dash(self): + """No scope columns → both ACCOUNTID and REGION fall back to '-'.""" + df = _df(["PROVIDER", "DESCRIPTION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_missing_anchors_still_fall_back_to_dash(self): + """Without DESCRIPTION/ASSESSMENTDATE anchors, both fall back to '-'.""" + df = _df(["PROVIDER", "FOO", "BAR"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_existing_accountid_does_not_consume_region_scope(self): + """An existing ACCOUNTID is kept; the leftover scope becomes REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "LOCATION", "ASSESSMENTDATE"]) + df["ACCOUNTID"] = ["acc-1"] + df["LOCATION"] = ["us-east-2"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "acc-1" + assert result["REGION"].iloc[0] == "us-east-2" diff --git a/tests/lib/check/check_loader_test.py b/tests/lib/check/check_loader_test.py index 60125270cb..24cc93bac0 100644 --- a/tests/lib/check/check_loader_test.py +++ b/tests/lib/check/check_loader_test.py @@ -629,3 +629,223 @@ class TestCheckLoader: provider=self.provider, ) assert exc_info.value.code == 1 + + def test_list_checks_includes_threat_detection(self): + """Test that list_checks=True includes threat-detection checks (fixes #10576)""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(), + } + + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + provider=self.provider, + list_checks=True, + ) + assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME in result + assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME in result + + def test_list_checks_with_service_includes_threat_detection(self): + """Test that list_checks=True with service filter includes threat-detection checks (fixes #10576)""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(), + } + service_list = ["cloudtrail"] + + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + service_list=service_list, + provider=self.provider, + list_checks=True, + ) + assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME in result + assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME not in result + + def test_scan_still_excludes_threat_detection_by_default(self): + """Test that without list_checks, threat-detection checks are still excluded""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(), + } + + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + provider=self.provider, + ) + assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME not in result + assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME in result + + def test_load_checks_to_execute_universal_framework_takes_precedence(self): + """When ``--compliance `` matches a universal framework, the + loader must source checks from ``universal_frameworks[fw].requirements[*] + .checks[provider]`` and NOT fall through to ``bulk_compliance_frameworks``. + + This is the path added by PR #10301 in checks_loader.py. + """ + from prowler.lib.check.compliance_models import ( + ComplianceFramework, + UniversalComplianceRequirement, + ) + + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } + + universal_framework = ComplianceFramework( + framework="csa_ccm", + name="CSA CCM 4.0", + version="4.0", + description="Cloud Controls Matrix", + requirements=[ + UniversalComplianceRequirement( + id="A&A-01", + description="Audit & Assurance", + attributes={}, + checks={"aws": [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME]}, + ), + ], + ) + + with patch( + "prowler.lib.check.checks_loader.CheckMetadata.get_bulk", + return_value=bulk_checks_metadata, + ): + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + bulk_compliance_frameworks={}, # legacy empty + compliance_frameworks=["csa_ccm_4.0"], + provider=self.provider, + universal_frameworks={"csa_ccm_4.0": universal_framework}, + ) + + assert result == {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} + + def test_load_checks_to_execute_universal_filters_by_provider(self): + """A universal requirement may declare checks for several + providers; the loader must only return those for the active + provider key (lowercased).""" + from prowler.lib.check.compliance_models import ( + ComplianceFramework, + UniversalComplianceRequirement, + ) + + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } + + # The same requirement maps a different check per provider. + # Only the AWS one must be returned for provider="aws". + universal_framework = ComplianceFramework( + framework="csa_ccm", + name="CSA CCM 4.0", + version="4.0", + description="Cloud Controls Matrix", + requirements=[ + UniversalComplianceRequirement( + id="A&A-02", + description="Multi-provider req", + attributes={}, + checks={ + "aws": [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME], + "azure": ["azure_only_check"], + "gcp": ["gcp_only_check"], + }, + ), + ], + ) + + with patch( + "prowler.lib.check.checks_loader.CheckMetadata.get_bulk", + return_value=bulk_checks_metadata, + ): + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + bulk_compliance_frameworks={}, + compliance_frameworks=["csa_ccm_4.0"], + provider=self.provider, # "aws" + universal_frameworks={"csa_ccm_4.0": universal_framework}, + ) + + assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME in result + assert "azure_only_check" not in result + assert "gcp_only_check" not in result + + def test_load_checks_to_execute_universal_no_match_falls_back_to_legacy(self): + """If the requested compliance framework is not present in + ``universal_frameworks``, the loader must fall back to the + legacy ``bulk_compliance_frameworks`` lookup.""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } + bulk_compliance_frameworks = { + "soc2_aws": Compliance( + Framework="SOC2", + Name="SOC2", + Provider="aws", + Version="2.0", + Description="x", + Requirements=[ + Compliance_Requirement( + Checks=[S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME], + Id="", + Description="", + Attributes=[], + ) + ], + ), + } + + with patch( + "prowler.lib.check.checks_loader.CheckMetadata.get_bulk", + return_value=bulk_checks_metadata, + ): + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + bulk_compliance_frameworks=bulk_compliance_frameworks, + compliance_frameworks=["soc2_aws"], + provider=self.provider, + universal_frameworks={"some_other_universal_fw": object()}, + ) + + assert result == {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} + + def test_load_checks_to_execute_universal_unknown_provider_returns_empty(self): + """If the universal requirement has no checks for the active + provider, no checks are picked up for that requirement.""" + from prowler.lib.check.compliance_models import ( + ComplianceFramework, + UniversalComplianceRequirement, + ) + + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } + universal_framework = ComplianceFramework( + framework="csa_ccm", + name="CSA CCM 4.0", + version="4.0", + description="Cloud Controls Matrix", + requirements=[ + UniversalComplianceRequirement( + id="A&A-03", + description="Only Azure", + attributes={}, + checks={"azure": ["azure_only_check"]}, + ), + ], + ) + + with patch( + "prowler.lib.check.checks_loader.CheckMetadata.get_bulk", + return_value=bulk_checks_metadata, + ): + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + bulk_compliance_frameworks={}, + compliance_frameworks=["csa_ccm_4.0"], + provider=self.provider, # "aws" — no checks declared + universal_frameworks={"csa_ccm_4.0": universal_framework}, + ) + + assert result == set() diff --git a/tests/lib/check/check_test.py b/tests/lib/check/check_test.py index b6332deb25..b9492d6754 100644 --- a/tests/lib/check/check_test.py +++ b/tests/lib/check/check_test.py @@ -1048,6 +1048,34 @@ class TestCheck: ) self.verify_metadata_check_id(base_directory) + def test_vercel_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/vercel/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_vercel_checks_metadata_use_canonical_hub_urls(self): + base_directory = pathlib.Path(__file__).resolve().parents[3] / "prowler" + provider_path = base_directory / "providers" / "vercel" / "services" + + invalid_urls = [] + + for metadata_file_path in provider_path.rglob("*.metadata.json"): + with metadata_file_path.open("r") as metadata_file: + data = json.load(metadata_file) + + recommendation = data.get("Remediation", {}).get("Recommendation", {}) + url = recommendation.get("Url", "") + + if url.startswith("https://hub.prowler.com/checks/vercel/"): + invalid_urls.append(f"{metadata_file_path}: {url}") + + assert not invalid_urls, "\n".join(invalid_urls) + def verify_metadata_check_id(self, provider_path): errors = [] # Walk through the base directory to find all service directories @@ -1090,6 +1118,71 @@ class TestCheck: assert not errors, "\n\n".join(errors) + def test_execute_oraclecloud_mutelist_passes_tenancy_id(self): + """Test that execute() passes tenancy_id to is_finding_muted for OCI provider.""" + tenancy_id = "ocid1.tenancy.oc1..aaaaaaaexample" + + finding = Mock() + finding.status = "PASS" + finding.muted = False + + check = Mock() + check.CheckID = "oci_test_check" + check.execute = Mock(return_value=[finding]) + + provider = mock.MagicMock() + provider.type = "oraclecloud" + provider.identity.tenancy_id = tenancy_id + provider.mutelist.mutelist = {"Accounts": {tenancy_id: {}}} + provider.mutelist.is_finding_muted = Mock(return_value=True) + + findings = execute( + check=check, + global_provider=provider, + custom_checks_metadata=None, + output_options=None, + ) + + provider.mutelist.is_finding_muted.assert_called_once_with( + tenancy_id=tenancy_id, + finding=finding, + ) + assert findings[0].muted is True + + def test_execute_azure_mutelist_passes_subscription_id_and_name(self): + """Test that execute() passes Azure subscription ID and display name.""" + subscription_id = "12345678-1234-1234-1234-123456789012" + subscription_name = "subscription_1" + + finding = Mock() + finding.status = "PASS" + finding.muted = False + finding.subscription = subscription_id + + check = Mock() + check.CheckID = "azure_test_check" + check.execute = Mock(return_value=[finding]) + + provider = mock.MagicMock() + provider.type = "azure" + provider.identity.subscriptions = {subscription_id: subscription_name} + provider.mutelist.mutelist = {"Accounts": {subscription_name: {}}} + provider.mutelist.is_finding_muted = Mock(return_value=True) + + findings = execute( + check=check, + global_provider=provider, + custom_checks_metadata=None, + output_options=None, + ) + + provider.mutelist.is_finding_muted.assert_called_once_with( + subscription_id=subscription_id, + subscription_name=subscription_name, + finding=finding, + ) + assert findings[0].muted is True + def test_execute_check_exception_only_logs(self, caplog): caplog.set_level(ERROR) diff --git a/tests/lib/check/compliance_check_test.py b/tests/lib/check/compliance_check_test.py index 73f93e3b38..631c134302 100644 --- a/tests/lib/check/compliance_check_test.py +++ b/tests/lib/check/compliance_check_test.py @@ -540,7 +540,9 @@ class TestCompliance: ): object = mock.Mock() object.path = "/path/to/compliance" - object.name = "framework1_aws" + # list_compliance_modules yields dotted module names; get_bulk matches + # the last segment exactly against the provider. + object.name = "prowler.compliance.aws" mock_list_modules.return_value = [object] mock_listdir.return_value = ["framework1_aws.json"] diff --git a/tests/lib/check/compliance_config_constraint_model_test.py b/tests/lib/check/compliance_config_constraint_model_test.py new file mode 100644 index 0000000000..48f67fd504 --- /dev/null +++ b/tests/lib/check/compliance_config_constraint_model_test.py @@ -0,0 +1,169 @@ +"""Validation coverage for the ConfigRequirements schema. + +``Compliance_Requirement_ConfigConstraint`` is the model behind every +``ConfigRequirements`` entry in the compliance framework JSONs. These tests pin +the operator vocabulary, the value-typing rules (notably that booleans are not +coerced to integers), and that constraints survive the legacy → universal +adaptation used by the App backend and the OCSF/table outputs. +""" + +import json +import pathlib + +import pytest +from pydantic.v1 import ValidationError + +from prowler.lib.check.compliance_models import ( + Compliance, + Compliance_Requirement_ConfigConstraint, + adapt_legacy_to_universal, +) + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +_CIS_6_0 = _REPO_ROOT / "prowler" / "compliance" / "aws" / "cis_6.0_aws.json" + + +def _load_cis(): + """Load the CIS 6.0 AWS framework JSON via a context manager.""" + with open(_CIS_6_0, encoding="utf-8") as f: + return json.load(f) + + +class Test_Compliance_Requirement_ConfigConstraint: + @pytest.mark.parametrize( + "operator,value", + [ + ("lte", 45), + ("gte", 365), + ("eq", False), + ("in", [1, 2, 3]), + ("subset", ["1.2", "1.3"]), + ("superset", ["RSA-1024", "P-192"]), + ], + ) + def test_valid_operators(self, operator, value): + c = Compliance_Requirement_ConfigConstraint( + Check="some_check", ConfigKey="some_key", Operator=operator, Value=value + ) + assert c.Operator == operator + assert c.Value == value + + def test_invalid_operator_rejected(self): + with pytest.raises(ValidationError): + Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator="between", Value=1 + ) + + @pytest.mark.parametrize( + "operator,value", + [ + # numeric operators reject non-numeric / boolean values + ("gte", [1, 2]), + ("lte", ["45"]), + ("gte", True), + # set/list operators reject scalars + ("subset", 5), + ("superset", "x"), + ("in", 1), + # eq rejects lists + ("eq", [1, 2]), + ], + ) + def test_value_type_inconsistent_with_operator_rejected(self, operator, value): + # A mistyped Value would otherwise be silently treated as "not satisfied" + # at runtime, forcing a spurious config-not-valid FAIL. + with pytest.raises(ValidationError): + Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator=operator, Value=value + ) + + def test_boolean_value_not_coerced_to_int(self): + # ``mute_non_default_regions == false`` must stay a bool, not become 0. + c = Compliance_Requirement_ConfigConstraint( + Check="securityhub_enabled", + ConfigKey="mute_non_default_regions", + Operator="eq", + Value=False, + ) + assert c.Value is False + assert isinstance(c.Value, bool) + + def test_list_value_preserved_for_set_operators(self): + c = Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator="subset", Value=["1.2", "1.3"] + ) + assert isinstance(c.Value, list) + assert c.Value == ["1.2", "1.3"] + + def test_missing_required_fields_rejected(self): + with pytest.raises(ValidationError): + Compliance_Requirement_ConfigConstraint(Check="c", ConfigKey="k") + + def test_provider_defaults_to_none(self): + # Single-provider frameworks omit Provider; it is optional. + c = Compliance_Requirement_ConfigConstraint( + Check="c", ConfigKey="k", Operator="eq", Value=False + ) + assert c.Provider is None + + def test_provider_scopes_constraint(self): + # Universal frameworks tag each constraint with the provider it applies to. + c = Compliance_Requirement_ConfigConstraint( + Check="securityhub_enabled", + Provider="aws", + ConfigKey="mute_non_default_regions", + Operator="eq", + Value=False, + ) + assert c.Provider == "aws" + + +class Test_ConfigRequirements_On_Compliance: + def test_requirements_without_constraints_default_to_none(self): + compliance = Compliance(**_load_cis()) + # Requirement without configurable checks → ConfigRequirements is None. + no_constraint = [r for r in compliance.Requirements if not r.ConfigRequirements] + assert no_constraint + assert no_constraint[0].ConfigRequirements is None + + def test_requirement_with_constraints_parses(self): + compliance = Compliance(**_load_cis()) + with_constraint = [r for r in compliance.Requirements if r.ConfigRequirements] + assert with_constraint, "cis_6.0_aws should declare ConfigRequirements" + constraint = with_constraint[0].ConfigRequirements[0] + assert isinstance(constraint, Compliance_Requirement_ConfigConstraint) + assert constraint.Check + assert constraint.Operator in {"lte", "gte", "eq", "in", "subset", "superset"} + + +class Test_Adapt_Legacy_To_Universal: + def test_config_requirements_carried_to_universal(self): + legacy = Compliance(**_load_cis()) + universal = adapt_legacy_to_universal(legacy) + + legacy_with = {r.Id for r in legacy.Requirements if r.ConfigRequirements} + universal_with = {r.id for r in universal.requirements if r.config_requirements} + assert legacy_with == universal_with + assert universal_with, "expected at least one requirement with constraints" + + # The constraint payload survives as the typed constraint model with the + # same fields (``Provider`` is carried through too, ``None`` for + # single-provider frameworks like CIS AWS). + sample = next(r for r in universal.requirements if r.config_requirements) + entry = sample.config_requirements[0] + assert isinstance(entry, Compliance_Requirement_ConfigConstraint) + assert set(entry.dict()) == { + "Check", + "Provider", + "ConfigKey", + "Operator", + "Value", + } + assert entry.Provider is None + + def test_requirements_without_constraints_are_none_in_universal(self): + legacy = Compliance(**_load_cis()) + universal = adapt_legacy_to_universal(legacy) + without = [r for r in universal.requirements if not r.config_requirements] + assert without + assert without[0].config_requirements is None diff --git a/tests/lib/check/compliance_config_eval_test.py b/tests/lib/check/compliance_config_eval_test.py new file mode 100644 index 0000000000..4acec9bb4c --- /dev/null +++ b/tests/lib/check/compliance_config_eval_test.py @@ -0,0 +1,408 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from prowler.lib.check.compliance_config_eval import ( + CONFIG_NOT_VALID_PREFIX, + accumulate_group_status, + accumulate_overview_status, + apply_config_status, + build_requirement_config_status, + evaluate_config_constraints, + get_effective_status, + get_scan_audit_config, + get_scan_provider_type, + resolve_requirement_config_status, +) + +CONSTRAINTS = [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + } +] + + +class Test_evaluate_config_constraints: + def test_no_constraints_is_compliant(self): + assert evaluate_config_constraints(None, {}) == (True, "") + assert evaluate_config_constraints([], {"x": 1}) == (True, "") + + def test_config_absent_assumes_default_ok(self): + # Key not explicitly set → default assumed adequate. + is_ok, reason = evaluate_config_constraints(CONSTRAINTS, {}) + assert is_ok is True + assert reason == "" + + def test_none_audit_config_is_compliant(self): + assert evaluate_config_constraints(CONSTRAINTS, None) == (True, "") + + def test_lte_satisfied(self): + assert evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": 45} + ) == (True, "") + + def test_lte_violated(self): + is_ok, reason = evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": 120} + ) + assert is_ok is False + # Product-facing message: names the check, the applied value, what the + # requirement needs and how to fix it, in plain language. + assert reason.startswith(CONFIG_NOT_VALID_PREFIX) + assert "iam_user_accesskey_unused" in reason + assert "max_unused_access_keys_days" in reason + assert "set to 120" in reason + assert "45 or lower" in reason + + def test_gte_operator(self): + c = [{"Check": "c", "ConfigKey": "k", "Operator": "gte", "Value": 10}] + assert evaluate_config_constraints(c, {"k": 10})[0] is True + assert evaluate_config_constraints(c, {"k": 9})[0] is False + + def test_eq_operator(self): + c = [{"Check": "c", "ConfigKey": "k", "Operator": "eq", "Value": "HIGH"}] + assert evaluate_config_constraints(c, {"k": "HIGH"})[0] is True + assert evaluate_config_constraints(c, {"k": "LOW"})[0] is False + + def test_in_operator(self): + c = [{"Check": "c", "ConfigKey": "k", "Operator": "in", "Value": [1, 2, 3]}] + assert evaluate_config_constraints(c, {"k": 2})[0] is True + assert evaluate_config_constraints(c, {"k": 9})[0] is False + + def test_subset_operator_allowlist(self): + # Allowlist config: applied list must stay within the secure baseline. + c = [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": ["1.2", "1.3"], + } + ] + assert ( + evaluate_config_constraints( + c, {"recommended_minimal_tls_versions": ["1.2", "1.3"]} + )[0] + is True + ) + # Stricter (subset) still passes. + assert ( + evaluate_config_constraints( + c, {"recommended_minimal_tls_versions": ["1.3"]} + )[0] + is True + ) + # Widening with a weaker value breaks it. + is_ok, reason = evaluate_config_constraints( + c, {"recommended_minimal_tls_versions": ["1.0", "1.2", "1.3"]} + ) + assert is_ok is False + assert "recommended_minimal_tls_versions" in reason + + def test_superset_operator_denylist(self): + # Denylist config: applied list must keep covering the forbidden baseline. + c = [ + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": ["RSA-1024", "P-192"], + } + ] + assert ( + evaluate_config_constraints( + c, {"insecure_key_algorithms": ["RSA-1024", "P-192"]} + )[0] + is True + ) + # Extra forbidden values are fine. + assert ( + evaluate_config_constraints( + c, {"insecure_key_algorithms": ["RSA-1024", "P-192", "P-224"]} + )[0] + is True + ) + # Removing a forbidden value breaks it. + assert ( + evaluate_config_constraints(c, {"insecure_key_algorithms": ["P-192"]})[0] + is False + ) + + def test_subset_superset_non_list_not_satisfied(self): + sub = [{"Check": "c", "ConfigKey": "k", "Operator": "subset", "Value": ["a"]}] + sup = [{"Check": "c", "ConfigKey": "k", "Operator": "superset", "Value": ["a"]}] + # A scalar applied value cannot satisfy a set constraint. + assert evaluate_config_constraints(sub, {"k": "a"})[0] is False + assert evaluate_config_constraints(sup, {"k": "a"})[0] is False + + def test_mismatched_types_not_satisfied(self): + assert ( + evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": "x"} + )[0] + is False + ) + + def test_multiple_constraints_first_violation_reported(self): + constraints = [ + {"Check": "a", "ConfigKey": "k1", "Operator": "lte", "Value": 45}, + {"Check": "b", "ConfigKey": "k2", "Operator": "lte", "Value": 45}, + ] + is_ok, reason = evaluate_config_constraints(constraints, {"k1": 45, "k2": 90}) + assert is_ok is False + # The first violation (check "b", key "k2", applied 90) is the one reported. + assert "k2" in reason + assert "set to 90" in reason + + +class Test_provider_scoping: + # An AWS-scoped constraint on a config key whose value is too loose. + AWS_CONSTRAINT = [ + { + "Check": "securityhub_enabled", + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ] + + def test_applies_when_provider_matches(self): + is_ok, _ = evaluate_config_constraints( + self.AWS_CONSTRAINT, {"mute_non_default_regions": True}, "aws" + ) + assert is_ok is False + + def test_skipped_when_provider_differs(self): + # Same loose value, but scanning GCP → the AWS constraint must not fire. + is_ok, reason = evaluate_config_constraints( + self.AWS_CONSTRAINT, {"mute_non_default_regions": True}, "gcp" + ) + assert is_ok is True + assert reason == "" + + def test_none_provider_type_disables_scoping(self): + # Without a known provider every constraint is evaluated (legacy default). + is_ok, _ = evaluate_config_constraints( + self.AWS_CONSTRAINT, {"mute_non_default_regions": True}, None + ) + assert is_ok is False + + def test_provider_match_is_case_insensitive(self): + # A constraint authored as "AWS" must still scope to the "aws" scan, + # not be silently bypassed by a casing mismatch. + constraint = [ + { + "Check": "securityhub_enabled", + "Provider": "AWS", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ] + is_ok, _ = evaluate_config_constraints( + constraint, {"mute_non_default_regions": True}, "aws" + ) + assert is_ok is False + + def test_untagged_constraint_applies_to_any_provider(self): + # Single-provider frameworks omit Provider → always evaluated. + is_ok, _ = evaluate_config_constraints( + CONSTRAINTS, {"max_unused_access_keys_days": 120}, "aws" + ) + assert is_ok is False + + +# A constraint forcing FAIL when the applied value is too loose. +REGION_CONSTRAINT = [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } +] + + +def _legacy_req(req_id, constraints=None): + """Fake legacy Compliance_Requirement (``Id`` / ``ConfigRequirements``).""" + return SimpleNamespace(Id=req_id, ConfigRequirements=constraints) + + +def _universal_req(req_id, constraints=None): + """Fake UniversalComplianceRequirement (``id`` / ``config_requirements``).""" + return SimpleNamespace(id=req_id, config_requirements=constraints) + + +class Test_build_requirement_config_status: + def test_only_requirements_with_constraints_included(self): + reqs = [_legacy_req("1", CONSTRAINTS), _legacy_req("2", None)] + status = build_requirement_config_status( + reqs, {"max_unused_access_keys_days": 120} + ) + assert set(status) == {"1"} + assert status["1"][0] is False + + def test_supports_universal_requirements(self): + reqs = [_universal_req("u1", REGION_CONSTRAINT)] + status = build_requirement_config_status( + reqs, {"mute_non_default_regions": True} + ) + assert status["u1"][0] is False + + def test_compliant_when_config_satisfied(self): + reqs = [_legacy_req("1", CONSTRAINTS)] + status = build_requirement_config_status( + reqs, {"max_unused_access_keys_days": 30} + ) + assert status["1"] == (True, "") + + +class Test_resolve_requirement_config_status: + def test_memoises_by_requirement_id(self): + cache = {} + req = _legacy_req("1", CONSTRAINTS) + first = resolve_requirement_config_status( + req, {"max_unused_access_keys_days": 120}, cache + ) + assert cache["1"] is first + assert first[0] is False + # A different audit_config is ignored once cached (intended for one build). + second = resolve_requirement_config_status(req, {}, cache) + assert second is first + + def test_requirement_without_constraints_is_ok(self): + cache = {} + req = _legacy_req("1", None) + assert resolve_requirement_config_status(req, {}, cache) == (True, "") + + +class Test_accumulate_overview_status: + def test_fail_wins_over_earlier_pass(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "PASS", p, f, m) + accumulate_overview_status(0, "FAIL", p, f, m) + assert (p, f, m) == (set(), {0}, set()) + + def test_pass_after_fail_does_not_double_count(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "FAIL", p, f, m) + accumulate_overview_status(0, "PASS", p, f, m) + assert (p, f, m) == (set(), {0}, set()) + + def test_pass_only(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "PASS", p, f, m) + assert (p, f, m) == ({0}, set(), set()) + + def test_muted(self): + p, f, m = set(), set(), set() + accumulate_overview_status(0, "Muted", p, f, m) + assert (p, f, m) == (set(), set(), {0}) + + +class Test_accumulate_group_status: + def test_first_status_counted(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + assert counts == {"FAIL": 0, "PASS": 1, "Muted": 0} + assert seen == {0: "PASS"} + + def test_pass_upgraded_to_fail(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + accumulate_group_status(0, "FAIL", counts, seen) + assert counts == {"FAIL": 1, "PASS": 0, "Muted": 0} + assert seen == {0: "FAIL"} + + def test_fail_not_downgraded_by_later_pass(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "FAIL", counts, seen) + accumulate_group_status(0, "PASS", counts, seen) + assert counts == {"FAIL": 1, "PASS": 0, "Muted": 0} + + def test_same_index_not_double_counted(self): + counts = {"FAIL": 0, "PASS": 0, "Muted": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + accumulate_group_status(0, "PASS", counts, seen) + assert counts["PASS"] == 1 + + def test_works_with_fail_pass_only_counts(self): + # Level-style counts (no "Muted" key) used by CIS / split tables. + counts = {"FAIL": 0, "PASS": 0} + seen = {} + accumulate_group_status(0, "PASS", counts, seen) + accumulate_group_status(0, "FAIL", counts, seen) + assert counts == {"FAIL": 1, "PASS": 0} + + def test_muted_on_fail_pass_only_counts_raises(self): + # Level-style callers only ever pass PASS/FAIL (they guard on + # ``not finding.muted``). Passing "Muted" to a Muted-less counts must + # fail loudly rather than silently create a bogus key. + counts = {"FAIL": 0, "PASS": 0} + with pytest.raises(KeyError): + accumulate_group_status(0, "Muted", counts, {}) + + +class Test_apply_config_status: + def test_none_config_status_keeps_finding(self): + assert apply_config_status("PASS", "ext", None) == ("PASS", "ext") + + def test_compliant_keeps_finding(self): + assert apply_config_status("PASS", "ext", (True, "")) == ("PASS", "ext") + + def test_invalid_config_forces_fail_and_prepends_reason(self): + # The reason already carries the full product-facing message; it is + # prepended verbatim to the finding's extended status. + reason = f"{CONFIG_NOT_VALID_PREFIX} bad config" + status, extended = apply_config_status("PASS", "ext", (False, reason)) + assert status == "FAIL" + assert extended.startswith(CONFIG_NOT_VALID_PREFIX) + assert reason in extended + assert "ext" in extended + + +class Test_get_effective_status: + def test_none_and_compliant_keep_status(self): + assert get_effective_status("PASS", None) == "PASS" + assert get_effective_status("PASS", (True, "")) == "PASS" + + def test_invalid_config_forces_fail(self): + assert get_effective_status("PASS", (False, "reason")) == "FAIL" + + +class Test_get_scan_audit_config: + def test_returns_empty_without_global_provider(self): + # No global provider set → get_global_provider() returns None → + # ``None.audit_config`` raises AttributeError → safe empty mapping. + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=None, + ): + assert get_scan_audit_config() == {} + + +class Test_get_scan_provider_type: + def test_returns_empty_when_no_global_provider(self): + # No global provider set → get_global_provider() returns None → + # ``None.type`` raises AttributeError → scoping disabled (empty string). + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=None, + ): + assert get_scan_provider_type() == "" + + def test_returns_global_provider_type(self): + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=SimpleNamespace(type="aws"), + ): + assert get_scan_provider_type() == "aws" diff --git a/tests/lib/check/compliance_config_requirements_data_test.py b/tests/lib/check/compliance_config_requirements_data_test.py new file mode 100644 index 0000000000..3b3ba5757f --- /dev/null +++ b/tests/lib/check/compliance_config_requirements_data_test.py @@ -0,0 +1,191 @@ +"""Data-integrity tests for every ``ConfigRequirements`` declared in the shipped +compliance framework JSONs. + +These guard the ~700 constraints added across the frameworks against drift: +- every constraint is well-formed (valid operator, value typed for its operator), +- every constraint targets a check the requirement actually maps (no orphans), +- the region-mute invariant holds (every requirement mapping a region-scoped + check carries the ``mute_non_default_regions == false`` constraint), +- every framework still parses through its model. +""" + +import glob +import json +import pathlib + +import pytest + +from prowler.lib.check.compliance_models import Compliance, ComplianceFramework + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +_COMPLIANCE_DIR = _REPO_ROOT / "prowler" / "compliance" + +_VALID_OPERATORS = {"lte", "gte", "eq", "in", "subset", "superset"} +# Checks whose result is untrustworthy when non-default regions are muted. +_REGION_CHECKS = { + "accessanalyzer_enabled", + "config_recorder_all_regions_enabled", + "drs_job_exist", + "guardduty_delegated_admin_enabled_all_regions", + "guardduty_is_enabled", + "securityhub_enabled", +} + +_ALL_FILES = sorted(glob.glob(str(_COMPLIANCE_DIR / "**" / "*.json"), recursive=True)) + + +def _load(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _requirements(data): + return data.get("Requirements") or data.get("requirements") or [] + + +def _req_id(req): + return req.get("Id") or req.get("id") + + +def _req_checks(req): + ch = req.get("Checks", req.get("checks")) + checks = set() + if isinstance(ch, dict): + for v in ch.values(): + checks |= set(v or []) + elif isinstance(ch, list): + checks |= set(ch) + return checks + + +def _req_constraints(req): + return req.get("ConfigRequirements") or req.get("config_requirements") or [] + + +def _iter_constraints(): + """Yield (file, req_id, checks, constraint) for every constraint shipped.""" + for path in _ALL_FILES: + data = _load(path) + for req in _requirements(data): + checks = _req_checks(req) + for c in _req_constraints(req): + yield pathlib.Path(path).name, _req_id(req), checks, c + + +_ALL_CONSTRAINTS = list(_iter_constraints()) + + +def test_there_are_constraints_to_validate(): + # Guards against the iteration silently finding nothing (e.g. path change). + assert len(_ALL_CONSTRAINTS) > 100 + + +@pytest.mark.parametrize( + "fname,req_id,checks,constraint", + _ALL_CONSTRAINTS, + ids=[f"{f}:{r}:{c['Check']}" for f, r, _, c in _ALL_CONSTRAINTS], +) +class Test_Constraint_Wellformed: + def test_has_required_keys(self, fname, req_id, checks, constraint): + required = {"Check", "ConfigKey", "Operator", "Value"} + # ``Provider`` is optional (universal frameworks set it, single-provider + # ones omit it); no other key is allowed. + assert required <= set(constraint) <= required | { + "Provider" + }, f"{fname}:{req_id} malformed constraint {constraint}" + + def test_operator_valid(self, fname, req_id, checks, constraint): + assert constraint["Operator"] in _VALID_OPERATORS + + def test_check_is_mapped_by_requirement(self, fname, req_id, checks, constraint): + # No orphan constraints: the target check must be one the requirement runs. + assert constraint["Check"] in checks, ( + f"{fname}:{req_id} constraint targets {constraint['Check']} " + f"which the requirement does not map" + ) + + def test_value_type_matches_operator(self, fname, req_id, checks, constraint): + op, val = constraint["Operator"], constraint["Value"] + if op in ("subset", "superset", "in"): + assert isinstance(val, list), f"{fname}:{req_id} {op} needs a list value" + elif op in ("lte", "gte"): + # Numeric threshold; bool is not a valid threshold even though it is + # an int subclass. + assert isinstance(val, (int, float)) and not isinstance( + val, bool + ), f"{fname}:{req_id} {op} needs a numeric value, got {val!r}" + elif op == "eq": + assert isinstance( + val, (bool, int, float, str) + ), f"{fname}:{req_id} eq needs a scalar value" + + +class Test_Region_Mute_Invariant: + """Every requirement mapping a region-scoped check must carry the + ``mute_non_default_regions == false`` constraint for it.""" + + def test_region_checks_always_constrained(self): + gaps = [] + for path in _ALL_FILES: + data = _load(path) + for req in _requirements(data): + checks = _req_checks(req) + constrained = { + c["Check"] + for c in _req_constraints(req) + if c["ConfigKey"] == "mute_non_default_regions" + } + for region_check in checks & _REGION_CHECKS: + if region_check not in constrained: + gaps.append( + f"{pathlib.Path(path).name}:{_req_id(req)}:{region_check}" + ) + assert not gaps, f"region-mute constraint missing for: {gaps}" + + def test_region_mute_constraints_use_eq_false(self): + for fname, req_id, _checks, c in _ALL_CONSTRAINTS: + if c["ConfigKey"] == "mute_non_default_regions": + assert ( + c["Operator"] == "eq" and c["Value"] is False + ), f"{fname}:{req_id} region-mute must be eq false" + + +class Test_Universal_Provider_Scoping: + """Universal (multi-provider) frameworks map checks per provider, so every + constraint must declare which provider it scopes to and that provider must + actually map the targeted check. Without this a constraint authored for one + provider's check would wrongly apply to scans of every other provider.""" + + def test_multiprovider_constraints_declare_consistent_provider(self): + gaps = [] + for path in _ALL_FILES: + data = _load(path) + for req in _requirements(data): + ch = req.get("Checks", req.get("checks")) + # Only universal frameworks key their checks by provider. + if not isinstance(ch, dict): + continue + for c in _req_constraints(req): + provider = c.get("Provider") + if not provider: + gaps.append( + f"{pathlib.Path(path).name}:{_req_id(req)}:" + f"{c['Check']} missing Provider" + ) + elif c["Check"] not in set(ch.get(provider, [])): + gaps.append( + f"{pathlib.Path(path).name}:{_req_id(req)}:" + f"{c['Check']} not mapped under provider {provider}" + ) + assert not gaps, f"universal constraints with bad Provider: {gaps}" + + +@pytest.mark.parametrize( + "path", _ALL_FILES, ids=[pathlib.Path(p).name for p in _ALL_FILES] +) +def test_every_framework_parses_with_constraints(path): + data = _load(path) + if "Requirements" in data: + Compliance(**data) + else: + ComplianceFramework.parse_obj(data) diff --git a/tests/lib/check/mitre_config_requirements_test.py b/tests/lib/check/mitre_config_requirements_test.py new file mode 100644 index 0000000000..ef3b33a17f --- /dev/null +++ b/tests/lib/check/mitre_config_requirements_test.py @@ -0,0 +1,148 @@ +"""Regression coverage for ConfigRequirements on MITRE requirements. + +``mitre_attack_aws.json`` declares ``ConfigRequirements`` on its requirements, +but ``Mitre_Requirement`` historically did not define the field, so Pydantic +silently dropped the constraints during MITRE parsing and the config validation +logic never saw them. These tests prove the constraints survive parsing and that +a violated MITRE config requirement forces the compliance result to FAIL through +the universal output path. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + Compliance, + Mitre_Requirement, + adapt_legacy_to_universal, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _mitre_compliance(check_id): + """A minimal one-requirement MITRE framework with a config constraint.""" + return Compliance( + Framework="MITRE-ATTACK", + Name="MITRE ATT&CK", + Provider="AWS", + Version="", + Description="Test MITRE framework", + Requirements=[ + { + "Name": "Test Technique", + "Id": "T9999", + "Tactics": ["initial-access"], + "SubTechniques": [], + "Platforms": ["AWS"], + "Description": "Requirement T9999", + "TechniqueURL": "https://attack.mitre.org/techniques/T9999", + "Checks": [check_id], + "ConfigRequirements": [ + { + "Check": check_id, + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ], + "Attributes": [ + { + "AWSService": "service", + "Category": "category", + "Value": "value", + "Comment": "comment", + } + ], + } + ], + ) + + +def _finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.metadata = SimpleNamespace( + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + ) + return finding + + +class Test_Mitre_Config_Requirements: + def test_config_requirements_survive_mitre_parsing(self): + """Real mitre_attack_aws.json constraints must not be dropped on parse.""" + compliance = Compliance.parse_file( + "prowler/compliance/aws/mitre_attack_aws.json" + ) + requirement = next(r for r in compliance.Requirements if r.Id == "T1190") + assert isinstance(requirement, Mitre_Requirement) + assert requirement.ConfigRequirements + # And they propagate through the legacy -> universal adapter unchanged. + universal = adapt_legacy_to_universal(compliance) + universal_requirement = next( + r for r in universal.requirements if r.id == "T1190" + ) + assert universal_requirement.config_requirements + assert len(universal_requirement.config_requirements) == len( + requirement.ConfigRequirements + ) + + def test_violating_mitre_config_forces_fail(self): + """A PASS finding becomes FAIL when the MITRE config constraint is violated.""" + check_id = "drs_job_exist" + framework = adapt_legacy_to_universal(_mitre_compliance(check_id)) + findings = [_finding(check_id, "PASS")] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = {"mute_non_default_regions": True} + out = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + event = out.data[0] + assert event.compliance.status_id == ComplianceStatusID.Fail + assert event.status_code == "FAIL" + assert "Configuration not valid" in event.message + # The nested Check object keeps the real (raw) finding status. + assert event.compliance.checks[0].status == "PASS" + + def test_valid_mitre_config_keeps_pass(self): + check_id = "drs_job_exist" + framework = adapt_legacy_to_universal(_mitre_compliance(check_id)) + findings = [_finding(check_id, "PASS")] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = {"mute_non_default_regions": False} + out = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + event = out.data[0] + assert event.compliance.status_id == ComplianceStatusID.Pass + assert event.status_code == "PASS" + assert "Configuration not valid" not in event.message diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 815479cdfa..f17e359441 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -95,6 +95,38 @@ class TestCheckMetada: "/path/to/accessanalyzer_enabled/accessanalyzer_enabled.metadata.json" ) + @mock.patch("prowler.lib.check.models.logger") + @mock.patch("prowler.lib.check.models.load_check_metadata") + @mock.patch("prowler.lib.check.models.recover_checks_from_provider") + def test_get_bulk_builtin_wins_on_check_id_collision( + self, mock_recover_checks, mock_load_metadata, mock_logger + ): + """Regression guard: when an entry-point plug-in re-registers a + built-in CheckID, the BUILT-IN metadata wins (first-write-wins) and + the plug-in is IGNORED. The override is surfaced via a warning so + the user knows their plug-in duplicate is being skipped and can + rename it. Matches the precedence in `_resolve_check_module`. See + PR #10700 review (HugoPBrito).""" + # Built-in first, plug-in last (matches recover_checks_from_provider order) + mock_recover_checks.return_value = [ + ("accessanalyzer_enabled", "/builtin/accessanalyzer_enabled"), + ("accessanalyzer_enabled", "/plugin/accessanalyzer_enabled"), + ] + + builtin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled") + plugin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled") + mock_load_metadata.side_effect = [builtin_metadata, plugin_metadata] + + result = CheckMetadata.get_bulk(provider="aws") + + # Built-in wins (first-write-wins on CheckID), plug-in is ignored + assert result["accessanalyzer_enabled"] is builtin_metadata + # Override is surfaced via warning naming the plug-in metadata file + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args.args[0] + assert "accessanalyzer_enabled" in warning_msg + assert "/plugin/accessanalyzer_enabled" in warning_msg + @mock.patch("prowler.lib.check.models.load_check_metadata") @mock.patch("prowler.lib.check.models.recover_checks_from_provider") def test_list(self, mock_recover_checks, mock_load_metadata): @@ -377,6 +409,50 @@ class TestCheckMetadataValidators: check_metadata = CheckMetadata(**valid_metadata) assert check_metadata.Categories == ["encryption", "logging", "secrets"] + def test_valid_vercel_plan_categories_success(self): + """Test Vercel plan categories are accepted using hyphen-separated names.""" + valid_metadata = { + "Provider": "vercel", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": [ + "vercel-hobby-plan", + "vercel-pro-plan", + "vercel-enterprise-plan", + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.Categories == [ + "vercel-hobby-plan", + "vercel-pro-plan", + "vercel-enterprise-plan", + ] + def test_valid_category_failure_non_string(self): """Test valid category validation fails with non-string category""" invalid_metadata = { @@ -454,7 +530,7 @@ class TestCheckMetadataValidators: with pytest.raises(ValidationError) as exc_info: CheckMetadata(**invalid_metadata) assert ( - "Categories can only contain lowercase letters, numbers and hyphen" + "Categories can only contain lowercase letters, numbers, and hyphen '-'" in str(exc_info.value) ) diff --git a/tests/lib/check/tool_wrapper_test.py b/tests/lib/check/tool_wrapper_test.py new file mode 100644 index 0000000000..5f4f0c7f88 --- /dev/null +++ b/tests/lib/check/tool_wrapper_test.py @@ -0,0 +1,124 @@ +"""Unit tests for prowler.lib.check.tool_wrapper. + +Covers the leaf helper directly (Provider.is_tool_wrapper_provider delegates +to it). Tests the frozenset fast path, the entry-point fallback for external +plug-ins, the broken-plug-in path, the no-match path, and the module-level +cache. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_ep_class_cache(): + """Reset the leaf module's cache between tests so they stay independent.""" + from prowler.lib.check import tool_wrapper + + tool_wrapper._ep_class_cache.clear() + yield + tool_wrapper._ep_class_cache.clear() + + +def _make_entry_point(name, cls): + """Create a mock entry point whose `load()` returns `cls`.""" + ep = MagicMock() + ep.name = name + ep.load.return_value = cls + return ep + + +class TestIsToolWrapperProvider: + """is_tool_wrapper_provider: frozenset + entry-point fallback.""" + + @pytest.mark.parametrize("name", ["iac", "llm", "image"]) + def test_returns_true_for_builtin_tool_wrappers(self, name): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + assert is_tool_wrapper_provider(name) is True + + @pytest.mark.parametrize("name", ["aws", "azure", "gcp", "github", "kubernetes"]) + def test_returns_false_for_regular_builtins(self, name): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + assert is_tool_wrapper_provider(name) is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_true_for_external_plugin_with_flag(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + cls = MagicMock(is_external_tool_provider=True) + mock_eps.return_value = [_make_entry_point("custom_wrapper", cls)] + + assert is_tool_wrapper_provider("custom_wrapper") is True + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_false_for_external_plugin_without_flag(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + cls = MagicMock(is_external_tool_provider=False) + mock_eps.return_value = [_make_entry_point("vanilla_external", cls)] + + assert is_tool_wrapper_provider("vanilla_external") is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_false_for_unknown_provider(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + mock_eps.return_value = [] + + assert is_tool_wrapper_provider("does-not-exist") is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_builtin_name_shortcircuits_before_loading_same_name_plugin(self, mock_eps): + """A plug-in registered under a built-in's name cannot flip the + built-in onto the tool-wrapper path, and its module is never loaded.""" + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + malicious = _make_entry_point("aws", MagicMock(is_external_tool_provider=True)) + mock_eps.return_value = [malicious] + + # `aws` is a built-in, so classification short-circuits to False... + assert is_tool_wrapper_provider("aws") is False + # ...and the shadowing plug-in's code is never executed via ep.load(). + malicious.load.assert_not_called() + + +class TestLoadEpClass: + """_load_ep_class: cache, broken plug-ins, no-match.""" + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_caches_result_across_calls(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + cls = MagicMock(is_external_tool_provider=True) + mock_eps.return_value = [_make_entry_point("cached_one", cls)] + + first = _load_ep_class("cached_one") + second = _load_ep_class("cached_one") + + assert first is cls + assert second is cls + # entry_points consulted only on the first call + assert mock_eps.call_count == 1 + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_none_for_broken_plugin(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + broken_ep = MagicMock() + broken_ep.name = "broken" + broken_ep.load.side_effect = ImportError("plug-in is broken") + mock_eps.return_value = [broken_ep] + + assert _load_ep_class("broken") is None + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_none_when_no_entry_point_matches(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + cls = MagicMock() + mock_eps.return_value = [_make_entry_point("other_provider", cls)] + + assert _load_ep_class("missing_provider") is None diff --git a/tests/lib/check/universal_compliance_models_test.py b/tests/lib/check/universal_compliance_models_test.py new file mode 100644 index 0000000000..c8f614488c --- /dev/null +++ b/tests/lib/check/universal_compliance_models_test.py @@ -0,0 +1,1239 @@ +import json +import os +import tempfile +from unittest.mock import MagicMock, patch + +import pytest +from pydantic.v1 import ValidationError + +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ChartConfig, + Compliance, + ComplianceFramework, + CriticalRequirementsFilter, + EnumValueDisplay, + I18nLabels, + OutputFormats, + OutputsConfig, + PDFConfig, + ReportFilter, + ScoringConfig, + ScoringFormula, + SplitByConfig, + TableConfig, + TableLabels, + UniversalComplianceRequirement, + adapt_legacy_to_universal, + get_bulk_compliance_frameworks_universal, + load_compliance_framework_universal, +) +from tests.lib.outputs.compliance.fixtures import ( + CIS_1_4_AWS, + ENS_RD2022_AWS, + KISA_ISMSP_AWS, + MITRE_ATTACK_AWS, + NIST_800_53_REVISION_4_AWS, + PROWLER_THREATSCORE_AWS, +) + + +class TestOutputFormats: + def test_defaults(self): + of = OutputFormats() + assert of.csv is True + assert of.ocsf is True + + def test_explicit_false(self): + of = OutputFormats(csv=False, ocsf=False) + assert of.csv is False + assert of.ocsf is False + + +class TestAttributeMetadata: + def test_basic(self): + meta = AttributeMetadata(key="Section", type="str") + assert meta.key == "Section" + assert meta.type == "str" + assert meta.output_formats.csv is True + assert meta.required is False + + def test_with_enum(self): + meta = AttributeMetadata( + key="Profile", + type="str", + enum=["Level 1", "Level 2"], + ) + assert meta.enum == ["Level 1", "Level 2"] + + def test_int_type(self): + meta = AttributeMetadata(key="LevelOfRisk", type="int", required=True) + assert meta.type == "int" + assert meta.required is True + + def test_enum_display_field(self): + meta = AttributeMetadata( + key="Dimensiones", + type="str", + enum=["confidencialidad", "integridad", "trazabilidad"], + enum_display={ + "confidencialidad": { + "label": "Confidencialidad", + "abbreviation": "C", + "color": "#FF6347", + }, + "integridad": { + "label": "Integridad", + "abbreviation": "I", + "color": "#4286F4", + }, + "trazabilidad": { + "label": "Trazabilidad", + "abbreviation": "T", + "color": "#32CD32", + }, + }, + ) + assert meta.enum_display is not None + assert meta.enum_display["confidencialidad"]["abbreviation"] == "C" + assert meta.enum_display["integridad"]["color"] == "#4286F4" + + def test_enum_order_field(self): + meta = AttributeMetadata( + key="Nivel", + type="str", + enum=["opcional", "bajo", "medio", "alto"], + enum_order=["alto", "medio", "bajo", "opcional"], + ) + assert meta.enum_order == ["alto", "medio", "bajo", "opcional"] + + def test_chart_label_field(self): + meta = AttributeMetadata( + key="Section", + type="str", + chart_label="Security Domain", + ) + assert meta.chart_label == "Security Domain" + + def test_output_formats_default_true(self): + meta = AttributeMetadata(key="Section") + assert meta.output_formats.csv is True + assert meta.output_formats.ocsf is True + + def test_output_formats_explicit_false(self): + meta = AttributeMetadata( + key="InternalNote", + output_formats=OutputFormats(csv=False, ocsf=False), + ) + assert meta.output_formats.csv is False + assert meta.output_formats.ocsf is False + + def test_new_fields_default_none(self): + meta = AttributeMetadata(key="Section") + assert meta.enum_display is None + assert meta.enum_order is None + assert meta.chart_label is None + + +class TestEnumValueDisplay: + def test_basic(self): + evd = EnumValueDisplay(label="Test") + assert evd.label == "Test" + assert evd.abbreviation is None + assert evd.color is None + assert evd.icon is None + + def test_dimension_style(self): + evd = EnumValueDisplay( + label="Trazabilidad", + abbreviation="T", + color="#4286F4", + ) + assert evd.label == "Trazabilidad" + assert evd.abbreviation == "T" + assert evd.color == "#4286F4" + + def test_tipo_style(self): + evd = EnumValueDisplay( + label="Requisito", + icon="⚠️", + ) + assert evd.icon == "⚠️" + assert evd.abbreviation is None + + +class TestChartConfig: + def test_horizontal_bar(self): + chart = ChartConfig( + id="section_compliance", + type="horizontal_bar", + group_by="Section", + title="Compliance Score by Domain", + y_label="Domain", + x_label="Compliance %", + ) + assert chart.type == "horizontal_bar" + assert chart.group_by == "Section" + assert chart.value_source == "compliance_percent" + assert chart.color_mode == "by_value" + + def test_vertical_bar(self): + chart = ChartConfig( + id="risk_distribution", + type="vertical_bar", + group_by="LevelOfRisk", + color_mode="fixed", + fixed_color="#336699", + ) + assert chart.type == "vertical_bar" + assert chart.fixed_color == "#336699" + + def test_radar(self): + chart = ChartConfig( + id="dimension_radar", + type="radar", + group_by="Dimensiones", + ) + assert chart.type == "radar" + + def test_defaults(self): + chart = ChartConfig(id="test", type="vertical_bar", group_by="Section") + assert chart.title is None + assert chart.x_label is None + assert chart.y_label is None + assert chart.value_source == "compliance_percent" + assert chart.color_mode == "by_value" + assert chart.fixed_color is None + + +class TestScoringFormula: + def test_threatscore_style(self): + formula = ScoringFormula( + risk_field="LevelOfRisk", + weight_field="Weight", + risk_boost_factor=0.25, + ) + assert formula.risk_field == "LevelOfRisk" + assert formula.weight_field == "Weight" + assert formula.risk_boost_factor == 0.25 + + def test_custom_boost_factor(self): + formula = ScoringFormula( + risk_field="Risk", + weight_field="Impact", + risk_boost_factor=0.5, + ) + assert formula.risk_boost_factor == 0.5 + + def test_default_boost_factor(self): + formula = ScoringFormula(risk_field="LevelOfRisk", weight_field="Weight") + assert formula.risk_boost_factor == 0.25 + + +class TestCriticalRequirementsFilter: + def test_int_based(self): + crf = CriticalRequirementsFilter( + filter_field="LevelOfRisk", + min_value=4, + title="Critical Failed Requirements", + ) + assert crf.filter_field == "LevelOfRisk" + assert crf.min_value == 4 + assert crf.filter_value is None + assert crf.status_filter == "FAIL" + assert crf.title == "Critical Failed Requirements" + + def test_string_based(self): + crf = CriticalRequirementsFilter( + filter_field="Nivel", + filter_value="alto", + ) + assert crf.filter_value == "alto" + assert crf.min_value is None + + def test_defaults(self): + crf = CriticalRequirementsFilter(filter_field="LevelOfRisk") + assert crf.status_filter == "FAIL" + assert crf.title is None + assert crf.min_value is None + assert crf.filter_value is None + + +class TestReportFilter: + def test_defaults(self): + rf = ReportFilter() + assert rf.only_failed is True + assert rf.include_manual is False + + def test_custom(self): + rf = ReportFilter(only_failed=False, include_manual=True) + assert rf.only_failed is False + assert rf.include_manual is True + + +class TestI18nLabels: + def test_english_defaults(self): + labels = I18nLabels() + assert labels.page_label == "Page" + assert labels.powered_by == "Powered by Prowler" + assert labels.framework_label == "Framework:" + assert labels.provider_label == "Provider:" + assert labels.report_title is None + + def test_spanish_override(self): + labels = I18nLabels( + report_title="Informe de Cumplimiento ENS", + page_label="Página", + powered_by="Generado por Prowler", + framework_label="Marco:", + version_label="Versión:", + provider_label="Proveedor:", + description_label="Descripción:", + compliance_score_label="Puntuación de Cumplimiento por Secciones", + requirements_index_label="Índice de Requisitos", + detailed_findings_label="Hallazgos Detallados", + ) + assert labels.page_label == "Página" + assert labels.provider_label == "Proveedor:" + assert labels.report_title == "Informe de Cumplimiento ENS" + + +class TestSplitByConfig: + def test_cis_style(self): + config = SplitByConfig(field="Profile", values=["Level 1", "Level 2"]) + assert config.field == "Profile" + assert len(config.values) == 2 + + def test_ens_style(self): + config = SplitByConfig( + field="Nivel", + values=["alto", "medio", "bajo", "opcional"], + ) + assert len(config.values) == 4 + + +class TestScoringConfig: + def test_threatscore_style(self): + config = ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight") + assert config.risk_field == "LevelOfRisk" + assert config.weight_field == "Weight" + + +class TestTableLabels: + def test_defaults(self): + labels = TableLabels() + assert labels.pass_label == "PASS" + assert labels.fail_label == "FAIL" + assert labels.provider_header == "Provider" + + def test_ens_spanish(self): + labels = TableLabels( + pass_label="CUMPLE", + fail_label="NO CUMPLE", + provider_header="Proveedor", + ) + assert labels.pass_label == "CUMPLE" + + +class TestTableConfig: + def test_grouped_mode(self): + tc = TableConfig(group_by="Section") + assert tc.group_by == "Section" + assert tc.split_by is None + assert tc.scoring is None + + def test_split_mode(self): + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + assert tc.split_by is not None + assert tc.split_by.field == "Profile" + + def test_scored_mode(self): + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + assert tc.scoring is not None + + +class TestPDFConfig: + def test_defaults(self): + pdf = PDFConfig() + assert pdf.language == "en" + assert pdf.logo_filename is None + assert pdf.primary_color is None + assert pdf.sections is None + assert pdf.section_short_names is None + assert pdf.group_by_field is None + assert pdf.sub_group_by_field is None + assert pdf.section_titles is None + assert pdf.charts is None + assert pdf.scoring is None + assert pdf.critical_filter is None + assert pdf.filter is None + assert pdf.labels is None + + def test_csa_ccm_style(self): + pdf = PDFConfig( + primary_color="#336699", + secondary_color="#4D80B3", + bg_color="#F2F8FF", + group_by_field="Section", + sections=["Audit & Assurance", "Identity & Access Management"], + section_short_names={"Identity & Access Management": "IAM"}, + charts=[ + ChartConfig( + id="section_compliance", + type="horizontal_bar", + group_by="Section", + title="Compliance Score by Domain", + ).dict() + ], + filter=ReportFilter(only_failed=True, include_manual=False), + ) + assert pdf.primary_color == "#336699" + assert len(pdf.sections) == 2 + assert pdf.section_short_names["Identity & Access Management"] == "IAM" + assert pdf.group_by_field == "Section" + assert pdf.charts is not None + assert len(pdf.charts) == 1 + assert pdf.filter.only_failed is True + + def test_ens_style(self): + pdf = PDFConfig( + language="es", + logo_filename="ens_logo.png", + primary_color="#CC3333", + group_by_field="Marco", + sub_group_by_field="Categoria", + labels=I18nLabels( + page_label="Página", + provider_label="Proveedor:", + ), + ) + assert pdf.language == "es" + assert pdf.logo_filename == "ens_logo.png" + assert pdf.group_by_field == "Marco" + assert pdf.sub_group_by_field == "Categoria" + assert pdf.labels.page_label == "Página" + + def test_threatscore_style(self): + pdf = PDFConfig( + primary_color="#336699", + sections=["1. IAM", "2. Attack Surface"], + scoring=ScoringFormula( + risk_field="LevelOfRisk", + weight_field="Weight", + risk_boost_factor=0.25, + ), + critical_filter=CriticalRequirementsFilter( + filter_field="LevelOfRisk", + min_value=4, + title="Critical Failed Requirements", + ), + ) + assert pdf.scoring is not None + assert pdf.scoring.risk_field == "LevelOfRisk" + assert pdf.critical_filter.min_value == 4 + + def test_section_titles(self): + pdf = PDFConfig( + section_titles={ + "1": "1. Policy on Security", + "2": "2. Risk Management", + }, + ) + assert pdf.section_titles["1"] == "1. Policy on Security" + + def test_in_framework(self): + fw = ComplianceFramework( + framework="Test", + name="Test Framework", + description="Test", + requirements=[], + outputs=OutputsConfig( + pdf_config=PDFConfig( + primary_color="#336699", + sections=["Section A"], + charts=[ + ChartConfig( + id="test_chart", + type="vertical_bar", + group_by="Section", + ).dict() + ], + ), + ), + ) + assert fw.outputs is not None + assert fw.outputs.pdf_config is not None + assert fw.outputs.pdf_config.primary_color == "#336699" + assert fw.outputs.pdf_config.sections == ["Section A"] + assert fw.outputs.pdf_config.charts is not None + assert len(fw.outputs.pdf_config.charts) == 1 + assert fw.outputs.pdf_config.charts[0]["id"] == "test_chart" + assert fw.outputs.pdf_config.charts[0]["type"] == "vertical_bar" + + def test_framework_without_pdf_config(self): + fw = ComplianceFramework( + framework="Test", + name="Test Framework", + description="Test", + requirements=[], + ) + assert fw.outputs is None + + +class TestUniversalComplianceRequirement: + def test_flat_dict_attributes(self): + req = UniversalComplianceRequirement( + id="1.1", + description="Test requirement", + attributes={"Section": "IAM", "Profile": "Level 1"}, + checks={"aws": ["check_a", "check_b"]}, + ) + assert req.attributes["Section"] == "IAM" + assert len(req.checks["aws"]) == 2 + + def test_mitre_optional_fields(self): + req = UniversalComplianceRequirement( + id="T1190", + description="Exploit Public-Facing Application", + attributes={}, + checks={"aws": ["drs_job_exist"]}, + tactics=["Initial Access"], + sub_techniques=[], + platforms=["IaaS", "Linux"], + technique_url="https://attack.mitre.org/techniques/T1190/", + ) + assert req.tactics == ["Initial Access"] + assert req.technique_url == "https://attack.mitre.org/techniques/T1190/" + + def test_dict_checks_multi_provider(self): + req = UniversalComplianceRequirement( + id="1.1", + description="Multi-provider", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ) + assert isinstance(req.checks, dict) + assert "aws" in req.checks + + def test_empty_checks(self): + req = UniversalComplianceRequirement( + id="manual-1", + description="Manual requirement", + attributes={"Section": "Governance"}, + checks={}, + ) + assert req.checks == {} + + def test_checks_default_is_empty_dict(self): + req = UniversalComplianceRequirement( + id="1.1", + description="No checks provided", + ) + assert req.checks == {} + + +class TestComplianceFramework: + def test_basic_framework(self): + fw = ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="A test framework", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="Test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ) + ], + attributes_metadata=[ + AttributeMetadata(key="Section", type="str"), + ], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + assert fw.framework == "TestFW" + assert fw.outputs.table_config.group_by == "Section" + assert len(fw.attributes_metadata) == 1 + assert len(fw.requirements) == 1 + + def test_optional_provider(self): + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi-cloud framework", + description="A multi-provider framework", + requirements=[], + ) + assert fw.provider is None + + def test_get_providers_from_dict_checks(self): + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi-cloud", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={}, + checks={ + "aws": ["check_a"], + "azure": ["check_b"], + "gcp": ["check_c"], + }, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={}, + checks={"aws": ["check_d"]}, + ), + ], + ) + providers = fw.get_providers() + assert providers == ["aws", "azure", "gcp"] + + def test_get_providers_fallback_to_explicit(self): + fw = ComplianceFramework( + framework="SingleCloud", + name="Single-cloud", + provider="AWS", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={}, + checks={}, + ), + ], + ) + providers = fw.get_providers() + assert providers == ["aws"] + + def test_supports_provider_dict_checks(self): + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi-cloud", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ], + ) + assert fw.supports_provider("aws") is True + assert fw.supports_provider("azure") is True + assert fw.supports_provider("gcp") is False + + def test_supports_provider_explicit_only(self): + """Framework with explicit provider but no per-requirement checks still supports the provider.""" + fw = ComplianceFramework( + framework="SingleCloud", + name="Single-cloud", + provider="AWS", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="Manual requirement", + attributes={}, + checks={}, + ), + ], + ) + assert fw.supports_provider("aws") is True + assert fw.supports_provider("azure") is False + + def test_no_provider_field_with_dict_checks(self): + """Multi-provider JSON has no Provider field — providers derived from checks.""" + fw = ComplianceFramework( + framework="CSA_CCM", + name="CSA CCM 4.0", + description="Cloud Controls Matrix", + requirements=[ + UniversalComplianceRequirement( + id="A&A-01", + description="Audit & Assurance", + attributes={"Domain": "A&A"}, + checks={ + "aws": ["check_a"], + "azure": ["check_b"], + "gcp": ["check_c"], + }, + ), + ], + ) + assert fw.provider is None + assert fw.get_providers() == ["aws", "azure", "gcp"] + assert fw.supports_provider("aws") + assert fw.supports_provider("azure") + assert fw.supports_provider("gcp") + assert not fw.supports_provider("kubernetes") + + def test_icon_field(self): + fw = ComplianceFramework( + framework="CSA_CCM", + name="CSA CCM 4.0", + description="Cloud Controls Matrix", + icon="csa", + requirements=[], + ) + assert fw.icon == "csa" + + def test_icon_defaults_to_none(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[], + ) + assert fw.icon is None + + +class TestAdaptLegacyToUniversal: + def test_adapt_cis(self): + fw = adapt_legacy_to_universal(CIS_1_4_AWS) + assert fw.framework == "CIS" + assert fw.provider == "AWS" + assert len(fw.requirements) == 2 + # First requirement should have flat attributes + req = fw.requirements[0] + assert "Section" in req.attributes + assert req.attributes["Section"] == "2. Storage" + assert req.tactics is None + # Checks must be wrapped in dict keyed by provider + assert isinstance(req.checks, dict) + assert "aws" in req.checks + + def test_adapt_ens(self): + fw = adapt_legacy_to_universal(ENS_RD2022_AWS) + assert fw.framework == "ENS" + req = fw.requirements[0] + assert "Marco" in req.attributes + assert req.attributes["Marco"] == "operacional" + + def test_adapt_mitre(self): + fw = adapt_legacy_to_universal(MITRE_ATTACK_AWS) + assert fw.framework == "MITRE-ATTACK" + req = fw.requirements[0] + assert req.tactics == ["Initial Access"] + assert req.technique_url == "https://attack.mitre.org/techniques/T1190/" + assert "_raw_attributes" in req.attributes + assert isinstance(req.checks, dict) + assert "aws" in req.checks + + def test_adapt_threatscore(self): + fw = adapt_legacy_to_universal(PROWLER_THREATSCORE_AWS) + req = fw.requirements[0] + assert req.attributes["LevelOfRisk"] == 5 + assert req.attributes["Weight"] == 1000 + + def test_adapt_generic(self): + fw = adapt_legacy_to_universal(NIST_800_53_REVISION_4_AWS) + req = fw.requirements[0] + assert "Section" in req.attributes + + def test_adapt_kisa(self): + fw = adapt_legacy_to_universal(KISA_ISMSP_AWS) + req = fw.requirements[0] + assert "Domain" in req.attributes + + def test_inferred_metadata_cis(self): + fw = adapt_legacy_to_universal(CIS_1_4_AWS) + assert fw.attributes_metadata is not None + keys = [m.key for m in fw.attributes_metadata] + assert "Section" in keys + assert "Profile" in keys + + def test_inferred_metadata_mitre_is_none(self): + fw = adapt_legacy_to_universal(MITRE_ATTACK_AWS) + assert fw.attributes_metadata is None + + def test_table_config_is_none(self): + fw = adapt_legacy_to_universal(CIS_1_4_AWS) + assert fw.outputs is None + + +class TestLoadComplianceFrameworkUniversal: + def test_load_universal_format(self, tmp_path): + data = { + "framework": "TestFW", + "name": "Test", + "provider": "AWS", + "version": "1.0", + "description": "desc", + "icon": "prowlerthreatscore", + "attributes_metadata": [{"key": "Section", "type": "str"}], + "outputs": {"table_config": {"group_by": "Section"}}, + "requirements": [ + { + "id": "1.1", + "description": "test", + "attributes": {"Section": "IAM"}, + "checks": {"aws": ["check_a"]}, + } + ], + } + path = tmp_path / "test.json" + path.write_text(json.dumps(data)) + fw = load_compliance_framework_universal(str(path)) + assert fw is not None + assert fw.framework == "TestFW" + assert fw.icon == "prowlerthreatscore" + assert fw.outputs.table_config.group_by == "Section" + + def test_load_universal_multi_provider(self, tmp_path): + data = { + "framework": "CSA_CCM", + "name": "CSA CCM 4.0", + "version": "4.0", + "description": "Cloud Controls Matrix", + "attributes_metadata": [{"key": "Domain", "type": "str"}], + "outputs": {"table_config": {"group_by": "Domain"}}, + "requirements": [ + { + "id": "A&A-01", + "description": "Audit", + "attributes": {"Domain": "Audit"}, + "checks": { + "aws": ["check_a"], + "azure": ["check_b"], + "gcp": ["check_c"], + }, + } + ], + } + path = tmp_path / "csa_ccm_4.0.json" + path.write_text(json.dumps(data)) + fw = load_compliance_framework_universal(str(path)) + assert fw is not None + assert fw.provider is None + assert fw.get_providers() == ["aws", "azure", "gcp"] + assert fw.supports_provider("aws") + assert not fw.supports_provider("kubernetes") + + def test_load_legacy_format(self, tmp_path): + data = { + "Framework": "SOC2", + "Name": "SOC2", + "Provider": "AWS", + "Version": "", + "Description": "desc", + "Requirements": [ + { + "Id": "1.1", + "Description": "test", + "Attributes": [{"Section": "Access Control"}], + "Checks": ["check_a"], + } + ], + } + path = tmp_path / "legacy.json" + path.write_text(json.dumps(data)) + fw = load_compliance_framework_universal(str(path)) + assert fw is not None + assert fw.framework == "SOC2" + assert fw.outputs is None + assert fw.requirements[0].attributes["Section"] == "Access Control" + assert fw.requirements[0].checks == {"aws": ["check_a"]} + + +class TestSmokeLoadAllJSONs: + """Parametrized smoke test: every existing compliance JSON must load as ComplianceFramework.""" + + @staticmethod + def _find_all_compliance_jsons(): + base = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "prowler", + "compliance", + ) + base = os.path.normpath(base) + jsons = [] + if os.path.isdir(base): + # Top-level JSONs (multi-provider) + for filename in os.listdir(base): + if filename.endswith(".json"): + jsons.append(os.path.join(base, filename)) + # Provider sub-directory JSONs + for provider_dir in os.listdir(base): + provider_path = os.path.join(base, provider_dir) + if os.path.isdir(provider_path): + for filename in os.listdir(provider_path): + if filename.endswith(".json"): + jsons.append(os.path.join(provider_path, filename)) + return jsons + + @pytest.mark.parametrize( + "json_path", + _find_all_compliance_jsons.__func__(), + ids=lambda p: os.path.basename(p), + ) + def test_loads_as_universal(self, json_path): + fw = load_compliance_framework_universal(json_path) + assert fw is not None, f"Failed to load {json_path}" + assert fw.framework + assert fw.name + assert len(fw.requirements) >= 0 + + +class TestBackwardCompat: + """Ensure Compliance.get_bulk still returns Compliance objects.""" + + def test_get_bulk_still_works(self): + # This test just validates the legacy path still returns Compliance objects + # We test with a constructed Compliance object + legacy = CIS_1_4_AWS + assert isinstance(legacy, Compliance) + assert legacy.Framework == "CIS" + + +class TestAttributesMetadataValidation: + """Validate that Requirement attributes match their attributes_metadata schema.""" + + def _metadata(self, required=False, enum=None, type_str="str"): + return [ + AttributeMetadata(key="Section", type="str", required=True), + AttributeMetadata(key="Level", type=type_str, required=required, enum=enum), + ] + + def test_valid_attributes_pass(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(), + ) + assert len(fw.requirements) == 1 + + def test_missing_required_key_raises(self): + with pytest.raises( + ValidationError, match="missing required attribute 'Section'" + ): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(), + ) + + def test_invalid_enum_value_raises(self): + with pytest.raises(ValidationError, match="not in"): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "invalid"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + + def test_valid_enum_value_passes(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + assert len(fw.requirements) == 1 + + def test_wrong_type_int_raises(self): + with pytest.raises(ValidationError, match="expected type int"): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "not_a_number"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(type_str="int"), + ) + + def test_correct_type_int_passes(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": 5}, + checks={}, + ), + ], + attributes_metadata=self._metadata(type_str="int"), + ) + assert fw.requirements[0].attributes["Level"] == 5 + + def test_none_optional_value_skips_validation(self): + """None values for non-required keys should not trigger type/enum errors.""" + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": None}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + assert len(fw.requirements) == 1 + + def test_no_metadata_skips_validation(self): + """Frameworks without attributes_metadata should not be validated.""" + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"anything": "goes"}, + checks={}, + ), + ], + ) + assert len(fw.requirements) == 1 + + def test_unknown_attribute_key_raises(self): + """Typos like 'Sectoin' must be rejected by the schema validator.""" + with pytest.raises(ValidationError, match="unknown attribute 'Sectoin'"): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Sectoin": "IAM", "Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + + def test_multiple_unknown_keys_all_reported(self): + """Every unknown key must appear in the validation error (deterministic order).""" + with pytest.raises( + ValidationError, + match=r"unknown attribute 'Bogus1'[\s\S]*unknown attribute 'Bogus2'", + ): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={ + "Section": "IAM", + "Level": "high", + "Bogus1": "x", + "Bogus2": "y", + }, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + + def test_multiple_errors_reported(self): + """All validation errors should be collected and reported together.""" + with pytest.raises( + ValidationError, match="missing required attribute 'Section'" + ): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Level": "bad"}, + checks={}, + ), + UniversalComplianceRequirement( + id="1.2", + description="d", + attributes={"Level": "also_bad"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + + +class TestGetBulkUniversalEntryPoints: + """Entry-point discovery for universal (multi-provider) compliance frameworks.""" + + @staticmethod + def _write_universal_json(directory, filename, framework, display_name): + data = { + "framework": framework, + "name": display_name, + "version": "1.0", + "description": "External multi-provider framework", + "requirements": [ + { + "id": "1", + "name": "Requirement 1", + "description": "desc", + "checks": {"fakeexternal": ["check_a"]}, + } + ], + } + with open(os.path.join(directory, filename), "w") as f: + json.dump(data, f) + + @staticmethod + def _entry_point(path): + module = MagicMock() + module.__path__ = [path] + ep = MagicMock() + ep.name = "fakeexternal" + ep.group = "prowler.compliance.universal" + ep.load.return_value = module + return ep + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_includes_external_universal_framework(self, mock_list_modules, mock_ep): + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json( + ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + mock_ep.assert_called_with(group="prowler.compliance.universal") + assert "customuniversal_1.0" in bulk + assert bulk["customuniversal_1.0"].framework == "CustomUniversal" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_builtin_wins_over_external_on_name_collision( + self, mock_list_modules, mock_ep + ): + with ( + tempfile.TemporaryDirectory() as root, + tempfile.TemporaryDirectory() as ep_dir, + ): + builtin_sub = os.path.join(root, "builtinprov") + os.makedirs(builtin_sub) + self._write_universal_json( + builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in" + ) + builtin_module = MagicMock() + builtin_module.module_finder.path = root + builtin_module.name = "prowler.compliance.builtinprov" + mock_list_modules.return_value = [builtin_module] + + self._write_universal_json( + ep_dir, "shared_1.0.json", "SharedFramework", "External" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "shared_1.0" in bulk + assert bulk["shared_1.0"].name == "Built-in" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_loads_all_frameworks_in_a_single_entry_point_path( + self, mock_list_modules, mock_ep + ): + """All JSONs in one entry-point directory are added, not collapsed to one.""" + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A") + self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B") + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "fw_a_1.0" in bulk + assert "fw_b_1.0" in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_merges_frameworks_from_multiple_packages_same_provider( + self, mock_list_modules, mock_ep + ): + """Two packages under the same provider name are both discovered.""" + mock_list_modules.return_value = [] + with ( + tempfile.TemporaryDirectory() as dir_a, + tempfile.TemporaryDirectory() as dir_b, + ): + self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A") + self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B") + mock_ep.return_value = [ + self._entry_point(dir_a), + self._entry_point(dir_b), + ] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "pkg_a_1.0" in bulk + assert "pkg_b_1.0" in bulk diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index e22ffac69b..94b6ad2b4d 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,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -35,8 +35,11 @@ def mock_get_available_providers(): "mongodbatlas", "oraclecloud", "alibabacloud", + "llm", "cloudflare", "openstack", + "stackit", + "linode", ] diff --git a/tests/lib/cli/redact_test.py b/tests/lib/cli/redact_test.py index 1f33998356..c7ff65a765 100644 --- a/tests/lib/cli/redact_test.py +++ b/tests/lib/cli/redact_test.py @@ -1,8 +1,14 @@ +import logging from unittest.mock import patch import pytest -from prowler.lib.cli.redact import REDACTED_VALUE, get_sensitive_arguments, redact_argv +from prowler.lib.cli.redact import ( + REDACTED_VALUE, + get_sensitive_arguments, + redact_argv, + warn_sensitive_argument_values, +) @pytest.fixture @@ -87,6 +93,62 @@ class TestRedactArgv: assert redact_argv(argv) == "aws --region=us-east-1" +class TestWarnSensitiveArgumentValues: + def test_no_warning_without_sensitive_flags(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["aws", "--region", "eu-west-1"]) + assert caplog.text == "" + + def test_no_warning_flag_without_value(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["github", "--personal-access-token"]) + assert caplog.text == "" + + def test_no_warning_flag_followed_by_another_flag( + self, caplog, mock_sensitive_args + ): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values( + ["github", "--personal-access-token", "--region", "eu-west-1"] + ) + assert caplog.text == "" + + def test_warning_flag_with_value(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values( + ["github", "--personal-access-token", "ghp_secret"] + ) + assert "--personal-access-token" in caplog.text + assert "not recommended" in caplog.text + + def test_warning_flag_with_equals_syntax(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["aws", "--shodan=key123"]) + assert "--shodan" in caplog.text + assert "not recommended" in caplog.text + + def test_warning_multiple_flags(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values( + [ + "github", + "--personal-access-token", + "ghp_secret", + "--shodan", + "key", + ] + ) + assert "--personal-access-token" in caplog.text + assert "--shodan" in caplog.text + + def test_no_color_output(self, caplog, mock_sensitive_args): + with caplog.at_level(logging.WARNING): + warn_sensitive_argument_values(["--no-color", "aws", "--shodan", "key123"]) + assert "not recommended" in caplog.text + # Should not contain ANSI escape codes + assert "\033[" not in caplog.text + + class TestGetSensitiveArguments: def test_discovers_known_sensitive_arguments(self): """Integration test: verify the discovery mechanism finds flags from provider modules.""" @@ -98,6 +160,7 @@ class TestGetSensitiveArguments: assert "--atlas-private-key" in result assert "--nhn-password" in result assert "--os-password" in result + assert "--stackit-service-account-key" in result def test_does_not_include_non_sensitive_flags(self): """Verify non-sensitive flags are not in the set.""" @@ -106,3 +169,4 @@ class TestGetSensitiveArguments: assert "--region" not in result assert "--profile" not in result assert "--output-formats" not in result + assert "--stackit-service-account-key-path" not in result diff --git a/tests/lib/outputs/compliance/asd_essential_eight/__init__.py b/tests/lib/outputs/compliance/asd_essential_eight/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws_test.py b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws_test.py new file mode 100644 index 0000000000..90042c9d57 --- /dev/null +++ b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_aws_test.py @@ -0,0 +1,128 @@ +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws import ( + ASDEssentialEightAWS, +) +from prowler.lib.outputs.compliance.asd_essential_eight.models import ( + ASDEssentialEightAWSModel, +) +from tests.lib.outputs.compliance.fixtures import ASD_ESSENTIAL_EIGHT_AWS +from tests.lib.outputs.fixtures.fixtures import generate_finding_output +from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1 + +# The fixture's first Requirement maps clause "E8-1.8" (Patch applications, +# clause 8: removal of unsupported online services). The second Requirement is +# E8-6.1 (Restrict Office macros, clause 1) which has no Checks and is therefore +# emitted as a manual row. +COMPLIANCE_NAME = "ASD-Essential-Eight-Nov 2023" + + +class TestASDEssentialEightAWS: + def test_output_transform(self): + findings = [generate_finding_output(compliance={COMPLIANCE_NAME: "E8-1.8"})] + + output = ASDEssentialEightAWS(findings, ASD_ESSENTIAL_EIGHT_AWS) + output_data = output.data[0] + assert isinstance(output_data, ASDEssentialEightAWSModel) + assert output_data.Provider == "aws" + assert output_data.Framework == ASD_ESSENTIAL_EIGHT_AWS.Framework + assert output_data.Name == ASD_ESSENTIAL_EIGHT_AWS.Name + assert output_data.Description == ASD_ESSENTIAL_EIGHT_AWS.Description + assert output_data.AccountId == AWS_ACCOUNT_NUMBER + assert output_data.Region == AWS_REGION_EU_WEST_1 + assert output_data.Requirements_Id == "E8-1.8" + assert ( + output_data.Requirements_Description + == ASD_ESSENTIAL_EIGHT_AWS.Requirements[0].Description + ) + assert output_data.Requirements_Attributes_Section == "1 Patch applications" + assert output_data.Requirements_Attributes_MaturityLevel == "ML1" + assert output_data.Requirements_Attributes_AssessmentStatus == "Automated" + assert output_data.Requirements_Attributes_CloudApplicability == "full" + assert ( + output_data.Requirements_Attributes_MitigatedThreats + == "Use of unsupported software, Long-tail vulnerability accumulation" + ) + assert ( + output_data.Requirements_Attributes_Description + == ASD_ESSENTIAL_EIGHT_AWS.Requirements[0].Attributes[0].Description + ) + assert output_data.Status == "PASS" + assert output_data.StatusExtended == "" + assert output_data.ResourceId == "" + assert output_data.ResourceName == "" + assert output_data.CheckId == "service_test_check_id" + assert not output_data.Muted + + def test_manual_requirement(self): + findings = [generate_finding_output(compliance={COMPLIANCE_NAME: "E8-1.8"})] + output = ASDEssentialEightAWS(findings, ASD_ESSENTIAL_EIGHT_AWS) + + # E8-6.1 (macros) has no Checks -> emitted as a manual row, non-applicable + manual_rows = [row for row in output.data if row.Status == "MANUAL"] + assert len(manual_rows) == 1 + + manual = manual_rows[0] + assert manual.Provider == "aws" + assert manual.AccountId == "" + assert manual.Region == "" + assert manual.Requirements_Id == "E8-6.1" + assert ( + manual.Requirements_Attributes_Section + == "6 Restrict Microsoft Office macros" + ) + assert manual.Requirements_Attributes_MaturityLevel == "ML1" + assert manual.Requirements_Attributes_AssessmentStatus == "Manual" + assert manual.Requirements_Attributes_CloudApplicability == "non-applicable" + assert ( + manual.Requirements_Attributes_MitigatedThreats + == "Macro-based malware delivery" + ) + assert manual.StatusExtended == "Manual check" + assert manual.ResourceId == "manual_check" + assert manual.ResourceName == "Manual check" + assert manual.CheckId == "manual" + assert not manual.Muted + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [generate_finding_output(compliance={COMPLIANCE_NAME: "E8-1.8"})] + output = ASDEssentialEightAWS(findings, ASD_ESSENTIAL_EIGHT_AWS) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + + # Validate header carries the E8-specific column names + first_line = content.split("\r\n", 1)[0] + for column in ( + "REQUIREMENTS_ATTRIBUTES_MATURITYLEVEL", + "REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS", + "REQUIREMENTS_ATTRIBUTES_CLOUDAPPLICABILITY", + "REQUIREMENTS_ATTRIBUTES_MITIGATEDTHREATS", + "REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT", + "REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE", + "REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE", + ): + assert column in first_line, f"missing column {column} in CSV header" + + # rows: header + matched + manual + rows = [r for r in content.split("\r\n") if r] + assert len(rows) == 3 + assert rows[1].split(";")[0] == "aws" + assert "ML1" in rows[1] + assert ";PASS;" in rows[1] + assert ";MANUAL;" in rows[2] + assert ";manual_check;" in rows[2] diff --git a/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py new file mode 100644 index 0000000000..6382dc1d2c --- /dev/null +++ b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py @@ -0,0 +1,132 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight import ( + get_asd_essential_eight_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="ASD-Essential-Eight"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestASDEssentialEightTable: + """Test cases verifying multi-section counting and provider-column attribution for the ASD Essential Eight compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ASD-Essential-Eight + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/c5/__init__.py b/tests/lib/outputs/compliance/c5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/c5/c5_table_test.py b/tests/lib/outputs/compliance/c5/c5_table_test.py new file mode 100644 index 0000000000..c423f5af0b --- /dev/null +++ b/tests/lib/outputs/compliance/c5/c5_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.c5.c5 import get_c5_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="C5"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestC5Table: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched C5 compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/ccc/__init__.py b/tests/lib/outputs/compliance/ccc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/ccc/ccc_aws_test.py b/tests/lib/outputs/compliance/ccc/ccc_aws_test.py new file mode 100644 index 0000000000..39460fd0ec --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_aws_test.py @@ -0,0 +1,138 @@ +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.ccc.ccc_aws import CCC_AWS +from prowler.lib.outputs.compliance.ccc.models import CCC_AWSModel +from tests.lib.outputs.compliance.fixtures import CCC_AWS_FIXTURE +from tests.lib.outputs.fixtures.fixtures import generate_finding_output +from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1 + + +class TestAWSCCC: + def test_output_transform_evaluated_requirement(self): + findings = [ + generate_finding_output(compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}) + ] + + output = CCC_AWS(findings, CCC_AWS_FIXTURE) + output_data = output.data[0] + + assert isinstance(output_data, CCC_AWSModel) + assert output_data.Provider == "aws" + assert output_data.AccountId == AWS_ACCOUNT_NUMBER + assert output_data.Region == AWS_REGION_EU_WEST_1 + assert output_data.Description == CCC_AWS_FIXTURE.Description + assert output_data.Requirements_Id == CCC_AWS_FIXTURE.Requirements[0].Id + assert ( + output_data.Requirements_Description + == CCC_AWS_FIXTURE.Requirements[0].Description + ) + attribute = CCC_AWS_FIXTURE.Requirements[0].Attributes[0] + assert output_data.Requirements_Attributes_FamilyName == attribute.FamilyName + assert ( + output_data.Requirements_Attributes_FamilyDescription + == attribute.FamilyDescription + ) + assert output_data.Requirements_Attributes_Section == attribute.Section + assert output_data.Requirements_Attributes_SubSection == attribute.SubSection + assert ( + output_data.Requirements_Attributes_SubSectionObjective + == attribute.SubSectionObjective + ) + assert ( + output_data.Requirements_Attributes_Applicability == attribute.Applicability + ) + assert ( + output_data.Requirements_Attributes_Recommendation + == attribute.Recommendation + ) + assert ( + output_data.Requirements_Attributes_SectionThreatMappings + == attribute.SectionThreatMappings + ) + assert ( + output_data.Requirements_Attributes_SectionGuidelineMappings + == attribute.SectionGuidelineMappings + ) + assert output_data.Status == "PASS" + assert output_data.StatusExtended == "" + assert output_data.ResourceId == "" + assert output_data.ResourceName == "" + assert output_data.CheckId == "service_test_check_id" + assert output_data.Muted is False + + def test_output_transform_manual_requirement(self): + # Use a finding for the evaluated requirement so the manual one is appended + # by the manual-loop branch (Checks=[]). + findings = [ + generate_finding_output(compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}) + ] + + output = CCC_AWS(findings, CCC_AWS_FIXTURE) + # data[0] is the evaluated PASS row, data[1] is the manual row + manual_row = output.data[1] + + assert isinstance(manual_row, CCC_AWSModel) + assert manual_row.Provider == "aws" + assert manual_row.AccountId == "" + assert manual_row.Region == "" + assert manual_row.Description == CCC_AWS_FIXTURE.Description + assert manual_row.Requirements_Id == CCC_AWS_FIXTURE.Requirements[1].Id + manual_attribute = CCC_AWS_FIXTURE.Requirements[1].Attributes[0] + assert ( + manual_row.Requirements_Attributes_FamilyName == manual_attribute.FamilyName + ) + assert manual_row.Requirements_Attributes_Section == manual_attribute.Section + assert manual_row.Status == "MANUAL" + assert manual_row.StatusExtended == "Manual check" + assert manual_row.ResourceId == "manual_check" + assert manual_row.ResourceName == "Manual check" + assert manual_row.CheckId == "manual" + assert manual_row.Muted is False + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.ccc.ccc_aws.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output(compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}) + ] + output = CCC_AWS(findings, CCC_AWS_FIXTURE) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + + # Header check: AWS-specific columns must be present + header = content.split("\r\n", 1)[0] + assert "ACCOUNTID" in header + assert "REGION" in header + assert "REQUIREMENTS_ATTRIBUTES_FAMILYNAME" in header + assert "REQUIREMENTS_ATTRIBUTES_SECTION" in header + assert "REQUIREMENTS_ATTRIBUTES_APPLICABILITY" in header + assert "REQUIREMENTS_ATTRIBUTES_SECTIONTHREATMAPPINGS" in header + # Header should NOT contain Azure or GCP-only columns + assert "SUBSCRIPTIONID" not in header + assert "PROJECTID" not in header + + # Body checks: evaluated row + manual row + rows = [r for r in content.split("\r\n") if r] + assert len(rows) == 3 # header + evaluated + manual + assert "CCC.Core.CN01.AR01" in rows[1] + assert "PASS" in rows[1] + assert AWS_ACCOUNT_NUMBER in rows[1] + assert AWS_REGION_EU_WEST_1 in rows[1] + assert "CCC.IAM.CN01.AR01" in rows[2] + assert "MANUAL" in rows[2] + assert "manual_check" in rows[2] + # The frozen timestamp should appear + assert "2025-01-01 00:00:00" in rows[1] diff --git a/tests/lib/outputs/compliance/ccc/ccc_azure_test.py b/tests/lib/outputs/compliance/ccc/ccc_azure_test.py new file mode 100644 index 0000000000..a3a2f7d028 --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_azure_test.py @@ -0,0 +1,99 @@ +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.ccc.ccc_azure import CCC_Azure +from prowler.lib.outputs.compliance.ccc.models import CCC_AzureModel +from tests.lib.outputs.compliance.fixtures import CCC_AZURE_FIXTURE +from tests.lib.outputs.fixtures.fixtures import generate_finding_output +from tests.providers.azure.azure_fixtures import AZURE_SUBSCRIPTION_ID + +AZURE_LOCATION = "westeurope" + + +class TestAzureCCC: + def test_output_transform_evaluated_requirement(self): + findings = [ + generate_finding_output( + provider="azure", + compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}, + account_uid=AZURE_SUBSCRIPTION_ID, + region=AZURE_LOCATION, + ) + ] + + output = CCC_Azure(findings, CCC_AZURE_FIXTURE) + output_data = output.data[0] + + assert isinstance(output_data, CCC_AzureModel) + assert output_data.Provider == "azure" + assert output_data.SubscriptionId == AZURE_SUBSCRIPTION_ID + assert output_data.Location == AZURE_LOCATION + assert output_data.Description == CCC_AZURE_FIXTURE.Description + assert output_data.Requirements_Id == CCC_AZURE_FIXTURE.Requirements[0].Id + attribute = CCC_AZURE_FIXTURE.Requirements[0].Attributes[0] + assert output_data.Requirements_Attributes_FamilyName == attribute.FamilyName + assert output_data.Requirements_Attributes_Section == attribute.Section + assert ( + output_data.Requirements_Attributes_Applicability == attribute.Applicability + ) + assert output_data.Status == "PASS" + assert output_data.CheckId == "service_test_check_id" + + def test_output_transform_manual_requirement(self): + findings = [ + generate_finding_output( + provider="azure", + compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}, + account_uid=AZURE_SUBSCRIPTION_ID, + region=AZURE_LOCATION, + ) + ] + output = CCC_Azure(findings, CCC_AZURE_FIXTURE) + manual_row = output.data[1] + + assert isinstance(manual_row, CCC_AzureModel) + assert manual_row.Provider == "azure" + assert manual_row.SubscriptionId == "" + assert manual_row.Location == "" + assert manual_row.Requirements_Id == CCC_AZURE_FIXTURE.Requirements[1].Id + assert manual_row.Status == "MANUAL" + assert manual_row.CheckId == "manual" + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.ccc.ccc_azure.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + provider="azure", + compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}, + account_uid=AZURE_SUBSCRIPTION_ID, + region=AZURE_LOCATION, + ) + ] + output = CCC_Azure(findings, CCC_AZURE_FIXTURE) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + header = content.split("\r\n", 1)[0] + assert "SUBSCRIPTIONID" in header + assert "LOCATION" in header + assert "ACCOUNTID" not in header + assert "PROJECTID" not in header + assert "REGION" not in header + rows = [r for r in content.split("\r\n") if r] + assert len(rows) == 3 + assert "CCC.Core.CN01.AR01" in rows[1] + assert AZURE_SUBSCRIPTION_ID in rows[1] + assert "CCC.IAM.CN01.AR01" in rows[2] + assert "MANUAL" in rows[2] diff --git a/tests/lib/outputs/compliance/ccc/ccc_gcp_test.py b/tests/lib/outputs/compliance/ccc/ccc_gcp_test.py new file mode 100644 index 0000000000..9ba2127235 --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_gcp_test.py @@ -0,0 +1,99 @@ +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.ccc.ccc_gcp import CCC_GCP +from prowler.lib.outputs.compliance.ccc.models import CCC_GCPModel +from tests.lib.outputs.compliance.fixtures import CCC_GCP_FIXTURE +from tests.lib.outputs.fixtures.fixtures import generate_finding_output + +GCP_PROJECT_ID = "test-project" +GCP_LOCATION = "europe-west1" + + +class TestGCPCCC: + def test_output_transform_evaluated_requirement(self): + findings = [ + generate_finding_output( + provider="gcp", + compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}, + account_uid=GCP_PROJECT_ID, + region=GCP_LOCATION, + ) + ] + + output = CCC_GCP(findings, CCC_GCP_FIXTURE) + output_data = output.data[0] + + assert isinstance(output_data, CCC_GCPModel) + assert output_data.Provider == "gcp" + assert output_data.ProjectId == GCP_PROJECT_ID + assert output_data.Location == GCP_LOCATION + assert output_data.Description == CCC_GCP_FIXTURE.Description + assert output_data.Requirements_Id == CCC_GCP_FIXTURE.Requirements[0].Id + attribute = CCC_GCP_FIXTURE.Requirements[0].Attributes[0] + assert output_data.Requirements_Attributes_FamilyName == attribute.FamilyName + assert output_data.Requirements_Attributes_Section == attribute.Section + assert ( + output_data.Requirements_Attributes_Applicability == attribute.Applicability + ) + assert output_data.Status == "PASS" + assert output_data.CheckId == "service_test_check_id" + + def test_output_transform_manual_requirement(self): + findings = [ + generate_finding_output( + provider="gcp", + compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}, + account_uid=GCP_PROJECT_ID, + region=GCP_LOCATION, + ) + ] + output = CCC_GCP(findings, CCC_GCP_FIXTURE) + manual_row = output.data[1] + + assert isinstance(manual_row, CCC_GCPModel) + assert manual_row.Provider == "gcp" + assert manual_row.ProjectId == "" + assert manual_row.Location == "" + assert manual_row.Requirements_Id == CCC_GCP_FIXTURE.Requirements[1].Id + assert manual_row.Status == "MANUAL" + assert manual_row.CheckId == "manual" + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.ccc.ccc_gcp.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + provider="gcp", + compliance={"CCC-v2025.10": "CCC.Core.CN01.AR01"}, + account_uid=GCP_PROJECT_ID, + region=GCP_LOCATION, + ) + ] + output = CCC_GCP(findings, CCC_GCP_FIXTURE) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + header = content.split("\r\n", 1)[0] + assert "PROJECTID" in header + assert "LOCATION" in header + assert "ACCOUNTID" not in header + assert "SUBSCRIPTIONID" not in header + assert "REGION" not in header + rows = [r for r in content.split("\r\n") if r] + assert len(rows) == 3 + assert "CCC.Core.CN01.AR01" in rows[1] + assert GCP_PROJECT_ID in rows[1] + assert "CCC.IAM.CN01.AR01" in rows[2] + assert "MANUAL" in rows[2] diff --git a/tests/lib/outputs/compliance/ccc/ccc_table_test.py b/tests/lib/outputs/compliance/ccc/ccc_table_test.py new file mode 100644 index 0000000000..f647aff3d0 --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ccc.ccc import get_ccc_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="CCC"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestCCCTable: + """Test cases verifying multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CCC compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/cis/cis_7_0_m365_test.py b/tests/lib/outputs/compliance/cis/cis_7_0_m365_test.py new file mode 100644 index 0000000000..4fbebb49f6 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_7_0_m365_test.py @@ -0,0 +1,65 @@ +import json +from pathlib import Path + +from prowler.lib.check.compliance_models import ( + CIS_Requirement_Attribute_AssessmentStatus, + CIS_Requirement_Attribute_Profile, + Compliance, +) + +PROWLER_ROOT = Path(__file__).parents[5] / "prowler" +FRAMEWORK_PATH = PROWLER_ROOT / "compliance" / "m365" / "cis_7.0_m365.json" +M365_SERVICES_PATH = PROWLER_ROOT / "providers" / "m365" / "services" + +VALID_PROFILES = {p.value for p in CIS_Requirement_Attribute_Profile} +VALID_STATUSES = {s.value for s in CIS_Requirement_Attribute_AssessmentStatus} + + +def _existing_m365_checks() -> set: + return { + metadata.stem.replace(".metadata", "") + for metadata in M365_SERVICES_PATH.rglob("*.metadata.json") + } + + +class TestCIS7_0_M365: + def test_framework_is_discoverable(self): + frameworks = Compliance.get_bulk("m365") + assert "cis_7.0_m365" in frameworks + + def test_framework_metadata(self): + framework = Compliance.get_bulk("m365")["cis_7.0_m365"] + assert framework.Framework == "CIS" + assert framework.Provider == "M365" + assert framework.Version == "7.0" + assert framework.Name == "CIS Microsoft 365 Foundations Benchmark v7.0.0" + assert len(framework.Requirements) == 160 + + def test_requirement_ids_are_unique(self): + framework = Compliance.get_bulk("m365")["cis_7.0_m365"] + ids = [req.Id for req in framework.Requirements] + assert len(ids) == len(set(ids)) + + def test_each_requirement_has_one_attribute_with_section(self): + framework = Compliance.get_bulk("m365")["cis_7.0_m365"] + for req in framework.Requirements: + assert len(req.Attributes) == 1, f"{req.Id} must have exactly one attribute" + attribute = req.Attributes[0] + assert attribute.Section, f"{req.Id} has an empty Section" + assert attribute.Profile in VALID_PROFILES + assert attribute.AssessmentStatus in VALID_STATUSES + + def test_all_mapped_checks_exist(self): + # Every check referenced by the framework must resolve to a real M365 check, + # otherwise the requirement would never be evaluated. + existing = _existing_m365_checks() + framework = json.loads(FRAMEWORK_PATH.read_text()) + unknown = { + check + for req in framework["Requirements"] + for check in req["Checks"] + if check not in existing + } + assert ( + not unknown + ), f"Framework references unknown M365 checks: {sorted(unknown)}" diff --git a/tests/lib/outputs/compliance/cis/cis_aws_config_requirements_test.py b/tests/lib/outputs/compliance/cis/cis_aws_config_requirements_test.py new file mode 100644 index 0000000000..28cc3d2e1a --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_aws_config_requirements_test.py @@ -0,0 +1,89 @@ +"""Integration coverage for requirement-level config validation in the CIS AWS +CSV output. Requirement CIS 6.0 AWS 2.11 maps two configurable checks; when the +scan config is looser than the requirement demands, the requirement row must be +FAIL even if the underlying finding is PASS. The applied config is read from the +active provider's ``audit_config``.""" + +import json +import pathlib +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] +_CIS_6_0 = _REPO_ROOT / "prowler" / "compliance" / "aws" / "cis_6.0_aws.json" + + +def _load_cis_60() -> Compliance: + return Compliance(**json.load(open(_CIS_6_0))) + + +def _finding(check_id: str, status: str): + return SimpleNamespace( + provider="aws", + account_uid="123456789012", + region="us-east-1", + check_id=check_id, + status=status, + status_extended=f"{check_id} {status}", + resource_uid="arn:aws:iam::123456789012:user/bob", + resource_name="bob", + muted=False, + ) + + +def _rows_for(requirement_id, findings, audit_config): + with patch( + "prowler.providers.common.provider.Provider.get_global_provider" + ) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = AWSCIS(findings=findings, compliance=_load_cis_60(), file_path=None) + return [r for r in out._data if r.Requirements_Id == requirement_id] + + +class Test_CIS_AWS_Config_Requirements: + def test_loose_config_forces_requirement_fail(self): + findings = [_finding("iam_user_accesskey_unused", "PASS")] + rows = _rows_for("2.11", findings, {"max_unused_access_keys_days": 120}) + assert rows, "expected a row for requirement 2.11" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_valid_config_keeps_finding_status(self): + findings = [_finding("iam_user_accesskey_unused", "PASS")] + rows = _rows_for("2.11", findings, {"max_unused_access_keys_days": 45}) + assert rows + assert all(r.Status == "PASS" for r in rows) + assert all("Configuration not valid" not in r.StatusExtended for r in rows) + + def test_absent_config_assumes_default_ok(self): + findings = [_finding("iam_user_accesskey_unused", "PASS")] + rows = _rows_for("2.11", findings, {}) + assert rows + assert all(r.Status == "PASS" for r in rows) + + def test_other_requirements_unaffected(self): + # A finding for a check without ConfigRequirements keeps its status even + # when the config is loose for a different requirement. + findings = [_finding("iam_rotate_access_key_90_days", "PASS")] + rows = _rows_for("2.13", findings, {"max_unused_access_keys_days": 120}) + assert rows + assert all(r.Status == "PASS" for r in rows) + + def test_region_mute_constraint_forces_fail(self): + # Requirement 5.16 maps securityhub_enabled with a + # mute_non_default_regions == false constraint: muting non-default + # regions makes the PASS untrustworthy, so the row must be FAIL. + findings = [_finding("securityhub_enabled", "PASS")] + rows = _rows_for("5.16", findings, {"mute_non_default_regions": True}) + assert rows, "expected a row for requirement 5.16" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_region_mute_constraint_default_passes(self): + findings = [_finding("securityhub_enabled", "PASS")] + rows = _rows_for("5.16", findings, {"mute_non_default_regions": False}) + assert rows + assert all(r.Status == "PASS" for r in rows) diff --git a/tests/lib/outputs/compliance/cis/cis_azure_config_requirements_test.py b/tests/lib/outputs/compliance/cis/cis_azure_config_requirements_test.py new file mode 100644 index 0000000000..a34402ec77 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_azure_config_requirements_test.py @@ -0,0 +1,75 @@ +"""Integration coverage for the ``subset`` set-operator in a CSV output. + +CIS Azure 5.0 requirement 9.1.3 maps ``storage_smb_channel_encryption_with_secure_algorithm`` +with a ``recommended_smb_channel_encryption_algorithms subset ["AES-256-GCM"]`` +constraint: widening the allowlist with a weaker algorithm makes the PASS +untrustworthy, so the requirement row must be FAIL. Exercises the shared override +path through a per-provider CSV class (not just OCSF).""" + +import json +import pathlib +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] +_CIS_5_0_AZURE = _REPO_ROOT / "prowler" / "compliance" / "azure" / "cis_5.0_azure.json" +_REQUIREMENT_ID = "9.1.3" +_CHECK = "storage_smb_channel_encryption_with_secure_algorithm" + + +def _load(): + return Compliance(**json.load(open(_CIS_5_0_AZURE))) + + +def _finding(check_id, status): + return SimpleNamespace( + provider="azure", + account_uid="00000000-0000-0000-0000-000000000000", + region="eastus", + check_id=check_id, + status=status, + status_extended=f"{check_id} {status}", + resource_uid="/subscriptions/x/storageAccounts/sa", + resource_name="sa", + muted=False, + ) + + +def _rows_for(audit_config): + findings = [_finding(_CHECK, "PASS")] + with patch( + "prowler.providers.common.provider.Provider.get_global_provider" + ) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = AzureCIS(findings=findings, compliance=_load(), file_path=None) + return [r for r in out._data if r.Requirements_Id == _REQUIREMENT_ID] + + +class Test_CIS_Azure_Subset_Constraint: + def test_widened_allowlist_forces_fail(self): + rows = _rows_for( + { + "recommended_smb_channel_encryption_algorithms": [ + "AES-128-CCM", + "AES-256-GCM", + ] + } + ) + assert rows, f"expected a row for requirement {_REQUIREMENT_ID}" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_secure_allowlist_keeps_pass(self): + rows = _rows_for( + {"recommended_smb_channel_encryption_algorithms": ["AES-256-GCM"]} + ) + assert rows + assert all(r.Status == "PASS" for r in rows) + + def test_absent_config_keeps_pass(self): + rows = _rows_for({}) + assert rows + assert all(r.Status == "PASS" for r in rows) diff --git a/tests/lib/outputs/compliance/cis/cis_table_test.py b/tests/lib/outputs/compliance/cis/cis_table_test.py new file mode 100644 index 0000000000..c47e1387a0 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_table_test.py @@ -0,0 +1,162 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.cis.cis import get_cis_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(section, profile="Level 1"): + return SimpleNamespace(Section=section, Profile=profile) + + +def _make_compliance(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance with the given (section, profile) attrs.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +def _make_compliance_multi_req(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance where each attr is its own requirement, + simulating a check that appears in several requirements.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=[attr]) for attr in attributes], + ) + + +class TestCISTable: + """Verify multi-section counting and provider-column attribution for the CIS compliance table.""" + + def test_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several sections must increment the + per-section Muted column for every section, not only the first seen. + + CIS counts FAIL/PASS through Level 1/Level 2 buckets, so only the Muted + per-section count was affected by the undercount bug. + """ + bulk_metadata = { + # check_a is muted and belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM"), _attr("2 Logging")]) + ] + ), + # A real (non-muted) finding so the table is rendered. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("1 IAM")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 in their last cell. + # Before the fix only the first section seen got incremented. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_same_section_level_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same section and profile must count once for that section/level, + not once per requirement (FAIL(1), never FAIL(2)).""" + bulk_metadata = { + # check_a is a single FAIL mapped to two requirements, both in the + # same section "1 IAM" and the same profile "Level 1". + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance_multi_req("aws", [_attr("1 IAM"), _attr("1 IAM")]) + ] + ), + # A second finding in another section so the table renders. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("2 Logging")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The "1 IAM" row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CIS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The trailing unrelated framework's provider must not leak in. + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/compliance_test.py b/tests/lib/outputs/compliance/compliance_test.py index bb6a7e4089..f38c783d42 100644 --- a/tests/lib/outputs/compliance/compliance_test.py +++ b/tests/lib/outputs/compliance/compliance_test.py @@ -442,3 +442,123 @@ class TestComplianceOutput: ) assert compliance_output.file_extension == ".csv" + + +class TestComplianceCheckHelperModule: + """Tests for the new ``compliance_check`` leaf module that hosts + ``get_check_compliance``. + + This module exists to break the cyclic import chain + ``finding -> compliance.compliance -> universal.* -> finding`` that + CodeQL flagged. It must be: + - importable directly without pulling in the universal pipeline + - re-exported by ``compliance.compliance`` for backward compatibility + - the SAME function object, regardless of import path + """ + + def test_module_is_importable_directly(self): + """The helper module must be importable on its own — it is the + leaf used by ``finding.py`` to break the cyclic import chain.""" + from prowler.lib.outputs.compliance import compliance_check + + assert hasattr(compliance_check, "get_check_compliance") + assert callable(compliance_check.get_check_compliance) + + def test_helper_module_only_depends_on_check_models_and_logger(self): + """The helper must not pull in universal pipeline modules; that + was the whole point of extracting it. Inspecting the module's + own imports keeps it honest without polluting ``sys.modules``.""" + import inspect + + from prowler.lib.outputs.compliance import compliance_check + + source = inspect.getsource(compliance_check) + # Only these two prowler imports are allowed in the leaf module + assert "from prowler.lib.check.models import Check_Report" in source + assert "from prowler.lib.logger import logger" in source + # And NOT these (would re-introduce the cycle): + assert "from prowler.lib.outputs.compliance.universal" not in source + assert "from prowler.lib.outputs.finding" not in source + assert "from prowler.lib.outputs.ocsf" not in source + + def test_re_export_from_compliance_compliance(self): + """``compliance.compliance.get_check_compliance`` must point to + the same function as ``compliance.compliance_check.get_check_compliance``.""" + from prowler.lib.outputs.compliance.compliance import ( + get_check_compliance as via_compliance, + ) + from prowler.lib.outputs.compliance.compliance_check import ( + get_check_compliance as via_helper, + ) + + assert via_compliance is via_helper + + def test_re_export_from_finding_module(self): + """``finding.get_check_compliance`` must point to the same + function. Test mocks rely on this attribute existing on the + ``prowler.lib.outputs.finding`` module.""" + from prowler.lib.outputs.compliance.compliance_check import ( + get_check_compliance as via_helper, + ) + from prowler.lib.outputs.finding import get_check_compliance as via_finding + + assert via_finding is via_helper + + def test_returns_empty_dict_on_unknown_check(self): + """Sanity test of the function logic via the helper module.""" + from prowler.lib.outputs.compliance.compliance_check import ( + get_check_compliance, + ) + + finding = mock.MagicMock() + finding.check_metadata.CheckID = "unknown_check_id" + result = get_check_compliance(finding, "aws", {}) + assert result == {} + + def test_filters_by_provider(self): + """The function returns frameworks only for the matching provider.""" + from prowler.lib.outputs.compliance.compliance_check import ( + get_check_compliance, + ) + + compliance_aws = mock.MagicMock( + Framework="CIS", + Version="1.4", + Provider="AWS", + Requirements=[mock.MagicMock(Id="2.1.3")], + ) + compliance_azure = mock.MagicMock( + Framework="CIS", + Version="2.0", + Provider="Azure", + Requirements=[mock.MagicMock(Id="9.1")], + ) + finding = mock.MagicMock() + finding.check_metadata.CheckID = "shared_check" + bulk = { + "shared_check": mock.MagicMock( + Compliance=[compliance_aws, compliance_azure] + ) + } + + # Only AWS frameworks come back + result = get_check_compliance(finding, "aws", bulk) + assert "CIS-1.4" in result + assert "CIS-2.0" not in result + + def test_returns_empty_dict_on_exception(self): + """If iteration raises, the function logs the error and returns + an empty dict (defensive behaviour).""" + from prowler.lib.outputs.compliance.compliance_check import ( + get_check_compliance, + ) + + # bulk_checks_metadata that raises when accessed → defensive path + class Boom: + def __contains__(self, _key): + raise RuntimeError("boom") + + finding = mock.MagicMock() + finding.check_metadata.CheckID = "any" + result = get_check_compliance(finding, "aws", Boom()) + assert result == {} diff --git a/tests/lib/outputs/compliance/config_status_dispatch_coverage_test.py b/tests/lib/outputs/compliance/config_status_dispatch_coverage_test.py new file mode 100644 index 0000000000..a4f09551a5 --- /dev/null +++ b/tests/lib/outputs/compliance/config_status_dispatch_coverage_test.py @@ -0,0 +1,168 @@ +"""End-to-end coverage: every shipped framework that declares ``ConfigRequirements`` +must apply the config-status override through the *real* table dispatcher. + +The companion ``config_status_renderer_coverage_test`` proves no renderer file +ignores the override. This test closes the other half of the gap that let +``okta_idaas_stig`` ship ConfigRequirements its renderers never applied: it walks +every per-provider compliance JSON that declares constraints, routes a synthetic +PASS finding through ``display_compliance_table`` exactly as a scan would, and +asserts the requirement is forced to FAIL when the scan's config is too loose. + +It runs each framework twice — once with a config that *violates* the first +constraint and once with a config that *satisfies* it — and asserts the violating +run reports strictly more failures. Comparing the two runs is language-neutral +(only the parenthesised counts are read, never the localized PASS/FAIL labels) and +self-checking (a renderer that ignored the override would report equal counts). + +Universal (multi-provider) frameworks render through a different path and are +covered by ``universal/universal_table_config_requirements_test.py``. +""" + +import glob +import io +import json +import pathlib +import re +import tempfile +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance import display_compliance_table + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] +_COMPLIANCE_DIR = _REPO_ROOT / "prowler" / "compliance" + +# Per-provider JSONs live in a provider subdir; top-level files are universal. +_PROVIDER_JSONS = sorted(glob.glob(str(_COMPLIANCE_DIR / "*" / "*.json"))) + + +def _first_constraint(data): + """Return ``(check, config_key, operator, value)`` of the first declared + constraint, or ``None`` when the framework declares none.""" + for requirement in data.get("Requirements", []): + constraints = requirement.get("ConfigRequirements") + if constraints: + c = constraints[0] + return c["Check"], c["ConfigKey"], c["Operator"], c["Value"] + return None + + +def _violating_value(operator, value): + """A config value that breaks the constraint (forces the requirement FAIL).""" + if operator == "lte": + return value + 1 + if operator == "gte": + return value - 1 + if operator == "eq": + if isinstance(value, bool): + return not value + if isinstance(value, (int, float)): + return value + 1 + return f"{value}__violates__" + if operator == "in": + return "__not_in_allowed_set__" + if operator == "subset": + return list(value) + ["__extra_not_allowed__"] + if operator == "superset": + return [] + raise AssertionError(f"unhandled operator {operator}") + + +def _satisfying_value(operator, value): + """A config value that satisfies the constraint (requirement keeps its status).""" + if operator in ("lte", "gte", "eq"): + return value + if operator == "in": + return value[0] + if operator == "subset": + return list(value) + if operator == "superset": + return list(value) + raise AssertionError(f"unhandled operator {operator}") + + +def _fail_count(findings, bulk, name, provider, applied_config): + """Render the framework table with ``applied_config`` and return the FAIL + count from the overview, or ``None`` when the table renders nothing.""" + + def _not_implemented(*_a, **_k): + raise NotImplementedError + + fake_provider = SimpleNamespace( + audit_config=applied_config, + type=provider, + display_compliance_table=_not_implemented, + ) + buffer = io.StringIO() + with tempfile.TemporaryDirectory() as tmp: + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=fake_provider, + ): + with redirect_stdout(buffer): + display_compliance_table(findings, bulk, name, "output", tmp, False) + plain = re.sub(r"\x1b\[[0-9;]*m", "", buffer.getvalue()) + # The overview's first parenthesised count is always the FAIL tally, in + # every renderer and every locale (only the label is translated). + counts = re.findall(r"\(\s*(\d+)\s*\)", plain) + return int(counts[0]) if counts else None + + +def _frameworks_with_constraints(): + for path in _PROVIDER_JSONS: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if _first_constraint(data): + name = pathlib.Path(path).stem + yield pytest.param(path, data, id=name) + + +@pytest.mark.parametrize("path, data", list(_frameworks_with_constraints())) +def test_framework_constraints_force_fail_through_dispatcher(path, data): + provider = pathlib.Path(path).parent.name + name = pathlib.Path(path).stem + check, config_key, operator, value = _first_constraint(data) + compliance = Compliance(**data) + + def _finding(): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check), + check_id=check, + status="PASS", + muted=False, + ) + + # Two findings: the renderers only print the table when more than one + # finding maps to the framework. + findings = [_finding(), _finding()] + bulk = {check: SimpleNamespace(Compliance=[compliance])} + + strict = _fail_count( + findings, bulk, name, provider, {config_key: _satisfying_value(operator, value)} + ) + loose = _fail_count( + findings, bulk, name, provider, {config_key: _violating_value(operator, value)} + ) + + if strict is None and loose is None: + # The framework's renderer gates rendering on its name/version and does + # not paint a table for this framework id. There is no status to assert + # here; the renderer itself is still proven config-aware by + # config_status_renderer_coverage_test. Surfaced rather than silently + # passed so the skip is visible. + pytest.skip(f"{name}: renderer paints no table for this framework id") + + assert strict == 0, ( + f"{name}: PASS findings reported {strict} failures with a compliant " + "config; the control run should be clean." + ) + assert loose and loose > 0, ( + f"{name}: a PASS finding whose requirement maps {check} ran with " + f"{config_key} too loose for the constraint ({operator} {value}) was NOT " + "forced to FAIL. The framework declares ConfigRequirements its renderer " + "fails to apply — wire it through the config-status helpers." + ) diff --git a/tests/lib/outputs/compliance/config_status_renderer_coverage_test.py b/tests/lib/outputs/compliance/config_status_renderer_coverage_test.py new file mode 100644 index 0000000000..ac0981c941 --- /dev/null +++ b/tests/lib/outputs/compliance/config_status_renderer_coverage_test.py @@ -0,0 +1,71 @@ +"""Guard that every dedicated compliance renderer applies the config-status rule. + +Declaring ``ConfigRequirements`` in a framework JSON is inert unless the renderer +that builds its output actually evaluates them. A requirement whose configurable +checks ran with a config too loose to trust must be forced to FAIL; that override +lives in ``prowler.lib.check.compliance_config_eval`` and every renderer that +emits a finding's status (CSV transform, CLI table, OCSF) must route through it. + +This test statically asserts the invariant: any renderer that reads a finding's +raw ``status`` must also reference one of the config-status helpers. It mirrors +the manual audit that caught ``okta_idaas_stig`` shipping ConfigRequirements that +its CSV and table renderers never applied, so the gap cannot silently reopen. +""" + +import pathlib + +import pytest + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] +_RENDERER_DIR = _REPO_ROOT / "prowler" / "lib" / "outputs" / "compliance" + +# Base/dispatch modules that orchestrate renderers but never emit a status row. +_EXCLUDED_BASENAMES = { + "__init__.py", + "models.py", + "compliance.py", + "compliance_check.py", + "compliance_output.py", +} + +# Any one of these, present in the source, means the renderer wires the override. +_CONFIG_STATUS_HELPERS = ( + "apply_config_status", + "build_requirement_config_status", + "resolve_requirement_config_status", + "get_effective_status", +) + +# A renderer builds its output from the finding's raw status via one of these. +_RAW_STATUS_MARKERS = ("finding.status", "finding.status_extended") + + +def _renderer_sources(): + for path in sorted(_RENDERER_DIR.glob("**/*.py")): + if path.name in _EXCLUDED_BASENAMES or "__pycache__" in path.parts: + continue + yield path + + +@pytest.mark.parametrize( + "renderer_path", + [ + pytest.param(p, id=str(p.relative_to(_RENDERER_DIR))) + for p in _renderer_sources() + ], +) +def test_renderer_emitting_status_applies_config_status(renderer_path): + source = renderer_path.read_text(encoding="utf-8") + + uses_raw_status = any(marker in source for marker in _RAW_STATUS_MARKERS) + if not uses_raw_status: + pytest.skip("renderer does not emit a finding's raw status") + + applies_config_status = any(helper in source for helper in _CONFIG_STATUS_HELPERS) + assert applies_config_status, ( + f"{renderer_path.relative_to(_REPO_ROOT)} emits a finding's raw status but " + "never applies the config-status override. Route it through " + "apply_config_status / build_requirement_config_status (CSV/OCSF) or " + "resolve_requirement_config_status / get_effective_status (CLI table), " + "otherwise its ConfigRequirements are silently ignored." + ) diff --git a/tests/lib/outputs/compliance/display_compliance_table_test.py b/tests/lib/outputs/compliance/display_compliance_table_test.py new file mode 100644 index 0000000000..bd854d6d7d --- /dev/null +++ b/tests/lib/outputs/compliance/display_compliance_table_test.py @@ -0,0 +1,238 @@ +"""Tests for display_compliance_table dispatch logic. + +Validates that each compliance framework name is routed to the correct +table renderer via startswith matching, and that the universal early-return +takes precedence when applicable. +""" + +from unittest.mock import patch + +import pytest + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.compliance import display_compliance_table + +MODULE = "prowler.lib.outputs.compliance.compliance" + +# Common args shared by every call — the actual values don't matter +# because we mock the downstream renderers. +_COMMON = dict( + findings=[], + bulk_checks_metadata={}, + output_filename="out", + output_directory="/tmp", + compliance_overview=False, +) + + +# ── Dispatch to legacy table renderers ─────────────────────────────── + + +class TestDispatchStartswith: + """Each framework prefix must route to exactly one renderer.""" + + @pytest.mark.parametrize( + "framework_name", + [ + "cis_1.4_aws", + "cis_2.0_azure", + "cis_3.0_gcp", + "cis_6.0_m365", + "cis_1.10_kubernetes", + ], + ) + @patch(f"{MODULE}.get_cis_table") + def test_cis_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + @pytest.mark.parametrize( + "framework_name", + ["ens_rd2022_aws", "ens_rd2022_azure", "ens_rd2022_gcp"], + ) + @patch(f"{MODULE}.get_ens_table") + def test_ens_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + @pytest.mark.parametrize( + "framework_name", + ["mitre_attack_aws", "mitre_attack_azure", "mitre_attack_gcp"], + ) + @patch(f"{MODULE}.get_mitre_attack_table") + def test_mitre_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + @pytest.mark.parametrize( + "framework_name", + ["kisa_isms_p_2023_aws", "kisa_isms_p_2023_korean_aws"], + ) + @patch(f"{MODULE}.get_kisa_ismsp_table") + def test_kisa_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + @pytest.mark.parametrize( + "framework_name", + [ + "prowler_threatscore_aws", + "prowler_threatscore_azure", + "prowler_threatscore_gcp", + "prowler_threatscore_kubernetes", + "prowler_threatscore_m365", + "prowler_threatscore_alibabacloud", + ], + ) + @patch(f"{MODULE}.get_prowler_threatscore_table") + def test_threatscore_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + @pytest.mark.parametrize( + "framework_name", + ["c5_aws", "c5_azure", "c5_gcp"], + ) + @patch(f"{MODULE}.get_c5_table") + def test_c5_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + @pytest.mark.parametrize( + "framework_name", + ["okta_idaas_stig_v1r2_okta"], + ) + @patch(f"{MODULE}.get_okta_idaas_stig_table") + def test_okta_idaas_stig_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + @pytest.mark.parametrize( + "framework_name", + [ + "soc2_aws", + "hipaa_aws", + "gdpr_aws", + "nist_800_53_revision_4_aws", + "pci_3.2.1_aws", + "iso27001_2013_aws", + "aws_well_architected_framework_security_pillar_aws", + "fedramp_low_revision_4_aws", + "cisa_aws", + ], + ) + @patch(f"{MODULE}.get_generic_compliance_table") + def test_generic_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + + +# ── No false matches (the old `in` bug) ───────────────────────────── + + +class TestNoFalseSubstringMatches: + """Frameworks that previously could false-match with `in` must NOT + be routed to the wrong renderer now that we use startswith.""" + + @patch(f"{MODULE}.get_ens_table") + @patch(f"{MODULE}.get_generic_compliance_table") + def test_cisa_does_not_match_cis(self, mock_generic, mock_cis): + """'cisa_aws' must NOT match startswith('cis_').""" + display_compliance_table(compliance_framework="cisa_aws", **_COMMON) + mock_generic.assert_called_once() + mock_cis.assert_not_called() + + @patch(f"{MODULE}.get_prowler_threatscore_table") + @patch(f"{MODULE}.get_generic_compliance_table") + def test_threatscore_prefix_not_partial(self, mock_generic, mock_ts): + """A hypothetical 'threatscore_custom_aws' must NOT match + startswith('prowler_threatscore_').""" + display_compliance_table( + compliance_framework="threatscore_custom_aws", **_COMMON + ) + mock_generic.assert_called_once() + mock_ts.assert_not_called() + + @patch(f"{MODULE}.get_ens_table") + @patch(f"{MODULE}.get_prowler_threatscore_table") + def test_prowler_threatscore_does_not_match_ens(self, mock_ts, mock_ens): + """'prowler_threatscore_aws' must hit threatscore, never ens.""" + display_compliance_table( + compliance_framework="prowler_threatscore_aws", **_COMMON + ) + mock_ts.assert_called_once() + mock_ens.assert_not_called() + + +# ── Universal early-return ─────────────────────────────────────────── + + +class TestUniversalEarlyReturn: + """The universal path must take precedence over the elif chain.""" + + @staticmethod + def _make_fw(): + return ComplianceFramework( + framework="CIS", + name="CIS", + provider="AWS", + version="5.0", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={}, + checks={"aws": ["check_a"]}, + ), + ], + outputs=OutputsConfig(table_config=TableConfig(group_by="_default")), + ) + + @patch(f"{MODULE}.get_universal_table") + @patch(f"{MODULE}.get_cis_table") + def test_universal_takes_precedence_over_cis(self, mock_cis, mock_universal): + """A CIS framework in universal_frameworks with TableConfig must + use the universal renderer, not get_cis_table.""" + fw = self._make_fw() + display_compliance_table( + compliance_framework="cis_5.0_aws", + universal_frameworks={"cis_5.0_aws": fw}, + **_COMMON, + ) + mock_universal.assert_called_once() + mock_cis.assert_not_called() + + @patch(f"{MODULE}.get_universal_table") + @patch(f"{MODULE}.get_cis_table") + def test_falls_through_without_table_config(self, mock_cis, mock_universal): + """If the universal framework has no TableConfig, fall through + to the legacy elif chain.""" + fw = self._make_fw() + fw.outputs = None + display_compliance_table( + compliance_framework="cis_5.0_aws", + universal_frameworks={"cis_5.0_aws": fw}, + **_COMMON, + ) + mock_cis.assert_called_once() + mock_universal.assert_not_called() + + @patch(f"{MODULE}.get_universal_table") + @patch(f"{MODULE}.get_generic_compliance_table") + def test_falls_through_when_not_in_universal_dict( + self, mock_generic, mock_universal + ): + """If universal_frameworks is empty, fall through to legacy.""" + display_compliance_table( + compliance_framework="soc2_aws", + universal_frameworks={}, + **_COMMON, + ) + mock_generic.assert_called_once() + mock_universal.assert_not_called() diff --git a/tests/lib/outputs/compliance/ens/ens_aws_config_requirements_test.py b/tests/lib/outputs/compliance/ens/ens_aws_config_requirements_test.py new file mode 100644 index 0000000000..b09d9bc0b3 --- /dev/null +++ b/tests/lib/outputs/compliance/ens/ens_aws_config_requirements_test.py @@ -0,0 +1,61 @@ +"""Integration coverage proving the shared requirement-level config validation +is applied beyond CIS. ENS RD2022 AWS requirement ``op.exp.1.aws.cfg.1`` maps +``config_recorder_all_regions_enabled`` with a ``mute_non_default_regions == +false`` constraint; muting non-default regions makes a PASS untrustworthy, so +the requirement row must be FAIL even when the finding PASSes. The applied +config is read from the active provider's ``audit_config``.""" + +import json +import pathlib +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] +_ENS = _REPO_ROOT / "prowler" / "compliance" / "aws" / "ens_rd2022_aws.json" +_REQUIREMENT_ID = "op.exp.1.aws.cfg.1" + + +def _load_ens() -> Compliance: + return Compliance(**json.load(open(_ENS))) + + +def _finding(check_id: str, status: str): + return SimpleNamespace( + provider="aws", + account_uid="123456789012", + region="us-east-1", + check_id=check_id, + status=status, + status_extended=f"{check_id} {status}", + resource_uid="arn:aws:config:us-east-1:123456789012:recorder/default", + resource_name="default", + muted=False, + ) + + +def _rows_for(requirement_id, findings, audit_config): + with patch( + "prowler.providers.common.provider.Provider.get_global_provider" + ) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = AWSENS(findings=findings, compliance=_load_ens(), file_path=None) + return [r for r in out._data if r.Requirements_Id == requirement_id] + + +class Test_ENS_AWS_Config_Requirements: + def test_region_mute_constraint_forces_fail(self): + findings = [_finding("config_recorder_all_regions_enabled", "PASS")] + rows = _rows_for(_REQUIREMENT_ID, findings, {"mute_non_default_regions": True}) + assert rows, f"expected a row for requirement {_REQUIREMENT_ID}" + assert all(r.Status == "FAIL" for r in rows) + assert all("Configuration not valid" in r.StatusExtended for r in rows) + + def test_default_config_keeps_finding_status(self): + findings = [_finding("config_recorder_all_regions_enabled", "PASS")] + rows = _rows_for(_REQUIREMENT_ID, findings, {}) + assert rows + assert all(r.Status == "PASS" for r in rows) + assert all("Configuration not valid" not in r.StatusExtended for r in rows) diff --git a/tests/lib/outputs/compliance/ens/ens_table_test.py b/tests/lib/outputs/compliance/ens/ens_table_test.py new file mode 100644 index 0000000000..2373885dbc --- /dev/null +++ b/tests/lib/outputs/compliance/ens/ens_table_test.py @@ -0,0 +1,235 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ens.ens import get_ens_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(marco, categoria, tipo="requisito", nivel="alto"): + return SimpleNamespace(Marco=marco, Categoria=categoria, Tipo=tipo, Nivel=nivel) + + +def _make_compliance(provider, attributes, framework="ENS"): + """Build a per-check ENS compliance with the given marco/categoria attrs.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +class TestENSTable: + """Test cases for ENS compliance table rendering. + + Verify multi-marco counting and provider-column attribution for the + compliance table. + """ + + def test_no_cumple_marked_in_every_marco(self, capsys, tmp_path): + """A single failing finding mapped to several marcos must mark every + one of them as NO CUMPLE, not only the first marco seen.""" + bulk_metadata = { + # check_a fails and belongs to two distinct marcos/categorias. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + # A passing finding so the overview total reaches 2. + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the failing finding maps to must read NO CUMPLE. + # Before the fix only the first marco was marked, the second stayed + # CUMPLE. Anchor the assertion to the actual marco rows (not the + # overview header line which also mentions NO CUMPLE). + op_row = [ + line + for line in plain.splitlines() + if "operacional/control de acceso" in line + ] + org_row = [ + line + for line in plain.splitlines() + if "organizativo/politica de seguridad" in line + ] + assert len(op_row) == 1 and "NO CUMPLE" in op_row[0] + assert len(org_row) == 1 and "NO CUMPLE" in org_row[0] + + def test_recomendacion_does_not_set_no_cumple(self, capsys, tmp_path): + """A FAIL on a 'recomendacion' attribute must not flip a marco to + NO CUMPLE (this path is intentionally excluded from the fix).""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr( + "operacional", "control de acceso", tipo="recomendacion" + ) + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("organizativo", "politica")]) + ] + ), + # A regular (non-recomendacion) check so the results table renders + # at least one marco row and the assertion below is not vacuous. + "check_c": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "continuidad")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + _make_finding("check_c", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The recomendacion FAIL must not appear as a NO CUMPLE marco row in the + # results table (the overview header line is allowed to mention it). + marco_rows = [ + line + for line in plain.splitlines() + if "operacional" in line or "organizativo" in line + ] + # Guard against a vacuous pass: the table must actually render rows. + assert marco_rows + assert all("NO CUMPLE" not in line for line in marco_rows) + + def test_muted_multi_marco_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several marcos must increment the + per-marco Muted column for every marco, not only the first seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the muted finding maps to must report a Muted count of + # 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Proveedor column must come from the matched ENS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index 7b29411663..a8fc7aa7e5 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -1,5 +1,7 @@ from prowler.lib.check.compliance_models import ( + ASDEssentialEight_Requirement_Attribute, AWS_Well_Architected_Requirement_Attribute, + CCC_Requirement_Attribute, CIS_Requirement_Attribute, Compliance, Compliance_Requirement, @@ -14,6 +16,7 @@ from prowler.lib.check.compliance_models import ( Mitre_Requirement_Attribute_Azure, Mitre_Requirement_Attribute_GCP, Prowler_ThreatScore_Requirement_Attribute, + STIG_Requirement_Attribute, ) CIS_1_4_AWS = Compliance( @@ -126,7 +129,7 @@ CIS_2_0_GCP = Compliance( Description="This CIS Benchmark is the product of a community consensus process and consists of secure configuration guidelines developed for Google Cloud Computing Platform", Requirements=[ Compliance_Requirement( - Checks=["apikeys_key_exits"], + Checks=["apikeys_key_exits", "service_test_check_id"], Id="2.13", Description="Ensure That Microsoft Defender for Databases Is Set To 'On'", Attributes=[ @@ -175,7 +178,7 @@ CIS_1_8_KUBERNETES = Compliance( Description="This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.", Requirements=[ Compliance_Requirement( - Checks=["apiserver_always_pull_images_plugin"], + Checks=["apiserver_always_pull_images_plugin", "service_test_check_id"], Id="1.1.3", Description="Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive", Attributes=[ @@ -257,6 +260,7 @@ CIS_4_0_M365 = Compliance( Compliance_Requirement( Checks=[ "mfa_delete_enabled", + "service_test_check_id", ], Id="2.1.3", Description="Ensure MFA Delete is enabled on S3 buckets", @@ -334,6 +338,7 @@ MITRE_ATTACK_AWS = Compliance( "inspector2_active_findings_exist", "awslambda_function_not_publicly_accessible", "ec2_instance_public_ip", + "service_test_check_id", ], ), Mitre_Requirement( @@ -410,6 +415,7 @@ MITRE_ATTACK_AZURE = Compliance( "defender_ensure_notify_emails_to_owners", "defender_ensure_system_updates_are_applied", "defender_ensure_wdatp_is_enabled", + "service_test_check_id", ], ), Mitre_Requirement( @@ -465,6 +471,7 @@ MITRE_ATTACK_GCP = Compliance( "compute_instance_public_ip", "compute_public_address_shodan", "kms_key_not_publicly_accessible", + "service_test_check_id", ], ), Mitre_Requirement( @@ -512,7 +519,7 @@ ENS_RD2022_AWS = Compliance( Dependencias=[], ) ], - Checks=["cloudtrail_log_file_validation_enabled"], + Checks=["cloudtrail_log_file_validation_enabled", "service_test_check_id"], ), Compliance_Requirement( Id="op.exp.8.aws.ct.4", @@ -560,7 +567,7 @@ ENS_RD2022_AZURE = Compliance( Dependencias=[], ) ], - Checks=["cloudtrail_log_file_validation_enabled"], + Checks=["cloudtrail_log_file_validation_enabled", "service_test_check_id"], ), Compliance_Requirement( Id="op.exp.8.azure.ct.4", @@ -607,7 +614,7 @@ ENS_RD2022_GCP = Compliance( Dependencias=[], ) ], - Checks=["cloudtrail_log_file_validation_enabled"], + Checks=["cloudtrail_log_file_validation_enabled", "service_test_check_id"], ), Compliance_Requirement( Id="op.exp.8.gcp.ct.4", @@ -664,7 +671,10 @@ AWS_WELL_ARCHITECTED = Compliance( ImplementationGuidanceUrl="https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_multi_accounts.html#implementation-guidance.", ) ], - Checks=["organizations_account_part_of_organizations"], + Checks=[ + "organizations_account_part_of_organizations", + "service_test_check_id", + ], ), Compliance_Requirement( Id="SEC01-BP02", @@ -707,7 +717,7 @@ ISO27001_2013_AWS = Compliance( Check_Summary="Setup Encryption at rest for RDS instances", ) ], - Checks=["rds_instance_storage_encrypted"], + Checks=["rds_instance_storage_encrypted", "service_test_check_id"], ), Compliance_Requirement( Id="A.10.2", @@ -757,6 +767,7 @@ NIST_800_53_REVISION_4_AWS = Compliance( "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", "securityhub_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -813,6 +824,7 @@ KISA_ISMSP_AWS = Compliance( Checks=[ "cloudwatch_log_metric_filter_authentication_failures", "cognito_user_pool_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -870,6 +882,7 @@ PROWLER_THREATSCORE_AWS = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -914,6 +927,7 @@ PROWLER_THREATSCORE_AZURE = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -958,6 +972,7 @@ PROWLER_THREATSCORE_GCP = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -1002,6 +1017,7 @@ PROWLER_THREATSCORE_M365 = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -1022,3 +1038,268 @@ PROWLER_THREATSCORE_M365 = Compliance( ), ], ) + + +# CCC fixtures cover the three providers Prowler ships catalogs for. Each +# fixture has one auto-evaluated requirement (with Checks) and one manual +# requirement (Checks=[]) so test suites can exercise both paths. +CCC_AWS_FIXTURE = Compliance( + Framework="CCC", + Name="Common Cloud Controls Catalog (CCC)", + Provider="AWS", + Version="v2025.10", + Description="Common Cloud Controls Catalog (CCC) for AWS", + Requirements=[ + Compliance_Requirement( + Checks=["service_test_check_id"], + Id="CCC.Core.CN01.AR01", + Description="When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + Attributes=[ + CCC_Requirement_Attribute( + FamilyName="Data", + FamilyDescription="The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + Section="CCC.Core.CN01 Encrypt Data for Transmission", + SubSection="", + SubSectionObjective="Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + Applicability=["tlp-green", "tlp-amber", "tlp-red"], + Recommendation="Most cloud services enable TLS 1.3 by default.", + SectionThreatMappings=[ + {"ReferenceId": "CCC", "Identifiers": ["CCC.Core.TH02"]} + ], + SectionGuidelineMappings=[ + {"ReferenceId": "CCM", "Identifiers": ["CEK-03", "CEK-04"]} + ], + ) + ], + ), + Compliance_Requirement( + Checks=[], + Id="CCC.IAM.CN01.AR01", + Description="When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating credentials or generating temporary session tokens.", + Attributes=[ + CCC_Requirement_Attribute( + FamilyName="Identity and Access Management", + FamilyDescription="Controls that restrict who can access and modify IAM resources.", + Section="CCC.IAM.CN01 Restrict IAM User Credentials Creation", + SubSection="", + SubSectionObjective="Prevent non-administrative principals from creating new long-lived credentials.", + Applicability=["tlp-clear", "tlp-green", "tlp-amber", "tlp-red"], + Recommendation="", + SectionThreatMappings=[ + {"ReferenceId": "CCC", "Identifiers": ["CCC.IAM.TH03"]} + ], + SectionGuidelineMappings=[ + {"ReferenceId": "NIST-CSF", "Identifiers": ["PR.AA-05"]} + ], + ) + ], + ), + ], +) + +CCC_AZURE_FIXTURE = Compliance( + Framework="CCC", + Name="Common Cloud Controls Catalog (CCC)", + Provider="Azure", + Version="v2025.10", + Description="Common Cloud Controls Catalog (CCC) for Azure", + Requirements=[ + Compliance_Requirement( + Checks=["service_test_check_id"], + Id="CCC.Core.CN01.AR01", + Description="When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + Attributes=[ + CCC_Requirement_Attribute( + FamilyName="Data", + FamilyDescription="The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + Section="CCC.Core.CN01 Encrypt Data for Transmission", + SubSection="", + SubSectionObjective="Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + Applicability=["tlp-green", "tlp-amber", "tlp-red"], + Recommendation="Most cloud services enable TLS 1.3 by default.", + SectionThreatMappings=[ + {"ReferenceId": "CCC", "Identifiers": ["CCC.Core.TH02"]} + ], + SectionGuidelineMappings=[ + {"ReferenceId": "CCM", "Identifiers": ["CEK-03", "CEK-04"]} + ], + ) + ], + ), + Compliance_Requirement( + Checks=[], + Id="CCC.IAM.CN01.AR01", + Description="When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating credentials.", + Attributes=[ + CCC_Requirement_Attribute( + FamilyName="Identity and Access Management", + FamilyDescription="Controls that restrict who can access and modify IAM resources.", + Section="CCC.IAM.CN01 Restrict IAM User Credentials Creation", + SubSection="", + SubSectionObjective="Prevent non-administrative principals from creating new long-lived credentials.", + Applicability=["tlp-clear", "tlp-green", "tlp-amber", "tlp-red"], + Recommendation="", + SectionThreatMappings=[ + {"ReferenceId": "CCC", "Identifiers": ["CCC.IAM.TH03"]} + ], + SectionGuidelineMappings=[ + {"ReferenceId": "NIST-CSF", "Identifiers": ["PR.AA-05"]} + ], + ) + ], + ), + ], +) + +CCC_GCP_FIXTURE = Compliance( + Framework="CCC", + Name="Common Cloud Controls Catalog (CCC)", + Provider="GCP", + Version="v2025.10", + Description="Common Cloud Controls Catalog (CCC) for GCP", + Requirements=[ + Compliance_Requirement( + Checks=["service_test_check_id"], + Id="CCC.Core.CN01.AR01", + Description="When a port is exposed for non-SSH network traffic, all traffic MUST include a TLS handshake AND be encrypted using TLS 1.3 or higher.", + Attributes=[ + CCC_Requirement_Attribute( + FamilyName="Data", + FamilyDescription="The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle.", + Section="CCC.Core.CN01 Encrypt Data for Transmission", + SubSection="", + SubSectionObjective="Ensure that all communications are encrypted in transit to protect data integrity and confidentiality.", + Applicability=["tlp-green", "tlp-amber", "tlp-red"], + Recommendation="Most cloud services enable TLS 1.3 by default.", + SectionThreatMappings=[ + {"ReferenceId": "CCC", "Identifiers": ["CCC.Core.TH02"]} + ], + SectionGuidelineMappings=[ + {"ReferenceId": "CCM", "Identifiers": ["CEK-03", "CEK-04"]} + ], + ) + ], + ), + Compliance_Requirement( + Checks=[], + Id="CCC.IAM.CN01.AR01", + Description="When an identity policy for a non-administrative principal is evaluated, it MUST NOT grant permissions for creating credentials.", + Attributes=[ + CCC_Requirement_Attribute( + FamilyName="Identity and Access Management", + FamilyDescription="Controls that restrict who can access and modify IAM resources.", + Section="CCC.IAM.CN01 Restrict IAM User Credentials Creation", + SubSection="", + SubSectionObjective="Prevent non-administrative principals from creating new long-lived credentials.", + Applicability=["tlp-clear", "tlp-green", "tlp-amber", "tlp-red"], + Recommendation="", + SectionThreatMappings=[ + {"ReferenceId": "CCC", "Identifiers": ["CCC.IAM.TH03"]} + ], + SectionGuidelineMappings=[ + {"ReferenceId": "NIST-CSF", "Identifiers": ["PR.AA-05"]} + ], + ) + ], + ), + ], +) + +ASD_ESSENTIAL_EIGHT_AWS = Compliance( + Framework="ASD-Essential-Eight", + Name="ASD Essential Eight Maturity Model - Maturity Level One (AWS)", + Version="Nov 2023", + Provider="AWS", + Description="Literal mapping of the Australian Signals Directorate (ASD) Essential Eight Maturity Model ML1 to AWS infrastructure checks.", + Requirements=[ + Compliance_Requirement( + Id="E8-1.8", + Description="Online services that are no longer supported by vendors are removed.", + Attributes=[ + ASDEssentialEight_Requirement_Attribute( + Section="1 Patch applications", + MaturityLevel="ML1", + AssessmentStatus="Automated", + CloudApplicability="full", + MitigatedThreats=[ + "Use of unsupported software", + "Long-tail vulnerability accumulation", + ], + Description="Detect and remove unsupported AWS-hosted online services (Lambda runtimes, RDS engines, EKS, Fargate, Kafka, OpenSearch).", + RationaleStatement="Unsupported services no longer receive security patches.", + ImpactStatement="", + RemediationProcedure="Migrate Lambda off deprecated runtimes; remove RDS Extended Support; upgrade EKS.", + AuditProcedure="Run all listed checks.", + AdditionalInformation="ASD Essential Eight ML1 - Patch applications - clause 8.", + References="https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model", + ) + ], + Checks=["service_test_check_id"], + ), + Compliance_Requirement( + Id="E8-6.1", + Description="Microsoft Office macros are disabled for users that do not have a demonstrated business requirement.", + Attributes=[ + ASDEssentialEight_Requirement_Attribute( + Section="6 Restrict Microsoft Office macros", + MaturityLevel="ML1", + AssessmentStatus="Manual", + CloudApplicability="non-applicable", + MitigatedThreats=["Macro-based malware delivery"], + Description="Endpoint / Microsoft 365 control. Out of AWS infrastructure scope.", + RationaleStatement="Most users never need Office macros.", + ImpactStatement="", + RemediationProcedure="Disable macros via Group Policy / Intune / M365 admin policies.", + AuditProcedure="Manual review of M365 macro policy.", + AdditionalInformation="ASD Essential Eight ML1 - Restrict Microsoft Office macros - clause 1. Out of AWS infrastructure scope.", + References="https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight/essential-eight-maturity-model", + ) + ], + Checks=[], + ), + ], +) + +OKTA_IDAAS_STIG_OKTA = Compliance( + Framework="Okta-IDaaS-STIG", + Name="DISA Okta Identity as a Service (IDaaS) STIG V1R2", + Version="1R2", + Provider="Okta", + Description="Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).", + Requirements=[ + Compliance_Requirement( + Id="OKTA-APP-000020", + Name="Okta must log out a session after a 15-minute period of inactivity.", + Description="A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273186r1098825_rule", + StigID="OKTA-APP-000020", + CCI=["CCI-000057", "CCI-001133"], + CheckText="Verify the Global Session Policy logs out a session after 15 minutes of inactivity.", + FixText="From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.", + ) + ], + Checks=["signon_global_session_idle_timeout_15min"], + ), + Compliance_Requirement( + Id="OKTA-APP-000650", + Name="Okta must enforce a minimum 15-character password length.", + Description="The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273209r1098894_rule", + StigID="OKTA-APP-000650", + CCI=["CCI-000205"], + CheckText="Verify the password policy enforces a minimum length of 15 characters.", + FixText="From the Admin Console set the minimum password length to 15 characters.", + ) + ], + Checks=[], + ), + ], +) diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index cda9a08bd3..335a9ad17d 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -5,6 +5,12 @@ from unittest import mock from freezegun import freeze_time from mock import patch +from prowler.lib.check.compliance_models import ( + Compliance, + Compliance_Requirement, + Generic_Compliance_Requirement_Attribute, + ISO27001_2013_Requirement_Attribute, +) from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel from tests.lib.outputs.compliance.fixtures import NIST_800_53_REVISION_4_AWS @@ -129,3 +135,111 @@ class TestAWSGenericCompliance: expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_SUBGROUP;REQUIREMENTS_ATTRIBUTES_SERVICE;REQUIREMENTS_ATTRIBUTES_TYPE;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME;FRAMEWORK;NAME;REQUIREMENTS_ATTRIBUTES_COMMENT\r\naws;NIST 800-53 is a regulatory standard that defines the minimum baseline of security controls for all U.S. federal information systems except those related to national security. The controls defined in this standard are customizable and address a diverse set of security and privacy requirements.;123456789012;eu-west-1;{datetime.now()};ac_2_4;Account Management;Access Control (AC);Account Management (AC-2);;aws;;PASS;;;service_test_check_id;False;;NIST-800-53-Revision-4;National Institute of Standards and Technology (NIST) 800-53 Revision 4;\r\naws;NIST 800-53 is a regulatory standard that defines the minimum baseline of security controls for all U.S. federal information systems except those related to national security. The controls defined in this standard are customizable and address a diverse set of security and privacy requirements.;;;{datetime.now()};ac_2_5;Account Management;Access Control (AC);Account Management (AC-2);;aws;;MANUAL;Manual check;manual_check;manual;False;Manual check;NIST-800-53-Revision-4;National Institute of Standards and Technology (NIST) 800-53 Revision 4;\r\n" assert content == expected_csv + + def test_csv_row_count_matches_framework_checks_not_stored_compliance(self): + """Regression test for PROWLER-1763. + + Ensures CSV emission is driven by the framework JSON's Requirements[].Checks + (the same source the UI uses) and not by the per-finding `finding.compliance` + snapshot stored at scan time. If `finding.check_id` is in a requirement's + Checks list, the row must be emitted regardless of what was stored in + `finding.compliance`. Conversely, a stale `finding.compliance` entry pointing + to a requirement whose Checks list no longer contains the finding's check_id + must not produce a row. + """ + framework_name = "Test-Framework-1763" + compliance = Compliance( + Framework=framework_name, + Name=framework_name, + Provider="AWS", + Version="", + Description="Regression fixture for PROWLER-1763", + Requirements=[ + Compliance_Requirement( + Id="req_in_framework", + Description="Requirement currently in framework", + Attributes=[ + Generic_Compliance_Requirement_Attribute( + Section="Section A", Service="aws" + ) + ], + Checks=["service_check_in_framework"], + ), + Compliance_Requirement( + Id="req_no_longer_in_framework", + Description="Requirement whose Checks list no longer includes the finding's check_id", + Attributes=[ + Generic_Compliance_Requirement_Attribute( + Section="Section B", Service="aws" + ) + ], + Checks=["service_different_check"], + ), + ], + ) + + # Snapshot drift case: finding.compliance maps to a requirement whose + # current Checks list no longer includes the finding's check_id, AND + # the finding belongs to a requirement that is NOT in the snapshot. + findings = [ + generate_finding_output( + check_id="service_check_in_framework", + compliance={framework_name: ["req_no_longer_in_framework"]}, + ) + ] + + output = GenericCompliance(findings, compliance) + rows = [ + row + for row in output.data + if row.Status != "MANUAL" and row.ResourceName != "Manual check" + ] + assert ( + len(rows) == 1 + ), f"Expected 1 row driven by framework JSON, got {len(rows)}" + assert rows[0].Requirements_Id == "req_in_framework" + assert rows[0].CheckId == "service_check_in_framework" + + def test_transform_tolerates_framework_specific_attribute_schema(self): + """GenericCompliance is the documented last-resort renderer, so it must not + crash on a framework whose attribute schema lacks the universal fields + (Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares + none of them; missing fields must render as None instead of raising + AttributeError and dropping the whole CSV.""" + framework_name = "ISO27001-2013-External" + compliance = Compliance( + Framework=framework_name, + Name=framework_name, + Provider="external", + Version="", + Description="Framework shipping a provider-specific attribute schema", + Requirements=[ + Compliance_Requirement( + Id="A.5.1.1", + Description="Policies for information security", + Attributes=[ + ISO27001_2013_Requirement_Attribute( + Category="Information security policies", + Objetive_ID="A.5.1", + Objetive_Name="Management direction", + Check_Summary="Policy is defined", + ) + ], + Checks=["service_test_check_id"], + ) + ], + ) + + findings = [generate_finding_output(check_id="service_test_check_id")] + + output = GenericCompliance(findings, compliance) + + rows = [row for row in output.data if row.Status != "MANUAL"] + assert len(rows) == 1 + assert rows[0].Requirements_Id == "A.5.1.1" + assert rows[0].Requirements_Attributes_Section is None + assert rows[0].Requirements_Attributes_SubSection is None + assert rows[0].Requirements_Attributes_SubGroup is None + assert rows[0].Requirements_Attributes_Service is None + assert rows[0].Requirements_Attributes_Type is None + assert rows[0].Requirements_Attributes_Comment is None diff --git a/tests/lib/outputs/compliance/kisa_ismsp/__init__.py b/tests/lib/outputs/compliance/kisa_ismsp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py new file mode 100644 index 0000000000..be0fe25ad5 --- /dev/null +++ b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py @@ -0,0 +1,137 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_table + +# The generator matches a compliance when its Framework starts with "KISA" and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "kisa-isms-p-2023_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, sections, framework="KISA-ISMS-P", version="kisa-isms-p-2023" +): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestKISAISMSPTable: + """Verify multi-section counting and provider-column attribution for the KISA ISMS-P compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched KISA compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/mitre_attack/__init__.py b/tests/lib/outputs/compliance/mitre_attack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py new file mode 100644 index 0000000000..3bd69b44e8 --- /dev/null +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py @@ -0,0 +1,140 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( + get_mitre_attack_table, +) + +# The generator matches a compliance when "MITRE-ATTACK" is in its Framework and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "mitre_attack_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, tactics, framework="MITRE-ATTACK", version="mitre_attack" +): + """Build a per-check compliance covering the given tactics.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Tactics=tactics)], + ) + + +class TestMitreAttackTable: + """Test multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_tactic_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several tactics must show FAIL(1) in + every tactic, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both Persistence and Execution must report FAIL(1); before the fix + # Execution was undercounted because the per-tactic count was gated by + # the global dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_tactic_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several tactics must increase the + per-tactic Muted count in every tactic, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A second finding is needed so the table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both Persistence and Execution, so the + # Muted column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched MITRE-ATTACK + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py b/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py new file mode 100644 index 0000000000..a6a0267851 --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py @@ -0,0 +1,192 @@ +from datetime import datetime +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.check.compliance_config_eval import CONFIG_NOT_VALID_PREFIX +from prowler.lib.check.compliance_models import ( + Compliance_Requirement_ConfigConstraint, +) +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) +from tests.lib.outputs.compliance.fixtures import OKTA_IDAAS_STIG_OKTA +from tests.lib.outputs.fixtures.fixtures import generate_finding_output + +OKTA_ORG_DOMAIN = "dev-12345.okta.com" + + +class TestOktaIDaaSSTIG: + def test_output_transform(self): + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output_data = output.data[0] + assert isinstance(output_data, OktaIDaaSSTIGModel) + assert output_data.Provider == "okta" + assert output_data.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data.OrganizationDomain == OKTA_ORG_DOMAIN + assert output_data.Description == OKTA_IDAAS_STIG_OKTA.Description + assert output_data.Requirements_Id == OKTA_IDAAS_STIG_OKTA.Requirements[0].Id + assert ( + output_data.Requirements_Name == OKTA_IDAAS_STIG_OKTA.Requirements[0].Name + ) + assert ( + output_data.Requirements_Description + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Description + ) + assert ( + output_data.Requirements_Attributes_Section + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Section + ) + assert ( + output_data.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Severity.value + ) + assert ( + output_data.Requirements_Attributes_RuleID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].RuleID + ) + assert ( + output_data.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].StigID + ) + assert ( + output_data.Requirements_Attributes_CCI + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CCI + ) + assert ( + output_data.Requirements_Attributes_CheckText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CheckText + ) + assert ( + output_data.Requirements_Attributes_FixText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].FixText + ) + assert output_data.Status == "PASS" + assert output_data.StatusExtended == "" + assert output_data.ResourceId == "okta-global-session-policy" + assert output_data.ResourceName == "Default Policy" + assert output_data.CheckId == "signon_global_session_idle_timeout_15min" + assert output_data.Muted is False + # Test manual check + output_data_manual = output.data[1] + assert output_data_manual.Provider == "okta" + assert output_data_manual.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data_manual.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data_manual.OrganizationDomain == "" + assert ( + output_data_manual.Requirements_Id + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Id + ) + assert ( + output_data_manual.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].Severity.value + ) + assert ( + output_data_manual.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].StigID + ) + assert output_data_manual.Status == "MANUAL" + assert output_data_manual.StatusExtended == "Manual check" + assert output_data_manual.ResourceId == "manual_check" + assert output_data_manual.ResourceName == "Manual check" + assert output_data_manual.CheckId == "manual" + assert output_data_manual.Muted is False + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + expected_csv = f"PROVIDER;DESCRIPTION;ORGANIZATIONDOMAIN;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SEVERITY;REQUIREMENTS_ATTRIBUTES_RULEID;REQUIREMENTS_ATTRIBUTES_STIGID;REQUIREMENTS_ATTRIBUTES_CCI;REQUIREMENTS_ATTRIBUTES_CHECKTEXT;REQUIREMENTS_ATTRIBUTES_FIXTEXT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;{OKTA_ORG_DOMAIN};{datetime.now()};OKTA-APP-000020;Okta must log out a session after a 15-minute period of inactivity.;A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.;CAT II (Medium);medium;SV-273186r1098825_rule;OKTA-APP-000020;['CCI-000057', 'CCI-001133'];Verify the Global Session Policy logs out a session after 15 minutes of inactivity.;From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.;PASS;;okta-global-session-policy;Default Policy;signon_global_session_idle_timeout_15min;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;;{datetime.now()};OKTA-APP-000650;Okta must enforce a minimum 15-character password length.;The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.;CAT II (Medium);medium;SV-273209r1098894_rule;OKTA-APP-000650;['CCI-000205'];Verify the password policy enforces a minimum length of 15 characters.;From the Admin Console set the minimum password length to 15 characters.;MANUAL;Manual check;manual_check;Manual check;manual;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\n" + + assert content == expected_csv + + def test_config_status_override_forces_fail(self): + """A PASS finding whose requirement declares a ConfigRequirements + constraint the scan's config violates must be reported as FAIL in the + CSV, with the config-not-valid reason prepended to StatusExtended.""" + # Inject a config constraint on the first requirement (idle timeout must + # be <= 15 minutes) without mutating the shared fixture. + compliance = OKTA_IDAAS_STIG_OKTA.copy(deep=True) + compliance.Requirements[0].ConfigRequirements = [ + Compliance_Requirement_ConfigConstraint( + Check="signon_global_session_idle_timeout_15min", + ConfigKey="okta_max_session_idle_minutes", + Operator="lte", + Value=15, + ) + ] + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + status="PASS", + status_extended="Idle timeout is configured.", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + + # The scan applied a 30-minute idle timeout, too loose for the 15-minute + # requirement, so the PASS must be overridden to FAIL. + with ( + patch( + "prowler.lib.check.compliance_config_eval.get_scan_audit_config", + return_value={"okta_max_session_idle_minutes": 30}, + ), + patch( + "prowler.lib.check.compliance_config_eval.get_scan_provider_type", + return_value="okta", + ), + ): + output = OktaIDaaSSTIG(findings, compliance) + + output_data = output.data[0] + assert output_data.Status == "FAIL" + assert output_data.StatusExtended.startswith(CONFIG_NOT_VALID_PREFIX) diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py new file mode 100644 index 0000000000..0dd353760b --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py @@ -0,0 +1,211 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + check_id=check_id, + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, + sections, + framework="Okta-IDaaS-STIG", + checks=None, + config_requirements=None, +): + """Build a per-check compliance covering the given sections. + + ``checks`` and ``config_requirements`` let a section's requirement declare + the checks it owns and the config constraints that gate it, so the table's + config-status override can be exercised. + """ + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace( + Id=f"REQ-{section}", + Checks=list(checks or []), + ConfigRequirements=list(config_requirements or []), + Attributes=[SimpleNamespace(Section=section)], + ) + for section in sections + ], + ) + + +class TestOktaIDaaSSTIGTable: + """Test cases for Okta IDaaS STIG compliance table rendering.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + # check_a belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging + # was undercounted and rendered as plain PASS. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. Before the fix only the first section seen + # was incremented, leaving the second at 0. + # Strip ANSI color codes before counting the bare values per row. + import re + + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # Each section row ends with its Muted value in its own cell; both rows + # must carry a Muted count of 1. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched Okta-IDaaS-STIG + compliance, never from a different framework that happens to be the + last entry in the check's compliance list.""" + # check_a maps to Okta-IDaaS-STIG (provider "okta") but its compliance + # list ends with a *different* framework whose provider is "aws". With + # the bug the leaked loop variable made the table render "aws". + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "okta" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "aws" not in captured.out + + def test_config_status_override_forces_fail(self, capsys, tmp_path): + """A configurable check that PASSes but ran with a config too loose for + its requirement must be forced to FAIL in the table, honouring the + requirement's ConfigRequirements. Without the override check_a would be + PASS and no results table would render at all.""" + constraint = { + "Check": "check_a", + "ConfigKey": "okta_max_session_idle_minutes", + "Operator": "lte", + "Value": 15, + } + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "okta", + ["IAM"], + checks=["check_a"], + config_requirements=[constraint], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("okta", ["Logging"])] + ), + } + # Both checks PASS on their own; the scan applied a 30-minute idle + # timeout, which is too loose for the 15-minute requirement. + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "PASS"), + ] + + with ( + patch( + "prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig.get_scan_audit_config", + return_value={"okta_max_session_idle_minutes": 30}, + ), + patch( + "prowler.lib.check.compliance_config_eval.get_scan_provider_type", + return_value="okta", + ), + ): + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # check_a was forced to FAIL by the config override, so its section + # (IAM) must report FAIL(1). + assert "FAIL(1)" in captured.out diff --git a/tests/lib/outputs/compliance/process_universal_test.py b/tests/lib/outputs/compliance/process_universal_test.py new file mode 100644 index 0000000000..4dc957ed4f --- /dev/null +++ b/tests/lib/outputs/compliance/process_universal_test.py @@ -0,0 +1,1006 @@ +"""Tests for process_universal_compliance_frameworks and --list-compliance fixes. + +Validates that the pre-processing step: + - generates both CSV and OCSF outputs for universal frameworks + - always generates OCSF (no output-format gate) + - skips frameworks without outputs or table_config + - skips frameworks not in universal_frameworks + - returns the set of processed names for removal from the legacy loop + - works across different providers + +Also validates that print_compliance_frameworks and print_compliance_requirements +work with universal ComplianceFramework objects (dict checks, None provider). +""" + +import csv +import json +import os +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from prowler.lib.check.check import ( + print_compliance_frameworks, + print_compliance_requirements, +) +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.compliance import ( + process_universal_compliance_frameworks, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) +from prowler.lib.outputs.compliance.universal.universal_output import ( + UniversalComplianceOutput, +) + + +@pytest.fixture(autouse=True) +def _create_compliance_dir(tmp_path): + """Ensure the compliance/ subdirectory exists before each test.""" + os.makedirs(tmp_path / "compliance", exist_ok=True) + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _make_finding(check_id, status="PASS", provider="aws"): + """Create a mock Finding with all fields needed by both output classes.""" + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_email = "" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.account_tags = {"env": "test"} + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "some details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.compliance = {"TestFW-1.0": ["1.1"]} + finding.metadata = SimpleNamespace( + Provider=provider, + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + CheckType=["test-type"], + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + Risk="test-risk", + RelatedUrl="https://example.com", + Remediation=SimpleNamespace( + Recommendation=SimpleNamespace(Text="Fix it", Url="https://fix.com"), + ), + DependsOn=[], + RelatedTo=[], + Categories=["test"], + Notes="", + AdditionalURLs=[], + ) + return finding + + +def _make_universal_framework(name="TestFW", version="1.0", with_table_config=True): + """Build a ComplianceFramework with optional table_config.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="Test requirement", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + ] + metadata = [AttributeMetadata(key="Section", type="str")] + outputs = None + if with_table_config: + outputs = OutputsConfig(table_config=TableConfig(group_by="Section")) + return ComplianceFramework( + framework=name, + name=f"{name} Framework", + provider="AWS", + version=version, + description="Test framework", + requirements=reqs, + attributes_metadata=metadata, + outputs=outputs, + ) + + +def _make_framework_with_manual(name="MixedFW", version="1.0"): + """Framework with one aws-covered requirement and one manual one. + + The manual requirement has no aws checks, so for provider ``aws`` it is + emitted as a manual row/event — used to assert manual requirements are + not duplicated when the writer is reused across streaming batches. + """ + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="Covered requirement", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="Manual requirement", + attributes={"Section": "GOV"}, + checks={"aws": []}, + ), + ] + metadata = [AttributeMetadata(key="Section", type="str")] + outputs = OutputsConfig(table_config=TableConfig(group_by="Section")) + return ComplianceFramework( + framework=name, + name=f"{name} Framework", + provider="AWS", + version=version, + description="Test framework", + requirements=reqs, + attributes_metadata=metadata, + outputs=outputs, + ) + + +# ── Tests ──────────────────────────────────────────────────────────── + + +class TestProcessUniversalComplianceFrameworks: + """Core tests for the extracted pre-processing function.""" + + def test_generates_csv_and_ocsf_outputs(self, tmp_path): + """Both CSV and OCSF outputs are appended to generated_outputs.""" + fw = _make_universal_framework() + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider="aws", + generated_outputs=generated, + ) + + assert processed == {"test_fw_1.0"} + assert len(generated["compliance"]) == 2 + assert isinstance(generated["compliance"][0], UniversalComplianceOutput) + assert isinstance(generated["compliance"][1], OCSFComplianceOutput) + + def test_ocsf_always_generated_no_format_gate(self, tmp_path): + """OCSF output is generated regardless of output_formats — no gate.""" + fw = _make_universal_framework() + generated = {"compliance": []} + process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider="aws", + generated_outputs=generated, + ) + + ocsf_outputs = [ + o for o in generated["compliance"] if isinstance(o, OCSFComplianceOutput) + ] + assert len(ocsf_outputs) == 1 + + def test_csv_file_written(self, tmp_path): + """CSV file is created with expected content.""" + fw = _make_universal_framework() + generated = {"compliance": []} + process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider="aws", + generated_outputs=generated, + ) + + csv_path = tmp_path / "compliance" / "prowler_output_test_fw_1.0.csv" + assert csv_path.exists() + content = csv_path.read_text() + assert "PROVIDER" in content + assert "REQUIREMENTS_ATTRIBUTES_SECTION" in content + + def test_ocsf_file_written(self, tmp_path): + """OCSF JSON file is created with valid content.""" + fw = _make_universal_framework() + generated = {"compliance": []} + process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider="aws", + generated_outputs=generated, + ) + + ocsf_path = tmp_path / "compliance" / "prowler_output_test_fw_1.0.ocsf.json" + assert ocsf_path.exists() + data = json.loads(ocsf_path.read_text()) + assert isinstance(data, list) + assert len(data) >= 1 + assert data[0]["class_uid"] == 2003 + + def test_returns_processed_names(self, tmp_path): + """Returns the set of framework names that were processed.""" + fw = _make_universal_framework() + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0", "legacy_fw"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == {"test_fw_1.0"} + assert "legacy_fw" not in processed + + +class TestSkipConditions: + """Tests for frameworks that should NOT be processed.""" + + def test_skips_framework_not_in_universal(self, tmp_path): + """Frameworks not in universal_frameworks dict are skipped.""" + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"cis_aws_1.4"}, + universal_frameworks={}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == set() + assert len(generated["compliance"]) == 0 + + def test_skips_framework_without_outputs(self, tmp_path): + """Frameworks with outputs=None are skipped.""" + fw = _make_universal_framework(with_table_config=False) + # outputs is None since with_table_config=False + assert fw.outputs is None + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == set() + assert len(generated["compliance"]) == 0 + + def test_skips_framework_with_outputs_but_no_table_config(self, tmp_path): + """Frameworks with outputs but table_config=None are skipped.""" + fw = _make_universal_framework() + # Manually set table_config to None while keeping outputs + fw.outputs = OutputsConfig(table_config=None) + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == set() + assert len(generated["compliance"]) == 0 + + def test_empty_input_frameworks(self, tmp_path): + """No processing when input set is empty.""" + fw = _make_universal_framework() + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks=set(), + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == set() + assert len(generated["compliance"]) == 0 + + +class TestMixedFrameworks: + """Tests with a mix of universal and legacy frameworks.""" + + def test_only_universal_processed_legacy_untouched(self, tmp_path): + """Only universal frameworks are processed; legacy names are not returned.""" + universal_fw = _make_universal_framework() + generated = {"compliance": []} + + all_frameworks = {"test_fw_1.0", "cis_aws_1.4", "nist_800_53_aws"} + processed = process_universal_compliance_frameworks( + input_compliance_frameworks=all_frameworks, + universal_frameworks={"test_fw_1.0": universal_fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == {"test_fw_1.0"} + # 2 outputs for the one universal framework (CSV + OCSF) + assert len(generated["compliance"]) == 2 + + def test_removal_from_input_set(self, tmp_path): + """Caller can subtract processed set from input to get legacy-only frameworks.""" + universal_fw = _make_universal_framework() + generated = {"compliance": []} + + input_frameworks = {"test_fw_1.0", "cis_aws_1.4", "nist_800_53_aws"} + processed = process_universal_compliance_frameworks( + input_compliance_frameworks=input_frameworks, + universal_frameworks={"test_fw_1.0": universal_fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + remaining = input_frameworks - processed + assert remaining == {"cis_aws_1.4", "nist_800_53_aws"} + + def test_multiple_universal_frameworks(self, tmp_path): + """Multiple universal frameworks each get CSV + OCSF.""" + fw1 = _make_universal_framework(name="FW1", version="1.0") + fw2 = _make_universal_framework(name="FW2", version="2.0") + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"fw1_1.0", "fw2_2.0", "legacy"}, + universal_frameworks={"fw1_1.0": fw1, "fw2_2.0": fw2}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == {"fw1_1.0", "fw2_2.0"} + # 2 frameworks × 2 outputs each = 4 + assert len(generated["compliance"]) == 4 + csv_outputs = [ + o + for o in generated["compliance"] + if isinstance(o, UniversalComplianceOutput) + ] + ocsf_outputs = [ + o for o in generated["compliance"] if isinstance(o, OCSFComplianceOutput) + ] + assert len(csv_outputs) == 2 + assert len(ocsf_outputs) == 2 + + +class TestProviderVariants: + """Verify the function works for different providers.""" + + @pytest.mark.parametrize( + "provider", + [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "oraclecloud", + "alibabacloud", + "nhn", + ], + ) + def test_all_providers_produce_outputs(self, tmp_path, provider): + """Each provider generates CSV + OCSF when given a universal framework.""" + fw = _make_universal_framework() + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a", provider=provider)], + output_directory=str(tmp_path), + output_filename="out", + provider=provider, + generated_outputs=generated, + ) + + assert processed == {"test_fw_1.0"} + assert len(generated["compliance"]) == 2 + assert isinstance(generated["compliance"][0], UniversalComplianceOutput) + assert isinstance(generated["compliance"][1], OCSFComplianceOutput) + + +class TestEmptyFindings: + """Test behavior when there are no findings.""" + + def test_still_processed_with_empty_findings(self, tmp_path): + """Framework is still marked as processed even with no findings.""" + fw = _make_universal_framework() + generated = {"compliance": []} + + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + assert processed == {"test_fw_1.0"} + # Outputs are still appended (they'll just have empty data) + assert len(generated["compliance"]) == 2 + + +class TestFilePaths: + """Verify correct file path construction.""" + + def test_csv_path_format(self, tmp_path): + """CSV output has the correct file path.""" + fw = _make_universal_framework() + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"csa_ccm_4.0"}, + universal_frameworks={"csa_ccm_4.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_report", + provider="aws", + generated_outputs=generated, + ) + + csv_output = generated["compliance"][0] + assert csv_output.file_path == ( + f"{tmp_path}/compliance/prowler_report_csa_ccm_4.0.csv" + ) + + def test_ocsf_path_format(self, tmp_path): + """OCSF output has the correct file path.""" + fw = _make_universal_framework() + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"csa_ccm_4.0"}, + universal_frameworks={"csa_ccm_4.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_report", + provider="aws", + generated_outputs=generated, + ) + + ocsf_output = generated["compliance"][1] + assert ocsf_output.file_path == ( + f"{tmp_path}/compliance/prowler_report_csa_ccm_4.0.ocsf.json" + ) + + +# ── Tests for --list-compliance fix ────────────────────────────────── + + +def _make_legacy_compliance(): + """Create a mock legacy Compliance-like object with the expected attributes.""" + return SimpleNamespace( + Framework="CIS", + Provider="AWS", + Version="1.4", + Requirements=[ + SimpleNamespace( + Id="2.1.3", + Description="Ensure MFA Delete is enabled", + Checks=["s3_bucket_mfa_delete"], + ), + ], + ) + + +class TestPrintComplianceFrameworks: + """Tests for print_compliance_frameworks with universal frameworks.""" + + def test_includes_universal_frameworks(self, capsys): + """Universal frameworks appear in the listing.""" + legacy = {"cis_1.4_aws": _make_legacy_compliance()} + universal = {"csa_ccm_4.0": _make_universal_framework()} + merged = {**legacy, **universal} + + print_compliance_frameworks(merged) + captured = capsys.readouterr().out + + assert "cis_1.4_aws" in captured + assert "csa_ccm_4.0" in captured + + def test_count_includes_both(self, capsys): + """Framework count includes both legacy and universal.""" + legacy = {"cis_1.4_aws": _make_legacy_compliance()} + universal = {"csa_ccm_4.0": _make_universal_framework()} + merged = {**legacy, **universal} + + print_compliance_frameworks(merged) + captured = capsys.readouterr().out + + assert "2" in captured + + def test_universal_only(self, capsys): + """Works when only universal frameworks are present.""" + universal = {"csa_ccm_4.0": _make_universal_framework()} + + print_compliance_frameworks(universal) + captured = capsys.readouterr().out + + assert "csa_ccm_4.0" in captured + assert "1" in captured + + +class TestPrintComplianceRequirements: + """Tests for print_compliance_requirements with universal frameworks.""" + + def test_list_checks_universal_framework(self, capsys): + """Requirements with dict checks are printed correctly.""" + fw = _make_universal_framework() + all_fw = {"test_fw_1.0": fw} + + print_compliance_requirements(all_fw, ["test_fw_1.0"]) + captured = capsys.readouterr().out + + assert "1.1" in captured + assert "check_a" in captured + + def test_dict_checks_universal_framework(self, capsys): + """Requirements with dict checks show provider-prefixed checks.""" + reqs = [ + UniversalComplianceRequirement( + id="A&A-01", + description="Audit & Assurance", + attributes={"Section": "A&A"}, + checks={"aws": ["check_a", "check_b"], "azure": ["check_c"]}, + ), + ] + fw = ComplianceFramework( + framework="CSA_CCM", + name="CSA CCM 4.0", + version="4.0", + description="Cloud Controls Matrix", + requirements=reqs, + ) + all_fw = {"csa_ccm_4.0": fw} + + print_compliance_requirements(all_fw, ["csa_ccm_4.0"]) + captured = capsys.readouterr().out + + assert "A&A-01" in captured + assert "[aws] check_a" in captured + assert "[aws] check_b" in captured + assert "[azure] check_c" in captured + + def test_none_provider_shows_multi_provider(self, capsys): + """Frameworks with provider=None show 'Multi-provider'.""" + fw = ComplianceFramework( + framework="CSA_CCM", + name="CSA CCM 4.0", + version="4.0", + description="Cloud Controls Matrix", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={}, + checks={"aws": ["check_a"]}, + ), + ], + ) + all_fw = {"csa_ccm_4.0": fw} + + print_compliance_requirements(all_fw, ["csa_ccm_4.0"]) + captured = capsys.readouterr().out + + assert "Multi-provider" in captured + + +# ── Idempotency tests ──────────────────────────────────────────────── + + +class TestIdempotency: + """The function must be safe to invoke multiple times for the same + framework. Repeated calls must reuse writers tracked in + ``generated_outputs["compliance"]`` instead of recreating them. + + This guards against: + - duplicate writer entries in generated_outputs (regular pipeline + treats one writer per framework) + - the OCSF append-bug where a second writer would emit + ``[...]...]`` and break the JSON array. + """ + + def test_second_call_does_not_duplicate_writers(self, tmp_path): + fw = _make_universal_framework() + generated = {"compliance": []} + kwargs = dict( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider="aws", + generated_outputs=generated, + ) + + first = process_universal_compliance_frameworks(**kwargs) + first_count = len(generated["compliance"]) + second = process_universal_compliance_frameworks(**kwargs) + second_count = len(generated["compliance"]) + + assert first == {"test_fw_1.0"} + assert second == {"test_fw_1.0"} # still reported as processed + assert first_count == 2 # CSV + OCSF + assert second_count == 2 # NO duplication + + def test_second_call_keeps_ocsf_json_valid(self, tmp_path): + """End-to-end: after two calls the OCSF JSON file must still be + a single, valid JSON array — not the broken ``[...]...]`` form.""" + fw = _make_universal_framework() + generated = {"compliance": []} + kwargs = dict( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider="aws", + generated_outputs=generated, + ) + + process_universal_compliance_frameworks(**kwargs) + process_universal_compliance_frameworks(**kwargs) + + ocsf_path = tmp_path / "compliance" / "prowler_output_test_fw_1.0.ocsf.json" + data = json.loads(ocsf_path.read_text()) # Will raise on invalid JSON + assert isinstance(data, list) + assert len(data) >= 1 + + def test_reuses_existing_writer_object(self, tmp_path): + """The CSV/OCSF writer instances appended on first call must be + the SAME objects after a second call — not fresh ones.""" + fw = _make_universal_framework() + generated = {"compliance": []} + kwargs = dict( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider="aws", + generated_outputs=generated, + ) + + process_universal_compliance_frameworks(**kwargs) + first_writers = list(generated["compliance"]) + process_universal_compliance_frameworks(**kwargs) + second_writers = list(generated["compliance"]) + + # Same identity, same length — reused, not recreated. + assert len(first_writers) == len(second_writers) + for a, b in zip(first_writers, second_writers): + assert a is b + + def test_idempotency_across_mixed_frameworks(self, tmp_path): + """When the second call adds a new framework, the new one is + created while existing ones are NOT recreated.""" + fw1 = _make_universal_framework(name="FW1", version="1.0") + fw2 = _make_universal_framework(name="FW2", version="2.0") + generated = {"compliance": []} + + # First call: only FW1 + process_universal_compliance_frameworks( + input_compliance_frameworks={"fw1_1.0"}, + universal_frameworks={"fw1_1.0": fw1, "fw2_2.0": fw2}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + first_writers = list(generated["compliance"]) + assert len(first_writers) == 2 + + # Second call: includes both. FW1 must be reused, FW2 created fresh. + process_universal_compliance_frameworks( + input_compliance_frameworks={"fw1_1.0", "fw2_2.0"}, + universal_frameworks={"fw1_1.0": fw1, "fw2_2.0": fw2}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + second_writers = list(generated["compliance"]) + assert len(second_writers) == 4 # 2 (FW1 reused) + 2 new (FW2) + # FW1 writer instances unchanged + assert second_writers[0] is first_writers[0] + assert second_writers[1] is first_writers[1] + + +class TestStreamingBatches: + """Streaming-aware behaviour: ``from_cli`` / ``is_last`` / ``_flush``. + + Regression coverage for the API streaming path where the helper is + invoked once per finding batch: before the fix only the first batch + was written (batches 2..N silently dropped) and manual requirements + were re-emitted on every batch. + """ + + def _run_batches(self, tmp_path, fw, key, batches): + """Invoke the helper once per (findings, is_last) batch, sharing + ``generated_outputs`` so writers are reused like the API does.""" + generated = {"compliance": []} + for findings, is_last in batches: + process_universal_compliance_frameworks( + input_compliance_frameworks={key}, + universal_frameworks={key: fw}, + finding_outputs=findings, + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + from_cli=False, + is_last=is_last, + ) + return generated + + def test_defaults_preserve_cli_single_call(self, tmp_path): + """Defaults (``from_cli=True``, ``is_last=True``): a single call + still finalizes a valid, closed OCSF JSON array (CLI unchanged).""" + fw = _make_universal_framework() + generated = {"compliance": []} + process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + ocsf_path = tmp_path / "compliance" / "out_test_fw_1.0.ocsf.json" + data = json.loads(ocsf_path.read_text()) + assert isinstance(data, list) and len(data) >= 1 + + def test_multibatch_csv_keeps_every_batch(self, tmp_path): + """Findings from batches 2..N must not be dropped (the bug).""" + fw = _make_universal_framework() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + generated = self._run_batches( + tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)] + ) + content = (tmp_path / "compliance" / "out_fw_1.0.csv").read_text() + assert "check_a is PASS" in content # batch 1 + assert "check_a is FAIL" in content # batch 2 — regression + # writer reused, not recreated: still just 1 CSV + 1 OCSF + assert len(generated["compliance"]) == 2 + + def test_multibatch_ocsf_valid_array_with_every_batch(self, tmp_path): + """OCSF is a valid (closed) JSON array holding every batch's + events only after the ``is_last=True`` call.""" + fw = _make_universal_framework() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)]) + data = json.loads( + (tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text() + ) + assert isinstance(data, list) + assert len(data) >= 2 # one event per batch finding + + def test_manual_requirement_not_duplicated_across_batches(self, tmp_path): + """Manual requirement is emitted once (first batch, via __init__), + never re-emitted when the writer is reused (``include_manual=False``).""" + fw = _make_framework_with_manual() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)]) + rows = list( + csv.DictReader( + (tmp_path / "compliance" / "out_fw_1.0.csv").read_text().splitlines(), + delimiter=";", + ) + ) + manual_rows = [r for r in rows if r["STATUS"] == "MANUAL"] + assert len(manual_rows) == 1 + assert manual_rows[0]["REQUIREMENTS_ID"] == "2.1" + + ocsf = json.loads( + (tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text() + ) + manual_events = [ + e + for e in ocsf + if (e.get("compliance") or {}).get("requirements") == ["2.1"] + ] + assert len(manual_events) == 1 + + def test_writer_reused_not_recreated_across_batches(self, tmp_path): + """Three batches still yield exactly one CSV + one OCSF writer, + and the same instances are reused throughout.""" + fw = _make_universal_framework() + generated = self._run_batches( + tmp_path, + fw, + "fw_1.0", + [ + ([_make_finding("check_a")], False), + ([_make_finding("check_a")], False), + ([_make_finding("check_a")], True), + ], + ) + assert len(generated["compliance"]) == 2 + assert isinstance(generated["compliance"][0], UniversalComplianceOutput) + assert isinstance(generated["compliance"][1], OCSFComplianceOutput) + + def test_label_without_version_still_outputs(self, tmp_path): + """Empty framework version → label is the framework name only; + the helper still produces both artifacts without error.""" + fw = _make_universal_framework(version="") + generated = {"compliance": []} + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"fw"}, + universal_frameworks={"fw": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + from_cli=False, + is_last=True, + ) + assert processed == {"fw"} + assert len(generated["compliance"]) == 2 + assert (tmp_path / "compliance" / "out_fw.csv").exists() + assert (tmp_path / "compliance" / "out_fw.ocsf.json").exists() + + +def _csa_like_framework() -> ComplianceFramework: + """Build a CSA CCM-style universal framework with checks across providers.""" + requirement = UniversalComplianceRequirement( + id="A&A-01", + description="Audit and Assurance", + attributes={"Section": "Audit"}, + checks={ + "aws": ["aws_check"], + "azure": ["azure_check"], + "gcp": ["gcp_check"], + }, + ) + return ComplianceFramework( + framework="CSA_CCM", + name="CSA Cloud Controls Matrix", + version="4.0", + description="Multi-provider framework", + requirements=[requirement], + attributes_metadata=[AttributeMetadata(key="Section", type="str")], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +class TestMultiProviderUniversalFramework: + """A top-level CSA-CCM-style framework produces a CSV+OCSF pair scoped + to the provider it is invoked with.""" + + @pytest.mark.parametrize( + "provider,check_id", + [ + ("aws", "aws_check"), + ("azure", "azure_check"), + ("gcp", "gcp_check"), + ], + ) + def test_per_provider_outputs_isolated(self, tmp_path, provider, check_id): + framework = _csa_like_framework() + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"csa_ccm_4.0"}, + universal_frameworks={"csa_ccm_4.0": framework}, + finding_outputs=[_make_finding(check_id, provider=provider)], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider=provider, + generated_outputs=generated, + ) + + ocsf_path = tmp_path / "compliance" / "prowler_output_csa_ccm_4.0.ocsf.json" + events = json.loads(ocsf_path.read_text()) + assert isinstance(events, list) + non_manual = [event for event in events if event.get("status_code") != "MANUAL"] + assert len(non_manual) == 1 + assert non_manual[0]["compliance"]["checks"][0]["uid"] == check_id + + +class TestMitreStyleOCSFOutput: + """MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak + the marker key through the OCSF pipeline.""" + + def test_mitre_raw_attributes_pass_through_pipeline(self, tmp_path): + mitre_requirement = UniversalComplianceRequirement( + id="T1078", + description="Valid Accounts", + attributes={ + "_raw_attributes": [{"AWSService": "IAM", "Category": "Initial Access"}] + }, + checks={"aws": ["check_a"]}, + ) + framework = ComplianceFramework( + framework="MITRE", + name="MITRE ATT&CK", + version="14", + description="Mitre", + requirements=[mitre_requirement], + outputs=OutputsConfig(table_config=TableConfig(group_by="AWSService")), + ) + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"mitre_attack_aws"}, + universal_frameworks={"mitre_attack_aws": framework}, + finding_outputs=[_make_finding("check_a", "PASS")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + ocsf_path = tmp_path / "compliance" / "out_mitre_attack_aws.ocsf.json" + events = json.loads(ocsf_path.read_text()) + assert isinstance(events, list) and len(events) >= 1 + for event in events: + requirement_attrs = (event.get("unmapped") or {}).get( + "requirement_attributes", {} + ) + assert "_raw_attributes" not in requirement_attrs + assert "raw_attributes" not in requirement_attrs diff --git a/tests/lib/outputs/compliance/prowler_threatscore/__init__.py b/tests/lib/outputs/compliance/prowler_threatscore/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py new file mode 100644 index 0000000000..d754395dc3 --- /dev/null +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py @@ -0,0 +1,147 @@ +import re +from types import SimpleNamespace +from unittest import mock + +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( + get_prowler_threatscore_table, +) + +# Patch target for the Compliance.get_bulk lookup used to render pillars without +# findings; the tests don't exercise that path so it returns nothing. +COMPLIANCE_PATH = ( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore.Compliance" +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, pillars, framework="ProwlerThreatScore"): + """Build a per-check compliance covering the given pillars (Section).""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace( + Attributes=[SimpleNamespace(Section=pillar, LevelOfRisk=5, Weight=100)] + ) + for pillar in pillars + ], + ) + + +class TestProwlerThreatScoreTable: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_pillar_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several pillars must show FAIL(1) in + every pillar, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Encryption must report FAIL(1); before the fix Encryption + # was undercounted because the per-pillar count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_pillar_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several pillars must increase the + per-pillar Muted count in every pillar, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Encryption, so the Muted + # column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ProwlerThreatScore + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/universal/__init__.py b/tests/lib/outputs/compliance/universal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/universal/fixtures/generic_compliance.json b/tests/lib/outputs/compliance/universal/fixtures/generic_compliance.json new file mode 100644 index 0000000000..e5e6257369 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/fixtures/generic_compliance.json @@ -0,0 +1,185 @@ +{ + "framework": "GenericCompliance", + "name": "Generic Compliance Framework", + "version": "1.0", + "description": "A generic compliance framework for validating common cloud security best practices using the universal compliance schema. Demonstrates both single-list and multi-provider dict Checks.", + "icon": "prowlerthreatscore", + "attributes_metadata": [ + { + "key": "Section", + "label": "Section", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "SubSection", + "label": "Sub Section", + "type": "str", + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "Type", + "label": "Type", + "type": "str", + "enum": [ + "automated", + "manual" + ], + "required": false, + "output_formats": { + "csv": true, + "ocsf": true + } + } + ], + "outputs": { + "table_config": { + "group_by": "Section" + } + }, + "requirements": [ + { + "id": "gen-1.1", + "description": "Ensure IAM password policy requires minimum password length of 14 or greater", + "name": "Password Policy Length", + "attributes": { + "Section": "1. Identity and Access Management", + "SubSection": "1.1 Password Policies", + "Type": "automated" + }, + "checks": { + "aws": [ + "iam_password_policy_minimum_length_14" + ] + } + }, + { + "id": "gen-1.2", + "description": "Ensure multi-factor authentication (MFA) is enabled for all IAM users with console access", + "name": "MFA for Console Users", + "attributes": { + "Section": "1. Identity and Access Management", + "SubSection": "1.2 Multi-Factor Authentication", + "Type": "automated" + }, + "checks": { + "aws": [ + "iam_user_mfa_enabled_console_access" + ], + "azure": [ + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa" + ] + } + }, + { + "id": "gen-1.3", + "description": "Ensure the root account has MFA enabled", + "name": "Root Account MFA", + "attributes": { + "Section": "1. Identity and Access Management", + "SubSection": "1.2 Multi-Factor Authentication", + "Type": "automated" + }, + "checks": { + "aws": [ + "iam_root_mfa_enabled" + ] + } + }, + { + "id": "gen-2.1", + "description": "Ensure audit logging is enabled in all regions", + "name": "Audit Logging Multi-Region", + "attributes": { + "Section": "2. Logging and Monitoring", + "SubSection": "2.1 Audit Logging", + "Type": "automated" + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "logging_audit_logging_enabled" + ] + } + }, + { + "id": "gen-2.2", + "description": "Ensure audit log file validation is enabled", + "name": "Audit Log Validation", + "attributes": { + "Section": "2. Logging and Monitoring", + "SubSection": "2.1 Audit Logging", + "Type": "automated" + }, + "checks": { + "aws": [ + "cloudtrail_log_file_validation_enabled" + ] + } + }, + { + "id": "gen-3.1", + "description": "Ensure no security groups allow ingress from 0.0.0.0/0 to port 22", + "name": "SSH Public Access", + "attributes": { + "Section": "3. Networking", + "SubSection": "3.1 Security Groups", + "Type": "automated" + }, + "checks": { + "aws": [ + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" + ], + "azure": [ + "network_ssh_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_ssh_access_from_the_internet_allowed" + ] + } + }, + { + "id": "gen-4.1", + "description": "Ensure object storage versioning is enabled", + "name": "Object Storage Versioning", + "attributes": { + "Section": "4. Data Protection", + "SubSection": "4.1 Object Storage", + "Type": "automated" + }, + "checks": { + "aws": [ + "s3_bucket_object_versioning" + ], + "gcp": [ + "storage_bucket_versioning_enabled" + ] + } + }, + { + "id": "gen-5.1", + "description": "Review organizational security policies and procedures", + "name": "Security Policy Review", + "attributes": { + "Section": "5. Governance", + "SubSection": "5.1 Policies", + "Type": "manual" + }, + "checks": {} + } + ] +} diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_config_requirements_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_config_requirements_test.py new file mode 100644 index 0000000000..c846385d88 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_config_requirements_test.py @@ -0,0 +1,191 @@ +"""Integration coverage for ConfigRequirements in the OCSF compliance output. + +OCSF is the universal output path every framework renders through, so it is the +natural place to exercise the requirement-level config override end to end across +all operators. When a requirement's configurable check ran with a config too +loose to trust, the Compliance status must be FAIL (even on a PASS finding) and +the message must carry the ``Configuration not valid`` marker. The Check status keeps +the real finding status. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_email = "" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.account_tags = {"env": "test"} + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.compliance = {} + finding.metadata = SimpleNamespace( + Provider=provider, + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + CheckType=["test-type"], + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + Risk="risk", + RelatedUrl="https://example.com", + Remediation=SimpleNamespace( + Recommendation=SimpleNamespace(Text="Fix", Url="https://fix.com"), + ), + DependsOn=[], + RelatedTo=[], + Categories=["test"], + Notes="", + AdditionalURLs=[], + ) + return finding + + +def _framework(check_id, constraint): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Requirement REQ-1", + attributes={}, + checks={"aws": [check_id]}, + config_requirements=[constraint], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test framework", + requirements=[req], + attributes_metadata=None, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _run(check_id, constraint, audit_config): + fw = _framework(check_id, constraint) + findings = [_finding(check_id, "PASS")] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + out = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + return out.data[0] + + +# (check, constraint, violating_config, valid_config) +_CASES = [ + ( + "securityhub_enabled", + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + }, + {"mute_non_default_regions": True}, + {"mute_non_default_regions": False}, + ), + ( + "iam_user_accesskey_unused", + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + }, + {"max_unused_access_keys_days": 120}, + {"max_unused_access_keys_days": 30}, + ), + ( + "cloudwatch_log_group_retention_policy_specific_days_enabled", + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365, + }, + {"log_group_retention_days": 90}, + {"log_group_retention_days": 365}, + ), + ( + "sqlserver_recommended_minimal_tls_version", + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": ["1.2", "1.3"], + }, + {"recommended_minimal_tls_versions": ["1.0", "1.2", "1.3"]}, + {"recommended_minimal_tls_versions": ["1.3"]}, + ), + ( + "acm_certificates_with_secure_key_algorithms", + { + "Check": "acm_certificates_with_secure_key_algorithms", + "ConfigKey": "insecure_key_algorithms", + "Operator": "superset", + "Value": ["RSA-1024", "P-192"], + }, + {"insecure_key_algorithms": ["P-192"]}, + {"insecure_key_algorithms": ["RSA-1024", "P-192"]}, + ), +] + + +class Test_OCSF_Config_Requirements: + @pytest.mark.parametrize( + "check,constraint,bad,ok", + _CASES, + ids=[c[1]["Operator"] for c in _CASES], + ) + def test_violating_config_fails_requirement(self, check, constraint, bad, ok): + cf = _run(check, constraint, bad) + assert cf.compliance.status_id == ComplianceStatusID.Fail + assert "Configuration not valid" in cf.message + + @pytest.mark.parametrize( + "check,constraint,bad,ok", + _CASES, + ids=[c[1]["Operator"] for c in _CASES], + ) + def test_valid_config_keeps_pass(self, check, constraint, bad, ok): + cf = _run(check, constraint, ok) + assert cf.compliance.status_id == ComplianceStatusID.Pass + assert "Configuration not valid" not in cf.message + + def test_absent_config_assumes_default_ok(self): + check, constraint, _bad, _ok = _CASES[0] + cf = _run(check, constraint, {}) + assert cf.compliance.status_id == ComplianceStatusID.Pass diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_status_scoping_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_status_scoping_test.py new file mode 100644 index 0000000000..8408868296 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_status_scoping_test.py @@ -0,0 +1,129 @@ +"""Top-level status consistency and provider scoping for the OCSF output. + +Two regressions are covered here: + +1. The event's top-level ``status_code``/``status_detail`` must reflect the + effective (config-aware) status, so a config-invalid PASS cannot produce an + event where ``compliance.status_id`` says FAIL while ``status_code`` still + says PASS. The nested Check object keeps the raw finding status. +2. Provider scoping: an Azure-scoped constraint must never affect an AWS output + even when the global provider would otherwise be relied upon. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.metadata = SimpleNamespace( + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + ) + return finding + + +def _framework(constraint, provider="AWS", check_provider="aws"): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Requirement REQ-1", + attributes={}, + checks={check_provider: ["check_a"]}, + config_requirements=[constraint], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider=provider, + version="1.0", + description="Test framework", + requirements=[req], + attributes_metadata=None, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _run(framework, audit_config, provider="aws", status="PASS"): + findings = [_finding("check_a", status, provider)] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + mock_gp.return_value.type = provider + out = OCSFComplianceOutput( + findings=findings, framework=framework, provider=provider + ) + return out.data[0] + + +_CONSTRAINT = { + "Check": "check_a", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, +} + + +class Test_OCSF_TopLevel_Status: + def test_config_invalid_pass_forces_toplevel_fail(self): + event = _run(_framework(_CONSTRAINT), {"max_unused_access_keys_days": 120}) + # Top-level and nested compliance status agree: both FAIL. + assert event.compliance.status_id == ComplianceStatusID.Fail + assert event.status_code == "FAIL" + assert "Configuration not valid" in event.status_detail + assert event.status_detail == event.message + # The nested Check preserves the raw finding result. + assert event.compliance.checks[0].status == "PASS" + + def test_valid_config_keeps_toplevel_pass(self): + event = _run(_framework(_CONSTRAINT), {"max_unused_access_keys_days": 30}) + assert event.compliance.status_id == ComplianceStatusID.Pass + assert event.status_code == "PASS" + assert "Configuration not valid" not in event.status_detail + + +class Test_OCSF_Provider_Scoping: + def test_azure_constraint_does_not_affect_aws_output(self): + constraint = {**_CONSTRAINT, "Provider": "azure"} + event = _run( + _framework(constraint), {"max_unused_access_keys_days": 120}, provider="aws" + ) + assert event.compliance.status_id == ComplianceStatusID.Pass + assert event.status_code == "PASS" + assert "Configuration not valid" not in event.status_detail diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py new file mode 100644 index 0000000000..db6b78de28 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py @@ -0,0 +1,753 @@ +import json +from datetime import datetime, timezone +from types import SimpleNamespace + +from py_ocsf_models.events.base_event import StatusID as EventStatusID +from py_ocsf_models.events.findings.compliance_finding import ComplianceFinding +from py_ocsf_models.events.findings.compliance_finding_type_id import ( + ComplianceFindingTypeID, +) +from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID + +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ComplianceFramework, + OutputFormats, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( + OCSFComplianceOutput, + _sanitize_resource_data, +) + + +def _make_finding(check_id, status="PASS", provider="aws"): + """Create a mock Finding with all fields needed by OCSFComplianceOutput.""" + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.account_email = "" + finding.account_organization_uid = "org-123" + finding.account_organization_name = "test-org" + finding.account_tags = {"env": "test"} + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.resource_details = "some details" + finding.resource_metadata = {} + finding.resource_tags = {"Name": "test"} + finding.partition = "aws" + finding.muted = False + finding.check_id = check_id + finding.uid = "test-finding-uid" + finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + finding.prowler_version = "5.0.0" + finding.compliance = {} + finding.metadata = SimpleNamespace( + Provider=provider, + CheckID=check_id, + CheckTitle=f"Title for {check_id}", + CheckType=["test-type"], + Description=f"Description for {check_id}", + Severity="medium", + ServiceName="iam", + ResourceType="aws-iam-role", + Risk="test-risk", + RelatedUrl="https://example.com", + Remediation=SimpleNamespace( + Recommendation=SimpleNamespace(Text="Fix it", Url="https://fix.com"), + ), + DependsOn=[], + RelatedTo=[], + Categories=["test"], + Notes="", + AdditionalURLs=[], + ) + return finding + + +def _make_framework(requirements, attrs_metadata=None): + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test framework", + requirements=requirements, + attributes_metadata=attrs_metadata, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _simple_requirement(req_id="REQ-1", checks=None): + if checks is None: + checks_dict = {"aws": ["check_a"]} + elif isinstance(checks, dict): + checks_dict = checks + else: + checks_dict = {"aws": list(checks)} if checks else {} + return UniversalComplianceRequirement( + id=req_id, + description=f"Description for {req_id}", + attributes={}, + checks=checks_dict, + ) + + +class TestOCSFComplianceOutput: + def test_transform_basic(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + assert isinstance(output.data[0], ComplianceFinding) + + def test_class_uid(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].class_uid == 2003 + + def test_type_uid(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].type_uid == ComplianceFindingTypeID.Create + assert output.data[0].type_uid == 200301 + + def test_compliance_object_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + compliance = output.data[0].compliance + assert compliance.standards == ["TestFW-1.0"] + assert compliance.requirements == ["REQ-1"] + assert compliance.control == "Description for REQ-1" + assert compliance.status_id == ComplianceStatusID.Pass + + def test_check_object_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "FAIL")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + checks = output.data[0].compliance.checks + assert len(checks) == 1 + assert checks[0].uid == "check_a" + assert checks[0].name == "Title for check_a" + assert checks[0].status == "FAIL" + assert checks[0].status_id == ComplianceStatusID.Fail + + def test_finding_info_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + info = output.data[0].finding_info + assert info.uid == "test-finding-uid-REQ-1" + assert info.title == "REQ-1" + assert info.desc == "Description for REQ-1" + + def test_metadata_fields(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + metadata = output.data[0].metadata + assert metadata.product.name == "Prowler" + assert metadata.product.uid == "prowler" + assert metadata.event_code == "check_a" + + def test_status_mapping_pass(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].compliance.status_id == ComplianceStatusID.Pass + + def test_status_mapping_fail(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "FAIL")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert output.data[0].compliance.status_id == ComplianceStatusID.Fail + + def test_manual_requirement(self): + req = _simple_requirement("MANUAL-1", checks=[]) + fw = _make_framework([req]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + cf = output.data[0] + assert cf.compliance.status_id == ComplianceStatusID.Unknown + assert cf.status_code == "MANUAL" + assert cf.finding_info.uid == "manual-MANUAL-1" + + def test_include_manual_false_skips_manual(self): + """``_transform(..., include_manual=False)`` emits check events but + NOT manual requirement events. The streaming caller passes ``False`` + for batches 2..N so manual events are not duplicated.""" + covered = _simple_requirement("REQ-1", ["check_a"]) + manual = _simple_requirement("MANUAL-1", checks=[]) + fw = _make_framework([covered, manual]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + # __init__ transforms with include_manual=True (default) → manual present + assert any(cf.status_code == "MANUAL" for cf in output.data) + + # A subsequent batch re-transforms with include_manual=False + output._data.clear() + output._transform(findings, fw, "TestFW-1.0", include_manual=False) + + assert len(output.data) == 1 # only the check event, no manual + assert all(cf.status_code != "MANUAL" for cf in output.data) + + def test_multi_provider_checks_dict(self): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Multi-provider req", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a", "PASS", provider="aws")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + assert output.data[0].compliance.checks[0].uid == "check_a" + + def test_empty_findings(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + + output = OCSFComplianceOutput(findings=[], framework=fw, provider="aws") + + assert output.data == [] + + def test_cloud_info_in_unmapped(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", provider="aws")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped is not None + assert cf.unmapped["cloud"]["provider"] == "aws" + assert cf.unmapped["cloud"]["account"]["uid"] == "123456789012" + assert cf.unmapped["cloud"]["account"]["name"] == "test-account" + + def test_resources_populated(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + resources = output.data[0].resources + assert len(resources) == 1 + assert resources[0].name == "check_a" + assert resources[0].uid == "arn:aws:iam::123456789012:check_a" + assert resources[0].type == "aws-iam-role" + + def test_batch_write_to_file(self, tmp_path): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [_make_finding("check_a", "PASS")] + filepath = str(tmp_path / "compliance.ocsf.json") + + output = OCSFComplianceOutput( + findings=findings, framework=fw, file_path=filepath, provider="aws" + ) + output.batch_write_data_to_file() + + with open(filepath) as f: + data = json.load(f) + + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["class_uid"] == 2003 + assert data[0]["compliance"]["standards"] == ["TestFW-1.0"] + assert data[0]["compliance"]["requirements"] == ["REQ-1"] + + def test_multiple_findings_same_requirement(self): + fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])]) + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_a", "FAIL"), + ] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 2 + statuses = [cf.compliance.status_id for cf in output.data] + assert ComplianceStatusID.Pass in statuses + assert ComplianceStatusID.Fail in statuses + + def test_requirement_attributes_in_unmapped(self): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test requirement", + attributes={"Section": "IAM", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped is not None + assert "requirement_attributes" in cf.unmapped + assert cf.unmapped["requirement_attributes"]["section"] == "IAM" + assert cf.unmapped["requirement_attributes"]["profile"] == "Level 1" + + def test_requirement_attributes_keys_are_snake_case(self): + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test requirement", + attributes={"Section": "IAM", "CCMLite": "Yes", "SubSection": "1.1"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "ccm_lite" in attrs + assert "sub_section" in attrs + + def test_requirement_attributes_empty_attrs_excluded(self): + req = _simple_requirement("REQ-1", checks=["check_a"]) + fw = _make_framework([req]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + # Cloud info is still present, but no requirement_attributes key + assert cf.unmapped is not None + assert "cloud" in cf.unmapped + assert "requirement_attributes" not in cf.unmapped + + def test_manual_requirement_has_attributes_in_unmapped(self): + req = UniversalComplianceRequirement( + id="MANUAL-1", + description="Manual check", + attributes={"Section": "Logging", "Type": "manual"}, + checks={}, + ) + fw = _make_framework([req]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + assert len(output.data) == 1 + cf = output.data[0] + assert cf.unmapped is not None + assert cf.unmapped["requirement_attributes"]["section"] == "Logging" + assert cf.unmapped["requirement_attributes"]["type"] == "manual" + # Manual findings have no cloud info (finding is None) + assert "cloud" not in cf.unmapped + + def test_ocsf_metadata_filters_attributes(self): + """Attributes with output_formats.ocsf=False should be excluded from unmapped.""" + metadata = [ + AttributeMetadata( + key="Section", + type="str", + output_formats=OutputFormats(ocsf=True), + ), + AttributeMetadata( + key="InternalNote", + type="str", + output_formats=OutputFormats(ocsf=False), + ), + AttributeMetadata( + key="Profile", + type="str", + output_formats=OutputFormats(ocsf=True), + ), + ] + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={ + "Section": "IAM", + "InternalNote": "skip me", + "Profile": "Level 1", + }, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "profile" in attrs + assert "internal_note" not in attrs + + def test_ocsf_metadata_all_false_excludes_all(self): + """When all attributes have output_formats.ocsf=False, requirement_attributes should be empty.""" + metadata = [ + AttributeMetadata( + key="Section", type="str", output_formats=OutputFormats(ocsf=False) + ), + ] + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped is not None + # requirement_attributes should not appear since all attrs are filtered out + assert "requirement_attributes" not in cf.unmapped + + def test_ocsf_no_metadata_includes_all(self): + """Without attributes_metadata, all attributes should be included (backward compat).""" + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={"Section": "IAM", "Custom": "value"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=None) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "custom" in attrs + + def test_ocsf_default_is_true(self): + """output_formats.ocsf defaults to True — attributes are included unless explicitly excluded.""" + metadata = [ + AttributeMetadata(key="Section", type="str"), + AttributeMetadata(key="Profile", type="str"), + ] + req = UniversalComplianceRequirement( + id="REQ-1", + description="Test", + attributes={"Section": "IAM", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + attrs = output.data[0].unmapped["requirement_attributes"] + assert "section" in attrs + assert "profile" in attrs + + def test_ocsf_filter_on_manual_requirements(self): + """OCSF filtering should also apply to manual requirements.""" + metadata = [ + AttributeMetadata( + key="Section", type="str", output_formats=OutputFormats(ocsf=True) + ), + AttributeMetadata( + key="InternalNote", + type="str", + output_formats=OutputFormats(ocsf=False), + ), + ] + req = UniversalComplianceRequirement( + id="MANUAL-1", + description="Manual", + attributes={"Section": "Logging", "InternalNote": "hidden"}, + checks={}, + ) + fw = _make_framework([req], attrs_metadata=metadata) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + + cf = output.data[0] + assert cf.unmapped["requirement_attributes"]["section"] == "Logging" + assert "internal_note" not in cf.unmapped["requirement_attributes"] + + +class TestSanitizeResourceData: + """Unit tests for the _sanitize_resource_data helper. + + Service resources may carry non-JSON-serializable objects (e.g. raw + Pydantic models such as ``Trail`` or ``LifecyclePolicy``). The helper + must convert them so the resulting ComplianceFinding can be serialized. + """ + + def test_dict_passthrough(self): + result = _sanitize_resource_data("details", {"a": 1, "b": "two"}) + assert result == {"details": "details", "metadata": {"a": 1, "b": "two"}} + + def test_none_metadata(self): + result = _sanitize_resource_data("details", None) + assert result == {"details": "details", "metadata": None} + + def test_pydantic_v2_model_dump(self): + class FakeV2Model: + def model_dump(self): + return {"name": "trail-1", "region": "us-east-1"} + + result = _sanitize_resource_data("d", {"trail": FakeV2Model()}) + assert result["metadata"]["trail"] == { + "name": "trail-1", + "region": "us-east-1", + } + + def test_pydantic_v1_dict(self): + class FakeV1Model: + def dict(self): + return {"name": "policy-1", "schedule": "daily"} + + result = _sanitize_resource_data("d", {"policy": FakeV1Model()}) + assert result["metadata"]["policy"] == { + "name": "policy-1", + "schedule": "daily", + } + + def test_nested_pydantic_in_list(self): + class FakeModel: + def model_dump(self): + return {"id": "x"} + + result = _sanitize_resource_data("d", {"items": [FakeModel(), FakeModel()]}) + assert result["metadata"]["items"] == [{"id": "x"}, {"id": "x"}] + + def test_nested_dict_recursion(self): + class FakeInner: + def model_dump(self): + return {"k": "v"} + + result = _sanitize_resource_data( + "d", {"outer": {"inner": FakeInner(), "x": [1, 2]}} + ) + assert result["metadata"]["outer"]["inner"] == {"k": "v"} + assert result["metadata"]["outer"]["x"] == [1, 2] + + def test_tuple_to_list(self): + result = _sanitize_resource_data("d", {"t": (1, 2, "three")}) + assert result["metadata"]["t"] == [1, 2, "three"] + + def test_non_string_dict_keys_coerced(self): + result = _sanitize_resource_data("d", {1: "a", 2: "b"}) + assert result["metadata"] == {"1": "a", "2": "b"} + + def test_unknown_object_falls_back_to_str(self): + class Opaque: + def __str__(self): + return "opaque-repr" + + result = _sanitize_resource_data("d", {"thing": Opaque()}) + assert result["metadata"]["thing"] == "opaque-repr" + + def test_circular_reference_falls_back_to_empty(self): + a = {} + a["self"] = a + # json.dumps raises ValueError on recursion → fallback to empty metadata + result = _sanitize_resource_data("d", a) + assert result == {"details": "d", "metadata": {}} + + def test_serializes_via_full_finding_pipeline(self): + """End-to-end: a finding with a non-serializable resource_metadata + produces a JSON-serializable ComplianceFinding.""" + + class TrailLike: + def __init__(self): + self.name = "trail-A" + self.kms_key_id = "arn:aws:kms:..." + + def model_dump(self): + return {"name": self.name, "kms_key_id": self.kms_key_id} + + finding = _make_finding("check_a") + finding.resource_metadata = {"trail": TrailLike()} + req = _simple_requirement() + fw = _make_framework([req]) + + output = OCSFComplianceOutput(findings=[finding], framework=fw, provider="aws") + + # Serialize the resulting ComplianceFinding — must NOT raise + cf = output.data[0] + if hasattr(cf, "model_dump_json"): + json_output = cf.model_dump_json(exclude_none=True) + else: + json_output = cf.json(exclude_none=True) + payload = json.loads(json_output) + + # Confirm the trail object made it through as a plain dict + assert payload["resources"][0]["data"]["metadata"]["trail"]["name"] == "trail-A" + + +class TestEventStatusInline: + """Tests for the inlined event_status logic that replaced + OCSF.get_finding_status_id() to break the cyclic import.""" + + def test_unmuted_finding_status_new(self): + finding = _make_finding("check_a") + finding.muted = False + req = _simple_requirement() + fw = _make_framework([req]) + + output = OCSFComplianceOutput(findings=[finding], framework=fw, provider="aws") + cf = output.data[0] + + assert cf.status_id == EventStatusID.New.value + assert cf.status == EventStatusID.New.name + + def test_muted_finding_status_suppressed(self): + finding = _make_finding("check_a") + finding.muted = True + req = _simple_requirement() + fw = _make_framework([req]) + + output = OCSFComplianceOutput(findings=[finding], framework=fw, provider="aws") + cf = output.data[0] + + assert cf.status_id == EventStatusID.Suppressed.value + assert cf.status == EventStatusID.Suppressed.name + + +class TestNoTopLevelOCSFImport: + """Regression test: the top-level OCSF/Finding imports were removed + to break the CodeQL cyclic-import warnings. Ensure they stay out of + the runtime namespace of the module (TYPE_CHECKING block only).""" + + def test_finding_not_in_runtime_namespace(self): + import prowler.lib.outputs.compliance.universal.ocsf_compliance as mod + + assert "Finding" not in dir(mod) + + def test_ocsf_class_not_imported(self): + import prowler.lib.outputs.compliance.universal.ocsf_compliance as mod + + assert "OCSF" not in dir(mod) + + +def _mitre_requirement(req_id="T1078", entries=None): + """Build a MITRE-style requirement with `_raw_attributes` wrapping.""" + return UniversalComplianceRequirement( + id=req_id, + description="Valid Accounts", + attributes={ + "_raw_attributes": entries + or [{"AWSService": "IAM", "Category": "Initial Access"}] + }, + checks={"aws": ["check_a"]}, + ) + + +class TestMitreRawAttributes: + """MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak + the marker key into the OCSF payload.""" + + def test_raw_attributes_key_not_in_unmapped(self): + framework = _make_framework([_mitre_requirement()]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + + requirement_attrs = (output.data[0].unmapped or {}).get( + "requirement_attributes", {} + ) + assert "_raw_attributes" not in requirement_attrs + assert "raw_attributes" not in requirement_attrs + + def test_finding_serializes_with_raw_attributes(self): + framework = _make_framework( + [ + _mitre_requirement( + entries=[ + {"AWSService": "IAM", "Category": "Initial Access"}, + {"AWSService": "STS", "Category": "Privilege Escalation"}, + ] + ) + ] + ) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + compliance_finding = output.data[0] + if hasattr(compliance_finding, "model_dump_json"): + payload = json.loads(compliance_finding.model_dump_json(exclude_none=True)) + else: + payload = json.loads(compliance_finding.json(exclude_none=True)) + assert payload["compliance"]["requirements"] == ["T1078"] + + +class TestProviderFiltering: + """OCSF writer scopes findings against `requirement.checks[provider]`.""" + + def test_check_for_other_provider_not_emitted(self): + azure_only_requirement = UniversalComplianceRequirement( + id="REQ-1", + description="Azure-only requirement", + attributes={}, + checks={"azure": ["check_a"]}, + ) + framework = _make_framework([azure_only_requirement]) + findings = [_make_finding("check_a", "PASS", provider="aws")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + + assert all( + compliance_finding.status_code == "MANUAL" + for compliance_finding in output.data + ) + + def test_no_provider_aggregates_all_checks(self): + multi_provider_requirement = UniversalComplianceRequirement( + id="REQ-1", + description="Multi-provider requirement", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ) + framework = _make_framework([multi_provider_requirement]) + findings = [ + _make_finding("check_a", "PASS", provider="aws"), + _make_finding("check_b", "FAIL", provider="azure"), + ] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider=None + ) + + statuses = sorted( + compliance_finding.status_code for compliance_finding in output.data + ) + assert statuses == ["FAIL", "PASS"] diff --git a/tests/lib/outputs/compliance/universal/universal_output_config_requirements_test.py b/tests/lib/outputs/compliance/universal/universal_output_config_requirements_test.py new file mode 100644 index 0000000000..dd83dad575 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/universal_output_config_requirements_test.py @@ -0,0 +1,115 @@ +"""Coverage for ConfigRequirements + provider scoping in the universal CSV. + +The universal CSV must apply the same effective-status logic as the OCSF/table +outputs: a config-invalid PASS is reported as FAIL instead of leaking the raw +finding status. Provider scoping must also hold, so a constraint scoped to +another provider (e.g. Azure) never affects this provider's output (e.g. AWS). +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.universal_output import ( + UniversalComplianceOutput, +) + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" + + +def _make_finding(check_id, status="PASS", provider="aws"): + finding = SimpleNamespace() + finding.provider = provider + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.muted = False + finding.check_id = check_id + finding.metadata = SimpleNamespace(Provider=provider, CheckID=check_id) + finding.compliance = {} + return finding + + +def _make_framework(constraint, provider="AWS", check_provider="aws"): + req = UniversalComplianceRequirement( + id="1.1", + description="test requirement", + attributes={"Section": "IAM"}, + checks={check_provider: ["check_a"]}, + config_requirements=[constraint], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider=provider, + version="1.0", + description="Test framework", + requirements=[req], + attributes_metadata=[AttributeMetadata(key="Section", type="str")], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _run(framework, audit_config, provider="aws", status="PASS"): + findings = [_make_finding("check_a", status, provider)] + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + mock_gp.return_value.type = provider + out = UniversalComplianceOutput( + findings=findings, framework=framework, provider=provider + ) + return out.data[0].dict() + + +class Test_Universal_CSV_Config_Requirements: + _CONSTRAINT = { + "Check": "check_a", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + } + + def test_violating_config_forces_fail(self): + fw = _make_framework(self._CONSTRAINT) + row = _run(fw, {"max_unused_access_keys_days": 120}) + assert row["Status"] == "FAIL" + assert "Configuration not valid" in row["StatusExtended"] + + def test_valid_config_keeps_pass(self): + fw = _make_framework(self._CONSTRAINT) + row = _run(fw, {"max_unused_access_keys_days": 30}) + assert row["Status"] == "PASS" + assert "Configuration not valid" not in row["StatusExtended"] + + def test_absent_config_assumes_default_ok(self): + fw = _make_framework(self._CONSTRAINT) + row = _run(fw, {}) + assert row["Status"] == "PASS" + + +class Test_Universal_CSV_Provider_Scoping: + def test_azure_constraint_does_not_affect_aws_output(self): + """An Azure-scoped constraint must not force an AWS output to FAIL.""" + constraint = { + "Check": "check_a", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "azure", + } + fw = _make_framework(constraint) + # Even with a config that *would* violate the constraint, the AWS output + # must keep PASS because the constraint is scoped to Azure. + row = _run(fw, {"max_unused_access_keys_days": 120}, provider="aws") + assert row["Status"] == "PASS" + assert "Configuration not valid" not in row["StatusExtended"] diff --git a/tests/lib/outputs/compliance/universal/universal_output_test.py b/tests/lib/outputs/compliance/universal/universal_output_test.py new file mode 100644 index 0000000000..d0fdf0f178 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/universal_output_test.py @@ -0,0 +1,605 @@ +from types import SimpleNamespace + +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ComplianceFramework, + OutputFormats, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.universal_output import ( + UniversalComplianceOutput, +) + + +def _make_finding(check_id, status="PASS", compliance_map=None): + """Create a mock Finding for output tests.""" + finding = SimpleNamespace() + finding.provider = "aws" + finding.account_uid = "123456789012" + finding.account_name = "test-account" + finding.region = "us-east-1" + finding.status = status + finding.status_extended = f"{check_id} is {status}" + finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}" + finding.resource_name = check_id + finding.muted = False + finding.check_id = check_id + finding.metadata = SimpleNamespace( + Provider="aws", + CheckID=check_id, + Severity="medium", + ) + finding.compliance = compliance_map or {} + return finding + + +def _make_framework(requirements, attrs_metadata=None, table_config=None): + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test framework", + requirements=requirements, + attributes_metadata=attrs_metadata, + outputs=OutputsConfig(table_config=table_config) if table_config else None, + ) + + +class TestDynamicCSVColumns: + def test_columns_match_metadata(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "SubSection": "Auth"}, + checks={"aws": ["check_a"]}, + ), + ] + metadata = [ + AttributeMetadata(key="Section", type="str"), + AttributeMetadata(key="SubSection", type="str"), + ] + fw = _make_framework(reqs, metadata, TableConfig(group_by="Section")) + + findings = [ + _make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]}), + ] + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + ) + + assert len(output.data) == 1 + row_dict = output.data[0].dict() + assert "Requirements_Attributes_Section" in row_dict + assert "Requirements_Attributes_SubSection" in row_dict + assert row_dict["Requirements_Attributes_Section"] == "IAM" + assert row_dict["Requirements_Attributes_SubSection"] == "Auth" + + +class TestManualRequirements: + def test_manual_status(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="manual-1", + description="manual check", + attributes={"Section": "Governance"}, + checks={}, + ), + ] + metadata = [ + AttributeMetadata(key="Section", type="str"), + ] + fw = _make_framework(reqs, metadata, TableConfig(group_by="Section")) + + findings = [ + _make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]}), + ] + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + ) + + # Should have 1 real finding + 1 manual + assert len(output.data) == 2 + manual_rows = [r for r in output.data if r.dict()["Status"] == "MANUAL"] + assert len(manual_rows) == 1 + assert manual_rows[0].dict()["Requirements_Id"] == "manual-1" + assert manual_rows[0].dict()["ResourceId"] == "manual_check" + + def test_include_manual_false_skips_manual_rows(self, tmp_path): + """``_transform(..., include_manual=False)`` emits finding rows but + NOT manual requirements. The streaming caller passes ``False`` for + batches 2..N so manual rows are not duplicated across batches.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="manual-1", + description="manual check", + attributes={"Section": "Governance"}, + checks={}, + ), + ] + metadata = [AttributeMetadata(key="Section", type="str")] + fw = _make_framework(reqs, metadata, TableConfig(group_by="Section")) + findings = [_make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]})] + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "t.csv"), + ) + # __init__ transforms with include_manual=True (default) → manual present + assert any(r.dict()["Status"] == "MANUAL" for r in output.data) + + # A subsequent batch re-transforms with include_manual=False + output._data.clear() + output._transform(findings, fw, "TestFW-1.0", include_manual=False) + + assert len(output.data) == 1 # only the finding row, no manual + assert all(r.dict()["Status"] != "MANUAL" for r in output.data) + + +class TestMITREExtraColumns: + def test_mitre_columns_present(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="T1190", + description="Exploit", + attributes={}, + checks={"aws": ["check_a"]}, + tactics=["Initial Access"], + sub_techniques=[], + platforms=["IaaS"], + technique_url="https://attack.mitre.org/techniques/T1190/", + ), + ] + fw = _make_framework(reqs, None, TableConfig(group_by="_Tactics")) + + findings = [ + _make_finding("check_a", "PASS", {"TestFW-1.0": ["T1190"]}), + ] + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + ) + + assert len(output.data) == 1 + row_dict = output.data[0].dict() + assert "Requirements_Tactics" in row_dict + assert row_dict["Requirements_Tactics"] == "Initial Access" + assert "Requirements_TechniqueURL" in row_dict + + +class TestCSVFileWrite: + def test_batch_write(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + ] + metadata = [ + AttributeMetadata(key="Section", type="str"), + ] + fw = _make_framework(reqs, metadata, TableConfig(group_by="Section")) + + findings = [ + _make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]}), + ] + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + ) + output.batch_write_data_to_file() + + # Verify file was created and has content + with open(filepath, "r") as f: + content = f.read() + assert "PROVIDER" in content # Headers are uppercase + assert "REQUIREMENTS_ATTRIBUTES_SECTION" in content + assert "IAM" in content + + +class TestNoFindings: + def test_empty_findings_no_data(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + ] + fw = _make_framework(reqs, None, TableConfig(group_by="Section")) + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=[], + framework=fw, + file_path=filepath, + ) + assert len(output.data) == 0 + + +class TestMultiProviderOutput: + def test_dict_checks_filtered_by_provider(self, tmp_path): + """Only checks for the given provider appear in CSV output.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + metadata = [ + AttributeMetadata(key="Section", type="str"), + ] + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi", + version="1.0", + description="Test multi-provider", + requirements=reqs, + attributes_metadata=metadata, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + findings = [ + _make_finding("check_a", "PASS", {"MultiCloud-1.0": ["1.1"]}), + _make_finding("check_b", "FAIL", {"MultiCloud-1.0": ["1.1"]}), + ] + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + provider="aws", + ) + + # Only check_a should match (it's the AWS check) + assert len(output.data) == 1 + row_dict = output.data[0].dict() + assert row_dict["Requirements_Attributes_Section"] == "IAM" + + def test_no_provider_includes_all(self, tmp_path): + """Without provider filter, all checks from all providers are included.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + metadata = [ + AttributeMetadata(key="Section", type="str"), + ] + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi", + version="1.0", + description="Test multi-provider", + requirements=reqs, + attributes_metadata=metadata, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + findings = [ + _make_finding("check_a", "PASS", {"MultiCloud-1.0": ["1.1"]}), + _make_finding("check_b", "FAIL", {"MultiCloud-1.0": ["1.1"]}), + ] + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + ) + + # Both checks should be included without provider filter + assert len(output.data) == 2 + + def test_empty_dict_checks_is_manual(self, tmp_path): + """Requirement with empty dict checks is treated as manual.""" + reqs = [ + UniversalComplianceRequirement( + id="manual-1", + description="manual check", + attributes={"Section": "Governance"}, + checks={}, + ), + ] + metadata = [ + AttributeMetadata(key="Section", type="str"), + ] + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi", + version="1.0", + description="Test", + requirements=reqs, + attributes_metadata=metadata, + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=[_make_finding("other_check", "PASS", {})], + framework=fw, + file_path=filepath, + provider="aws", + ) + + manual_rows = [r for r in output.data if r.dict()["Status"] == "MANUAL"] + assert len(manual_rows) == 1 + assert manual_rows[0].dict()["Requirements_Id"] == "manual-1" + + +class TestCSVExclude: + def test_csv_false_excludes_column(self, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "Internal": "hidden"}, + checks={"aws": ["check_a"]}, + ), + ] + metadata = [ + AttributeMetadata( + key="Section", type="str", output_formats=OutputFormats(csv=True) + ), + AttributeMetadata( + key="Internal", type="str", output_formats=OutputFormats(csv=False) + ), + ] + fw = _make_framework(reqs, metadata, TableConfig(group_by="Section")) + + findings = [ + _make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]}), + ] + filepath = str(tmp_path / "test.csv") + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + ) + + row_dict = output.data[0].dict() + assert "Requirements_Attributes_Section" in row_dict + assert "Requirements_Attributes_Internal" not in row_dict + + +def _make_provider_finding(provider, check_id="check_a", status="PASS"): + """Create a mock Finding with a specific provider.""" + finding = _make_finding(check_id, status, {"TestFW-1.0": ["1.1"]}) + finding.provider = provider + return finding + + +def _simple_framework(): + all_providers = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "oraclecloud", + "alibabacloud", + "nhn", + "unknown", + ] + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={p: ["check_a"] for p in all_providers}, + ), + ] + metadata = [ + AttributeMetadata(key="Section", type="str"), + ] + return _make_framework(reqs, metadata, TableConfig(group_by="Section")) + + +class TestProviderHeaders: + def test_aws_headers(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("aws")] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "test.csv"), + provider="aws", + ) + row_dict = output.data[0].dict() + assert "AccountId" in row_dict + assert "Region" in row_dict + assert row_dict["AccountId"] == "123456789012" + assert row_dict["Region"] == "us-east-1" + + def test_azure_headers(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("azure")] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "test.csv"), + provider="azure", + ) + row_dict = output.data[0].dict() + assert "SubscriptionId" in row_dict + assert "Location" in row_dict + assert row_dict["SubscriptionId"] == "123456789012" + assert row_dict["Location"] == "us-east-1" + + def test_gcp_headers(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("gcp")] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "test.csv"), + provider="gcp", + ) + row_dict = output.data[0].dict() + assert "ProjectId" in row_dict + assert "Location" in row_dict + assert row_dict["ProjectId"] == "123456789012" + + def test_kubernetes_headers(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("kubernetes")] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "test.csv"), + provider="kubernetes", + ) + row_dict = output.data[0].dict() + assert "Context" in row_dict + assert "Namespace" in row_dict + # Kubernetes Context maps to account_name + assert row_dict["Context"] == "test-account" + assert row_dict["Namespace"] == "us-east-1" + + def test_github_headers(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("github")] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "test.csv"), + provider="github", + ) + row_dict = output.data[0].dict() + assert "Account_Name" in row_dict + assert "Account_Id" in row_dict + # GitHub: Account_Name (pos 3) from account_name, Account_Id (pos 4) from account_uid + assert row_dict["Account_Name"] == "test-account" + assert row_dict["Account_Id"] == "123456789012" + # Verify column order matches legacy (Account_Name before Account_Id) + keys = list(row_dict.keys()) + assert keys.index("Account_Name") < keys.index("Account_Id") + + def test_unknown_provider_defaults(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("unknown")] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "test.csv"), + provider="unknown", + ) + row_dict = output.data[0].dict() + assert "AccountId" in row_dict + assert "Region" in row_dict + + def test_none_provider_defaults(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("aws")] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "test.csv"), + ) + row_dict = output.data[0].dict() + assert "AccountId" in row_dict + assert "Region" in row_dict + + def test_csv_write_azure_headers(self, tmp_path): + fw = _simple_framework() + findings = [_make_provider_finding("azure")] + filepath = str(tmp_path / "test.csv") + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=filepath, + provider="azure", + ) + output.batch_write_data_to_file() + + with open(filepath, "r") as f: + content = f.read() + assert "SUBSCRIPTIONID" in content + assert "LOCATION" in content + # Should NOT have the default AccountId/Region headers + assert "ACCOUNTID" not in content + + def test_column_order_matches_legacy(self, tmp_path): + """Verify that the base column order matches the legacy per-provider models. + + Legacy models all define: Provider, Description, , , AssessmentDate, ... + The universal output must preserve this exact order for backward compatibility. + """ + # Expected column order per provider (positions 3 and 4 after Provider, Description) + legacy_order = { + "aws": ("AccountId", "Region"), + "azure": ("SubscriptionId", "Location"), + "gcp": ("ProjectId", "Location"), + "kubernetes": ("Context", "Namespace"), + "m365": ("TenantId", "Location"), + "github": ("Account_Name", "Account_Id"), + "oraclecloud": ("TenancyId", "Region"), + "alibabacloud": ("AccountId", "Region"), + "nhn": ("AccountId", "Region"), + } + + for provider_name, (expected_col3, expected_col4) in legacy_order.items(): + fw = _simple_framework() + findings = [_make_provider_finding(provider_name)] + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / f"test_{provider_name}.csv"), + provider=provider_name, + ) + keys = list(output.data[0].dict().keys()) + assert keys[0] == "Provider", f"{provider_name}: col 1 should be Provider" + assert ( + keys[1] == "Description" + ), f"{provider_name}: col 2 should be Description" + assert ( + keys[2] == expected_col3 + ), f"{provider_name}: col 3 should be {expected_col3}, got {keys[2]}" + assert ( + keys[3] == expected_col4 + ), f"{provider_name}: col 4 should be {expected_col4}, got {keys[3]}" + assert ( + keys[4] == "AssessmentDate" + ), f"{provider_name}: col 5 should be AssessmentDate" diff --git a/tests/lib/outputs/compliance/universal/universal_table_config_requirements_test.py b/tests/lib/outputs/compliance/universal/universal_table_config_requirements_test.py new file mode 100644 index 0000000000..3ac8dc6d8b --- /dev/null +++ b/tests/lib/outputs/compliance/universal/universal_table_config_requirements_test.py @@ -0,0 +1,155 @@ +"""Integration coverage for ConfigRequirements in the console table generators. + +The table generators aggregate pass/fail counts, so a requirement whose config +is too loose must count its (otherwise PASS) finding as FAIL. Driven through the +universal table renderer, which backs the table output for every framework using +the shared ``get_effective_status`` helper.""" + +from types import SimpleNamespace +from unittest.mock import patch + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + TableConfig, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.universal_table import get_universal_table + +_MODULE = "prowler.providers.common.provider.Provider.get_global_provider" +_CHECK = "securityhub_enabled" + + +def _finding(status="PASS"): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=_CHECK), status=status, muted=False + ) + + +# The overview table is only printed when there is more than one finding, so the +# tests use two PASS findings (both mapping the constrained check). +_FINDINGS = [_finding("PASS"), _finding("PASS")] + + +def _framework(): + req = UniversalComplianceRequirement( + id="1.1", + description="region check", + attributes={"Section": "Monitoring"}, + checks={"aws": [_CHECK]}, + config_requirements=[ + { + "Check": _CHECK, + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ], + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test", + requirements=[req], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +def _render(audit_config, capsys, output_directory): + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = audit_config + get_universal_table( + findings=_FINDINGS, + bulk_checks_metadata={}, + compliance_framework_name="testfw_1.0_aws", + output_filename="out", + output_directory=str(output_directory), + compliance_overview=False, + framework=_framework(), + provider="aws", + ) + return capsys.readouterr().out + + +class Test_Universal_Table_Config_Requirements: + def test_violating_config_counts_pass_finding_as_fail(self, capsys, tmp_path): + out = _render({"mute_non_default_regions": True}, capsys, tmp_path) + assert "FAIL(2)" in out + assert "PASS(2)" not in out + + def test_valid_config_keeps_pass_count(self, capsys, tmp_path): + out = _render({"mute_non_default_regions": False}, capsys, tmp_path) + assert "PASS(2)" in out + assert "FAIL(2)" not in out + + def test_absent_config_keeps_pass_count(self, capsys, tmp_path): + out = _render({}, capsys, tmp_path) + assert "PASS(2)" in out + assert "FAIL(2)" not in out + + +def _framework_two_requirements(): + """Same check evidences two requirements; only one carries a guardrail. + + Drives the double-count scenario: with the config violated, the shared + finding is FAIL for the constrained requirement and PASS for the other, so + its index would land in both pass and fail counts without FAIL precedence. + """ + constrained = UniversalComplianceRequirement( + id="1.1", + description="region check", + attributes={"Section": "Monitoring"}, + checks={"aws": [_CHECK]}, + config_requirements=[ + { + "Check": _CHECK, + "Provider": "aws", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": False, + } + ], + ) + unconstrained = UniversalComplianceRequirement( + id="2.1", + description="other check", + attributes={"Section": "Logging"}, + checks={"aws": [_CHECK]}, + ) + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="Test", + requirements=[constrained, unconstrained], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +class Test_Universal_Table_Multi_Requirement_Dedup: + def test_finding_in_two_requirements_counted_once_with_fail_precedence( + self, capsys, tmp_path + ): + # mute=True violates the constrained requirement → each shared PASS + # finding must be counted once as FAIL in the overview, not double + # counted as both PASS and FAIL across the two requirements it maps. + with patch(_MODULE) as mock_gp: + mock_gp.return_value.audit_config = {"mute_non_default_regions": True} + mock_gp.return_value.type = "aws" + get_universal_table( + findings=_FINDINGS, + bulk_checks_metadata={}, + compliance_framework_name="testfw_1.0_aws", + output_filename="out", + output_directory=str(tmp_path), + compliance_overview=True, + framework=_framework_two_requirements(), + provider="aws", + ) + out = capsys.readouterr().out + # Two findings, each counted once as FAIL → 100% FAIL, 0 PASS. + assert "(2) FAIL" in out + assert "(0) PASS" in out diff --git a/tests/lib/outputs/compliance/universal/universal_table_test.py b/tests/lib/outputs/compliance/universal/universal_table_test.py new file mode 100644 index 0000000000..abe3d0c3d0 --- /dev/null +++ b/tests/lib/outputs/compliance/universal/universal_table_test.py @@ -0,0 +1,677 @@ +import re +from types import SimpleNamespace +from unittest.mock import MagicMock + +from prowler.lib.check.compliance_models import ( + ComplianceFramework, + OutputsConfig, + ScoringConfig, + SplitByConfig, + TableConfig, + TableLabels, + UniversalComplianceRequirement, +) +from prowler.lib.outputs.compliance.universal.universal_table import ( + _build_requirement_check_map, + _get_group_key, + get_universal_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + """Create a mock finding for table tests.""" + finding = SimpleNamespace() + finding.check_metadata = SimpleNamespace(CheckID=check_id) + finding.status = status + finding.muted = muted + return finding + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_framework(requirements, table_config, provider="AWS"): + return ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider=provider, + version="1.0", + description="Test", + requirements=requirements, + outputs=OutputsConfig(table_config=table_config) if table_config else None, + ) + + +class TestBuildRequirementCheckMap: + """Test cases for building the requirement-to-check map of a framework.""" + + def test_basic(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "IAM"}, + checks={"aws": ["check_b", "check_c"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw) + assert "check_a" in check_map + assert len(check_map["check_b"]) == 2 + assert "check_c" in check_map + + def test_dict_checks_no_provider_filter(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw) + assert "check_a" in check_map + assert "check_b" in check_map + + def test_dict_checks_filtered_by_provider(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw, provider="aws") + assert "check_a" in check_map + assert "check_b" not in check_map + + def test_dict_checks_provider_not_present(self): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ] + fw = _make_framework(reqs, TableConfig(group_by="Section")) + check_map = _build_requirement_check_map(fw, provider="gcp") + assert len(check_map) == 0 + + +class TestGetGroupKey: + """Test cases for resolving the group key of a requirement.""" + + def test_normal_field(self): + req = UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={}, + ) + assert _get_group_key(req, "Section") == ["IAM"] + + def test_tactics(self): + req = UniversalComplianceRequirement( + id="T1190", + description="test", + attributes={}, + checks={}, + tactics=["Initial Access", "Execution"], + ) + assert _get_group_key(req, "_Tactics") == ["Initial Access", "Execution"] + + +class TestGroupedMode: + """Test cases for grouped-mode universal compliance table rendering.""" + + def test_grouped_rendering(self, capsys, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "IAM" in captured.out + assert "Logging" in captured.out + assert "PASS" in captured.out + assert "FAIL" in captured.out + + def test_grouped_multi_section_no_undercount(self, capsys, tmp_path): + """A single check mapped to several sections must be counted in + every section it belongs to, not only the first one seen.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging sections; check_b + # (PASS, IAM only) is added so the overview total reaches 2 and the + # results table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both the IAM and Logging rows must report FAIL(1). Before the fix the + # second section seen (Logging) was undercounted to FAIL(0) and rendered + # as PASS. Anchor each occurrence to its own table row so an unrelated + # "FAIL(1)" elsewhere cannot mask an undercount. + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + + def test_grouped_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several groups must be counted in + the per-group Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both IAM and Logging; check_b is a + # plain FAIL so the overview total reaches 2 and the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The muted finding belongs to both sections, so both the IAM row and + # the Logging row must carry a Muted count of 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + +class TestSplitMode: + """Test cases for split-mode universal compliance table rendering.""" + + def test_split_rendering(self, capsys, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "Storage", "Profile": "Level 2"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "Storage" in captured.out + assert "Level 1" in captured.out + assert "Level 2" in captured.out + + def test_split_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """In split mode a single MUTED finding mapped to several groups must + be counted in the Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both Storage and Logging; check_b is a + # plain FAIL so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 (last cell). Before the + # fix only the first group seen incremented Muted, leaving the other 0. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_split_same_group_value_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same group and split value must count once for that group/split, + not once per requirement (FAIL(1), never FAIL(2)).""" + reqs = [ + # check_a appears in two requirements, both Storage / Level 1. + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + # A second group so the table renders with more than one finding. + UniversalComplianceRequirement( + id="2.1", + description="test3", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The Storage row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + + +class TestScoredMode: + """Test cases for scored-mode universal compliance table rendering.""" + + def test_scored_rendering(self, capsys, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "IAM", "LevelOfRisk": 3, "Weight": 50}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "IAM" in captured.out + assert "Score" in captured.out + assert "Threat Score" in captured.out + + def test_scored_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """In scored mode a single FAIL finding mapped to several groups must + show FAIL(1) in every group it belongs to, not only the first one.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "LevelOfRisk": 3, "Weight": 50}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging; check_b (PASS, IAM + # only) raises the overview total to 2 so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + + +class TestCustomLabels: + """Test cases for custom-label universal compliance table rendering.""" + + def test_ens_spanish_labels(self, capsys, tmp_path): + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Marco": "operacional"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Marco": "organizativo"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Marco", + labels=TableLabels( + pass_label="CUMPLE", + fail_label="NO CUMPLE", + provider_header="Proveedor", + title="Estado de Cumplimiento", + ), + ) + fw = _make_framework(reqs, tc) + + findings = [_make_finding("check_a", "PASS"), _make_finding("check_b", "FAIL")] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + assert "CUMPLE" in captured.out + assert "Estado de Cumplimiento" in captured.out + + +class TestMultiProviderDictChecks: + """Test cases for multi-provider dict checks in the universal table.""" + + def test_only_aws_checks_matched(self, capsys, tmp_path): + """With dict checks and provider='aws', only AWS checks match findings.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_c"], "gcp": ["check_d"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi", + description="Test", + requirements=reqs, + outputs=OutputsConfig(table_config=tc), + ) + + findings = [ + _make_finding("check_a", "PASS"), + _make_finding("check_b", "FAIL"), # Azure check, should be ignored + _make_finding("check_c", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + "check_c": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "multi_cloud", + "output", + str(tmp_path), + False, + framework=fw, + provider="aws", + ) + + captured = capsys.readouterr() + assert "IAM" in captured.out + assert "Logging" in captured.out + # check_b (azure) should not have been counted as FAIL for AWS + assert "PASS" in captured.out + + +class TestNoTableConfig: + """Test cases for the universal table when no table config is present.""" + + def test_returns_early_without_table_config(self, capsys, tmp_path): + fw = ComplianceFramework( + framework="TestFW", + name="Test", + provider="AWS", + description="Test", + requirements=[], + ) + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=fw) + captured = capsys.readouterr() + assert captured.out == "" + + def test_returns_early_without_framework(self, capsys, tmp_path): + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=None) + captured = capsys.readouterr() + assert captured.out == "" diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index 00ce2fe1f7..f843cb8f00 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -331,8 +331,8 @@ class TestFinding: assert finding_output.auth_method == "mock_identity_type: mock_identity_id" assert finding_output.account_organization_uid == "mock_tenant_id_1" assert finding_output.account_organization_name == "mock_tenant_domain" - assert finding_output.account_uid == "mock_subscription_name" - assert finding_output.account_name == "mock_subscription_id" + assert finding_output.account_uid == "mock_subscription_id" + assert finding_output.account_name == "mock_subscription_name" assert finding_output.resource_name == "test_resource_name" assert finding_output.resource_uid == "test_resource_id" assert finding_output.region == "us-west-1" @@ -557,7 +557,7 @@ class TestFinding: assert finding_output.resource_tags == {} assert finding_output.partition is None assert finding_output.account_uid == "test_cluster" - assert finding_output.provider_uid == "In-Cluster" + assert finding_output.provider_uid == "test_cluster" assert finding_output.account_name == "context: In-Cluster" assert finding_output.account_email is None assert finding_output.account_organization_uid is None @@ -591,6 +591,40 @@ class TestFinding: assert finding_output.metadata.Notes == "mock_notes" assert finding_output.metadata.Compliance == [] + def test_generate_output_kubernetes_kubeconfig(self): + # Mock provider + provider = MagicMock() + provider.type = "kubernetes" + provider.identity.context = "test-context" + provider.identity.cluster = "test_cluster" + + # Mock check result + check_output = MagicMock() + check_output.resource_name = "test_resource_name" + check_output.resource_id = "test_resource_id" + check_output.namespace = "test_namespace" + check_output.resource_details = "test_resource_details" + check_output.status = Status.PASS + check_output.status_extended = "mock_status_extended" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="kubernetes") + check_output.timestamp = datetime.now() + check_output.resource = {} + check_output.compliance = {} + + # Mock Output Options + output_options = MagicMock() + output_options.unix_timestamp = True + + # Generate the finding + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert isinstance(finding_output, Finding) + assert finding_output.auth_method == "kubeconfig" + assert finding_output.account_uid == "test_cluster" + assert finding_output.provider_uid == "test-context" + assert finding_output.account_name == "context: test-context" + def test_generate_output_github_personal_access_token(self): """Test GitHub output generation with Personal Access Token authentication.""" # Mock provider using Personal Access Token @@ -727,6 +761,87 @@ class TestFinding: assert finding_output.metadata.Severity == Severity.high assert finding_output.metadata.ResourceType == "mock_resource_type" + def _build_linode_check_output(self): + check_output = MagicMock() + check_output.resource_id = "12345" + check_output.resource_name = "test-instance" + check_output.resource_details = "" + check_output.resource_tags = {} + check_output.region = "us-east" + check_output.status = Status.PASS + check_output.status_extended = "Instance is compliant" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="linode") + check_output.resource = {} + check_output.compliance = {} + return check_output + + def test_generate_output_linode(self): + """Test Linode output generation when the account ID is available.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id="E1AF1B6C-1111-2222-3333-444455556666", + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.provider == "linode" + assert finding_output.auth_method == "api_token" + assert finding_output.account_uid == "E1AF1B6C-1111-2222-3333-444455556666" + assert finding_output.account_name == "admin" + + def test_generate_output_linode_without_account_id_falls_back_to_username(self): + """account_uid is required; when account_id is None it must fall back to + the username so findings are never silently dropped.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + # Must not raise a ValidationError and must use the username fallback + assert finding_output.account_uid == "admin" + + def test_generate_output_linode_without_account_id_or_username(self): + """When neither account_id nor username/email is available, account_uid + falls back to the literal provider name.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=None, + email=None, + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.account_uid == "linode" + def test_generate_output_iac_remote(self): # Mock provider provider = MagicMock() @@ -1473,3 +1588,106 @@ class TestFinding: with pytest.raises(KeyError): Finding.transform_api_finding(dummy_finding, provider) + + @patch( + "prowler.lib.outputs.finding.get_check_compliance", + new=mock_get_check_compliance, + ) + def test_generate_output_stackit(self): + provider = MagicMock() + provider.type = "stackit" + provider.auth_method = "service_account_key" + provider.identity.project_id = "test-project-id" + provider.identity.project_name = "test-project-name" + + check_output = MagicMock() + check_output.resource_id = "test_resource_id" + check_output.resource_name = "test_resource_name" + check_output.resource_details = "" + check_output.location = "eu01" + check_output.status = Status.PASS + check_output.status_extended = "mock_status_extended" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="stackit") + check_output.resource = {} + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = True + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert isinstance(finding_output, Finding) + assert finding_output.auth_method == "service_account_key" + assert finding_output.account_uid == "test-project-id" + assert finding_output.account_name == "test-project-name" + assert finding_output.resource_name == "test_resource_name" + assert finding_output.resource_uid == "test_resource_id" + assert finding_output.region == "eu01" + assert finding_output.status == Status.PASS + assert finding_output.muted is False + + def test_transform_api_finding_stackit(self): + provider = MagicMock() + provider.type = "stackit" + provider.auth_method = "service_account_key" + provider.identity.project_id = "stackit-project-id" + provider.identity.project_name = "stackit-project-name" + dummy_finding = DummyAPIFinding() + dummy_finding.inserted_at = 1234567890 + dummy_finding.scan = DummyScan(provider=provider) + dummy_finding.uid = "finding-uid-stackit" + dummy_finding.status = "PASS" + dummy_finding.status_extended = "StackIT check extended" + check_metadata = { + "provider": "stackit", + "checkid": "service_stackit_check_001", + "checktitle": "Test StackIT Check", + "checktype": [], + "servicename": "service", + "subservicename": "", + "severity": "high", + "resourcetype": "StackITResourceType", + "description": "StackIT check description", + "risk": "High risk", + "relatedurl": "", + "remediation": { + "code": { + "nativeiac": "", + "terraform": "", + "cli": "", + "other": "", + }, + "recommendation": { + "text": "Fix it", + "url": "https://hub.prowler.com/check/service_stackit_check_001", + }, + }, + "resourceidtemplate": "template", + "categories": ["encryption"], + "dependson": [], + "relatedto": [], + "notes": "StackIT notes", + } + dummy_finding.check_metadata = check_metadata + dummy_finding.raw_result = {} + dummy_finding.resource_name = "stackit-resource-name" + dummy_finding.resource_id = "stackit-resource-uid" + dummy_finding.location = "eu01" + dummy_finding.project_id = "stackit-project-id" + resource = DummyResource( + uid="stackit-resource-uid", + name="stackit-resource-name", + resource_arn="", + region="eu01", + tags=[], + ) + dummy_finding.resources = DummyResources(resource) + dummy_finding.muted = False + finding_obj = Finding.transform_api_finding(dummy_finding, provider) + assert finding_obj.auth_method == "service_account_key" + assert finding_obj.account_uid == "stackit-project-id" + assert finding_obj.account_name == "stackit-project-name" + assert finding_obj.resource_name == "stackit-resource-name" + assert finding_obj.resource_uid == "stackit-resource-uid" + assert finding_obj.region == "eu01" diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index c8d4ac199d..536e40f808 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -962,6 +962,43 @@ class TestHTML: assert summary == image_list_html_assessment_summary + def test_stackit_get_assessment_summary(self): + """Test StackIT HTML assessment summary shows the project ID.""" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "stackit" + provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d" + provider.identity.project_name = "ProwlerDev" + provider.identity.audited_regions = {"eu01", "eu02"} + + summary = output.get_assessment_summary(provider) + + assert "StackIT Assessment Summary" in summary + assert "StackIT Credentials" in summary + assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary + assert "Project Name: ProwlerDev" in summary + assert "Regions: eu01, eu02" in summary + assert "Authentication Type: Service Account Key" in summary + + def test_stackit_get_assessment_summary_without_project_name(self): + """Project ID is always shown; the Project Name line is omitted when + the service account cannot read it from Resource Manager.""" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "stackit" + provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d" + provider.identity.project_name = "" + provider.identity.audited_regions = {"eu01"} + + summary = output.get_assessment_summary(provider) + + assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary + assert "Project Name:" not in summary + def test_process_markdown_bold_text(self): """Test that **text** is converted to text""" test_text = "This is **bold text** and this is **also bold**" diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 03656891c8..76352cb297 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -339,6 +339,88 @@ class TestJiraIntegration: with pytest.raises(JiraRefreshTokenError): self.jira_integration.refresh_access_token() + @patch("prowler.lib.outputs.jira.jira.requests.post") + @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") + def test_get_auth_sends_timeout(self, mock_get_cloud_id, mock_post): + """get_auth must pass a request timeout to avoid hanging on an unresponsive Jira.""" + # To disable vulture + mock_get_cloud_id = mock_get_cloud_id + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.get_auth("test_auth_code") + + assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_sends_timeout(self, mock_get): + """get_cloud_id (OAuth path) must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": "test_cloud_id"}] + mock_get.return_value = mock_response + + self.jira_integration.get_cloud_id("test_access_token") + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_basic_auth_sends_timeout(self, mock_get): + """get_cloud_id (basic-auth tenant_info path) must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"cloudId": "test_cloud_id"} + mock_get.return_value = mock_response + + self.jira_integration_basic_auth.get_cloud_id(domain=self.domain) + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_refresh_access_token_sends_timeout(self, mock_post): + """refresh_access_token must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "new_access_token", + "refresh_token": "new_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.refresh_access_token() + + assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_projects_sends_timeout( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """get_projects must pass a request timeout.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"key": "PROJ1", "name": "Project One"}] + mock_get.return_value = mock_response + + self.jira_integration.get_projects() + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + @patch.object(Jira, "get_auth", return_value=None) @patch.object( Jira, @@ -1004,6 +1086,89 @@ class TestJiraIntegration: for mark in node.get("marks", []) ) + @staticmethod + def _find_empty_text_nodes(node) -> List[str]: + # ADF forbids empty text nodes; collect any to assert the document is valid. + empties: List[str] = [] + + def walk(current) -> None: + if isinstance(current, dict): + if current.get("type") == "text" and current.get("text", "") == "": + empties.append(current.get("text", "")) + for value in current.values(): + walk(value) + elif isinstance(current, list): + for item in current: + walk(item) + + walk(node) + return empties + + def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self): + # A resource without a name (e.g. an AWS-managed IAM policy) used to emit an + # empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT. + adf_description = self.jira_integration.get_adf_description( + check_id="CHECK-1", + check_title="Sample check", + severity="CRITICAL", + severity_color="#FF0000", + status="FAIL", + status_color="#FF0000", + status_extended="Some status", + provider="aws", + region="eu-west-1", + resource_uid="arn:aws:iam::aws:policy/AdministratorAccess", + resource_name="", + recommendation_text="", + ) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + resource_name_row = self._find_table_row(table["content"], "Resource Name") + value_cell = resource_name_row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + + @pytest.mark.parametrize( + "field, header", + [ + ("check_id", "Check Id"), + ("check_title", "Check Title"), + ("status_extended", "Status Extended"), + ("provider", "Provider"), + ("region", "Region"), + ("resource_uid", "Resource UID"), + ("resource_name", "Resource Name"), + ], + ) + def test_get_adf_description_empty_plain_text_fields_render_placeholder( + self, field, header + ): + base_kwargs = dict( + check_id="CHECK-1", + check_title="Sample check", + severity="HIGH", + severity_color="#FF0000", + status="FAIL", + status_color="#00FF00", + status_extended="Some status", + provider="aws", + region="us-east-1", + resource_uid="resource-1", + resource_name="resource-name", + recommendation_text="", + ) + base_kwargs[field] = "" + + adf_description = self.jira_integration.get_adf_description(**base_kwargs) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + row = self._find_table_row(table["content"], header) + value_cell = row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py index 16ff1ce317..f449fd49f7 100644 --- a/tests/lib/outputs/ocsf/ocsf_test.py +++ b/tests/lib/outputs/ocsf/ocsf_test.py @@ -2,8 +2,10 @@ import json from datetime import datetime, timezone from io import StringIO from typing import Optional +from unittest.mock import MagicMock from uuid import UUID +import pytest import requests from freezegun import freeze_time from mock import patch @@ -300,6 +302,36 @@ class TestOCSF: def test_batch_write_data_to_file_without_findings(self): assert not OCSF([])._file_descriptor + def test_batch_write_data_to_file_propagates_oserror(self): + """An I/O error (e.g. ENOSPC) while writing a finding must propagate + instead of being swallowed, so the caller can fail fast.""" + findings = [ + generate_finding_output( + status="FAIL", + severity="low", + muted=False, + region=AWS_REGION_EU_WEST_1, + timestamp=datetime.now(), + resource_details="resource_details", + resource_name="resource_name", + resource_uid="resource-id", + status_extended="status extended", + ) + ] + + output = OCSF(findings) + mock_file = MagicMock() + mock_file.closed = False + # Non-zero so the "[" prelude is skipped and the failure happens on the + # per-finding write, the exact path that hit ENOSPC in production. + mock_file.tell.return_value = 1 + mock_file.write.side_effect = OSError(28, "No space left on device") + output._file_descriptor = mock_file + + with pytest.raises(OSError) as excinfo: + output.batch_write_data_to_file() + assert excinfo.value.errno == 28 + def test_finding_output_cloud_pass_low_muted(self): finding_output = generate_finding_output( status="PASS", diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py index 4aeccf1104..ca18717847 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -51,9 +51,7 @@ class TestOutputs: def test_parse_html_string(self): string = "CISA: your-systems-3, your-data-1, your-data-2 | CIS-1.4: 2.1.1 | CIS-1.5: 2.1.1 | GDPR: article_32 | AWS-Foundational-Security-Best-Practices: s3 | HIPAA: 164_308_a_1_ii_b, 164_308_a_4_ii_a, 164_312_a_2_iv, 164_312_c_1, 164_312_c_2, 164_312_e_2_ii | GxP-21-CFR-Part-11: 11.10-c, 11.30 | GxP-EU-Annex-11: 7.1-data-storage-damage-protection | NIST-800-171-Revision-2: 3_3_8, 3_5_10, 3_13_11, 3_13_16 | NIST-800-53-Revision-4: sc_28 | NIST-800-53-Revision-5: au_9_3, cm_6_a, cm_9_b, cp_9_d, cp_9_8, pm_11_b, sc_8_3, sc_8_4, sc_13_a, sc_16_1, sc_28_1, si_19_4 | ENS-RD2022: mp.si.2.aws.s3.1 | NIST-CSF-1.1: ds_1 | RBI-Cyber-Security-Framework: annex_i_1_3 | FFIEC: d3-pc-am-b-12 | PCI-3.2.1: s3 | FedRamp-Moderate-Revision-4: sc-13, sc-28 | FedRAMP-Low-Revision-4: sc-13 | KISA-ISMS-P-2023: 2.6.1 | KISA-ISMS-P-2023-korean: 2.6.1" - assert ( - parse_html_string(string) - == """ + assert parse_html_string(string) == """ •CISA: your-systems-3, your-data-1, your-data-2 •CIS-1.4: 2.1.1 @@ -94,7 +92,6 @@ class TestOutputs: •KISA-ISMS-P-2023-korean: 2.6.1 """ - ) def test_unroll_tags(self): dict_list = [ @@ -1256,3 +1253,43 @@ class TestReport: f"\t{Fore.YELLOW}INFO{Style.RESET_ALL} There are no resources" ) mocked_print.assert_called() # Verifying that print was called + + def test_report_with_stackit_provider_pass(self): + finding = MagicMock() + finding.status = "PASS" + finding.muted = False + finding.location = "eu01" + finding.check_metadata.Provider = "stackit" + finding.status_extended = "Security group has no unrestricted SSH access" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = ["PASS", "FAIL"] + output_options.fixer = False + + provider = MagicMock() + provider.type = "stackit" + + with mock.patch("builtins.print") as mocked_print: + report([finding], provider, output_options) + mocked_print.assert_called() + + def test_report_with_stackit_provider_fail(self): + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.location = "eu01" + finding.check_metadata.Provider = "stackit" + finding.status_extended = "Security group allows unrestricted SSH access" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = ["PASS", "FAIL"] + output_options.fixer = False + + provider = MagicMock() + provider.type = "stackit" + + with mock.patch("builtins.print") as mocked_print: + report([finding], provider, output_options) + mocked_print.assert_called() diff --git a/tests/lib/outputs/sarif/sarif_test.py b/tests/lib/outputs/sarif/sarif_test.py new file mode 100644 index 0000000000..368021df9e --- /dev/null +++ b/tests/lib/outputs/sarif/sarif_test.py @@ -0,0 +1,312 @@ +import json +import os +import tempfile + +import pytest + +from prowler.lib.outputs.sarif.sarif import SARIF, SARIF_SCHEMA_URL, SARIF_VERSION +from tests.lib.outputs.fixtures.fixtures import generate_finding_output + + +class TestSARIF: + def test_transform_fail_finding(self): + finding = generate_finding_output( + status="FAIL", + status_extended="S3 bucket is not encrypted", + severity="high", + resource_name="main.tf", + service_name="s3", + check_id="s3_encryption_check", + check_title="S3 Bucket Encryption", + ) + sarif = SARIF(findings=[finding], file_path=None) + + assert sarif.data[0]["$schema"] == SARIF_SCHEMA_URL + assert sarif.data[0]["version"] == SARIF_VERSION + assert len(sarif.data[0]["runs"]) == 1 + + run = sarif.data[0]["runs"][0] + assert run["tool"]["driver"]["name"] == "Prowler" + assert len(run["tool"]["driver"]["rules"]) == 1 + assert len(run["results"]) == 1 + + rule = run["tool"]["driver"]["rules"][0] + assert rule["id"] == "s3_encryption_check" + assert rule["shortDescription"]["text"] == "S3 Bucket Encryption" + assert rule["defaultConfiguration"]["level"] == "error" + assert rule["properties"]["security-severity"] == "7.0" + + result = run["results"][0] + assert result["ruleId"] == "s3_encryption_check" + assert result["ruleIndex"] == 0 + assert result["level"] == "error" + assert result["message"]["text"] == "S3 bucket is not encrypted" + + def test_transform_pass_finding_excluded(self): + finding = generate_finding_output(status="PASS", severity="high") + sarif = SARIF(findings=[finding], file_path=None) + + run = sarif.data[0]["runs"][0] + assert len(run["results"]) == 0 + assert len(run["tool"]["driver"]["rules"]) == 0 + + def test_transform_muted_finding_excluded(self): + finding = generate_finding_output(status="FAIL", severity="high", muted=True) + sarif = SARIF(findings=[finding], file_path=None) + run = sarif.data[0]["runs"][0] + assert len(run["results"]) == 0 + assert len(run["tool"]["driver"]["rules"]) == 0 + + @pytest.mark.parametrize( + "severity,expected_level,expected_security_severity", + [ + ("critical", "error", "9.0"), + ("high", "error", "7.0"), + ("medium", "warning", "4.0"), + ("low", "note", "2.0"), + ("informational", "note", "0.0"), + ], + ) + def test_transform_severity_mapping( + self, severity, expected_level, expected_security_severity + ): + finding = generate_finding_output( + status="FAIL", + severity=severity, + ) + sarif = SARIF(findings=[finding], file_path=None) + + run = sarif.data[0]["runs"][0] + result = run["results"][0] + rule = run["tool"]["driver"]["rules"][0] + + assert result["level"] == expected_level + assert rule["defaultConfiguration"]["level"] == expected_level + assert rule["properties"]["security-severity"] == expected_security_severity + + def test_transform_multiple_findings_dedup_rules(self): + findings = [ + generate_finding_output( + status="FAIL", + resource_name="file1.tf", + status_extended="Finding in file1", + ), + generate_finding_output( + status="FAIL", + resource_name="file2.tf", + status_extended="Finding in file2", + ), + ] + sarif = SARIF(findings=findings, file_path=None) + + run = sarif.data[0]["runs"][0] + assert len(run["tool"]["driver"]["rules"]) == 1 + assert len(run["results"]) == 2 + assert run["results"][0]["ruleIndex"] == 0 + assert run["results"][1]["ruleIndex"] == 0 + + def test_transform_multiple_different_rules(self): + findings = [ + generate_finding_output( + status="FAIL", + service_name="alpha", + check_id="alpha_check_one", + status_extended="Finding A", + ), + generate_finding_output( + status="FAIL", + service_name="beta", + check_id="beta_check_two", + status_extended="Finding B", + ), + ] + sarif = SARIF(findings=findings, file_path=None) + + run = sarif.data[0]["runs"][0] + assert len(run["tool"]["driver"]["rules"]) == 2 + assert run["results"][0]["ruleIndex"] == 0 + assert run["results"][1]["ruleIndex"] == 1 + + def test_transform_location_with_line_range(self): + finding = generate_finding_output( + status="FAIL", + resource_name="modules/s3/main.tf", + ) + finding.raw = {"resource_line_range": "10:25"} + + sarif = SARIF(findings=[finding], file_path=None) + + result = sarif.data[0]["runs"][0]["results"][0] + location = result["locations"][0]["physicalLocation"] + assert location["artifactLocation"]["uri"] == "modules/s3/main.tf" + assert location["region"]["startLine"] == 10 + assert location["region"]["endLine"] == 25 + + def test_transform_location_without_line_range(self): + finding = generate_finding_output( + status="FAIL", + resource_name="main.tf", + ) + sarif = SARIF(findings=[finding], file_path=None) + + result = sarif.data[0]["runs"][0]["results"][0] + location = result["locations"][0]["physicalLocation"] + assert location["artifactLocation"]["uri"] == "main.tf" + assert "region" not in location + + def test_transform_no_resource_name(self): + finding = generate_finding_output( + status="FAIL", + resource_name="", + ) + sarif = SARIF(findings=[finding], file_path=None) + + result = sarif.data[0]["runs"][0]["results"][0] + assert "locations" not in result + + def test_batch_write_data_to_file(self): + finding = generate_finding_output( + status="FAIL", + status_extended="test finding", + resource_name="main.tf", + ) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sarif", delete=False + ) as tmp: + tmp_path = tmp.name + + sarif = SARIF( + findings=[finding], + file_path=tmp_path, + ) + sarif.batch_write_data_to_file() + + with open(tmp_path) as f: + content = json.load(f) + + assert content["$schema"] == SARIF_SCHEMA_URL + assert content["version"] == SARIF_VERSION + assert len(content["runs"][0]["results"]) == 1 + + os.unlink(tmp_path) + + def test_sarif_schema_structure(self): + finding = generate_finding_output( + status="FAIL", + severity="critical", + resource_name="infra/main.tf", + service_name="iac", + check_id="iac_misconfig_check", + check_title="IaC Misconfiguration", + description="Checks for misconfigurations", + remediation_recommendation_text="Fix the configuration", + ) + finding.raw = {"resource_line_range": "5:15"} + + sarif = SARIF(findings=[finding], file_path=None) + doc = sarif.data[0] + + assert "$schema" in doc + assert "version" in doc + assert "runs" in doc + + run = doc["runs"][0] + + assert "tool" in run + assert "driver" in run["tool"] + driver = run["tool"]["driver"] + assert "name" in driver + assert "version" in driver + assert "informationUri" in driver + assert "rules" in driver + + rule = driver["rules"][0] + assert "id" in rule + assert "shortDescription" in rule + assert "fullDescription" in rule + assert "help" in rule + assert "defaultConfiguration" in rule + assert "properties" in rule + assert "tags" in rule["properties"] + assert "security-severity" in rule["properties"] + + result = run["results"][0] + assert "ruleId" in result + assert "ruleIndex" in result + assert "level" in result + assert "message" in result + assert "locations" in result + + loc = result["locations"][0]["physicalLocation"] + assert "artifactLocation" in loc + assert "uri" in loc["artifactLocation"] + assert "region" in loc + assert "startLine" in loc["region"] + assert "endLine" in loc["region"] + + def test_transform_helpuri_present_when_related_url_set(self): + finding = generate_finding_output( + status="FAIL", + provider="iac", + related_url="https://docs.example.com/check", + ) + sarif = SARIF(findings=[finding], file_path=None) + rule = sarif.data[0]["runs"][0]["tool"]["driver"]["rules"][0] + assert rule["helpUri"] == "https://docs.example.com/check" + + def test_transform_helpuri_absent_when_related_url_empty(self): + finding = generate_finding_output( + status="FAIL", + related_url="", + ) + sarif = SARIF(findings=[finding], file_path=None) + rule = sarif.data[0]["runs"][0]["tool"]["driver"]["rules"][0] + assert "helpUri" not in rule + + def test_location_with_non_numeric_line_range(self): + finding = generate_finding_output( + status="FAIL", + resource_name="main.tf", + ) + finding.raw = {"resource_line_range": "abc:def"} + sarif = SARIF(findings=[finding], file_path=None) + location = sarif.data[0]["runs"][0]["results"][0]["locations"][0][ + "physicalLocation" + ] + assert "region" not in location + + def test_location_with_single_value_line_range(self): + finding = generate_finding_output( + status="FAIL", + resource_name="main.tf", + ) + finding.raw = {"resource_line_range": "10"} + sarif = SARIF(findings=[finding], file_path=None) + location = sarif.data[0]["runs"][0]["results"][0]["locations"][0][ + "physicalLocation" + ] + assert "region" not in location + + def test_location_with_zero_line_numbers(self): + finding = generate_finding_output( + status="FAIL", + resource_name="main.tf", + ) + finding.raw = {"resource_line_range": "0:0"} + sarif = SARIF(findings=[finding], file_path=None) + location = sarif.data[0]["runs"][0]["results"][0]["locations"][0][ + "physicalLocation" + ] + assert "region" not in location + + def test_only_pass_findings(self): + findings = [ + generate_finding_output(status="PASS"), + generate_finding_output(status="PASS"), + ] + sarif = SARIF(findings=findings, file_path=None) + + run = sarif.data[0]["runs"][0] + assert len(run["results"]) == 0 + assert len(run["tool"]["driver"]["rules"]) == 0 diff --git a/tests/lib/outputs/summary_table_test.py b/tests/lib/outputs/summary_table_test.py new file mode 100644 index 0000000000..0c842225cc --- /dev/null +++ b/tests/lib/outputs/summary_table_test.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace + +from prowler.lib.outputs.summary_table import display_summary_table + + +class TestDisplaySummaryTable: + def test_azure_summary_shows_display_name_and_subscription_id(self, capsys): + provider = SimpleNamespace( + type="azure", + identity=SimpleNamespace( + tenant_domain="tenant.example.com", + tenant_ids=["tenant-id"], + subscriptions={ + "subscription-id-1": "Duplicate Subscription", + "subscription-id-2": "Duplicate Subscription", + }, + ), + ) + output_options = SimpleNamespace( + output_directory="out", + output_filename="report", + output_modes=[], + ) + findings = [ + SimpleNamespace( + status="PASS", + muted=False, + check_metadata=SimpleNamespace( + ServiceName="network", + Provider="azure", + Severity="low", + ), + ) + ] + + display_summary_table(findings, provider, output_options) + + captured = capsys.readouterr() + + assert "Subscriptions scanned:" in captured.out + assert "Duplicate Subscription (subscription-id-1)" in captured.out + assert "Duplicate Subscription (subscription-id-2)" in captured.out + + def test_stackit_summary_with_project_name(self, capsys): + provider = SimpleNamespace( + type="stackit", + identity=SimpleNamespace( + project_id="test-project-id", + project_name="my-prod-env", + ), + ) + output_options = SimpleNamespace( + output_directory="out", + output_filename="report", + output_modes=[], + ) + findings = [ + SimpleNamespace( + status="PASS", + muted=False, + check_metadata=SimpleNamespace( + ServiceName="iaas", + Provider="stackit", + Severity="high", + ), + ) + ] + + display_summary_table(findings, provider, output_options) + + captured = capsys.readouterr() + assert "Project" in captured.out + assert "my-prod-env" in captured.out + + def test_stackit_summary_with_project_id_only(self, capsys): + provider = SimpleNamespace( + type="stackit", + identity=SimpleNamespace( + project_id="test-project-id", + project_name=None, + ), + ) + output_options = SimpleNamespace( + output_directory="out", + output_filename="report", + output_modes=[], + ) + findings = [ + SimpleNamespace( + status="PASS", + muted=False, + check_metadata=SimpleNamespace( + ServiceName="iaas", + Provider="stackit", + Severity="high", + ), + ) + ] + + display_summary_table(findings, provider, output_options) + + captured = capsys.readouterr() + assert "Project ID" in captured.out + assert "test-project-id" in captured.out diff --git a/tests/lib/scan/scan_test.py b/tests/lib/scan/scan_test.py index b0fe82b7b2..8037668b10 100644 --- a/tests/lib/scan/scan_test.py +++ b/tests/lib/scan/scan_test.py @@ -51,7 +51,7 @@ def mock_provider(): def mock_execute(): with mock.patch("prowler.lib.scan.scan.execute", autospec=True) as mock_exec: findings = [finding] - mock_exec.side_effect = lambda *args, **kwargs: findings + mock_exec.side_effect = lambda *_args, **_kwargs: findings yield mock_exec @@ -264,10 +264,10 @@ class TestScan: @patch("prowler.lib.scan.scan.update_checks_metadata_with_compliance") @patch("prowler.lib.scan.scan.Compliance.get_bulk") @patch("prowler.lib.scan.scan.CheckMetadata.get_bulk") - @patch("prowler.lib.scan.scan.import_check") + @patch("prowler.lib.scan.scan._resolve_check_module") def test_scan( self, - mock_import_check, + mock_resolve_check_module, mock_get_bulk, mock_compliance_get_bulk, mock_update_checks_metadata, @@ -285,7 +285,7 @@ class TestScan: mock_check_instance.CheckTitle = "Check if IAM Access Analyzer is enabled" mock_check_instance.Categories = [] - mock_import_check.return_value = MagicMock( + mock_resolve_check_module.return_value = MagicMock( accessanalyzer_enabled=mock_check_class ) diff --git a/tests/lib/utils/test_vulnerability_references.py b/tests/lib/utils/test_vulnerability_references.py new file mode 100644 index 0000000000..8610980b51 --- /dev/null +++ b/tests/lib/utils/test_vulnerability_references.py @@ -0,0 +1,91 @@ +from prowler.lib.utils.vulnerability_references import ( + build_finding_reference_url, + resolve_vulnerability_reference_urls, +) + + +class TestBuildFindingReferenceUrl: + def test_cve_id_returns_cve_org_url(self): + assert ( + build_finding_reference_url("CVE-2023-1234") + == "https://www.cve.org/CVERecord?id=CVE-2023-1234" + ) + + def test_lowercase_cve_id_is_normalized(self): + assert ( + build_finding_reference_url("cve-2024-9999") + == "https://www.cve.org/CVERecord?id=CVE-2024-9999" + ) + + def test_ghsa_id_returns_github_advisory_url(self): + assert ( + build_finding_reference_url("GHSA-abcd-1234-efgh") + == "https://github.com/advisories/GHSA-ABCD-1234-EFGH" + ) + + def test_avd_prefixed_id_strips_prefix_for_hub(self): + assert ( + build_finding_reference_url("AVD-AWS-0001") + == "https://hub.prowler.com/check/AWS-0001" + ) + + def test_clean_trivy_id_uses_hub_directly(self): + assert ( + build_finding_reference_url("AWS-0104") + == "https://hub.prowler.com/check/AWS-0104" + ) + + def test_kubernetes_id_uses_hub(self): + assert ( + build_finding_reference_url("AVD-K8S-0001") + == "https://hub.prowler.com/check/K8S-0001" + ) + + def test_dockerfile_id_uses_hub(self): + assert ( + build_finding_reference_url("AVD-DOCKER-0001") + == "https://hub.prowler.com/check/DOCKER-0001" + ) + + def test_whitespace_is_trimmed(self): + assert ( + build_finding_reference_url(" AZU-0013 ") + == "https://hub.prowler.com/check/AZU-0013" + ) + + +class TestResolveVulnerabilityReferenceUrls: + def test_cve_with_cve_org_reference_uses_it(self): + recommendation_url, additional_urls = resolve_vulnerability_reference_urls( + vulnerability_id="CVE-2023-1234", + references=[ + "https://avd.aquasec.com/nvd/cve-2023-1234", + "https://www.cve.org/CVERecord?id=CVE-2023-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + ], + primary_url="https://avd.aquasec.com/nvd/cve-2023-1234", + ) + + assert recommendation_url == "https://www.cve.org/CVERecord?id=CVE-2023-1234" + assert additional_urls == ["https://www.cve.org/CVERecord?id=CVE-2023-1234"] + + def test_cve_without_cve_org_reference_builds_url(self): + recommendation_url, additional_urls = resolve_vulnerability_reference_urls( + vulnerability_id="CVE-2023-5678", + references=["https://nvd.nist.gov/vuln/detail/CVE-2023-5678"], + ) + + assert recommendation_url == "https://www.cve.org/CVERecord?id=CVE-2023-5678" + assert additional_urls == ["https://www.cve.org/CVERecord?id=CVE-2023-5678"] + + def test_non_cve_id_returns_filtered_references(self): + recommendation_url, additional_urls = resolve_vulnerability_reference_urls( + vulnerability_id="GHSA-abcd-1234-efgh", + references=[ + "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "https://github.com/advisories/GHSA-abcd-1234-efgh", + ], + ) + + assert recommendation_url == "" + assert additional_urls == ["https://github.com/advisories/GHSA-abcd-1234-efgh"] diff --git a/tests/lib/utils/utils_test.py b/tests/lib/utils/utils_test.py index c8db5a62d0..c3340704de 100644 --- a/tests/lib/utils/utils_test.py +++ b/tests/lib/utils/utils_test.py @@ -1,4 +1,5 @@ import os +import subprocess import tempfile from datetime import datetime from time import mktime @@ -7,7 +8,8 @@ import pytest from mock import patch from prowler.lib.utils.utils import ( - detect_secrets_scan, + SecretsScanError, + detect_secrets_scan_batch, file_exists, get_file_permissions, hash_sha512, @@ -20,6 +22,95 @@ from prowler.lib.utils.utils import ( ) +def _fake_kingfisher_run(output_content=None, returncode=0, stderr=""): + """Build a ``subprocess.run`` replacement that mimics a Kingfisher call. + + When ``output_content`` is given it is written to the ``--output`` path from + the command (so the reader sees realistic file content); the call returns a + CompletedProcess with the requested ``returncode``/``stderr``. + """ + + def _run(command, *_args, **_kwargs): + if output_content is not None: + output_path = command[command.index("--output") + 1] + with open(output_path, "w") as output_file: + output_file.write(output_content) + return subprocess.CompletedProcess( + command, returncode, stdout="", stderr=stderr + ) + + return _run + + +def _fake_kingfisher_run_with_findings(findings): + """Build a ``subprocess.run`` replacement that emits crafted findings. + + Each entry in ``findings`` is a ``(payload_index, line)`` pair: the finding + is mapped back to the temp file named ``str(payload_index)`` (the basename + ``_scan_batch_chunk`` writes per payload) and given the requested ``line`` + value (omitted entirely when ``line`` is the sentinel ``_OMIT``). Returns a + success exit code so only the finding shape is under test. + """ + + def _run(command, *_args, **_kwargs): + output_path = command[command.index("--output") + 1] + entries = [] + for payload_index, line in findings: + finding = {"path": str(payload_index), "snippet": "secret"} + if line is not _OMIT: + finding["line"] = line + entries.append({"finding": finding, "rule": {"name": "Generic Secret"}}) + import json as _json + + with open(output_path, "w") as output_file: + output_file.write(_json.dumps({"findings": entries})) + return subprocess.CompletedProcess(command, 200, stdout="", stderr="") + + return _run + + +_OMIT = object() + + +class Test_detect_secrets_scan_batch_invalid_line: + """Kingfisher's ``line`` is consumed as a trusted 1-based index by checks + (e.g. CloudWatch ``events[line_number - 1]``). A malformed line must fail + closed as SecretsScanError, never return a finding with a bad index.""" + + @pytest.mark.parametrize( + "line", + [_OMIT, None, "2", 0, -1, 5, True], + ids=["missing", "none", "string", "zero", "negative", "out_of_range", "bool"], + ) + def test_invalid_line_raises(self, line): + # Payload "data" is a single line, so any line other than 1 is invalid. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run_with_findings([(0, line)]), + ): + with pytest.raises(SecretsScanError) as exc: + detect_secrets_scan_batch({"a": "data"}) + assert "invalid line number" in str(exc.value) + + def test_valid_line_is_returned(self): + # A valid in-range line must still pass through to the caller. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run_with_findings([(0, 1)]), + ): + results = detect_secrets_scan_batch({"a": "data"}) + assert results["a"][0]["line_number"] == 1 + + def test_one_invalid_line_aborts_the_whole_scan(self): + # Even mixed with a valid finding, a single invalid line fails closed. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run_with_findings([(0, 1), (1, 0)]), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch({"a": "data", "b": "data"}) + + class Test_utils_open_file: def test_open_read_file(self): temp_data_file = tempfile.NamedTemporaryFile(delete=False) @@ -108,75 +199,108 @@ class Test_utils_validate_ip_address: assert not validate_ip_address("Not an IP") -class Test_detect_secrets_scan: - def test_detect_secrets_scan_data(self): - data = "password=password" - secrets_detected = detect_secrets_scan(data=data, excluded_secrets=[]) - assert type(secrets_detected) is list - assert len(secrets_detected) == 1 - assert "filename" in secrets_detected[0] - assert "hashed_secret" in secrets_detected[0] - assert "is_verified" in secrets_detected[0] - assert secrets_detected[0]["line_number"] == 1 - assert secrets_detected[0]["type"] == "Secret Keyword" - - def test_detect_secrets_scan_no_secrets_data(self): - data = "" - assert detect_secrets_scan(data=data) is None - - def test_detect_secrets_scan_file_with_secrets(self): - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(b"password=password") - temp_data_file.seek(0) - secrets_detected = detect_secrets_scan( - file=temp_data_file.name, excluded_secrets=[] +class Test_detect_secrets_scan_batch: + def test_batch_returns_findings_per_key(self): + results = detect_secrets_scan_batch( + { + "a": 'password = "Tr0ub4dor3xKq9vLmZ"', + "b": "just a normal config = value", + } ) - assert type(secrets_detected) is list - assert len(secrets_detected) == 1 - assert "filename" in secrets_detected[0] - assert "hashed_secret" in secrets_detected[0] - assert "is_verified" in secrets_detected[0] - assert secrets_detected[0]["line_number"] == 1 - assert secrets_detected[0]["type"] == "Secret Keyword" - os.remove(temp_data_file.name) + assert "a" in results + assert results["a"][0]["type"] == "Generic Password" + # keys without findings are omitted + assert "b" not in results - def test_detect_secrets_scan_file_no_secrets(self): - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(b"no secrets") - temp_data_file.seek(0) - assert detect_secrets_scan(file=temp_data_file.name) is None - os.remove(temp_data_file.name) + def test_batch_no_dedup_reports_identical_secret_in_each_key(self): + # The same secret in two payloads must be reported for both (matches + # scanning each payload individually). + secret = "token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + results = detect_secrets_scan_batch({"a": secret, "b": secret}) + assert "a" in results + assert "b" in results - def test_detect_secrets_using_regex(self): - data = "MYSQL_ALLOW_EMPTY_PASSWORD=password" - secrets_detected = detect_secrets_scan( - data=data, excluded_secrets=[".*password"] + def test_batch_excluded_secrets_filters(self): + results = detect_secrets_scan_batch( + {"a": 'DB_ALLOW_EMPTY_PASSWORD = "Tr0ub4dor3xKq9vLmZ"'}, + excluded_secrets=[".*ALLOW_EMPTY_PASSWORD.*"], ) - assert secrets_detected is None + assert results == {} - def test_detect_secrets_using_regex_file(self): - temp_data_file = tempfile.NamedTemporaryFile(delete=False) - temp_data_file.write(b"MYSQL_ALLOW_EMPTY_PASSWORD=password") - temp_data_file.seek(0) - secrets_detected = detect_secrets_scan( - file=temp_data_file.name, excluded_secrets=[".*password"] - ) - assert secrets_detected is None - os.remove(temp_data_file.name) + def test_batch_chunking_maps_all_keys(self): + payloads = {f"k{i}": f'password = "S3cr3tV4lu3xy{i}z"' for i in range(5)} + results = detect_secrets_scan_batch(payloads, chunk_size=2) + assert sorted(results.keys()) == ["k0", "k1", "k2", "k3", "k4"] - def test_detect_secrets_secrets_using_regex(self): - data = "MYSQL_ALLOW_EMPTY_PASSWORD=password, MYSQL_PASSWORD=password" - # Update the regex to exclude only the exact key "MYSQL_ALLOW_EMPTY_PASSWORD" - secrets_detected = detect_secrets_scan( - data=data, excluded_secrets=["^MYSQL_ALLOW_EMPTY_PASSWORD$"] + def test_batch_empty_payloads(self): + assert detect_secrets_scan_batch({}) == {} + + def test_batch_accepts_iterable_of_pairs(self): + results = detect_secrets_scan_batch( + iter([("x", 'password = "Tr0ub4dor3xKq9vLmZ"')]) ) - assert type(secrets_detected) is list - assert len(secrets_detected) == 1 - assert "filename" in secrets_detected[0] - assert "hashed_secret" in secrets_detected[0] - assert "is_verified" in secrets_detected[0] - assert secrets_detected[0]["line_number"] == 1 - assert secrets_detected[0]["type"] == "Secret Keyword" + assert "x" in results + + +class Test_detect_secrets_scan_batch_failures: + """A scanner failure must surface as SecretsScanError, never as empty + results (which a caller would read as 'no secrets found').""" + + def test_non_zero_exit_code_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run(returncode=1, stderr="boom"), + ): + with pytest.raises(SecretsScanError) as exc: + detect_secrets_scan_batch({"a": "data"}) + assert "exited with code 1" in str(exc.value) + assert "boom" in str(exc.value) + + def test_timeout_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="kingfisher", timeout=300), + ): + with pytest.raises(SecretsScanError) as exc: + detect_secrets_scan_batch({"a": "data"}) + assert "timed out" in str(exc.value) + + def test_malformed_json_output_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run( + output_content="{not valid json", returncode=0 + ), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch({"a": "data"}) + + def test_missing_binary_raises(self): + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=FileNotFoundError("kingfisher binary not found"), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch({"a": "data"}) + + def test_empty_output_is_not_a_failure(self): + # Empty output means the scan ran and found nothing; it must NOT raise. + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run(output_content="", returncode=0), + ): + assert detect_secrets_scan_batch({"a": "data"}) == {} + + def test_failure_in_any_chunk_aborts_the_whole_scan(self): + # A failure in any chunk must abort the whole scan, not silently return + # partial results from the chunks that happened to succeed first. + payloads = {f"k{i}": "data" for i in range(4)} + with patch( + "prowler.lib.utils.utils.subprocess.run", + side_effect=_fake_kingfisher_run(returncode=2, stderr="boom"), + ): + with pytest.raises(SecretsScanError): + detect_secrets_scan_batch(payloads, chunk_size=2) class Test_hash_sha512: diff --git a/tests/providers/alibabacloud/alibabacloud_models_test.py b/tests/providers/alibabacloud/alibabacloud_models_test.py new file mode 100644 index 0000000000..75f89ea2e9 --- /dev/null +++ b/tests/providers/alibabacloud/alibabacloud_models_test.py @@ -0,0 +1,64 @@ +from unittest.mock import patch + +import pytest + +from prowler.providers.alibabacloud.models import ( + AlibabaCloudCredentials, + AlibabaCloudSession, +) + + +def _build_session(): + session = AlibabaCloudSession(cred_client=object()) + session._credentials = AlibabaCloudCredentials( + access_key_id="test-access-key-id", + access_key_secret="test-access-key-secret", + ) + return session + + +def test_securitycenter_client_uses_outside_china_endpoint(): + session = _build_session() + + with patch( + "prowler.providers.alibabacloud.models.SasClient", + side_effect=lambda config: config, + ): + config = session.client("sas", "ap-northeast-1") + + assert config.endpoint == "tds.ap-southeast-1.aliyuncs.com" + + +def test_securitycenter_client_uses_china_endpoint(): + session = _build_session() + + with patch( + "prowler.providers.alibabacloud.models.SasClient", + side_effect=lambda config: config, + ): + config = session.client("securitycenter", "cn-hangzhou") + + assert config.endpoint == "tds.cn-shanghai.aliyuncs.com" + + +@pytest.mark.parametrize( + ("region", "expected_endpoint"), + [ + ("cn-beijing", "rds.aliyuncs.com"), + ("cn-shanghai", "rds.aliyuncs.com"), + ("cn-heyuan", "rds.aliyuncs.com"), + ("cn-hongkong", "rds.aliyuncs.com"), + ("ap-northeast-1", "rds.ap-northeast-1.aliyuncs.com"), + ("cn-guangzhou", "rds.cn-guangzhou.aliyuncs.com"), + ], +) +def test_rds_client_uses_documented_public_endpoints(region, expected_endpoint): + session = _build_session() + + with patch( + "prowler.providers.alibabacloud.models.RdsClient", + side_effect=lambda config: config, + ): + config = session.client("rds", region) + + assert config.endpoint == expected_endpoint diff --git a/tests/providers/alibabacloud/services/actiontrail/actiontrail_service_test.py b/tests/providers/alibabacloud/services/actiontrail/actiontrail_service_test.py index be652182a1..c3111e598d 100644 --- a/tests/providers/alibabacloud/services/actiontrail/actiontrail_service_test.py +++ b/tests/providers/alibabacloud/services/actiontrail/actiontrail_service_test.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch from tests.providers.alibabacloud.alibabacloud_fixtures import ( set_mocked_alibabacloud_provider, @@ -24,3 +25,53 @@ class TestActionTrailService: assert actiontrail_client.service == "actiontrail" assert actiontrail_client.provider == alibabacloud_provider + + def test_describe_trails_retries_transient_connection_reset(self): + from prowler.providers.alibabacloud.services.actiontrail import ( + actiontrail_service as actiontrail_service_module, + ) + + class ConnectionResetError(Exception): + pass + + service = actiontrail_service_module.ActionTrail.__new__( + actiontrail_service_module.ActionTrail + ) + service.audited_account = "1234567890" + service.audit_resources = [] + service.trails = {} + + regional_client = MagicMock() + regional_client.region = "cn-shenzhen" + regional_client.describe_trails.side_effect = [ + ConnectionResetError( + "('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))" + ), + SimpleNamespace( + body=SimpleNamespace( + trail_list=[ + SimpleNamespace( + name="trail-1", + trail_region="All", + home_region="cn-hangzhou", + status="Enable", + oss_bucket_name="bucket-1", + oss_bucket_location="cn-hangzhou", + sls_project_arn="", + event_rw="All", + create_time="2026-01-01T00:00:00Z", + ) + ] + ) + ), + ] + + with patch.object( + actiontrail_service_module, + "actiontrail_models", + SimpleNamespace(DescribeTrailsRequest=MagicMock(return_value=object())), + ): + service._describe_trails(regional_client) + + assert regional_client.describe_trails.call_count == 2 + assert len(service.trails) == 1 diff --git a/tests/providers/alibabacloud/services/cs/cs_service_test.py b/tests/providers/alibabacloud/services/cs/cs_service_test.py index cf94e3e537..16b04e9773 100644 --- a/tests/providers/alibabacloud/services/cs/cs_service_test.py +++ b/tests/providers/alibabacloud/services/cs/cs_service_test.py @@ -1,4 +1,7 @@ -from unittest.mock import patch +from datetime import datetime, timezone +from threading import Lock +from types import SimpleNamespace +from unittest.mock import MagicMock, patch from tests.providers.alibabacloud.alibabacloud_fixtures import ( set_mocked_alibabacloud_provider, @@ -22,3 +25,247 @@ class TestCSService: assert cs_client.service == "cs" assert cs_client.provider == alibabacloud_provider + + def test_get_cluster_detail_uses_requestless_sdk_and_parses_response(self): + from prowler.providers.alibabacloud.services.cs import ( + cs_service as cs_service_module, + ) + + service = cs_service_module.CS.__new__(cs_service_module.CS) + regional_client = MagicMock(region="cn-hangzhou") + regional_client.describe_cluster_detail.return_value = SimpleNamespace( + body=SimpleNamespace( + meta_data='{"AuditProjectName":"audit-project","Addons":[{"name":"terway","disabled":false}],"RBACEnabled":"true"}', + parameters={"authorization_mode": "RBAC", "endpoint_public": "false"}, + master_url="", + ) + ) + + with patch.object(cs_service_module, "cs_models", SimpleNamespace()): + result = service._get_cluster_detail(regional_client, "cluster-id") + + regional_client.describe_cluster_detail.assert_called_once_with("cluster-id") + assert result == { + "meta_data": { + "AuditProjectName": "audit-project", + "Addons": [{"name": "terway", "disabled": False}], + "RBACEnabled": "true", + }, + "parameters": { + "authorization_mode": "RBAC", + "endpoint_public": "false", + }, + "master_url": "", + } + + def test_get_last_cluster_check_uses_list_cluster_checks(self): + from prowler.providers.alibabacloud.services.cs import ( + cs_service as cs_service_module, + ) + + service = cs_service_module.CS.__new__(cs_service_module.CS) + regional_client = MagicMock(region="cn-hangzhou") + request = object() + most_recent = datetime(2026, 4, 22, tzinfo=timezone.utc) + older = datetime(2026, 4, 20, tzinfo=timezone.utc) + regional_client.list_cluster_checks.return_value = SimpleNamespace( + body=SimpleNamespace( + checks=[ + SimpleNamespace(status="Succeeded", finished_at=older), + SimpleNamespace(status="Failed", finished_at=None), + SimpleNamespace(status="Succeeded", finished_at=most_recent), + ] + ) + ) + mock_models = SimpleNamespace( + ListClusterChecksRequest=MagicMock(return_value=request) + ) + + with patch.object(cs_service_module, "cs_models", mock_models): + result = service._get_last_cluster_check(regional_client, "cluster-id") + + mock_models.ListClusterChecksRequest.assert_called_once_with() + regional_client.list_cluster_checks.assert_called_once_with( + "cluster-id", request + ) + assert result == most_recent + + def test_describe_clusters_populates_clusters_with_sdk_6_1_0_shape(self): + from prowler.providers.alibabacloud.services.cs import ( + cs_service as cs_service_module, + ) + + service = cs_service_module.CS.__new__(cs_service_module.CS) + service.audit_resources = [] + service.clusters = [] + service.regional_clients = {} + service._cluster_ids_lock = Lock() + service._seen_cluster_ids = set() + + describe_clusters_request = object() + describe_node_pools_request = object() + list_checks_request = object() + regional_client = MagicMock(region="cn-hangzhou") + regional_client.describe_clusters_v1.return_value = SimpleNamespace( + body=SimpleNamespace( + clusters=[ + SimpleNamespace( + cluster_id="c-1", + name="test-cluster", + cluster_type="ManagedKubernetes", + state="running", + ) + ] + ) + ) + regional_client.describe_cluster_detail.return_value = SimpleNamespace( + body=SimpleNamespace( + meta_data='{"AuditProjectName":"audit-project","Addons":[{"name":"terway","disabled":false}],"RBACEnabled":"true"}', + parameters={"authorization_mode": "RBAC"}, + master_url="", + ) + ) + regional_client.describe_cluster_node_pools.return_value = SimpleNamespace( + body=SimpleNamespace( + nodepools=[ + SimpleNamespace(kubernetes_config=SimpleNamespace(cms_enabled=True)) + ] + ) + ) + regional_client.list_cluster_checks.return_value = SimpleNamespace( + body=SimpleNamespace( + checks=[ + SimpleNamespace( + status="Succeeded", + finished_at=datetime(2026, 4, 22, tzinfo=timezone.utc), + ) + ] + ) + ) + mock_models = SimpleNamespace( + DescribeClustersV1Request=MagicMock(return_value=describe_clusters_request), + DescribeClusterNodePoolsRequest=MagicMock( + return_value=describe_node_pools_request + ), + ListClusterChecksRequest=MagicMock(return_value=list_checks_request), + ) + + with patch.object(cs_service_module, "cs_models", mock_models): + service._describe_clusters(regional_client) + + regional_client.describe_clusters_v1.assert_called_once_with( + describe_clusters_request + ) + regional_client.describe_cluster_detail.assert_called_once_with("c-1") + regional_client.describe_cluster_node_pools.assert_called_once_with( + "c-1", describe_node_pools_request + ) + regional_client.list_cluster_checks.assert_called_once_with( + "c-1", list_checks_request + ) + assert len(service.clusters) == 1 + cluster = service.clusters[0] + assert cluster.id == "c-1" + assert cluster.log_service_enabled is True + assert cluster.cloudmonitor_enabled is True + assert cluster.rbac_enabled is True + assert cluster.network_policy_enabled is True + assert cluster.eni_multiple_ip_enabled is True + assert cluster.private_cluster_enabled is True + + def test_describe_clusters_uses_cluster_region_and_deduplicates(self): + from prowler.providers.alibabacloud.services.cs import ( + cs_service as cs_service_module, + ) + + service = cs_service_module.CS.__new__(cs_service_module.CS) + service.audit_resources = [] + service.clusters = [] + service._cluster_ids_lock = Lock() + service._seen_cluster_ids = set() + + list_request = object() + node_pools_request = object() + checks_request = object() + canonical_client = MagicMock(region="ap-southeast-1") + duplicate_client = MagicMock(region="cn-shenzhen") + service.regional_clients = { + "ap-southeast-1": canonical_client, + "cn-shenzhen": duplicate_client, + } + + for client in (canonical_client, duplicate_client): + client.describe_clusters_v1.return_value = SimpleNamespace( + body=SimpleNamespace( + clusters=[ + SimpleNamespace( + cluster_id="c-1", + name="test-cluster", + cluster_type="ManagedKubernetes", + state="running", + region_id="ap-southeast-1", + ) + ] + ) + ) + + canonical_client.describe_cluster_detail.return_value = SimpleNamespace( + body=SimpleNamespace( + meta_data='{"AuditProjectName":"audit-project","Addons":[]}', + parameters={"authorization_mode": "RBAC"}, + master_url="", + ) + ) + canonical_client.describe_cluster_node_pools.return_value = SimpleNamespace( + body=SimpleNamespace(nodepools=[]) + ) + canonical_client.list_cluster_checks.return_value = SimpleNamespace( + body=SimpleNamespace(checks=[]) + ) + + mock_models = SimpleNamespace( + DescribeClustersV1Request=MagicMock(return_value=list_request), + DescribeClusterNodePoolsRequest=MagicMock(return_value=node_pools_request), + ListClusterChecksRequest=MagicMock(return_value=checks_request), + ) + + with patch.object(cs_service_module, "cs_models", mock_models): + service._describe_clusters(duplicate_client) + service._describe_clusters(canonical_client) + + assert len(service.clusters) == 1 + assert service.clusters[0].region == "ap-southeast-1" + canonical_client.describe_cluster_detail.assert_called_once_with("c-1") + duplicate_client.describe_cluster_detail.assert_not_called() + + def test_check_cluster_addons_handles_null_addons_without_logging_error(self): + from prowler.providers.alibabacloud.services.cs import ( + cs_service as cs_service_module, + ) + + service = cs_service_module.CS.__new__(cs_service_module.CS) + + with patch.object(cs_service_module.logger, "error") as logger_error: + result = service._check_cluster_addons( + {"meta_data": {"Addons": None}}, + "cn-hangzhou", + ) + + assert result == { + "dashboard_enabled": False, + "network_policy_enabled": False, + "eni_multiple_ip_enabled": False, + } + logger_error.assert_not_called() + + def test_check_public_access_handles_false_string(self): + from prowler.providers.alibabacloud.services.cs.cs_service import CS + + service = CS.__new__(CS) + + result = service._check_public_access( + {"parameters": {"endpoint_public": "false"}, "master_url": ""}, + "cn-hangzhou", + ) + + assert result is False diff --git a/tests/providers/alibabacloud/services/oss/oss_service_test.py b/tests/providers/alibabacloud/services/oss/oss_service_test.py index e71c2eca9e..96c822dbfc 100644 --- a/tests/providers/alibabacloud/services/oss/oss_service_test.py +++ b/tests/providers/alibabacloud/services/oss/oss_service_test.py @@ -26,6 +26,8 @@ def _build_oss_service(audit_resources=None): service.client = client service.session = MagicMock() service.session.get_credentials.return_value = _DummyCreds() + service._bucket_inventory_lock = Lock() + service._bucket_inventory_loaded = False # Avoid real thread pool in tests service.__threading_call__ = lambda call, iterator=None: [ call(item) for item in ((iterator or service.regional_clients.values())) @@ -97,3 +99,37 @@ def test_list_buckets_rejects_xxe_payload(): oss._list_buckets() assert oss.buckets == {} + + +def test_list_buckets_userdisable_is_not_logged_as_error(): + oss = _build_oss_service() + + with ( + patch("requests.get") as get_mock, + patch( + "prowler.providers.alibabacloud.services.oss.oss_service.logger.error" + ) as logger_error, + ): + get_mock.return_value = MagicMock( + status_code=403, + text="UserDisableUserDisable", + ) + oss._list_buckets() + + assert oss.buckets == {} + logger_error.assert_not_called() + + +def test_list_buckets_inventory_is_loaded_once_across_regions(): + oss = _build_oss_service() + other_client = MagicMock() + other_client.region = "us-east-1" + oss.regional_clients["us-east-1"] = other_client + + with patch("requests.get") as get_mock: + get_mock.return_value = MagicMock( + status_code=200, text=_fake_oss_list_response() + ) + oss.__threading_call__(oss._list_buckets) + + assert get_mock.call_count == 1 diff --git a/tests/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number_test.py b/tests/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number_test.py new file mode 100644 index 0000000000..cc9b9ab35e --- /dev/null +++ b/tests/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number_test.py @@ -0,0 +1,67 @@ +from unittest import mock + +from tests.providers.alibabacloud.alibabacloud_fixtures import ( + set_mocked_alibabacloud_provider, +) + + +class TestRamPasswordPolicyNumber: + def test_numbers_not_required_fails(self): + ram_client = mock.MagicMock() + ram_client.audited_account = "1234567890" + ram_client.region = "cn-hangzhou" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number.ram_client", + new=ram_client, + ), + ): + from prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number import ( + ram_password_policy_number, + ) + from prowler.providers.alibabacloud.services.ram.ram_service import ( + PasswordPolicy, + ) + + ram_client.password_policy = PasswordPolicy(require_numbers=False) + + check = ram_password_policy_number() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_numbers_required_passes(self): + ram_client = mock.MagicMock() + ram_client.audited_account = "1234567890" + ram_client.region = "cn-hangzhou" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number.ram_client", + new=ram_client, + ), + ): + from prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number import ( + ram_password_policy_number, + ) + from prowler.providers.alibabacloud.services.ram.ram_service import ( + PasswordPolicy, + ) + + ram_client.password_policy = PasswordPolicy(require_numbers=True) + + check = ram_password_policy_number() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/alibabacloud/services/rds/alibabacloud_rds_service_test.py b/tests/providers/alibabacloud/services/rds/alibabacloud_rds_service_test.py index e6eb54801c..02433746c4 100644 --- a/tests/providers/alibabacloud/services/rds/alibabacloud_rds_service_test.py +++ b/tests/providers/alibabacloud/services/rds/alibabacloud_rds_service_test.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch from tests.providers.alibabacloud.alibabacloud_fixtures import ( set_mocked_alibabacloud_provider, @@ -22,3 +23,47 @@ class TestRDSService: assert rds_client.service == "rds" assert rds_client.provider == alibabacloud_provider + + def test_describe_instances_sets_region_id_on_list_request(self): + from prowler.providers.alibabacloud.services.rds import ( + rds_service as rds_service_module, + ) + + service = rds_service_module.RDS.__new__(rds_service_module.RDS) + service.audit_resources = [] + service.instances = [] + + request = SimpleNamespace(region_id=None) + regional_client = MagicMock(region="cn-qingdao") + regional_client.describe_dbinstances.return_value = SimpleNamespace( + body=SimpleNamespace(items=None) + ) + mock_models = SimpleNamespace( + DescribeDBInstancesRequest=MagicMock(return_value=request) + ) + + with patch.object(rds_service_module, "rds_models", mock_models): + service._describe_instances(regional_client) + + assert request.region_id == "cn-qingdao" + + def test_describe_db_instance_attribute_sets_region_id(self): + from prowler.providers.alibabacloud.services.rds import ( + rds_service as rds_service_module, + ) + + service = rds_service_module.RDS.__new__(rds_service_module.RDS) + request = SimpleNamespace(dbinstance_id=None, region_id=None) + regional_client = MagicMock(region="cn-qingdao") + regional_client.describe_dbinstance_attribute.return_value = SimpleNamespace( + body=SimpleNamespace(items=None) + ) + mock_models = SimpleNamespace( + DescribeDBInstanceAttributeRequest=MagicMock(return_value=request) + ) + + with patch.object(rds_service_module, "rds_models", mock_models): + service._describe_db_instance_attribute(regional_client, "rm-test") + + assert request.dbinstance_id == "rm-test" + assert request.region_id == "cn-qingdao" diff --git a/tests/providers/alibabacloud/services/securitycenter/securitycenter_service_test.py b/tests/providers/alibabacloud/services/securitycenter/securitycenter_service_test.py index 2943b775a9..4d545beeaf 100644 --- a/tests/providers/alibabacloud/services/securitycenter/securitycenter_service_test.py +++ b/tests/providers/alibabacloud/services/securitycenter/securitycenter_service_test.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch from tests.providers.alibabacloud.alibabacloud_fixtures import ( set_mocked_alibabacloud_provider, @@ -24,3 +25,38 @@ class TestSecurityCenterService: assert securitycenter_client.service == "securitycenter" assert securitycenter_client.provider == alibabacloud_provider + + def test_get_edition_retries_transient_service_unavailable(self): + from prowler.providers.alibabacloud.services.securitycenter import ( + securitycenter_service as securitycenter_service_module, + ) + + class ServiceUnavailableError(Exception): + def __init__(self): + super().__init__("ServiceUnavailable") + self.code = "ServiceUnavailable" + self.statusCode = 503 + + service = securitycenter_service_module.SecurityCenter.__new__( + securitycenter_service_module.SecurityCenter + ) + service.client = MagicMock() + service.client.describe_version_config.side_effect = [ + ServiceUnavailableError(), + SimpleNamespace(body=SimpleNamespace(version=5)), + ] + service.edition = None + service.version = None + + with patch.object( + securitycenter_service_module, + "sas_models", + SimpleNamespace( + DescribeVersionConfigRequest=MagicMock(return_value=object()) + ), + ): + service._get_edition() + + assert service.edition == "Advanced" + assert service.version == 5 + assert service.client.describe_version_config.call_count == 2 diff --git a/tests/providers/alibabacloud/services/sls/sls_service_test.py b/tests/providers/alibabacloud/services/sls/sls_service_test.py index bd4db7851c..6d14b2e900 100644 --- a/tests/providers/alibabacloud/services/sls/sls_service_test.py +++ b/tests/providers/alibabacloud/services/sls/sls_service_test.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch from tests.providers.alibabacloud.alibabacloud_fixtures import ( set_mocked_alibabacloud_provider, @@ -22,3 +23,47 @@ class TestSLSService: assert sls_client.service == "sls" assert sls_client.provider == alibabacloud_provider + + def test_get_alerts_retries_transient_list_project_timeout(self): + from prowler.providers.alibabacloud.services.sls import ( + sls_service as sls_service_module, + ) + + class ReadTimeoutError(Exception): + pass + + service = sls_service_module.Sls.__new__(sls_service_module.Sls) + service.audited_account = "1234567890" + service.regional_clients = { + "cn-hangzhou": MagicMock(), + } + service.alerts = [] + + client = service.regional_clients["cn-hangzhou"] + client.list_project.side_effect = [ + ReadTimeoutError( + "HTTPSConnectionPool(host='cn-hangzhou.log.aliyuncs.com', port=443): Read timed out. (read timeout=10.0)" + ), + SimpleNamespace( + body=SimpleNamespace( + projects=[ + SimpleNamespace(project_name="project-1"), + ] + ) + ), + ] + client.list_alerts.return_value = SimpleNamespace( + body=SimpleNamespace(results=[]) + ) + + with patch.object( + sls_service_module, + "sls_models", + SimpleNamespace( + ListProjectRequest=MagicMock(return_value=object()), + ListAlertsRequest=MagicMock(return_value=object()), + ), + ): + service._get_alerts() + + assert client.list_project.call_count == 2 diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index 68342a2735..f874ca8812 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -21,6 +21,7 @@ from prowler.providers.aws.config import ( AWS_STS_GLOBAL_ENDPOINT_REGION, BOTO3_USER_AGENT_EXTRA, ROLE_SESSION_NAME, + get_default_session_config, ) from prowler.providers.aws.exceptions.exceptions import ( AWSArgumentTypeValidationError, @@ -455,6 +456,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 @@ -839,6 +889,132 @@ aws: assert isinstance(aws_provider, AwsProvider) + @mock_aws + def test_excluded_regions_removed_from_enabled_regions(self): + aws_provider = AwsProvider(excluded_regions={AWS_REGION_EU_WEST_1}) + + assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions + assert AWS_REGION_EU_WEST_1 not in aws_provider.generate_regional_clients("ec2") + + @mock_aws + def test_excluded_regions_pruned_from_input_regions(self): + aws_provider = AwsProvider( + regions={AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1}, + excluded_regions={AWS_REGION_EU_WEST_1}, + ) + + assert AWS_REGION_EU_WEST_1 not in aws_provider._identity.audited_regions + assert AWS_REGION_US_EAST_1 in aws_provider._identity.audited_regions + + @mock_aws + def test_excluded_regions_from_config_file(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp: + tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n") + config_path = tmp.name + try: + aws_provider = AwsProvider(config_path=config_path) + assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions + assert aws_provider._excluded_regions == {AWS_REGION_EU_WEST_1} + finally: + os.remove(config_path) + + @mock_aws + def test_excluded_regions_from_env_on_direct_provider_init(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_DISALLOWED_REGIONS": AWS_REGION_EU_WEST_1}, + clear=False, + ): + aws_provider = AwsProvider() + + assert aws_provider._excluded_regions == {AWS_REGION_EU_WEST_1} + assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions + + @mock_aws + def test_excluded_regions_precedence_explicit_over_env_and_config(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp: + tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n") + config_path = tmp.name + try: + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_DISALLOWED_REGIONS": AWS_REGION_US_EAST_1}, + clear=False, + ): + aws_provider = AwsProvider( + config_path=config_path, + excluded_regions={AWS_REGION_US_EAST_2}, + ) + + assert aws_provider._excluded_regions == {AWS_REGION_US_EAST_2} + assert AWS_REGION_US_EAST_2 not in aws_provider._enabled_regions + assert AWS_REGION_EU_WEST_1 in aws_provider._enabled_regions + assert AWS_REGION_US_EAST_1 in aws_provider._enabled_regions + finally: + os.remove(config_path) + + @mock_aws + def test_excluded_regions_from_config_avoid_excluded_profile_region( + self, monkeypatch + ): + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_EU_WEST_1) + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp: + tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n") + config_path = tmp.name + try: + aws_provider = AwsProvider(config_path=config_path) + + assert aws_provider.identity.profile_region == AWS_REGION_US_EAST_1 + finally: + os.remove(config_path) + + @mock_aws + def test_aws_provider_raises_when_all_input_regions_are_excluded(self): + with raises(AWSArgumentTypeValidationError): + AwsProvider( + regions={AWS_REGION_EU_WEST_1}, + excluded_regions={AWS_REGION_EU_WEST_1}, + ) + + def test_get_excluded_regions_from_env_parses_comma_list(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_DISALLOWED_REGIONS": " me-south-1 , ap-east-1 ,, "}, + ): + assert Provider.get_excluded_regions_from_env() == { + "me-south-1", + "ap-east-1", + } + + def test_get_excluded_regions_from_env_ignores_legacy_generic_name(self): + with mock.patch.dict( + os.environ, + {"PROWLER_DISALLOWED_REGIONS": "me-south-1"}, + clear=True, + ): + assert Provider.get_excluded_regions_from_env() == set() + + def test_get_excluded_regions_from_env_unset(self): + with mock.patch.dict(os.environ, {}, clear=True): + assert Provider.get_excluded_regions_from_env() == set() + + @mock_aws + def test_print_credentials_shows_all_except_excluded_regions(self): + aws_provider = AwsProvider( + excluded_regions={AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1} + ) + + with patch( + "prowler.providers.aws.aws_provider.print_boxes" + ) as mock_print_boxes: + aws_provider.print_credentials() + + report_lines = mock_print_boxes.call_args.args[0] + assert any( + "AWS Regions:" in line and "all except eu-west-1, us-east-1" in line + for line in report_lines + ) + @mock_aws def test_generate_regional_clients_all_enabled_regions(self): aws_provider = AwsProvider() @@ -2033,6 +2209,24 @@ aws: == AWS_REGION_EU_WEST_1 ) + def test_get_aws_region_for_sts_avoids_excluded_session_region(self): + input_regions = None + session_region = AWS_REGION_EU_WEST_1 + assert ( + get_aws_region_for_sts( + session_region, input_regions, {AWS_REGION_EU_WEST_1} + ) + == AWS_REGION_US_EAST_1 + ) + + def test_get_profile_region_avoids_excluded_session_region(self): + mocked_session = mock.Mock(region_name=AWS_REGION_EU_WEST_1) + + assert ( + AwsProvider.get_profile_region(mocked_session, {AWS_REGION_EU_WEST_1}) + == AWS_REGION_US_EAST_1 + ) + @mock_aws def test_set_session_config_default(self): aws_provider = AwsProvider() @@ -2049,6 +2243,12 @@ aws: assert session_config.user_agent_extra == BOTO3_USER_AGENT_EXTRA assert session_config.retries == {"max_attempts": 10, "mode": "standard"} + def test_get_default_session_config(self): + config = get_default_session_config() + + assert config.user_agent_extra == BOTO3_USER_AGENT_EXTRA + assert config.retries == {"max_attempts": 3, "mode": "standard"} + @mock_aws @patch( "prowler.lib.check.utils.recover_checks_from_provider", diff --git a/tests/providers/aws/conftest.py b/tests/providers/aws/conftest.py new file mode 100644 index 0000000000..f19fcd7d2c --- /dev/null +++ b/tests/providers/aws/conftest.py @@ -0,0 +1,46 @@ +import pytest +from unittest.mock import patch + +from moto import mock_aws + + +@pytest.fixture(autouse=True) +def _mock_aws_globally(): + """Activate moto's mock_aws for every test under tests/providers/aws/. + + This prevents any test from accidentally hitting real AWS endpoints, + even if it forgets to add @mock_aws on the method. Tests that never + call boto3 are unaffected (mock_aws is a no-op in that case). + """ + with mock_aws(): + yield + + +@pytest.fixture(autouse=True) +def _detect_aws_leaks(): + """Fail the test if any HTTP request reaches a real AWS endpoint.""" + calls = [] + original_send = None + + try: + from botocore.httpsession import URLLib3Session + + original_send = URLLib3Session.send + except ImportError: + yield + return + + def tracking_send(self, request): + url = getattr(request, "url", str(request)) + if ".amazonaws.com" in url: + calls.append(url) + return original_send(self, request) + + with patch.object(URLLib3Session, "send", tracking_send): + yield + + if calls: + pytest.fail( + f"Test leaked {len(calls)} real AWS call(s):\n" + + "\n".join(f" - {url}" for url in calls[:5]) + ) diff --git a/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py b/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py index 1c1c10bfd3..5c2c99cbfc 100644 --- a/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py +++ b/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py @@ -100,7 +100,7 @@ class TestCloudTrailTimeline: assert len(result) == 1 assert result[0]["event_name"] == "RunInstances" - assert result[0]["actor"] == "admin" + assert result[0]["actor"] == "user/admin" assert result[0]["source_ip_address"] == "203.0.113.1" def test_get_resource_timeline_with_resource_uid( @@ -120,7 +120,7 @@ class TestCloudTrailTimeline: assert result[0]["event_name"] == "RunInstances" def test_get_resource_timeline_prefers_uid_over_id(self, mock_session): - """When both resource_id and resource_uid are provided, UID should be used.""" + """When both resource_id and resource_uid are provided, UID is tried first.""" mock_client = MagicMock() mock_client.lookup_events.return_value = {"Events": []} mock_session.client.return_value = mock_client @@ -132,9 +132,9 @@ class TestCloudTrailTimeline: resource_uid="arn:aws:ec2:us-east-1:123:instance/i-1234", ) - # Verify UID was used in the lookup - call_args = mock_client.lookup_events.call_args - lookup_attrs = call_args.kwargs["LookupAttributes"] + # Verify UID was used on the first lookup call + first_call = mock_client.lookup_events.call_args_list[0] + lookup_attrs = first_call.kwargs["LookupAttributes"] assert ( lookup_attrs[0]["AttributeValue"] == "arn:aws:ec2:us-east-1:123:instance/i-1234" @@ -304,14 +304,28 @@ class TestExtractActor: "arn": "arn:aws:iam::123456789012:user/alice", "userName": "alice", } - assert CloudTrailTimeline._extract_actor(user_identity) == "alice" + assert CloudTrailTimeline._extract_actor(user_identity) == "user/alice" def test_extract_actor_assumed_role(self): user_identity = { "type": "AssumedRole", "arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-name", } - assert CloudTrailTimeline._extract_actor(user_identity) == "MyRole" + assert ( + CloudTrailTimeline._extract_actor(user_identity) + == "assumed-role/MyRole/session-name" + ) + + def test_extract_actor_assumed_role_sso(self): + """SSO sessions store the user identity in the session name.""" + user_identity = { + "type": "AssumedRole", + "arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com", + } + assert ( + CloudTrailTimeline._extract_actor(user_identity) + == "assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com" + ) def test_extract_actor_root(self): user_identity = {"type": "Root", "arn": "arn:aws:iam::123456789012:root"} @@ -327,21 +341,33 @@ class TestExtractActor: == "elasticloadbalancing.amazonaws.com" ) - def test_extract_actor_fallback_to_principal_id(self): - user_identity = {"type": "Unknown", "principalId": "AROAEXAMPLEID:session"} - assert ( - CloudTrailTimeline._extract_actor(user_identity) == "AROAEXAMPLEID:session" - ) - def test_extract_actor_unknown(self): assert CloudTrailTimeline._extract_actor({}) == "Unknown" + def test_extract_actor_username_only_returns_unknown(self): + """When userIdentity carries only userName/principalId (no arn or + invokedBy), we deliberately return "Unknown" — we rely on the ARN + from the upstream service for the actor.""" + assert ( + CloudTrailTimeline._extract_actor({"type": "IAMUser", "userName": "alice"}) + == "Unknown" + ) + assert ( + CloudTrailTimeline._extract_actor( + {"type": "Unknown", "principalId": "AROAEXAMPLEID:session"} + ) + == "Unknown" + ) + def test_extract_actor_federated_user(self): user_identity = { "type": "FederatedUser", "arn": "arn:aws:sts::123456789012:federated-user/developer", } - assert CloudTrailTimeline._extract_actor(user_identity) == "developer" + assert ( + CloudTrailTimeline._extract_actor(user_identity) + == "federated-user/developer" + ) class TestParseEvent: @@ -380,7 +406,7 @@ class TestParseEvent: assert result is not None assert result["event_name"] == "RunInstances" assert result["event_source"] == "ec2.amazonaws.com" - assert result["actor"] == "admin" + assert result["actor"] == "user/admin" assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin" assert result["actor_type"] == "IAMUser" @@ -424,7 +450,10 @@ class TestParseEvent: "EventName": "RunInstances", "EventSource": "ec2.amazonaws.com", "CloudTrailEvent": { - "userIdentity": {"type": "IAMUser", "userName": "admin"}, + "userIdentity": { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/admin", + }, }, } timeline = CloudTrailTimeline(session=mock_session) @@ -432,7 +461,7 @@ class TestParseEvent: assert result is not None assert result["event_name"] == "RunInstances" - assert result["actor"] == "admin" + assert result["actor"] == "user/admin" def test_parse_event_missing_event_id(self, mock_session): """Test parsing event without EventId returns None (event_id is required).""" @@ -506,7 +535,7 @@ class TestParseEvent: assert result is not None assert result["event_name"] == "RunInstances" - assert result["actor"] == "admin" + assert result["actor"] == "user/admin" # actor_type should be None when not present in userIdentity assert result["actor_type"] is None @@ -606,3 +635,159 @@ class TestIsReadOnlyEvent: """Verify write events are not marked as read-only.""" timeline = CloudTrailTimeline(session=mock_session) assert timeline._is_read_only_event(event_name) is False + + +class TestExtractShortName: + """Tests for _extract_short_name static method.""" + + @pytest.mark.parametrize( + "identifier,expected", + [ + ("arn:aws:s3:::my-bucket", "my-bucket"), + ("arn:aws:iam::123456789012:user/alice", "alice"), + ("arn:aws:iam::123456789012:role/MyRole", "MyRole"), + ( + "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234", + "i-0abc1234", + ), + ( + "arn:aws:lambda:us-east-1:123456789012:function:my-func", + "my-func", + ), + ("arn:aws:rds:us-east-1:123456789012:db:mydb", "mydb"), + ("arn:aws:dynamodb:us-east-1:123456789012:table/MyTable", "MyTable"), + ( + "arn:aws:kms:us-east-1:123456789012:key/abcd-efgh", + "abcd-efgh", + ), + ("i-0abc1234", "i-0abc1234"), + ("my-bucket", "my-bucket"), + ("", ""), + ], + ) + def test_extract_short_name(self, identifier, expected): + assert CloudTrailTimeline._extract_short_name(identifier) == expected + + +class TestLookupEventsFallback: + """Tests for the ARN-to-short-name fallback in _lookup_events.""" + + @pytest.fixture + def mock_session(self): + return MagicMock() + + @pytest.fixture + def sample_event(self): + return { + "EventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "CreateBucket", + "EventSource": "s3.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/admin", + "userName": "admin", + } + } + ), + } + + def test_no_fallback_when_arn_returns_events(self, mock_session, sample_event): + """When the ARN lookup returns events, we do not retry with the short name.""" + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": [sample_event]} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", + resource_uid="arn:aws:kms:us-east-1:123456789012:key/abcd-efgh", + ) + + assert len(result) == 1 + assert mock_client.lookup_events.call_count == 1 + call = mock_client.lookup_events.call_args + assert ( + call.kwargs["LookupAttributes"][0]["AttributeValue"] + == "arn:aws:kms:us-east-1:123456789012:key/abcd-efgh" + ) + + def test_fallback_to_short_name_when_arn_returns_empty( + self, mock_session, sample_event + ): + """When the ARN lookup returns nothing, we retry with the short name.""" + mock_client = MagicMock() + mock_client.lookup_events.side_effect = [ + {"Events": []}, + {"Events": [sample_event]}, + ] + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", resource_uid="arn:aws:s3:::my-bucket" + ) + + assert len(result) == 1 + assert mock_client.lookup_events.call_count == 2 + first_call, second_call = mock_client.lookup_events.call_args_list + assert ( + first_call.kwargs["LookupAttributes"][0]["AttributeValue"] + == "arn:aws:s3:::my-bucket" + ) + assert ( + second_call.kwargs["LookupAttributes"][0]["AttributeValue"] == "my-bucket" + ) + + def test_no_fallback_when_identifier_has_no_short_name(self, mock_session): + """A non-ARN identifier collapses to itself; no retry should fire.""" + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": []} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", resource_id="i-0abc1234" + ) + + assert result == [] + assert mock_client.lookup_events.call_count == 1 + + def test_no_fallback_when_identifier_is_not_arn(self, mock_session): + """A non-ARN identifier with / or : must not trigger the retry.""" + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": []} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", resource_id="some-prefix/weird:value" + ) + + assert result == [] + assert mock_client.lookup_events.call_count == 1 + + def test_both_lookups_empty_returns_empty_list(self, mock_session): + """If both the ARN and short-name lookups return empty, we return [].""" + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": []} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", + resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234", + ) + + assert result == [] + assert mock_client.lookup_events.call_count == 2 + first_call, second_call = mock_client.lookup_events.call_args_list + assert ( + first_call.kwargs["LookupAttributes"][0]["AttributeValue"] + == "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234" + ) + assert ( + second_call.kwargs["LookupAttributes"][0]["AttributeValue"] == "i-0abc1234" + ) diff --git a/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py b/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py new file mode 100644 index 0000000000..bce53165d6 --- /dev/null +++ b/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py @@ -0,0 +1,112 @@ +import json +import urllib.error +from ipaddress import IPv4Network, IPv6Network, ip_address +from unittest.mock import MagicMock, patch + +from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks + +URLOPEN_TARGET = "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen" + +SAMPLE_RANGES = { + "prefixes": [ + {"ip_prefix": "54.152.0.0/16", "service": "AMAZON"}, + {"ip_prefix": "3.5.140.0/22", "service": "S3"}, + ], + "ipv6_prefixes": [ + {"ipv6_prefix": "2600:1f00::/24", "service": "AMAZON"}, + ], +} + + +def mock_urlopen(payload): + response = MagicMock() + response.read.return_value = json.dumps(payload).encode() + context_manager = MagicMock() + context_manager.__enter__.return_value = response + context_manager.__exit__.return_value = False + return context_manager + + +class TestGetPublicIPNetworks: + def test_parses_ipv4_and_ipv6_prefixes(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert networks == [ + IPv4Network("54.152.0.0/16"), + IPv4Network("3.5.140.0/22"), + IPv6Network("2600:1f00::/24"), + ] + + def test_known_aws_ip_is_contained(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert any(ip_address("54.152.12.70") in network for network in networks) + + def test_external_ip_is_not_contained(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert not any(ip_address("17.5.7.3") in network for network in networks) + + def test_empty_payload_returns_empty_list(self): + with patch( + "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen", + return_value=mock_urlopen({}), + ): + networks = get_public_ip_networks() + + assert networks == [] + + def test_prefixes_missing_cidr_are_skipped(self): + payload = { + "prefixes": [{"ip_prefix": "10.0.0.0/8"}, {"service": "EC2"}], + "ipv6_prefixes": [{"service": "AMAZON"}], + } + with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)): + networks = get_public_ip_networks() + + assert networks == [IPv4Network("10.0.0.0/8")] + + def test_urlopen_failure_returns_empty_list(self): + with patch(URLOPEN_TARGET, side_effect=urllib.error.URLError("boom")): + networks = get_public_ip_networks() + + assert networks == [] + + def test_timeout_returns_empty_list(self): + with patch(URLOPEN_TARGET, side_effect=TimeoutError("timed out")): + networks = get_public_ip_networks() + + assert networks == [] + + def test_invalid_json_returns_empty_list(self): + response = MagicMock() + response.read.return_value = b"not json" + context_manager = MagicMock() + context_manager.__enter__.return_value = response + context_manager.__exit__.return_value = False + with patch(URLOPEN_TARGET, return_value=context_manager): + networks = get_public_ip_networks() + + assert networks == [] + + def test_malformed_cidr_is_skipped(self): + payload = { + "prefixes": [ + {"ip_prefix": "300.0.0.0/8"}, + {"ip_prefix": "10.0.0.0/8"}, + ], + "ipv6_prefixes": [ + {"ipv6_prefix": "2600::/129"}, + {"ipv6_prefix": "2600:1f00::/24"}, + ], + } + with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)): + networks = get_public_ip_networks() + + assert networks == [ + IPv4Network("10.0.0.0/8"), + IPv6Network("2600:1f00::/24"), + ] diff --git a/tests/providers/aws/lib/organizations/organizations_test.py b/tests/providers/aws/lib/organizations/organizations_test.py index 8c0def64ac..a2d8ad0441 100644 --- a/tests/providers/aws/lib/organizations/organizations_test.py +++ b/tests/providers/aws/lib/organizations/organizations_test.py @@ -4,6 +4,8 @@ import boto3 from botocore.exceptions import ClientError from moto import mock_aws +from prowler.providers.aws.aws_provider import AwsProvider +from prowler.providers.aws.config import BOTO3_USER_AGENT_EXTRA from prowler.providers.aws.lib.organizations.organizations import ( _get_ou_metadata, get_organizations_metadata, @@ -222,6 +224,20 @@ class Test_AWS_Organizations: assert tags == {} assert ou_metadata == {} + def test_get_organizations_metadata_uses_user_agent_extra(self): + real_session = boto3.Session() + real_session._session.set_default_client_config( + AwsProvider.set_session_config(None) + ) + wrapper = MagicMock(wraps=real_session) + + get_organizations_metadata("123456789012", wrapper) + + wrapper.client.assert_called_once() + default_config = real_session._session.get_default_client_config() + assert default_config is not None + assert BOTO3_USER_AGENT_EXTRA in default_config.user_agent_extra + def test_parse_organizations_metadata_with_empty_ou_metadata(self): tags = {"Tags": []} metadata = { diff --git a/tests/providers/aws/lib/service/service_test.py b/tests/providers/aws/lib/service/service_test.py index ea6768bdc1..c1cd4b05bc 100644 --- a/tests/providers/aws/lib/service/service_test.py +++ b/tests/providers/aws/lib/service/service_test.py @@ -1,5 +1,6 @@ from mock import patch +from prowler.providers.aws.config import BOTO3_USER_AGENT_EXTRA from prowler.providers.aws.lib.service.service import AWSService from tests.providers.aws.utils import ( AWS_ACCOUNT_ARN, @@ -78,7 +79,9 @@ class TestAWSService: def test_AWSService_non_global_service_uses_profile_region(self): """Non-global services should use the profile region when available.""" service_name = "s3" - provider = set_mocked_aws_provider(profile_region=AWS_REGION_EU_WEST_1) + provider = set_mocked_aws_provider( + audited_regions=[], profile_region=AWS_REGION_EU_WEST_1 + ) service = AWSService(service_name, provider) assert service.region == AWS_REGION_EU_WEST_1 @@ -187,6 +190,15 @@ class TestAWSService: == f"arn:{service.audited_partition}:{service_name}::{AWS_ACCOUNT_NUMBER}:bucket/unknown" ) + def test_AWSService_clients_carry_user_agent_extra(self): + provider = set_mocked_aws_provider() + + service = AWSService("s3", provider) + ad_hoc_client = service.session.client("ec2", AWS_REGION_US_EAST_1) + + assert BOTO3_USER_AGENT_EXTRA in service.client._client_config.user_agent_extra + assert BOTO3_USER_AGENT_EXTRA in ad_hoc_client._client_config.user_agent_extra + def test_AWSService_get_unknown_arn_resource_type_set_region(self): service_name = "s3" provider = set_mocked_aws_provider() diff --git a/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py new file mode 100644 index 0000000000..1685115755 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py @@ -0,0 +1,157 @@ +from unittest import mock + +from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + + +def _build_client(certificate_authorities, audit_config=None): + acmpca_client = mock.MagicMock() + acmpca_client.certificate_authorities = certificate_authorities + acmpca_client.audit_config = audit_config or {} + return acmpca_client + + +def _ca(key_algorithm: str, status: str = "ACTIVE"): + return CertificateAuthority( + arn=CA_ARN, + id=CA_ID, + region=AWS_REGION_US_EAST_1, + status=status, + type="SUBORDINATE", + usage_mode="GENERAL_PURPOSE", + key_algorithm=key_algorithm, + signing_algorithm="ML_DSA_65" if "ML_DSA" in key_algorithm else "SHA256WITHRSA", + ) + + +class Test_acmpca_certificate_authority_pqc_key_algorithm: + def test_no_cas(self): + acmpca_client = _build_client({}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + assert len(result) == 0 + + def test_ml_dsa_65(self): + acmpca_client = _build_client({CA_ARN: _ca("ML_DSA_65")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "ML_DSA_65" in result[0].status_extended + assert result[0].resource_id == CA_ID + assert result[0].resource_arn == CA_ARN + + def test_rsa_2048_fails(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RSA_2048" in result[0].status_extended + + def test_deleted_ca_skipped(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048", status="DELETED")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 0 + + def test_configurable_allowlist(self): + acmpca_client = _build_client( + {CA_ARN: _ca("RSA_2048")}, + audit_config={"acmpca_pqc_key_algorithms": ["ML_DSA_65", "RSA_2048"]}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/acmpca/acmpca_service_test.py b/tests/providers/aws/services/acmpca/acmpca_service_test.py new file mode 100644 index 0000000000..024df5e5a2 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_service_test.py @@ -0,0 +1,65 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.acmpca.acmpca_service import ( + ACMPCA, + CertificateAuthority, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListCertificateAuthorities": + return { + "CertificateAuthorities": [ + { + "Arn": CA_ARN, + "Status": "ACTIVE", + "Type": "SUBORDINATE", + "UsageMode": "GENERAL_PURPOSE", + "CertificateAuthorityConfiguration": { + "KeyAlgorithm": "ML_DSA_65", + "SigningAlgorithm": "ML_DSA_65", + }, + } + ] + } + if operation_name == "ListTags": + assert kwarg["CertificateAuthorityArn"] == CA_ARN + return {"Tags": [{"Key": "Environment", "Value": "test"}]} + return make_api_call(self, operation_name, kwarg) + + +class Test_ACMPCA_Service: + @mock_aws + def test_service(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert acmpca.service == "acm-pca" + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_certificate_authorities(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert len(acmpca.certificate_authorities) == 1 + ca = acmpca.certificate_authorities[CA_ARN] + assert isinstance(ca, CertificateAuthority) + assert ca.id == CA_ID + assert ca.region == AWS_REGION_US_EAST_1 + assert ca.status == "ACTIVE" + assert ca.type == "SUBORDINATE" + assert ca.key_algorithm == "ML_DSA_65" + assert ca.signing_algorithm == "ML_DSA_65" + assert ca.tags == [{"Key": "Environment", "Value": "test"}] diff --git a/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py b/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py new file mode 100644 index 0000000000..6d460be32e --- /dev/null +++ b/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py @@ -0,0 +1,243 @@ +from unittest import mock + +from moto import mock_aws + +from prowler.providers.aws.services.apigateway.apigateway_service import DomainName +from tests.providers.aws.utils import ( + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +DOMAIN_NAME = "api.example.com" +DOMAIN_ARN = f"arn:aws:apigateway:{AWS_REGION_US_EAST_1}::/domainnames/{DOMAIN_NAME}" + + +def _build_client(security_policy: str): + apigw_client = mock.MagicMock() + apigw_client.audit_config = {} + apigw_client.domain_names = [ + DomainName( + name=DOMAIN_NAME, + arn=DOMAIN_ARN, + region=AWS_REGION_US_EAST_1, + security_policy=security_policy, + ) + ] + return apigw_client + + +class Test_apigateway_domain_name_pqc_tls_enabled: + @mock_aws + def test_no_domains(self): + apigw_client = mock.MagicMock() + apigw_client.audit_config = {} + apigw_client.domain_names = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_tls13_only_policy_fails_by_default(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_3_2025_09") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "SecurityPolicy_TLS13_1_3_2025_09" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + assert result[0].resource_id == DOMAIN_NAME + assert result[0].resource_arn == DOMAIN_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_alternate_pq_policy(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "SecurityPolicy_TLS13_1_2_PQ_2025_09" in result[0].status_extended + ) + + @mock_aws + def test_legacy_tls_1_2(self): + apigw_client = _build_client("TLS_1_2") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLS_1_2" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + + @mock_aws + def test_missing_security_policy(self): + apigw_client = _build_client("") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "" in result[0].status_extended + + @mock_aws + def test_configurable_allowlist(self): + apigw_client = _build_client("TLS_1_2") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": [ + "TLS_1_2", + ] + } + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_null_config_uses_default_allowlist(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": None, + } + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_non_iterable_config_uses_default_allowlist(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": 123, + } + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/apigateway/apigateway_service_test.py b/tests/providers/aws/services/apigateway/apigateway_service_test.py index 9396693ec3..c153af2871 100644 --- a/tests/providers/aws/services/apigateway/apigateway_service_test.py +++ b/tests/providers/aws/services/apigateway/apigateway_service_test.py @@ -206,3 +206,26 @@ class Test_APIGateway_Service: assert list(apigateway.rest_apis[0].resources[1].resource_methods.values()) == [ "AWS_IAM" ] + + # Test APIGateway _get_domain_names + @mock_aws + def test_get_domain_names(self): + apigateway_client = client("apigateway", region_name=AWS_REGION_US_EAST_1) + + apigateway_client.create_domain_name( + domainName="api.example.com", + securityPolicy="SecurityPolicy_TLS13_1_3_2025_09", + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + apigateway = APIGateway(aws_provider) + + assert len(apigateway.domain_names) == 1 + domain = apigateway.domain_names[0] + assert domain.name == "api.example.com" + assert domain.region == AWS_REGION_US_EAST_1 + assert domain.security_policy == "SecurityPolicy_TLS13_1_3_2025_09" + assert ( + domain.arn + == f"arn:aws:apigateway:{AWS_REGION_US_EAST_1}::/domainnames/api.example.com" + ) diff --git a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py index 1005761834..ec45b89a49 100644 --- a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py +++ b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration_test.py @@ -104,7 +104,7 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration: InstanceType="t1.micro", KeyName="the_keys", SecurityGroups=["default", "default2"], - UserData="DB_PASSWORD=foobar123", + UserData='DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"', ) launch_configuration_arn = autoscaling_client.describe_launch_configurations( LaunchConfigurationNames=[launch_configuration_name] @@ -341,7 +341,9 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration: check = autoscaling_find_secrets_ec2_launch_configuration() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not decode User Data" in result[0].status_extended @mock_aws def test_one_autoscaling_file_invalid_gzip_error(self): @@ -381,4 +383,6 @@ class Test_autoscaling_find_secrets_ec2_launch_configuration: check = autoscaling_find_secrets_ec2_launch_configuration() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not decode User Data" in result[0].status_extended diff --git a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture index 2fb5138932..c591954ab4 100644 --- a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture +++ b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture @@ -1,4 +1,4 @@ -DB_PASSWORD=foobar123 +DB_PASSWORD="Tr0ub4dor3xKq9vLmZ" DB_USER=foo -API_KEY=12345abcd -SERVICE_PASSWORD=bbaabb45 +API_KEY=s3rv1c3Acc0untS3cr3tV4lu3x9 +SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4" diff --git a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz index 6120fcfbc4..15e68af70e 100644 Binary files a/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz and b/tests/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/fixtures/fixture.gz differ diff --git a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py index 5f97082c1b..e6399eb3ce 100644 --- a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py +++ b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code_test.py @@ -19,7 +19,7 @@ LAMBDA_FUNCTION_RUNTIME = "nodejs4.3" LAMBDA_FUNCTION_ARN = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{LAMBDA_FUNCTION_NAME}" LAMBDA_FUNCTION_CODE_WITH_SECRETS = """ def lambda_handler(event, context): - db_password = "test-password" + db_password = "Tr0ub4dor3xKq9vLmZ" print("custom log event") return event """ @@ -126,7 +126,7 @@ class Test_awslambda_function_no_secrets_in_code: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in Lambda function {LAMBDA_FUNCTION_NAME} code -> lambda_function.py: Secret Keyword on line 3." + == f"Potential secret found in Lambda function {LAMBDA_FUNCTION_NAME} code -> lambda_function.py: Generic Password on line 3." ) assert result[0].resource_tags == [] @@ -201,3 +201,35 @@ class Test_awslambda_function_no_secrets_in_code: == f"No secrets found in Lambda function {LAMBDA_FUNCTION_NAME} code." ) assert result[0].resource_tags == [] + + def test_scan_failure_reports_manual_not_pass(self): + from prowler.lib.utils.utils import SecretsScanError + + lambda_client = mock.MagicMock + lambda_client.functions = {LAMBDA_FUNCTION_ARN: create_lambda_function()} + lambda_client._get_function_code = mock_get_function_codewith_secrets + lambda_client.audit_config = {"secrets_ignore_patterns": []} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.awslambda_client", + new=lambda_client, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_code.awslambda_function_no_secrets_in_code import ( + awslambda_function_no_secrets_in_code, + ) + + check = awslambda_function_no_secrets_in_code() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended diff --git a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py index 6ae517dfe2..f7ba14a7d3 100644 --- a/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py +++ b/tests/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables_test.py @@ -97,7 +97,7 @@ class Test_awslambda_function_no_secrets_in_variables: arn=function_arn, region=AWS_REGION_US_EAST_1, runtime=function_runtime, - environment={"db_password": "test-password"}, + environment={"db_password": "Tr0ub4dor3xKq9vLmZ"}, ) } @@ -126,7 +126,7 @@ class Test_awslambda_function_no_secrets_in_variables: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in Lambda function {function_name} variables -> Secret Keyword in variable db_password." + == f"Potential secret found in Lambda function {function_name} variables -> Generic Password in variable db_password." ) assert result[0].resource_tags == [] @@ -145,7 +145,69 @@ class Test_awslambda_function_no_secrets_in_variables: arn=function_arn, region=AWS_REGION_US_EAST_1, runtime=function_runtime, - environment={"db_password": "srv://admin:pass@db"}, + environment={ + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.awslambda_client", + new=lambda_client, + ), + ): + # Test Check + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import ( + awslambda_function_no_secrets_in_variables, + ) + + check = awslambda_function_no_secrets_in_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].status == "FAIL" + # Kingfisher reports both the generic keyword rule and the JWT rule + # for the same value; their order is not guaranteed, so assert on + # presence rather than a fixed concatenation order. + assert result[0].status_extended.startswith( + f"Potential secret found in Lambda function {function_name} variables -> " + ) + assert ( + "Generic Password in variable db_password" in result[0].status_extended + ) + assert ( + "JSON Web Token (base64url-encoded) in variable db_password" + in result[0].status_extended + ) + assert result[0].resource_tags == [] + + def test_function_secrets_in_variables_telegram_token(self): + lambda_client = mock.MagicMock + function_name = "test-lambda" + function_runtime = "nodejs4.3" + function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}" + lambda_client.audit_config = {"secrets_ignore_patterns": []} + lambda_client.functions = { + "function_name": Function( + name=function_name, + security_groups=[], + arn=function_arn, + region=AWS_REGION_US_EAST_1, + runtime=function_runtime, + environment={ + # The Telegram bot-token rule is no longer enabled in + # Kingfisher's built-in ruleset, so a detectable JWT + # is used to keep this token-in-variable case meaningful. + "TELEGRAM_BOT_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, ) } @@ -174,16 +236,22 @@ class Test_awslambda_function_no_secrets_in_variables: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in Lambda function {function_name} variables -> Secret Keyword in variable db_password, Basic Auth Credentials in variable db_password." + == f"Potential secret found in Lambda function {function_name} variables -> JSON Web Token (base64url-encoded) in variable TELEGRAM_BOT_TOKEN." ) assert result[0].resource_tags == [] - def test_function_secrets_in_variables_telegram_token(self): + def test_function_with_verified_secret(self): + from prowler.lib.check.models import Severity + lambda_client = mock.MagicMock function_name = "test-lambda" function_runtime = "nodejs4.3" function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}" - lambda_client.audit_config = {"secrets_ignore_patterns": []} + lambda_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": True, + } + lambda_client.functions = { "function_name": Function( name=function_name, @@ -191,19 +259,35 @@ class Test_awslambda_function_no_secrets_in_variables: arn=function_arn, region=AWS_REGION_US_EAST_1, runtime=function_runtime, - environment={"TELEGRAM_BOT_TOKEN": "telegram-token"}, + environment={"db_password": "test-value"}, ) } with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_aws_provider(), + return_value=set_mocked_aws_provider( + audit_config={"secrets_validate": True} + ), ), mock.patch( "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.awslambda_client", new=lambda_client, ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, ): # Test Check from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import ( @@ -213,16 +297,13 @@ class Test_awslambda_function_no_secrets_in_variables: check = awslambda_function_no_secrets_in_variables() result = check.execute() + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True assert len(result) == 1 - assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended assert result[0].resource_id == function_name - assert result[0].resource_arn == function_arn - assert result[0].status == "PASS" - assert ( - result[0].status_extended - == f"No secrets found in Lambda function {function_name} variables." - ) - assert result[0].resource_tags == [] def test_function_no_secrets_in_variables(self): lambda_client = mock.MagicMock @@ -270,3 +351,48 @@ class Test_awslambda_function_no_secrets_in_variables: == f"No secrets found in Lambda function {function_name} variables." ) assert result[0].resource_tags == [] + + def test_scan_failure_reports_manual_not_pass(self): + # A scanner failure must not be treated as "no secrets found". + from prowler.lib.utils.utils import SecretsScanError + + lambda_client = mock.MagicMock + function_name = "test-lambda" + function_arn = f"arn:aws:lambda:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:function/{function_name}" + lambda_client.audit_config = {"secrets_ignore_patterns": []} + lambda_client.functions = { + "function_name": Function( + name=function_name, + security_groups=[], + arn=function_arn, + region=AWS_REGION_US_EAST_1, + runtime="nodejs4.3", + environment={"db_password": "test-value"}, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.awslambda_client", + new=lambda_client, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_secrets_in_variables.awslambda_function_no_secrets_in_variables import ( + awslambda_function_no_secrets_in_variables, + ) + + check = awslambda_function_no_secrets_in_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended + assert "manual review is required" in result[0].status_extended diff --git a/tests/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible_test.py b/tests/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible_test.py index ef3168433b..ed5e620d47 100644 --- a/tests/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible_test.py +++ b/tests/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible_test.py @@ -312,7 +312,9 @@ class Test_awslambda_function_not_publicly_accessible: with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_aws_provider(), + return_value=set_mocked_aws_provider( + audited_regions=[AWS_REGION_EU_WEST_1] + ), ), mock.patch( "prowler.providers.aws.services.awslambda.awslambda_function_not_publicly_accessible.awslambda_function_not_publicly_accessible.awslambda_client", @@ -552,7 +554,9 @@ class Test_awslambda_function_not_publicly_accessible: with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_aws_provider(), + return_value=set_mocked_aws_provider( + audited_regions=[AWS_REGION_EU_WEST_1] + ), ), mock.patch( "prowler.providers.aws.services.awslambda.awslambda_function_not_publicly_accessible.awslambda_function_not_publicly_accessible.awslambda_client", @@ -615,7 +619,9 @@ class Test_awslambda_function_not_publicly_accessible: with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_aws_provider(), + return_value=set_mocked_aws_provider( + audited_regions=[AWS_REGION_EU_WEST_1] + ), ), mock.patch( "prowler.providers.aws.services.awslambda.awslambda_function_not_publicly_accessible.awslambda_function_not_publicly_accessible.awslambda_client", @@ -690,7 +696,9 @@ class Test_awslambda_function_not_publicly_accessible: with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_aws_provider(), + return_value=set_mocked_aws_provider( + audited_regions=[AWS_REGION_EU_WEST_1] + ), ), mock.patch( "prowler.providers.aws.services.awslambda.awslambda_function_not_publicly_accessible.awslambda_function_not_publicly_accessible.awslambda_client", diff --git a/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py new file mode 100644 index 0000000000..c6e5d7f756 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py @@ -0,0 +1,275 @@ +from json import dumps +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +AGENT_ID = "test-agent-id" +AGENT_NAME = "test-agent-name" +AGENT_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_ID}" +) +ROLE_NAME = "AmazonBedrockExecutionRoleForAgents_test" +ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{ROLE_NAME}" +BOUNDARY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AgentBoundary" + +ASSUME_ROLE_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "bedrock.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} + +BOUNDARY_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "bedrock:*", "Resource": "*"}], +} + +NARROW_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::my-rag-bucket/*"], + } + ], +} + +BROAD_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} + + +# Mock both ListAgents and GetAgent at the botocore level. moto's bedrock-agent +# support is incomplete for our needs (GetAgent often doesn't echo back the +# role ARN we set), so we control the responses directly. We also need to keep +# IAM calls going to moto. +make_api_call = botocore.client.BaseClient._make_api_call + + +def _mock_bedrock_agent_factory(role_arn): + """Return a mock_make_api_call function that returns role_arn from GetAgent. + + Pass role_arn=None to simulate an agent whose role can't be resolved. + """ + + def _mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListAgents": + return { + "agentSummaries": [ + {"agentId": AGENT_ID, "agentName": AGENT_NAME}, + ] + } + if operation_name == "GetAgent": + return { + "agent": { + "agentId": AGENT_ID, + "agentName": AGENT_NAME, + "agentResourceRoleArn": role_arn, + } + } + if operation_name == "ListTagsForResource": + return {"tags": {}} + if operation_name == "ListPrompts": + return {"promptSummaries": []} + return make_api_call(self, operation_name, kwarg) + + return _mock_make_api_call + + +def _setup_role( + *, + attached_policy_arns=(), + inline_policies=None, + permissions_boundary=None, +): + """Create an IAM role in moto with the given configuration. Returns the role ARN.""" + iam = client("iam", region_name=AWS_REGION_US_EAST_1) + + if permissions_boundary: + iam.create_policy( + PolicyName="AgentBoundary", + PolicyDocument=dumps(BOUNDARY_POLICY_DOCUMENT), + ) + + create_kwargs = { + "RoleName": ROLE_NAME, + "AssumeRolePolicyDocument": dumps(ASSUME_ROLE_POLICY_DOCUMENT), + } + if permissions_boundary: + create_kwargs["PermissionsBoundary"] = permissions_boundary + iam.create_role(**create_kwargs) + + for policy_arn in attached_policy_arns: + iam.attach_role_policy(RoleName=ROLE_NAME, PolicyArn=policy_arn) + + for policy_name, policy_document in (inline_policies or {}).items(): + iam.put_role_policy( + RoleName=ROLE_NAME, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + return ROLE_ARN + + +def _run_check(role_arn_for_get_agent): + """Build the IAM + BedrockAgent services, patch them in, run the check.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_bedrock_agent_factory(role_arn_for_get_agent), + ): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + return bedrock_agent_role_least_privilege().execute() + + +class Test_bedrock_agent_role_least_privilege: + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_no_agents(self): + """No agents in the account -> zero findings.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + assert bedrock_agent_role_least_privilege().execute() == [] + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_compliant(self): + """Narrow inline policy + boundary + no *FullAccess attached -> PASS.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "follows least privilege" in result[0].status_extended + assert result[0].resource_id == AGENT_ID + assert result[0].resource_arn == AGENT_ARN + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_full_access_attached(self): + """AmazonBedrockFullAccess attached -> FAIL.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AmazonBedrockFullAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants full access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_administrator_access_attached(self): + """AdministratorAccess attached (no FullAccess suffix) -> FAIL via doc-based admin check.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AdministratorAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "managed policy AdministratorAccess grants administrative access" + in result[0].status_extended + ) + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_resource_star_broad_action(self): + """Inline statement with Action:* on Resource:* -> FAIL.""" + role_arn = _setup_role( + inline_policies={"BroadAccess": BROAD_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants administrative access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_no_permissions_boundary(self): + """Otherwise clean role but missing permissions boundary -> FAIL.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=None, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no permissions boundary configured" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_not_resolvable(self): + """role_arn returned by GetAgent doesn't match any IAM role -> FAIL.""" + result = _run_check( + role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist" + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "could not be resolved" in result[0].status_extended diff --git a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py index bf4f6381b9..d75d248c7c 100644 --- a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py @@ -3,466 +3,197 @@ from unittest import mock from moto import mock_aws +from prowler.lib.check.models import Severity from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider +BEDROCK_SERVICE = "bedrock.amazonaws.com" + + +def _make_user(name="test_user"): + from prowler.providers.aws.services.iam.iam_service import User + + return User( + name=name, + arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{name}", + attached_policies=[], + inline_policies=[], + ) + + +def _make_credential( + user, + credential_id="test-credential-id", + expiration_delta_days=None, + service_name=BEDROCK_SERVICE, +): + from prowler.providers.aws.services.iam.iam_service import ServiceSpecificCredential + + expiration_date = ( + datetime.now(timezone.utc) + timedelta(days=expiration_delta_days) + if expiration_delta_days is not None + else None + ) + return ServiceSpecificCredential( + arn=( + f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{user.name}/" + f"credential/{credential_id}" + ), + user=user, + status="Active", + create_date=datetime.now(timezone.utc), + service_user_name=None, + service_credential_alias=None, + expiration_date=expiration_date, + id=credential_id, + service_name=service_name, + region=AWS_REGION_US_EAST_1, + ) + + +def _run_check(credentials): + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.service_specific_credentials = credentials + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", + new=iam, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( + bedrock_api_key_no_long_term_credentials, + ) + + check = bedrock_api_key_no_long_term_credentials() + return check.execute() + class Test_bedrock_api_key_no_long_term_credentials: @mock_aws def test_no_bedrock_api_keys(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=IAM(aws_provider), - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert _run_check([]) == [] @mock_aws - def test_bedrock_api_key_with_future_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_short_expiration_key_fails_high(self): + # Per AWS guidance, every active long-term key is a finding regardless of + # how soon it expires. Short remaining lifetime does not downgrade severity. + credential = _make_credential(_make_user(), expiration_delta_days=30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "will expire in" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_critical_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_long_expiration_key_fails_high(self): + credential = _make_credential(_make_user(), expiration_delta_days=365) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with very far future expiration date (>10000 days) - expiration_date = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "never expires" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 - assert check.Severity == "critical" + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_expired_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_never_expires_key_fails_critical(self): + # >10000 days approximates AWS's "no expiration" sentinel (~100 years). + credential = _make_credential(_make_user(), expiration_delta_days=15000) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with past expiration date - expiration_date = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "PASS" - assert "has expired" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "configured to never expire" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_without_expiration_date_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_already_expired_key_passes(self): + credential = _make_credential(_make_user(), expiration_delta_days=-30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential without expiration date (should be ignored) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=None, # No expiration date - should be ignored - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has already expired" in result[0].status_extended @mock_aws - def test_non_bedrock_api_key_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential for a different service - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="codecommit.amazonaws.com", # Different service - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + def test_key_without_expiration_date_ignored(self): + credential = _make_credential(_make_user(), expiration_delta_days=None) + assert _run_check([credential]) == [] @mock_aws - def test_multiple_bedrock_api_keys_mixed_scenarios(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, + def test_non_bedrock_service_ignored(self): + credential = _make_credential( + _make_user(), + expiration_delta_days=30, + service_name="codecommit.amazonaws.com", ) + assert _run_check([credential]) == [] - # Create mock users - mock_user1 = User( - name="test_user1", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1", - attached_policies=[], - inline_policies=[], + @mock_aws + def test_mixed_scenarios(self): + user1, user2, user3 = ( + _make_user("u1"), + _make_user("u2"), + _make_user("u3"), ) - - mock_user2 = User( - name="test_user2", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2", - attached_policies=[], - inline_policies=[], - ) - - mock_user3 = User( - name="test_user3", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date1 = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential1 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1/credential/test-credential-id-1", - user=mock_user1, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date1, - id="test-credential-id-1", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with critical expiration date - expiration_date2 = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential2 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2/credential/test-credential-id-2", - user=mock_user2, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date2, - id="test-credential-id-2", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with expired date - expiration_date3 = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential3 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3/credential/test-credential-id-3", - user=mock_user3, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date3, - id="test-credential-id-3", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [ - mock_credential1, - mock_credential2, - mock_credential3, + credentials = [ + _make_credential(user1, "active-key", expiration_delta_days=191), + _make_credential(user2, "never-key", expiration_delta_days=15000), + _make_credential(user3, "expired-key", expiration_delta_days=-30), ] + result = _run_check(credentials) - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, + assert len(result) == 3 + by_id = {r.resource_id: r for r in result} + + assert by_id["active-key"].status == "FAIL" + assert by_id["active-key"].check_metadata.Severity == Severity.high + + assert by_id["never-key"].status == "FAIL" + assert by_id["never-key"].check_metadata.Severity == Severity.critical + + assert by_id["expired-key"].status == "PASS" + + @mock_aws + def test_severity_does_not_leak_never_then_active(self): + """Regression: a never-expires key processed before an active key must + not bleed `critical` severity into the active finding.""" + credentials = [ + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) + ] + result = _run_check(credentials) - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() + by_id = {r.resource_id: r for r in result} + assert by_id["never-key"].check_metadata.Severity == Severity.critical + assert by_id["active-key"].check_metadata.Severity == Severity.high - assert len(result) == 3 + @mock_aws + def test_severity_does_not_leak_active_then_never(self): + """Regression: same as above with the reverse iteration order.""" + credentials = [ + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 + ), + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 + ), + ] + result = _run_check(credentials) - # Check the credential with future expiration date (FAIL) - fail_result1 = next( - r for r in result if r.resource_id == "test-credential-id-1" - ) - assert fail_result1.status == "FAIL" - assert "will expire in" in fail_result1.status_extended - assert "test-credential-id-1" in fail_result1.status_extended - assert "test_user1" in fail_result1.status_extended - - # Check the credential with critical expiration date (FAIL) - fail_result2 = next( - r for r in result if r.resource_id == "test-credential-id-2" - ) - assert fail_result2.status == "FAIL" - assert "never expires" in fail_result2.status_extended - assert "test-credential-id-2" in fail_result2.status_extended - assert "test_user2" in fail_result2.status_extended - - # Check the credential with expired date (PASS) - pass_result = next( - r for r in result if r.resource_id == "test-credential-id-3" - ) - assert pass_result.status == "PASS" - assert "has expired" in pass_result.status_extended - assert "test-credential-id-3" in pass_result.status_extended - assert "test_user3" in pass_result.status_extended + by_id = {r.resource_id: r for r in result} + assert by_id["active-key"].check_metadata.Severity == Severity.high + assert by_id["never-key"].check_metadata.Severity == Severity.critical diff --git a/tests/providers/aws/services/bedrock/bedrock_full_access_policy_attached/bedrock_full_access_policy_attached_test.py b/tests/providers/aws/services/bedrock/bedrock_full_access_policy_attached/bedrock_full_access_policy_attached_test.py new file mode 100644 index 0000000000..1a261d96cc --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_full_access_policy_attached/bedrock_full_access_policy_attached_test.py @@ -0,0 +1,357 @@ +from json import dumps +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.iam.iam_service import Role +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +AWS_ACCOUNT_ID = "123456789012" + +ASSUME_ROLE_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": { + "Sid": "test", + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{AWS_ACCOUNT_ID}:root"}, + "Action": "sts:AssumeRole", + }, +} + + +class Test_bedrock_full_access_policy_attached: + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_no_roles(self): + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 0 + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_role_without_bedrock_full_access_policy(self): + iam = client("iam") + role_name = "test" + response = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ASSUME_ROLE_POLICY_DOCUMENT), + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "IAM Role test does not have AmazonBedrockFullAccess policy attached." + ) + assert result[0].resource_id == "test" + assert result[0].resource_arn == response["Role"]["Arn"] + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_role_with_other_policy(self): + iam = client("iam") + role_name = "test" + response = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ASSUME_ROLE_POLICY_DOCUMENT), + ) + iam.attach_role_policy( + RoleName=role_name, + PolicyArn="arn:aws:iam::aws:policy/SecurityAudit", + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "IAM Role test does not have AmazonBedrockFullAccess policy attached." + ) + assert result[0].resource_id == "test" + assert result[0].resource_arn == response["Role"]["Arn"] + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_role_with_bedrock_full_access_policy(self): + iam = client("iam") + role_name = "test" + response = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ASSUME_ROLE_POLICY_DOCUMENT), + ) + iam.attach_role_policy( + RoleName=role_name, + PolicyArn="arn:aws:iam::aws:policy/AmazonBedrockFullAccess", + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "IAM Role test has AmazonBedrockFullAccess policy attached." + ) + assert result[0].resource_id == "test" + assert result[0].resource_arn == response["Role"]["Arn"] + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_asterisk_principal_role_with_bedrock_full_access_policy(self): + iam = client("iam") + role_name = "test" + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": { + "Sid": "test", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + }, + } + response = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(assume_role_policy_document), + ) + iam.attach_role_policy( + RoleName=role_name, + PolicyArn="arn:aws:iam::aws:policy/AmazonBedrockFullAccess", + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "IAM Role test has AmazonBedrockFullAccess policy attached." + ) + assert result[0].resource_id == "test" + assert result[0].resource_arn == response["Role"]["Arn"] + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_multiple_roles_mixed_policies(self): + iam = client("iam") + + # Create a compliant role (no AmazonBedrockFullAccess) + compliant_response = iam.create_role( + RoleName="compliant-role", + AssumeRolePolicyDocument=dumps(ASSUME_ROLE_POLICY_DOCUMENT), + ) + iam.attach_role_policy( + RoleName="compliant-role", + PolicyArn="arn:aws:iam::aws:policy/SecurityAudit", + ) + + # Create a non-compliant role (with AmazonBedrockFullAccess) + non_compliant_response = iam.create_role( + RoleName="non-compliant-role", + AssumeRolePolicyDocument=dumps(ASSUME_ROLE_POLICY_DOCUMENT), + ) + iam.attach_role_policy( + RoleName="non-compliant-role", + PolicyArn="arn:aws:iam::aws:policy/AmazonBedrockFullAccess", + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 2 + + # Sort results by resource_id for deterministic assertions + result = sorted(result, key=lambda r: r.resource_id) + + # Compliant role + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "IAM Role compliant-role does not have AmazonBedrockFullAccess policy attached." + ) + assert result[0].resource_id == "compliant-role" + assert result[0].resource_arn == compliant_response["Role"]["Arn"] + assert result[0].region == AWS_REGION_US_EAST_1 + + # Non-compliant role + assert result[1].status == "FAIL" + assert ( + result[1].status_extended + == "IAM Role non-compliant-role has AmazonBedrockFullAccess policy attached." + ) + assert result[1].resource_id == "non-compliant-role" + assert result[1].resource_arn == non_compliant_response["Role"]["Arn"] + assert result[1].region == AWS_REGION_US_EAST_1 + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_only_aws_service_linked_roles(self): + iam_client = mock.MagicMock + iam_client.roles = [] + iam_client.roles.append( + Role( + name="AWSServiceRoleForAmazonGuardDuty", + arn="arn:aws:iam::106908755756:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty", + assume_role_policy={ + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + }, + is_service_role=True, + ) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=iam_client, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 0 + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_access_denied(self): + iam_client = mock.MagicMock + iam_client.roles = None + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached.iam_client", + new=iam_client, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_full_access_policy_attached.bedrock_full_access_policy_attached import ( + bedrock_full_access_policy_attached, + ) + + check = bedrock_full_access_policy_attached() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured_test.py b/tests/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured_test.py new file mode 100644 index 0000000000..63ecb39832 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_guardrails_configured/bedrock_guardrails_configured_test.py @@ -0,0 +1,302 @@ +from unittest import mock + +import botocore +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +GUARDRAIL_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/test-id" +) + + +def mock_make_api_call_with_guardrail(self, operation_name, kwarg): + """Mock API call returning one guardrail in us-east-1.""" + if operation_name == "ListGuardrails": + return { + "guardrails": [ + { + "id": "test-id", + "arn": GUARDRAIL_ARN, + "status": "READY", + "name": "test-guardrail", + } + ] + } + elif operation_name == "GetGuardrail": + return { + "name": "test-guardrail", + "guardrailId": "test-id", + "guardrailArn": GUARDRAIL_ARN, + "status": "READY", + "blockedInputMessaging": "Blocked", + "blockedOutputsMessaging": "Blocked", + "contentPolicy": {"filters": []}, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_without_guardrails(self, operation_name, kwarg): + """Mock API call returning no guardrails.""" + if operation_name == "ListGuardrails": + return {"guardrails": []} + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_guardrails_only_in_us_east_1(self, operation_name, kwarg): + """Mock API call returning a guardrail only in us-east-1 and none elsewhere.""" + if operation_name == "ListGuardrails": + if self.meta.region_name == AWS_REGION_US_EAST_1: + return { + "guardrails": [ + { + "id": "test-id", + "arn": GUARDRAIL_ARN, + "status": "READY", + "name": "test-guardrail", + } + ] + } + return {"guardrails": []} + elif operation_name == "GetGuardrail": + return { + "name": "test-guardrail", + "guardrailId": "test-id", + "guardrailArn": GUARDRAIL_ARN, + "status": "READY", + "blockedInputMessaging": "Blocked", + "blockedOutputsMessaging": "Blocked", + "contentPolicy": {"filters": []}, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_guardrail_validation_exception(self, operation_name, kwarg): + """Mock API call raising ValidationException for ListGuardrails.""" + if operation_name == "ListGuardrails": + raise ClientError( + { + "Error": { + "Code": "ValidationException", + "Message": "Guardrails are not supported in this region.", + } + }, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +class Test_bedrock_guardrails_configured: + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_without_guardrails, + ) + @mock_aws + def test_no_guardrails_single_region(self): + """Test FAIL when no guardrails are configured in a single region.""" + from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured.bedrock_client", + new=Bedrock(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured import ( + bedrock_guardrails_configured, + ) + + check = bedrock_guardrails_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Bedrock has no guardrails configured in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "bedrock-guardrails" + assert ( + result[0].resource_arn + == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrails" + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_tags == [] + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_without_guardrails, + ) + def test_no_guardrails_multi_region(self): + """Test FAIL in both regions when no guardrails are configured.""" + from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured.bedrock_client", + new=Bedrock(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured import ( + bedrock_guardrails_configured, + ) + + check = bedrock_guardrails_configured() + result = check.execute() + + assert len(result) == 2 + assert result[0].status == "FAIL" + assert result[0].resource_id == "bedrock-guardrails" + assert result[0].resource_tags == [] + assert result[1].status == "FAIL" + assert result[1].resource_id == "bedrock-guardrails" + assert result[1].resource_tags == [] + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_with_guardrail, + ) + @mock_aws + def test_guardrail_configured(self): + """Test PASS when at least one guardrail is configured in the region.""" + from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured.bedrock_client", + new=Bedrock(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured import ( + bedrock_guardrails_configured, + ) + + check = bedrock_guardrails_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Bedrock guardrail test-guardrail is available in region {AWS_REGION_US_EAST_1}. This does not confirm that the guardrail is attached to agents or used on model invocations." + ) + assert result[0].resource_id == "test-id" + assert result[0].resource_arn == GUARDRAIL_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_tags == [] + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_guardrails_only_in_us_east_1, + ) + @mock_aws + def test_guardrails_in_one_region_only(self): + """Test PASS in the region with a guardrail and FAIL in the region without one.""" + from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured.bedrock_client", + new=Bedrock(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured import ( + bedrock_guardrails_configured, + ) + + check = bedrock_guardrails_configured() + result = check.execute() + + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + eu_result = results_by_region[AWS_REGION_EU_WEST_1] + assert eu_result.status == "FAIL" + assert ( + eu_result.status_extended + == f"Bedrock has no guardrails configured in region {AWS_REGION_EU_WEST_1}." + ) + assert eu_result.resource_id == "bedrock-guardrails" + assert ( + eu_result.resource_arn + == f"arn:aws:bedrock:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:guardrails" + ) + assert eu_result.resource_tags == [] + + us_result = results_by_region[AWS_REGION_US_EAST_1] + assert us_result.status == "PASS" + assert ( + us_result.status_extended + == f"Bedrock guardrail test-guardrail is available in region {AWS_REGION_US_EAST_1}. This does not confirm that the guardrail is attached to agents or used on model invocations." + ) + assert us_result.resource_id == "test-id" + assert us_result.resource_arn == GUARDRAIL_ARN + assert us_result.resource_tags == [] + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_guardrail_validation_exception, + ) + @mock_aws + def test_guardrails_unsupported_region_is_skipped(self): + """Test unsupported regions are skipped instead of failing.""" + from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured.bedrock_client", + new=Bedrock(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_guardrails_configured.bedrock_guardrails_configured import ( + bedrock_guardrails_configured, + ) + + check = bedrock_guardrails_configured() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk_test.py b/tests/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..6a009983a0 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_prompt_encrypted_with_cmk/bedrock_prompt_encrypted_with_cmk_test.py @@ -0,0 +1,174 @@ +from unittest import mock + +import botocore + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +PROMPT_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id" +) +PROMPT_ID = "test-prompt-id" +PROMPT_NAME = "test-prompt" +KMS_KEY_ARN = ( + f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/" + "12345678-1234-1234-1234-123456789012" +) + + +def mock_make_api_call_with_cmk(self, operation_name, kwarg): + """Mock API call returning a prompt encrypted with a customer-managed KMS key.""" + if operation_name == "ListPrompts": + return { + "promptSummaries": [ + { + "id": PROMPT_ID, + "name": PROMPT_NAME, + "arn": PROMPT_ARN, + } + ] + } + elif operation_name == "GetPrompt": + return { + "id": PROMPT_ID, + "name": PROMPT_NAME, + "arn": PROMPT_ARN, + "customerEncryptionKeyArn": KMS_KEY_ARN, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_without_cmk(self, operation_name, kwarg): + """Mock API call returning a prompt without a customer-managed KMS key.""" + if operation_name == "ListPrompts": + return { + "promptSummaries": [ + { + "id": PROMPT_ID, + "name": PROMPT_NAME, + "arn": PROMPT_ARN, + } + ] + } + elif operation_name == "GetPrompt": + return { + "id": PROMPT_ID, + "name": PROMPT_NAME, + "arn": PROMPT_ARN, + } + return make_api_call(self, operation_name, kwarg) + + +class Test_bedrock_prompt_encrypted_with_cmk: + """Test suite for the bedrock_prompt_encrypted_with_cmk check.""" + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=lambda self, op, kwarg: make_api_call(self, op, kwarg), + ) + def test_no_prompts(self): + """Test when no prompts exist.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import ( + bedrock_prompt_encrypted_with_cmk, + ) + + check = bedrock_prompt_encrypted_with_cmk() + result = check.execute() + + assert len(result) == 0 + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_with_cmk, + ) + def test_prompt_encrypted_with_cmk(self): + """Test when a prompt is encrypted with a customer-managed KMS key.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import ( + bedrock_prompt_encrypted_with_cmk, + ) + + check = bedrock_prompt_encrypted_with_cmk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Bedrock Prompt {PROMPT_NAME} is encrypted with a customer-managed KMS key." + ) + assert result[0].resource_id == PROMPT_ID + assert result[0].resource_arn == PROMPT_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_without_cmk, + ) + def test_prompt_not_encrypted_with_cmk(self): + """Test when a prompt is not encrypted with a customer-managed KMS key.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import ( + bedrock_prompt_encrypted_with_cmk, + ) + + check = bedrock_prompt_encrypted_with_cmk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Bedrock Prompt {PROMPT_NAME} is not encrypted with a customer-managed KMS key." + ) + assert result[0].resource_id == PROMPT_ID + assert result[0].resource_arn == PROMPT_ARN + assert result[0].region == AWS_REGION_US_EAST_1 diff --git a/tests/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists_test.py b/tests/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists_test.py new file mode 100644 index 0000000000..c29b67c064 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists_test.py @@ -0,0 +1,280 @@ +from unittest import mock + +import botocore +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +PROMPT_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id" +) + + +def mock_make_api_call_list_prompts_access_denied(self, operation_name, kwarg): + """Mock API call where ListPrompts fails with AccessDeniedException.""" + if operation_name == "ListPrompts": + raise ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: bedrock:ListPrompts", + } + }, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_with_prompts(self, operation_name, kwarg): + """Mock API call that returns prompts.""" + if operation_name == "ListPrompts": + return { + "promptSummaries": [ + { + "id": "test-prompt-id", + "name": "test-prompt", + "arn": PROMPT_ARN, + } + ] + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_with_multiple_prompts(self, operation_name, kwarg): + """Mock API call that returns multiple prompts.""" + if operation_name == "ListPrompts": + return { + "promptSummaries": [ + { + "id": "test-prompt-id-1", + "name": "test-prompt-1", + "arn": f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id-1", + }, + { + "id": "test-prompt-id-2", + "name": "test-prompt-2", + "arn": f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id-2", + }, + { + "id": "test-prompt-id-3", + "name": "test-prompt-3", + "arn": f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id-3", + }, + ] + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_no_prompts(self, operation_name, kwarg): + """Mock API call that returns no prompts.""" + if operation_name == "ListPrompts": + return {"promptSummaries": []} + return make_api_call(self, operation_name, kwarg) + + +class Test_bedrock_prompt_management_exists: + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_prompts, + ) + @mock_aws + def test_no_prompts(self): + """Test FAIL when no prompts exist in the region.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import ( + bedrock_prompt_management_exists, + ) + + check = bedrock_prompt_management_exists() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No Bedrock Prompt Management prompts exist in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "prompt-management" + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].resource_arn + == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt-management" + ) + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_with_prompts, + ) + @mock_aws + def test_prompts_exist(self): + """Test PASS when prompts exist in the region.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import ( + bedrock_prompt_management_exists, + ) + + check = bedrock_prompt_management_exists() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Bedrock Prompt Management prompt test-prompt exists in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "test-prompt-id" + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_arn == PROMPT_ARN + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_with_multiple_prompts, + ) + @mock_aws + def test_multiple_prompts_exist(self): + """Test PASS with one finding per prompt when multiple prompts exist.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import ( + bedrock_prompt_management_exists, + ) + + check = bedrock_prompt_management_exists() + result = check.execute() + + assert len(result) == 3 + for index, finding in enumerate(result, start=1): + expected_name = f"test-prompt-{index}" + expected_id = f"test-prompt-id-{index}" + assert finding.status == "PASS" + assert ( + finding.status_extended + == f"Bedrock Prompt Management prompt {expected_name} exists in region {AWS_REGION_US_EAST_1}." + ) + assert finding.resource_id == expected_id + assert finding.region == AWS_REGION_US_EAST_1 + assert ( + finding.resource_arn + == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/{expected_id}" + ) + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_prompts, + ) + @mock_aws + def test_no_prompts_multiple_regions(self): + """Test FAIL in multiple regions when no prompts exist.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, 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.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import ( + bedrock_prompt_management_exists, + ) + + check = bedrock_prompt_management_exists() + result = check.execute() + + assert len(result) == 2 + for finding in result: + assert finding.status == "FAIL" + assert ( + finding.status_extended + == f"No Bedrock Prompt Management prompts exist in region {finding.region}." + ) + assert finding.resource_id == "prompt-management" + assert ( + finding.resource_arn + == f"arn:aws:bedrock:{finding.region}:{AWS_ACCOUNT_NUMBER}:prompt-management" + ) + regions = {finding.region for finding in result} + assert regions == {AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1} + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_list_prompts_access_denied, + ) + @mock_aws + def test_list_prompts_client_error_skips_region(self): + """Test that regions where ListPrompts fails produce no findings.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import ( + bedrock_prompt_management_exists, + ) + + check = bedrock_prompt_management_exists() + result = check.execute() + + assert result == [] diff --git a/tests/providers/aws/services/bedrock/bedrock_service_test.py b/tests/providers/aws/services/bedrock/bedrock_service_test.py index ed39ac865b..f8a1fc8996 100644 --- a/tests/providers/aws/services/bedrock/bedrock_service_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_service_test.py @@ -341,3 +341,140 @@ class TestBedrockAgentPagination: # Verify paginator was used regional_client.get_paginator.assert_called_once_with("list_agents") paginator.paginate.assert_called_once() + + +class TestBedrockPromptPagination: + """Test suite for Bedrock Prompt pagination logic.""" + + def test_list_prompts_pagination(self): + """Test that list_prompts iterates through all pages.""" + # Mock the audit_info + audit_info = MagicMock() + audit_info.audited_partition = "aws" + audit_info.audited_account = "123456789012" + audit_info.audit_resources = None + + # Mock the regional client + regional_client = MagicMock() + regional_client.region = "us-east-1" + + # Mock paginator + paginator = MagicMock() + page1 = { + "promptSummaries": [ + { + "id": "prompt-1", + "name": "prompt-name-1", + "arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1", + } + ] + } + page2 = { + "promptSummaries": [ + { + "id": "prompt-2", + "name": "prompt-name-2", + "arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2", + } + ] + } + paginator.paginate.return_value = [page1, page2] + regional_client.get_paginator.return_value = paginator + + # Initialize service and inject mock client + bedrock_agent_service = BedrockAgent(audit_info) + bedrock_agent_service.regional_clients = {"us-east-1": regional_client} + bedrock_agent_service.prompts = {} # Clear init side effects + bedrock_agent_service.prompt_scanned_regions = set() + + # Run method + bedrock_agent_service._list_prompts(regional_client) + + # Assertions + assert len(bedrock_agent_service.prompts) == 2 + assert ( + "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1" + in bedrock_agent_service.prompts + ) + assert ( + "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2" + in bedrock_agent_service.prompts + ) + assert "us-east-1" in bedrock_agent_service.prompt_scanned_regions + + # Verify paginator was used + regional_client.get_paginator.assert_called_once_with("list_prompts") + paginator.paginate.assert_called_once() + + def test_list_prompts_filters_audit_resources(self): + """Prompt collection must honor audit_resources when resource ARNs are scoped.""" + audit_info = MagicMock() + audit_info.audited_partition = "aws" + audit_info.audited_account = "123456789012" + audit_info.audit_resources = [ + "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1" + ] + + regional_client = MagicMock() + regional_client.region = "us-east-1" + + paginator = MagicMock() + paginator.paginate.return_value = [ + { + "promptSummaries": [ + { + "id": "prompt-1", + "name": "prompt-name-1", + "arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1", + }, + { + "id": "prompt-2", + "name": "prompt-name-2", + "arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2", + }, + ] + } + ] + regional_client.get_paginator.return_value = paginator + + bedrock_agent_service = BedrockAgent(audit_info) + bedrock_agent_service.regional_clients = {"us-east-1": regional_client} + bedrock_agent_service.prompts = {} + bedrock_agent_service.prompt_scanned_regions = set() + + bedrock_agent_service._list_prompts(regional_client) + + assert len(bedrock_agent_service.prompts) == 1 + assert ( + "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1" + in bedrock_agent_service.prompts + ) + assert ( + "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2" + not in bedrock_agent_service.prompts + ) + assert "us-east-1" in bedrock_agent_service.prompt_scanned_regions + + def test_list_prompts_error_does_not_mark_region_scanned(self): + """If ListPrompts raises, the region must not be added to prompt_scanned_regions.""" + audit_info = MagicMock() + audit_info.audited_partition = "aws" + audit_info.audited_account = "123456789012" + audit_info.audit_resources = None + + regional_client = MagicMock() + regional_client.region = "us-east-1" + + paginator = MagicMock() + paginator.paginate.side_effect = Exception("ListPrompts failed") + regional_client.get_paginator.return_value = paginator + + bedrock_agent_service = BedrockAgent(audit_info) + bedrock_agent_service.regional_clients = {"us-east-1": regional_client} + bedrock_agent_service.prompts = {} + bedrock_agent_service.prompt_scanned_regions = set() + + bedrock_agent_service._list_prompts(regional_client) + + assert bedrock_agent_service.prompts == {} + assert bedrock_agent_service.prompt_scanned_regions == set() diff --git a/tests/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured_test.py b/tests/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured_test.py new file mode 100644 index 0000000000..a4850d8a39 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_vpc_endpoints_configured/bedrock_vpc_endpoints_configured_test.py @@ -0,0 +1,558 @@ +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.bedrock.bedrock_service import ( + Guardrail, + LoggingConfiguration, +) +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +BEDROCK_SERVICES = [ + "com.amazonaws.us-east-1.bedrock", + "com.amazonaws.us-east-1.bedrock-runtime", + "com.amazonaws.us-east-1.bedrock-agent", + "com.amazonaws.us-east-1.bedrock-agent-runtime", + "com.amazonaws.us-east-1.bedrock-mantle", +] + +MOCK_BEDROCK_CLIENT = mock.MagicMock( + logging_configurations={AWS_REGION_US_EAST_1: LoggingConfiguration(enabled=True)}, + guardrails={}, +) + +MOCK_BEDROCK_AGENT_CLIENT = mock.MagicMock(agents={}) + +MOCK_BEDROCK_CLIENT_NO_ACTIVITY = mock.MagicMock( + logging_configurations={AWS_REGION_US_EAST_1: LoggingConfiguration(enabled=False)}, + guardrails={}, +) + +MOCK_BEDROCK_AGENT_CLIENT_NO_ACTIVITY = mock.MagicMock(agents={}) + +CHECK_MODULE = "prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured" + + +class Test_bedrock_vpc_endpoints_configured: + @mock_aws + def test_no_resources(self): + """Test with no in-use VPCs and scan_unused_services disabled - should return no results.""" + client("ec2", region_name=AWS_REGION_US_EAST_1) + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_no_bedrock_activity(self): + """Test VPCs in region with no Bedrock activity - should return no results.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + ec2_client.describe_vpcs() + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT_NO_ACTIVITY, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT_NO_ACTIVITY, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_vpc_no_endpoints(self): + """Test VPC with no VPC endpoints at all - should FAIL with all services missing.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2_client.describe_vpcs()["Vpcs"][0]["VpcId"] + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].resource_id == vpc_id + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].status == "FAIL" + assert "Bedrock control plane" in result[0].status_extended + assert "Bedrock runtime" in result[0].status_extended + assert "Bedrock agent control plane" in result[0].status_extended + assert "Bedrock agent runtime" in result[0].status_extended + assert "Bedrock Mantle" in result[0].status_extended + + @mock_aws + def test_vpc_only_bedrock_runtime_endpoint(self): + """Test VPC with only Bedrock runtime endpoint - should FAIL with three services missing.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + + vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"] + + route_table = ec2_client.create_route_table(VpcId=vpc["VpcId"])["RouteTable"] + ec2_client.create_vpc_endpoint( + VpcId=vpc["VpcId"], + ServiceName="com.amazonaws.us-east-1.bedrock-runtime", + RouteTableIds=[route_table["RouteTableId"]], + VpcEndpointType="Interface", + ) + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + # Default VPC + created VPC + assert len(result) == 2 + finding = next(f for f in result if f.resource_id == vpc["VpcId"]) + assert finding.region == AWS_REGION_US_EAST_1 + assert finding.status == "FAIL" + assert "Bedrock runtime" not in finding.status_extended + assert "Bedrock control plane" in finding.status_extended + assert "Bedrock agent control plane" in finding.status_extended + assert "Bedrock agent runtime" in finding.status_extended + assert "Bedrock Mantle" in finding.status_extended + assert ( + finding.resource_arn + == f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:123456789012:vpc/{vpc['VpcId']}" + ) + + @mock_aws + def test_vpc_only_bedrock_agent_runtime_endpoint(self): + """Test VPC with only Bedrock agent runtime endpoint - should FAIL with three services missing.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + + vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"] + + route_table = ec2_client.create_route_table(VpcId=vpc["VpcId"])["RouteTable"] + ec2_client.create_vpc_endpoint( + VpcId=vpc["VpcId"], + ServiceName="com.amazonaws.us-east-1.bedrock-agent-runtime", + RouteTableIds=[route_table["RouteTableId"]], + VpcEndpointType="Interface", + ) + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 2 + finding = next(f for f in result if f.resource_id == vpc["VpcId"]) + assert finding.region == AWS_REGION_US_EAST_1 + assert finding.status == "FAIL" + assert "Bedrock agent runtime" not in finding.status_extended + assert "Bedrock control plane" in finding.status_extended + assert "Bedrock runtime" in finding.status_extended + assert "Bedrock agent control plane" in finding.status_extended + assert "Bedrock Mantle" in finding.status_extended + assert ( + finding.resource_arn + == f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:123456789012:vpc/{vpc['VpcId']}" + ) + + @mock_aws + def test_vpc_all_bedrock_endpoints(self): + """Test VPC with all four Bedrock endpoints - should PASS.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + + vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"] + vpc_default_id = ec2_client.describe_vpcs()["Vpcs"][0]["VpcId"] + + route_table = ec2_client.create_route_table(VpcId=vpc["VpcId"])["RouteTable"] + for svc in BEDROCK_SERVICES: + ec2_client.create_vpc_endpoint( + VpcId=vpc["VpcId"], + ServiceName=svc, + RouteTableIds=[route_table["RouteTableId"]], + VpcEndpointType="Interface", + ) + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 2 + finding = next(f for f in result if f.resource_id == vpc["VpcId"]) + assert finding.region == AWS_REGION_US_EAST_1 + assert finding.status == "PASS" + assert ( + finding.status_extended + == f"VPC {vpc['VpcId']} has VPC endpoints for all Bedrock services." + ) + assert ( + finding.resource_arn + == f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:123456789012:vpc/{vpc['VpcId']}" + ) + + # Default VPC should FAIL + default_finding = next( + f for f in result if f.resource_id == vpc_default_id + ) + assert default_finding.status == "FAIL" + + @mock_aws + def test_vpc_only_runtime_endpoints(self): + """Test VPC with only both runtime endpoints but missing control plane endpoints - should FAIL.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + + vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"] + + route_table = ec2_client.create_route_table(VpcId=vpc["VpcId"])["RouteTable"] + ec2_client.create_vpc_endpoint( + VpcId=vpc["VpcId"], + ServiceName="com.amazonaws.us-east-1.bedrock-runtime", + RouteTableIds=[route_table["RouteTableId"]], + VpcEndpointType="Interface", + ) + ec2_client.create_vpc_endpoint( + VpcId=vpc["VpcId"], + ServiceName="com.amazonaws.us-east-1.bedrock-agent-runtime", + RouteTableIds=[route_table["RouteTableId"]], + VpcEndpointType="Interface", + ) + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 2 + finding = next(f for f in result if f.resource_id == vpc["VpcId"]) + assert finding.status == "FAIL" + assert "Bedrock control plane" in finding.status_extended + assert "Bedrock agent control plane" in finding.status_extended + assert "Bedrock Mantle" in finding.status_extended + assert "Bedrock runtime" not in finding.status_extended + assert "Bedrock agent runtime" not in finding.status_extended + + @mock_aws + def test_vpc_unrelated_endpoint_only(self): + """Test VPC with only an unrelated endpoint (S3) - should FAIL with all services missing.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + + vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"] + + route_table = ec2_client.create_route_table(VpcId=vpc["VpcId"])["RouteTable"] + ec2_client.create_vpc_endpoint( + VpcId=vpc["VpcId"], + ServiceName="com.amazonaws.us-east-1.s3", + RouteTableIds=[route_table["RouteTableId"]], + VpcEndpointType="Gateway", + ) + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 2 + finding = next(f for f in result if f.resource_id == vpc["VpcId"]) + assert finding.region == AWS_REGION_US_EAST_1 + assert finding.status == "FAIL" + assert "Bedrock control plane" in finding.status_extended + assert "Bedrock runtime" in finding.status_extended + assert "Bedrock agent control plane" in finding.status_extended + assert "Bedrock agent runtime" in finding.status_extended + assert "Bedrock Mantle" in finding.status_extended + + @mock_aws + def test_vpc_only_bedrock_mantle_endpoint(self): + """Test VPC with only Bedrock Mantle endpoint - should FAIL with four services missing.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + + vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"] + + route_table = ec2_client.create_route_table(VpcId=vpc["VpcId"])["RouteTable"] + ec2_client.create_vpc_endpoint( + VpcId=vpc["VpcId"], + ServiceName="com.amazonaws.us-east-1.bedrock-mantle", + RouteTableIds=[route_table["RouteTableId"]], + VpcEndpointType="Interface", + ) + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=MOCK_BEDROCK_CLIENT, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + assert len(result) == 2 + finding = next(f for f in result if f.resource_id == vpc["VpcId"]) + assert finding.status == "FAIL" + assert "Bedrock Mantle" not in finding.status_extended + assert "Bedrock control plane" in finding.status_extended + assert "Bedrock runtime" in finding.status_extended + assert "Bedrock agent control plane" in finding.status_extended + assert "Bedrock agent runtime" in finding.status_extended + + @mock_aws + def test_bedrock_activity_via_guardrail(self): + """Test that Bedrock activity is detected via guardrails.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2_client.describe_vpcs()["Vpcs"][0]["VpcId"] + + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + bedrock_with_guardrail = mock.MagicMock( + logging_configurations={ + AWS_REGION_US_EAST_1: LoggingConfiguration(enabled=False) + }, + guardrails={ + "arn:aws:bedrock:us-east-1:123456789012:guardrail/test": Guardrail( + id="test", + name="test-guardrail", + arn="arn:aws:bedrock:us-east-1:123456789012:guardrail/test", + region=AWS_REGION_US_EAST_1, + ) + }, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with ( + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_client", + new=bedrock_with_guardrail, + ), + mock.patch( + f"{CHECK_MODULE}.bedrock_agent_client", + new=MOCK_BEDROCK_AGENT_CLIENT_NO_ACTIVITY, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_vpc_endpoints_configured.bedrock_vpc_endpoints_configured import ( + bedrock_vpc_endpoints_configured, + ) + + check = bedrock_vpc_endpoints_configured() + result = check.execute() + + # Region has Bedrock activity via guardrail, so VPC should be checked + assert len(result) == 1 + assert result[0].resource_id == vpc_id + assert result[0].status == "FAIL" diff --git a/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py b/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py index 6b8d0d9b15..ad9d277788 100644 --- a/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py +++ b/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py @@ -38,7 +38,10 @@ class Test_cloudformation_stack_outputs_find_secrets: Stack( arn="arn:aws:cloudformation:eu-west-1:123456789012:stack/Test-Stack/796c8d26-b390-41d7-a23c-0702c4e78b60", name=stack_name, - outputs=["DB_PASSWORD:foobar123", "ENV:DEV"], + outputs=[ + "DB_KEY:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "ENV:DEV", + ], region=AWS_REGION, ) ] @@ -66,7 +69,7 @@ class Test_cloudformation_stack_outputs_find_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in CloudFormation Stack {stack_name} Outputs -> Secret Keyword in Output 1." + == f"Potential secret found in CloudFormation Stack {stack_name} Outputs -> JSON Web Token (base64url-encoded) in Output 1." ) assert result[0].resource_id == "Test-Stack" assert ( diff --git a/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py b/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py new file mode 100644 index 0000000000..5170d5538d --- /dev/null +++ b/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py @@ -0,0 +1,158 @@ +import sys +from unittest import mock + +from prowler.providers.aws.services.cloudfront.cloudfront_service import ( + Distribution, + Origin, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +DISTRIBUTION_ID = "E27LVI50CSW06W" +DISTRIBUTION_ARN = ( + f"arn:aws:cloudfront::{AWS_ACCOUNT_NUMBER}:distribution/{DISTRIBUTION_ID}" +) +REGION = "us-east-1" +CHECK_MODULE = "prowler.providers.aws.services.cloudfront.cloudfront_distributions_pqc_tls_enabled.cloudfront_distributions_pqc_tls_enabled" +CLIENT_MODULE = "prowler.providers.aws.services.cloudfront.cloudfront_client" + + +def _clear_cloudfront_modules(): + sys.modules.pop(CHECK_MODULE, None) + sys.modules.pop(CLIENT_MODULE, None) + + +def _build_distribution( + *, + minimum_protocol_version: str, + default_certificate: bool = False, +): + return Distribution( + arn=DISTRIBUTION_ARN, + id=DISTRIBUTION_ID, + region=REGION, + origins=[ + Origin( + id="o1", + domain_name="origin.example.com", + origin_protocol_policy="https-only", + origin_ssl_protocols=["TLSv1.2"], + ) + ], + origin_failover=False, + minimum_protocol_version=minimum_protocol_version, + default_certificate=default_certificate, + ) + + +def _build_client(distributions: dict, audit_config: dict | None = None): + cloudfront_client = mock.MagicMock() + cloudfront_client.distributions = distributions + cloudfront_client.audit_config = audit_config or {} + return cloudfront_client + + +def _execute_check(cloudfront_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + _clear_cloudfront_modules() + + try: + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudfront.cloudfront_service.CloudFront", + return_value=cloudfront_client, + ), + ): + from prowler.providers.aws.services.cloudfront.cloudfront_distributions_pqc_tls_enabled.cloudfront_distributions_pqc_tls_enabled import ( + cloudfront_distributions_pqc_tls_enabled, + ) + + check = cloudfront_distributions_pqc_tls_enabled() + return check.execute() + finally: + _clear_cloudfront_modules() + + +class Test_cloudfront_distributions_pqc_tls_enabled: + def test_no_distributions(self): + cloudfront_client = _build_client({}) + + result = _execute_check(cloudfront_client) + + assert len(result) == 0 + + def test_pq_policy_tls13_2025(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.3_2025" + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TLSv1.3_2025" in result[0].status_extended + assert result[0].resource_id == DISTRIBUTION_ID + assert result[0].resource_arn == DISTRIBUTION_ARN + + def test_classical_tls12_2021(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.2_2021" + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLSv1.2_2021" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + + def test_default_cloudfront_certificate(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1", + default_certificate=True, + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "default CloudFront certificate" in result[0].status_extended + + def test_configurable_allowlist(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.2_2021" + ) + }, + audit_config={ + "cloudfront_pqc_min_protocol_versions": [ + "TLSv1.3_2025", + "TLSv1.2_2021", + ] + }, + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py index 4c0a9b349e..5dd6f712a7 100644 --- a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py +++ b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py @@ -64,6 +64,7 @@ def example_distribution_config(ref): "ViewerCertificate": { "SSLSupportMethod": "static-ip", "Certificate": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012", + "MinimumProtocolVersion": "TLSv1.3_2025", }, "Comment": "an optional comment that's not actually optional", "Enabled": False, @@ -234,6 +235,7 @@ class Test_CloudFront_Service: ] SSL_SUPPORT_METHOD = SSLSupportMethod.sni_only CERTIFICATE = "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012" + MINIMUM_PROTOCOL_VERSION = "TLSv1.3_2025" cloudfront = mock.MagicMock cloudfront.distributions = { @@ -249,6 +251,7 @@ class Test_CloudFront_Service: tags=TAGS, ssl_support_method=SSL_SUPPORT_METHOD, certificate=CERTIFICATE, + minimum_protocol_version=MINIMUM_PROTOCOL_VERSION, ) } @@ -288,6 +291,10 @@ class Test_CloudFront_Service: == DEFAULT_CACHE_CONFIG.field_level_encryption_id ) assert cloudfront.distributions[DISTRIBUTION_ID].tags == TAGS + assert ( + cloudfront.distributions[DISTRIBUTION_ID].minimum_protocol_version + == MINIMUM_PROTOCOL_VERSION + ) def test_get_log_delivery_sources_with_active_delivery(self): from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER diff --git a/tests/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled_test.py b/tests/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled_test.py new file mode 100644 index 0000000000..7b0cfa6a07 --- /dev/null +++ b/tests/providers/aws/services/cloudtrail/cloudtrail_bedrock_logging_enabled/cloudtrail_bedrock_logging_enabled_test.py @@ -0,0 +1,1020 @@ +from unittest import mock + +import pytest +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE_PATH = "prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled" + + +class Test_cloudtrail_bedrock_logging_enabled: + @mock_aws + def test_no_trails(self): + """Test when there are no CloudTrail trails configured.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert ( + result[0].resource_arn + == f"arn:aws:cloudtrail:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trail" + ) + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_trail_not_logging(self): + """Test when a trail exists but is not actively logging.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + EventSelectors=[ + { + "ReadWriteType": "All", + "IncludeManagementEvents": True, + "DataResources": [ + {"Type": "AWS::S3::Object", "Values": ["arn:aws:s3"]} + ], + } + ], + ) + # Trail is not started, so is_logging remains False + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + + @mock_aws + def test_trail_without_management_events(self): + """Test when a trail has data events but no management events enabled.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + EventSelectors=[ + { + "ReadWriteType": "All", + "IncludeManagementEvents": False, + "DataResources": [ + {"Type": "AWS::S3::Object", "Values": ["arn:aws:s3"]} + ], + } + ], + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + + @mock_aws + def test_trail_with_classic_management_events(self): + """Test PASS when a trail has classic management events enabled.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + EventSelectors=[ + { + "ReadWriteType": "All", + "IncludeManagementEvents": True, + "DataResources": [ + {"Type": "AWS::S3::Object", "Values": ["arn:aws:s3"]} + ], + } + ], + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Trail {trail_name} from home region {AWS_REGION_US_EAST_1} has management events enabled to log Amazon Bedrock control-plane API calls." + ) + assert result[0].resource_id == trail_name + assert result[0].resource_arn == trail["TrailARN"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_trail_with_classic_management_events_read_only(self): + """Test FAIL when a trail has management events but ReadWriteType is ReadOnly.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + EventSelectors=[ + { + "ReadWriteType": "ReadOnly", + "IncludeManagementEvents": True, + "DataResources": [ + {"Type": "AWS::S3::Object", "Values": ["arn:aws:s3"]} + ], + } + ], + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + + @mock_aws + def test_trail_with_advanced_management_events(self): + """Test PASS when a trail has unrestricted advanced management selectors.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + AdvancedEventSelectors=[ + { + "Name": "Management events selector", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Management"]}, + ], + }, + ], + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Trail {trail_name} from home region {AWS_REGION_US_EAST_1} has an advanced management event selector to log Amazon Bedrock control-plane API calls." + ) + assert result[0].resource_id == trail_name + assert result[0].resource_arn == trail["TrailARN"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + @pytest.mark.parametrize( + "event_source", + [ + pytest.param("bedrock.amazonaws.com", id="bedrock"), + pytest.param("bedrock-agent.amazonaws.com", id="bedrock-agent"), + pytest.param("bedrock-runtime.amazonaws.com", id="bedrock-runtime"), + pytest.param( + "bedrock-agent-runtime.amazonaws.com", + id="bedrock-agent-runtime", + ), + pytest.param( + "bedrock-data-automation.amazonaws.com", + id="bedrock-data-automation", + ), + pytest.param( + "bedrock-data-automation-runtime.amazonaws.com", + id="bedrock-data-automation-runtime", + ), + ], + ) + def test_trail_with_advanced_management_events_bedrock_event_sources( + self, event_source + ): + """Test PASS when advanced management events are scoped to Bedrock family sources.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + Event_Selector, + ) + + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ) as mock_cloudtrail_client, + ): + trail_arn = trail["TrailARN"] + mock_cloudtrail_client.trails[trail_arn].data_events = [ + Event_Selector( + is_advanced=True, + event_selector={ + "Name": "Bedrock management events selector", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Management"]}, + {"Field": "eventSource", "Equals": [event_source]}, + ], + }, + ) + ] + + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Trail {trail_name} from home region {AWS_REGION_US_EAST_1} has an advanced management event selector to log Amazon Bedrock control-plane API calls." + ) + assert result[0].resource_id == trail_name + assert result[0].resource_arn == trail["TrailARN"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_trail_with_advanced_bedrock_data_events(self): + """Test PASS when a trail has advanced event selectors for Bedrock resources.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + Event_Selector, + ) + + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ) as mock_cloudtrail_client, + ): + # Manually inject the Bedrock advanced event selector since moto + # does not support Bedrock resource types. + trail_arn = trail["TrailARN"] + mock_cloudtrail_client.trails[trail_arn].data_events = [ + Event_Selector( + is_advanced=True, + event_selector={ + "Name": "Bedrock data events", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Data"]}, + { + "Field": "resources.type", + "Equals": ["AWS::Bedrock::Model"], + }, + ], + }, + ) + ] + + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Trail {trail_name} from home region {AWS_REGION_US_EAST_1} has an advanced data event selector to log Amazon Bedrock API calls." + ) + assert result[0].resource_id == trail_name + assert result[0].resource_arn == trail["TrailARN"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_trail_with_advanced_bedrock_guardrail_events(self): + """Test PASS when a trail has advanced event selectors for Bedrock Guardrail resources.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + Event_Selector, + ) + + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ) as mock_cloudtrail_client, + ): + # Manually inject the Bedrock Guardrail advanced event selector + # since moto does not support Bedrock resource types. + trail_arn = trail["TrailARN"] + mock_cloudtrail_client.trails[trail_arn].data_events = [ + Event_Selector( + is_advanced=True, + event_selector={ + "Name": "Bedrock guardrail events", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Data"]}, + { + "Field": "resources.type", + "Equals": ["AWS::Bedrock::Guardrail"], + }, + ], + }, + ) + ] + + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Trail {trail_name} from home region {AWS_REGION_US_EAST_1} has an advanced data event selector to log Amazon Bedrock API calls." + ) + assert result[0].resource_id == trail_name + assert result[0].resource_arn == trail["TrailARN"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_trail_with_advanced_non_bedrock_data_events(self): + """Test FAIL when a trail has advanced event selectors for non-Bedrock resources.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + AdvancedEventSelectors=[ + { + "Name": "S3 data events", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Data"]}, + { + "Field": "resources.type", + "Equals": ["AWS::S3::Object"], + }, + ], + }, + ], + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + + @mock_aws + def test_trail_with_classic_management_events_write_only(self): + """Test PASS when a trail has management events with ReadWriteType WriteOnly.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + EventSelectors=[ + { + "ReadWriteType": "WriteOnly", + "IncludeManagementEvents": True, + "DataResources": [], + } + ], + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Trail {trail_name} from home region {AWS_REGION_US_EAST_1} has management events enabled to log Amazon Bedrock control-plane API calls." + ) + assert result[0].resource_id == trail_name + assert result[0].resource_arn == trail["TrailARN"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_trail_with_classic_management_events_default_read_write_type(self): + """Test PASS when a classic selector omits ReadWriteType and uses the AWS default.""" + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + cloudtrail_client_us.put_event_selectors( + TrailName=trail_name, + EventSelectors=[ + { + "IncludeManagementEvents": True, + "DataResources": [], + } + ], + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Trail {trail_name} from home region {AWS_REGION_US_EAST_1} has management events enabled to log Amazon Bedrock control-plane API calls." + ) + assert result[0].resource_id == trail_name + assert result[0].resource_arn == trail["TrailARN"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_trail_with_advanced_management_events_read_only(self): + """Test FAIL when advanced management event selector has readOnly=true restriction.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + Event_Selector, + ) + + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ) as mock_cloudtrail_client, + ): + trail_arn = trail["TrailARN"] + mock_cloudtrail_client.trails[trail_arn].data_events = [ + Event_Selector( + is_advanced=True, + event_selector={ + "Name": "Management events selector", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Management"]}, + {"Field": "readOnly", "Equals": ["true"]}, + ], + }, + ) + ] + + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + + @mock_aws + def test_trail_with_advanced_management_events_read_only_not_equals_false(self): + """Test FAIL when advanced management selector restricts events with NotEquals false.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + Event_Selector, + ) + + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ) as mock_cloudtrail_client, + ): + trail_arn = trail["TrailARN"] + mock_cloudtrail_client.trails[trail_arn].data_events = [ + Event_Selector( + is_advanced=True, + event_selector={ + "Name": "Management events selector", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Management"]}, + {"Field": "readOnly", "NotEquals": ["false"]}, + ], + }, + ) + ] + + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + + @mock_aws + def test_trail_with_advanced_management_events_other_service_event_source(self): + """Test FAIL when advanced management events are scoped to a non-Bedrock event source.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + Event_Selector, + ) + + cloudtrail_client_us = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + s3_client_us = client("s3", region_name=AWS_REGION_US_EAST_1) + trail_name = "trail_test" + bucket_name = "bucket_test" + s3_client_us.create_bucket(Bucket=bucket_name) + trail = cloudtrail_client_us.create_trail( + Name=trail_name, S3BucketName=bucket_name, IsMultiRegionTrail=False + ) + cloudtrail_client_us.start_logging(Name=trail_name) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ) as mock_cloudtrail_client, + ): + trail_arn = trail["TrailARN"] + mock_cloudtrail_client.trails[trail_arn].data_events = [ + Event_Selector( + is_advanced=True, + event_selector={ + "Name": "EC2 management events selector", + "FieldSelectors": [ + {"Field": "eventCategory", "Equals": ["Management"]}, + { + "Field": "eventSource", + "Equals": ["ec2.amazonaws.com"], + }, + ], + }, + ) + ] + + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudTrail trails are configured to log Amazon Bedrock API calls." + ) + + @mock_aws + def test_access_denied(self): + """Test when trails are None due to access denied.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ) as mock_cloudtrail_client, + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + mock_cloudtrail_client.trails = None + check = cloudtrail_bedrock_logging_enabled() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + @pytest.mark.parametrize( + ("selector", "expected"), + [ + pytest.param( + {"Equals": ["bedrock.amazonaws.com"]}, + True, + id="equals-match", + ), + pytest.param( + {"Equals": ["ec2.amazonaws.com"]}, + False, + id="equals-mismatch", + ), + pytest.param( + {"NotEquals": ["ec2.amazonaws.com"]}, + True, + id="not-equals-match", + ), + pytest.param( + {"NotEquals": ["bedrock.amazonaws.com"]}, + False, + id="not-equals-mismatch", + ), + pytest.param( + {"StartsWith": ["bedrock."]}, + True, + id="starts-with-match", + ), + pytest.param( + {"StartsWith": ["ec2."]}, + False, + id="starts-with-mismatch", + ), + pytest.param( + {"NotStartsWith": ["ec2."]}, + True, + id="not-starts-with-match", + ), + pytest.param( + {"NotStartsWith": ["bedrock."]}, + False, + id="not-starts-with-mismatch", + ), + pytest.param( + {"EndsWith": [".amazonaws.com"]}, + True, + id="ends-with-match", + ), + pytest.param( + {"EndsWith": [".amazonaws.org"]}, + False, + id="ends-with-mismatch", + ), + pytest.param( + {"NotEndsWith": [".amazonaws.org"]}, + True, + id="not-ends-with-match", + ), + pytest.param( + {"NotEndsWith": [".amazonaws.com"]}, + False, + id="not-ends-with-mismatch", + ), + pytest.param({}, True, id="no-conditions"), + ], + ) + def test_field_selector_matches_value(self, selector, expected): + """Test advanced field selector operators against the Bedrock event source.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudtrail.cloudtrail_bedrock_logging_enabled.cloudtrail_bedrock_logging_enabled import ( + cloudtrail_bedrock_logging_enabled, + ) + + assert ( + cloudtrail_bedrock_logging_enabled._field_selector_matches_value( + "bedrock.amazonaws.com", selector + ) + is expected + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py index 66c2099843..928ff2b47c 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py @@ -674,3 +674,185 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ReplaceNetworkAclAssociation) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = CreateNetworkAcl) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py index afe0f7d3ce..50c8663437 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py @@ -616,3 +616,95 @@ class Test_cloudwatch_changes_to_network_gateways_alarm_configured: ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_tags == [{}] + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = CreateCustomerGateway) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured import ( + cloudwatch_changes_to_network_gateways_alarm_configured, + ) + + check = cloudwatch_changes_to_network_gateways_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py index 7ec6e32c56..929793eb34 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_network_route_tables_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = DisassociateRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DeleteRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = ReplaceRoute) || ($.eventName = CreateRouteTable) || ($.eventName = CreateRoute) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DisassociateRouteTable) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py index 31f27030ff..57d33cb3fe 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_vpcs_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = EnableVpcClassicLink) || ($.eventName = DisableVpcClassicLink) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = ModifyVpcAttribute) || ($.eventName = DeleteVpc) || ($.eventName = CreateVpc) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py index e9eb00175b..afa082b35d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_no_secrets_in_logs/cloudwatch_log_group_no_secrets_in_logs_test.py @@ -132,7 +132,7 @@ class Test_cloudwatch_log_group_no_secrets_in_logs: logEvents=[ { "timestamp": timestamp, - "message": "password = password123", + "message": 'password = "Tr0ub4dor3xKq9vLmZ"', } ], ) @@ -174,7 +174,7 @@ class Test_cloudwatch_log_group_no_secrets_in_logs: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - Secret Keyword on line 1." + == f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - Generic Password on line 1." ) assert result[0].resource_id == "test" assert ( @@ -222,3 +222,323 @@ class Test_cloudwatch_log_group_no_secrets_in_logs: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_multiline_event_all_secrets_ignored_is_pass(self): + # Regression: a multiline event whose secrets are all dropped by the + # rescan (e.g. filtered by secrets_ignore_patterns) must NOT produce a + # FAIL with no actual secret evidence. + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + logs_client.create_log_group(logGroupName="test", tags={"test": "test"}) + logs_client.create_log_stream(logGroupName="test", logStreamName="test stream") + logs_client.put_log_events( + logGroupName="test", + logStreamName="test stream", + logEvents=[ + { + "timestamp": timestamp, + # Valid JSON so the rescan expands it to multiple lines. + "message": '{"api_key": "AKIAIOSFODNN7EXAMPLE", "note": "x"}', + } + ], + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch", + side_effect=[ + # Phase 1: stream flagged on its single (multiline) event. + { + ("test", "test stream"): [ + { + "type": "AWS Access Key", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": False, + } + ] + }, + # Phase 3: rescan drops everything (all secrets ignored). + {}, + ], + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == "No secrets found in test log group." + + @mock_aws + def test_cloudwatch_scan_failure_reports_manual(self): + # A scanner failure on the stream scan must surface as MANUAL, not PASS. + from prowler.lib.utils.utils import SecretsScanError + + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + logs_client.create_log_group(logGroupName="test", tags={"test": "test"}) + logs_client.create_log_stream(logGroupName="test", logStreamName="test stream") + logs_client.put_log_events( + logGroupName="test", + logStreamName="test stream", + logEvents=[{"timestamp": timestamp, "message": "some log line"}], + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended + + @mock_aws + def test_two_multiline_events_same_timestamp_do_not_collide(self): + # Regression: a CloudWatch stream can hold several events sharing one + # millisecond timestamp. The multiline rescan must be keyed per event + # (not only per timestamp), otherwise the later event's payload + # overwrites the earlier one and secret evidence is lost. + log_group_arn = ( + f"arn:aws:logs:{AWS_REGION_US_EAST_1}:123456789012:log-group:test:*" + ) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + logs_client.create_log_group(logGroupName="test", tags={"test": "test"}) + logs_client.create_log_stream(logGroupName="test", logStreamName="test stream") + # Two distinct multiline (valid JSON) events at the same timestamp. + logs_client.put_log_events( + logGroupName="test", + logStreamName="test stream", + logEvents=[ + { + "timestamp": timestamp, + "message": '{"api_key": "AKIAIOSFODNN7EXAMPLE", "note": "a"}', + }, + { + "timestamp": timestamp, + "message": '{"secret": "AKIAI44QH8DHBEXAMPLE", "note": "b"}', + }, + ], + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.detect_secrets_scan_batch", + side_effect=[ + # Phase 1: both events flagged (one secret on each line). + { + (log_group_arn, "test stream"): [ + { + "type": "AWS Access Key", + "line_number": 1, + "filename": "data", + "hashed_secret": "a", + "is_verified": False, + }, + { + "type": "AWS Access Key", + "line_number": 2, + "filename": "data", + "hashed_secret": "b", + "is_verified": False, + }, + ] + }, + # Phase 3: each event is rescanned under its own key. If the + # keys collided, only one of these would survive. + { + ( + log_group_arn, + "test stream", + dttimestamp, + 0, + ): [ + { + "type": "AWS Access Key", + "line_number": 2, + "filename": "data", + "hashed_secret": "a", + "is_verified": False, + } + ], + ( + log_group_arn, + "test stream", + dttimestamp, + 1, + ): [ + { + "type": "AWS Access Key", + "line_number": 2, + "filename": "data", + "hashed_secret": "b", + "is_verified": False, + } + ], + }, + ], + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + # Both events' secrets must be reported, not just the last one. + assert ( + result[0].status_extended + == f"Potential secrets found in log group test in log stream test stream at {dttimestamp} - AWS Access Key on line 2." + ) + + @mock_aws + def test_same_group_and_stream_names_in_two_regions_do_not_collide(self): + # Regression: log group and stream names are not unique across regions, + # so the per-stream key must be region-aware (ARN-based). Otherwise the + # secret found in one region would be reused for the same-named group in + # another region, producing a false FAIL. + group_name = "shared-name" + stream_name = "shared stream" + + us_client = client("logs", region_name=AWS_REGION_US_EAST_1) + us_client.create_log_group(logGroupName=group_name) + us_client.create_log_stream(logGroupName=group_name, logStreamName=stream_name) + us_client.put_log_events( + logGroupName=group_name, + logStreamName=stream_name, + logEvents=[ + { + "timestamp": timestamp, + "message": 'password = "Tr0ub4dor3xKq9vLmZ"', + } + ], + ) + + eu_client = client("logs", region_name=AWS_REGION_EU_WEST_1) + eu_client.create_log_group(logGroupName=group_name) + eu_client.create_log_stream(logGroupName=group_name, logStreamName=stream_name) + eu_client.put_log_events( + logGroupName=group_name, + logStreamName=stream_name, + logEvents=[{"timestamp": timestamp, "message": "just a normal log line"}], + ) + + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs.logs_client", + new=Logs(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_no_secrets_in_logs.cloudwatch_log_group_no_secrets_in_logs import ( + cloudwatch_log_group_no_secrets_in_logs, + ) + + check = cloudwatch_log_group_no_secrets_in_logs() + result = check.execute() + + assert len(result) == 2 + by_region = {report.region: report for report in result} + # Only the region with the real secret must FAIL. + assert by_region[AWS_REGION_US_EAST_1].status == "FAIL" + assert by_region[AWS_REGION_EU_WEST_1].status == "PASS" diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py index 30982553b2..62ca14d4ba 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py @@ -665,3 +665,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_c result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = config.amazonaws.com) && (($.eventName = PutConfigurationRecorder) || ($.eventName = PutDeliveryChannel) || ($.eventName = DeleteDeliveryChannel) || ($.eventName = StopConfigurationRecorder)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py index fcb98fda3e..3b555bf05b 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_c == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = StopLogging) || ($.eventName = StartLogging) || ($.eventName = DeleteTrail) || ($.eventName = UpdateTrail) || ($.eventName = CreateTrail) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py index 66b9d8116b..201be76f6d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_authentication_failures: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.errorMessage = Failed authentication) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures import ( + cloudwatch_log_metric_filter_authentication_failures, + ) + + check = cloudwatch_log_metric_filter_authentication_failures() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py index 3aaf475cbe..534c50c906 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_aws_organizations_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = organizations.amazonaws.com) && ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = MoveAccount) || ($.eventName = DisablePolicyType) || ($.eventName = DetachPolicy) || ($.eventName = LeaveOrganization) || ($.eventName = InviteAccountToOrganization) || ($.eventName = EnablePolicyType) || ($.eventName = EnableAllFeatures) || ($.eventName = DeletePolicy) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeleteOrganization) || ($.eventName = DeclineHandshake) || ($.eventName = CreatePolicy) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreateOrganization) || ($.eventName = CreateAccount) || ($.eventName = CancelHandshake) || ($.eventName = AttachPolicy) || ($.eventName = AcceptHandshake) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes import ( + cloudwatch_log_metric_filter_aws_organizations_changes, + ) + + check = cloudwatch_log_metric_filter_aws_organizations_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py index 7eb1cae331..f13263cf9f 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = kms.amazonaws.com) && (($.eventName = ScheduleKeyDeletion) || ($.eventName = DisableKey)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk import ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk, + ) + + check = ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py index 7d2d631f11..aa88f5164d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_for_s3_bucket_policy_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = s3.amazonaws.com) && (($.eventName = DeleteBucketReplication) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketPolicy) || ($.eventName = PutBucketReplication) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketAcl)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes import ( + cloudwatch_log_metric_filter_for_s3_bucket_policy_changes, + ) + + check = cloudwatch_log_metric_filter_for_s3_bucket_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py index 06e36688d6..fd3e86d313 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachGroupPolicy) || ($.eventName = AttachGroupPolicy) || ($.eventName = DetachUserPolicy) || ($.eventName = AttachUserPolicy) || ($.eventName = DetachRolePolicy) || ($.eventName = AttachRolePolicy) || ($.eventName = DeletePolicyVersion) || ($.eventName = CreatePolicyVersion) || ($.eventName = DeletePolicy) || ($.eventName = CreatePolicy) || ($.eventName = PutUserPolicy) || ($.eventName = PutRolePolicy) || ($.eventName = PutGroupPolicy) || ($.eventName = DeleteUserPolicy) || ($.eventName = DeleteRolePolicy) || ($.eventName = DeleteGroupPolicy) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes import ( + cloudwatch_log_metric_filter_policy_changes, + ) + + check = cloudwatch_log_metric_filter_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py index ede85e8e6e..35dee7b516 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py @@ -599,3 +599,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DeleteSecurityGroup) || ($.eventName = CreateSecurityGroup) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = AuthorizeSecurityGroupIngress) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes import ( + cloudwatch_log_metric_filter_security_group_changes, + ) + + check = cloudwatch_log_metric_filter_security_group_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py index df67472cdd..6944860d75 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_sign_in_without_mfa: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.additionalEventData.MFAUsed != Yes) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa import ( + cloudwatch_log_metric_filter_sign_in_without_mfa, + ) + + check = cloudwatch_log_metric_filter_sign_in_without_mfa() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py index 8ae7ed980f..33d4bde7d7 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py @@ -1,3 +1,4 @@ +import pytest from boto3 import client from moto import mock_aws @@ -5,6 +6,9 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( CloudWatch, Logs, ) +from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1, @@ -216,3 +220,13 @@ class Test_CloudWatch_Service: assert logs.log_groups[arn].kms_id == "test_kms_id" assert logs.log_groups[arn].region == AWS_REGION_US_EAST_1 assert logs.log_groups[arn].tags == [{}] + + +class Test_build_metric_filter_pattern: + @pytest.mark.parametrize("bad_operator", ["==", "~=", "<", "<>", ">=", ""]) + def test_rejects_unsupported_operator(self, bad_operator): + with pytest.raises(ValueError, match="unsupported operator"): + build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", bad_operator, "Failed authentication")], + ) diff --git a/tests/providers/aws/services/codeartifact/codeartifact_service_test.py b/tests/providers/aws/services/codeartifact/codeartifact_service_test.py index d1b3c6f9e6..99325dd2ea 100644 --- a/tests/providers/aws/services/codeartifact/codeartifact_service_test.py +++ b/tests/providers/aws/services/codeartifact/codeartifact_service_test.py @@ -54,6 +54,9 @@ def mock_make_api_call(self, operation_name, kwarg): } if operation_name == "ListPackageVersions": + assert ( + kwarg.get("maxResults") == 1 + ), "list_package_versions must pass maxResults=1 to avoid fetching all versions" return { "defaultDisplayVersion": "latest", "format": "pypi", @@ -204,3 +207,102 @@ class Test_CodeArtifact_Service: .latest_version.origin.origin_type == OriginInformationValues.INTERNAL ) + + +def mock_make_api_call_no_namespace(self, operation_name, kwarg): + """Mock for packages without a namespace to exercise the else branch""" + if operation_name == "ListRepositories": + return { + "repositories": [ + { + "name": "test-repository", + "administratorAccount": AWS_ACCOUNT_NUMBER, + "domainName": "test-domain", + "domainOwner": AWS_ACCOUNT_NUMBER, + "arn": TEST_REPOSITORY_ARN, + "description": "test description", + }, + ] + } + if operation_name == "ListPackages": + return { + "packages": [ + { + "format": "pypi", + "package": "test-package-no-ns", + "originConfiguration": { + "restrictions": { + "publish": "ALLOW", + "upstream": "BLOCK", + } + }, + }, + ], + } + + if operation_name == "ListPackageVersions": + assert ( + kwarg.get("maxResults") == 1 + ), "list_package_versions must pass maxResults=1 to avoid fetching all versions" + assert ( + "namespace" not in kwarg + ), "namespace should not be passed when package has no namespace" + return { + "defaultDisplayVersion": "1.0.0", + "format": "pypi", + "package": "test-package-no-ns", + "versions": [ + { + "version": "1.0.0", + "revision": "abc123", + "status": "Published", + "origin": { + "domainEntryPoint": { + "repositoryName": "test-repository", + "externalConnectionName": "", + }, + "originType": "EXTERNAL", + }, + }, + ], + } + + if operation_name == "ListTagsForResource": + return {"tags": []} + + return make_api_call(self, operation_name, kwarg) + + +@patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_namespace, +) +@patch( + "prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients", + new=mock_generate_regional_clients, +) +class Test_CodeArtifact_Service_No_Namespace: + def test_list_packages_no_namespace(self): + codeartifact = CodeArtifact( + set_mocked_aws_provider([AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]) + ) + + assert len(codeartifact.repositories[TEST_REPOSITORY_ARN].packages) == 1 + + package = codeartifact.repositories[TEST_REPOSITORY_ARN].packages[0] + assert package.name == "test-package-no-ns" + assert package.namespace is None + assert package.format == "pypi" + assert ( + package.origin_configuration.restrictions.publish == RestrictionValues.ALLOW + ) + assert ( + package.origin_configuration.restrictions.upstream + == RestrictionValues.BLOCK + ) + assert package.latest_version.version == "1.0.0" + assert package.latest_version.status == LatestPackageVersionStatus.Published + assert ( + package.latest_version.origin.origin_type + == OriginInformationValues.EXTERNAL + ) diff --git a/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py b/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py index c08d1c8bb3..8060f7db59 100644 --- a/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py +++ b/tests/providers/aws/services/codebuild/codebuild_project_no_secrets_in_variables/codebuild_project_no_secrets_in_variables_test.py @@ -1,6 +1,10 @@ from unittest import mock -from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1 +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) class Test_codebuild_project_no_secrets_in_variables: @@ -202,7 +206,11 @@ class Test_codebuild_project_no_secrets_in_variables: environment_variables=[ { "name": "AWS_ACCESS_KEY_ID", - "value": "AKIAIOSFODNN7EXAMPLE", + # Realistic fake secret that Kingfisher detects. The classic + # "AKIAIOSFODNN7EXAMPLE" placeholder is suppressed by + # Kingfisher and its AWS Access Key rule is not enabled, so a + # detectable provider secret is used instead. + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", } ], @@ -231,15 +239,100 @@ class Test_codebuild_project_no_secrets_in_variables: assert len(result) == 1 assert result[0].status == "FAIL" + # The JWT paired with a "KEY" variable name yields both a + # JWT and a Generic API Key finding; order is non-deterministic. + assert result[0].status_extended.startswith( + "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:" + ) assert ( - result[0].status_extended - == "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_ACCESS_KEY_ID." + "JSON Web Token (base64url-encoded) in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended + ) + assert ( + "Generic API Key in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_id == "SensitiveProject" assert result[0].resource_arn == project_arn assert result[0].resource_tags == [] + def test_project_with_verified_secret(self): + from prowler.lib.check.models import Severity + + codebuild_client = mock.MagicMock() + + from prowler.providers.aws.services.codebuild.codebuild_service import Project + + project_arn = f"arn:aws:codebuild:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:project/SensitiveProject" + codebuild_client.projects = { + project_arn: Project( + name="SensitiveProject", + arn=project_arn, + region=AWS_REGION_US_EAST_1, + last_invoked_time=None, + buildspec=None, + environment_variables=[ + { + "name": "EXAMPLE_VAR", + "value": "ExampleValue", + "type": "PLAINTEXT", + } + ], + tags=[], + ) + } + + codebuild_client.audit_config = { + "excluded_sensitive_environment_variables": [], + "secrets_validate": True, + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider( + audit_config={"secrets_validate": True} + ), + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.codebuild_client", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.detect_secrets_scan_batch", + return_value={ + (0, 0): [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables import ( + codebuild_project_no_secrets_in_variables, + ) + + check = codebuild_project_no_secrets_in_variables() + result = check.execute() + + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert result[0].resource_id == "SensitiveProject" + def test_project_with_sensitive_plaintext_credentials_exluded(self): codebuild_client = mock.MagicMock @@ -373,12 +466,12 @@ class Test_codebuild_project_no_secrets_in_variables: environment_variables=[ { "name": "AWS_DUMB_ACCESS_KEY", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, { "name": "AWS_ACCESS_KEY_ID", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, ], @@ -409,10 +502,21 @@ class Test_codebuild_project_no_secrets_in_variables: assert len(result) == 1 assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_ACCESS_KEY_ID." + # AWS_DUMB_ACCESS_KEY is excluded, so only AWS_ACCESS_KEY_ID is + # scanned; its JWT + "KEY" name yields both a JWT and a + # Generic API Key finding with non-deterministic order. + assert result[0].status_extended.startswith( + "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:" ) + assert ( + "JSON Web Token (base64url-encoded) in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended + ) + assert ( + "Generic API Key in variable AWS_ACCESS_KEY_ID" + in result[0].status_extended + ) + assert "AWS_DUMB_ACCESS_KEY" not in result[0].status_extended assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_id == "SensitiveProject" assert result[0].resource_arn == project_arn @@ -434,12 +538,12 @@ class Test_codebuild_project_no_secrets_in_variables: environment_variables=[ { "name": "AWS_DUMB_ACCESS_KEY", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, { "name": "AWS_ACCESS_KEY_ID", - "value": "AKIAIOSFODNN7EXAMPLE", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "type": "PLAINTEXT", }, ], @@ -468,11 +572,80 @@ class Test_codebuild_project_no_secrets_in_variables: assert len(result) == 1 assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables: AWS Access Key in variable AWS_DUMB_ACCESS_KEY, AWS Access Key in variable AWS_ACCESS_KEY_ID." + # Both variables hold a JWT and have "KEY" in their name, so + # each yields a JWT and a Generic API Key finding; order is + # non-deterministic. + assert result[0].status_extended.startswith( + "CodeBuild project SensitiveProject has sensitive environment plaintext credentials in variables:" ) + for var_name in ("AWS_DUMB_ACCESS_KEY", "AWS_ACCESS_KEY_ID"): + assert ( + f"JSON Web Token (base64url-encoded) in variable {var_name}" + in result[0].status_extended + ) + assert ( + f"Generic API Key in variable {var_name}" + in result[0].status_extended + ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_id == "SensitiveProject" assert result[0].resource_arn == project_arn assert result[0].resource_tags == [] + + def test_scan_failure_reports_manual(self): + from prowler.lib.utils.utils import SecretsScanError + + codebuild_client = mock.MagicMock() + from prowler.providers.aws.services.codebuild.codebuild_service import Project + + project_arn = f"arn:aws:codebuild:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:project/SensitiveProject" + codebuild_client.projects = { + project_arn: Project( + name="SensitiveProject", + arn=project_arn, + region=AWS_REGION_US_EAST_1, + last_invoked_time=None, + buildspec=None, + environment_variables=[ + { + "name": "EXAMPLE_VAR", + "value": "ExampleValue", + "type": "PLAINTEXT", + } + ], + tags=[], + ) + } + codebuild_client.audit_config = { + "excluded_sensitive_environment_variables": [], + "secrets_ignore_patterns": [], + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.codebuild_client", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_no_secrets_in_variables.codebuild_project_no_secrets_in_variables import ( + codebuild_project_no_secrets_in_variables, + ) + + check = codebuild_project_no_secrets_in_variables() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended diff --git a/tests/providers/aws/services/codebuild/codebuild_service_test.py b/tests/providers/aws/services/codebuild/codebuild_service_test.py index 6bf81f58c2..5b9820cc47 100644 --- a/tests/providers/aws/services/codebuild/codebuild_service_test.py +++ b/tests/providers/aws/services/codebuild/codebuild_service_test.py @@ -45,11 +45,12 @@ def mock_make_api_call(self, operation_name, kwarg): elif operation_name == "ListBuildsForProject": return {"ids": [build_id]} elif operation_name == "BatchGetBuilds": - return {"builds": [{"endTime": last_invoked_time}]} + return {"builds": [{"id": build_id, "endTime": last_invoked_time}]} elif operation_name == "BatchGetProjects": return { "projects": [ { + "arn": project_arn, "source": { "type": source_type, "location": bitbucket_url, @@ -139,7 +140,7 @@ class Test_Codebuild_Service: ) @mock_aws def test_codebuild_service(self): - codebuild = Codebuild(set_mocked_aws_provider()) + codebuild = Codebuild(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert codebuild.session.__class__.__name__ == "Session" assert codebuild.service == "codebuild" @@ -230,3 +231,97 @@ class Test_Codebuild_Service: assert ( codebuild.report_groups[report_group_arn].tags[0]["value"] == project_name ) + + +# Module-level state and helpers used by the chunking/out-of-order test below. +# Kept at module level so the API-call mock is a plain function rather than a +# closure defined inside the test method. +TOTAL_PROJECTS = 150 +many_project_names = [f"project-{i}" for i in range(TOTAL_PROJECTS)] +many_project_arns = [ + f"arn:{AWS_COMMERCIAL_PARTITION}:codebuild:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:project/{name}" + for name in many_project_names +] +many_build_ids_for = {name: f"{name}:build-id" for name in many_project_names} +many_end_times_for = { + name: datetime.now() - timedelta(days=i) + for i, name in enumerate(many_project_names) +} +many_name_by_build_id = {v: k for k, v in many_build_ids_for.items()} +many_batch_call_sizes = {"BatchGetProjects": [], "BatchGetBuilds": []} + + +def mock_make_api_call_many_projects(self, operation_name, kwarg): + if operation_name == "ListProjects": + return {"projects": many_project_names} + if operation_name == "ListBuildsForProject": + return {"ids": [many_build_ids_for[kwarg["projectName"]]]} + if operation_name == "BatchGetBuilds": + ids = kwarg["ids"] + many_batch_call_sizes["BatchGetBuilds"].append(len(ids)) + # Reverse the response order to verify id->project mapping does not + # depend on response ordering. + builds = [ + {"id": bid, "endTime": many_end_times_for[many_name_by_build_id[bid]]} + for bid in reversed(ids) + ] + return {"builds": builds} + if operation_name == "BatchGetProjects": + names = kwarg["names"] + many_batch_call_sizes["BatchGetProjects"].append(len(names)) + # Reverse the response order to verify arn->project mapping does not + # depend on response ordering. + projects = [ + { + "arn": f"arn:{AWS_COMMERCIAL_PARTITION}:codebuild:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:project/{name}", + "source": {"type": "NO_SOURCE"}, + "logsConfig": {}, + "tags": [], + "projectVisibility": "PRIVATE", + } + for name in reversed(names) + ] + return {"projects": projects} + if operation_name == "ListReportGroups": + return {"reportGroups": []} + return make_api_call(self, operation_name, kwarg) + + +class Test_Codebuild_Service_Batching: + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_many_projects, + ) + @patch( + "prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients", + new=mock_generate_regional_clients, + ) + @mock_aws + def test_codebuild_batches_chunks_over_100_projects_and_maps_out_of_order_responses( + self, + ): + """Verify _batch_get_projects/_batch_get_builds chunk in groups of 100 + and correctly map out-of-order batch responses back to the right + project using `arn`/`id`. + """ + # Reset the per-test recorder (module-level state survives across runs). + many_batch_call_sizes["BatchGetProjects"].clear() + many_batch_call_sizes["BatchGetBuilds"].clear() + + codebuild = Codebuild(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) + + # Verify chunking: 150 items -> two batches of 100 and 50. + assert sorted(many_batch_call_sizes["BatchGetProjects"]) == [50, 100] + assert sorted(many_batch_call_sizes["BatchGetBuilds"]) == [50, 100] + + # Verify all projects were tracked. + assert len(codebuild.projects) == TOTAL_PROJECTS + + # Verify out-of-order responses were correctly mapped back to the + # right project by `arn` (projects) and `id` (builds). + for name, arn in zip(many_project_names, many_project_arns): + project = codebuild.projects[arn] + assert project.name == name + assert project.project_visibility == "PRIVATE" + assert project.last_build == Build(id=many_build_ids_for[name]) + assert project.last_invoked_time == many_end_times_for[name] diff --git a/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py b/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py index d0d1057307..bf27c29632 100644 --- a/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py +++ b/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py @@ -1,3 +1,4 @@ +import ssl import urllib.error import urllib.request from unittest import mock @@ -67,7 +68,7 @@ class Test_codepipeline_project_repo_private: "Connection": {"ProviderType": "GitHub"} } - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): raise urllib.error.HTTPError( url="", code=404, msg="", hdrs={}, fp=None ) @@ -145,7 +146,7 @@ class Test_codepipeline_project_repo_private: mock_response.getcode.return_value = 200 mock_response.geturl.return_value = f"https://github.com/{repo_id}" - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): if "github.com" in req.get_full_url(): return mock_response raise urllib.error.HTTPError( @@ -172,6 +173,145 @@ class Test_codepipeline_project_repo_private: assert result[0].resource_tags == [] assert result[0].region == AWS_REGION + def test_pipeline_repo_ssl_verification_failure(self): + """Test that a TLS certificate verification failure is treated as private. + When the probe cannot verify the server certificate (e.g. a MITM + presenting a forged certificate), the repository must not be reported + as public. + """ + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION]), + ): + codepipeline_client = mock.MagicMock + pipeline_name = "test-pipeline" + pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}" + connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection" + repo_id = "prowler-cloud/prowler-private" + + codepipeline_client.pipelines = { + pipeline_arn: Pipeline( + name=pipeline_name, + arn=pipeline_arn, + region=AWS_REGION, + source=Source( + type="CodeStarSourceConnection", + repository_id=repo_id, + configuration={ + "FullRepositoryId": repo_id, + "ConnectionArn": connection_arn, + }, + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline", + codepipeline_client, + ), + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client", + codepipeline_client, + ), + mock.patch("boto3.client") as mock_client, + mock.patch("urllib.request.urlopen") as mock_urlopen, + ): + mock_connection = mock_client.return_value + mock_connection.get_connection.return_value = { + "Connection": {"ProviderType": "GitHub"} + } + + def mock_urlopen_side_effect(req, **kwargs): + raise ssl.SSLError("certificate verify failed") + + mock_urlopen.side_effect = mock_urlopen_side_effect + + from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import ( + codepipeline_project_repo_private, + ) + + check = codepipeline_project_repo_private() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CodePipeline {pipeline_name} source repository {repo_id} is private." + ) + + def test_pipeline_repo_probe_verifies_certificate_and_sets_timeout(self): + """Test the probe never disables certificate verification and sets a timeout. + Regression test for the SSL verification bypass: the check must not use + an unverified SSL context, and every request must carry a timeout. + """ + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION]), + ): + codepipeline_client = mock.MagicMock + pipeline_name = "test-pipeline" + pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}" + connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection" + repo_id = "prowler-cloud/prowler-private" + + codepipeline_client.pipelines = { + pipeline_arn: Pipeline( + name=pipeline_name, + arn=pipeline_arn, + region=AWS_REGION, + source=Source( + type="CodeStarSourceConnection", + repository_id=repo_id, + configuration={ + "FullRepositoryId": repo_id, + "ConnectionArn": connection_arn, + }, + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline", + codepipeline_client, + ), + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client", + codepipeline_client, + ), + mock.patch("boto3.client") as mock_client, + mock.patch("urllib.request.urlopen") as mock_urlopen, + mock.patch("ssl._create_unverified_context") as mock_unverified, + ): + mock_connection = mock_client.return_value + mock_connection.get_connection.return_value = { + "Connection": {"ProviderType": "GitHub"} + } + + def mock_urlopen_side_effect(req, **kwargs): + raise urllib.error.HTTPError( + url="", code=404, msg="", hdrs={}, fp=None + ) + + mock_urlopen.side_effect = mock_urlopen_side_effect + + from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import ( + HTTP_TIMEOUT, + codepipeline_project_repo_private, + ) + + check = codepipeline_project_repo_private() + check.execute() + + mock_unverified.assert_not_called() + assert mock_urlopen.call_count > 0 + for call in mock_urlopen.call_args_list: + assert call.kwargs.get("timeout") == HTTP_TIMEOUT + def test_pipeline_public_gitlab_repo(self): """Test detection of public GitLab repository in CodePipeline. Tests that the check correctly identifies a public GitLab repository @@ -225,7 +365,7 @@ class Test_codepipeline_project_repo_private: mock_response.getcode.return_value = 200 mock_response.geturl.return_value = f"https://gitlab.com/{repo_id}" - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): if "gitlab.com" in req.get_full_url(): return mock_response raise urllib.error.HTTPError( diff --git a/tests/providers/aws/services/codepipeline/codepipeline_service_test.py b/tests/providers/aws/services/codepipeline/codepipeline_service_test.py index d2584e9b65..ed8e9bca68 100644 --- a/tests/providers/aws/services/codepipeline/codepipeline_service_test.py +++ b/tests/providers/aws/services/codepipeline/codepipeline_service_test.py @@ -76,7 +76,7 @@ class Test_CodePipeline_Service: ) @mock_aws def test_codepipeline_service(self): - codepipeline = CodePipeline(set_mocked_aws_provider()) + codepipeline = CodePipeline(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert codepipeline.session.__class__.__name__ == "Session" assert codepipeline.service == "codepipeline" diff --git a/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py new file mode 100644 index 0000000000..f2acf555d1 --- /dev/null +++ b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py @@ -0,0 +1,491 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + + +AGG_ARN_TEMPLATE = ( + "arn:aws:config:{region}:" + AWS_ACCOUNT_NUMBER + ":config-aggregator/{name}" +) + + +def _aggregator_payload( + name, region, *, org_aware=True, all_regions=True, aws_regions=None +): + payload = { + "ConfigurationAggregatorName": name, + "ConfigurationAggregatorArn": AGG_ARN_TEMPLATE.format(region=region, name=name), + } + if org_aware: + org_source = { + "RoleArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AWSConfigRoleForOrganizations", + "AllAwsRegions": all_regions, + } + if not all_regions and aws_regions: + org_source["AwsRegions"] = aws_regions + payload["OrganizationAggregationSource"] = org_source + return payload + + +def make_mock_no_aggregators_no_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return {"ConfigurationAggregators": []} + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregator_not_org_aware(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "legacy-agg", + AWS_REGION_EU_WEST_1, + org_aware=False, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_org_aggregator_not_all_regions_with_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "partial-org-agg", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=False, + aws_regions=[AWS_REGION_EU_WEST_1], + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_full_pass(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_access_denied_on_orgs(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: organizations:ListDelegatedAdministrators", + } + }, + operation_name, + ) + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_access_denied(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "denied", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_other_client_error(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InternalServerError", + "Message": "boom", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise RuntimeError("simulated transient error") + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_delegated_admins_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + return _mock + + +class Test_config_delegated_admin_and_org_aggregator_all_regions: + @mock_aws + def test_no_aggregators_no_admin(self): + """Test when no aggregators exist in any region and no delegated admin is set.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_no_aggregators_no_admin(), + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "no Organization Aggregator configured in any region" + in result[0].status_extended + ) + assert ( + "no delegated administrator registered for config.amazonaws.com" + in result[0].status_extended + ) + + @mock_aws + def test_aggregator_not_org_aware(self): + """Test when an aggregator exists but is not an organization aggregator.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregator_not_org_aware(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "is not an organization aggregator" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_org_aggregator_not_all_regions_with_admin(self): + """Test org aggregator that doesn't cover all AWS regions (delegated admin set).""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_org_aggregator_not_all_regions_with_admin(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "does not cover all AWS regions" in eu_west_1_result.status_extended + ) + + @mock_aws + def test_full_pass(self): + """Test PASS: delegated admin set and org aggregator covering all AWS regions.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_full_pass(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert ( + "is an organization aggregator covering all AWS regions" + in eu_west_1_result.status_extended + ) + assert "delegated admin configured" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == AGG_ARN_TEMPLATE.format( + region=AWS_REGION_EU_WEST_1, name="org-aggregator" + ) + + @mock_aws + def test_access_denied_on_organizations(self): + """Test that AccessDenied on Organizations is reported as unknown admin state.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_access_denied_on_orgs(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + # The check still runs; aggregator coverage is satisfied but the + # delegated-admin status is unknown, so it must FAIL. + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status for config.amazonaws.com could not be determined" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_aggregators_access_denied(self): + """AccessDenied on DescribeConfigurationAggregators is swallowed: no aggregators recorded for that region.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_access_denied(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_other_client_error(self): + """Non-access ClientError on DescribeConfigurationAggregators is logged at error level.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_other_client_error(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_unexpected_exception(self): + """Non-ClientError on DescribeConfigurationAggregators is caught by bare except.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_delegated_admins_unexpected_exception(self): + """Non-ClientError on ListDelegatedAdministrators must still set lookup_failed.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_delegated_admins_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.delegated_administrators_lookup_failed is True + assert service.delegated_administrators == [] diff --git a/tests/providers/aws/services/datasync/datasync_service_test.py b/tests/providers/aws/services/datasync/datasync_service_test.py index 13fc22dc88..10bd0d7ff0 100644 --- a/tests/providers/aws/services/datasync/datasync_service_test.py +++ b/tests/providers/aws/services/datasync/datasync_service_test.py @@ -106,27 +106,27 @@ def mock_generate_regional_clients(provider, service): class Test_DataSync_Service: # Test DataSync Service initialization def test_service(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) datasync = DataSync(aws_provider) assert datasync.service == "datasync" # Test DataSync clients creation def test_client(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) datasync = DataSync(aws_provider) for reg_client in datasync.regional_clients.values(): assert reg_client.__class__.__name__ == "DataSync" # Test DataSync session def test__get_session__(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) datasync = DataSync(aws_provider) assert datasync.session.__class__.__name__ == "Session" # Test listing DataSync tasks @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_list_tasks(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) datasync = DataSync(aws_provider) task_arn = "arn:aws:datasync:eu-west-1:123456789012:task/task-12345678901234567" @@ -142,7 +142,7 @@ class Test_DataSync_Service: # Test generic exception in list_tasks def test_list_tasks_generic_exception(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) # Mock the regional client's list_tasks method specifically mock_client = MagicMock() @@ -155,7 +155,7 @@ class Test_DataSync_Service: # Test describing DataSync tasks with various exceptions @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_describe_tasks_with_exceptions(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) datasync = DataSync(aws_provider) # Check all tasks were processed despite exceptions @@ -183,7 +183,7 @@ class Test_DataSync_Service: # Test listing task tags with various exceptions @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_list_task_tags_with_exceptions(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) datasync = DataSync(aws_provider) tasks_by_name = {task.name: task for task in datasync.tasks.values()} diff --git a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py index 0e8d303fe0..a2f2dc705a 100644 --- a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py +++ b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data_test.py @@ -100,7 +100,7 @@ class Test_ec2_instance_secrets_user_data: ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1, - UserData="DB_PASSWORD=foobar123", + UserData='DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"', )[0] from prowler.providers.aws.services.ec2.ec2_service import EC2 @@ -130,7 +130,7 @@ class Test_ec2_instance_secrets_user_data: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1." + == f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1." ) assert result[0].resource_id == instance.id assert ( @@ -233,7 +233,7 @@ class Test_ec2_instance_secrets_user_data: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == instance.id assert ( @@ -327,7 +327,7 @@ class Test_ec2_instance_secrets_user_data: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in EC2 instance {instance.id} User Data -> Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in EC2 instance {instance.id} User Data -> Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == instance.id assert ( @@ -337,6 +337,64 @@ class Test_ec2_instance_secrets_user_data: assert result[0].resource_tags is None assert result[0].region == AWS_REGION_US_EAST_1 + @mock_aws + def test_one_ec2_with_verified_secret(self): + from prowler.lib.check.models import Severity + + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + UserData='STRIPE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"', + )[0] + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + audit_config={"secrets_validate": True}, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data import ( + ec2_instance_secrets_user_data, + ) + + check = ec2_instance_secrets_user_data() + result = check.execute() + + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert result[0].resource_id == instance.id + @mock_aws def test_one_secrets_with_unicode_error(self): invalid_utf8_bytes = b"\xc0\xaf" @@ -368,4 +426,50 @@ class Test_ec2_instance_secrets_user_data: check = ec2_instance_secrets_user_data() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not decode User Data" in result[0].status_extended + + @mock_aws + def test_scan_failure_reports_manual(self): + from prowler.lib.utils.utils import SecretsScanError + + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + UserData='password = "Tr0ub4dor3xKq9vLmZ"', + )[0] + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.ec2.ec2_instance_secrets_user_data.ec2_instance_secrets_user_data import ( + ec2_instance_secrets_user_data, + ) + + check = ec2_instance_secrets_user_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended + assert result[0].resource_id == instance.id diff --git a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture index 2fb5138932..528ff40f8f 100644 --- a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture +++ b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture @@ -1,4 +1,4 @@ -DB_PASSWORD=foobar123 +DB_PASSWORD="Tr0ub4dor3xKq9vLmZ" DB_USER=foo -API_KEY=12345abcd -SERVICE_PASSWORD=bbaabb45 +STRIPE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U +SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4" diff --git a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz index 6120fcfbc4..859b38cb62 100644 Binary files a/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz and b/tests/providers/aws/services/ec2/ec2_instance_secrets_user_data/fixtures/fixture.gz differ diff --git a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py index 0430ca5cae..dab6debb6b 100644 --- a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py +++ b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets_test.py @@ -29,7 +29,9 @@ def mock_make_api_call(self, operation_name, kwarg): "VersionNumber": 123, "LaunchTemplateData": { "UserData": b64encode( - "DB_PASSWORD=foobar123".encode(encoding_format_utf_8) + 'DB_PASSWORD="Tr0ub4dor3xKq9vLmZ"'.encode( + encoding_format_utf_8 + ) ).decode(encoding_format_utf_8), "NetworkInterfaces": [{"AssociatePublicIpAddress": True}], }, @@ -164,7 +166,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Potential secret found in User Data for EC2 Launch Template tester1 in template versions: Version 123: Secret Keyword on line 1." + == "Potential secret found in User Data for EC2 Launch Template tester1 in template versions: Version 123: Generic Password on line 1." ) assert result[0].resource_id == "lt-1234567890" assert result[0].region == AWS_REGION_US_EAST_1 @@ -212,7 +214,7 @@ class Test_ec2_launch_template_no_secrets: ) ec2_client.launch_templates = [launch_template] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -236,7 +238,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4, Version 2: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4, Version 2: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 @@ -290,7 +292,7 @@ class Test_ec2_launch_template_no_secrets: ) ec2_client.launch_templates = [launch_template] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -314,7 +316,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 @@ -358,7 +360,7 @@ class Test_ec2_launch_template_no_secrets: ) ec2_client.launch_templates = [launch_template] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -382,7 +384,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 @@ -391,6 +393,81 @@ class Test_ec2_launch_template_no_secrets: ) assert result[0].resource_tags == [] + def test_one_launch_template_with_verified_secret(self): + from prowler.lib.check.models import Severity + + ec2_client = mock.MagicMock() + launch_template_name = "tester" + launch_template_id = "lt-1234567890" + launch_template_arn = ( + f"arn:aws:ec2:us-east-1:123456789012:launch-template/{launch_template_id}" + ) + + launch_template_data = TemplateData( + user_data=b64encode( + "This is some user_data".encode(encoding_format_utf_8) + ).decode(encoding_format_utf_8), + associate_public_ip_address=True, + ) + + launch_template_versions = [ + LaunchTemplateVersion( + version_number=1, + template_data=launch_template_data, + ), + ] + + launch_template = LaunchTemplate( + name=launch_template_name, + id=launch_template_id, + arn=launch_template_arn, + region=AWS_REGION_US_EAST_1, + versions=launch_template_versions, + ) + + ec2_client.launch_templates = [launch_template] + ec2_client.audit_config = {"secrets_validate": True} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets.ec2_client", + new=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets.detect_secrets_scan_batch", + return_value={ + (0, 0): [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 1, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + # Test Check + from prowler.providers.aws.services.ec2.ec2_launch_template_no_secrets.ec2_launch_template_no_secrets import ( + ec2_launch_template_no_secrets, + ) + + check = ec2_launch_template_no_secrets() + result = check.execute() + + # The check must forward secrets_validate from the config to the scan. + assert mock_scan.call_args.kwargs.get("validate") is True + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert result[0].resource_id == launch_template_id + @mock_aws def test_one_launch_template_without_user_data(self): launch_template_name = "tester" @@ -506,7 +583,7 @@ class Test_ec2_launch_template_no_secrets: launch_template_secrets, launch_template_no_secrets, ] - ec2_client.audit_config = {"detect_secrets_plugins": None} + ec2_client.audit_config = {} with ( mock.patch( @@ -530,7 +607,7 @@ class Test_ec2_launch_template_no_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name1} in template versions: Version 1: Secret Keyword on line 1, Hex High Entropy String on line 3, Secret Keyword on line 3, Secret Keyword on line 4." + == f"Potential secret found in User Data for EC2 Launch Template {launch_template_name1} in template versions: Version 1: Generic Password on line 1, JSON Web Token (base64url-encoded) on line 3, Generic Password on line 4." ) assert result[0].resource_id == launch_template_id1 assert result[0].region == AWS_REGION_US_EAST_1 @@ -593,10 +670,10 @@ class Test_ec2_launch_template_no_secrets: result = check.execute() assert len(result) == 1 - assert result[0].status == "PASS" + assert result[0].status == "MANUAL" assert ( - result[0].status_extended - == f"No secrets found in User Data of any version for EC2 Launch Template {launch_template_name}." + f"Could not decode User Data for EC2 Launch Template {launch_template_name}" + in result[0].status_extended ) assert result[0].resource_id == launch_template_id assert result[0].region == AWS_REGION_US_EAST_1 diff --git a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture index 2fb5138932..528ff40f8f 100644 --- a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture +++ b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture @@ -1,4 +1,4 @@ -DB_PASSWORD=foobar123 +DB_PASSWORD="Tr0ub4dor3xKq9vLmZ" DB_USER=foo -API_KEY=12345abcd -SERVICE_PASSWORD=bbaabb45 +STRIPE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U +SERVICE_PASSWORD="Xy9zPq2wKmRtVbN4" diff --git a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz index 6120fcfbc4..859b38cb62 100644 Binary files a/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz and b/tests/providers/aws/services/ec2/ec2_launch_template_no_secrets/fixtures/fixture.gz differ diff --git a/tests/providers/aws/services/ecr/ecr_service_test.py b/tests/providers/aws/services/ecr/ecr_service_test.py index 59957b03a1..60f2e0e7ec 100644 --- a/tests/providers/aws/services/ecr/ecr_service_test.py +++ b/tests/providers/aws/services/ecr/ecr_service_test.py @@ -170,20 +170,20 @@ def mock_generate_regional_clients(provider, service): class Test_ECR_Service: # Test ECR Service def test_service(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert ecr.service == "ecr" # Test ECR client def test_client(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) for regional_client in ecr.regional_clients.values(): assert regional_client.__class__.__name__ == "ECR" # Test ECR session def test_get_session(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert ecr.session.__class__.__name__ == "Session" @@ -198,7 +198,7 @@ class Test_ECR_Service: {"Key": "test", "Value": "test"}, ], ) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert len(ecr.registries) == 1 @@ -226,7 +226,7 @@ class Test_ECR_Service: imageScanningConfiguration={"scanOnPush": True}, imageTagMutability="IMMUTABLE", ) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert len(ecr.registries) == 1 assert len(ecr.registries[AWS_REGION_EU_WEST_1].repositories) == 1 @@ -255,7 +255,7 @@ class Test_ECR_Service: imageScanningConfiguration={"scanOnPush": True}, imageTagMutability="IMMUTABLE", ) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert len(ecr.registries) == 1 assert len(ecr.registries[AWS_REGION_EU_WEST_1].repositories) == 1 @@ -273,7 +273,7 @@ class Test_ECR_Service: repositoryName=repo_name, imageScanningConfiguration={"scanOnPush": True}, ) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert len(ecr.registries) == 1 @@ -366,7 +366,7 @@ class Test_ECR_Service: # Test get ECR Registries Scanning Configuration @mock_aws def test_get_registry_scanning_configuration(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert len(ecr.registries) == 1 assert ecr.registries[AWS_REGION_EU_WEST_1].id == AWS_ACCOUNT_NUMBER diff --git a/tests/providers/aws/services/ecs/ecs_service_test.py b/tests/providers/aws/services/ecs/ecs_service_test.py index dec81a1ecc..b472e94b88 100644 --- a/tests/providers/aws/services/ecs/ecs_service_test.py +++ b/tests/providers/aws/services/ecs/ecs_service_test.py @@ -122,27 +122,27 @@ def mock_generate_regional_clients(provider, service): class Test_ECS_Service: # Test ECS Service def test_service(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) assert ecs.service == "ecs" # Test ECS client def test_client(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) for reg_client in ecs.regional_clients.values(): assert reg_client.__class__.__name__ == "ECS" # Test ECS session def test__get_session__(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) assert ecs.session.__class__.__name__ == "Session" # Test list ECS task definitions @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_list_task_definitions(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) task_arn = "arn:aws:ecs:eu-west-1:123456789012:task-definition/test_cluster_1/test_ecs_task:1" @@ -156,7 +156,7 @@ class Test_ECS_Service: @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) # Test describe ECS task definitions def test_describe_task_definitions(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) task_arn = "arn:aws:ecs:eu-west-1:123456789012:task-definition/test_cluster_1/test_ecs_task:1" @@ -204,7 +204,7 @@ class Test_ECS_Service: # Test list ECS clusters @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_list_clusters(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) cluster_arn1 = "arn:aws:ecs:eu-west-1:123456789012:cluster/test_cluster_1" @@ -217,7 +217,7 @@ class Test_ECS_Service: @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) # Test describe ECS clusters def test_describe_clusters(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) cluster_arn1 = "arn:aws:ecs:eu-west-1:123456789012:cluster/test_cluster_1" @@ -237,7 +237,7 @@ class Test_ECS_Service: @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) # Test describe ECS services def test_describe_services(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecs = ECS(aws_provider) service_arn = ( diff --git a/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py b/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py index 24c27ccf0b..753c00b0ef 100644 --- a/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py +++ b/tests/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets_test.py @@ -11,9 +11,17 @@ CONTAINER_NAME = "test-container" ENV_VAR_NAME_NO_SECRETS = "host" ENV_VAR_VALUE_NO_SECRETS = "localhost:1234" ENV_VAR_NAME_WITH_KEYWORD = "DB_PASSWORD" -ENV_VAR_VALUE_WITH_SECRETS = "srv://admin:pass@db" +# Realistic fake secrets that Kingfisher actually detects (placeholders such as +# the previous "srv://admin:pass@db" basic-auth URL are no longer flagged). +# A JWT fires on any line of the dumped JSON (even when followed by a +# trailing comma); a keyword-named variable additionally fires the generic +# keyword rule when it is the last entry in the dump. +ENV_VAR_VALUE_WITH_SECRETS = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" ENV_VAR_NAME_WITH_KEYWORD2 = "DATABASE_PASSWORD" -ENV_VAR_VALUE_WITH_SECRETS2 = "srv://admin:password@database" +ENV_VAR_VALUE_WITH_SECRETS2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEwIiwibmFtZSI6IkphbmUifQ.s5LqY8mC2pX1vN0bQwReTyUiOpAsDfGhJkLzXcVbNm0" +# Generic password/secret assignment value (detected only on the last entry of +# the JSON dump, where there is no trailing comma after the value). +ENV_VAR_VALUE_GENERIC_SECRET = "Tr0ub4dor3xKq9vLmZ" class Test_ecs_task_definitions_no_environment_secrets: @@ -143,7 +151,7 @@ class Test_ecs_task_definitions_no_environment_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Basic Auth Credentials on the environment variable host." + == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> JSON Web Token (base64url-encoded) on the environment variable host." ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -167,7 +175,7 @@ class Test_ecs_task_definitions_no_environment_secrets: "environment": [ { "name": ENV_VAR_NAME_WITH_KEYWORD, - "value": ENV_VAR_VALUE_NO_SECRETS, + "value": ENV_VAR_VALUE_GENERIC_SECRET, } ], } @@ -198,7 +206,7 @@ class Test_ecs_task_definitions_no_environment_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD." + == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Generic Password on the environment variable DB_PASSWORD." ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -251,9 +259,20 @@ class Test_ecs_task_definitions_no_environment_secrets: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + # The keyword-named variable holding a real secret triggers both the + # generic keyword rule and the JWT rule on the same line. + # Kingfisher emits same-line findings in a non-deterministic order, so + # assert both are present without pinning their order. + assert result[0].status_extended.startswith( + f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> " + ) assert ( - result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD." + "JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DB_PASSWORD" + in result[0].status_extended ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -310,9 +329,23 @@ class Test_ecs_task_definitions_no_environment_secrets: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + # DB_PASSWORD holds a JWT under a keyword name, so it fires + # both the JWT rule and the generic keyword rule on the + # same line (non-deterministic order); host holds a second JWT. + assert result[0].status_extended.startswith( + f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> " + ) assert ( - result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable host." + "JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "JSON Web Token (base64url-encoded) on the environment variable host" + in result[0].status_extended ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn @@ -340,7 +373,7 @@ class Test_ecs_task_definitions_no_environment_secrets: }, { "name": ENV_VAR_NAME_WITH_KEYWORD2, - "value": ENV_VAR_VALUE_WITH_SECRETS2, + "value": ENV_VAR_VALUE_GENERIC_SECRET, }, ], } @@ -369,9 +402,24 @@ class Test_ecs_task_definitions_no_environment_secrets: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + # DB_PASSWORD holds a JWT under a keyword name, so it fires + # both the JWT and the generic keyword rule on the same line + # (non-deterministic order); DATABASE_PASSWORD fires the generic + # keyword rule on its own line. + assert result[0].status_extended.startswith( + f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> " + ) assert ( - result[0].status_extended - == f"Potential secrets found in ECS task definition {TASK_NAME} with revision {TASK_REVISION}: Secrets in container test-container -> Secret Keyword on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DB_PASSWORD, Basic Auth Credentials on the environment variable DATABASE_PASSWORD, Secret Keyword on the environment variable DATABASE_PASSWORD." + "JSON Web Token (base64url-encoded) on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DB_PASSWORD" + in result[0].status_extended + ) + assert ( + "Generic Password on the environment variable DATABASE_PASSWORD" + in result[0].status_extended ) assert result[0].resource_id == f"{TASK_NAME}:{TASK_REVISION}" assert result[0].resource_arn == task_arn diff --git a/tests/providers/aws/services/efs/efs_service_test.py b/tests/providers/aws/services/efs/efs_service_test.py index bf8b6555b2..7b2d16e141 100644 --- a/tests/providers/aws/services/efs/efs_service_test.py +++ b/tests/providers/aws/services/efs/efs_service_test.py @@ -93,18 +93,18 @@ def mock_generate_regional_clients(provider, service): class Test_EFS: # Test EFS Session def test__get_session__(self): - access_analyzer = EFS(set_mocked_aws_provider()) + access_analyzer = EFS(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert access_analyzer.session.__class__.__name__ == "Session" # Test EFS Service def test__get_service__(self): - access_analyzer = EFS(set_mocked_aws_provider()) + access_analyzer = EFS(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert access_analyzer.service == "efs" @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) # Test EFS describe file systems def test_describe_file_systems(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) efs = EFS(aws_provider) efs_arn = f"arn:aws:elasticfilesystem:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:file-system/{FILE_SYSTEM_ID}" assert len(efs.filesystems) == 1 @@ -119,7 +119,7 @@ class Test_EFS: @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) # Test EFS describe file systems policies def test_describe_file_system_policies(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) efs = EFS(aws_provider) efs_arn = f"arn:aws:elasticfilesystem:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:file-system/{FILE_SYSTEM_ID}" assert len(efs.filesystems) == 1 @@ -131,7 +131,7 @@ class Test_EFS: @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) # Test EFS describe mount targets def test_describe_mount_targets(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) efs = EFS(aws_provider) assert len(efs.filesystems) == 1 efs_arn = f"arn:aws:elasticfilesystem:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:file-system/{FILE_SYSTEM_ID}" @@ -144,7 +144,7 @@ class Test_EFS: @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) # Test EFS describe access points def test_describe_access_points(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) efs = EFS(aws_provider) assert len(efs.filesystems) == 1 efs_arn = f"arn:aws:elasticfilesystem:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:file-system/{FILE_SYSTEM_ID}" diff --git a/tests/providers/aws/services/eks/eks_service_test.py b/tests/providers/aws/services/eks/eks_service_test.py index 4eccf90808..86de0e246a 100644 --- a/tests/providers/aws/services/eks/eks_service_test.py +++ b/tests/providers/aws/services/eks/eks_service_test.py @@ -31,20 +31,20 @@ def mock_generate_regional_clients(provider, service): class Test_EKS_Service: # Test EKS Service def test_service(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) eks = EKS(aws_provider) assert eks.service == "eks" # Test EKS client def test_client(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) eks = EKS(aws_provider) for reg_client in eks.regional_clients.values(): assert reg_client.__class__.__name__ == "EKS" # Test EKS session def test__get_session__(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) eks = EKS(aws_provider) assert eks.session.__class__.__name__ == "Session" @@ -73,7 +73,7 @@ class Test_EKS_Service: roleArn=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/eks-service-role-AWSServiceRoleForAmazonEKS-J7ONKE3BQ4PI", tags={"test": "test"}, ) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) eks = EKS(aws_provider) assert len(eks.clusters) == 1 assert eks.clusters[0].name == cluster_name @@ -126,7 +126,7 @@ class Test_EKS_Service: }, ], ) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) eks = EKS(aws_provider) assert len(eks.clusters) == 1 assert eks.clusters[0].name == cluster_name diff --git a/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py index 3c77a13d7e..48348a4020 100644 --- a/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py +++ b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py @@ -59,7 +59,9 @@ class Test_ElasticBeanstalk_Service: # Test ElasticBeanstalk Client @mock_aws def test_get_client(self): - elasticbeanstalk = ElasticBeanstalk(set_mocked_aws_provider()) + elasticbeanstalk = ElasticBeanstalk( + set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ) assert ( elasticbeanstalk.regional_clients[AWS_REGION_EU_WEST_1].__class__.__name__ == "ElasticBeanstalk" @@ -68,13 +70,17 @@ class Test_ElasticBeanstalk_Service: # Test ElasticBeanstalk Session @mock_aws def test__get_session__(self): - elasticbeanstalk = ElasticBeanstalk(set_mocked_aws_provider()) + elasticbeanstalk = ElasticBeanstalk( + set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ) assert elasticbeanstalk.session.__class__.__name__ == "Session" # Test ElasticBeanstalk Service @mock_aws def test__get_service__(self): - elasticbeanstalk = ElasticBeanstalk(set_mocked_aws_provider()) + elasticbeanstalk = ElasticBeanstalk( + set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ) assert elasticbeanstalk.service == "elasticbeanstalk" # Test _describe_environments @@ -90,7 +96,9 @@ class Test_ElasticBeanstalk_Service: EnvironmentName="test-env", ) # ElasticBeanstalk Class - elasticbeanstalk = ElasticBeanstalk(set_mocked_aws_provider()) + elasticbeanstalk = ElasticBeanstalk( + set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ) assert len(elasticbeanstalk.environments) == 1 assert ( @@ -125,7 +133,9 @@ class Test_ElasticBeanstalk_Service: EnvironmentName="test-env", ) # ElasticBeanstalk Class - elasticbeanstalk = ElasticBeanstalk(set_mocked_aws_provider()) + elasticbeanstalk = ElasticBeanstalk( + set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ) assert ( elasticbeanstalk.environments[ environment["EnvironmentArn"] @@ -158,7 +168,9 @@ class Test_ElasticBeanstalk_Service: Tags=[{"Key": "test-key", "Value": "test-value"}], ) # ElasticBeanstalk Class - elasticbeanstalk = ElasticBeanstalk(set_mocked_aws_provider()) + elasticbeanstalk = ElasticBeanstalk( + set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ) assert elasticbeanstalk.environments[environment["EnvironmentArn"]].tags == [ {"Key": "test-key", "Value": "test-value"} ] diff --git a/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py new file mode 100644 index 0000000000..40d8d91f0d --- /dev/null +++ b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py @@ -0,0 +1,254 @@ +from importlib import import_module +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_EU_WEST_1_AZA, + AWS_REGION_EU_WEST_1_AZB, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = ( + "prowler.providers.aws.services.elbv2." + "elbv2_alb_drop_invalid_header_fields_enabled." + "elbv2_alb_drop_invalid_header_fields_enabled" +) +ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client" +GLOBAL_PROVIDER_PATCH = ".".join( + [ + "prowler.providers.common.provider.Provider", + "get_global_provider", + ] +) +PASS_STATUS_EXTENDED = " ".join( + [ + "ELBv2 ALB my-lb is configured to drop invalid", + "header fields.", + ] +) +FAIL_STATUS_EXTENDED = ( + "ELBv2 ALB my-lb is not configured to drop invalid header fields." +) + + +def get_check_class(): + return getattr( + import_module(CHECK_MODULE), + "elbv2_alb_drop_invalid_header_fields_enabled", + ) + + +class Test_elbv2_alb_drop_invalid_header_fields_enabled: + @mock_aws + def test_elb_no_balancers(self): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_elbv2_dropping_invalid_header_fields(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "true", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == PASS_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_not_dropping_invalid_header_fields(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == FAIL_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_network_load_balancer_ignored(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + + conn.create_load_balancer( + Name="my-nlb", + Subnets=[subnet1.id], + Scheme="internal", + Type="network", + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/aws/services/emr/emr_cluster_publicly_accesible/emr_cluster_publicly_accesible_test.py b/tests/providers/aws/services/emr/emr_cluster_publicly_accesible/emr_cluster_publicly_accesible_test.py index 3b721b4e62..ab5cd01fc6 100644 --- a/tests/providers/aws/services/emr/emr_cluster_publicly_accesible/emr_cluster_publicly_accesible_test.py +++ b/tests/providers/aws/services/emr/emr_cluster_publicly_accesible/emr_cluster_publicly_accesible_test.py @@ -91,7 +91,11 @@ class Test_emr_cluster_publicly_accesible: ), mock.patch( "prowler.providers.aws.services.emr.emr_cluster_publicly_accesible.emr_cluster_publicly_accesible.ec2_client", - new=EC2(set_mocked_aws_provider(create_default_organization=False)), + new=EC2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], create_default_organization=False + ) + ), ), ): # Test Check @@ -161,7 +165,11 @@ class Test_emr_cluster_publicly_accesible: ), mock.patch( "prowler.providers.aws.services.emr.emr_cluster_publicly_accesible.emr_cluster_publicly_accesible.ec2_client", - new=EC2(set_mocked_aws_provider(create_default_organization=False)), + new=EC2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], create_default_organization=False + ) + ), ), ): # Test Check @@ -248,7 +256,11 @@ class Test_emr_cluster_publicly_accesible: ), mock.patch( "prowler.providers.aws.services.emr.emr_cluster_publicly_accesible.emr_cluster_publicly_accesible.ec2_client", - new=EC2(set_mocked_aws_provider(create_default_organization=False)), + new=EC2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], create_default_organization=False + ) + ), ), ): # Test Check @@ -338,7 +350,11 @@ class Test_emr_cluster_publicly_accesible: ), mock.patch( "prowler.providers.aws.services.emr.emr_cluster_publicly_accesible.emr_cluster_publicly_accesible.ec2_client", - new=EC2(set_mocked_aws_provider(create_default_organization=False)), + new=EC2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], create_default_organization=False + ) + ), ), ): # Test Check @@ -425,7 +441,11 @@ class Test_emr_cluster_publicly_accesible: ), mock.patch( "prowler.providers.aws.services.emr.emr_cluster_publicly_accesible.emr_cluster_publicly_accesible.ec2_client", - new=EC2(set_mocked_aws_provider(create_default_organization=False)), + new=EC2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], create_default_organization=False + ) + ), ), ): # Test Check diff --git a/tests/providers/aws/services/emr/emr_service_test.py b/tests/providers/aws/services/emr/emr_service_test.py index 70ed19d022..febb2506e6 100644 --- a/tests/providers/aws/services/emr/emr_service_test.py +++ b/tests/providers/aws/services/emr/emr_service_test.py @@ -53,19 +53,19 @@ class Test_EMR_Service: # Test EMR Client @mock_aws def test_get_client(self): - emr = EMR(set_mocked_aws_provider()) + emr = EMR(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert emr.regional_clients[AWS_REGION_EU_WEST_1].__class__.__name__ == "EMR" # Test EMR Session @mock_aws def test__get_session__(self): - emr = EMR(set_mocked_aws_provider()) + emr = EMR(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert emr.session.__class__.__name__ == "Session" # Test EMR Service @mock_aws def test__get_service__(self): - emr = EMR(set_mocked_aws_provider()) + emr = EMR(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert emr.service == "emr" # Test _list_clusters and _describe_cluster @@ -93,7 +93,7 @@ class Test_EMR_Service: ) cluster_id = emr_client.run_job_flow(**run_job_flow_args)["JobFlowId"] # EMR Class - emr = EMR(set_mocked_aws_provider()) + emr = EMR(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert len(emr.clusters) == 1 assert emr.clusters[cluster_id].id == cluster_id @@ -115,7 +115,7 @@ class Test_EMR_Service: @mock_aws def test_get_block_public_access_configuration(self): - emr = EMR(set_mocked_aws_provider()) + emr = EMR(set_mocked_aws_provider([AWS_REGION_EU_WEST_1])) assert len(emr.block_public_access_configuration) == 1 assert emr.block_public_access_configuration[ diff --git a/tests/providers/aws/services/globalaccelerator/globalaccelerator_service_test.py b/tests/providers/aws/services/globalaccelerator/globalaccelerator_service_test.py index 19c35763fa..0a940aa7ef 100644 --- a/tests/providers/aws/services/globalaccelerator/globalaccelerator_service_test.py +++ b/tests/providers/aws/services/globalaccelerator/globalaccelerator_service_test.py @@ -55,27 +55,27 @@ class Test_GlobalAccelerator_Service: # Test GlobalAccelerator Service def test_service(self): # GlobalAccelerator client for this test class - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2]) globalaccelerator = GlobalAccelerator(aws_provider) assert globalaccelerator.service == "globalaccelerator" # Test GlobalAccelerator Client def test_client(self): # GlobalAccelerator client for this test class - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2]) globalaccelerator = GlobalAccelerator(aws_provider) assert globalaccelerator.client.__class__.__name__ == "GlobalAccelerator" # Test GlobalAccelerator Session def test__get_session__(self): # GlobalAccelerator client for this test class - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2]) globalaccelerator = GlobalAccelerator(aws_provider) assert globalaccelerator.session.__class__.__name__ == "Session" def test_list_accelerators(self): # GlobalAccelerator client for this test class - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2]) globalaccelerator = GlobalAccelerator(aws_provider) accelerator_name = "TestAccelerator" @@ -99,7 +99,7 @@ class Test_GlobalAccelerator_Service: def test_list_tags(self): # GlobalAccelerator client for this test class - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2]) globalaccelerator = GlobalAccelerator(aws_provider) assert len(globalaccelerator.accelerators) == 1 diff --git a/tests/providers/aws/services/guardduty/guardduty_centrally_managed/guardduty_centrally_managed_test.py b/tests/providers/aws/services/guardduty/guardduty_centrally_managed/guardduty_centrally_managed_test.py index 5459a969b0..581a406ae2 100644 --- a/tests/providers/aws/services/guardduty/guardduty_centrally_managed/guardduty_centrally_managed_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_centrally_managed/guardduty_centrally_managed_test.py @@ -39,7 +39,7 @@ def mock_make_api_call_members_managers(self, operation_name, api_params): class Test_guardduty_centrally_managed: @mock_aws def test_no_detectors(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -67,7 +67,7 @@ class Test_guardduty_centrally_managed: detector_id = guardduty_client.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -112,7 +112,7 @@ class Test_guardduty_centrally_managed: detector_id = guardduty_client.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -156,7 +156,7 @@ class Test_guardduty_centrally_managed: detector_id = guardduty_client.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty diff --git a/tests/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions_test.py b/tests/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions_test.py index d171aa2473..c2875af3ed 100644 --- a/tests/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions_test.py @@ -64,7 +64,7 @@ class Test_guardduty_delegated_admin_enabled_all_regions: @mock_aws def test_no_detectors(self): """Test when no GuardDuty detectors exist.""" - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -102,7 +102,7 @@ class Test_guardduty_delegated_admin_enabled_all_regions: guardduty_client_boto = client("guardduty", region_name=AWS_REGION_EU_WEST_1) detector_id = guardduty_client_boto.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -148,7 +148,7 @@ class Test_guardduty_delegated_admin_enabled_all_regions: guardduty_client_boto = client("guardduty", region_name=AWS_REGION_EU_WEST_1) detector_id = guardduty_client_boto.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -194,7 +194,7 @@ class Test_guardduty_delegated_admin_enabled_all_regions: guardduty_client_boto = client("guardduty", region_name=AWS_REGION_EU_WEST_1) detector_id = guardduty_client_boto.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty diff --git a/tests/providers/aws/services/guardduty/guardduty_ec2_malware_protection_enabled/guardduty_ec2_malware_protection_enabled_test.py b/tests/providers/aws/services/guardduty/guardduty_ec2_malware_protection_enabled/guardduty_ec2_malware_protection_enabled_test.py index 2b5b127adf..39164f2c4c 100644 --- a/tests/providers/aws/services/guardduty/guardduty_ec2_malware_protection_enabled/guardduty_ec2_malware_protection_enabled_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_ec2_malware_protection_enabled/guardduty_ec2_malware_protection_enabled_test.py @@ -44,7 +44,7 @@ def mock_make_api_call(self, operation_name, kwarg): class Test_guardduty_ec2_malware_protection_enabled: def test_no_detectors(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -74,7 +74,7 @@ class Test_guardduty_ec2_malware_protection_enabled: guardduty_client.create_detector(Enable=False) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -112,7 +112,7 @@ class Test_guardduty_ec2_malware_protection_enabled: }, )["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -161,7 +161,7 @@ class Test_guardduty_ec2_malware_protection_enabled: }, )["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty diff --git a/tests/providers/aws/services/guardduty/guardduty_eks_audit_log_enabled/guardduty_eks_audit_log_enabled_test.py b/tests/providers/aws/services/guardduty/guardduty_eks_audit_log_enabled/guardduty_eks_audit_log_enabled_test.py index 563a309a01..b85ca49189 100644 --- a/tests/providers/aws/services/guardduty/guardduty_eks_audit_log_enabled/guardduty_eks_audit_log_enabled_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_eks_audit_log_enabled/guardduty_eks_audit_log_enabled_test.py @@ -12,7 +12,7 @@ from tests.providers.aws.utils import ( class Test_guardduty_eks_audit_log_enabled: def test_no_detectors(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -42,7 +42,7 @@ class Test_guardduty_eks_audit_log_enabled: guardduty_client.create_detector(Enable=False) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -74,7 +74,7 @@ class Test_guardduty_eks_audit_log_enabled: Enable=True, DataSources={"Kubernetes": {"AuditLogs": {"Enable": True}}} )["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -118,7 +118,7 @@ class Test_guardduty_eks_audit_log_enabled: Enable=True, DataSources={"Kubernetes": {"AuditLogs": {"Enable": False}}} )["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty diff --git a/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py b/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py index ca8a9e5877..12dd2a3090 100644 --- a/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py @@ -6,6 +6,7 @@ from moto import mock_aws from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, set_mocked_aws_provider, ) @@ -13,7 +14,7 @@ from tests.providers.aws.utils import ( class Test_guardduty_is_enabled: @mock_aws def test_no_detectors(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -43,7 +44,7 @@ class Test_guardduty_is_enabled: detector_id = guardduty_client.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -85,7 +86,7 @@ class Test_guardduty_is_enabled: detector_id = guardduty_client.create_detector(Enable=False)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -131,7 +132,7 @@ class Test_guardduty_is_enabled: detector_id = guardduty_client.create_detector(Enable=False)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -177,7 +178,9 @@ class Test_guardduty_is_enabled: detector_id = guardduty_client.create_detector(Enable=False)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty diff --git a/tests/providers/aws/services/guardduty/guardduty_lambda_protection_enabled/guardduty_lambda_protection_enabled_test.py b/tests/providers/aws/services/guardduty/guardduty_lambda_protection_enabled/guardduty_lambda_protection_enabled_test.py index e156f8db84..83b3840871 100644 --- a/tests/providers/aws/services/guardduty/guardduty_lambda_protection_enabled/guardduty_lambda_protection_enabled_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_lambda_protection_enabled/guardduty_lambda_protection_enabled_test.py @@ -15,7 +15,7 @@ orig = botocore.client.BaseClient._make_api_call class Test_guardduty_lambda_protection_enabled: def test_no_detectors(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -45,7 +45,7 @@ class Test_guardduty_lambda_protection_enabled: guardduty_client.create_detector(Enable=False) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -78,7 +78,7 @@ class Test_guardduty_lambda_protection_enabled: Features=[{"Name": "LAMBDA_NETWORK_LOGS", "Status": "ENABLED"}], )["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -123,7 +123,7 @@ class Test_guardduty_lambda_protection_enabled: Features=[{"Name": "LAMBDA_NETWORK_LOGS", "Status": "DISABLED"}], )["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty diff --git a/tests/providers/aws/services/guardduty/guardduty_no_high_severity_findings/guardduty_no_high_severity_findings_test.py b/tests/providers/aws/services/guardduty/guardduty_no_high_severity_findings/guardduty_no_high_severity_findings_test.py index b3a7694c55..8f2ca7c883 100644 --- a/tests/providers/aws/services/guardduty/guardduty_no_high_severity_findings/guardduty_no_high_severity_findings_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_no_high_severity_findings/guardduty_no_high_severity_findings_test.py @@ -28,7 +28,7 @@ def mock_make_api_call(self, operation_name, kwarg): class Test_guardduty_no_high_severity_findings: @mock_aws def test_no_detectors(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -56,7 +56,7 @@ class Test_guardduty_no_high_severity_findings: detector_id = guardduty_client.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty @@ -97,7 +97,7 @@ class Test_guardduty_no_high_severity_findings: detector_id = guardduty_client.create_detector(Enable=True)["DetectorId"] - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty diff --git a/tests/providers/aws/services/guardduty/guardduty_service_test.py b/tests/providers/aws/services/guardduty/guardduty_service_test.py index e6d51a05a1..4b903ecb4f 100644 --- a/tests/providers/aws/services/guardduty/guardduty_service_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_service_test.py @@ -66,20 +66,20 @@ def mock_generate_regional_clients(provider, service): class Test_GuardDuty_Service: # Test GuardDuty Service def test_service(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) assert guardduty.service == "guardduty" # Test GuardDuty client def test_client(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) for reg_client in guardduty.regional_clients.values(): assert reg_client.__class__.__name__ == "GuardDuty" # Test GuardDuty session def test__get_session__(self): - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) assert guardduty.session.__class__.__name__ == "Session" @@ -89,7 +89,7 @@ class Test_GuardDuty_Service: guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) response = guardduty_client.create_detector(Enable=True, Tags={"test": "test"}) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) assert len(guardduty.detectors) == 1 @@ -121,7 +121,7 @@ class Test_GuardDuty_Service: ], ) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) assert len(guardduty.detectors) == 1 @@ -149,7 +149,7 @@ class Test_GuardDuty_Service: guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) response = guardduty_client.create_detector(Enable=True) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) assert len(guardduty.detectors) == 1 @@ -170,7 +170,7 @@ class Test_GuardDuty_Service: guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) response = guardduty_client.create_detector(Enable=True) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) assert len(guardduty.detectors) == 1 @@ -192,7 +192,7 @@ class Test_GuardDuty_Service: guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) response = guardduty_client.create_detector(Enable=True) - aws_provider = set_mocked_aws_provider() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) guardduty = GuardDuty(aws_provider) assert len(guardduty.detectors) == 1 diff --git a/tests/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe_test.py b/tests/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe_test.py new file mode 100644 index 0000000000..8f9c57a1e1 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_inline_policy_no_wildcard_marketplace_subscribe/iam_inline_policy_no_wildcard_marketplace_subscribe_test.py @@ -0,0 +1,461 @@ +from json import dumps +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.iam.iam_service import IAM +from tests.providers.aws.utils import ( + ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE_PATH = "prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe" + + +class Test_iam_inline_policy_no_wildcard_marketplace_subscribe: + @mock_aws + def test_inline_policy_allows_marketplace_subscribe_on_all_resources(self): + """FAIL: Inline policy allows aws-marketplace:Subscribe on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "marketplace_subscribe_wildcard" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inline policy {policy_name} attached to role {role_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == f"{role_name}/{policy_name}" + assert result[0].resource_arn == role_arn + assert result[0].region == "eu-west-1" + + @mock_aws + def test_inline_policy_allows_marketplace_wildcard_action(self): + """FAIL: Inline policy allows aws-marketplace:* on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "marketplace_all_actions" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:*", + "Resource": "*", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inline policy {policy_name} attached to role {role_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == f"{role_name}/{policy_name}" + assert result[0].resource_arn == role_arn + assert result[0].region == "eu-west-1" + + @mock_aws + def test_inline_policy_scoped_resource(self): + """PASS: Inline policy allows aws-marketplace:Subscribe on a specific resource.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "marketplace_subscribe_scoped" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "arn:aws:aws-marketplace::123456789012:product/example-product-id", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inline policy {policy_name} attached to role {role_name} does not allow 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == f"{role_name}/{policy_name}" + assert result[0].resource_arn == role_arn + assert result[0].region == "eu-west-1" + + @mock_aws + def test_inline_policy_unrelated_action(self): + """PASS: Inline policy allows an unrelated action on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "ec2_full_access" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ec2:*", + "Resource": "*", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inline policy {policy_name} attached to role {role_name} does not allow 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == f"{role_name}/{policy_name}" + assert result[0].resource_arn == role_arn + assert result[0].region == "eu-west-1" + + @mock_aws + def test_inline_policy_deny_overrides_allow(self): + """PASS: Allow + Deny on * for same action — Deny wins.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "marketplace_subscribe_denied" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inline policy {policy_name} attached to role {role_name} does not allow 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == f"{role_name}/{policy_name}" + assert result[0].resource_arn == role_arn + assert result[0].region == "eu-west-1" + + @mock_aws + def test_inline_policy_deny_specific_resource_does_not_override(self): + """FAIL: Deny on specific resource does not negate Allow on *.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "deny_specific_only" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "aws-marketplace:Subscribe", + "Resource": "arn:aws:aws-marketplace::123456789012:product/blocked", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inline policy {policy_name} attached to role {role_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == f"{role_name}/{policy_name}" + assert result[0].resource_arn == role_arn + assert result[0].region == "eu-west-1" + + @mock_aws + def test_inline_policy_not_action_allows_marketplace_subscribe(self): + """FAIL: Allow + NotAction excludes only unrelated actions so Subscribe is granted.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "marketplace_not_action" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "NotAction": "ec2:Describe*", + "Resource": "*", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert result[0].resource_arn == role_arn + + @mock_aws + def test_inline_policy_not_action_excludes_marketplace_subscribe(self): + """PASS: Allow + NotAction that excludes aws-marketplace:Subscribe does not grant it.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_name = "test_role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=dumps(ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY), + )["Role"]["Arn"] + + policy_name = "marketplace_not_action_excluded" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "NotAction": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + iam_client.put_role_policy( + RoleName=role_name, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert result[0].resource_arn == role_arn + + @mock_aws + def test_no_inline_policies(self): + """No findings when there are no inline policies.""" + 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( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_inline_policy_no_wildcard_marketplace_subscribe.iam_inline_policy_no_wildcard_marketplace_subscribe import ( + iam_inline_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_inline_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption_test.py b/tests/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption_test.py index 6a9576050c..1fbebe6f63 100644 --- a/tests/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption_test.py +++ b/tests/providers/aws/services/iam/iam_no_custom_policy_permissive_role_assumption/iam_no_custom_policy_permissive_role_assumption_test.py @@ -408,3 +408,83 @@ class Test_iam_no_custom_policy_permissive_role_assumption: assert search( "allows permissive STS Role assumption", result[0].status_extended ) + + @mock_aws + def test_unattached_policy_skipped_when_scan_unused_services_disabled(self): + iam_client = client("iam") + policy_name = "unattached_permissive_assume_role" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "*"}, + ], + } + iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + ) + + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption import ( + iam_no_custom_policy_permissive_role_assumption, + ) + + check = iam_no_custom_policy_permissive_role_assumption() + result = check.execute() + assert result == [] + + @mock_aws + def test_attached_policy_fails_when_scan_unused_services_disabled(self): + iam_client = client("iam") + user_name = "test_user_assume_role" + policy_name = "attached_permissive_assume_role" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "*"}, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + iam_client.create_user(UserName=user_name) + iam_client.attach_user_policy(UserName=user_name, PolicyArn=arn) + + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption import ( + iam_no_custom_policy_permissive_role_assumption, + ) + + check = iam_no_custom_policy_permissive_role_assumption() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_arn == arn + assert search( + "allows permissive STS Role assumption", result[0].status_extended + ) diff --git a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py index 79e1b25955..850a5ba2cb 100644 --- a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py @@ -1261,3 +1261,86 @@ class Test_iam_policy_allows_privilege_escalation: permissions ]: assert search(permission, finding.status_extended) + + @mock_aws + def test_unattached_policy_skipped_when_scan_unused_services_disabled(self): + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "unattached_privilege_escalation" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "iam:CreateAccessKey", "Resource": "*"}, + ], + } + iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert result == [] + + @mock_aws + def test_attached_policy_fails_when_scan_unused_services_disabled(self): + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + user_name = "test_user_privesc" + policy_name = "attached_privilege_escalation" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "iam:CreateAccessKey", "Resource": "*"}, + ], + } + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + iam_client.create_user(UserName=user_name) + iam_client.attach_user_policy(UserName=user_name, PolicyArn=policy_arn) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_arn == policy_arn + assert search( + f"Custom Policy {policy_arn} allows privilege escalation", + result[0].status_extended, + ) diff --git a/tests/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail_test.py b/tests/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail_test.py index a29b130f27..38f5a77e19 100644 --- a/tests/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail_test.py +++ b/tests/providers/aws/services/iam/iam_policy_no_full_access_to_cloudtrail/iam_policy_no_full_access_to_cloudtrail_test.py @@ -207,3 +207,78 @@ class Test_iam_policy_no_full_access_to_cloudtrail: assert result[0].resource_id == "policy_no_cloudtrail_full_no_actions" assert result[0].resource_arn == arn assert result[0].region == "us-east-1" + + @mock_aws + def test_unattached_policy_skipped_when_scan_unused_services_disabled(self): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "unattached_cloudtrail_full" + policy_document_full_access = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "cloudtrail:*", "Resource": "*"}, + ], + } + iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_full_access) + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_cloudtrail.iam_policy_no_full_access_to_cloudtrail.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_cloudtrail.iam_policy_no_full_access_to_cloudtrail import ( + iam_policy_no_full_access_to_cloudtrail, + ) + + check = iam_policy_no_full_access_to_cloudtrail() + result = check.execute() + assert result == [] + + @mock_aws + def test_attached_policy_fails_when_scan_unused_services_disabled(self): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + user_name = "test_user_cloudtrail" + policy_name = "attached_cloudtrail_full" + policy_document_full_access = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "cloudtrail:*", "Resource": "*"}, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_full_access) + )["Policy"]["Arn"] + iam_client.create_user(UserName=user_name) + iam_client.attach_user_policy(UserName=user_name, PolicyArn=arn) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_cloudtrail.iam_policy_no_full_access_to_cloudtrail.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_cloudtrail.iam_policy_no_full_access_to_cloudtrail import ( + iam_policy_no_full_access_to_cloudtrail, + ) + + check = iam_policy_no_full_access_to_cloudtrail() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'cloudtrail:*' privileges." + ) + assert result[0].resource_arn == arn diff --git a/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py b/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py index 514a83b935..15ff9f80ae 100644 --- a/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py +++ b/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py @@ -329,6 +329,81 @@ class Test_iam_policy_no_full_access_to_kms_with_unicode: assert result[0].resource_arn == arn assert result[0].region == "us-east-1" + @mock_aws + def test_unattached_policy_skipped_when_scan_unused_services_disabled(self): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + iam_client = client("iam") + policy_name = "unattached_kms_full" + policy_document_full_access = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "kms:*", "Resource": "*"}, + ], + } + iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_full_access) + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms import ( + iam_policy_no_full_access_to_kms, + ) + + check = iam_policy_no_full_access_to_kms() + result = check.execute() + assert result == [] + + @mock_aws + def test_attached_policy_fails_when_scan_unused_services_disabled(self): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + iam_client = client("iam") + user_name = "test_user_kms" + policy_name = "attached_kms_full" + policy_document_full_access = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "kms:*", "Resource": "*"}, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_full_access) + )["Policy"]["Arn"] + iam_client.create_user(UserName=user_name) + iam_client.attach_user_policy(UserName=user_name, PolicyArn=arn) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms import ( + iam_policy_no_full_access_to_kms, + ) + + check = iam_policy_no_full_access_to_kms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'kms:*' privileges." + ) + assert result[0].resource_arn == arn + @mock_aws def test_policy_full_access_and_full_deny_to_kms(self): aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) diff --git a/tests/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe_test.py b/tests/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe_test.py new file mode 100644 index 0000000000..b3b41f5e7a --- /dev/null +++ b/tests/providers/aws/services/iam/iam_policy_no_wildcard_marketplace_subscribe/iam_policy_no_wildcard_marketplace_subscribe_test.py @@ -0,0 +1,590 @@ +from json import dumps +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.iam.iam_service import IAM +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +CHECK_MODULE_PATH = "prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe" + + +class Test_iam_policy_no_wildcard_marketplace_subscribe: + @mock_aws + def test_policy_allows_marketplace_subscribe_on_all_resources(self): + """FAIL: Policy explicitly allows aws-marketplace:Subscribe on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "marketplace_subscribe_wildcard" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_allows_marketplace_wildcard_action_on_all_resources(self): + """FAIL: Policy allows aws-marketplace:* on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "marketplace_all_actions" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:*", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_allows_full_wildcard_on_all_resources(self): + """FAIL: Policy allows * (all actions) on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "full_admin_access" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_allows_marketplace_subscribe_on_specific_resource(self): + """PASS: Policy allows aws-marketplace:Subscribe on a specific resource ARN.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "marketplace_subscribe_scoped" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "arn:aws:aws-marketplace::123456789012:product/example-product-id", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} does not allow 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_unrelated_action_on_all_resources(self): + """PASS: Policy allows an unrelated action on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "ec2_full_access" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ec2:*", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} does not allow 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_marketplace_subscribe_denied_on_all_resources(self): + """PASS: Allow + Deny on * for same action — Deny wins.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "marketplace_subscribe_denied" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} does not allow 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_deny_specific_resource_does_not_override_allow_all(self): + """FAIL: Deny on a specific resource does not negate Allow on Resource:*.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "deny_specific_resource" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "aws-marketplace:Subscribe", + "Resource": "arn:aws:aws-marketplace::123456789012:product/blocked-product", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_deny_unrelated_action_does_not_override_allow(self): + """FAIL: Deny for a different action does not override the Allow for Subscribe.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "deny_unrelated_action" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "ec2:TerminateInstances", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_case_insensitive_action_matching(self): + """FAIL: Mixed-case action still matches aws-marketplace:Subscribe.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "mixed_case_subscribe" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "AWS-Marketplace:SUBSCRIBE", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'aws-marketplace:Subscribe' on all resources." + ) + assert result[0].resource_id == policy_name + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_not_action_allows_marketplace_subscribe(self): + """FAIL: Allow + NotAction excludes only unrelated actions so Subscribe is granted.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "marketplace_not_action" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "NotAction": "ec2:Describe*", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "FAIL" + assert result[0].resource_arn == arn + + @mock_aws + def test_policy_not_action_excludes_marketplace_subscribe(self): + """PASS: Allow + NotAction that excludes aws-marketplace:Subscribe does not grant it.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "marketplace_not_action_excluded" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "NotAction": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result[0].status == "PASS" + assert result[0].resource_arn == arn + + @mock_aws + def test_no_custom_policies(self): + """No findings when there are no custom IAM policies.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert len(result) == 0 + + @mock_aws + def test_unattached_policy_skipped_when_scan_unused_services_disabled(self): + """No FAIL for an unattached risky policy when --scan-unused-services is off.""" + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + iam_client = client("iam") + policy_name = "unattached_marketplace_subscribe" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert result == [] + + @mock_aws + def test_attached_policy_fails_when_scan_unused_services_disabled(self): + """Attached risky policy still FAILs when --scan-unused-services is off.""" + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], scan_unused_services=False + ) + iam_client = client("iam") + user_name = "test_user_marketplace" + policy_name = "attached_marketplace_subscribe" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + iam_client.create_user(UserName=user_name) + iam_client.attach_user_policy(UserName=user_name, PolicyArn=arn) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + f"{CHECK_MODULE_PATH}.iam_client", + new=IAM(aws_provider), + ): + from prowler.providers.aws.services.iam.iam_policy_no_wildcard_marketplace_subscribe.iam_policy_no_wildcard_marketplace_subscribe import ( + iam_policy_no_wildcard_marketplace_subscribe, + ) + + check = iam_policy_no_wildcard_marketplace_subscribe() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_arn == arn diff --git a/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py b/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock_test.py b/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock_test.py new file mode 100644 index 0000000000..06e1f27db7 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock_test.py @@ -0,0 +1,492 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +IAM_ROLE_NAME = "test-role" +IAM_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{IAM_ROLE_NAME}" +ROLE_DATA = (IAM_ROLE_NAME, IAM_ROLE_ARN) + +CHECK_MODULE = ( + "prowler.providers.aws.services.iam." + "iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock" +) + + +class Test_iam_role_access_not_stale_to_bedrock: + @mock_aws + def test_no_roles_with_bedrock_permissions(self): + """No findings when no roles have Bedrock in last accessed services.""" + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.role_last_accessed_services = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + assert len(check.execute()) == 0 + + @mock_aws + def test_role_bedrock_access_stale(self): + """FAIL when a role last accessed Bedrock more than 60 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=120) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has not accessed Bedrock" in result[0].status_extended + assert "Role" in result[0].status_extended + assert IAM_ROLE_NAME in result[0].status_extended + assert result[0].resource_id == IAM_ROLE_NAME + assert result[0].resource_arn == IAM_ROLE_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_role_bedrock_access_recent(self): + """PASS when a role accessed Bedrock recently.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=5) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "accessed Bedrock" in result[0].status_extended + assert "Role" in result[0].status_extended + assert IAM_ROLE_NAME in result[0].status_extended + + @mock_aws + def test_role_bedrock_never_accessed(self): + """FAIL when a role has Bedrock permissions but never accessed them.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has never used them" in result[0].status_extended + assert "Role" in result[0].status_extended + + @mock_aws + def test_no_roles_listed(self): + """No findings when iam.roles is None (short-circuit).""" + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.roles = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + assert check.execute() == [] + + @mock_aws + def test_role_without_bedrock_permissions(self): + """Role with non-Bedrock services is skipped.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + iam.role_last_accessed_services = { + ROLE_DATA: [ + {"ServiceNamespace": "iam", "ServiceName": "IAM"}, + {"ServiceNamespace": "s3", "ServiceName": "S3"}, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + assert len(check.execute()) == 0 + + @mock_aws + def test_role_bedrock_access_with_string_date(self): + """PASS when LastAuthenticated is an ISO string instead of a datetime object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_access_date = datetime.now(timezone.utc) - timedelta(days=5) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_access_date.isoformat(), + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_role_bedrock_access_at_exact_threshold(self): + """PASS when role accessed Bedrock exactly at the 60-day boundary.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=60) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "60 days ago" in result[0].status_extended + assert "threshold: 60 days" in result[0].status_extended + + @mock_aws + def test_role_bedrock_access_one_day_over_threshold(self): + """FAIL when role accessed Bedrock 61 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=61) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "61 days" in result[0].status_extended + assert "threshold: 60 days" in result[0].status_extended + + @mock_aws + def test_custom_threshold_via_audit_config(self): + """Custom threshold from audit_config is respected for roles.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.audit_config = {"max_unused_bedrock_access_days": 30} + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=45) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "45 days" in result[0].status_extended + assert "threshold: 30 days" in result[0].status_extended + + @mock_aws + def test_role_tags_are_populated(self): + """Verify resource_tags are populated from the role object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + role_tags = [{"Key": "Team", "Value": "ml-platform"}] + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + tags=role_tags, + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=5) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_tags == role_tags diff --git a/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock_test.py b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock_test.py new file mode 100644 index 0000000000..c05b9362c9 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock_test.py @@ -0,0 +1,449 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +IAM_USER_NAME = "test-user" +IAM_USER_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{IAM_USER_NAME}" +USER_DATA = (IAM_USER_NAME, IAM_USER_ARN) + +CHECK_MODULE = ( + "prowler.providers.aws.services.iam." + "iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock" +) + + +class Test_iam_user_access_not_stale_to_bedrock: + @mock_aws + def test_no_users_with_bedrock_permissions(self): + """No findings when no users have Bedrock in last accessed services.""" + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.last_accessed_services = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + assert len(check.execute()) == 0 + + @mock_aws + def test_user_without_bedrock_permissions(self): + """User with non-Bedrock services is skipped.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + iam.last_accessed_services = { + USER_DATA: [ + {"ServiceNamespace": "iam", "ServiceName": "IAM"}, + {"ServiceNamespace": "s3", "ServiceName": "S3"}, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + assert len(check.execute()) == 0 + + @mock_aws + def test_user_bedrock_access_recent(self): + """PASS when user accessed Bedrock within the threshold.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=10) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "accessed Bedrock" in result[0].status_extended + assert IAM_USER_NAME in result[0].status_extended + assert result[0].resource_id == IAM_USER_NAME + assert result[0].resource_arn == IAM_USER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_user_bedrock_access_stale(self): + """FAIL when user last accessed Bedrock more than 60 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=90) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has not accessed Bedrock" in result[0].status_extended + assert "90 days" in result[0].status_extended + assert IAM_USER_NAME in result[0].status_extended + + @mock_aws + def test_user_bedrock_never_accessed(self): + """FAIL when user has Bedrock permissions but has never used them.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has never used them" in result[0].status_extended + + @mock_aws + def test_custom_threshold_via_audit_config(self): + """Custom threshold from audit_config is respected.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.audit_config = {"max_unused_bedrock_access_days": 30} + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=45) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "45 days" in result[0].status_extended + assert "threshold: 30 days" in result[0].status_extended + + @mock_aws + def test_user_bedrock_access_at_exact_threshold(self): + """PASS when user accessed Bedrock exactly at the 60-day boundary.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=60) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "60 days ago" in result[0].status_extended + assert "threshold: 60 days" in result[0].status_extended + + @mock_aws + def test_user_bedrock_access_one_day_over_threshold(self): + """FAIL when user accessed Bedrock 61 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=61) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "61 days" in result[0].status_extended + assert "threshold: 60 days" in result[0].status_extended + + @mock_aws + def test_user_bedrock_access_with_string_date(self): + """PASS when LastAuthenticated is an ISO string instead of a datetime object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_access_date = datetime.now(timezone.utc) - timedelta(days=5) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_access_date.isoformat(), + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_user_tags_are_populated(self): + """Verify resource_tags are populated from the user object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + user_tags = [{"Key": "Environment", "Value": "production"}] + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + tags=user_tags, + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=10) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_tags == user_tags diff --git a/tests/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker_test.py b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker_test.py new file mode 100644 index 0000000000..0119ac7154 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_sagemaker/iam_user_access_not_stale_to_sagemaker_test.py @@ -0,0 +1,623 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +IAM_USER_NAME = "test-user" +IAM_USER_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{IAM_USER_NAME}" +USER_DATA = (IAM_USER_NAME, IAM_USER_ARN) + +CHECK_MODULE = ( + "prowler.providers.aws.services.iam." + "iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker" +) + + +class Test_iam_user_access_not_stale_to_sagemaker: + @mock_aws + def test_no_users_with_sagemaker_permissions(self): + """No findings when no users have SageMaker in last accessed services.""" + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.last_accessed_services = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + assert len(check.execute()) == 0 + + @mock_aws + def test_user_without_sagemaker_permissions(self): + """User with non-SageMaker services is skipped.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + iam.last_accessed_services = { + USER_DATA: [ + {"ServiceNamespace": "iam", "ServiceName": "IAM"}, + {"ServiceNamespace": "s3", "ServiceName": "S3"}, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + assert len(check.execute()) == 0 + + @mock_aws + def test_user_sagemaker_access_recent(self): + """PASS when user accessed SageMaker within the threshold.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=10) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "accessed SageMaker" in result[0].status_extended + assert IAM_USER_NAME in result[0].status_extended + assert result[0].resource_id == IAM_USER_NAME + assert result[0].resource_arn == IAM_USER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_user_sagemaker_access_stale(self): + """FAIL when user last accessed SageMaker more than 90 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=120) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has not accessed SageMaker" in result[0].status_extended + assert "120 days" in result[0].status_extended + assert IAM_USER_NAME in result[0].status_extended + + @mock_aws + def test_user_sagemaker_never_accessed(self): + """FAIL when user has SageMaker permissions but has never used them.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has never used them" in result[0].status_extended + + @mock_aws + def test_custom_threshold_via_audit_config(self): + """Custom threshold from audit_config is respected.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.audit_config = {"max_unused_sagemaker_access_days": 30} + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=45) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "45 days" in result[0].status_extended + assert "threshold: 30 days" in result[0].status_extended + + @mock_aws + def test_user_sagemaker_access_at_exact_threshold(self): + """PASS when user accessed SageMaker exactly at the 90-day boundary.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=90) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90 days ago" in result[0].status_extended + assert "threshold: 90 days" in result[0].status_extended + + @mock_aws + def test_user_sagemaker_access_one_day_over_threshold(self): + """FAIL when user accessed SageMaker 91 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=91) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "91 days" in result[0].status_extended + assert "threshold: 90 days" in result[0].status_extended + + @mock_aws + def test_user_sagemaker_access_with_string_date(self): + """PASS when LastAuthenticated is an ISO string instead of a datetime object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_access_date = datetime.now(timezone.utc) - timedelta(days=5) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_access_date.isoformat(), + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_user_tags_are_populated(self): + """Verify resource_tags are populated from the user object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + user_tags = [{"Key": "Environment", "Value": "production"}] + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + tags=user_tags, + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=10) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_tags == user_tags + + @mock_aws + def test_multiple_users_mixed_results(self): + """Multiple users: one recent (PASS), one stale (FAIL), one without SageMaker (skipped).""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + recent_user_name = "recent-user" + recent_user_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{recent_user_name}" + stale_user_name = "stale-user" + stale_user_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{stale_user_name}" + no_sagemaker_user_name = "no-sagemaker-user" + no_sagemaker_user_arn = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{no_sagemaker_user_name}" + ) + + iam.users = [ + User( + name=recent_user_name, + arn=recent_user_arn, + attached_policies=[], + inline_policies=[], + ), + User( + name=stale_user_name, + arn=stale_user_arn, + attached_policies=[], + inline_policies=[], + ), + User( + name=no_sagemaker_user_name, + arn=no_sagemaker_user_arn, + attached_policies=[], + inline_policies=[], + ), + ] + + recent_access = datetime.now(timezone.utc) - timedelta(days=10) + stale_access = datetime.now(timezone.utc) - timedelta(days=120) + iam.last_accessed_services = { + (recent_user_name, recent_user_arn): [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": recent_access, + }, + ], + (stale_user_name, stale_user_arn): [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": stale_access, + }, + ], + (no_sagemaker_user_name, no_sagemaker_user_arn): [ + {"ServiceNamespace": "s3", "ServiceName": "S3"}, + ], + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 2 + results_by_id = {r.resource_id: r for r in result} + + assert results_by_id[recent_user_name].status == "PASS" + assert ( + "accessed SageMaker" in results_by_id[recent_user_name].status_extended + ) + + assert results_by_id[stale_user_name].status == "FAIL" + assert ( + "has not accessed SageMaker" + in results_by_id[stale_user_name].status_extended + ) + assert "120 days" in results_by_id[stale_user_name].status_extended + + assert no_sagemaker_user_name not in results_by_id + + @mock_aws + def test_user_arn_not_in_users_list(self): + """No findings when last_accessed_services entries do not match any iam.users.""" + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.users = [] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=10) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + assert check.execute() == [] + + @mock_aws + def test_sagemaker_among_multiple_services(self): + """SageMaker entry is correctly found when mixed with other services.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=15) + iam.last_accessed_services = { + USER_DATA: [ + {"ServiceNamespace": "iam", "ServiceName": "IAM"}, + {"ServiceNamespace": "s3", "ServiceName": "S3"}, + { + "ServiceNamespace": "sagemaker", + "ServiceName": "Amazon SageMaker", + "LastAuthenticated": last_authenticated, + }, + {"ServiceNamespace": "ec2", "ServiceName": "EC2"}, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import ( + iam_user_access_not_stale_to_sagemaker, + ) + + check = iam_user_access_not_stale_to_sagemaker() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "accessed SageMaker" in result[0].status_extended + assert "15 days ago" in result[0].status_extended diff --git a/tests/providers/aws/services/iam/lib/policy_test.py b/tests/providers/aws/services/iam/lib/policy_test.py index afea8ca658..bf64ff9211 100644 --- a/tests/providers/aws/services/iam/lib/policy_test.py +++ b/tests/providers/aws/services/iam/lib/policy_test.py @@ -15,6 +15,7 @@ from prowler.providers.aws.services.iam.lib.policy import ( is_condition_restricting_from_private_ip, is_condition_restricting_to_trusted_ips, is_policy_public, + policy_allows_marketplace_subscribe_on_all_resources, ) TRUSTED_AWS_ACCOUNT_NUMBER = "123456789012" @@ -137,6 +138,47 @@ class Test_Policy: assert result == {"s3:GetObject", "s3:ListBucket"} assert "s3:PutObject" not in result + def test_policy_allows_marketplace_subscribe_conditional_deny_does_not_cancel(self): + """Conditional deny should not globally cancel a wildcard marketplace allow.""" + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + "Condition": {"StringEquals": {"aws:RequestedRegion": "us-east-1"}}, + }, + ], + } + + assert policy_allows_marketplace_subscribe_on_all_resources(policy) + + def test_policy_allows_marketplace_subscribe_unconditional_deny_cancels(self): + """Unconditional deny on Resource:* should cancel the wildcard marketplace allow.""" + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "aws-marketplace:Subscribe", + "Resource": "*", + }, + ], + } + + assert not policy_allows_marketplace_subscribe_on_all_resources(policy) + # Test lowercase context key name --> aws def test_condition_parser_string_equals_aws_SourceAccount_list(self): condition_statement = { @@ -1413,6 +1455,115 @@ class Test_Policy: condition_statement, TRUSTED_AWS_ACCOUNT_NUMBER ) + def test_condition_parser_string_equals_aws_CalledVia_str(self): + condition_statement = { + "StringEquals": {"aws:CalledVia": "cloudformation.amazonaws.com"} + } + assert is_condition_block_restrictive( + condition_statement, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=True, + ) + + def test_condition_parser_string_equals_aws_CalledViaFirst_str(self): + condition_statement = { + "StringEquals": {"aws:CalledViaFirst": "cloudformation.amazonaws.com"} + } + assert is_condition_block_restrictive( + condition_statement, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=True, + ) + + def test_condition_parser_string_equals_aws_CalledViaLast_str(self): + condition_statement = { + "StringEquals": {"aws:CalledViaLast": "glue.amazonaws.com"} + } + assert is_condition_block_restrictive( + condition_statement, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=True, + ) + + def test_condition_parser_string_like_aws_CalledVia_str(self): + condition_statement = {"StringLike": {"aws:CalledVia": "*.amazonaws.com"}} + assert is_condition_block_restrictive( + condition_statement, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=True, + ) + + def test_condition_parser_string_equals_kms_CallerAccount_str(self): + condition_statement = { + "StringEquals": {"kms:CallerAccount": TRUSTED_AWS_ACCOUNT_NUMBER} + } + assert is_condition_block_restrictive( + condition_statement, TRUSTED_AWS_ACCOUNT_NUMBER + ) + + def test_condition_parser_string_equals_kms_CallerAccount_str_not_valid(self): + condition_statement = { + "StringEquals": {"kms:CallerAccount": NON_TRUSTED_AWS_ACCOUNT_NUMBER} + } + assert not is_condition_block_restrictive( + condition_statement, TRUSTED_AWS_ACCOUNT_NUMBER + ) + + def test_condition_parser_string_equals_kms_CallerAccount_list(self): + condition_statement = { + "StringEquals": {"kms:CallerAccount": [TRUSTED_AWS_ACCOUNT_NUMBER]} + } + assert is_condition_block_restrictive( + condition_statement, TRUSTED_AWS_ACCOUNT_NUMBER + ) + + def test_condition_parser_string_equals_kms_CallerAccount_list_not_valid(self): + condition_statement = { + "StringEquals": { + "kms:CallerAccount": [ + TRUSTED_AWS_ACCOUNT_NUMBER, + NON_TRUSTED_AWS_ACCOUNT_NUMBER, + ] + } + } + assert not is_condition_block_restrictive( + condition_statement, TRUSTED_AWS_ACCOUNT_NUMBER + ) + + def test_condition_parser_string_equals_kms_ViaService_str(self): + condition_statement = { + "StringEquals": {"kms:ViaService": "glue.eu-central-1.amazonaws.com"} + } + assert is_condition_block_restrictive( + condition_statement, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=True, + ) + + def test_condition_parser_string_like_kms_CallerAccount_str(self): + condition_statement = { + "StringLike": {"kms:CallerAccount": TRUSTED_AWS_ACCOUNT_NUMBER} + } + assert is_condition_block_restrictive( + condition_statement, TRUSTED_AWS_ACCOUNT_NUMBER + ) + + def test_condition_parser_string_like_kms_CallerAccount_str_not_valid(self): + condition_statement = { + "StringLike": {"kms:CallerAccount": NON_TRUSTED_AWS_ACCOUNT_NUMBER} + } + assert not is_condition_block_restrictive( + condition_statement, TRUSTED_AWS_ACCOUNT_NUMBER + ) + + def test_condition_parser_string_like_kms_ViaService_str(self): + condition_statement = {"StringLike": {"kms:ViaService": "glue.*.amazonaws.com"}} + assert is_condition_block_restrictive( + condition_statement, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=True, + ) + def test_condition_parser_two_lists_unrestrictive(self): condition_statement = { "StringLike": { @@ -2357,6 +2508,71 @@ class Test_Policy: trusted_ips=["1.2.3.4", "5.6.7.8"], ) + def test_is_policy_public_kms_caller_account_and_via_service(self): + policy = { + "Version": "2008-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:CreateGrant", + "kms:DescribeKey", + ], + "Resource": "*", + "Condition": { + "StringEquals": { + "kms:ViaService": "glue.eu-central-1.amazonaws.com", + "kms:CallerAccount": TRUSTED_AWS_ACCOUNT_NUMBER, + } + }, + }, + ], + } + assert not is_policy_public(policy, TRUSTED_AWS_ACCOUNT_NUMBER) + + def test_is_policy_public_kms_caller_account_only(self): + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["kms:Decrypt"], + "Resource": "*", + "Condition": { + "StringEquals": { + "kms:CallerAccount": TRUSTED_AWS_ACCOUNT_NUMBER, + } + }, + }, + ], + } + assert not is_policy_public(policy, TRUSTED_AWS_ACCOUNT_NUMBER) + + def test_is_policy_public_kms_via_service_without_account_restriction(self): + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["kms:Decrypt"], + "Resource": "*", + "Condition": { + "StringEquals": { + "kms:ViaService": "glue.eu-central-1.amazonaws.com", + } + }, + }, + ], + } + assert not is_policy_public(policy, TRUSTED_AWS_ACCOUNT_NUMBER) + def test_check_admin_access(self): policy = { "Version": "2012-10-17", diff --git a/tests/providers/aws/services/opensearch/opensearch_service_test.py b/tests/providers/aws/services/opensearch/opensearch_service_test.py index 9e89f7cac8..08e7f9a500 100644 --- a/tests/providers/aws/services/opensearch/opensearch_service_test.py +++ b/tests/providers/aws/services/opensearch/opensearch_service_test.py @@ -104,26 +104,26 @@ def mock_generate_regional_clients(provider, service): class TestOpenSearchServiceService: # Test OpenSearchService Service def test_service(self): - aws_provider = set_mocked_aws_provider([]) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) opensearch = OpenSearchService(aws_provider) assert opensearch.service == "opensearch" # Test OpenSearchService_ client def test_client(self): - aws_provider = set_mocked_aws_provider([]) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) opensearch = OpenSearchService(aws_provider) for reg_client in opensearch.regional_clients.values(): assert reg_client.__class__.__name__ == "OpenSearchService" # Test OpenSearchService session def test__get_session__(self): - aws_provider = set_mocked_aws_provider([]) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) opensearch = OpenSearchService(aws_provider) assert opensearch.session.__class__.__name__ == "Session" # Test OpenSearchService list domains names def test_list_domain_names(self): - aws_provider = set_mocked_aws_provider([]) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) opensearch = OpenSearchService(aws_provider) assert len(opensearch.opensearch_domains) == 1 assert opensearch.opensearch_domains[domain_arn].name == test_domain_name @@ -132,7 +132,7 @@ class TestOpenSearchServiceService: # Test OpenSearchService describe domain @mock_aws def test_describe_domain(self): - aws_provider = set_mocked_aws_provider([]) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) opensearch = OpenSearchService(aws_provider) assert len(opensearch.opensearch_domains) == 1 assert opensearch.opensearch_domains[domain_arn].name == test_domain_name @@ -237,7 +237,7 @@ class TestOpenSearchServiceService: "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_missing_fields, ): - aws_provider = set_mocked_aws_provider([]) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) opensearch = OpenSearchService(aws_provider) # Should not crash even with missing optional fields diff --git a/tests/providers/aws/services/rds/rds_instance_no_public_access/rds_instance_no_public_access_test.py b/tests/providers/aws/services/rds/rds_instance_no_public_access/rds_instance_no_public_access_test.py index 6673583864..76d7907408 100644 --- a/tests/providers/aws/services/rds/rds_instance_no_public_access/rds_instance_no_public_access_test.py +++ b/tests/providers/aws/services/rds/rds_instance_no_public_access/rds_instance_no_public_access_test.py @@ -248,6 +248,7 @@ class Test_rds_instance_no_public_access: PubliclyAccessible=True, VpcSecurityGroupIds=[default_sg_id], ) + from prowler.providers.aws.services.ec2.ec2_service import EC2 from prowler.providers.aws.services.rds.rds_service import RDS aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) @@ -256,9 +257,15 @@ class Test_rds_instance_no_public_access: "prowler.providers.common.provider.Provider.get_global_provider", return_value=aws_provider, ): - with mock.patch( - "prowler.providers.aws.services.rds.rds_instance_no_public_access.rds_instance_no_public_access.rds_client", - new=RDS(aws_provider), + with ( + mock.patch( + "prowler.providers.aws.services.rds.rds_instance_no_public_access.rds_instance_no_public_access.rds_client", + new=RDS(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.rds.rds_instance_no_public_access.rds_instance_no_public_access.ec2_client", + new=EC2(aws_provider), + ), ): # Test Check from prowler.providers.aws.services.rds.rds_instance_no_public_access.rds_instance_no_public_access import ( diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py new file mode 100644 index 0000000000..d21d4e20b7 --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py @@ -0,0 +1,92 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + RolesAnywhere, + TrustAnchor, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +TA_ID = "11111111-2222-3333-4444-555555555555" +TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" +PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListTrustAnchors": + return { + "trustAnchors": [ + { + "trustAnchorArn": TA_ARN, + "trustAnchorId": TA_ID, + "name": "pqc-trust", + "enabled": True, + "source": { + "sourceType": "AWS_ACM_PCA", + "sourceData": {"acmPcaArn": PCA_ARN}, + }, + } + ] + } + if operation_name == "ListTagsForResource": + return {"tags": [{"key": "Environment", "value": "test"}]} + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_tags_failure(self, operation_name, kwarg): + if operation_name == "ListTagsForResource": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "Access denied", + } + }, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwarg) + + +class Test_RolesAnywhere_Service: + @mock_aws + def test_service(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert rolesanywhere.service == "rolesanywhere" + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_trust_anchors(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.trust_anchors) == 1 + ta = rolesanywhere.trust_anchors[TA_ARN] + assert isinstance(ta, TrustAnchor) + assert ta.id == TA_ID + assert ta.name == "pqc-trust" + assert ta.enabled is True + assert ta.source_type == "AWS_ACM_PCA" + assert ta.acm_pca_arn == PCA_ARN + assert ta.region == AWS_REGION_US_EAST_1 + assert ta.tags == [{"key": "Environment", "value": "test"}] + + @patch( + "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure + ) + @mock_aws + def test_list_trust_anchors_continues_when_tags_fail(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.trust_anchors) == 1 + ta = rolesanywhere.trust_anchors[TA_ARN] + assert isinstance(ta, TrustAnchor) + assert ta.id == TA_ID + assert ta.tags == [] diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py new file mode 100644 index 0000000000..2038fdde57 --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py @@ -0,0 +1,203 @@ +from unittest import mock + +from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + TrustAnchor, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +TA_ID = "11111111-2222-3333-4444-555555555555" +TA_NAME = "pqc-trust" +TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" +PCA_ID = "12345678-1234-1234-1234-123456789012" +PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{PCA_ID}" + + +def _trust_anchor(*, source_type: str, acm_pca_arn: str = ""): + return TrustAnchor( + arn=TA_ARN, + id=TA_ID, + name=TA_NAME, + region=AWS_REGION_US_EAST_1, + enabled=True, + source_type=source_type, + acm_pca_arn=acm_pca_arn, + ) + + +def _ca(key_algorithm: str, status: str = "ACTIVE"): + return CertificateAuthority( + arn=PCA_ARN, + id=PCA_ID, + region=AWS_REGION_US_EAST_1, + status=status, + type="SUBORDINATE", + usage_mode="GENERAL_PURPOSE", + key_algorithm=key_algorithm, + signing_algorithm=( + key_algorithm if "ML_DSA" in key_algorithm else "SHA256WITHRSA" + ), + ) + + +def _build_clients(trust_anchors, certificate_authorities=None, audit_config=None): + ra_client = mock.MagicMock() + ra_client.trust_anchors = trust_anchors + ra_client.audit_config = audit_config or {} + pca_client = mock.MagicMock() + pca_client.certificate_authorities = certificate_authorities or {} + return ra_client, pca_client + + +def _patched(ra_client, pca_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + return [ + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_client", + new=ra_client, + ), + mock.patch( + "prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.acmpca_client", + new=pca_client, + ), + ] + + +def _enter(patches): + from contextlib import ExitStack + + stack = ExitStack() + for p in patches: + stack.enter_context(p) + return stack + + +class Test_rolesanywhere_trust_anchor_pqc_pki: + def test_no_trust_anchors(self): + ra_client, pca_client = _build_clients({}) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 0 + + def test_pca_backed_pqc(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "ML_DSA_65" in result[0].status_extended + assert result[0].resource_id == TA_ID + assert result[0].resource_arn == TA_ARN + + def test_pca_backed_custom_allowlist_pqc(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("CUSTOM_PQC")}, + audit_config={"rolesanywhere_pqc_pca_key_algorithms": ["CUSTOM_PQC"]}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "CUSTOM_PQC" in result[0].status_extended + assert result[0].resource_id == TA_ID + assert result[0].resource_arn == TA_ARN + + def test_custom_allowlist_replaces_default(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65")}, + audit_config={"rolesanywhere_pqc_pca_key_algorithms": ["ML_DSA_87"]}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ML_DSA_65" in result[0].status_extended + assert "not post-quantum (ML-DSA)" in result[0].status_extended + + def test_pca_backed_rsa(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("RSA_2048")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RSA_2048" in result[0].status_extended + + def test_pca_backed_inactive_pqc_ca(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65", status="DISABLED")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "DISABLED status" in result[0].status_extended + + def test_pca_not_in_inventory(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "could not be inspected" in result[0].status_extended + + def test_certificate_bundle_source(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="CERTIFICATE_BUNDLE")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "CERTIFICATE_BUNDLE" in result[0].status_extended diff --git a/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py b/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py index 63cc9193d9..a7b72eb956 100644 --- a/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py +++ b/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py @@ -4,6 +4,7 @@ from boto3 import client, resource from moto import mock_aws from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1, AWS_REGION_US_WEST_2, set_mocked_aws_provider, @@ -502,3 +503,276 @@ class Test_route53_dangling_ip_subdomain_takeover: result[0].status_extended == f"Route53 record {record_ip} (name: {record_set_name}) in Hosted Zone {HOSTED_ZONE_NAME} is not a dangling IP." ) + + @mock_aws + def test_hosted_zone_cname_to_existing_s3_website_bucket(self): + bucket_name = "my-static-site" + s3 = client("s3", region_name=AWS_REGION_US_EAST_1) + s3.create_bucket(Bucket=bucket_name) + + conn = client("route53", region_name=AWS_REGION_US_EAST_1) + zone_id = conn.create_hosted_zone( + Name=HOSTED_ZONE_NAME, CallerReference=str(hash("foo")) + )["HostedZone"]["Id"] + + record_set_name = "www.testdns.aws.com." + cname_target = f"{bucket_name}.s3-website-us-east-1.amazonaws.com" + conn.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Changes": [ + { + "Action": "CREATE", + "ResourceRecordSet": { + "Name": record_set_name, + "Type": "CNAME", + "TTL": 60, + "ResourceRecords": [{"Value": cname_target}], + }, + } + ] + }, + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.route53.route53_service import Route53 + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.route53_client", + new=Route53(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.ec2_client", + new=EC2(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.s3_client", + new=S3(aws_provider), + ): + from prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover import ( + route53_dangling_ip_subdomain_takeover, + ) + + check = route53_dangling_ip_subdomain_takeover() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Route53 CNAME {record_set_name} in Hosted Zone {HOSTED_ZONE_NAME} points to S3 website endpoint of bucket {bucket_name} which exists in the account." + ) + assert ( + result[0].resource_id + == zone_id.replace("/hostedzone/", "") + + "/" + + record_set_name + + "/" + + cname_target + ) + assert ( + result[0].resource_arn + == f"arn:{aws_provider.identity.partition}:route53:::hostedzone/{zone_id.replace('/hostedzone/', '')}" + ) + + @mock_aws + def test_hosted_zone_cname_to_dangling_s3_website_bucket(self): + # Bucket name referenced by the CNAME is NOT created in the account + # (simulates a deleted bucket whose name is now claimable by anyone) + missing_bucket = "deleted-static-site" + + conn = client("route53", region_name=AWS_REGION_US_EAST_1) + zone_id = conn.create_hosted_zone( + Name=HOSTED_ZONE_NAME, CallerReference=str(hash("foo")) + )["HostedZone"]["Id"] + + record_set_name = "www.testdns.aws.com." + cname_target = f"{missing_bucket}.s3-website-us-east-1.amazonaws.com" + conn.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Changes": [ + { + "Action": "CREATE", + "ResourceRecordSet": { + "Name": record_set_name, + "Type": "CNAME", + "TTL": 60, + "ResourceRecords": [{"Value": cname_target}], + }, + } + ] + }, + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.route53.route53_service import Route53 + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.route53_client", + new=Route53(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.ec2_client", + new=EC2(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.s3_client", + new=S3(aws_provider), + ): + from prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover import ( + route53_dangling_ip_subdomain_takeover, + ) + + check = route53_dangling_ip_subdomain_takeover() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Route53 CNAME {record_set_name} in Hosted Zone {HOSTED_ZONE_NAME} points to S3 website endpoint of bucket {missing_bucket} which does not exist in the account and can lead to a subdomain takeover attack." + ) + assert ( + result[0].resource_id + == zone_id.replace("/hostedzone/", "") + + "/" + + record_set_name + + "/" + + cname_target + ) + + @mock_aws + def test_hosted_zone_cname_to_dangling_s3_website_bucket_dot_format(self): + # Newer regions use the dot-style endpoint: + # .s3-website..amazonaws.com + missing_bucket = "deleted-eu-site" + + conn = client("route53", region_name=AWS_REGION_US_EAST_1) + zone_id = conn.create_hosted_zone( + Name=HOSTED_ZONE_NAME, CallerReference=str(hash("foo")) + )["HostedZone"]["Id"] + + record_set_name = "eu.testdns.aws.com." + cname_target = ( + f"{missing_bucket}.s3-website.{AWS_REGION_EU_WEST_1}.amazonaws.com" + ) + conn.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Changes": [ + { + "Action": "CREATE", + "ResourceRecordSet": { + "Name": record_set_name, + "Type": "CNAME", + "TTL": 60, + "ResourceRecords": [{"Value": cname_target}], + }, + } + ] + }, + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.route53.route53_service import Route53 + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.route53_client", + new=Route53(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.ec2_client", + new=EC2(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.s3_client", + new=S3(aws_provider), + ): + from prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover import ( + route53_dangling_ip_subdomain_takeover, + ) + + check = route53_dangling_ip_subdomain_takeover() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert missing_bucket in result[0].status_extended + + @mock_aws + def test_hosted_zone_cname_to_non_s3_target_is_ignored(self): + # CNAMEs that do not target an S3 website endpoint must not yield a finding + conn = client("route53", region_name=AWS_REGION_US_EAST_1) + zone_id = conn.create_hosted_zone( + Name=HOSTED_ZONE_NAME, CallerReference=str(hash("foo")) + )["HostedZone"]["Id"] + + conn.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Changes": [ + { + "Action": "CREATE", + "ResourceRecordSet": { + "Name": "blog.testdns.aws.com.", + "Type": "CNAME", + "TTL": 60, + "ResourceRecords": [{"Value": "external-host.example.com"}], + }, + } + ] + }, + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.route53.route53_service import Route53 + from prowler.providers.aws.services.s3.s3_service import S3 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.route53_client", + new=Route53(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.ec2_client", + new=EC2(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.s3_client", + new=S3(aws_provider), + ): + from prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover import ( + route53_dangling_ip_subdomain_takeover, + ) + + check = route53_dangling_ip_subdomain_takeover() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability_test.py b/tests/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability_test.py index 9b80d6db35..4b435f0f59 100644 --- a/tests/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability_test.py +++ b/tests/providers/aws/services/s3/s3_bucket_shadow_resource_vulnerability/s3_bucket_shadow_resource_vulnerability_test.py @@ -93,6 +93,8 @@ class Test_s3_bucket_shadow_resource_vulnerability: @mock_aws def test_bucket_not_predictable(self): + # A bucket whose name does not match any predictable service pattern + # is not a shadow resource and must not produce any finding. aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root" @@ -113,6 +115,55 @@ class Test_s3_bucket_shadow_resource_vulnerability: s3_client.provider = aws_provider s3_client._head_bucket = mock.MagicMock(return_value=False) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability.s3_client", + new=s3_client, + ), + ): + from prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability import ( + s3_bucket_shadow_resource_vulnerability, + ) + + check = s3_bucket_shadow_resource_vulnerability() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_only_predictable_bucket_reported_among_many(self): + # With a mix of buckets, only the one matching a predictable pattern + # must produce a finding; the rest must be silent. + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root" + + predictable_bucket = f"sagemaker-{AWS_REGION_US_EAST_1}-{AWS_ACCOUNT_NUMBER}" + plain_buckets = [ + "config-bucket-data", + "my-app-data-bucket", + "guardduty-findings-store", + ] + + s3_client = mock.MagicMock() + s3_client.audited_canonical_id = AWS_ACCOUNT_NUMBER + s3_client.audited_partition = "aws" + s3_client.buckets = { + name: Bucket( + name=name, + arn=f"arn:aws:s3:::{name}", + region=AWS_REGION_US_EAST_1, + owner_id=AWS_ACCOUNT_NUMBER, + tags=[], + ) + for name in [predictable_bucket, *plain_buckets] + } + s3_client.provider = aws_provider + s3_client._head_bucket = mock.MagicMock(return_value=False) + with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -132,14 +183,10 @@ class Test_s3_bucket_shadow_resource_vulnerability: assert len(result) == 1 report = result[0] - - # Test all report attributes assert report.status == "PASS" - assert report.region == AWS_REGION_US_EAST_1 - assert report.resource_id == bucket_name - assert report.resource_arn == f"arn:aws:s3:::{bucket_name}" - assert report.resource_tags == [{"Key": "Project", "Value": "test-project"}] - assert "is not a known shadow resource" in report.status_extended + assert report.resource_id == predictable_bucket + assert "SageMaker" in report.status_extended + assert "is correctly owned by the audited account" in report.status_extended @mock_aws def test_shadow_resource_in_other_account(self): diff --git a/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py new file mode 100644 index 0000000000..32f5487100 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py @@ -0,0 +1,247 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ProcessingJob +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-clarify-processing:1.0" +NON_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-xgboost:1.0" +CUSTOM_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/my-clarify-thing:1.0" +PROCESSING_JOB_ARN = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/clarify-job" + + +class Test_sagemaker_clarify_exists: + def test_no_processing_jobs_no_scanned_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = set() + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 0 + + def test_no_processing_jobs_region_scanned(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "sagemaker-clarify" + + def test_non_clarify_processing_job(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="xgboost-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/xgboost-job", + region=AWS_REGION_US_EAST_1, + image_uri=NON_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_custom_image_with_clarify_in_name_does_not_match(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="my-clarify-thing-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/my-clarify-thing-job", + region=AWS_REGION_US_EAST_1, + image_uri=CUSTOM_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_clarify_processing_job_exists(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "clarify-job" + assert result[0].resource_arn == PROCESSING_JOB_ARN + + def test_mixed_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = { + AWS_REGION_US_EAST_1, + AWS_REGION_EU_WEST_1, + } + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + us_result = results_by_region[AWS_REGION_US_EAST_1] + assert us_result.status == "PASS" + assert ( + us_result.status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + + eu_result = results_by_region[AWS_REGION_EU_WEST_1] + assert eu_result.status == "FAIL" + assert ( + eu_result.status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_EU_WEST_1}." + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured_test.py b/tests/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured_test.py new file mode 100644 index 0000000000..0026a568c0 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_domain_sso_configured/sagemaker_domain_sso_configured_test.py @@ -0,0 +1,234 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import Domain +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +test_domain_name = "test-domain" +test_domain_id = "d-testdomain123" +domain_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:domain/{test_domain_id}" +test_sso_instance_id = "app-test-instance-id" +test_sso_application_arn = ( + f"arn:aws:sso::{AWS_ACCOUNT_NUMBER}:application/sagemaker/apl-test" +) + + +class Test_sagemaker_domain_sso_configured: + def test_no_domains(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_domains = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import ( + sagemaker_domain_sso_configured, + ) + + check = sagemaker_domain_sso_configured() + result = check.execute() + assert len(result) == 0 + + def test_domain_sso_configured_with_instance_id(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_domains = [ + Domain( + domain_id=test_domain_id, + name=test_domain_name, + arn=domain_arn, + region=AWS_REGION_EU_WEST_1, + auth_mode="SSO", + single_sign_on_managed_application_instance_id=test_sso_instance_id, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import ( + sagemaker_domain_sso_configured, + ) + + check = sagemaker_domain_sso_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker domain {test_domain_name} is configured with SSO authentication and is associated with an IAM Identity Center instance." + ) + assert result[0].resource_id == test_domain_name + assert result[0].resource_arn == domain_arn + + def test_domain_sso_configured_with_application_arn(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_domains = [ + Domain( + domain_id=test_domain_id, + name=test_domain_name, + arn=domain_arn, + region=AWS_REGION_EU_WEST_1, + auth_mode="SSO", + single_sign_on_application_arn=test_sso_application_arn, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import ( + sagemaker_domain_sso_configured, + ) + + check = sagemaker_domain_sso_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker domain {test_domain_name} is configured with SSO authentication and is associated with an IAM Identity Center instance." + ) + + def test_domain_sso_without_identity_center(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_domains = [ + Domain( + domain_id=test_domain_id, + name=test_domain_name, + arn=domain_arn, + region=AWS_REGION_EU_WEST_1, + auth_mode="SSO", + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import ( + sagemaker_domain_sso_configured, + ) + + check = sagemaker_domain_sso_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SageMaker domain {test_domain_name} is configured with SSO authentication but is not associated with an IAM Identity Center instance." + ) + assert result[0].resource_id == test_domain_name + assert result[0].resource_arn == domain_arn + + def test_domain_iam_mode(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_domains = [ + Domain( + domain_id=test_domain_id, + name=test_domain_name, + arn=domain_arn, + region=AWS_REGION_EU_WEST_1, + auth_mode="IAM", + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import ( + sagemaker_domain_sso_configured, + ) + + check = sagemaker_domain_sso_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SageMaker domain {test_domain_name} is not configured with SSO authentication, current mode is IAM." + ) + assert result[0].resource_id == test_domain_name + assert result[0].resource_arn == domain_arn + + def test_domain_auth_mode_unknown(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_domains = [ + Domain( + domain_id=test_domain_id, + name=test_domain_name, + arn=domain_arn, + region=AWS_REGION_EU_WEST_1, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import ( + sagemaker_domain_sso_configured, + ) + + check = sagemaker_domain_sso_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SageMaker domain {test_domain_name} is not configured with SSO authentication, current mode is unknown." + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py b/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py new file mode 100644 index 0000000000..afdb5c6ff7 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py @@ -0,0 +1,229 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ( + MonitoringSchedule, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +test_monitoring_schedule = "test-monitoring-schedule" +monitoring_schedule_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{test_monitoring_schedule}" + +aggregate_name = "SageMaker Monitoring Schedules" +unknown_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/unknown" + + +class Test_sagemaker_models_monitor_enabled: + def test_no_models_monitoring_schedules_exist(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=False, + is_scheduled=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}." + ) + assert result[0].resource_id == aggregate_name + assert result[0].resource_arn == unknown_arn + + def test_region_with_schedules_but_none_scheduled(self): + # A region that has monitoring schedules but none in Scheduled state + # must FAIL once. + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=True, + is_scheduled=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"No active SageMaker monitoring schedule in region {AWS_REGION_EU_WEST_1}; existing schedules are not in Scheduled status." + ) + + def test_region_with_one_scheduled_passes(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=test_monitoring_schedule, + region=AWS_REGION_EU_WEST_1, + arn=monitoring_schedule_arn, + has_schedules=True, + is_scheduled=True, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"SageMaker monitoring schedule {test_monitoring_schedule} is enabled in region {AWS_REGION_EU_WEST_1}." + ) + assert result[0].resource_id == test_monitoring_schedule + assert result[0].resource_arn == monitoring_schedule_arn + + def test_scheduled_not_masked_across_regions(self): + # Regression: a region without an active monitor must not mask a + # Scheduled monitor in another region; one finding per region. + scheduled_name = "scheduled-monitor" + scheduled_arn = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{scheduled_name}" + + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=False, + is_scheduled=False, + ), + MonitoringSchedule( + name=scheduled_name, + region=AWS_REGION_US_EAST_1, + arn=scheduled_arn, + has_schedules=True, + is_scheduled=True, + ), + ] + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + assert results_by_region[AWS_REGION_EU_WEST_1].status == "FAIL" + assert ( + results_by_region[AWS_REGION_EU_WEST_1].status_extended + == f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}." + ) + + assert results_by_region[AWS_REGION_US_EAST_1].status == "PASS" + assert ( + results_by_region[AWS_REGION_US_EAST_1].status_extended + == f"SageMaker monitoring schedule {scheduled_name} is enabled in region {AWS_REGION_US_EAST_1}." + ) + + def test_empty_schedules_list(self): + # Regression: an empty list must not raise and must yield no findings. + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert result == [] diff --git a/tests/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use_test.py b/tests/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use_test.py new file mode 100644 index 0000000000..c192217de3 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_models_registry_in_use/sagemaker_models_registry_in_use_test.py @@ -0,0 +1,156 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ModelRegistry +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +registry_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-registry/unknown" + + +class Test_sagemaker_models_registry_in_use: + def test_no_registries(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 0 + + def test_registry_no_groups(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [ + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=AWS_REGION_EU_WEST_1, + has_groups=False, + has_approved_packages=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has no Model Package Groups." + ) + assert result[0].resource_id == "SageMaker Model Registry" + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_registry_groups_no_approved_packages(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [ + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=AWS_REGION_EU_WEST_1, + has_groups=True, + has_approved_packages=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has Model Package Groups but no approved model packages." + ) + assert result[0].resource_id == "SageMaker Model Registry" + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_registry_with_approved_packages(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_model_registries = [ + ModelRegistry( + name="SageMaker Model Registry", + arn=registry_arn, + region=AWS_REGION_EU_WEST_1, + has_groups=True, + has_approved_packages=True, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_models_registry_in_use.sagemaker_models_registry_in_use import ( + sagemaker_models_registry_in_use, + ) + + check = sagemaker_models_registry_in_use() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker Model Registry in region {AWS_REGION_EU_WEST_1} has at least one approved model package." + ) + assert result[0].resource_id == "SageMaker Model Registry" + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index 4213abd650..50431c2e13 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -13,6 +13,11 @@ from tests.providers.aws.utils import ( set_mocked_aws_provider, ) +test_model_package_group_name = "test-model-package-group" +test_model_package_group_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-package-group/{test_model_package_group_name}" +test_model_package_name = "test-model-package" +test_model_package_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:model-package/{test_model_package_name}/1" + test_notebook_instance = "test-notebook-instance" notebook_instance_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:notebook-instance/{test_notebook_instance}" test_model = "test-model" @@ -26,6 +31,13 @@ kms_key_id = str(uuid4()) endpoint_config_name = "endpoint-config-test" endpoint_config_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:endpoint-config/{endpoint_config_name}" prod_variant_name = "Variant1" +test_domain_name = "test-domain" +test_domain_id = "d-testdomain123" +test_domain_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:domain/{test_domain_id}" +test_sso_instance_id = "app-test-instance-id" +test_sso_application_arn = ( + f"arn:aws:sso::{AWS_ACCOUNT_NUMBER}:application/sagemaker/apl-test" +) make_api_call = botocore.client.BaseClient._make_api_call @@ -87,6 +99,25 @@ def mock_make_api_call(self, operation_name, kwarg): "EnableNetworkIsolation": True, "EnableInterContainerTrafficEncryption": True, } + if operation_name == "ListModelPackageGroups": + return { + "ModelPackageGroupSummaryList": [ + { + "ModelPackageGroupName": test_model_package_group_name, + "ModelPackageGroupArn": test_model_package_group_arn, + }, + ] + } + if operation_name == "ListModelPackages": + return { + "ModelPackageSummaryList": [ + { + "ModelPackageName": test_model_package_name, + "ModelPackageArn": test_model_package_arn, + "ModelApprovalStatus": "Approved", + }, + ] + } if operation_name == "ListTags": return { "Tags": [ @@ -115,6 +146,25 @@ def mock_make_api_call(self, operation_name, kwarg): }, ] } + if operation_name == "ListDomains": + return { + "Domains": [ + { + "DomainId": test_domain_id, + "DomainName": test_domain_name, + "DomainArn": test_domain_arn, + }, + ], + } + if operation_name == "DescribeDomain": + return { + "DomainId": test_domain_id, + "DomainName": test_domain_name, + "DomainArn": test_domain_arn, + "AuthMode": "SSO", + "SingleSignOnManagedApplicationInstanceId": test_sso_instance_id, + "SingleSignOnApplicationArn": test_sso_application_arn, + } return make_api_call(self, operation_name, kwarg) @@ -249,6 +299,33 @@ class Test_SageMaker_Service: else: assert prod_variant.initial_instance_count == 2 + # Test SageMaker list domains + def test_list_domains(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sagemaker = SageMaker(aws_provider) + assert len(sagemaker.sagemaker_domains) == 1 + assert sagemaker.sagemaker_domains[0].domain_id == test_domain_id + assert sagemaker.sagemaker_domains[0].name == test_domain_name + assert sagemaker.sagemaker_domains[0].arn == test_domain_arn + assert sagemaker.sagemaker_domains[0].region == AWS_REGION_EU_WEST_1 + + # Test SageMaker describe domain + def test_describe_domain(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sagemaker = SageMaker(aws_provider) + assert len(sagemaker.sagemaker_domains) == 1 + assert sagemaker.sagemaker_domains[0].auth_mode == "SSO" + assert ( + sagemaker.sagemaker_domains[ + 0 + ].single_sign_on_managed_application_instance_id + == test_sso_instance_id + ) + assert ( + sagemaker.sagemaker_domains[0].single_sign_on_application_arn + == test_sso_application_arn + ) + # Test SageMaker _list_tags_for_resource def test_list_tags_for_resource_calls_client(self): """Test that _list_tags_for_resource calls the correct AWS client and updates the resource.""" @@ -312,14 +389,47 @@ class Test_SageMaker_Service: patch( "prowler.providers.aws.services.sagemaker.sagemaker_service.SageMaker._list_endpoint_configs" ), + patch( + "prowler.providers.aws.services.sagemaker.sagemaker_service.SageMaker._list_domains" + ), ): sagemaker_service = SageMaker(audit_info) # Check that __threading_call__ was called for _list_tags_for_resource - # (at least 4 calls expected, one for each resource type) + # (one for each resource type: models, notebooks, training jobs, processing jobs, endpoint configs, domains) tag_calls = [ c for c in mock_threading_call.call_args_list if c[0][0] == sagemaker_service._list_tags_for_resource ] - assert len(tag_calls) == 4 + assert len(tag_calls) == 6 + + # Test SageMaker list model package groups + def test_list_model_package_groups(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sagemaker = SageMaker(aws_provider) + assert len(sagemaker.sagemaker_model_registries) == 1 + registry = sagemaker.sagemaker_model_registries[0] + assert registry.region == AWS_REGION_EU_WEST_1 + assert registry.has_groups is True + assert registry.has_approved_packages is True + + def test_list_model_package_groups_access_denied(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + def mock_access_denied(self, operation_name, kwarg): + if operation_name == "ListModelPackageGroups": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform sagemaker:ListModelPackageGroups", + } + }, + "ListModelPackageGroups", + ) + return make_api_call(self, operation_name, kwarg) + + with patch("botocore.client.BaseClient._make_api_call", new=mock_access_denied): + sagemaker = SageMaker(aws_provider) + assert sagemaker.sagemaker_model_registries == [] 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/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py new file mode 100644 index 0000000000..01af147e9a --- /dev/null +++ b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py @@ -0,0 +1,512 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + +HUB_ARN = f"arn:aws:securityhub:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:hub/default" + + +def _active_hub_responses(operation_name): + """Return a moto-friendly response for hub-describing API calls. + + Returns None if the operation is not one of the hub APIs (so the caller + can fall back to the default behavior). + """ + if operation_name == "DescribeHub": + return { + "HubArn": HUB_ARN, + "SubscribedAt": "2024-01-01T00:00:00.000Z", + "AutoEnableControls": True, + } + if operation_name == "GetEnabledStandards": + return {"StandardsSubscriptions": []} + if operation_name == "ListEnabledProductsForImport": + return {"ProductSubscriptions": []} + if operation_name == "ListTagsForResource": + return {"Tags": {}} + return None + + +def mock_make_api_call_org_admin_and_config(self, operation_name, api_params): + """Mock organization admin accounts and configuration APIs - PASS scenario.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": True, + "AutoEnableStandards": "DEFAULT", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_org_admin_no_auto_enable(self, operation_name, api_params): + """Mock organization admin configured but auto-enable disabled.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_no_org_admin(self, operation_name, api_params): + """Mock no organization admin configured.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_securityhub_not_subscribed(self, operation_name, api_params): + """Simulate Security Hub not subscribed in the account (InvalidAccessException).""" + if operation_name == "DescribeHub": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InvalidAccessException", + "Message": "Account is not subscribed to AWS Security Hub", + } + }, + operation_name, + ) + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_access_denied(self, operation_name, api_params): + """Hub is ACTIVE but ListOrganizationAdminAccounts is denied — lookup-failed path.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: securityhub:ListOrganizationAdminAccounts", + } + }, + operation_name, + ) + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_unexpected(self, operation_name, api_params): + """ListOrganizationAdminAccounts raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise RuntimeError("simulated transient error") + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_other_client_error( + self, operation_name, api_params +): + """DescribeOrganizationConfiguration raises a non-access ClientError — else branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "InternalServerError", "Message": "boom"}}, + operation_name, + ) + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_unexpected(self, operation_name, api_params): + """DescribeOrganizationConfiguration raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + +class Test_securityhub_delegated_admin_enabled_all_regions: + def teardown_method(self): + """Evict cached securityhub modules so legacy mock.patch-based tests + in the same session see a fresh import path.""" + import sys + + for mod in ( + "prowler.providers.aws.services.securityhub.securityhub_client", + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions", + ): + sys.modules.pop(mod, None) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_securityhub_not_subscribed, + ) + @mock_aws + def test_no_securityhub(self): + """Test when Security Hub is not subscribed in any region.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + # Should have findings for each region (with NOT_AVAILABLE hubs) + assert len(result) > 0 + # All should fail since hub is not enabled + for finding in result: + assert finding.status == "FAIL" + assert "Security Hub not enabled" in finding.status_extended + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_org_admin, + ) + @mock_aws + def test_securityhub_enabled_no_delegated_admin(self): + """Test when Security Hub is enabled but no delegated admin is configured.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "no delegated administrator configured" + in eu_west_1_result.status_extended + ) + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_no_auto_enable, + ) + @mock_aws + def test_securityhub_enabled_with_admin_no_auto_enable(self): + """Test when Security Hub is enabled with delegated admin but auto-enable is off.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "organization auto-enable not configured" + in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_and_config, + ) + @mock_aws + def test_securityhub_enabled_with_admin_and_auto_enable(self): + """Test when Security Hub is enabled with delegated admin and auto-enable on (PASS).""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert "delegated admin configured" in eu_west_1_result.status_extended + assert "auto-enable" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_access_denied, + ) + @mock_aws + def test_admin_lookup_access_denied(self): + """AccessDenied on ListOrganizationAdminAccounts must FAIL with unknown-admin message.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in eu_west_1_result.status_extended + ) + assert ( + "no delegated administrator configured" + not in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_unexpected, + ) + @mock_aws + def test_admin_lookup_unexpected_exception(self): + """Non-ClientError raised from ListOrganizationAdminAccounts still sets lookup_failed.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.organization_admin_lookup_failed is True + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in result[0].status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_other_client_error, + ) + @mock_aws + def test_describe_org_config_other_client_error(self): + """Non-access ClientError on DescribeOrganizationConfiguration is logged at error level.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + # organization_config_available stays False, so the auto-enable issue is suppressed + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + # Admin is configured and hub is active; with org config unavailable the + # check should PASS because there are no other detectable issues. + assert result and result[0].status == "PASS" + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_unexpected, + ) + @mock_aws + def test_describe_org_config_unexpected_exception(self): + """Non-ClientError on DescribeOrganizationConfiguration is caught by bare except.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "PASS" diff --git a/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py b/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py new file mode 100644 index 0000000000..be4fda3cf4 --- /dev/null +++ b/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py @@ -0,0 +1,328 @@ +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.ses.ses_service import SES +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call_dkim_pass(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-pass", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": True, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_fail_not_started(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-not-started", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "NOT_STARTED", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_fail_failed(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-failed", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "FAILED", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_pending(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-pending", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "PENDING", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_success_not_enabled(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-verified-not-signed", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +class Test_ses_identity_dkim_enabled: + @mock_aws + def test_no_identities(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 0 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_pass, + ) + def test_identity_dkim_enabled_and_verified(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-pass has DKIM signing enabled and verified." + ) + assert result[0].resource_id == "test-domain-dkim-pass" + assert ( + result[0].resource_arn + == f"arn:aws:ses:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:identity/test-domain-dkim-pass" + ) + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_fail_not_started, + ) + def test_identity_dkim_not_started(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-not-started has DKIM signing not verified (status: NOT_STARTED)." + ) + assert result[0].resource_id == "test-domain-dkim-not-started" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_fail_failed, + ) + def test_identity_dkim_failed(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-failed has DKIM signing failed verification." + ) + assert result[0].resource_id == "test-domain-dkim-failed" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_pending, + ) + def test_identity_dkim_pending(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-pending has DKIM signing not verified (status: PENDING)." + ) + assert result[0].resource_id == "test-domain-dkim-pending" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_success_not_enabled, + ) + def test_identity_dkim_verified_but_not_enabled(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-verified-not-signed has DKIM verified but signing is disabled." + ) + assert result[0].resource_id == "test-domain-dkim-verified-not-signed" + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/ses/ses_service_test.py b/tests/providers/aws/services/ses/ses_service_test.py index dc1e6eb710..223ed94365 100644 --- a/tests/providers/aws/services/ses/ses_service_test.py +++ b/tests/providers/aws/services/ses/ses_service_test.py @@ -29,6 +29,11 @@ def mock_make_api_call(self, operation_name, kwarg): "policy1": '{"policy1": "value1"}', }, "Tags": {"tag1": "value1", "tag2": "value2"}, + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": True, + "SigningAttributesOrigin": "AWS_SES", + }, } return make_api_call(self, operation_name, kwarg) @@ -78,3 +83,6 @@ class Test_SES_Service: assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1 assert ses.email_identities[arn].policy == {"policy1": "value1"} assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"} + assert ses.email_identities[arn].dkim_status == "SUCCESS" + assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES" + assert ses.email_identities[arn].dkim_signing_enabled is True diff --git a/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py b/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py index 24f0a1fdd1..9fc6de4762 100644 --- a/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py +++ b/tests/providers/aws/services/ssm/ssm_document_secrets/ssm_document_secrets_test.py @@ -27,13 +27,13 @@ class Test_ssm_documents_secrets: document_name = "test-document" document_arn = f"arn:aws:ssm:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:document/{document_name}" ssm_client.audited_account = AWS_ACCOUNT_NUMBER - ssm_client.audit_config = {"detect_secrets_plugins": None} + ssm_client.audit_config = {} ssm_client.documents = { document_name: Document( arn=document_arn, name=document_name, region=AWS_REGION_US_EAST_1, - content={"db_password": "test-password"}, + content={"db_password": "Tr0ub4dor3xKq9vLmZ"}, account_owners=[], ) } @@ -56,7 +56,7 @@ class Test_ssm_documents_secrets: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Potential secret found in SSM Document {document_name} -> Secret Keyword on line 2." + == f"Potential secret found in SSM Document {document_name} -> Generic Password on line 2." ) def test_document_no_secrets(self): diff --git a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..44ec8f03c9 --- /dev/null +++ b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py @@ -0,0 +1,142 @@ +from datetime import datetime +from unittest.mock import patch + +import pytest +from moto import mock_aws + +from prowler.providers.aws.services.stepfunctions.stepfunctions_service import ( + EncryptionConfiguration, + EncryptionType, + StateMachine, + StepFunctions, +) +from tests.providers.aws.utils import set_mocked_aws_provider + +AWS_REGION_EU_WEST_1 = "eu-west-1" +STATE_MACHINE_ID = "state-machine-12345" +STATE_MACHINE_ARN = f"arn:aws:states:{AWS_REGION_EU_WEST_1}:123456789012:stateMachine:{STATE_MACHINE_ID}" +KMS_KEY_ARN = "arn:aws:kms:eu-west-1:123456789012:key/some-key-id" + + +def create_state_machine(name, encryption_configuration): + """Create a mock StateMachine instance for use in tests. + + Args: + name (str): The display name of the state machine. + encryption_configuration (Optional[EncryptionConfiguration]): The encryption + configuration to assign to the state machine, or None. + + Returns: + StateMachine: A StateMachine instance pre-populated with test constants. + """ + return StateMachine( + id=STATE_MACHINE_ID, + arn=STATE_MACHINE_ARN, + name=name, + region=AWS_REGION_EU_WEST_1, + encryption_configuration=encryption_configuration, + tags=[], + status="ACTIVE", + definition="{}", + role_arn="arn:aws:iam::123456789012:role/step-functions-role", + type="STANDARD", + creation_date=datetime.now(), + ) + + +@pytest.mark.parametrize( + "state_machines, expected_count, expected_status, expected_status_extended", + [ + # No state machines , no findings + ({}, 0, None, None), + # AWS-owned key (default) , FAIL + ( + { + STATE_MACHINE_ARN: create_state_machine( + "TestStateMachine", + EncryptionConfiguration( + type=EncryptionType.AWS_OWNED_KEY, + kms_key_id=None, + kms_data_key_reuse_period_seconds=None, + ), + ) + }, + 1, + "FAIL", + "Step Functions state machine TestStateMachine is not encrypted at rest with a customer-managed KMS key.", + ), + # No encryption configuration (None) , FAIL + ( + { + STATE_MACHINE_ARN: create_state_machine( + "TestStateMachine", + None, + ) + }, + 1, + "FAIL", + "Step Functions state machine TestStateMachine is not encrypted at rest with a customer-managed KMS key.", + ), + # Customer-managed KMS key , PASS + ( + { + STATE_MACHINE_ARN: create_state_machine( + "TestStateMachine", + EncryptionConfiguration( + type=EncryptionType.CUSTOMER_MANAGED_KMS_KEY, + kms_key_id=KMS_KEY_ARN, + kms_data_key_reuse_period_seconds=300, + ), + ) + }, + 1, + "PASS", + "Step Functions state machine TestStateMachine is encrypted at rest with a customer-managed KMS key.", + ), + ], +) +@mock_aws(config={"stepfunctions": {"execute_state_machine": True}}) +def test_stepfunctions_statemachine_encrypted_with_cmk( + state_machines, + expected_count, + expected_status, + expected_status_extended, +): + """Test stepfunctions_statemachine_encrypted_with_cmk check across multiple scenarios. + + Parametrized test cases cover: + - No state machines present (empty findings). + - State machine using the default AWS-owned key (FAIL). + - State machine with no encryption configuration set (FAIL). + - State machine using a customer-managed KMS key (PASS). + + Args: + state_machines (dict): Mapping of ARN to StateMachine used to mock the service client. + expected_count (int): Expected number of findings returned by the check. + expected_status (Optional[str]): Expected status of the finding, or None if no findings. + expected_status_extended (Optional[str]): Expected status_extended message, or None. + """ + mocked_aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + stepfunctions_client = StepFunctions(mocked_aws_provider) + stepfunctions_client.state_machines = state_machines + + with patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_encrypted_with_cmk.stepfunctions_statemachine_encrypted_with_cmk.stepfunctions_client", + new=stepfunctions_client, + ): + from prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_encrypted_with_cmk.stepfunctions_statemachine_encrypted_with_cmk import ( + stepfunctions_statemachine_encrypted_with_cmk, + ) + + check = stepfunctions_statemachine_encrypted_with_cmk() + result = check.execute() + + assert len(result) == expected_count + + if expected_count == 1: + assert result[0].status == expected_status + assert result[0].status_extended == expected_status_extended + assert result[0].resource_id == STATE_MACHINE_ID + assert result[0].resource_arn == STATE_MACHINE_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + assert result[0].resource == state_machines[STATE_MACHINE_ARN] diff --git a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/__init__.py b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition_test.py b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition_test.py new file mode 100644 index 0000000000..1b1fa1bfca --- /dev/null +++ b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_no_secrets_in_definition/stepfunctions_statemachine_no_secrets_in_definition_test.py @@ -0,0 +1,180 @@ +from datetime import datetime +from unittest import mock + +from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1 + + +class Test_stepfunctions_statemachine_no_secrets_in_definition: + def test_no_statemachines(self): + stepfunctions_client = mock.MagicMock() + stepfunctions_client.state_machines = {} + stepfunctions_client.audit_config = {"secrets_ignore_patterns": []} + + with ( + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_service.StepFunctions", + stepfunctions_client, + ), + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_client", + stepfunctions_client, + ), + ): + from prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition import ( + stepfunctions_statemachine_no_secrets_in_definition, + ) + + check = stepfunctions_statemachine_no_secrets_in_definition() + result = check.execute() + + assert len(result) == 0 + + def test_statemachine_with_no_definition(self): + stepfunctions_client = mock.MagicMock() + + from prowler.providers.aws.services.stepfunctions.stepfunctions_service import ( + StateMachine, + StateMachineStatus, + StateMachineType, + ) + + statemachine_arn = f"arn:aws:states:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:stateMachine:TestStateMachine" + stepfunctions_client.state_machines = { + statemachine_arn: StateMachine( + id="TestStateMachine", + arn=statemachine_arn, + name="TestStateMachine", + status=StateMachineStatus.ACTIVE, + definition=None, + region=AWS_REGION_US_EAST_1, + type=StateMachineType.STANDARD, + creation_date=datetime.now(), + ) + } + stepfunctions_client.audit_config = {"secrets_ignore_patterns": []} + + with ( + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_service.StepFunctions", + stepfunctions_client, + ), + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_client", + stepfunctions_client, + ), + ): + from prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition import ( + stepfunctions_statemachine_no_secrets_in_definition, + ) + + check = stepfunctions_statemachine_no_secrets_in_definition() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No secrets found in Step Functions state machine TestStateMachine definition." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "TestStateMachine" + assert result[0].resource_arn == statemachine_arn + + def test_statemachine_with_no_secrets_in_definition(self): + stepfunctions_client = mock.MagicMock() + + from prowler.providers.aws.services.stepfunctions.stepfunctions_service import ( + StateMachine, + StateMachineStatus, + StateMachineType, + ) + + statemachine_arn = f"arn:aws:states:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:stateMachine:TestStateMachine" + stepfunctions_client.state_machines = { + statemachine_arn: StateMachine( + id="TestStateMachine", + arn=statemachine_arn, + name="TestStateMachine", + status=StateMachineStatus.ACTIVE, + definition='{"Comment": "A simple example", "StartAt": "HelloWorld", "States": {"HelloWorld": {"Type": "Pass", "End": true}}}', + region=AWS_REGION_US_EAST_1, + type=StateMachineType.STANDARD, + creation_date=datetime.now(), + ) + } + stepfunctions_client.audit_config = {"secrets_ignore_patterns": []} + + with ( + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_service.StepFunctions", + stepfunctions_client, + ), + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_client", + stepfunctions_client, + ), + ): + from prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition import ( + stepfunctions_statemachine_no_secrets_in_definition, + ) + + check = stepfunctions_statemachine_no_secrets_in_definition() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No secrets found in Step Functions state machine TestStateMachine definition." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "TestStateMachine" + assert result[0].resource_arn == statemachine_arn + + def test_statemachine_with_secrets_in_definition(self): + stepfunctions_client = mock.MagicMock() + + from prowler.providers.aws.services.stepfunctions.stepfunctions_service import ( + StateMachine, + StateMachineStatus, + StateMachineType, + ) + + statemachine_arn = f"arn:aws:states:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:stateMachine:TestStateMachine" + stepfunctions_client.state_machines = { + statemachine_arn: StateMachine( + id="TestStateMachine", + arn=statemachine_arn, + name="TestStateMachine", + status=StateMachineStatus.ACTIVE, + definition='{"Comment": "Example with secret", "StartAt": "MyTask", "States": {"MyTask": {"Type": "Task", "Parameters": {"api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}, "End": true}}}', + region=AWS_REGION_US_EAST_1, + type=StateMachineType.STANDARD, + creation_date=datetime.now(), + ) + } + stepfunctions_client.audit_config = {"secrets_ignore_patterns": []} + + with ( + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_service.StepFunctions", + stepfunctions_client, + ), + mock.patch( + "prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_client", + stepfunctions_client, + ), + ): + from prowler.providers.aws.services.stepfunctions.stepfunctions_statemachine_no_secrets_in_definition.stepfunctions_statemachine_no_secrets_in_definition import ( + stepfunctions_statemachine_no_secrets_in_definition, + ) + + check = stepfunctions_statemachine_no_secrets_in_definition() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TestStateMachine" in result[0].status_extended + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "TestStateMachine" + assert result[0].resource_arn == statemachine_arn diff --git a/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py b/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py new file mode 100644 index 0000000000..d1a098f0ad --- /dev/null +++ b/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py @@ -0,0 +1,215 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +SERVER_ID = "s-01234567890abcdef" +SERVER_ARN = ( + f"arn:aws:transfer:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:server/{SERVER_ID}" +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def _make_describe_server_mock(security_policy_name: str): + def _mock(self, operation_name, kwarg): + if operation_name == "ListServers": + return { + "Servers": [ + { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + } + ] + } + if operation_name == "DescribeServer": + return { + "Server": { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + "Protocols": ["SFTP"], + "SecurityPolicyName": security_policy_name, + } + } + return make_api_call(self, operation_name, kwarg) + + return _mock + + +mock_pqc = _make_describe_server_mock("TransferSecurityPolicy-2025-03") +mock_fips_pqc = _make_describe_server_mock("TransferSecurityPolicy-FIPS-2025-03") +mock_classical = _make_describe_server_mock("TransferSecurityPolicy-2024-01") +mock_no_policy = _make_describe_server_mock("") + + +class Test_transfer_server_pqc_ssh_kex_enabled: + @mock_aws + def test_no_servers(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 0 + + @patch("botocore.client.BaseClient._make_api_call", new=mock_pqc) + @mock_aws + def test_pq_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TransferSecurityPolicy-2025-03" in result[0].status_extended + assert result[0].resource_id == SERVER_ID + assert result[0].resource_arn == SERVER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @patch("botocore.client.BaseClient._make_api_call", new=mock_fips_pqc) + @mock_aws + def test_fips_pq_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "FIPS-2025-03" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_classical) + @mock_aws + def test_classical_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TransferSecurityPolicy-2024-01" in result[0].status_extended + assert "does not enable post-quantum" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_no_policy) + @mock_aws + def test_missing_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_classical) + @mock_aws + def test_configurable_allowlist(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + audit_config={ + "transfer_pqc_ssh_allowed_policies": [ + "TransferSecurityPolicy-2025-03", + "TransferSecurityPolicy-2024-01", + ] + }, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/transfer/transfer_service_test.py b/tests/providers/aws/services/transfer/transfer_service_test.py index 61a963ab09..7a08e66c92 100644 --- a/tests/providers/aws/services/transfer/transfer_service_test.py +++ b/tests/providers/aws/services/transfer/transfer_service_test.py @@ -35,6 +35,7 @@ def mock_make_api_call(self, operation_name, kwarg): "Arn": SERVER_ARN, "ServerId": SERVER_ID, "Protocols": ["SFTP"], + "SecurityPolicyName": "TransferSecurityPolicy-2025-03", "Tags": [{"Key": "key", "Value": "value"}], } } @@ -83,3 +84,7 @@ class Test_transfer_service: assert transfer.servers[SERVER_ARN].region == "us-east-1" assert transfer.servers[SERVER_ARN].tags == [{"Key": "key", "Value": "value"}] assert transfer.servers[SERVER_ARN].protocols[0] == Protocol.SFTP + assert ( + transfer.servers[SERVER_ARN].security_policy_name + == "TransferSecurityPolicy-2025-03" + ) diff --git a/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py b/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled_test.py b/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled_test.py new file mode 100644 index 0000000000..14b71e8f52 --- /dev/null +++ b/tests/providers/aws/services/waf/waf_regional_webacl_logging_enabled/waf_regional_webacl_logging_enabled_test.py @@ -0,0 +1,199 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +WEB_ACL_ID = "test-web-acl-id" +WEB_ACL_NAME = "test-web-acl-name" +WEB_ACL_ARN = f"arn:aws:waf-regional:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:webacl/{WEB_ACL_ID}" +FIREHOSE_ARN = f"arn:aws:firehose:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:deliverystream/aws-waf-logs-regional" + +# Original botocore _make_api_call function +orig = botocore.client.BaseClient._make_api_call + + +def _base_waf_regional_calls(operation_name, kwarg): + """Return responses for WAFRegional API calls that are common across all test scenarios. + + Args: + operation_name (str): The name of the botocore operation being called. + kwarg (dict): The keyword arguments passed to the API call. + + Returns: + dict or None: The mocked API response if the operation is handled, otherwise None. + """ + unused_operations = [ + "ListRules", + "GetRule", + "ListRuleGroups", + "ListActivatedRulesInRuleGroup", + "ListResourcesForWebACL", + ] + if operation_name in unused_operations: + return {} + if operation_name == "GetChangeToken": + return {"ChangeToken": "my-change-token"} + if operation_name == "ListWebACLs": + return {"WebACLs": [{"WebACLId": WEB_ACL_ID, "Name": WEB_ACL_NAME}]} + if operation_name == "GetWebACL": + return {"WebACL": {"Rules": []}} + return None + + +def mock_make_api_call_logging_enabled(self, operation_name, kwarg): + """Mock botocore API calls with logging enabled on the Regional Web ACL. + + Args: + self: The botocore client instance. + operation_name (str): The name of the botocore operation being called. + kwarg (dict): The keyword arguments passed to the API call. + + Returns: + dict: The mocked API response. + """ + base = _base_waf_regional_calls(operation_name, kwarg) + if base is not None: + return base + if operation_name == "GetLoggingConfiguration": + return { + "LoggingConfiguration": { + "ResourceArn": WEB_ACL_ARN, + "LogDestinationConfigs": [FIREHOSE_ARN], + "RedactedFields": [], + "ManagedByFirewallManager": False, + } + } + return orig(self, operation_name, kwarg) + + +def mock_make_api_call_logging_disabled(self, operation_name, kwarg): + """Mock botocore API calls with logging disabled on the Regional Web ACL. + + Args: + self: The botocore client instance. + operation_name (str): The name of the botocore operation being called. + kwarg (dict): The keyword arguments passed to the API call. + + Returns: + dict: The mocked API response. + """ + base = _base_waf_regional_calls(operation_name, kwarg) + if base is not None: + return base + if operation_name == "GetLoggingConfiguration": + return { + "LoggingConfiguration": { + "ResourceArn": WEB_ACL_ARN, + "LogDestinationConfigs": [], + "RedactedFields": [], + "ManagedByFirewallManager": False, + } + } + return orig(self, operation_name, kwarg) + + +class Test_waf_regional_webacl_logging_enabled: + """Tests for the waf_regional_webacl_logging_enabled check.""" + + @mock_aws + def test_no_waf(self): + """Test that no findings are returned when no Regional Web ACLs exist.""" + from prowler.providers.aws.services.waf.waf_service import WAFRegional + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled.wafregional_client", + new=WAFRegional(aws_provider), + ): + from prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled import ( + waf_regional_webacl_logging_enabled, + ) + + check = waf_regional_webacl_logging_enabled() + result = check.execute() + + assert len(result) == 0 + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_logging_disabled, + ) + @mock_aws + def test_waf_regional_webacl_logging_disabled(self): + """Test that a FAIL finding is returned when logging is disabled on a Regional Web ACL.""" + from prowler.providers.aws.services.waf.waf_service import WAFRegional + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled.wafregional_client", + new=WAFRegional(aws_provider), + ): + from prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled import ( + waf_regional_webacl_logging_enabled, + ) + + check = waf_regional_webacl_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AWS WAF Regional Web ACL {WEB_ACL_NAME} does not have logging enabled." + ) + assert result[0].resource_id == WEB_ACL_ID + assert result[0].resource_arn == WEB_ACL_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_logging_enabled, + ) + @mock_aws + def test_waf_regional_webacl_logging_enabled(self): + """Test that a PASS finding is returned when logging is enabled on a Regional Web ACL.""" + from prowler.providers.aws.services.waf.waf_service import WAFRegional + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled.wafregional_client", + new=WAFRegional(aws_provider), + ): + from prowler.providers.aws.services.waf.waf_regional_webacl_logging_enabled.waf_regional_webacl_logging_enabled import ( + waf_regional_webacl_logging_enabled, + ) + + check = waf_regional_webacl_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AWS WAF Regional Web ACL {WEB_ACL_NAME} does have logging enabled." + ) + assert result[0].resource_id == WEB_ACL_ID + assert result[0].resource_arn == WEB_ACL_ARN + assert result[0].region == AWS_REGION_US_EAST_1 diff --git a/tests/providers/aws/utils.py b/tests/providers/aws/utils.py index 32cb087a67..0b11798e1f 100644 --- a/tests/providers/aws/utils.py +++ b/tests/providers/aws/utils.py @@ -2,7 +2,6 @@ from argparse import Namespace from json import dumps from boto3 import client, session -from botocore.config import Config from moto import mock_aws from prowler.config.config import ( @@ -96,7 +95,7 @@ ADMINISTRATOR_ROLE_ASSUME_ROLE_POLICY = { # This here causes to call this function mocking the AWS calls @mock_aws def set_mocked_aws_provider( - audited_regions: list[str] = [], + audited_regions: list[str] = [AWS_REGION_US_EAST_1], audited_account: str = AWS_ACCOUNT_NUMBER, audited_account_arn: str = AWS_ACCOUNT_ARN, audited_partition: str = AWS_COMMERCIAL_PARTITION, @@ -116,6 +115,12 @@ def set_mocked_aws_provider( status: list[str] = [], create_default_organization: bool = True, ) -> AwsProvider: + if audited_regions is None: + raise ValueError( + "audited_regions is None, which means all 36 regions will be used. " + "Pass an explicit list of regions instead." + ) + if create_default_organization: # Create default AWS Organization create_default_aws_organization() @@ -127,10 +132,11 @@ def set_mocked_aws_provider( provider = AwsProvider() # Mock Session - provider._session.session_config = None + session_config = AwsProvider.set_session_config(None) + provider._session.session_config = session_config provider._session.original_session = original_session provider._session.current_session = audit_session - provider._session.session_config = Config() + audit_session._session.set_default_client_config(session_config) # Mock Identity provider._identity.account = audited_account provider._identity.account_arn = audited_account_arn @@ -143,7 +149,9 @@ def set_mocked_aws_provider( # Mock Configiration provider._scan_unused_services = scan_unused_services provider._enabled_regions = ( - enabled_regions if enabled_regions else set(audited_regions) + enabled_regions + if enabled_regions is not None + else (set(audited_regions) if audited_regions else None) ) # TODO: we can create the organizations metadata here with moto provider._organizations_metadata = None @@ -189,7 +197,13 @@ def create_default_aws_organization(): mockdomain = "moto-example.org" mockemail = "@".join([mockname, mockdomain]) - _ = organizations_client.create_organization(FeatureSet="ALL")["Organization"]["Id"] + try: + _ = organizations_client.create_organization(FeatureSet="ALL")["Organization"][ + "Id" + ] + except organizations_client.exceptions.AlreadyInOrganizationException: + return + account_id = organizations_client.create_account( AccountName=mockname, Email=mockemail )["CreateAccountStatus"]["AccountId"] diff --git a/tests/providers/azure/azure_fixtures.py b/tests/providers/azure/azure_fixtures.py index 51b919e0d6..84d43fd2c3 100644 --- a/tests/providers/azure/azure_fixtures.py +++ b/tests/providers/azure/azure_fixtures.py @@ -8,6 +8,7 @@ from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig AZURE_SUBSCRIPTION_ID = str(uuid4()) AZURE_SUBSCRIPTION_NAME = "Subscription Name" +AZURE_SUBSCRIPTION_DISPLAY = f"{AZURE_SUBSCRIPTION_NAME} ({AZURE_SUBSCRIPTION_ID})" # Azure Identity IDENTITY_ID = "00000000-0000-0000-0000-000000000000" diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index a0c8a5e5d4..1d9aa97e6b 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -1,8 +1,10 @@ -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest from azure.core.credentials import AccessToken +from azure.core.exceptions import HttpResponseError from azure.identity import DefaultAzureCredential from mock import MagicMock @@ -85,6 +87,7 @@ class TestAzureProvider: "python_latest_version": "3.12", "java_latest_version": "17", "recommended_minimal_tls_versions": ["1.2", "1.3"], + "recommended_smb_channel_encryption_algorithms": ["AES-256-GCM"], "vm_backup_min_daily_retention_days": 7, "desired_vm_sku_sizes": [ "Standard_A8_v2", @@ -432,8 +435,29 @@ class TestAzureProvider: ) def test_test_connection_with_ClientAuthenticationError(self): - with pytest.raises(AzureHTTPResponseError) as exception: - tenant_id = str(uuid4()) + tenant_id = str(uuid4()) + error_message = ( + "Authentication failed: Unable to get authority configuration for " + f"https://login.microsoftonline.com/{tenant_id}." + ) + + with ( + patch( + "prowler.providers.azure.azure_provider.AzureProvider.setup_session" + ) as mock_setup_session, + patch( + "prowler.providers.azure.azure_provider.SubscriptionClient" + ) as mock_subscription_client, + pytest.raises(AzureHTTPResponseError) as exception, + ): + mock_setup_session.return_value = MagicMock() + mock_client = MagicMock() + mock_client.subscriptions = MagicMock() + mock_client.subscriptions.list.side_effect = HttpResponseError( + message=error_message + ) + mock_subscription_client.return_value = mock_client + AzureProvider.test_connection( browser_auth=True, tenant_id=tenant_id, @@ -441,9 +465,8 @@ class TestAzureProvider: ) assert exception.type == AzureHTTPResponseError - assert ( - exception.value.args[0] - == f"[2010] Error in HTTP response from Azure - Authentication failed: Unable to get authority configuration for https://login.microsoftonline.com/{tenant_id}. Authority would typically be in a format of https://login.microsoftonline.com/your_tenant or https://tenant_name.ciamlogin.com or https://tenant_name.b2clogin.com/tenant.onmicrosoft.com/policy. Also please double check your tenant name or GUID is correct." + assert exception.value.args[0] == ( + f"[2010] Error in HTTP response from Azure - {error_message}" ) def test_test_connection_without_any_method(self): @@ -527,3 +550,555 @@ class TestAzureProvider: regions = azure_provider.get_regions(subscription_ids=subscription_ids) assert regions == expected_regions + + +class TestAzureProviderSetupIdentitySubscriptions: + """Regression tests ensuring identity.subscriptions preserves every + subscription even when multiple Azure subscriptions share the same + display_name (which is permitted by Azure).""" + + @staticmethod + def _mock_subscription(display_name, subscription_id): + mock_subscription = MagicMock() + mock_subscription.display_name = display_name + mock_subscription.subscription_id = subscription_id + return mock_subscription + + @staticmethod + def _build_subscriptions_client_mock(list_result=None, get_map=None): + """Construct a fully explicit SubscriptionClient mock so the tests do + not depend on MagicMock auto-attribute behavior, which makes the suite + sensitive to shared state across test files.""" + subscriptions_operations = MagicMock() + subscriptions_operations.list = MagicMock(return_value=list_result or []) + if get_map is not None: + subscriptions_operations.get = MagicMock( + side_effect=lambda subscription_id: get_map[subscription_id] + ) + else: + subscriptions_operations.get = MagicMock() + + tenants_operations = MagicMock() + tenants_operations.list = MagicMock(return_value=[]) + + client_instance = MagicMock() + client_instance.subscriptions = subscriptions_operations + client_instance.tenants = tenants_operations + + client_class = MagicMock(return_value=client_instance) + return client_class + + @staticmethod + def _build_provider(): + """Create an AzureProvider instance ready to invoke setup_identity + with auth flags left False so the AAD lookup branches are skipped and + the test focuses on the subscription resolution logic.""" + with patch.object(AzureProvider, "__init__", return_value=None): + azure_provider = AzureProvider() + azure_provider._session = MagicMock() + azure_provider._region_config = AzureRegionConfig( + name="AzureCloud", + authority=None, + base_url="https://management.azure.com", + credential_scopes=["https://management.azure.com/.default"], + ) + return azure_provider + + def test_setup_identity_auto_discovery_preserves_unique_display_names(self): + first_id = str(uuid4()) + second_id = str(uuid4()) + client_class = self._build_subscriptions_client_mock( + list_result=[ + self._mock_subscription("Unique Name One", first_id), + self._mock_subscription("Unique Name Two", second_id), + ] + ) + with patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + client_class, + ): + azure_provider = self._build_provider() + + identity = azure_provider.setup_identity( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + subscription_ids=[], + client_id=None, + ) + + assert identity.subscriptions == { + first_id: "Unique Name One", + second_id: "Unique Name Two", + } + + def test_setup_identity_auto_discovery_preserves_duplicate_display_names( + self, + ): + shared_name = "Shared Display Name" + first_id = str(uuid4()) + second_id = str(uuid4()) + client_class = self._build_subscriptions_client_mock( + list_result=[ + self._mock_subscription(shared_name, first_id), + self._mock_subscription(shared_name, second_id), + ] + ) + with patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + client_class, + ): + azure_provider = self._build_provider() + + identity = azure_provider.setup_identity( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + subscription_ids=[], + client_id=None, + ) + + assert identity.subscriptions == { + first_id: shared_name, + second_id: shared_name, + } + + def test_setup_identity_filtered_preserves_unique_display_names(self): + first_id = str(uuid4()) + second_id = str(uuid4()) + client_class = self._build_subscriptions_client_mock( + get_map={ + first_id: self._mock_subscription("Unique Name One", first_id), + second_id: self._mock_subscription("Unique Name Two", second_id), + } + ) + with patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + client_class, + ): + azure_provider = self._build_provider() + + identity = azure_provider.setup_identity( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + subscription_ids=[first_id, second_id], + client_id=None, + ) + + assert identity.subscriptions == { + first_id: "Unique Name One", + second_id: "Unique Name Two", + } + + def test_setup_identity_filtered_preserves_duplicate_display_names(self): + shared_name = "Shared Display Name" + first_id = str(uuid4()) + second_id = str(uuid4()) + client_class = self._build_subscriptions_client_mock( + get_map={ + first_id: self._mock_subscription(shared_name, first_id), + second_id: self._mock_subscription(shared_name, second_id), + } + ) + with patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + client_class, + ): + azure_provider = self._build_provider() + + identity = azure_provider.setup_identity( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + subscription_ids=[first_id, second_id], + client_id=None, + ) + + assert identity.subscriptions == { + first_id: shared_name, + second_id: shared_name, + } + + +class TestAzureProviderSovereignCloudSupport: + """Sovereign-cloud authentication coverage across AzureCloud, + AzureChinaCloud and AzureUSGovernment for every authentication code path + Prowler exposes. Pinned to issue #8425.""" + + REGION_CASES = [ + ( + "AzureCloud", + None, + "https://management.azure.com", + ["https://management.azure.com/.default"], + "https://graph.microsoft.com/.default", + "https://api.loganalytics.io", + "login.microsoftonline.com", + ), + ( + "AzureChinaCloud", + "login.chinacloudapi.cn", + "https://management.chinacloudapi.cn", + ["https://management.chinacloudapi.cn/.default"], + "https://microsoftgraph.chinacloudapi.cn/.default", + "https://api.loganalytics.azure.cn", + "login.chinacloudapi.cn", + ), + ( + "AzureUSGovernment", + "login.microsoftonline.us", + "https://management.usgovcloudapi.net", + ["https://management.usgovcloudapi.net/.default"], + "https://graph.microsoft.us/.default", + "https://api.loganalytics.us", + "login.microsoftonline.us", + ), + ] + + @pytest.mark.parametrize( + "region,authority,base_url,credential_scopes,graph_scope,logs_endpoint,_login_endpoint", + REGION_CASES, + ) + def test_setup_region_config_per_cloud( + self, + region, + authority, + base_url, + credential_scopes, + graph_scope, + logs_endpoint, + _login_endpoint, + ): + config = AzureProvider.setup_region_config(region) + + # graph_host mirrors graph_scope without the `/.default` suffix; we + # derive it here to avoid threading a separate parameter through every + # parametrized test in this class. + expected_graph_host = graph_scope.removesuffix("/.default") + assert config == AzureRegionConfig( + name=region, + authority=authority, + base_url=base_url, + credential_scopes=credential_scopes, + graph_host=expected_graph_host, + graph_scope=graph_scope, + logs_endpoint=logs_endpoint, + ) + + @pytest.mark.parametrize( + "region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint", + REGION_CASES, + ) + def test_setup_session_static_credentials_passes_authority( + self, + region, + authority, + _base_url, + _credential_scopes, + _graph_scope, + _logs_endpoint, + _login_endpoint, + ): + with patch( + "prowler.providers.azure.azure_provider.ClientSecretCredential" + ) as mock_client_secret_credential: + azure_credentials = { + "tenant_id": str(uuid4()), + "client_id": str(uuid4()), + "client_secret": "fake-secret-value", + } + region_config = AzureProvider.setup_region_config(region) + + AzureProvider.setup_session( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + tenant_id=azure_credentials["tenant_id"], + azure_credentials=azure_credentials, + region_config=region_config, + ) + + mock_client_secret_credential.assert_called_once_with( + tenant_id=azure_credentials["tenant_id"], + client_id=azure_credentials["client_id"], + client_secret=azure_credentials["client_secret"], + authority=authority, + ) + + @pytest.mark.parametrize( + "region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint", + REGION_CASES, + ) + def test_setup_session_browser_auth_passes_authority( + self, + region, + authority, + _base_url, + _credential_scopes, + _graph_scope, + _logs_endpoint, + _login_endpoint, + ): + with patch( + "prowler.providers.azure.azure_provider.InteractiveBrowserCredential" + ) as mock_interactive_browser_credential: + tenant_id = str(uuid4()) + region_config = AzureProvider.setup_region_config(region) + + AzureProvider.setup_session( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=True, + managed_identity_auth=False, + tenant_id=tenant_id, + azure_credentials=None, + region_config=region_config, + ) + + mock_interactive_browser_credential.assert_called_once_with( + tenant_id=tenant_id, + authority=authority, + ) + + @pytest.mark.parametrize( + "region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint", + REGION_CASES, + ) + def test_setup_session_default_credential_passes_authority( + self, + region, + authority, + _base_url, + _credential_scopes, + _graph_scope, + _logs_endpoint, + _login_endpoint, + ): + with patch( + "prowler.providers.azure.azure_provider.DefaultAzureCredential" + ) as mock_default_credential: + region_config = AzureProvider.setup_region_config(region) + + AzureProvider.setup_session( + az_cli_auth=True, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + tenant_id=None, + azure_credentials=None, + region_config=region_config, + ) + + _, called_kwargs = mock_default_credential.call_args + assert called_kwargs["authority"] == authority + assert called_kwargs["exclude_cli_credential"] is False + assert called_kwargs["exclude_environment_credential"] is True + assert called_kwargs["exclude_managed_identity_credential"] is True + + @pytest.mark.parametrize( + "region,_authority,_base_url,_credential_scopes,graph_scope,_logs_endpoint,login_endpoint", + REGION_CASES, + ) + def test_verify_client_uses_per_cloud_endpoints( + self, + region, + _authority, + _base_url, + _credential_scopes, + graph_scope, + _logs_endpoint, + login_endpoint, + ): + tenant_id = str(uuid4()) + client_id = str(uuid4()) + client_secret = "fake-secret" + region_config = AzureProvider.setup_region_config(region) + + with patch("prowler.providers.azure.azure_provider.requests.post") as mock_post: + mock_post.return_value = MagicMock() + mock_post.return_value.json.return_value = {"access_token": "fake-token"} + + AzureProvider.verify_client( + tenant_id, client_id, client_secret, region_config + ) + + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + assert args[0] == ( + f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token" + ) + assert kwargs["data"]["scope"] == graph_scope + assert kwargs["data"]["client_id"] == client_id + assert kwargs["data"]["client_secret"] == client_secret + + @pytest.mark.parametrize( + "region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint", + REGION_CASES, + ) + def test_test_connection_passes_base_url_to_subscription_client( + self, + region, + _authority, + base_url, + credential_scopes, + _graph_scope, + _logs_endpoint, + _login_endpoint, + ): + subscription_client_instance = MagicMock() + subscription_client_instance.subscriptions = MagicMock() + subscription_client_instance.subscriptions.list = MagicMock(return_value=[]) + subscription_client_class = MagicMock(return_value=subscription_client_instance) + + with ( + patch( + "prowler.providers.azure.azure_provider.AzureProvider.setup_session" + ) as mock_setup_session, + patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + subscription_client_class, + ), + ): + mock_setup_session.return_value = MagicMock() + + AzureProvider.test_connection( + az_cli_auth=True, + region=region, + raise_on_exception=False, + ) + + subscription_client_class.assert_called_once() + _, kwargs = subscription_client_class.call_args + assert kwargs["base_url"] == base_url + assert kwargs["credential_scopes"] == credential_scopes + + @pytest.mark.parametrize( + "region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint", + REGION_CASES, + ) + def test_get_locations_passes_base_url_to_subscription_client( + self, + region, + _authority, + base_url, + credential_scopes, + _graph_scope, + _logs_endpoint, + _login_endpoint, + ): + subscription_client_instance = MagicMock() + subscription_client_instance.subscriptions = MagicMock() + subscription_client_instance.subscriptions.list_locations = MagicMock( + return_value=[] + ) + subscription_client_class = MagicMock(return_value=subscription_client_instance) + + with ( + patch.object(AzureProvider, "__init__", return_value=None), + patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + subscription_client_class, + ), + ): + azure_provider = AzureProvider() + azure_provider._session = MagicMock() + azure_provider._region_config = AzureProvider.setup_region_config(region) + azure_provider._identity = AzureIdentityInfo(subscriptions={}) + + azure_provider.get_locations() + + subscription_client_class.assert_called_once() + _, kwargs = subscription_client_class.call_args + assert kwargs["base_url"] == base_url + assert kwargs["credential_scopes"] == credential_scopes + + +class TestAzureProviderSetupIdentityEventLoop: + """Regression for the Celery worker scenario where + asyncio.get_event_loop() raised "There is no current event loop in + thread 'MainThread'." on Python 3.12. setup_identity now uses + asyncio.run(), which creates its own loop and must work without a + pre-existing one in the current thread.""" + + @staticmethod + def _mock_subscription(display_name, subscription_id): + mock_subscription = MagicMock() + mock_subscription.display_name = display_name + mock_subscription.subscription_id = subscription_id + return mock_subscription + + @staticmethod + def _build_subscriptions_client_mock(subscriptions): + subscriptions_operations = MagicMock() + subscriptions_operations.list = MagicMock(return_value=subscriptions) + subscriptions_operations.get = MagicMock() + + tenants_operations = MagicMock() + tenants_operations.list = MagicMock(return_value=[]) + + client_instance = MagicMock() + client_instance.subscriptions = subscriptions_operations + client_instance.tenants = tenants_operations + return MagicMock(return_value=client_instance) + + @staticmethod + def _build_provider(): + with patch.object(AzureProvider, "__init__", return_value=None): + azure_provider = AzureProvider() + azure_provider._session = MagicMock() + azure_provider._region_config = AzureRegionConfig( + name="AzureCloud", + authority=None, + base_url="https://management.azure.com", + credential_scopes=["https://management.azure.com/.default"], + ) + return azure_provider + + def test_setup_identity_succeeds_without_active_event_loop(self): + sub_id = str(uuid4()) + subscriptions_client = self._build_subscriptions_client_mock( + [self._mock_subscription("Sub", sub_id)] + ) + + graph_client = MagicMock() + graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[])) + graph_client.me.get = AsyncMock(return_value=None) + + # Simulate the Celery worker state: no event loop registered for the + # current thread. Before the fix this combination triggered + # `RuntimeError: There is no current event loop in thread 'MainThread'.` + # on Python 3.12 from asyncio.get_event_loop(). + asyncio.set_event_loop(None) + try: + with ( + patch( + "prowler.providers.azure.azure_provider.GraphServiceClient", + return_value=graph_client, + ), + patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + subscriptions_client, + ), + ): + azure_provider = self._build_provider() + identity = azure_provider.setup_identity( + az_cli_auth=False, + sp_env_auth=True, + browser_auth=False, + managed_identity_auth=False, + subscription_ids=[], + client_id="00000000-0000-0000-0000-000000000000", + ) + finally: + # Re-arm a loop for sibling tests that may rely on the default. + asyncio.set_event_loop(asyncio.new_event_loop()) + + assert isinstance(identity, AzureIdentityInfo) + assert identity.subscriptions == {sub_id: "Sub"} + graph_client.domains.get.assert_awaited_once() diff --git a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py index d15faa83ed..24f6d5ef1e 100644 --- a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py +++ b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py @@ -64,10 +64,12 @@ class TestAzureMutelist: finding.status = "FAIL" finding.resource_name = "test_resource" finding.resource_tags = {} - finding.subscription = "subscription_1" + finding.subscription = "12345678-1234-1234-1234-123456789012" assert mutelist.is_finding_muted( - finding, "12345678-1234-1234-1234-123456789012" + finding, + "12345678-1234-1234-1234-123456789012", + "subscription_1", ) def test_is_finding_muted_subscription_id(self): diff --git a/tests/providers/azure/lib/regions/regions_test.py b/tests/providers/azure/lib/regions/regions_test.py index 2f8fdd6053..674897725d 100644 --- a/tests/providers/azure/lib/regions/regions_test.py +++ b/tests/providers/azure/lib/regions/regions_test.py @@ -2,8 +2,17 @@ from azure.identity import AzureAuthorityHosts from prowler.providers.azure.lib.regions.regions import ( AZURE_CHINA_CLOUD, + AZURE_CHINA_GRAPH_HOST, + AZURE_CHINA_GRAPH_SCOPE, + AZURE_CHINA_LOGS_ENDPOINT, AZURE_GENERIC_CLOUD, + AZURE_GENERIC_GRAPH_HOST, + AZURE_GENERIC_GRAPH_SCOPE, + AZURE_GENERIC_LOGS_ENDPOINT, AZURE_US_GOV_CLOUD, + AZURE_US_GOV_GRAPH_HOST, + AZURE_US_GOV_GRAPH_SCOPE, + AZURE_US_GOV_LOGS_ENDPOINT, get_regions_config, ) @@ -20,16 +29,25 @@ class Test_azure_regions: "authority": None, "base_url": AZURE_GENERIC_CLOUD, "credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"], + "graph_host": AZURE_GENERIC_GRAPH_HOST, + "graph_scope": AZURE_GENERIC_GRAPH_SCOPE, + "logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT, }, "AzureChinaCloud": { "authority": AzureAuthorityHosts.AZURE_CHINA, "base_url": AZURE_CHINA_CLOUD, "credential_scopes": [AZURE_CHINA_CLOUD + "/.default"], + "graph_host": AZURE_CHINA_GRAPH_HOST, + "graph_scope": AZURE_CHINA_GRAPH_SCOPE, + "logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT, }, "AzureUSGovernment": { "authority": AzureAuthorityHosts.AZURE_GOVERNMENT, "base_url": AZURE_US_GOV_CLOUD, "credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"], + "graph_host": AZURE_US_GOV_GRAPH_HOST, + "graph_scope": AZURE_US_GOV_GRAPH_SCOPE, + "logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT, }, } diff --git a/tests/providers/azure/lib/service/azure_service_test.py b/tests/providers/azure/lib/service/azure_service_test.py new file mode 100644 index 0000000000..9360be85ea --- /dev/null +++ b/tests/providers/azure/lib/service/azure_service_test.py @@ -0,0 +1,108 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.azure.lib.service.service import AzureService +from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig + +REGION_CASES = [ + ( + "AzureCloud", + "https://graph.microsoft.com", + "https://graph.microsoft.com/.default", + "https://api.loganalytics.io", + ), + ( + "AzureChinaCloud", + "https://microsoftgraph.chinacloudapi.cn", + "https://microsoftgraph.chinacloudapi.cn/.default", + "https://api.loganalytics.azure.cn", + ), + ( + "AzureUSGovernment", + "https://graph.microsoft.us", + "https://graph.microsoft.us/.default", + "https://api.loganalytics.us", + ), +] + + +def _identity_and_session(): + identity = AzureIdentityInfo( + tenant_domain="tenant.onmicrosoft.com", + subscriptions={"sub-1": "Subscription 1"}, + ) + session = MagicMock() + return identity, session + + +class TestAzureServiceSovereignClouds: + """Cover __set_clients__ kwargs for the Graph and Logs clients across the + three sovereign clouds — these are the two service slots in service.py + that historically defaulted to public-cloud endpoints.""" + + @pytest.mark.parametrize( + "_region,graph_host,graph_scope,_logs_endpoint", + REGION_CASES, + ) + def test_set_clients_graph_uses_per_cloud_host_scope_and_adapter( + self, _region, graph_host, graph_scope, _logs_endpoint + ): + graph_service = MagicMock() + graph_service.__str__ = MagicMock(return_value="GraphServiceClient") + region_config = AzureRegionConfig( + graph_host=graph_host, + graph_scope=graph_scope, + logs_endpoint=_logs_endpoint, + ) + identity, session = _identity_and_session() + + with ( + patch.object(AzureService, "__init__", return_value=None), + patch( + "prowler.providers.azure.lib.service.service.AzureIdentityAuthenticationProvider" + ) as mock_auth_provider_cls, + patch( + "prowler.providers.azure.lib.service.service.GraphClientFactory" + ) as mock_factory, + patch( + "prowler.providers.azure.lib.service.service.GraphRequestAdapter" + ) as mock_adapter_cls, + ): + service = AzureService.__new__(AzureService) + service.__set_clients__(identity, session, graph_service, region_config) + + mock_auth_provider_cls.assert_called_once_with(session, scopes=[graph_scope]) + mock_factory.create_with_default_middleware.assert_called_once_with( + host=graph_host + ) + mock_adapter_cls.assert_called_once_with( + mock_auth_provider_cls.return_value, + client=mock_factory.create_with_default_middleware.return_value, + ) + graph_service.assert_called_once_with( + request_adapter=mock_adapter_cls.return_value + ) + + @pytest.mark.parametrize( + "_region,_graph_host,_graph_scope,logs_endpoint", + REGION_CASES, + ) + def test_set_clients_logs_passes_per_cloud_endpoint( + self, _region, _graph_host, _graph_scope, logs_endpoint + ): + logs_service = MagicMock() + logs_service.__str__ = MagicMock(return_value="LogsQueryClient") + region_config = AzureRegionConfig( + graph_host=_graph_host, + graph_scope=_graph_scope, + logs_endpoint=logs_endpoint, + ) + identity, session = _identity_and_session() + + with patch.object(AzureService, "__init__", return_value=None): + service = AzureService.__new__(AzureService) + + service.__set_clients__(identity, session, logs_service, region_config) + + logs_service.assert_called_once_with(credential=session, endpoint=logs_endpoint) diff --git a/tests/providers/azure/services/aisearch/aisearch_service_public_access_level_is_disabled/aisearch_service_public_access_level_is_disabled_test.py b/tests/providers/azure/services/aisearch/aisearch_service_public_access_level_is_disabled/aisearch_service_public_access_level_is_disabled_test.py index 7e35a2ca36..6f3aebcc00 100644 --- a/tests/providers/azure/services/aisearch/aisearch_service_public_access_level_is_disabled/aisearch_service_public_access_level_is_disabled_test.py +++ b/tests/providers/azure/services/aisearch/aisearch_service_public_access_level_is_disabled/aisearch_service_public_access_level_is_disabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.aisearch.aisearch_service import AISearchService from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_AISearch_service_not_publicly_accessible: def test_aisearch_sevice_no_aisearch_services(self): aisearch_client = mock.MagicMock + aisearch_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aisearch_client.aisearch_services = {} with ( @@ -35,6 +38,7 @@ class Test_AISearch_service_not_publicly_accessible: aisearch_service_id = str(uuid4()) aisearch_service_name = "Test AISearch Service" aisearch_client = mock.MagicMock + aisearch_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aisearch_client.aisearch_services = { AZURE_SUBSCRIPTION_ID: { aisearch_service_id: AISearchService( @@ -66,7 +70,7 @@ class Test_AISearch_service_not_publicly_accessible: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"AISearch Service {aisearch_service_name} from subscription {AZURE_SUBSCRIPTION_ID} allows public access." + == f"AISearch Service {aisearch_service_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows public access." ) assert result[0].resource_id == aisearch_service_id assert result[0].subscription == AZURE_SUBSCRIPTION_ID @@ -77,6 +81,7 @@ class Test_AISearch_service_not_publicly_accessible: aisearch_service_id = str(uuid4()) aisearch_service_name = "Test Search Service" aisearch_client = mock.MagicMock + aisearch_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aisearch_client.aisearch_services = { AZURE_SUBSCRIPTION_ID: { aisearch_service_id: AISearchService( @@ -108,7 +113,7 @@ class Test_AISearch_service_not_publicly_accessible: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"AISearch Service {aisearch_service_name} from subscription {AZURE_SUBSCRIPTION_ID} does not allows public access." + == f"AISearch Service {aisearch_service_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not allows public access." ) assert result[0].resource_id == aisearch_service_id assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py new file mode 100644 index 0000000000..b7c2ac3bd9 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +def build_cluster(auto_upgrade_channel): + return Cluster( + id="/sub/rg/cluster1", + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + auto_upgrade_channel=auto_upgrade_channel, + ) + + +class Test_aks_cluster_auto_upgrade_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + aks_client.clusters = {} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + cluster = build_cluster(auto_upgrade_channel="stable") + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + @pytest.mark.parametrize("auto_upgrade_channel", [None, "", "none", "None"]) + def test_fail(self, auto_upgrade_channel): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + cluster = build_cluster(auto_upgrade_channel=auto_upgrade_channel) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py new file mode 100644 index 0000000000..a209a50199 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py @@ -0,0 +1,121 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_azure_monitor_enabled." + "aks_cluster_azure_monitor_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_azure_monitor_enabled + + +def build_cluster(azure_monitor_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + azure_monitor_enabled=azure_monitor_enabled, + ) + + +class Test_aks_cluster_azure_monitor_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + cluster = build_cluster(azure_monitor_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Azure Monitor managed Prometheus metrics enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("azure_monitor_enabled", [False, None]) + def test_fail(self, azure_monitor_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + cluster = build_cluster(azure_monitor_enabled=azure_monitor_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Azure Monitor managed Prometheus metrics enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py new file mode 100644 index 0000000000..0575b67a4a --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py @@ -0,0 +1,122 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_defender_enabled." + "aks_cluster_defender_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_defender_enabled + + +def build_cluster(defender_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + defender_enabled=defender_enabled, + ) + + +class Test_aks_cluster_defender_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Defender for Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("defender_enabled", [False, None, "true"]) + def test_fail(self, defender_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=defender_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Defender for " + "Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py b/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py new file mode 100644 index 0000000000..a4c63dc172 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py @@ -0,0 +1,121 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_local_accounts_disabled." + "aks_cluster_local_accounts_disabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_local_accounts_disabled + + +def build_cluster(local_accounts_disabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + local_accounts_disabled=local_accounts_disabled, + ) + + +class Test_aks_cluster_local_accounts_disabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + cluster = build_cluster(local_accounts_disabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has local accounts disabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("local_accounts_disabled", [False, None]) + def test_fail(self, local_accounts_disabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + cluster = build_cluster(local_accounts_disabled=local_accounts_disabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has local accounts enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled_test.py index a4706f013a..5cbe7d67b5 100644 --- a/tests/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled_test.py +++ b/tests/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.aks.aks_service import Cluster from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_aks_cluster_rbac_enabled: def test_aks_no_subscriptions(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {} with ( @@ -33,6 +36,7 @@ class Test_aks_cluster_rbac_enabled: def test_aks_subscription_empty(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_aks_cluster_rbac_enabled: def test_aks_cluster_rbac_enabled(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -91,7 +96,7 @@ class Test_aks_cluster_rbac_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"RBAC is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"RBAC is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "cluster_name" assert result[0].resource_id == cluster_id @@ -100,6 +105,7 @@ class Test_aks_cluster_rbac_enabled: def test_aks_rbac_not_enabled(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -136,7 +142,7 @@ class Test_aks_cluster_rbac_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"RBAC is not enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"RBAC is not enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "cluster_name" assert result[0].resource_id == cluster_id diff --git a/tests/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes_test.py b/tests/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes_test.py index 74ee1e7206..bf5a32660d 100644 --- a/tests/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes_test.py +++ b/tests/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.aks.aks_service import Cluster from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_aks_clusters_created_with_private_nodes: def test_aks_no_subscriptions(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {} with ( @@ -33,6 +36,7 @@ class Test_aks_clusters_created_with_private_nodes: def test_aks_subscription_empty(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_aks_clusters_created_with_private_nodes: def test_aks_cluster_no_private_nodes(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -91,7 +96,7 @@ class Test_aks_clusters_created_with_private_nodes: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Cluster 'cluster_name' was not created with private nodes in subscription '{AZURE_SUBSCRIPTION_ID}'" + == f"Cluster 'cluster_name' was not created with private nodes in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'" ) assert result[0].resource_id == cluster_id assert result[0].resource_name == "cluster_name" @@ -100,6 +105,7 @@ class Test_aks_clusters_created_with_private_nodes: def test_aks_cluster_private_nodes(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -136,7 +142,7 @@ class Test_aks_clusters_created_with_private_nodes: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Cluster 'cluster_name' was created with private nodes in subscription '{AZURE_SUBSCRIPTION_ID}'" + == f"Cluster 'cluster_name' was created with private nodes in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'" ) assert result[0].resource_id == cluster_id assert result[0].resource_name == "cluster_name" @@ -145,6 +151,7 @@ class Test_aks_clusters_created_with_private_nodes: def test_aks_cluster_public_and_private_nodes(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -185,7 +192,7 @@ class Test_aks_clusters_created_with_private_nodes: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Cluster 'cluster_name' was not created with private nodes in subscription '{AZURE_SUBSCRIPTION_ID}'" + == f"Cluster 'cluster_name' was not created with private nodes in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'" ) assert result[0].resource_id == cluster_id assert result[0].resource_name == "cluster_name" diff --git a/tests/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled_test.py b/tests/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled_test.py index 6d012c39e5..dfb000a096 100644 --- a/tests/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled_test.py +++ b/tests/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.aks.aks_service import Cluster from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_aks_clusters_public_access_disabled: def test_aks_no_subscriptions(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {} with ( @@ -33,6 +36,7 @@ class Test_aks_clusters_public_access_disabled: def test_aks_subscription_empty(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_aks_clusters_public_access_disabled: def test_aks_cluster_public_fqdn(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -91,7 +96,7 @@ class Test_aks_clusters_public_access_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Public access to nodes is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_ID}'" + == f"Public access to nodes is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'" ) assert result[0].resource_id == cluster_id assert result[0].resource_name == "cluster_name" @@ -100,6 +105,7 @@ class Test_aks_clusters_public_access_disabled: def test_aks_cluster_private_fqdn(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -136,7 +142,7 @@ class Test_aks_clusters_public_access_disabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Public access to nodes is disabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_ID}'" + == f"Public access to nodes is disabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'" ) assert result[0].resource_id == cluster_id assert result[0].resource_name == "cluster_name" @@ -145,6 +151,7 @@ class Test_aks_clusters_public_access_disabled: def test_aks_cluster_private_fqdn_with_public_ip(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -181,7 +188,7 @@ class Test_aks_clusters_public_access_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Public access to nodes is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_ID}'" + == f"Public access to nodes is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'" ) assert result[0].resource_id == cluster_id assert result[0].resource_name == "cluster_name" diff --git a/tests/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled_test.py b/tests/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled_test.py index a172e94151..896fa44405 100644 --- a/tests/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled_test.py +++ b/tests/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.aks.aks_service import Cluster from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_aks_network_policy_enabled: def test_aks_no_subscriptions(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {} with ( @@ -33,6 +36,7 @@ class Test_aks_network_policy_enabled: def test_aks_subscription_empty(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_aks_network_policy_enabled: def test_aks_network_policy_enabled(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -91,7 +96,7 @@ class Test_aks_network_policy_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Network policy is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Network policy is enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "cluster_name" assert result[0].resource_id == cluster_id @@ -100,6 +105,7 @@ class Test_aks_network_policy_enabled: def test_aks_network_policy_disabled(self): aks_client = mock.MagicMock + aks_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cluster_id = str(uuid4()) aks_client.clusters = { AZURE_SUBSCRIPTION_ID: { @@ -136,7 +142,7 @@ class Test_aks_network_policy_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Network policy is not enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Network policy is not enabled for cluster 'cluster_name' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "cluster_name" assert result[0].resource_id == cluster_id diff --git a/tests/providers/azure/services/apim/apim_service_test.py b/tests/providers/azure/services/apim/apim_service_test.py index 3b1a061293..f2141aee6b 100644 --- a/tests/providers/azure/services/apim/apim_service_test.py +++ b/tests/providers/azure/services/apim/apim_service_test.py @@ -8,6 +8,7 @@ from azure.monitor.query import LogsQueryResult from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -193,6 +194,9 @@ class Test_APIM_Service(TestCase): # Properly mock the nested client structure mock_client = mock.MagicMock() + mock_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } mock_workspaces = mock.MagicMock() mock_workspaces.get.return_value = mock_workspace mock_client.workspaces = mock_workspaces @@ -246,6 +250,9 @@ class Test_APIM_Service(TestCase): # Properly mock the nested client structure mock_client = mock.MagicMock() + mock_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } mock_client.query_workspace.return_value = mock_response mock_logsquery_client.clients = {AZURE_SUBSCRIPTION_ID: mock_client} diff --git a/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py index a551b33c7e..997c2392fa 100644 --- a/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py +++ b/tests/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking_test.py @@ -2,7 +2,9 @@ from datetime import datetime from unittest import mock from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -139,10 +141,18 @@ def mock_get_llm_operations_logs_no_workspace(subscription, instance, minutes): return [] +def mock_get_llm_operations_logs_by_subscription(subscription, instance, minutes): + """Return different logs per subscription to validate isolation.""" + if subscription == AZURE_SUBSCRIPTION_ID: + return mock_get_llm_operations_logs_attacker(subscription, instance, minutes) + return mock_get_llm_operations_logs_2_operations(subscription, instance, minutes) + + class Test_apim_threat_detection_llm_jacking: def test_no_apim_instances(self): """Test when there are no APIM instances""" apim_client = mock.MagicMock() + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.instances = {} apim_client.audit_config = { "apim_threat_detection_llm_jacking_threshold": 0.1, @@ -175,6 +185,7 @@ class Test_apim_threat_detection_llm_jacking: def test_no_potential_llm_jacking(self): """Test when no potential LLM jacking is detected""" apim_client = mock.MagicMock() + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.instances = { AZURE_SUBSCRIPTION_ID: [ mock.MagicMock( @@ -184,7 +195,7 @@ class Test_apim_threat_detection_llm_jacking: ) ] } - apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.audit_config = { "apim_threat_detection_llm_jacking_threshold": 0.9, "apim_threat_detection_llm_jacking_minutes": 1440, @@ -240,6 +251,7 @@ class Test_apim_threat_detection_llm_jacking: def test_potential_llm_jacking_detected(self): """Test when potential LLM jacking is detected""" apim_client = mock.MagicMock() + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.instances = { AZURE_SUBSCRIPTION_ID: [ mock.MagicMock( @@ -286,6 +298,7 @@ class Test_apim_threat_detection_llm_jacking: "Potential LLM Jacking attack detected from IP address 10.0.0.50" in result[0].status_extended ) + assert AZURE_SUBSCRIPTION_DISPLAY in result[0].status_extended assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource["name"] == "10.0.0.50" assert result[0].resource["id"] == "10.0.0.50" @@ -293,6 +306,7 @@ class Test_apim_threat_detection_llm_jacking: def test_higher_threshold_no_detection(self): """Test when threshold is higher and no attack is detected""" apim_client = mock.MagicMock() + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.instances = { AZURE_SUBSCRIPTION_ID: [ mock.MagicMock( @@ -302,7 +316,7 @@ class Test_apim_threat_detection_llm_jacking: ) ] } - apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.audit_config = { "apim_threat_detection_llm_jacking_threshold": 0.9, "apim_threat_detection_llm_jacking_minutes": 1440, @@ -367,7 +381,7 @@ class Test_apim_threat_detection_llm_jacking: ) ] } - apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.audit_config = { "apim_threat_detection_llm_jacking_threshold": 0.9, "apim_threat_detection_llm_jacking_minutes": 1440, @@ -423,6 +437,7 @@ class Test_apim_threat_detection_llm_jacking: def test_multiple_subscriptions(self): """Test with multiple subscriptions""" apim_client = mock.MagicMock() + apim_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} apim_client.instances = { AZURE_SUBSCRIPTION_ID: [ mock.MagicMock( @@ -440,7 +455,7 @@ class Test_apim_threat_detection_llm_jacking: ], } apim_client.subscriptions = { - AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME, "another-subscription": "another-subscription-id", } apim_client.audit_config = { @@ -496,3 +511,90 @@ class Test_apim_threat_detection_llm_jacking: "No potential LLM Jacking attacks detected" in report.status_extended ) + + def test_multiple_subscriptions_keep_findings_isolated(self): + """Ensure findings from one subscription do not leak into another.""" + apim_client = mock.MagicMock() + second_subscription_id = "another-subscription" + second_subscription_name = "another-subscription-id" + apim_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME, + second_subscription_id: second_subscription_name, + } + apim_client.instances = { + AZURE_SUBSCRIPTION_ID: [ + mock.MagicMock( + id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/test-apim", + name="test-apim", + log_analytics_workspace_id="/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/test-workspace", + ) + ], + second_subscription_id: [ + mock.MagicMock( + id="/subscriptions/another-sub/resourceGroups/test-rg/providers/Microsoft.ApiManagement/service/another-apim", + name="another-apim", + log_analytics_workspace_id="/subscriptions/another-sub/resourceGroups/test-rg/providers/Microsoft.OperationalInsights/workspaces/another-workspace", + ) + ], + } + apim_client.audit_config = { + "apim_threat_detection_llm_jacking_threshold": 0.2, + "apim_threat_detection_llm_jacking_minutes": 1440, + "apim_threat_detection_llm_jacking_actions": [ + "ChatCompletions_Create", + "ImageGenerations_Create", + "Completions_Create", + "Embeddings_Create", + "FineTuning_Jobs_Create", + "Models_List", + "Deployments_List", + "Deployments_Get", + "Deployments_Create", + "Deployments_Delete", + "Messages_Create", + "Claude_Create", + "GenerateContent", + "GenerateText", + "GenerateImage", + "Llama_Create", + "CodeLlama_Create", + "Gemini_Generate", + "Claude_Generate", + "Llama_Generate", + ], + } + apim_client.get_llm_operations_logs = ( + mock_get_llm_operations_logs_by_subscription + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking.apim_client", + new=apim_client, + ), + ): + from prowler.providers.azure.services.apim.apim_threat_detection_llm_jacking.apim_threat_detection_llm_jacking import ( + apim_threat_detection_llm_jacking, + ) + + check = apim_threat_detection_llm_jacking() + result = check.execute() + + assert len(result) == 2 + + report_by_subscription = {report.subscription: report for report in result} + + assert report_by_subscription[AZURE_SUBSCRIPTION_ID].status == "FAIL" + assert ( + AZURE_SUBSCRIPTION_DISPLAY + in report_by_subscription[AZURE_SUBSCRIPTION_ID].status_extended + ) + assert report_by_subscription[second_subscription_id].status == "PASS" + assert ( + f"{second_subscription_name} ({second_subscription_id})" + in report_by_subscription[second_subscription_id].status_extended + ) diff --git a/tests/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on_test.py b/tests/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on_test.py index 581b3be994..b140223890 100644 --- a/tests/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on_test.py +++ b/tests/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_client_certificates_on: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_client_certificates_on: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_client_certificates_on: def test_app_client_certificates_on(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -91,7 +96,7 @@ class Test_app_client_certificates_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Clients are required to present a certificate for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Clients are required to present a certificate for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -101,6 +106,7 @@ class Test_app_client_certificates_on: def test_app_client_certificates_off(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -137,7 +143,7 @@ class Test_app_client_certificates_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Clients are not required to present a certificate for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Clients are not required to present a certificate for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" diff --git a/tests/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up_test.py b/tests/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up_test.py index 79bf195d80..b8f0d12ed2 100644 --- a/tests/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up_test.py +++ b/tests/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_ensure_auth_is_set_up: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_ensure_auth_is_set_up: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_ensure_auth_is_set_up: def test_app_auth_enabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -91,7 +96,7 @@ class Test_app_ensure_auth_is_set_up: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Authentication is set up for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Authentication is set up for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "app_id-1" assert result[0].resource_id == resource_id @@ -101,6 +106,7 @@ class Test_app_ensure_auth_is_set_up: def test_app_auth_disabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -137,7 +143,7 @@ class Test_app_ensure_auth_is_set_up: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Authentication is not set up for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Authentication is not set up for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "app_id-1" assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https_test.py b/tests/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https_test.py index 08743bbd79..4d959f3e3f 100644 --- a/tests/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https_test.py +++ b/tests/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_ensure_http_is_redirected_to_https: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_ensure_http_is_redirected_to_https: def test_app_subscriptions_empty_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_ensure_http_is_redirected_to_https: def test_app_http_to_https(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -91,7 +96,7 @@ class Test_app_ensure_http_is_redirected_to_https: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"HTTP is not redirected to HTTPS for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"HTTP is not redirected to HTTPS for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "app_id-1" assert result[0].resource_id == resource_id @@ -101,6 +106,7 @@ class Test_app_ensure_http_is_redirected_to_https: def test_app_http_to_https_enabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -137,7 +143,7 @@ class Test_app_ensure_http_is_redirected_to_https: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"HTTP is redirected to HTTPS for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"HTTP is redirected to HTTPS for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_name == "app_id-1" assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py b/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py index 22a9d3771a..2e0d847b97 100644 --- a/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py +++ b/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_ensure_java_version_is_latest: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_ensure_java_version_is_latest: def test_app_subscriptions_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_ensure_java_version_is_latest: def test_app_configurations_none(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -92,6 +97,7 @@ class Test_app_ensure_java_version_is_latest: def test_app_linux_java_version_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"java_latest_version": "17"} @@ -132,7 +138,7 @@ class Test_app_ensure_java_version_is_latest: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Java version is set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Java version is set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -142,6 +148,7 @@ class Test_app_ensure_java_version_is_latest: def test_app_linux_java_version_not_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"java_latest_version": "17"} @@ -182,7 +189,7 @@ class Test_app_ensure_java_version_is_latest: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Java version is set to 'Tomcat|9.0-java11', but should be set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Java version is set to 'Tomcat|9.0-java11', but should be set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -192,6 +199,7 @@ class Test_app_ensure_java_version_is_latest: def test_app_windows_java_version_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"java_latest_version": "17"} @@ -232,7 +240,7 @@ class Test_app_ensure_java_version_is_latest: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Java version is set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Java version is set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -242,6 +250,7 @@ class Test_app_ensure_java_version_is_latest: def test_app_windows_java_version_not_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"java_latest_version": "17"} with ( @@ -281,7 +290,7 @@ class Test_app_ensure_java_version_is_latest: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Java version is set to 'java11', but should be set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Java version is set to 'java11', but should be set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -291,6 +300,7 @@ class Test_app_ensure_java_version_is_latest: def test_app_linux_php_version_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"java_latest_version": "17"} diff --git a/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py b/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py index 3db89439a3..2e192501ef 100644 --- a/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py +++ b/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_ensure_php_version_is_latest: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_ensure_php_version_is_latest: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_ensure_php_version_is_latest: def test_app_configurations_none(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -92,6 +97,7 @@ class Test_app_ensure_php_version_is_latest: def test_app_php_version_not_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"php_latest_version": "8.2"} @@ -130,7 +136,7 @@ class Test_app_ensure_php_version_is_latest: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"PHP version is set to 'php|8.0', the latest version that you could use is the '8.2' version, for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"PHP version is set to 'php|8.0', the latest version that you could use is the '8.2' version, for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -140,6 +146,7 @@ class Test_app_ensure_php_version_is_latest: def test_app_php_version_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"php_latest_version": "8.2"} @@ -178,7 +185,7 @@ class Test_app_ensure_php_version_is_latest: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"PHP version is set to '8.2' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"PHP version is set to '8.2' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" diff --git a/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py b/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py index 899d3615b5..7f2eaf6693 100644 --- a/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py +++ b/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_ensure_python_version_is_latest: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_ensure_python_version_is_latest: def test_app_subscriptions_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_ensure_python_version_is_latest: def test_app_configurations_none(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -91,6 +96,7 @@ class Test_app_ensure_python_version_is_latest: def test_app_python_version_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"python_latest_version": "3.12"} @@ -129,7 +135,7 @@ class Test_app_ensure_python_version_is_latest: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Python version is set to '3.12' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Python version is set to '3.12' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -139,6 +145,7 @@ class Test_app_ensure_python_version_is_latest: def test_app_python_version_not_latest(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.audit_config = {"python_latest_version": "3.12"} @@ -177,7 +184,7 @@ class Test_app_ensure_python_version_is_latest: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Python version is 'python|3.10', the latest version that you could use is the '3.12' version, for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Python version is 'python|3.10', the latest version that you could use is the '3.12' version, for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" diff --git a/tests/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20_test.py b/tests/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20_test.py index d392d4d3b0..105a7b9a03 100644 --- a/tests/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20_test.py +++ b/tests/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_ensure_using_http20: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_ensure_using_http20: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_ensure_using_http20: def test_app_configurations_none(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -91,7 +96,7 @@ class Test_app_ensure_using_http20: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"HTTP/2.0 is not enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"HTTP/2.0 is not enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -101,6 +106,7 @@ class Test_app_ensure_using_http20: def test_app_http20_enabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -137,7 +143,7 @@ class Test_app_ensure_using_http20: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"HTTP/2.0 is enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"HTTP/2.0 is enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -147,6 +153,7 @@ class Test_app_ensure_using_http20: def test_app_http20_not_enabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -183,7 +190,7 @@ class Test_app_ensure_using_http20: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"HTTP/2.0 is not enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"HTTP/2.0 is not enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" diff --git a/tests/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled_test.py b/tests/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled_test.py index 6d0633fef3..fc0d61b9cc 100644 --- a/tests/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled_test.py +++ b/tests/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_ftp_deployment_disabled: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_ftp_deployment_disabled: def test_app_subscriptions_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_ftp_deployment_disabled: def test_app_configurations_none(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -91,7 +96,7 @@ class Test_app_ftp_deployment_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"FTP is enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"FTP is enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -101,6 +106,7 @@ class Test_app_ftp_deployment_disabled: def test_app_ftp_deployment_disabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -137,7 +143,7 @@ class Test_app_ftp_deployment_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"FTP is enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"FTP is enabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -147,6 +153,7 @@ class Test_app_ftp_deployment_disabled: def test_app_ftp_deploy_enabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -183,7 +190,7 @@ class Test_app_ftp_deployment_disabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"FTP is disabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"FTP is disabled for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" diff --git a/tests/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured_test.py b/tests/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured_test.py index 42d8360ee1..770ca07b2b 100644 --- a/tests/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured_test.py +++ b/tests/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_access_keys_configured: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -33,6 +36,7 @@ class Test_app_function_access_keys_configured: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.functions = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_function_access_keys_configured: def test_app_function_no_keys(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.functions = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -98,7 +103,7 @@ class Test_app_function_access_keys_configured: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 does not have function keys configured." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have function keys configured." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" @@ -107,6 +112,7 @@ class Test_app_function_access_keys_configured: def test_app_function_using_functions_keys(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.functions = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -153,7 +159,7 @@ class Test_app_function_access_keys_configured: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 has function keys configured." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has function keys configured." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" diff --git a/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py b/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py index 4ea444157c..4a55b1e108 100644 --- a/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py +++ b/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_application_insights_enabled: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -33,6 +36,7 @@ class Test_app_function_application_insights_enabled: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -56,6 +60,7 @@ class Test_app_function_application_insights_enabled: def test_app_function_no_app_insights(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -97,7 +102,7 @@ class Test_app_function_application_insights_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 is not using Application Insights." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using Application Insights." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" @@ -106,6 +111,7 @@ class Test_app_function_application_insights_enabled: def test_app_function_using_app_insights(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -147,7 +153,7 @@ class Test_app_function_application_insights_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 is using Application Insights." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using Application Insights." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" @@ -156,6 +162,7 @@ class Test_app_function_application_insights_enabled: def test_app_function_using_app_insights_different_key(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -197,7 +204,7 @@ class Test_app_function_application_insights_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 is using Application Insights." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using Application Insights." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" @@ -206,6 +213,7 @@ class Test_app_function_application_insights_enabled: def test_app_function_with_app_insights_no_key(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -247,7 +255,7 @@ class Test_app_function_application_insights_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 is not using Application Insights." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using Application Insights." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" diff --git a/tests/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled_test.py b/tests/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled_test.py index 2546ba2f44..b08c712da1 100644 --- a/tests/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled_test.py +++ b/tests/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_ftps_deployment_disabled: def test_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -33,6 +36,7 @@ class Test_app_function_ftps_deployment_disabled: def test_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -56,6 +60,7 @@ class Test_app_function_ftps_deployment_disabled: def test_function_ftp_deployment_enabled(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -97,7 +102,7 @@ class Test_app_function_ftps_deployment_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 has FTP deployment enabled" + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has FTP deployment enabled." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id @@ -106,6 +111,7 @@ class Test_app_function_ftps_deployment_disabled: def test_function_ftps_deployment_enabled(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -147,7 +153,7 @@ class Test_app_function_ftps_deployment_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 has FTPS deployment enabled" + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has FTPS deployment enabled." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id @@ -156,6 +162,7 @@ class Test_app_function_ftps_deployment_disabled: def test_function_ftp_and_ftps_deployment_disabled(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -197,7 +204,7 @@ class Test_app_function_ftps_deployment_disabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 has FTP and FTPS deployment disabled" + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has FTP and FTPS deployment disabled." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id diff --git a/tests/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured_test.py b/tests/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured_test.py index ffa4a0e743..84dbece6d2 100644 --- a/tests/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured_test.py +++ b/tests/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_identity_is_configured: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -33,6 +36,7 @@ class Test_app_function_identity_is_configured: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -56,6 +60,7 @@ class Test_app_function_identity_is_configured: def test_app_function_no_identity(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -97,7 +102,7 @@ class Test_app_function_identity_is_configured: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 does not have a managed identity enabled." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have a managed identity enabled." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id @@ -106,6 +111,7 @@ class Test_app_function_identity_is_configured: def test_app_function_identity_configured(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -147,7 +153,7 @@ class Test_app_function_identity_is_configured: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 has a SystemAssigned identity enabled." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a SystemAssigned identity enabled." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id diff --git a/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py b/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py index c4761099b9..9c7f074c3b 100644 --- a/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py +++ b/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.config import USER_ACCESS_ADMINISTRATOR_ROLE_ID from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_identity_without_admin_privileges: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -34,6 +37,7 @@ class Test_app_function_identity_without_admin_privileges: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -57,6 +61,7 @@ class Test_app_function_identity_without_admin_privileges: def test_app_function_no_identity(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -98,7 +103,9 @@ class Test_app_function_identity_without_admin_privileges: def test_app_function_no_admin_roles(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -175,7 +182,7 @@ class Test_app_function_identity_without_admin_privileges: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 has a managed identity enabled but without admin privileges." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a managed identity enabled but without admin privileges." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" @@ -184,7 +191,9 @@ class Test_app_function_identity_without_admin_privileges: def test_app_function_admin_roles(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -212,7 +221,7 @@ class Test_app_function_identity_without_admin_privileges: function_id = str(uuid4()) function_scope = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.Web/sites/function1" app_client.functions = { - "subscription-name-1": { + AZURE_SUBSCRIPTION_ID: { function_id: FunctionApp( id=function_id, name="function1", @@ -229,11 +238,11 @@ class Test_app_function_identity_without_admin_privileges: } iam_client.subscriptions = { - "subscription-name-1": AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME, } iam_client.role_assignments = { - "subscription-name-1": { + AZURE_SUBSCRIPTION_ID: { "role-assignment-id-2": RoleAssignment( id="role-assignment-id-2", name="role-assignment-name-2", @@ -246,7 +255,7 @@ class Test_app_function_identity_without_admin_privileges: } iam_client.roles = { - "subscription-name-1": { + AZURE_SUBSCRIPTION_ID: { f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{USER_ACCESS_ADMINISTRATOR_ROLE_ID}": Role( id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{USER_ACCESS_ADMINISTRATOR_ROLE_ID}", name="User Access Administrator", @@ -263,9 +272,9 @@ class Test_app_function_identity_without_admin_privileges: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 has a managed identity enabled and it is configure with admin privileges using role User Access Administrator." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a managed identity enabled and it is configure with admin privileges using role User Access Administrator." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" - assert result[0].subscription == "subscription-name-1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].location == "West Europe" diff --git a/tests/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version_test.py b/tests/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version_test.py index 533874141d..89bfc642b0 100644 --- a/tests/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version_test.py +++ b/tests/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_latest_runtime_version: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -33,6 +36,7 @@ class Test_app_function_latest_runtime_version: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -56,6 +60,7 @@ class Test_app_function_latest_runtime_version: def test_app_function_runtime_is_latest(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -96,7 +101,7 @@ class Test_app_function_latest_runtime_version: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - "Function function1 is using the latest runtime." + f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using the latest runtime." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" @@ -105,6 +110,7 @@ class Test_app_function_latest_runtime_version: def test_app_function_runtime_is_not_latest(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -145,7 +151,7 @@ class Test_app_function_latest_runtime_version: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == ( - "Function function1 is not using the latest runtime. The current runtime is '2' and should be '~4'." + f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using the latest runtime. The current runtime is '2' and should be '~4'." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" diff --git a/tests/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible_test.py b/tests/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible_test.py index 749a094e65..3c60aebc20 100644 --- a/tests/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible_test.py +++ b/tests/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_not_publicly_accessible: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -33,6 +36,7 @@ class Test_app_function_not_publicly_accessible: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -56,6 +60,7 @@ class Test_app_function_not_publicly_accessible: def test_app_function_not_publicly_accessible(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -97,7 +102,7 @@ class Test_app_function_not_publicly_accessible: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 is not publicly accessible." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not publicly accessible." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id @@ -106,6 +111,7 @@ class Test_app_function_not_publicly_accessible: def test_app_function_publicly_accessible(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -147,7 +153,7 @@ class Test_app_function_not_publicly_accessible: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 is publicly accessible." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} is publicly accessible." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id diff --git a/tests/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled_test.py b/tests/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled_test.py index 33a99d0086..f12422f1da 100644 --- a/tests/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled_test.py +++ b/tests/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_function_vnet_integration_enabled: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -33,6 +36,7 @@ class Test_app_function_vnet_integration_enabled: def test_app_subscription_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -56,6 +60,7 @@ class Test_app_function_vnet_integration_enabled: def test_app_function_vnet_integration_enabled(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -97,14 +102,16 @@ class Test_app_function_vnet_integration_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 has Virtual Network integration enabled with subnet 'vnet_subnet_id' enabled." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Virtual Network integration enabled with subnet 'vnet_subnet_id' enabled." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id assert result[0].location == "West Europe" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID def test_app_function_vnet_integration_disabled(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -146,8 +153,9 @@ class Test_app_function_vnet_integration_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Function function1 does not have virtual network integration enabled." + == f"Function function1 from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have virtual network integration enabled." ) assert result[0].resource_name == "function1" assert result[0].resource_id == function_id assert result[0].location == "West Europe" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled_test.py b/tests/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled_test.py index 7f24fe6284..6a8a0a1d83 100644 --- a/tests/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled_test.py +++ b/tests/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled_test.py @@ -1,7 +1,9 @@ from unittest import mock from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ class Test_app_http_logs_enabled: def test_app_http_logs_enabled_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -33,6 +36,7 @@ class Test_app_http_logs_enabled: def test_app_subscriptions_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_http_logs_enabled: def test_no_diagnostics_settings(self): app_client = mock.MagicMock() + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -93,12 +98,13 @@ class Test_app_http_logs_enabled: assert result[0].resource_id == "resource_id" assert ( result[0].status_extended - == f"App app1 does not have a diagnostic setting in subscription {AZURE_SUBSCRIPTION_ID}." + == f"App app1 does not have a diagnostic setting in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID def test_diagnostic_setting_configured(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -233,11 +239,12 @@ class Test_app_http_logs_enabled: assert result[0].resource_id == "resource_id2" assert ( result[0].status_extended - == f"App app_id-2 has HTTP Logs enabled in diagnostic setting name_diagnostic_setting2 in subscription {AZURE_SUBSCRIPTION_ID}" + == f"App app_id-2 has HTTP Logs enabled in diagnostic setting name_diagnostic_setting2 in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) def test_diagnostic_setting_with_all_logs_category_group(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -296,11 +303,12 @@ class Test_app_http_logs_enabled: assert result[0].resource_id == "resource_id3" assert ( result[0].status_extended - == f"App app_id-3 has allLogs category group which includes HTTP Logs enabled in diagnostic setting name_diagnostic_setting3 in subscription {AZURE_SUBSCRIPTION_ID}" + == f"App app_id-3 has allLogs category group which includes HTTP Logs enabled in diagnostic setting name_diagnostic_setting3 in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) def test_diagnostic_setting_with_all_logs_category_group_disabled(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -359,5 +367,5 @@ class Test_app_http_logs_enabled: assert result[0].resource_id == "resource_id4" assert ( result[0].status_extended - == f"App app_id-4 does not have HTTP Logs enabled in diagnostic setting name_diagnostic_setting4 in subscription {AZURE_SUBSCRIPTION_ID}" + == f"App app_id-4 does not have HTTP Logs enabled in diagnostic setting name_diagnostic_setting4 in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) diff --git a/tests/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12_test.py b/tests/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12_test.py index a3055c855a..41dc73d30d 100644 --- a/tests/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12_test.py +++ b/tests/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_minimum_tls_version_12: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_minimum_tls_version_12: def test_app_subscriptions_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_minimum_tls_version_12: def test_app_none_configurations(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -91,7 +96,7 @@ class Test_app_minimum_tls_version_12: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Minimum TLS version is not set to 1.2 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Minimum TLS version is not set to 1.2 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -101,6 +106,7 @@ class Test_app_minimum_tls_version_12: def test_app_min_tls_version_12(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -137,7 +143,7 @@ class Test_app_minimum_tls_version_12: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Minimum TLS version is set to 1.2 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Minimum TLS version is set to 1.2 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -147,6 +153,7 @@ class Test_app_minimum_tls_version_12: def test_app_min_tls_version_10(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -183,7 +190,7 @@ class Test_app_minimum_tls_version_12: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Minimum TLS version is not set to 1.2 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Minimum TLS version is not set to 1.2 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -193,6 +200,7 @@ class Test_app_minimum_tls_version_12: def test_app_min_tls_version_13(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -229,7 +237,7 @@ class Test_app_minimum_tls_version_12: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Minimum TLS version is set to 1.3 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Minimum TLS version is set to 1.3 for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" diff --git a/tests/providers/azure/services/app/app_register_with_identity/app_register_with_identity_test.py b/tests/providers/azure/services/app/app_register_with_identity/app_register_with_identity_test.py index 785439fe0e..218a0ce136 100644 --- a/tests/providers/azure/services/app/app_register_with_identity/app_register_with_identity_test.py +++ b/tests/providers/azure/services/app/app_register_with_identity/app_register_with_identity_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_app_register_with_identity: def test_app_no_subscriptions(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {} with ( @@ -32,6 +35,7 @@ class Test_app_register_with_identity: def test_app_subscriptions_empty(self): app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} app_client.apps = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_app_register_with_identity: def test_app_none_configurations(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -91,7 +96,7 @@ class Test_app_register_with_identity: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"App 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}' does not have an identity configured." + == f"App 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' does not have an identity configured." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" @@ -101,6 +106,7 @@ class Test_app_register_with_identity: def test_app_identity(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock + app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -137,7 +143,7 @@ class Test_app_register_with_identity: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"App 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_ID}' has an identity configured." + == f"App 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' has an identity configured." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "app_id-1" diff --git a/tests/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured_test.py b/tests/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured_test.py index 4982a718cc..513414ce57 100644 --- a/tests/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured_test.py +++ b/tests/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured_test.py @@ -2,7 +2,9 @@ from unittest import mock from prowler.providers.azure.services.appinsights.appinsights_service import Component from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_appinsights_ensure_is_configured: def test_appinsights_no_subscriptions(self): appinsights_client = mock.MagicMock + appinsights_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } appinsights_client.components = {} with ( @@ -34,7 +39,7 @@ class Test_appinsights_ensure_is_configured: appinsights_client = mock.MagicMock appinsights_client.components = {AZURE_SUBSCRIPTION_ID: {}} appinsights_client.subscriptions = { - AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME } with ( @@ -60,7 +65,7 @@ class Test_appinsights_ensure_is_configured: assert result[0].resource_name == AZURE_SUBSCRIPTION_ID assert ( result[0].status_extended - == f"There are no AppInsight configured in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There are no AppInsight configured in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_appinsights_configured(self): @@ -76,7 +81,7 @@ class Test_appinsights_ensure_is_configured: } } appinsights_client.subscriptions = { - AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME } with ( @@ -103,5 +108,5 @@ class Test_appinsights_ensure_is_configured: assert result[0].location == "global" assert ( result[0].status_extended - == f"There is at least one AppInsight configured in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is at least one AppInsight configured in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled_test.py b/tests/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled_test.py index a2295b6d06..62cc55b1d3 100644 --- a/tests/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled_test.py +++ b/tests/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled_test.py @@ -3,7 +3,9 @@ from unittest.mock import MagicMock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class TestContainerRegistryAdminUserDisabled: def test_no_container_registries(self): containerregistry_client = MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } containerregistry_client.registries = {} with ( @@ -33,6 +38,9 @@ class TestContainerRegistryAdminUserDisabled: def test_container_registry_admin_user_enabled(self): containerregistry_client = MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } registry_id = str(uuid4()) with ( @@ -76,7 +84,7 @@ class TestContainerRegistryAdminUserDisabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_ID} has its admin user enabled." + == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_DISPLAY} has its admin user enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "mock_registry" @@ -90,6 +98,9 @@ class TestContainerRegistryAdminUserDisabled: def test_container_registry_admin_user_disabled(self): containerregistry_client = mock.MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } containerregistry_client.registries = {} with ( @@ -135,7 +146,7 @@ class TestContainerRegistryAdminUserDisabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_ID} has its admin user disabled." + == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_DISPLAY} has its admin user disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "mock_registry" diff --git a/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py b/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py index 683552daca..40b6550382 100644 --- a/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py +++ b/tests/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible_test.py @@ -3,7 +3,9 @@ from unittest.mock import MagicMock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_containerregistry_not_publicly_accessible: def test_no_container_registries(self): containerregistry_client = MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } containerregistry_client.registries = {} with ( @@ -33,6 +38,9 @@ class Test_containerregistry_not_publicly_accessible: def test_container_registry_network_access_unrestricted(self): containerregistry_client = MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } registry_id = str(uuid4()) with ( @@ -93,7 +101,7 @@ class Test_containerregistry_not_publicly_accessible: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Container Registry {containerregistry_client.registries[AZURE_SUBSCRIPTION_ID][registry_id].name} from subscription {AZURE_SUBSCRIPTION_ID} allows unrestricted network access." + == f"Container Registry {containerregistry_client.registries[AZURE_SUBSCRIPTION_ID][registry_id].name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows unrestricted network access." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "mock_registry" @@ -107,6 +115,9 @@ class Test_containerregistry_not_publicly_accessible: def test_container_registry_network_access_restricted(self): containerregistry_client = mock.MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } containerregistry_client.registries = {} with ( @@ -168,7 +179,7 @@ class Test_containerregistry_not_publicly_accessible: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Container Registry {containerregistry_client.registries[AZURE_SUBSCRIPTION_ID][registry_id].name} from subscription {AZURE_SUBSCRIPTION_ID} does not allow unrestricted network access." + == f"Container Registry {containerregistry_client.registries[AZURE_SUBSCRIPTION_ID][registry_id].name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not allow unrestricted network access." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "mock_registry" diff --git a/tests/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link_test.py b/tests/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link_test.py index f8b9237a21..aa0afd1742 100644 --- a/tests/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link_test.py +++ b/tests/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link_test.py @@ -3,7 +3,9 @@ from unittest.mock import MagicMock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_containerregistry_uses_private_link: def test_no_container_registries(self): containerregistry_client = MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } containerregistry_client.registries = {} with ( @@ -33,6 +38,9 @@ class Test_containerregistry_uses_private_link: def test_container_registry_not_uses_private_link(self): containerregistry_client = MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } registry_id = str(uuid4()) with ( @@ -76,7 +84,7 @@ class Test_containerregistry_uses_private_link: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_ID} does not use a private link." + == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not use a private link." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "mock_registry" @@ -90,6 +98,9 @@ class Test_containerregistry_uses_private_link: def test_container_registry_uses_private_link(self): containerregistry_client = mock.MagicMock() + containerregistry_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } containerregistry_client.registries = {} with ( @@ -141,7 +152,7 @@ class Test_containerregistry_uses_private_link: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_ID} uses a private link." + == f"Container Registry mock_registry from subscription {AZURE_SUBSCRIPTION_DISPLAY} uses a private link." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "mock_registry" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py new file mode 100644 index 0000000000..8b4330d64c --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py @@ -0,0 +1,115 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_automatic_failover_enabled: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + enable_automatic_failover=True, + ) + ] + } + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + enable_automatic_failover=False, + ) + ] + } + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py new file mode 100644 index 0000000000..0cc282485b --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py @@ -0,0 +1,157 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_backup_policy_continuous: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type="Continuous", + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_periodic(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type="Periodic", + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_fail_no_backup_policy(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type=None, + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks_test.py index 7d7b793954..7f27f2ebf1 100644 --- a/tests/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks_test.py +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.cosmosdb.cosmosdb_service import Account from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_cosmosdb_account_firewall_use_selected_networks: def test_no_accounts(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cosmosdb_client.accounts = {} with ( @@ -33,6 +36,7 @@ class Test_cosmosdb_account_firewall_use_selected_networks: def test_accounts_no_virtual_network_filter_enabled(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} account_name = "Account Name" account_id = str(uuid4()) cosmosdb_client.accounts = { @@ -71,7 +75,7 @@ class Test_cosmosdb_account_firewall_use_selected_networks: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_ID} has firewall rules that allow access from all networks." + == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has firewall rules that allow access from all networks." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == account_name @@ -80,6 +84,7 @@ class Test_cosmosdb_account_firewall_use_selected_networks: def test_accounts_virtual_network_filter_enabled(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} account_name = "Account Name" account_id = str(uuid4()) cosmosdb_client.accounts = { @@ -118,7 +123,7 @@ class Test_cosmosdb_account_firewall_use_selected_networks: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_ID} has firewall rules that allow access only from selected networks." + == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has firewall rules that allow access only from selected networks." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == account_name diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py new file mode 100644 index 0000000000..b14c08f697 --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py @@ -0,0 +1,209 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_minimum_tls_version: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 0 + + def test_pass_tls12(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls12", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} enforces TLS 1.2 or higher." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_pass_tls13(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls13", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_tls11(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls11", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} does not enforce TLS 1.2 or higher." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_fail_no_tls_version(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version=None, + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py new file mode 100644 index 0000000000..60297d6138 --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py @@ -0,0 +1,210 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_public_network_access_disabled: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 0 + + def test_pass_disabled(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="Disabled", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} has public network access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_pass_secured_by_perimeter(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="SecuredByPerimeter", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_enabled(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="Enabled", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} does not have public network access " + f"disabled (current value: 'Enabled')." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_fail_no_public_network_access(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access=None, + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac_test.py index 8fcdf99fa9..5430469cfd 100644 --- a/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac_test.py +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.cosmosdb.cosmosdb_service import Account from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_cosmosdb_account_use_aad_and_rbac: def test_no_accounts(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cosmosdb_client.accounts = {} with ( @@ -33,6 +36,7 @@ class Test_cosmosdb_account_use_aad_and_rbac: def test_accounts_disable_local_auth_false(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} account_name = "Account Name" account_id = str(uuid4()) cosmosdb_client.accounts = { @@ -71,7 +75,7 @@ class Test_cosmosdb_account_use_aad_and_rbac: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_ID} is not using AAD and RBAC" + == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using AAD and RBAC" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == account_name @@ -80,6 +84,7 @@ class Test_cosmosdb_account_use_aad_and_rbac: def test_accounts_disable_local_auth_true(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} account_name = "Account Name" account_id = str(uuid4()) cosmosdb_client.accounts = { @@ -118,7 +123,7 @@ class Test_cosmosdb_account_use_aad_and_rbac: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_ID} is using AAD and RBAC" + == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using AAD and RBAC" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == account_name diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints_test.py index d3827dee0b..1c62ca289e 100644 --- a/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints_test.py +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints_test.py @@ -5,7 +5,9 @@ from azure.mgmt.cosmosdb.models import PrivateEndpointConnection from prowler.providers.azure.services.cosmosdb.cosmosdb_service import Account from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_cosmosdb_account_use_private_endpoints: def test_no_accounts(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} cosmosdb_client.accounts = {} with ( @@ -35,6 +38,7 @@ class Test_cosmosdb_account_use_private_endpoints: def test_accounts_no_private_endpoints_connections(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} account_name = "Account Name" account_id = str(uuid4()) cosmosdb_client.accounts = { @@ -73,7 +77,7 @@ class Test_cosmosdb_account_use_private_endpoints: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_ID} is not using private endpoints connections" + == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using private endpoints connections" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == account_name @@ -82,6 +86,7 @@ class Test_cosmosdb_account_use_private_endpoints: def test_accounts_private_endpoints_connections(self): cosmosdb_client = mock.MagicMock + cosmosdb_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} account_name = "Account Name" account_id = str(uuid4()) cosmosdb_client.accounts = { @@ -124,7 +129,7 @@ class Test_cosmosdb_account_use_private_endpoints: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_ID} is using private endpoints connections" + == f"CosmosDB account {account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using private endpoints connections" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == account_name diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py index 69651f2235..f663d81fe2 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -18,6 +18,8 @@ def mock_databricks_get_workspaces(_): id="test-workspace-id", name="test-workspace", location="eastus", + public_network_access="Disabled", + no_public_ip_enabled=True, custom_managed_vnet_id="test-vnet-id", managed_disk_encryption=ManagedDiskEncryption( key_name="test-key", @@ -53,6 +55,8 @@ class Test_Databricks_Service: assert workspace.id == "test-workspace-id" assert workspace.name == "test-workspace" assert workspace.location == "eastus" + assert workspace.public_network_access == "Disabled" + assert workspace.no_public_ip_enabled is True assert workspace.custom_managed_vnet_id == "test-vnet-id" assert ( workspace.managed_disk_encryption.__class__.__name__ diff --git a/tests/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled_test.py index 1e145d4f80..679aec0fff 100644 --- a/tests/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled_test.py +++ b/tests/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.databricks.databricks_service import ( ManagedDiskEncryption, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_databricks_workspace_cmk_encryption_enabled: def test_no_databricks_workspaces(self): databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } databricks_client.workspaces = {} with ( @@ -39,6 +44,9 @@ class Test_databricks_workspace_cmk_encryption_enabled: workspace_name = "test-workspace" databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } databricks_client.workspaces = { AZURE_SUBSCRIPTION_ID: { workspace_id: DatabricksWorkspace( @@ -71,7 +79,7 @@ class Test_databricks_workspace_cmk_encryption_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} does not have customer-managed key (CMK) encryption enabled." + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have customer-managed key (CMK) encryption enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == workspace_name @@ -86,6 +94,9 @@ class Test_databricks_workspace_cmk_encryption_enabled: key_vault_uri = "test-vault-uri" databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } databricks_client.workspaces = { AZURE_SUBSCRIPTION_ID: { workspace_id: DatabricksWorkspace( @@ -122,7 +133,7 @@ class Test_databricks_workspace_cmk_encryption_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} has customer-managed key (CMK) encryption enabled with key {key_vault_uri}/{key_name}/{key_version}." + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has customer-managed key (CMK) encryption enabled with key {key_vault_uri}/{key_name}/{key_version}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == workspace_name diff --git a/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py new file mode 100644 index 0000000000..a7903770c6 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py @@ -0,0 +1,174 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_no_public_ip_enabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_no_public_ip_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=False, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=True, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_not_reported(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py new file mode 100644 index 0000000000..7d39d38760 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py @@ -0,0 +1,170 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_public_network_access_disabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_public_network_access_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_public_network_access_not_set(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + + def test_databricks_workspace_public_network_access_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled_test.py index 912ee363ac..f8f9b7bd2c 100644 --- a/tests/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled_test.py +++ b/tests/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled_test.py @@ -5,7 +5,9 @@ from prowler.providers.azure.services.databricks.databricks_service import ( DatabricksWorkspace, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_databricks_workspace_vnet_injection_enabled: def test_databricks_no_workspaces(self): databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } databricks_client.workspaces = {} with ( @@ -37,6 +42,9 @@ class Test_databricks_workspace_vnet_injection_enabled: workspace_id = str(uuid4()) workspace_name = "test-workspace" databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } databricks_client.workspaces = { AZURE_SUBSCRIPTION_ID: { workspace_id: DatabricksWorkspace( @@ -68,7 +76,7 @@ class Test_databricks_workspace_vnet_injection_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} is not deployed in a customer-managed VNet (VNet Injection is not enabled)." + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} is not deployed in a customer-managed VNet (VNet Injection is not enabled)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == workspace_name @@ -80,6 +88,9 @@ class Test_databricks_workspace_vnet_injection_enabled: workspace_name = "test-workspace" vnet_id = "test-vnet-id" databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } databricks_client.workspaces = { AZURE_SUBSCRIPTION_ID: { workspace_id: DatabricksWorkspace( @@ -111,7 +122,7 @@ class Test_databricks_workspace_vnet_injection_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} is deployed in a customer-managed VNet ({vnet_id})." + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} is deployed in a customer-managed VNet ({vnet_id})." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == workspace_name diff --git a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py index 752c8a6641..75f3d5014a 100644 --- a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py +++ b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.defender.defender_service import ( SecurityContactConfiguration, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} with ( @@ -37,6 +40,7 @@ class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_no_additional_emails(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -74,7 +78,7 @@ class Test_defender_additional_email_configured_with_a_security_contact: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"There is not another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not another correct email configured for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -83,6 +87,7 @@ class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_additional_email_configured(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -120,7 +125,7 @@ class Test_defender_additional_email_configured_with_a_security_contact: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"There is another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is another correct email configured for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" diff --git a/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py b/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py index dd16c7571c..1e567ac153 100644 --- a/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py +++ b/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Assesment from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_assessments_vm_endpoint_protection_installed: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} with ( @@ -33,6 +36,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed: def test_defender_subscriptions_with_no_assessments(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed: def test_defender_subscriptions_with_healthy_assessments(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} resource_id = str(uuid4()) defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -86,13 +91,14 @@ class Test_defender_assessments_vm_endpoint_protection_installed: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Endpoint protection is set up in all VMs in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Endpoint protection is set up in all VMs in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].resource_name == "vm1" assert result[0].resource_id == resource_id def test_defender_subscriptions_with_unhealthy_assessments(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} resource_id = str(uuid4()) defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { @@ -124,7 +130,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Endpoint protection is not set up in all VMs in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Endpoint protection is not set up in all VMs in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].resource_name == "vm1" assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py index cd21783149..ebece2e029 100644 --- a/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py +++ b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.defender.defender_service import ( SecurityContactConfiguration, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_attack_path_notifications_properly_configured: def test_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} defender_client.audit_config = {} with ( @@ -38,6 +41,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -74,7 +78,7 @@ class Test_defender_attack_path_notifications_properly_configured: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == ( - f"Attack path notifications are not enabled in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + f"Attack path notifications are not enabled in subscription {AZURE_SUBSCRIPTION_DISPLAY} for security contact {contact_name}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == contact_name @@ -85,6 +89,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -123,7 +128,7 @@ class Test_defender_attack_path_notifications_properly_configured: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_DISPLAY} for security contact {contact_name}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == contact_name @@ -134,6 +139,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -172,7 +178,7 @@ class Test_defender_attack_path_notifications_properly_configured: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_DISPLAY} for security contact {contact_name}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == contact_name @@ -183,6 +189,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -219,7 +226,7 @@ class Test_defender_attack_path_notifications_properly_configured: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - f"Attack path notifications are enabled with minimal risk level Low in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + f"Attack path notifications are enabled with minimal risk level Low in subscription {AZURE_SUBSCRIPTION_DISPLAY} for security contact {contact_name}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == contact_name @@ -230,6 +237,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -266,7 +274,7 @@ class Test_defender_attack_path_notifications_properly_configured: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_DISPLAY} for security contact {contact_name}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == contact_name @@ -277,6 +285,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -313,7 +322,7 @@ class Test_defender_attack_path_notifications_properly_configured: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - f"Attack path notifications are enabled with minimal risk level High in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + f"Attack path notifications are enabled with minimal risk level High in subscription {AZURE_SUBSCRIPTION_DISPLAY} for security contact {contact_name}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == contact_name @@ -324,6 +333,7 @@ class Test_defender_attack_path_notifications_properly_configured: resource_id = str(uuid4()) contact_name = "default" defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -360,7 +370,7 @@ class Test_defender_attack_path_notifications_properly_configured: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == ( - f"Attack path notifications are enabled with minimal risk level Critical in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + f"Attack path notifications are enabled with minimal risk level Critical in subscription {AZURE_SUBSCRIPTION_DISPLAY} for security contact {contact_name}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == contact_name diff --git a/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py b/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py index 3f39654c2b..9a99281e94 100644 --- a/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py +++ b/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py @@ -5,7 +5,9 @@ from prowler.providers.azure.services.defender.defender_service import ( AutoProvisioningSetting, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = {} with ( @@ -36,6 +39,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_auto_provisioning_log_analytics_off(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = { AZURE_SUBSCRIPTION_ID: { "default": AutoProvisioningSetting( @@ -67,7 +71,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF." + == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -76,6 +80,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_auto_provisioning_log_analytics_on(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = { AZURE_SUBSCRIPTION_ID: { "default": AutoProvisioningSetting( @@ -107,7 +112,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_ID} is set to ON." + == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -116,6 +121,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: def test_defender_auto_provisioning_log_analytics_on_and_off(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.auto_provisioning_settings = { AZURE_SUBSCRIPTION_ID: { "default": AutoProvisioningSetting( @@ -153,7 +159,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_ID} is set to ON." + == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -162,7 +168,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on: assert result[1].status == "FAIL" assert ( result[1].status_extended - == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF." + == f"Defender Auto Provisioning Log Analytics Agents from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF." ) assert result[1].subscription == AZURE_SUBSCRIPTION_ID assert result[1].resource_name == "default2" diff --git a/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py b/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py index bb78eb8fae..eeddb61012 100644 --- a/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py +++ b/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Assesment from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} with ( @@ -34,6 +37,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: def test_defender_machines_no_vulnerability_assessment_solution(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Machines should have a vulnerability assessment solution": Assesment( @@ -64,7 +68,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Vulnerability assessment is not set up in all VMs in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Vulnerability assessment is not set up in all VMs in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "vm1" @@ -73,6 +77,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: def test_defender_machines_vulnerability_assessment_solution(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Machines should have a vulnerability assessment solution": Assesment( @@ -103,7 +108,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Vulnerability assessment is set up in all VMs in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Vulnerability assessment is set up in all VMs in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "vm1" diff --git a/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py b/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py index bfaabbce9a..510a995692 100644 --- a/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py +++ b/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Assesment from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_container_images_resolved_vulnerabilities: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} with ( @@ -33,6 +36,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_empty(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_no_assesment(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "": Assesment( @@ -85,6 +90,7 @@ class Test_defender_container_images_resolved_vulnerabilities: def test_defender_subscription_assesment_unhealthy(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Azure running container images should have vulnerabilities resolved (powered by Microsoft Defender Vulnerability Management)": Assesment( @@ -128,11 +134,12 @@ class Test_defender_container_images_resolved_vulnerabilities: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( result[0].status_extended - == f"Azure running container images have unresolved vulnerabilities in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Azure running container images have unresolved vulnerabilities in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) def test_defender_subscription_assesment_healthy(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Azure running container images should have vulnerabilities resolved (powered by Microsoft Defender Vulnerability Management)": Assesment( @@ -176,11 +183,12 @@ class Test_defender_container_images_resolved_vulnerabilities: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( result[0].status_extended - == f"Azure running container images do not have unresolved vulnerabilities in subscription '{AZURE_SUBSCRIPTION_ID}'." + == f"Azure running container images do not have unresolved vulnerabilities in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) def test_defender_subscription_assesment_not_applicable(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Azure running container images should have vulnerabilities resolved (powered by Microsoft Defender Vulnerability Management)": Assesment( diff --git a/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py b/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py index 6bb9fb0e9a..977ee8acdb 100644 --- a/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py @@ -4,7 +4,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_container_images_scan_enabled: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_empty(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -56,6 +60,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_no_containers(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "NotContainers": Pricing( @@ -87,6 +92,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_containers_no_extensions(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Containers": Pricing( @@ -118,7 +124,7 @@ class Test_defender_container_images_scan_enabled: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == ( - f"Container image scan is disabled in subscription {AZURE_SUBSCRIPTION_ID}." + f"Container image scan is disabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert ( result[0].resource_id @@ -131,6 +137,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_containers_container_images_scan_off(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Containers": Pricing( @@ -162,7 +169,7 @@ class Test_defender_container_images_scan_enabled: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == ( - f"Container image scan is disabled in subscription {AZURE_SUBSCRIPTION_ID}." + f"Container image scan is disabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert ( result[0].resource_id @@ -175,6 +182,7 @@ class Test_defender_container_images_scan_enabled: def test_defender_subscription_containers_container_images_scan_on(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Containers": Pricing( @@ -206,7 +214,7 @@ class Test_defender_container_images_scan_enabled: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - f"Container image scan is enabled in subscription {AZURE_SUBSCRIPTION_ID}." + f"Container image scan is enabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert ( result[0].resource_id diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py new file mode 100644 index 0000000000..b81d10d31d --- /dev/null +++ b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py @@ -0,0 +1,117 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.defender.defender_service import Pricing +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_defender_ensure_defender_cspm_is_on: + def test_defender_no_cspm(self): + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 0 + + def test_defender_cspm_pricing_tier_not_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Free", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id + + def test_defender_cspm_pricing_tier_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Standard", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py index a3b10bb0d4..b2528e28e7 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_app_services_is_on: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_app_services_is_on: def test_defender_app_services_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "AppServices": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_app_services_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for App Services from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for App Services from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan App Services" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_app_services_is_on: def test_defender_app_services_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "AppServices": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_app_services_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for App Services from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for App Services from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan App Services" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py index 247cebf877..357e3ca9e7 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_arm_is_on: def test_defender_no_arm(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_arm_is_on: def test_defender_arm_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Arm": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_arm_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for ARM from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for ARM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan ARM" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_arm_is_on: def test_defender_arm_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Arm": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_arm_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for ARM from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for ARM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan ARM" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py index 5a0ab49b7a..c10314042b 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_azure_sql_databases_is_on: def test_defender_no_sql_databases(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on: def test_defender_sql_databases_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServers": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for Azure SQL DB Servers from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for Azure SQL DB Servers from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on: def test_defender_sql_databases_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServers": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for Azure SQL DB Servers from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for Azure SQL DB Servers from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py index 622ad77b42..7ff728add9 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_containers_is_on: def test_defender_no_container_registries(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_containers_is_on: def test_defender_container_registries_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Containers": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_containers_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for Containers from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for Containers from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_containers_is_on: def test_defender_container_registries_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Containers": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_containers_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for Containers from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for Containers from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py index 21507b9c20..351f38d97f 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_cosmosdb_is_on: def test_defender_no_cosmosdb(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on: def test_defender_cosmosdb_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "CosmosDbs": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for Cosmos DB from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for Cosmos DB from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Cosmos DB" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on: def test_defender_cosmosdb_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "CosmosDbs": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for Cosmos DB from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for Cosmos DB from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Cosmos DB" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py index 354025d4b6..48cbc57ad1 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_databases_is_on: def test_defender_no_databases(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_sql_servers(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServers": Pricing( @@ -66,6 +70,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_sql_server_virtual_machines(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServerVirtualMachines": Pricing( @@ -98,6 +103,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_open_source_relation_databases(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "OpenSourceRelationalDatabases": Pricing( @@ -130,6 +136,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_cosmosdbs(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "CosmosDbs": Pricing( @@ -162,6 +169,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_all_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServers": Pricing( @@ -211,7 +219,7 @@ class Test_defender_ensure_defender_for_databases_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for Databases from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for Databases from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" @@ -220,6 +228,7 @@ class Test_defender_ensure_defender_for_databases_is_on: def test_defender_databases_cosmosdb_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServers": Pricing( @@ -269,7 +278,7 @@ class Test_defender_ensure_defender_for_databases_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for Databases from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for Databases from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py index 22729f4677..6b50ea4c5f 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_dns_is_on: def test_defender_no_dns(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_dns_is_on: def test_defender_dns_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Dns": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_dns_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for DNS from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for DNS from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan DNS" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_dns_is_on: def test_defender_dns_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "Dns": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_dns_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for DNS from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for DNS from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan DNS" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py index f2ef503114..f587a92961 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_keyvault_is_on: def test_defender_no_keyvaults(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on: def test_defender_keyvaults_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "KeyVaults": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for KeyVaults from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for KeyVaults from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan KeyVaults" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on: def test_defender_keyvaults_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "KeyVaults": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for KeyVaults from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for KeyVaults from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan KeyVaults" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py index a00ad47526..dc28fb3bb2 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_no_os_relational_databases(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_os_relational_databases_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "OpenSourceRelationalDatabases": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_os_relational_databases_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for Open-Source Relational Databases from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for Open-Source Relational Databases from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( @@ -77,6 +81,7 @@ class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_os_relational_databases_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "OpenSourceRelationalDatabases": Pricing( @@ -108,7 +113,7 @@ class Test_defender_ensure_defender_for_os_relational_databases_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for Open-Source Relational Databases from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for Open-Source Relational Databases from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py index 460a206150..226b26ad3a 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_server_is_on: def test_defender_no_server(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_server_is_on: def test_defender_server_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "VirtualMachines": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_server_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for Servers from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for Servers from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_server_is_on: def test_defender_server_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "VirtualMachines": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_server_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for Servers from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for Servers from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Servers" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py index c99a270e89..1907cdbb6c 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_sql_servers_is_on: def test_defender_no_server(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_sql_servers_is_on: def test_defender_server_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServerVirtualMachines": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_sql_servers_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for SQL Server VMs from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for SQL Server VMs from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan SQL Server VMs" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_sql_servers_is_on: def test_defender_server_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "SqlServerVirtualMachines": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_sql_servers_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for SQL Server VMs from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for SQL Server VMs from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan SQL Server VMs" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py index d3d22ee1d7..f5eee6879a 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Pricing from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_storage_is_on: def test_defender_no_server(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_defender_for_storage_is_on: def test_defender_server_pricing_tier_not_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "StorageAccounts": Pricing( @@ -65,7 +69,7 @@ class Test_defender_ensure_defender_for_storage_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Defender plan Defender for Storage Accounts from subscription {AZURE_SUBSCRIPTION_ID} is set to OFF (pricing tier not standard)." + == f"Defender plan Defender for Storage Accounts from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Storage Accounts" @@ -74,6 +78,7 @@ class Test_defender_ensure_defender_for_storage_is_on: def test_defender_server_pricing_tier_standard(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.pricings = { AZURE_SUBSCRIPTION_ID: { "StorageAccounts": Pricing( @@ -105,7 +110,7 @@ class Test_defender_ensure_defender_for_storage_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Defender plan Defender for Storage Accounts from subscription {AZURE_SUBSCRIPTION_ID} is set to ON (pricing tier standard)." + == f"Defender plan Defender for Storage Accounts from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "Defender plan Storage Accounts" diff --git a/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py index 14ae870fdf..f4ac17c5ae 100644 --- a/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py @@ -5,7 +5,9 @@ from prowler.providers.azure.services.defender.defender_service import ( IoTSecuritySolution, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = {} with ( @@ -36,7 +39,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_no_iot_hub_solutions(self): defender_client = mock.MagicMock defender_client.iot_security_solutions = {AZURE_SUBSCRIPTION_ID: {}} - defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -58,7 +61,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"No IoT Security Solutions found in the subscription {AZURE_SUBSCRIPTION_ID}." + == f"No IoT Security Solutions found in the subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].resource_name == AZURE_SUBSCRIPTION_ID assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" @@ -66,6 +69,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_iot_hub_solution_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { resource_id: IoTSecuritySolution( @@ -94,7 +98,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"The security solution iot_sec_solution is disabled in subscription {AZURE_SUBSCRIPTION_ID}" + == f"The security solution iot_sec_solution is disabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) assert result[0].resource_name == "iot_sec_solution" assert result[0].resource_id == resource_id @@ -102,6 +106,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_iot_hub_solution_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { resource_id: IoTSecuritySolution( @@ -130,7 +135,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"The security solution iot_sec_solution is enabled in subscription {AZURE_SUBSCRIPTION_ID}." + == f"The security solution iot_sec_solution is enabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].resource_name == "iot_sec_solution" assert result[0].resource_id == resource_id @@ -140,6 +145,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: resource_id_enabled = str(uuid4()) resource_id_disabled = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { resource_id_enabled: IoTSecuritySolution( @@ -175,7 +181,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"The security solution iot_sec_solution_enabled is enabled in subscription {AZURE_SUBSCRIPTION_ID}." + == f"The security solution iot_sec_solution_enabled is enabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].resource_name == "iot_sec_solution_enabled" assert result[0].resource_id == resource_id_enabled @@ -184,7 +190,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: assert result[1].status == "FAIL" assert ( result[1].status_extended - == f"The security solution iot_sec_solution_disabled is disabled in subscription {AZURE_SUBSCRIPTION_ID}" + == f"The security solution iot_sec_solution_disabled is disabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) assert result[1].resource_name == "iot_sec_solution_disabled" assert result[1].resource_id == resource_id_disabled diff --git a/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py b/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py index 5db8eb19a0..7770ab0baf 100644 --- a/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Setting from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_mcas_is_enabled: def test_defender_no_settings(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { "MCAS": Setting( @@ -66,7 +70,7 @@ class Test_defender_ensure_mcas_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Microsoft Defender for Cloud Apps is disabled for subscription {AZURE_SUBSCRIPTION_ID}." + == f"Microsoft Defender for Cloud Apps is disabled for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "MCAS" @@ -75,6 +79,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { "MCAS": Setting( @@ -107,7 +112,7 @@ class Test_defender_ensure_mcas_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Microsoft Defender for Cloud Apps is enabled for subscription {AZURE_SUBSCRIPTION_ID}." + == f"Microsoft Defender for Cloud Apps is enabled for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "MCAS" @@ -116,7 +121,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_no_settings(self): defender_client = mock.MagicMock defender_client.settings = {AZURE_SUBSCRIPTION_ID: {}} - defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -138,7 +143,7 @@ class Test_defender_ensure_mcas_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Microsoft Defender for Cloud Apps not exists for subscription {AZURE_SUBSCRIPTION_ID}." + == f"Microsoft Defender for Cloud Apps not exists for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py index 85355bc1f0..8d2a3a05f7 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.defender.defender_service import ( SecurityContactConfiguration, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} with ( @@ -37,6 +40,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_critical(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -74,7 +78,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -83,6 +87,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_high(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -121,7 +126,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Notifications are enabled for alerts with a minimum severity of high or lower (High) in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are enabled for alerts with a minimum severity of high or lower (High) in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -130,6 +135,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_low(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -168,7 +174,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Notifications are enabled for alerts with a minimum severity of high or lower (Low) in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are enabled for alerts with a minimum severity of high or lower (Low) in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -176,6 +182,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_default_security_contact_not_found(self): defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default": SecurityContactConfiguration( @@ -212,7 +219,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py index e4c2dc4371..b125320764 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.defender.defender_service import ( SecurityContactConfiguration, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_emails_to_owners: def test_defender_no_subscriptions(self): defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = {} with ( @@ -37,6 +40,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_no_notify_emails_to_owners(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -76,6 +80,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_notify_emails_to_owners_off(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -113,7 +118,7 @@ class Test_defender_ensure_notify_emails_to_owners: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"The Owner role is not notified for subscription {AZURE_SUBSCRIPTION_ID}." + == f"The Owner role is not notified for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -122,6 +127,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_notify_emails_to_owners(self): resource_id = str(uuid4()) defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { resource_id: SecurityContactConfiguration( @@ -159,7 +165,7 @@ class Test_defender_ensure_notify_emails_to_owners: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"The Owner role is notified for subscription {AZURE_SUBSCRIPTION_ID}." + == f"The Owner role is notified for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" diff --git a/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py b/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py index 4a98f0bba6..e6a80853dd 100644 --- a/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Assesment from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_system_updates_are_applied: def test_defender_no_app_services(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_system_updates_are_applied: def test_defender_machines_no_log_analytics_installed(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Log Analytics agent should be installed on virtual machines": Assesment( @@ -74,7 +78,7 @@ class Test_defender_ensure_system_updates_are_applied: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"System updates are not applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_ID}." + == f"System updates are not applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "vm1" @@ -85,6 +89,7 @@ class Test_defender_ensure_system_updates_are_applied: ): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Log Analytics agent should be installed on virtual machines": Assesment( @@ -125,7 +130,7 @@ class Test_defender_ensure_system_updates_are_applied: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"System updates are not applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_ID}." + == f"System updates are not applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "vm1" @@ -134,6 +139,7 @@ class Test_defender_ensure_system_updates_are_applied: def test_defender_machines_no_system_updates_installed(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Log Analytics agent should be installed on virtual machines": Assesment( @@ -174,7 +180,7 @@ class Test_defender_ensure_system_updates_are_applied: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"System updates are not applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_ID}." + == f"System updates are not applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "vm1" @@ -185,6 +191,7 @@ class Test_defender_ensure_system_updates_are_applied: ): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.assessments = { AZURE_SUBSCRIPTION_ID: { "Log Analytics agent should be installed on virtual machines": Assesment( @@ -225,7 +232,7 @@ class Test_defender_ensure_system_updates_are_applied: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"System updates are applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_ID}." + == f"System updates are applied for all the VMs in the subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "vm1" diff --git a/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py b/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py index fba1d24ba8..202e332b3f 100644 --- a/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.defender.defender_service import Setting from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_wdatp_is_enabled: def test_defender_no_settings(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = {} with ( @@ -34,6 +37,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { "WDATP": Setting( @@ -66,7 +70,7 @@ class Test_defender_ensure_wdatp_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Microsoft Defender for Endpoint integration is disabled for subscription {AZURE_SUBSCRIPTION_ID}." + == f"Microsoft Defender for Endpoint integration is disabled for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "WDATP" @@ -75,6 +79,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { "WDATP": Setting( @@ -107,7 +112,7 @@ class Test_defender_ensure_wdatp_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Microsoft Defender for Endpoint integration is enabled for subscription {AZURE_SUBSCRIPTION_ID}." + == f"Microsoft Defender for Endpoint integration is enabled for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "WDATP" @@ -116,7 +121,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_no_settings(self): defender_client = mock.MagicMock defender_client.settings = {AZURE_SUBSCRIPTION_ID: {}} - defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -138,7 +143,7 @@ class Test_defender_ensure_wdatp_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Microsoft Defender for Endpoint integration not exists for subscription {AZURE_SUBSCRIPTION_ID}." + == f"Microsoft Defender for Endpoint integration not exists for subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py new file mode 100644 index 0000000000..89fa5bac16 --- /dev/null +++ b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py @@ -0,0 +1,269 @@ +from datetime import datetime, timezone, timedelta +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + + +class Test_entra_app_registration_credential_not_expired: + def test_entra_no_tenants(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + + entra_client.app_registrations = {} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_entra_app_no_credentials(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppRegistration, + ) + + app = AppRegistration(id=app_id, name="no-creds-app", credentials=[]) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_entra_app_credential_expired(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="expired-app", + credentials=[ + AppCredential( + display_name="old-secret", + credential_type="password", + end_date_time=datetime.now(timezone.utc) - timedelta(days=30), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "expired" in result[0].status_extended + + def test_entra_app_credential_expiring_soon(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="expiring-soon-app", + credentials=[ + AppCredential( + display_name="expiring-cert", + credential_type="certificate", + end_date_time=datetime.now(timezone.utc) + timedelta(days=15), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "expiring in" in result[0].status_extended + + def test_entra_app_credential_no_expiration(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="no-expiry-app", + credentials=[ + AppCredential( + display_name="forever-secret", + credential_type="password", + end_date_time=None, + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration" in result[0].status_extended + + def test_entra_app_credential_valid(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="healthy-app", + credentials=[ + AppCredential( + display_name="good-secret", + credential_type="password", + end_date_time=datetime.now(timezone.utc) + timedelta(days=180), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "more days" in result[0].status_extended + + def test_entra_app_multiple_credentials_mixed(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="mixed-app", + credentials=[ + AppCredential( + display_name="expired-one", + credential_type="password", + end_date_time=datetime.now(timezone.utc) - timedelta(days=10), + ), + AppCredential( + display_name="valid-one", + credential_type="certificate", + end_date_time=datetime.now(timezone.utc) + timedelta(days=200), + ), + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 2 + statuses = {r.status for r in result} + assert "FAIL" in statuses + assert "PASS" in statuses diff --git a/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py b/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py new file mode 100644 index 0000000000..e3102bf3e9 --- /dev/null +++ b/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + +CHECK_MODULE = ( + "prowler.providers.azure.services.entra." + "entra_authentication_methods_policy_strong_auth_enforced." + "entra_authentication_methods_policy_strong_auth_enforced" +) + + +class Test_entra_authentication_methods_policy_strong_auth_enforced: + def test_entra_no_tenants(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + + entra_client.authentication_methods_policy = {} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 0 + + def test_entra_policy_none(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + + entra_client.authentication_methods_policy = {DOMAIN: None} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 0 + + def test_entra_registration_enabled_strong_method_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="enabled", + method_configurations=[ + AuthMethodConfig( + id="MicrosoftAuthenticator", + method_name="microsoftAuthenticator", + state="enabled", + ), + AuthMethodConfig(id="Sms", method_name="sms", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Authentication Methods Policy" + assert result[0].resource_id == "authMethodsPolicy" + assert "is enforced" in result[0].status_extended + assert "microsoftAuthenticator" in result[0].status_extended + + def test_entra_registration_disabled_no_strong_methods(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="disabled", + method_configurations=[ + AuthMethodConfig(id="Sms", method_name="sms", state="enabled"), + AuthMethodConfig(id="Voice", method_name="voice", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not enforced" in result[0].status_extended + assert "registration campaign is not enabled" in result[0].status_extended + assert "no strong authentication methods" in result[0].status_extended + + def test_entra_registration_disabled_strong_method_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="disabled", + method_configurations=[ + AuthMethodConfig(id="Fido2", method_name="fido2", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + # Strong method present but registration campaign off -> not enforced + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "registration campaign is not enabled" in result[0].status_extended + assert "no strong authentication methods" not in result[0].status_extended + + def test_entra_multiple_strong_methods(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="enabled", + method_configurations=[ + AuthMethodConfig( + id="MicrosoftAuthenticator", + method_name="microsoftAuthenticator", + state="enabled", + ), + AuthMethodConfig(id="Fido2", method_name="fido2", state="enabled"), + AuthMethodConfig( + id="x509", method_name="x509Certificate", state="enabled" + ), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "microsoftAuthenticator" in result[0].status_extended + assert "fido2" in result[0].status_extended diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index 75ef4f98c4..ebd2b790ab 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -294,6 +294,7 @@ def test_azure_entra__get_users_handles_pagination(): "id", "displayName", "accountEnabled", + "signInActivity", ] with_url_mock.assert_called_once_with("next-link") registration_details_builder.get.assert_awaited() diff --git a/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py new file mode 100644 index 0000000000..f940cf232c --- /dev/null +++ b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py @@ -0,0 +1,321 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + + +class Test_entra_user_with_recent_sign_in: + def test_entra_no_tenants(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + entra_client.users = {} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 0 + + def test_entra_user_disabled(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="disabled-user", + account_enabled=False, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"disabled-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 0 + + def test_entra_user_never_signed_in(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="never-signed-in", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"never-signed-in@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No sign-in activity data available" in result[0].status_extended + + def test_entra_single_user_no_sign_in_data_reports_telemetry_gap(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="single-user", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"single-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No sign-in activity data available" in result[0].status_extended + assert "1 enabled user" in result[0].status_extended + + def test_entra_user_stale_sign_in(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="stale-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=120), + ) + + entra_client.users = {DOMAIN: {f"stale-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "120 days" in result[0].status_extended + + def test_entra_user_recent_sign_in(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="active-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=10), + ) + + entra_client.users = {DOMAIN: {f"active-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "10 days ago" in result[0].status_extended + + def test_entra_all_users_no_sign_in_data_license_issue(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + # Multiple enabled users, ALL with no sign-in data = license issue + users = {} + for i in range(5): + uid = str(uuid4()) + users[f"user{i}@{DOMAIN}"] = User( + id=uid, + name=f"user{i}", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: users} + + check = entra_user_with_recent_sign_in() + result = check.execute() + # Should produce 1 finding (license warning), not 5 individual FAILs + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Entra ID P1/P2 licensing" in result[0].status_extended + assert "5 enabled users" in result[0].status_extended + + def test_entra_user_never_signed_in_when_telemetry_exists_for_tenant(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + active_user = User( + id=str(uuid4()), + name="active-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=5), + ) + never_user = User( + id=str(uuid4()), + name="never-user", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = { + DOMAIN: { + f"active-user@{DOMAIN}": active_user, + f"never-user@{DOMAIN}": never_user, + } + } + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 2 + assert any( + r.status == "PASS" and "5 days ago" in r.status_extended for r in result + ) + assert any( + r.status == "FAIL" and "never signed in" in r.status_extended + for r in result + ) + + def test_entra_user_boundary_90_days(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="boundary-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=90), + ) + + entra_client.users = {DOMAIN: {f"boundary-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90 days ago" in result[0].status_extended diff --git a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py index b9ebe959ef..46dc9389af 100644 --- a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.config import VIRTUAL_MACHINE_ADMINISTRATOR_LOGIN_ROLE_ID from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, DOMAIN, set_mocked_azure_provider, ) @@ -12,7 +14,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_iam_no_roles(self): iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} entra_client = mock.MagicMock + entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -37,8 +41,10 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa(self): iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) with ( @@ -98,7 +104,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"User test can access VMs in subscription {AZURE_SUBSCRIPTION_ID} but it has MFA." + == f"User test can access VMs in subscription {AZURE_SUBSCRIPTION_DISPLAY} but it has MFA." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "test" @@ -106,8 +112,10 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_mfa(self): iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) with ( @@ -167,7 +175,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"User test without MFA can access VMs in subscription {AZURE_SUBSCRIPTION_ID}" + == f"User test without MFA can access VMs in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "test" @@ -175,8 +183,10 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_user(self): iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) with ( @@ -227,8 +237,10 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_role(self): iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py index eaa59eff14..5125130871 100644 --- a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py +++ b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py @@ -4,7 +4,9 @@ from azure.mgmt.authorization.v2022_04_01.models import Permission from prowler.providers.azure.services.iam.iam_service import Role from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_custom_role_has_permissions_to_administer_resource_locks: def test_iam_no_roles(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {} with ( @@ -36,6 +39,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { AZURE_SUBSCRIPTION_ID: { @@ -76,7 +80,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_ID} has permission to administer resource locks." + == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has permission to administer resource locks." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( @@ -91,6 +95,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { AZURE_SUBSCRIPTION_ID: { @@ -124,7 +129,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_ID} has no permission to administer resource locks." + == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has no permission to administer resource locks." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( @@ -139,6 +144,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" role_name2 = "test-role2" defender_client.custom_roles = { @@ -194,7 +200,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_ID} has permission to administer resource locks." + == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has permission to administer resource locks." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( @@ -206,6 +212,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: def test_iam_custom_roles_empty_list_but_with_key(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {AZURE_SUBSCRIPTION_ID: {}} with ( diff --git a/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py index dbf842d589..8ccf6e6f64 100644 --- a/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py +++ b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.iam.iam_service import Role, RoleAssignment from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_role_user_access_admin_restricted: def test_iam_no_role_assignments(self): iam_client = mock.MagicMock + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} iam_client.role_assignments = {} iam_client.roles = {} @@ -40,11 +43,11 @@ class Test_iam_role_user_access_admin_restricted: role_name = "User Access Administrator" iam_client.subscriptions = { - "subscription-name-1": AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME, } iam_client.role_assignments = { - "subscription-name-1": { + AZURE_SUBSCRIPTION_ID: { role_assignment_id: RoleAssignment( id=role_assignment_id, name="test-assignment", @@ -56,7 +59,7 @@ class Test_iam_role_user_access_admin_restricted: } } iam_client.roles = { - "subscription-name-1": { + AZURE_SUBSCRIPTION_ID: { f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{role_id}": Role( id=role_id, name=role_name, @@ -87,9 +90,9 @@ class Test_iam_role_user_access_admin_restricted: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Role assignment test-assignment in subscription subscription-name-1 grants User Access Administrator role to User {agent_id}." + == f"Role assignment test-assignment in subscription {AZURE_SUBSCRIPTION_DISPLAY} grants User Access Administrator role to User {agent_id}." ) - assert result[0].subscription == "subscription-name-1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_id == role_assignment_id def test_iam_non_user_access_administrator_role_assigned(self): @@ -100,11 +103,11 @@ class Test_iam_role_user_access_admin_restricted: role_name = "Reader" iam_client.subscriptions = { - "subscription-name-1": AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME, } iam_client.role_assignments = { - "subscription-name-1": { + AZURE_SUBSCRIPTION_ID: { role_assignment_id: RoleAssignment( id=role_assignment_id, name="test-assignment", @@ -116,7 +119,7 @@ class Test_iam_role_user_access_admin_restricted: } } iam_client.roles = { - "subscription-name-1": { + AZURE_SUBSCRIPTION_ID: { f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{role_id}": Role( id=role_id, name=role_name, @@ -147,7 +150,7 @@ class Test_iam_role_user_access_admin_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Role assignment test-assignment in subscription subscription-name-1 does not grant User Access Administrator role." + == f"Role assignment test-assignment in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not grant User Access Administrator role." ) - assert result[0].subscription == "subscription-name-1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_id == role_assignment_id diff --git a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py index 7f15b69466..1d2d37ee11 100644 --- a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py +++ b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py @@ -4,7 +4,9 @@ from azure.mgmt.authorization.v2022_04_01.models import Permission from prowler.providers.azure.services.iam.iam_service import Role from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_no_roles(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {} with ( @@ -34,6 +37,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_custom_owner_role_created_with_all(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { AZURE_SUBSCRIPTION_ID: { @@ -67,7 +71,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_ID} is a custom owner role." + == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is a custom owner role." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( @@ -80,6 +84,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_custom_owner_role_created_with_no_permissions(self): defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { AZURE_SUBSCRIPTION_ID: { @@ -113,7 +118,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_ID} is not a custom owner role." + == f"Role {role_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not a custom owner role." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( diff --git a/tests/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints_test.py b/tests/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints_test.py index 793b3b4912..4244684d1a 100644 --- a/tests/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_access_only_through_private_endpoints: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -32,6 +35,7 @@ class Test_keyvault_access_only_through_private_endpoints: def test_key_vaults_no_private_endpoints(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -78,6 +82,7 @@ class Test_keyvault_access_only_through_private_endpoints: def test_key_vaults_with_private_endpoints_public_access_enabled(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -127,7 +132,7 @@ class Test_keyvault_access_only_through_private_endpoints: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has public network access enabled while using private endpoints." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled while using private endpoints." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name @@ -136,6 +141,7 @@ class Test_keyvault_access_only_through_private_endpoints: def test_key_vaults_with_private_endpoints_public_access_disabled(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -185,7 +191,7 @@ class Test_keyvault_access_only_through_private_endpoints: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has public network access disabled and is using private endpoints." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access disabled and is using private endpoints." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name diff --git a/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py b/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py index 8c9901080b..9da66f9eda 100644 --- a/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py @@ -4,7 +4,9 @@ from uuid import uuid4 from azure.mgmt.keyvault.v2023_07_01.models import KeyAttributes, VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_key_expiration_set_in_non_rbac: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -34,6 +37,7 @@ class Test_keyvault_key_expiration_set_in_non_rbac: def test_no_keys(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -75,6 +79,7 @@ class Test_keyvault_key_expiration_set_in_non_rbac: def test_key_vaults_invalid_keys(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) key_name = "Key Name" @@ -127,17 +132,19 @@ class Test_keyvault_key_expiration_set_in_non_rbac: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} without expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" assert result[0].location == "westeurope" def test_key_vaults_valid_keys(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + key_name = "Key Name" with ( mock.patch( @@ -159,7 +166,7 @@ class Test_keyvault_key_expiration_set_in_non_rbac: key = Key( id="id", - name="name", + name=key_name, enabled=True, location="westeurope", attributes=KeyAttributes(expires=49394, enabled=True), @@ -187,9 +194,128 @@ class Test_keyvault_key_expiration_set_in_non_rbac: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has all the keys with expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" assert result[0].location == "westeurope" + + def test_disabled_key_skipped(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac import ( + keyvault_key_expiration_set_in_non_rbac, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + key = Key( + id="id", + name="disabled_key", + enabled=False, + location="westeurope", + attributes=KeyAttributes(expires=None, enabled=False), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[key], + secrets=[], + ) + ] + } + check = keyvault_key_expiration_set_in_non_rbac() + result = check.execute() + assert len(result) == 0 + + def test_multiple_keys_mixed_expiration(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + key_with_expiry = "key_with_expiry" + key_without_expiry = "key_without_expiry" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac import ( + keyvault_key_expiration_set_in_non_rbac, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[ + Key( + id="id1", + name=key_without_expiry, + enabled=True, + location="westeurope", + attributes=KeyAttributes(expires=None, enabled=True), + ), + Key( + id="id2", + name=key_with_expiry, + enabled=True, + location="westeurope", + attributes=KeyAttributes(expires=49394, enabled=True), + ), + ], + secrets=[], + ) + ] + } + check = keyvault_key_expiration_set_in_non_rbac() + result = check.execute() + assert len(result) == 2 + assert result[0] is not result[1] + assert result[0].status == "FAIL" + assert key_without_expiry in result[0].status_extended + assert result[1].status == "PASS" + assert key_with_expiry in result[1].status_extended diff --git a/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py b/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py index cb4224a12f..fd6e2495ce 100644 --- a/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py @@ -4,7 +4,9 @@ from azure.keyvault.keys import KeyRotationLifetimeAction, KeyRotationPolicy from azure.mgmt.keyvault.v2023_07_01.models import KeyAttributes, VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_key_rotation_enabled: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -34,6 +37,7 @@ class Test_keyvault_key_rotation_enabled: def test_no_keys(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -75,6 +79,7 @@ class Test_keyvault_key_rotation_enabled: def test_key_without_rotation_policy(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "keyvault_name" key_name = "key_name" @@ -128,15 +133,16 @@ class Test_keyvault_key_rotation_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} without rotation policy set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have a rotation policy set." ) - assert result[0].resource_name == keyvault_name + assert result[0].resource_name == key_name assert result[0].resource_id == "id" assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].location == "westeurope" + assert result[0].location == "location" def test_key_with_rotation_policy(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "keyvault_name" key_name = "key_name" @@ -198,9 +204,181 @@ class Test_keyvault_key_rotation_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} with rotation policy set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a rotation policy set." ) - assert result[0].resource_name == keyvault_name + assert result[0].resource_name == key_name assert result[0].resource_id == "id" assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].location == "westeurope" + assert result[0].location == "location" + + def test_multiple_keys_mixed_rotation_policies(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "keyvault_name" + key_with_rotation = "key_with_rotation" + key_without_rotation = "key_without_rotation" + key_with_notify_only = "key_with_notify_only" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled import ( + keyvault_key_rotation_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[ + Key( + id="id1", + name=key_with_rotation, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=KeyRotationPolicy( + lifetime_actions=[ + KeyRotationLifetimeAction( + action="Rotate", + lifetime_action_type="Rotate", + lifetime_percentage=80, + ) + ] + ), + ), + Key( + id="id2", + name=key_without_rotation, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=None, + ), + Key( + id="id3", + name=key_with_notify_only, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=KeyRotationPolicy( + lifetime_actions=[ + KeyRotationLifetimeAction( + action="Notify", + lifetime_action_type="Notify", + lifetime_percentage=90, + ) + ] + ), + ), + ], + secrets=[], + ) + ] + } + check = keyvault_key_rotation_enabled() + result = check.execute() + assert len(result) == 3 + # Each finding must be a distinct object + assert result[0] is not result[1] + assert result[1] is not result[2] + # Key with rotation policy -> PASS + assert result[0].status == "PASS" + assert key_with_rotation in result[0].status_extended + # Key without rotation policy -> FAIL + assert result[1].status == "FAIL" + assert key_without_rotation in result[1].status_extended + # Key with only Notify action (no Rotate) -> FAIL + assert result[2].status == "FAIL" + assert key_with_notify_only in result[2].status_extended + + def test_rotation_action_not_first_in_lifetime_actions(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "keyvault_name" + key_name = "key_name" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled import ( + keyvault_key_rotation_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[ + Key( + id="id", + name=key_name, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=KeyRotationPolicy( + lifetime_actions=[ + KeyRotationLifetimeAction( + action="Notify", + lifetime_action_type="Notify", + lifetime_percentage=90, + ), + KeyRotationLifetimeAction( + action="Rotate", + lifetime_action_type="Rotate", + lifetime_percentage=80, + ), + ] + ), + ) + ], + secrets=[], + ) + ] + } + check = keyvault_key_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a rotation policy set." + ) diff --git a/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py b/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py index 1bbc078c08..6050263af3 100644 --- a/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_logging_enabled/keyvault_logging_enabled_test.py @@ -3,14 +3,17 @@ from unittest import mock from azure.mgmt.keyvault.v2023_07_01.models import VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) class Test_keyvault_logging_enabled: - def test_keyvault_logging_enabled(self): + def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -27,7 +30,6 @@ class Test_keyvault_logging_enabled: new=keyvault_client, ), ): - from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( keyvault_logging_enabled, ) @@ -38,7 +40,8 @@ class Test_keyvault_logging_enabled: def test_no_diagnostic_settings(self): keyvault_client = mock.MagicMock - keyvault_client.key_vaults = {AZURE_SUBSCRIPTION_ID: []} + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -56,13 +59,42 @@ class Test_keyvault_logging_enabled: from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( keyvault_logging_enabled, ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[], + ), + ] + } check = keyvault_logging_enabled() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" - def test_diagnostic_settings_configured(self): + def test_diagnostic_setting_without_audit_logging(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -104,7 +136,7 @@ class Test_keyvault_logging_enabled: secrets=[], monitor_diagnostic_settings=[ DiagnosticSetting( - id="id/id", + id="id/ds1", logs=[ mock.MagicMock( category_group="audit", @@ -117,17 +149,60 @@ class Test_keyvault_logging_enabled: enabled=False, ), ], - storage_account_name="storage_account_name", - storage_account_id="storage_account_id", - name="name_diagnostic_setting", + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_incomplete", ), ], ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + + def test_diagnostic_setting_with_audit_logging(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=mock.MagicMock(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( + keyvault_logging_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ KeyVaultInfo( - id="id2", - name="name_keyvault2", - location="eastus", - resource_group="resource_group2", + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", properties=VaultProperties( tenant_id="tenantid", sku="sku", @@ -137,7 +212,7 @@ class Test_keyvault_logging_enabled: secrets=[], monitor_diagnostic_settings=[ DiagnosticSetting( - id="id2/id2", + id="id/ds1", logs=[ mock.MagicMock( category_group="audit", @@ -150,9 +225,360 @@ class Test_keyvault_logging_enabled: enabled=True, ), ], - storage_account_name="storage_account_name2", - storage_account_id="storage_account_id2", - name="name_diagnostic_setting2", + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_compliant", + ), + ], + ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_DISPLAY} has a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + + def test_diagnostic_setting_with_audit_event_category_logging(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=mock.MagicMock(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( + keyvault_logging_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id/ds1", + logs=[ + mock.MagicMock( + category_group=None, + category="AuditEvent", + enabled=True, + ), + mock.MagicMock( + category_group=None, + category="AzurePolicyEvaluationDetails", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_audit_event", + ), + ], + ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_DISPLAY} has a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + + def test_diagnostic_setting_with_audit_event_category_disabled(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=mock.MagicMock(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( + keyvault_logging_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id/ds1", + logs=[ + mock.MagicMock( + category_group=None, + category="AuditEvent", + enabled=False, + ), + mock.MagicMock( + category_group=None, + category="AzurePolicyEvaluationDetails", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_audit_event_disabled", + ), + ], + ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + + def test_multiple_diagnostic_settings_one_compliant(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=mock.MagicMock(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( + keyvault_logging_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id/ds1", + logs=[ + mock.MagicMock( + category_group="audit", + category="None", + enabled=False, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_noncompliant", + ), + DiagnosticSetting( + id="id/ds2", + logs=[ + mock.MagicMock( + category_group="audit", + category="None", + enabled=True, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=True, + ), + ], + storage_account_name="sa2", + storage_account_id="sa_id2", + name="ds_compliant", + ), + ], + ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + + def test_multiple_vaults_mixed(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=mock.MagicMock(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( + keyvault_logging_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + DiagnosticSetting, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id1", + name="vault_fail", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id1/ds1", + logs=[ + mock.MagicMock( + category_group="audit", + category="None", + enabled=True, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_incomplete", + ), + ], + ), + KeyVaultInfo( + id="id2", + name="vault_pass", + location="eastus", + resource_group="resource_group2", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id2/ds2", + logs=[ + mock.MagicMock( + category_group="audit", + category="None", + enabled=True, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=True, + ), + ], + storage_account_name="sa2", + storage_account_id="sa_id2", + name="ds_compliant", ), ], ), @@ -162,20 +588,8 @@ class Test_keyvault_logging_enabled: result = check.execute() assert len(result) == 2 assert result[0].status == "FAIL" - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "name_diagnostic_setting" - assert result[0].resource_id == "id/id" - assert result[0].location == "westeurope" - assert ( - result[0].status_extended - == f"Diagnostic setting name_diagnostic_setting for Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_ID} does not have audit logging." - ) + assert result[0].resource_name == "vault_fail" + assert result[0].resource_id == "id1" assert result[1].status == "PASS" - assert result[1].subscription == AZURE_SUBSCRIPTION_ID - assert result[1].resource_name == "name_diagnostic_setting2" - assert result[1].resource_id == "id2/id2" - assert result[1].location == "eastus" - assert ( - result[1].status_extended - == f"Diagnostic setting name_diagnostic_setting2 for Key Vault name_keyvault2 in subscription {AZURE_SUBSCRIPTION_ID} has audit logging." - ) + assert result[1].resource_name == "vault_pass" + assert result[1].resource_id == "id2" diff --git a/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py b/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py index 9a799a1a06..eb6036f9c7 100644 --- a/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py @@ -4,7 +4,9 @@ from uuid import uuid4 from azure.mgmt.keyvault.v2023_07_01.models import SecretAttributes, VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_non_rbac_secret_expiration_set: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -34,6 +37,7 @@ class Test_keyvault_non_rbac_secret_expiration_set: def test_no_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -76,6 +80,7 @@ class Test_keyvault_non_rbac_secret_expiration_set: def test_key_vaults_invalid_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) secret_name = "Secret" @@ -128,15 +133,16 @@ class Test_keyvault_non_rbac_secret_expiration_set: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the secret {secret_name} without expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == secret_name + assert result[0].resource_id == "id" + assert result[0].location == "location" def test_key_vaults_invalid_multiple_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) secret1_name = "Secret1" @@ -193,21 +199,19 @@ class Test_keyvault_non_rbac_secret_expiration_set: } check = keyvault_non_rbac_secret_expiration_set() result = check.execute() - assert len(result) == 1 + assert len(result) == 2 + assert result[0] is not result[1] assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the secret {secret1_name} without expiration date set." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert secret1_name in result[0].status_extended + assert result[1].status == "PASS" + assert secret2_name in result[1].status_extended - def test_key_vaults_valid_keys(self): + def test_key_vaults_valid_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + secret_name = "name" with ( mock.patch( @@ -229,10 +233,10 @@ class Test_keyvault_non_rbac_secret_expiration_set: secret = Secret( id="id", - name="name", - enabled=False, + name=secret_name, + enabled=True, location="location", - attributes=SecretAttributes(expires=None), + attributes=SecretAttributes(expires=84934), ) keyvault_client.key_vaults = { AZURE_SUBSCRIPTION_ID: [ @@ -257,9 +261,61 @@ class Test_keyvault_non_rbac_secret_expiration_set: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has all the secrets with expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == secret_name + assert result[0].resource_id == "id" + assert result[0].location == "location" + + def test_disabled_secret_skipped(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_non_rbac_secret_expiration_set.keyvault_non_rbac_secret_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_non_rbac_secret_expiration_set.keyvault_non_rbac_secret_expiration_set import ( + keyvault_non_rbac_secret_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + Secret, + ) + + secret = Secret( + id="id", + name="disabled_secret", + enabled=False, + location="location", + attributes=SecretAttributes(expires=None), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[secret], + ) + ] + } + check = keyvault_non_rbac_secret_expiration_set() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints_test.py b/tests/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints_test.py index e071453485..783ba6c319 100644 --- a/tests/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_private_endpoints/keyvault_private_endpoints_test.py @@ -7,7 +7,9 @@ from azure.mgmt.keyvault.v2023_07_01.models import ( ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -15,6 +17,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_private_endpoints: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -37,6 +40,7 @@ class Test_keyvault_private_endpoints: def test_key_vaults_no_private_endpoints(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -82,7 +86,7 @@ class Test_keyvault_private_endpoints: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} is not using private endpoints." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using private endpoints." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name @@ -91,6 +95,7 @@ class Test_keyvault_private_endpoints: def test_key_vaults_using_private_endpoints(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) private_endpoint = PrivateEndpointConnectionItem( @@ -141,7 +146,7 @@ class Test_keyvault_private_endpoints: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} is using private endpoints." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using private endpoints." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name diff --git a/tests/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled_test.py b/tests/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled_test.py index 00729ab0bf..be72938b0e 100644 --- a/tests/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_rbac_enabled/keyvault_rbac_enabled_test.py @@ -4,7 +4,9 @@ from uuid import uuid4 from azure.mgmt.keyvault.v2023_07_01.models import VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_rbac_enabled: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -34,6 +37,7 @@ class Test_keyvault_rbac_enabled: def test_key_vaults_no_rbac(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -77,7 +81,7 @@ class Test_keyvault_rbac_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} is not using RBAC for access control." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using RBAC for access control." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name @@ -86,6 +90,7 @@ class Test_keyvault_rbac_enabled: def test_key_vaults_rbac(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -129,7 +134,7 @@ class Test_keyvault_rbac_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} is using RBAC for access control." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using RBAC for access control." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name diff --git a/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py b/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py index c9034e7e87..b73948adef 100644 --- a/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py @@ -4,7 +4,9 @@ from uuid import uuid4 from azure.mgmt.keyvault.v2023_07_01.models import KeyAttributes, VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_rbac_key_expiration_set: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -34,6 +37,7 @@ class Test_keyvault_rbac_key_expiration_set: def test_no_keys(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -75,6 +79,7 @@ class Test_keyvault_rbac_key_expiration_set: def test_key_vaults_invalid_keys(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) key_name = "Key Name" @@ -127,17 +132,19 @@ class Test_keyvault_rbac_key_expiration_set: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} without expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" + assert result[0].location == "location" def test_key_vaults_valid_keys(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + key_name = "Key Name" with ( mock.patch( @@ -159,7 +166,7 @@ class Test_keyvault_rbac_key_expiration_set: key = Key( id="id", - name="name", + name=key_name, enabled=True, location="location", attributes=KeyAttributes(expires=49394, enabled=True), @@ -187,9 +194,128 @@ class Test_keyvault_rbac_key_expiration_set: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has all the keys with expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" + assert result[0].location == "location" + + def test_disabled_key_skipped(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set import ( + keyvault_rbac_key_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + key = Key( + id="id", + name="disabled_key", + enabled=False, + location="location", + attributes=KeyAttributes(expires=None, enabled=False), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=True, + ), + keys=[key], + secrets=[], + ) + ] + } + check = keyvault_rbac_key_expiration_set() + result = check.execute() + assert len(result) == 0 + + def test_multiple_keys_mixed_expiration(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + key_with_expiry = "key_with_expiry" + key_without_expiry = "key_without_expiry" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set import ( + keyvault_rbac_key_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=True, + ), + keys=[ + Key( + id="id1", + name=key_without_expiry, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + ), + Key( + id="id2", + name=key_with_expiry, + enabled=True, + location="location", + attributes=KeyAttributes(expires=49394, enabled=True), + ), + ], + secrets=[], + ) + ] + } + check = keyvault_rbac_key_expiration_set() + result = check.execute() + assert len(result) == 2 + assert result[0] is not result[1] + assert result[0].status == "FAIL" + assert key_without_expiry in result[0].status_extended + assert result[1].status == "PASS" + assert key_with_expiry in result[1].status_extended diff --git a/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py b/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py index d46bc7b223..dffcca1f7a 100644 --- a/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py @@ -4,7 +4,9 @@ from uuid import uuid4 from azure.mgmt.keyvault.v2023_07_01.models import SecretAttributes, VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_rbac_secret_expiration_set: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -34,6 +37,7 @@ class Test_keyvault_rbac_secret_expiration_set: def test_no_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -75,6 +79,7 @@ class Test_keyvault_rbac_secret_expiration_set: def test_key_vaults_invalid_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) secret_name = "Secret" @@ -97,12 +102,11 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) - secret_id = str(uuid4()) secret = Secret( - id=secret_id, + id="id", name=secret_name, enabled=True, - location="westeurope", + location="location", attributes=SecretAttributes(expires=None), ) keyvault_client.key_vaults = { @@ -128,15 +132,16 @@ class Test_keyvault_rbac_secret_expiration_set: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Secret '{secret_name}' in KeyVault '{keyvault_name}' does not have expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == secret_name - assert result[0].resource_id == secret_id - assert result[0].location == "westeurope" + assert result[0].resource_id == "id" + assert result[0].location == "location" def test_key_vaults_invalid_multiple_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) secret1_name = "Secret1" @@ -160,20 +165,18 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) - secret1_id = str(uuid4()) - secret2_id = str(uuid4()) secret1 = Secret( - id=secret1_id, + id="id", name=secret1_name, enabled=True, - location="westeurope", + location="location", attributes=SecretAttributes(expires=None), ) secret2 = Secret( - id=secret2_id, + id="id", name=secret2_name, enabled=True, - location="westeurope", + location="location", attributes=SecretAttributes(expires=84934), ) keyvault_client.key_vaults = { @@ -195,40 +198,19 @@ class Test_keyvault_rbac_secret_expiration_set: } check = keyvault_rbac_secret_expiration_set() result = check.execute() - # Now we get 1 finding per secret (2 total) assert len(result) == 2 + assert result[0] is not result[1] + assert result[0].status == "FAIL" + assert secret1_name in result[0].status_extended + assert result[1].status == "PASS" + assert secret2_name in result[1].status_extended - # Find the FAIL and PASS results by status - fail_results = [r for r in result if r.status == "FAIL"] - pass_results = [r for r in result if r.status == "PASS"] - - assert len(fail_results) == 1 - assert len(pass_results) == 1 - - # Verify FAIL finding (secret1 without expiration) - assert ( - fail_results[0].status_extended - == f"Secret '{secret1_name}' in KeyVault '{keyvault_name}' does not have expiration date set." - ) - assert fail_results[0].subscription == AZURE_SUBSCRIPTION_ID - assert fail_results[0].resource_name == secret1_name - assert fail_results[0].resource_id == secret1_id - assert fail_results[0].location == "westeurope" - - # Verify PASS finding (secret2 with expiration) - assert ( - pass_results[0].status_extended - == f"Secret '{secret2_name}' in KeyVault '{keyvault_name}' has expiration date set." - ) - assert pass_results[0].subscription == AZURE_SUBSCRIPTION_ID - assert pass_results[0].resource_name == secret2_name - assert pass_results[0].resource_id == secret2_id - assert pass_results[0].location == "westeurope" - - def test_key_vaults_valid_keys(self): + def test_key_vaults_valid_secrets(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + secret_name = "name" with ( mock.patch( @@ -248,14 +230,12 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) - secret_name = "secret-name" - secret_id = str(uuid4()) secret = Secret( - id=secret_id, + id="id", name=secret_name, - enabled=False, - location="westeurope", - attributes=SecretAttributes(expires=None), + enabled=True, + location="location", + attributes=SecretAttributes(expires=84934), ) keyvault_client.key_vaults = { AZURE_SUBSCRIPTION_ID: [ @@ -280,9 +260,61 @@ class Test_keyvault_rbac_secret_expiration_set: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Secret '{secret_name}' in KeyVault '{keyvault_name}' has expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == secret_name - assert result[0].resource_id == secret_id - assert result[0].location == "westeurope" + assert result[0].resource_id == "id" + assert result[0].location == "location" + + def test_disabled_secret_skipped(self): + keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_rbac_secret_expiration_set.keyvault_rbac_secret_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_rbac_secret_expiration_set.keyvault_rbac_secret_expiration_set import ( + keyvault_rbac_secret_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + Secret, + ) + + secret = Secret( + id="id", + name="disabled_secret", + enabled=False, + location="location", + attributes=SecretAttributes(expires=None), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=True, + ), + keys=[], + secrets=[secret], + ) + ] + } + check = keyvault_rbac_secret_expiration_set() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable_test.py b/tests/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable_test.py index 733683d7a1..f0a0592e02 100644 --- a/tests/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_recoverable/keyvault_recoverable_test.py @@ -4,7 +4,9 @@ from uuid import uuid4 from azure.mgmt.keyvault.v2023_07_01.models import SecretAttributes, VaultProperties from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ class Test_keyvault_recoverable: def test_no_key_vaults(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_client.key_vaults = {} with ( @@ -35,6 +38,7 @@ class Test_keyvault_recoverable: def test_key_vaults_no_purge(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -80,7 +84,7 @@ class Test_keyvault_recoverable: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} is not recoverable." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not recoverable." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name @@ -89,6 +93,7 @@ class Test_keyvault_recoverable: def test_key_vaults_no_soft_delete(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -149,7 +154,7 @@ class Test_keyvault_recoverable: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} is not recoverable." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not recoverable." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name @@ -158,6 +163,7 @@ class Test_keyvault_recoverable: def test_key_vaults_valid_configuration(self): keyvault_client = mock.MagicMock + keyvault_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) @@ -211,7 +217,7 @@ class Test_keyvault_recoverable: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} is recoverable." + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is recoverable." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == keyvault_name diff --git a/tests/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment_test.py b/tests/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment_test.py index 4e380d2c33..d785de72f4 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_create_policy_assignment/monitor_alert_create_policy_assignment_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_create_policy_assignment: def test_monitor_alert_create_policy_assignment_no_subscriptions(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( @@ -34,7 +37,7 @@ class Test_monitor_alert_create_policy_assignment: def test_no_alert_rules(self): monitor_client = mock.MagicMock monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -58,11 +61,12 @@ class Test_monitor_alert_create_policy_assignment: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for creating Policy Assignments in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for creating Policy Assignments in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -125,5 +129,5 @@ class Test_monitor_alert_create_policy_assignment: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for creating Policy Assignments in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for creating Policy Assignments in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg_test.py b/tests/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg_test.py index ef620f468b..224a2eaca3 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_create_update_nsg/monitor_alert_create_update_nsg_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_create_update_nsg: def test_monitor_alert_create_update_nsg_no_subscriptions(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_create_update_nsg: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_create_update_nsg: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for creating/updating Network Security Groups in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for creating/updating Network Security Groups in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -123,5 +127,5 @@ class Test_monitor_alert_create_update_nsg: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for creating/updating Network Security Groups in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for creating/updating Network Security Groups in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule_test.py b/tests/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule_test.py index 987532ed24..0917f1f381 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_create_update_public_ip_address_rule/monitor_alert_create_update_public_ip_address_rule_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_create_update_security_solution: def test_monitor_alert_create_update_public_ip_address_rule_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_create_update_security_solution: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for creating/updating Public IP address rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for creating/updating Public IP address rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -124,5 +128,5 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for creating/updating Public IP address rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for creating/updating Public IP address rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution_test.py b/tests/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution_test.py index ba7f475ca3..8638ba0718 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_create_update_security_solution/monitor_alert_create_update_security_solution_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_create_update_security_solution: def test_monitor_alert_create_update_security_solution_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_create_update_security_solution: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for creating/updating Security Solution in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for creating/updating Security Solution in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -124,5 +128,5 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for creating/updating Security Solution in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for creating/updating Security Solution in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr_test.py b/tests/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr_test.py index 7195e321e3..d56c7e7ba8 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_create_update_sqlserver_fr/monitor_alert_create_update_sqlserver_fr_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_create_update_sqlserver_fr: def test_monitor_alert_create_update_sqlserver_fr_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_create_update_sqlserver_fr: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_create_update_sqlserver_fr: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for creating/updating SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for creating/updating SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -124,5 +128,5 @@ class Test_monitor_alert_create_update_sqlserver_fr: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for creating/updating SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for creating/updating SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg_test.py b/tests/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg_test.py index 161b0de95d..a06a40b532 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_delete_nsg/monitor_alert_delete_nsg_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_delete_nsg: def test_monitor_alert_delete_nsg_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_delete_nsg: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_delete_nsg: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for deleting Network Security Groups in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for deleting Network Security Groups in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -124,5 +128,5 @@ class Test_monitor_alert_delete_nsg: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for deleting Network Security Groups in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for deleting Network Security Groups in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment_test.py b/tests/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment_test.py index 1f52f15480..afa492a468 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_delete_policy_assignment/monitor_alert_delete_policy_assignment_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_delete_policy_assignment: def test_monitor_alert_delete_policy_assignment_no_subscriptions(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( @@ -34,7 +37,7 @@ class Test_monitor_alert_delete_policy_assignment: def test_no_alert_rules(self): monitor_client = mock.MagicMock monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -58,11 +61,12 @@ class Test_monitor_alert_delete_policy_assignment: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for deleting policy assignment in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for deleting policy assignment in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -125,5 +129,5 @@ class Test_monitor_alert_delete_policy_assignment: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for deleting policy assignment in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for deleting policy assignment in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule_test.py b/tests/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule_test.py index 79aef33e20..44fdfd41c8 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_delete_public_ip_address_rule/monitor_alert_delete_public_ip_address_rule_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_create_update_security_solution: def test_monitor_alert_delete_public_ip_address_rule_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_create_update_security_solution: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for deleting public IP address rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for deleting public IP address rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -124,5 +128,5 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for deleting public IP address rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for deleting public IP address rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution_test.py b/tests/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution_test.py index ba80bfff41..3c204de572 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_delete_security_solution/monitor_alert_delete_security_solution_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_create_update_security_solution: def test_monitor_alert_delete_security_solution_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_create_update_security_solution: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for deleting Security Solution in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for deleting Security Solution in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -124,5 +128,5 @@ class Test_monitor_alert_create_update_security_solution: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for deleting Security Solution in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for deleting Security Solution in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr_test.py b/tests/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr_test.py index 2725fafd19..1a0a63a869 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_delete_sqlserver_fr/monitor_alert_delete_sqlserver_fr_test.py @@ -3,7 +3,9 @@ from unittest import mock from azure.mgmt.monitor.models import AlertRuleAnyOfOrLeafCondition from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_delete_sqlserver_fr: def test_monitor_alert_delete_sqlserver_fr_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -33,7 +36,7 @@ class Test_monitor_alert_delete_sqlserver_fr: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,11 +60,12 @@ class Test_monitor_alert_delete_sqlserver_fr: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is not an alert for deleting SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is not an alert for deleting SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -124,5 +128,5 @@ class Test_monitor_alert_delete_sqlserver_fr: assert result[0].resource_id == "id2" assert ( result[0].status_extended - == f"There is an alert configured for deleting SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an alert configured for deleting SQL Server firewall rule in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists_test.py b/tests/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists_test.py index 3cfd80bedf..ba928af0e3 100644 --- a/tests/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists_test.py +++ b/tests/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists_test.py @@ -1,7 +1,9 @@ from unittest import mock from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -9,6 +11,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_monitor_alert_service_health_exists: def test_monitor_alert_service_health_exists_no_subscriptions(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.alert_rules = {} with ( mock.patch( @@ -31,7 +34,7 @@ class Test_monitor_alert_service_health_exists: def test_no_alert_rules(self): monitor_client = mock.MagicMock() monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -55,11 +58,12 @@ class Test_monitor_alert_service_health_exists: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is no activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is no activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -108,11 +112,12 @@ class Test_monitor_alert_service_health_exists: assert result[0].resource_id == "id1" assert ( result[0].status_extended - == f"There is an activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is an activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_alert_rules_configured_but_disabled(self): monitor_client = mock.MagicMock() + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -153,7 +158,7 @@ class Test_monitor_alert_service_health_exists: ] } monitor_client.subscriptions = { - AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME } check = monitor_alert_service_health_exists() result = check.execute() @@ -164,5 +169,5 @@ class Test_monitor_alert_service_health_exists: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"There is no activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_ID}." + == f"There is no activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories_test.py b/tests/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories_test.py index 56d10199bc..6c111b76f8 100644 --- a/tests/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories_test.py +++ b/tests/providers/azure/services/monitor/monitor_diagnostic_setting_with_appropriate_categories/monitor_diagnostic_setting_with_appropriate_categories_test.py @@ -1,7 +1,9 @@ from unittest import mock from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ class Test_monitor_diagnostic_setting_with_appropriate_categories: self, ): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.diagnostics_settings = {} with ( @@ -34,7 +37,7 @@ class Test_monitor_diagnostic_setting_with_appropriate_categories: def test_no_diagnostic_settings(self): monitor_client = mock.MagicMock monitor_client.diagnostics_settings = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -58,11 +61,12 @@ class Test_monitor_diagnostic_setting_with_appropriate_categories: assert result[0].resource_name == AZURE_SUBSCRIPTION_ID assert ( result[0].status_extended - == f"No diagnostic setting captures all appropriate categories (Administrative, Security, Alert, Policy) in subscription {AZURE_SUBSCRIPTION_ID}." + == f"No diagnostic setting captures all appropriate categories (Administrative, Security, Alert, Policy) in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_diagnostic_settings_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -128,5 +132,5 @@ class Test_monitor_diagnostic_setting_with_appropriate_categories: assert result[0].resource_name == "name" assert ( result[0].status_extended - == f"Diagnostic setting name captures appropriate categories in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Diagnostic setting name captures appropriate categories in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py b/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py index a4638ffac7..4bac8b5bd7 100644 --- a/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py +++ b/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py @@ -1,7 +1,9 @@ from unittest import mock from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ class Test_monitor_diagnostic_settings_exists: self, ): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.diagnostics_settings = {} with ( @@ -34,7 +37,7 @@ class Test_monitor_diagnostic_settings_exists: def test_no_diagnostic_settings(self): monitor_client = mock.MagicMock monitor_client.diagnostics_settings = {AZURE_SUBSCRIPTION_ID: []} - monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -58,12 +61,14 @@ class Test_monitor_diagnostic_settings_exists: assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert ( result[0].status_extended - == f"No diagnostic settings found in subscription {AZURE_SUBSCRIPTION_ID}." + == f"No diagnostic settings found in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_diagnostic_settings_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -196,5 +201,5 @@ class Test_monitor_diagnostic_settings_exists: assert result[0].resource_id == "id" assert ( result[0].status_extended - == f"Diagnostic setting name found in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Diagnostic setting name found in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py index 707fd11af2..cf4dbcc54b 100644 --- a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py +++ b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py @@ -1,7 +1,9 @@ from unittest import mock from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: self, ): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.diagnostics_settings = {} with ( @@ -33,6 +36,7 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: def test_no_diagnostic_settings(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.diagnostics_settings = {AZURE_SUBSCRIPTION_ID: []} with ( mock.patch( @@ -54,7 +58,9 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: def test_diagnostic_settings_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -191,7 +197,7 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: ) assert ( result[0].status_extended - == f"Storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][0].name} storing activity log in subscription {AZURE_SUBSCRIPTION_ID} is encrypted with Customer Managed Key or not necessary." + == f"Storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][0].name} storing activity log in subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with Customer Managed Key or not necessary." ) assert result[1].status == "FAIL" assert result[1].resource_name == "storageaccountname2" @@ -202,5 +208,5 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: ) assert ( result[1].status_extended - == f"Storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][1].name} storing activity log in subscription {AZURE_SUBSCRIPTION_ID} is not encrypted with Customer Managed Key." + == f"Storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][1].name} storing activity log in subscription {AZURE_SUBSCRIPTION_DISPLAY} is not encrypted with Customer Managed Key." ) diff --git a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py index debcab0321..0e69470251 100644 --- a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py +++ b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py @@ -1,7 +1,9 @@ from unittest import mock from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ class Test_monitor_storage_account_with_activity_logs_is_private: self, ): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.diagnostics_settings = {} with ( @@ -33,6 +36,7 @@ class Test_monitor_storage_account_with_activity_logs_is_private: def test_no_diagnostic_settings(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} monitor_client.diagnostics_settings = {AZURE_SUBSCRIPTION_ID: []} with ( mock.patch( @@ -54,7 +58,9 @@ class Test_monitor_storage_account_with_activity_logs_is_private: def test_diagnostic_settings_configured(self): monitor_client = mock.MagicMock + monitor_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -190,7 +196,7 @@ class Test_monitor_storage_account_with_activity_logs_is_private: assert result[0].resource_name == "storageaccountname1" assert ( result[0].status_extended - == f"Blob public access enabled in storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][0].name} storing activity logs in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Blob public access enabled in storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][0].name} storing activity logs in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[1].subscription == AZURE_SUBSCRIPTION_ID assert result[1].status == "PASS" @@ -202,5 +208,5 @@ class Test_monitor_storage_account_with_activity_logs_is_private: assert result[1].resource_name == "storageaccountname2" assert ( result[1].status_extended - == f"Blob public access disabled in storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][1].name} storing activity logs in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Blob public access disabled in storage account {storage_client.storage_accounts[AZURE_SUBSCRIPTION_ID][1].name} storing activity logs in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py index 47ef92551b..e6eccbbd4a 100644 --- a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.mysql.mysql_service import ( FlexibleServer, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_mysql_flexible_server_audit_log_connection_activated: def test_mysql_no_subscriptions(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {} with ( @@ -36,6 +39,7 @@ class Test_mysql_flexible_server_audit_log_connection_activated: def test_mysql_no_servers(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -59,6 +63,7 @@ class Test_mysql_flexible_server_audit_log_connection_activated: def test_mysql_audit_log_connection_activated_lowercase(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -104,12 +109,13 @@ class Test_mysql_flexible_server_audit_log_connection_activated: ) assert ( result[0].status_extended - == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_audit_log_connection_not_connection(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -155,12 +161,13 @@ class Test_mysql_flexible_server_audit_log_connection_activated: ) assert ( result[0].status_extended - == f"Audit log is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Audit log is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_audit_log_connection_activated(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -206,12 +213,13 @@ class Test_mysql_flexible_server_audit_log_connection_activated: ) assert ( result[0].status_extended - == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_audit_log_connection_activated_with_other_options(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -257,5 +265,5 @@ class Test_mysql_flexible_server_audit_log_connection_activated: ) assert ( result[0].status_extended - == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py index 7c32f337fd..b4bb7c9925 100644 --- a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.mysql.mysql_service import ( FlexibleServer, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_mysql_flexible_server_audit_log_enabled: def test_mysql_no_subscriptions(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {} with ( @@ -36,6 +39,7 @@ class Test_mysql_flexible_server_audit_log_enabled: def test_mysql_no_servers(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -59,6 +63,7 @@ class Test_mysql_flexible_server_audit_log_enabled: def test_mysql_audit_log_enabled_lowercase(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -104,12 +109,13 @@ class Test_mysql_flexible_server_audit_log_enabled: ) assert ( result[0].status_extended - == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_audit_log_disabled(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -155,12 +161,13 @@ class Test_mysql_flexible_server_audit_log_enabled: ) assert ( result[0].status_extended - == f"Audit log is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Audit log is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_audit_log_enabled(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -206,5 +213,5 @@ class Test_mysql_flexible_server_audit_log_enabled: ) assert ( result[0].status_extended - == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..c5e1f27de2 --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,125 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_geo_redundant_backup_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Geo-redundant backup is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Geo-redundant backup is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py new file mode 100644 index 0000000000..c1084ec47b --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py @@ -0,0 +1,166 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_high_availability_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_high_availability_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_high_availability_not_set(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + + def test_mysql_high_availability_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="ZoneRedundant", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"High availability is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12_test.py index 8d277dad77..d8571f61e9 100644 --- a/tests/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12_test.py +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.mysql.mysql_service import ( FlexibleServer, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_mysql_flexible_server_minimum_tls_version_12: def test_mysql_no_subscriptions(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {} with ( @@ -36,6 +39,7 @@ class Test_mysql_flexible_server_minimum_tls_version_12: def test_mysql_no_servers(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -59,6 +63,7 @@ class Test_mysql_flexible_server_minimum_tls_version_12: def test_mysql_no_tls_configuration(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -94,12 +99,13 @@ class Test_mysql_flexible_server_minimum_tls_version_12: assert result[0].location == "location" assert ( result[0].status_extended - == f"TLS version is not configured in server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"TLS version is not configured in server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_flexible_server_minimum_tls_version_12(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -144,12 +150,13 @@ class Test_mysql_flexible_server_minimum_tls_version_12: ) assert ( result[0].status_extended - == f"TLS version is TLSv1.2 in server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}. This version of TLS is considered secure." + == f"TLS version is TLSv1.2 in server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}. This version of TLS is considered secure." ) def test_mysql_tls_version_is_1_3(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -194,12 +201,13 @@ class Test_mysql_flexible_server_minimum_tls_version_12: ) assert ( result[0].status_extended - == f"TLS version is TLSv1.3 in server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}. This version of TLS is considered secure." + == f"TLS version is TLSv1.3 in server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}. This version of TLS is considered secure." ) def test_mysql_tls_version_is_not_1_2(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -244,12 +252,13 @@ class Test_mysql_flexible_server_minimum_tls_version_12: ) assert ( result[0].status_extended - == f"TLS version is TLSv1.1 in server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}. There is at leat one version of TLS that is considered insecure." + == f"TLS version is TLSv1.1 in server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}. There is at leat one version of TLS that is considered insecure." ) def test_mysql_tls_version_is_1_1_and_1_3(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -294,5 +303,5 @@ class Test_mysql_flexible_server_minimum_tls_version_12: ) assert ( result[0].status_extended - == f"TLS version is TLSv1.1,TLSv1.3 in server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}. There is at leat one version of TLS that is considered insecure." + == f"TLS version is TLSv1.1,TLSv1.3 in server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}. There is at leat one version of TLS that is considered insecure." ) diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py index 2b87a28d8f..0c521458f9 100644 --- a/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.mysql.mysql_service import ( FlexibleServer, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_mysql_flexible_server_ssl_connection_enabled: def test_mysql_no_subscriptions(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {} with ( @@ -36,6 +39,7 @@ class Test_mysql_flexible_server_ssl_connection_enabled: def test_mysql_no_servers(self): mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -59,6 +63,7 @@ class Test_mysql_flexible_server_ssl_connection_enabled: def test_mysql_connection_enabled(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -104,12 +109,13 @@ class Test_mysql_flexible_server_ssl_connection_enabled: ) assert ( result[0].status_extended - == f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_connection_enabled_lowercase(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -155,12 +161,13 @@ class Test_mysql_flexible_server_ssl_connection_enabled: ) assert ( result[0].status_extended - == f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_ssl_connection_disabled(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -206,12 +213,13 @@ class Test_mysql_flexible_server_ssl_connection_enabled: ) assert ( result[0].status_extended - == f"SSL connection is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"SSL connection is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_ssl_connection_no_configuration(self): server_name = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id": FlexibleServer( @@ -248,13 +256,14 @@ class Test_mysql_flexible_server_ssl_connection_enabled: assert result[0].location == "location" assert ( result[0].status_extended - == f"SSL connection is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"SSL connection is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_mysql_ssl_connection_enabled_and_disabled(self): server_name_1 = str(uuid4()) server_name_2 = str(uuid4()) mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mysql_client.flexible_servers = { AZURE_SUBSCRIPTION_ID: { "/subscriptions/resource_id1": FlexibleServer( @@ -313,7 +322,7 @@ class Test_mysql_flexible_server_ssl_connection_enabled: ) assert ( result[0].status_extended - == f"SSL connection is enabled for server {server_name_1} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"SSL connection is enabled for server {server_name_1} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[1].status == "FAIL" assert result[1].subscription == AZURE_SUBSCRIPTION_ID @@ -325,5 +334,5 @@ class Test_mysql_flexible_server_ssl_connection_enabled: ) assert ( result[1].status_extended - == f"SSL connection is disabled for server {server_name_2} in subscription {AZURE_SUBSCRIPTION_ID}." + == f"SSL connection is disabled for server {server_name_2} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists_test.py b/tests/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists_test.py index 4d5d1b49f1..0a5a2c7d46 100644 --- a/tests/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists_test.py +++ b/tests/providers/azure/services/network/network_bastion_host_exists/network_bastion_host_exists_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.network.network_service import BastionHost from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,7 +14,7 @@ class Test_network_bastion_host_exists: def test_no_bastion_hosts(self): network_client = mock.MagicMock network_client.bastion_hosts = {AZURE_SUBSCRIPTION_ID: []} - network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_ID} + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( @@ -38,7 +40,7 @@ class Test_network_bastion_host_exists: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Bastion Host from subscription {AZURE_SUBSCRIPTION_ID} does not exist" + == f"Bastion Host from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not exist" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == AZURE_SUBSCRIPTION_ID @@ -46,6 +48,7 @@ class Test_network_bastion_host_exists: def test_network_bastion_host_exists(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} bastion_host_name = "Bastion Host Name" bastion_host_id = str(uuid4()) @@ -83,7 +86,7 @@ class Test_network_bastion_host_exists: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Bastion Host {bastion_host_name} exists in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Bastion Host {bastion_host_name} exists in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == bastion_host_name diff --git a/tests/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent_test.py b/tests/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent_test.py index 24ac2c76d5..cbd29e1d33 100644 --- a/tests/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent_test.py +++ b/tests/providers/azure/services/network/network_flow_log_captured_sent/network_flow_log_captured_sent_test.py @@ -1,18 +1,30 @@ from unittest import mock from uuid import uuid4 -from azure.mgmt.network.models import FlowLog, RetentionPolicyParameters - -from prowler.providers.azure.services.network.network_service import NetworkWatcher -from tests.providers.azure.azure_fixtures import AZURE_SUBSCRIPTION_ID +from prowler.providers.azure.services.network.network_service import ( + FlowLog, + NetworkWatcher, + RetentionPolicy, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) class Test_network_flow_log_captured_sent: def test_no_network_watchers(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_client.network_watchers = {} with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), mock.patch( "prowler.providers.azure.services.network.network_service.Network", new=network_client, @@ -32,6 +44,7 @@ class Test_network_flow_log_captured_sent: def test_network_network_watchers_no_flow_logs(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -47,6 +60,10 @@ class Test_network_flow_log_captured_sent: } with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), mock.patch( "prowler.providers.azure.services.network.network_service.Network", new=network_client, @@ -66,7 +83,7 @@ class Test_network_flow_log_captured_sent: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has no flow logs" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has no flow logs" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name @@ -75,6 +92,7 @@ class Test_network_flow_log_captured_sent: def test_network_network_watchers_flow_logs_disabled(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -86,8 +104,11 @@ class Test_network_flow_log_captured_sent: location="location", flow_logs=[ FlowLog( + id=str(uuid4()), + name="disabled-flow-log", enabled=False, - retention_policy=RetentionPolicyParameters(days=90), + target_resource_id=None, + retention_policy=RetentionPolicy(days=90), ) ], ) @@ -95,6 +116,10 @@ class Test_network_flow_log_captured_sent: } with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), mock.patch( "prowler.providers.azure.services.network.network_service.Network", new=network_client, @@ -114,7 +139,7 @@ class Test_network_flow_log_captured_sent: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has flow logs disabled" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has flow logs disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name @@ -123,6 +148,7 @@ class Test_network_flow_log_captured_sent: def test_network_network_watchers_flow_logs_well_configured(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -134,8 +160,11 @@ class Test_network_flow_log_captured_sent: location="location", flow_logs=[ FlowLog( + id=str(uuid4()), + name="workspace-disabled", enabled=True, - retention_policy=RetentionPolicyParameters(days=90), + target_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/test-vnet", + retention_policy=RetentionPolicy(days=90), ) ], ) @@ -143,6 +172,185 @@ class Test_network_flow_log_captured_sent: } with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_service.Network", + new=network_client, + ) as service_client, + mock.patch( + "prowler.providers.azure.services.network.network_client.network_client", + new=service_client, + ), + ): + from prowler.providers.azure.services.network.network_flow_log_captured_sent.network_flow_log_captured_sent import ( + network_flow_log_captured_sent, + ) + + check = network_flow_log_captured_sent() + result = check.execute() + assert len(result) == 1 + assert result[0].location == "location" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == network_watcher_name + assert result[0].resource_id == network_watcher_id + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has enabled flow logs that are not configured to send traffic analytics to a Log Analytics workspace" + ) + + def test_network_network_watchers_traffic_analytics_without_workspace(self): + network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + network_watcher_name = "Network Watcher Name" + network_watcher_id = str(uuid4()) + + network_client.network_watchers = { + AZURE_SUBSCRIPTION_ID: [ + NetworkWatcher( + id=network_watcher_id, + name=network_watcher_name, + location="location", + flow_logs=[ + FlowLog( + id=str(uuid4()), + name="ta-without-workspace", + enabled=True, + target_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/test-vnet", + retention_policy=RetentionPolicy(days=90), + traffic_analytics_enabled=True, + workspace_resource_id=None, + ) + ], + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_service.Network", + new=network_client, + ) as service_client, + mock.patch( + "prowler.providers.azure.services.network.network_client.network_client", + new=service_client, + ), + ): + from prowler.providers.azure.services.network.network_flow_log_captured_sent.network_flow_log_captured_sent import ( + network_flow_log_captured_sent, + ) + + check = network_flow_log_captured_sent() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has enabled flow logs that are not configured to send traffic analytics to a Log Analytics workspace" + ) + + def test_network_network_watchers_mixed_flow_logs_fails(self): + network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + network_watcher_name = "Network Watcher Name" + network_watcher_id = str(uuid4()) + + network_client.network_watchers = { + AZURE_SUBSCRIPTION_ID: [ + NetworkWatcher( + id=network_watcher_id, + name=network_watcher_name, + location="location", + flow_logs=[ + FlowLog( + id=str(uuid4()), + name="vnet-flow-log-workspace-backed", + enabled=True, + target_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/test-vnet", + retention_policy=RetentionPolicy(days=90), + traffic_analytics_enabled=True, + workspace_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/test-law", + ), + FlowLog( + id=str(uuid4()), + name="nsg-flow-log-storage-only", + enabled=True, + target_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Network/networkSecurityGroups/test-nsg", + retention_policy=RetentionPolicy(days=90), + traffic_analytics_enabled=False, + workspace_resource_id=None, + ), + ], + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_service.Network", + new=network_client, + ) as service_client, + mock.patch( + "prowler.providers.azure.services.network.network_client.network_client", + new=service_client, + ), + ): + from prowler.providers.azure.services.network.network_flow_log_captured_sent.network_flow_log_captured_sent import ( + network_flow_log_captured_sent, + ) + + check = network_flow_log_captured_sent() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has enabled flow logs that are not configured to send traffic analytics to a Log Analytics workspace" + ) + + def test_network_network_watchers_vnet_flow_logs_well_configured(self): + network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + network_watcher_name = "Network Watcher Name" + network_watcher_id = str(uuid4()) + + network_client.network_watchers = { + AZURE_SUBSCRIPTION_ID: [ + NetworkWatcher( + id=network_watcher_id, + name=network_watcher_name, + location="location", + flow_logs=[ + FlowLog( + id=str(uuid4()), + name="vnet-flow-log", + enabled=True, + target_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/test-vnet", + retention_policy=RetentionPolicy(days=90), + traffic_analytics_enabled=True, + workspace_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/test-law", + ) + ], + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), mock.patch( "prowler.providers.azure.services.network.network_service.Network", new=network_client, @@ -163,7 +371,7 @@ class Test_network_flow_log_captured_sent: assert result[0].location == "location" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has flow logs that are captured and sent to Log Analytics workspace" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has flow logs that are captured and sent to Log Analytics workspace" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name diff --git a/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py b/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py index 1a8f13e280..771a3e2809 100644 --- a/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py +++ b/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py @@ -1,11 +1,15 @@ from unittest import mock from uuid import uuid4 -from azure.mgmt.network.models import FlowLog, RetentionPolicyParameters - -from prowler.providers.azure.services.network.network_service import NetworkWatcher +from prowler.providers.azure.services.network.network_service import ( + FlowLog, + NetworkWatcher, + RetentionPolicy, +) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +17,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_network_flow_log_more_than_90_days: def test_no_network_watchers(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_client.network_watchers = {} with ( @@ -39,6 +44,7 @@ class Test_network_flow_log_more_than_90_days: def test_network_network_watchers_no_flow_logs(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -77,7 +83,7 @@ class Test_network_flow_log_more_than_90_days: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has no flow logs" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has no flow logs" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name @@ -86,6 +92,7 @@ class Test_network_flow_log_more_than_90_days: def test_network_network_watchers_flow_logs_disabled(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -97,8 +104,11 @@ class Test_network_flow_log_more_than_90_days: location="location", flow_logs=[ FlowLog( + id=str(uuid4()), + name="disabled-flow-log", enabled=False, - retention_policy=RetentionPolicyParameters(days=90), + target_resource_id=None, + retention_policy=RetentionPolicy(days=90), ) ], ) @@ -129,7 +139,7 @@ class Test_network_flow_log_more_than_90_days: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has flow logs disabled" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has flow logs disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name @@ -138,6 +148,7 @@ class Test_network_flow_log_more_than_90_days: def test_network_network_watchers_flow_logs_retention_days_80(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -149,8 +160,11 @@ class Test_network_flow_log_more_than_90_days: location="location", flow_logs=[ FlowLog( + id=str(uuid4()), + name="retention-80", enabled=True, - retention_policy=RetentionPolicyParameters(days=80), + target_resource_id=None, + retention_policy=RetentionPolicy(days=80), ) ], ) @@ -181,7 +195,7 @@ class Test_network_flow_log_more_than_90_days: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} flow logs retention policy is less than 90 days" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} flow logs retention policy is less than 90 days" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name @@ -190,6 +204,7 @@ class Test_network_flow_log_more_than_90_days: def test_network_network_watchers_flow_logs_retention_days_0(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -201,8 +216,11 @@ class Test_network_flow_log_more_than_90_days: location="location", flow_logs=[ FlowLog( + id=str(uuid4()), + name="retention-0", enabled=True, - retention_policy=RetentionPolicyParameters(days=0), + target_resource_id=None, + retention_policy=RetentionPolicy(days=0), ) ], ) @@ -233,7 +251,7 @@ class Test_network_flow_log_more_than_90_days: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has flow logs enabled for more than 90 days" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has flow logs enabled for more than 90 days" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name @@ -242,6 +260,7 @@ class Test_network_flow_log_more_than_90_days: def test_network_network_watchers_flow_logs_well_configured(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher Name" network_watcher_id = str(uuid4()) @@ -253,8 +272,11 @@ class Test_network_flow_log_more_than_90_days: location="location", flow_logs=[ FlowLog( + id=str(uuid4()), + name="vnet-flow-log", enabled=True, - retention_policy=RetentionPolicyParameters(days=90), + target_resource_id="/subscriptions/test-sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/test-vnet", + retention_policy=RetentionPolicy(days=90), ) ], ) @@ -285,7 +307,7 @@ class Test_network_flow_log_more_than_90_days: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has flow logs enabled for more than 90 days" + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has flow logs enabled for more than 90 days" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name diff --git a/tests/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted_test.py b/tests/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted_test.py index 9b8959777e..cd598f2f17 100644 --- a/tests/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted_test.py +++ b/tests/providers/azure/services/network/network_http_internet_access_restricted/network_http_internet_access_restricted_test.py @@ -5,7 +5,9 @@ from azure.mgmt.network.models import SecurityRule from prowler.providers.azure.services.network.network_service import SecurityGroup from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_network_http_internet_access_restricted: def test_no_security_groups(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_client.security_groups = {} with ( @@ -39,6 +42,7 @@ class Test_network_http_internet_access_restricted: def test_network_security_groups_none_destination_port_range(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -85,7 +89,7 @@ class Test_network_http_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has HTTP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has HTTP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -94,6 +98,7 @@ class Test_network_http_internet_access_restricted: def test_network_security_groups_invalid_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -140,7 +145,7 @@ class Test_network_http_internet_access_restricted: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has HTTP internet access allowed." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has HTTP internet access allowed." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -149,6 +154,7 @@ class Test_network_http_internet_access_restricted: def test_network_security_groups_invalid_security_rules_range(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -195,7 +201,7 @@ class Test_network_http_internet_access_restricted: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has HTTP internet access allowed." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has HTTP internet access allowed." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -204,6 +210,7 @@ class Test_network_http_internet_access_restricted: def test_network_security_groups_valid_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -250,7 +257,7 @@ class Test_network_http_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has HTTP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has HTTP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name diff --git a/tests/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan_test.py b/tests/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan_test.py index a2d7f3753a..ab12877620 100644 --- a/tests/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan_test.py +++ b/tests/providers/azure/services/network/network_public_ip_shodan/network_public_ip_shodan_test.py @@ -2,7 +2,9 @@ from unittest import mock from prowler.providers.azure.services.network.network_service import PublicIp from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_network_public_ip_shodan: def test_no_public_ip_addresses(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_client.public_ip_addresses = {} with ( @@ -38,6 +41,7 @@ class Test_network_public_ip_shodan: def test_network_ip_in_shodan(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} public_ip_id = "id" public_ip_name = "name" ip_address = "ip_address" @@ -87,7 +91,7 @@ class Test_network_public_ip_shodan: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Public IP {ip_address} listed in Shodan with open ports {str(shodan_info['ports'])} and ISP {shodan_info['isp']} in {shodan_info['country_name']}. More info at https://www.shodan.io/host/{ip_address}." + == f"Public IP {ip_address} from subscription {AZURE_SUBSCRIPTION_DISPLAY} listed in Shodan with open ports {str(shodan_info['ports'])} and ISP {shodan_info['isp']} in {shodan_info['country_name']}. More info at https://www.shodan.io/host/{ip_address}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == public_ip_name diff --git a/tests/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted_test.py b/tests/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted_test.py index 9f8c9b145a..3f75cfe051 100644 --- a/tests/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted_test.py +++ b/tests/providers/azure/services/network/network_rdp_internet_access_restricted/network_rdp_internet_access_restricted_test.py @@ -5,7 +5,9 @@ from azure.mgmt.network.models import SecurityRule from prowler.providers.azure.services.network.network_service import SecurityGroup from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_network_rdp_internet_access_restricted: def test_no_security_groups(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_client.security_groups = {} with ( @@ -39,6 +42,7 @@ class Test_network_rdp_internet_access_restricted: def test_network_security_groups_none_destination_port_range(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -85,7 +89,7 @@ class Test_network_rdp_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has HTTP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has HTTP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -94,6 +98,7 @@ class Test_network_rdp_internet_access_restricted: def test_network_security_groups_no_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -132,7 +137,7 @@ class Test_network_rdp_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has RDP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has RDP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -141,6 +146,7 @@ class Test_network_rdp_internet_access_restricted: def test_network_security_groups_valid_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -187,7 +193,7 @@ class Test_network_rdp_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has RDP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has RDP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -196,6 +202,7 @@ class Test_network_rdp_internet_access_restricted: def test_network_security_groups_invalid_security_rules_range(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -242,7 +249,7 @@ class Test_network_rdp_internet_access_restricted: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has RDP internet access allowed." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has RDP internet access allowed." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name diff --git a/tests/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted_test.py b/tests/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted_test.py index 4472055075..f2112d72de 100644 --- a/tests/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted_test.py +++ b/tests/providers/azure/services/network/network_ssh_internet_access_restricted/network_ssh_internet_access_restricted_test.py @@ -5,7 +5,9 @@ from azure.mgmt.network.models import SecurityRule from prowler.providers.azure.services.network.network_service import SecurityGroup from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_network_ssh_internet_access_restricted: def test_no_security_groups(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_client.security_groups = {} with ( @@ -39,6 +42,7 @@ class Test_network_ssh_internet_access_restricted: def test_network_security_groups_none_destination_port_range(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -85,7 +89,7 @@ class Test_network_ssh_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has HTTP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has HTTP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -94,6 +98,7 @@ class Test_network_ssh_internet_access_restricted: def test_network_security_groups_no_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -132,7 +137,7 @@ class Test_network_ssh_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has SSH internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has SSH internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -141,6 +146,7 @@ class Test_network_ssh_internet_access_restricted: def test_network_security_groups_invalid_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -187,7 +193,7 @@ class Test_network_ssh_internet_access_restricted: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has SSH internet access allowed." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has SSH internet access allowed." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -196,6 +202,7 @@ class Test_network_ssh_internet_access_restricted: def test_network_security_groups_invalid_security_rules_range(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -242,7 +249,7 @@ class Test_network_ssh_internet_access_restricted: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has SSH internet access allowed." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has SSH internet access allowed." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -251,6 +258,7 @@ class Test_network_ssh_internet_access_restricted: def test_network_security_groups_valid_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -297,7 +305,7 @@ class Test_network_ssh_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has SSH internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has SSH internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name diff --git a/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py b/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py new file mode 100644 index 0000000000..4166c6f968 --- /dev/null +++ b/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py @@ -0,0 +1,188 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VNET_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/test-vnet" +SUBNET_ID = f"{VNET_ID}/subnets/test-subnet" +NSG_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/networkSecurityGroups/test-nsg" + + +class Test_network_subnet_nsg_associated: + def test_no_subscriptions(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + network_client.virtual_networks = {} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 0 + + def test_subnet_with_nsg(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[VNetSubnet(id=SUBNET_ID, name="test-subnet", nsg_id=NSG_ID)], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_subnet_without_nsg(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[VNetSubnet(id=SUBNET_ID, name="app-subnet", nsg_id=None)], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_gateway_subnet_excluded(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[ + VNetSubnet( + id=f"{VNET_ID}/subnets/GatewaySubnet", + name="GatewaySubnet", + nsg_id=None, + ) + ], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + # GatewaySubnet should be excluded + assert len(result) == 0 + + def test_mixed_subnets(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[ + VNetSubnet(id=f"{VNET_ID}/subnets/app", name="app", nsg_id=NSG_ID), + VNetSubnet(id=f"{VNET_ID}/subnets/db", name="db", nsg_id=None), + VNetSubnet( + id=f"{VNET_ID}/subnets/GatewaySubnet", + name="GatewaySubnet", + nsg_id=None, + ), + ], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + # 2 results: app (PASS) + db (FAIL), GatewaySubnet excluded + assert len(result) == 2 + statuses = {r.status for r in result} + assert "PASS" in statuses + assert "FAIL" in statuses diff --git a/tests/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted_test.py b/tests/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted_test.py index 7d519df326..18fd523657 100644 --- a/tests/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted_test.py +++ b/tests/providers/azure/services/network/network_udp_internet_access_restricted/network_udp_internet_access_restricted_test.py @@ -5,7 +5,9 @@ from azure.mgmt.network.models import SecurityRule from prowler.providers.azure.services.network.network_service import SecurityGroup from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_network_udp_internet_access_restricted: def test_no_security_groups(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_client.security_groups = {} with ( @@ -39,6 +42,7 @@ class Test_network_udp_internet_access_restricted: def test_network_security_groups_none_source_address_prefix(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -85,7 +89,7 @@ class Test_network_udp_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has HTTP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has HTTP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -94,6 +98,7 @@ class Test_network_udp_internet_access_restricted: def test_network_security_groups_no_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -132,7 +137,7 @@ class Test_network_udp_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has UDP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has UDP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -141,6 +146,7 @@ class Test_network_udp_internet_access_restricted: def test_network_security_groups_invalid_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -186,7 +192,7 @@ class Test_network_udp_internet_access_restricted: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has UDP internet access allowed." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has UDP internet access allowed." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name @@ -195,6 +201,7 @@ class Test_network_udp_internet_access_restricted: def test_network_security_groups_valid_security_rules(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} security_group_name = "Security Group Name" security_group_id = str(uuid4()) @@ -240,7 +247,7 @@ class Test_network_udp_internet_access_restricted: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_ID} has UDP internet access restricted." + == f"Security Group {security_group_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has UDP internet access restricted." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == security_group_name diff --git a/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py b/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py new file mode 100644 index 0000000000..f37d3eb781 --- /dev/null +++ b/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VNET_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/test-vnet" + + +class Test_network_vnet_ddos_protection_enabled: + def test_no_subscriptions(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + + network_client.virtual_networks = {} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_ddos_enabled(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + enable_ddos_protection=True, + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_ddos_disabled(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + enable_ddos_protection=False, + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled_test.py b/tests/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled_test.py index aca77ea13e..f309a21e15 100644 --- a/tests/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled_test.py +++ b/tests/providers/azure/services/network/network_watcher_enabled/network_watcher_enabled_test.py @@ -2,6 +2,7 @@ from unittest import mock from prowler.providers.azure.services.network.network_service import NetworkWatcher from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, @@ -11,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_network_watcher_enabled: def test_no_network_watchers(self): network_client = mock.MagicMock + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} locations = [] network_client.locations = {AZURE_SUBSCRIPTION_ID: locations} network_client.security_groups = {} @@ -41,13 +43,13 @@ class Test_network_watcher_enabled: def test_network_invalid_network_watchers(self): network_client = mock.MagicMock locations = ["location"] - network_client.locations = {AZURE_SUBSCRIPTION_NAME: locations} - network_client.subscriptions = {AZURE_SUBSCRIPTION_NAME: AZURE_SUBSCRIPTION_ID} + network_client.locations = {AZURE_SUBSCRIPTION_ID: locations} + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher" network_watcher_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/NetworkWatcherRG/providers/Microsoft.Network/networkWatchers/NetworkWatcher_*" network_client.network_watchers = { - AZURE_SUBSCRIPTION_NAME: [ + AZURE_SUBSCRIPTION_ID: [ NetworkWatcher( id=network_watcher_id, name=network_watcher_name, @@ -81,23 +83,23 @@ class Test_network_watcher_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Network Watcher is not enabled for the following locations in subscription '{AZURE_SUBSCRIPTION_NAME}': location." + == f"Network Watcher is not enabled for the following locations in subscription '{AZURE_SUBSCRIPTION_DISPLAY}': location." ) - assert result[0].subscription == AZURE_SUBSCRIPTION_NAME - assert result[0].resource_name == AZURE_SUBSCRIPTION_NAME + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == AZURE_SUBSCRIPTION_ID assert result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" assert result[0].location == "global" def test_network_valid_network_watchers(self): network_client = mock.MagicMock locations = ["location"] - network_client.locations = {AZURE_SUBSCRIPTION_NAME: locations} - network_client.subscriptions = {AZURE_SUBSCRIPTION_NAME: AZURE_SUBSCRIPTION_ID} + network_client.locations = {AZURE_SUBSCRIPTION_ID: locations} + network_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} network_watcher_name = "Network Watcher" network_watcher_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/NetworkWatcherRG/providers/Microsoft.Network/networkWatchers/NetworkWatcher_*" network_client.network_watchers = { - AZURE_SUBSCRIPTION_NAME: [ + AZURE_SUBSCRIPTION_ID: [ NetworkWatcher( id=network_watcher_id, name=network_watcher_name, @@ -131,8 +133,8 @@ class Test_network_watcher_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Network Watcher {network_watcher_name} is enabled in location location in subscription '{AZURE_SUBSCRIPTION_NAME}'." + == f"Network Watcher {network_watcher_name} is enabled in location location in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'." ) - assert result[0].subscription == AZURE_SUBSCRIPTION_NAME + assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == network_watcher_name assert result[0].resource_id == network_watcher_id diff --git a/tests/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled_test.py b/tests/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled_test.py index f61a25b7d8..b763e7cb79 100644 --- a/tests/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled_test.py +++ b/tests/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.policy.policy_service import PolicyAssigment from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_policy_ensure_asc_enforcement_enabled: def test_policy_no_subscriptions(self): policy_client = mock.MagicMock + policy_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} policy_client.policy_assigments = {} with ( @@ -33,6 +36,7 @@ class Test_policy_ensure_asc_enforcement_enabled: def test_policy_subscription_empty(self): policy_client = mock.MagicMock + policy_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} policy_client.policy_assigments = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -55,6 +59,7 @@ class Test_policy_ensure_asc_enforcement_enabled: def test_policy_subscription_no_asc(self): policy_client = mock.MagicMock + policy_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} resource_id = uuid4() policy_client.policy_assigments = { AZURE_SUBSCRIPTION_ID: { @@ -84,6 +89,7 @@ class Test_policy_ensure_asc_enforcement_enabled: def test_policy_subscription_asc_default(self): policy_client = mock.MagicMock + policy_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} resource_id = str(uuid4()) policy_client.policy_assigments = { AZURE_SUBSCRIPTION_ID: { @@ -115,7 +121,7 @@ class Test_policy_ensure_asc_enforcement_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Policy assigment '{resource_id}' is configured with enforcement mode 'Default'." + == f"Policy assigment '{resource_id}' from subscription {AZURE_SUBSCRIPTION_DISPLAY} is configured with enforcement mode 'Default'." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "SecurityCenterBuiltIn" @@ -123,6 +129,7 @@ class Test_policy_ensure_asc_enforcement_enabled: def test_policy_subscription_asc_not_default(self): policy_client = mock.MagicMock + policy_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} resource_id = str(uuid4()) policy_client.policy_assigments = { AZURE_SUBSCRIPTION_ID: { @@ -154,7 +161,7 @@ class Test_policy_ensure_asc_enforcement_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Policy assigment '{resource_id}' is not configured with enforcement mode Default." + == f"Policy assigment '{resource_id}' from subscription {AZURE_SUBSCRIPTION_DISPLAY} is not configured with enforcement mode Default." ) assert result[0].resource_id == resource_id assert result[0].resource_name == "SecurityCenterBuiltIn" diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled_test.py index 3f21f8a200..9d1afcdbba 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_allow_access_services_disabled/postgresql_flexible_server_allow_access_services_disabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.postgresql.postgresql_service import ( Server, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_allow_access_services_disabled: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -36,6 +41,9 @@ class Test_postgresql_flexible_server_allow_access_services_disabled: def test_flexible_servers_allow_public_access(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) firewall = Firewall( @@ -84,7 +92,7 @@ class Test_postgresql_flexible_server_allow_access_services_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has allow public access from any Azure service enabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has allow public access from any Azure service enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -93,6 +101,9 @@ class Test_postgresql_flexible_server_allow_access_services_disabled: def test_flexible_servers_dont_allow_public_access(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) firewall = Firewall( @@ -141,7 +152,7 @@ class Test_postgresql_flexible_server_allow_access_services_disabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has allow public access from any Azure service disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has allow public access from any Azure service disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on_test.py index 8a0d65d27d..f027dc44a6 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_connection_throttling_on/postgresql_flexible_server_connection_throttling_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.postgresql.postgresql_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_connection_throttling_on: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -33,6 +38,9 @@ class Test_postgresql_flexible_server_connection_throttling_on: def test_flexible_servers_connection_throttling_off(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -75,7 +83,7 @@ class Test_postgresql_flexible_server_connection_throttling_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has connection_throttling disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has connection_throttling disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -84,6 +92,9 @@ class Test_postgresql_flexible_server_connection_throttling_on: def test_flexible_servers_connection_throttling_on(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -126,7 +137,7 @@ class Test_postgresql_flexible_server_connection_throttling_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has connection_throttling enabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has connection_throttling enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled_test.py index abe971b89d..55ac9c6f3d 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_enforce_ssl_enabled/postgresql_flexible_server_enforce_ssl_enabled_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.postgresql.postgresql_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_enforce_ssl_enabled: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -33,6 +38,9 @@ class Test_postgresql_flexible_server_enforce_ssl_enabled: def test_flexible_servers_require_secure_transport_off(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -75,7 +83,7 @@ class Test_postgresql_flexible_server_enforce_ssl_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has enforce ssl disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has enforce ssl disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -84,6 +92,9 @@ class Test_postgresql_flexible_server_enforce_ssl_enabled: def test_flexible_servers_require_secure_transport_on(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -126,7 +137,7 @@ class Test_postgresql_flexible_server_enforce_ssl_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has enforce ssl enabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has enforce ssl enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled_test.py index 6ee413b15b..4785799245 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_entra_id_authentication_enabled/postgresql_flexible_server_entra_id_authentication_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.postgresql.postgresql_service import ( Server, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_entra_id_authentication_enabled: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -36,6 +41,9 @@ class Test_postgresql_flexible_server_entra_id_authentication_enabled: def test_flexible_servers_entra_id_auth_disabled(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -78,7 +86,7 @@ class Test_postgresql_flexible_server_entra_id_authentication_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has Microsoft Entra ID authentication disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Microsoft Entra ID authentication disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -87,6 +95,9 @@ class Test_postgresql_flexible_server_entra_id_authentication_enabled: def test_flexible_servers_entra_id_auth_enabled_no_admins(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -129,7 +140,7 @@ class Test_postgresql_flexible_server_entra_id_authentication_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has Microsoft Entra ID authentication enabled but no Entra ID administrators configured" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Microsoft Entra ID authentication enabled but no Entra ID administrators configured" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -138,6 +149,9 @@ class Test_postgresql_flexible_server_entra_id_authentication_enabled: def test_flexible_servers_entra_id_auth_enabled(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -187,7 +201,7 @@ class Test_postgresql_flexible_server_entra_id_authentication_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has Microsoft Entra ID authentication enabled with 1 administrator configured" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Microsoft Entra ID authentication enabled with 1 administrator configured" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..db0291c989 --- /dev/null +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,132 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.postgresql.postgresql_service import Server +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +def _make_server(server_id, server_name, geo_redundant_backup): + return Server( + id=server_id, + name=server_name, + resource_group="resource_group", + location="eastus", + require_secure_transport="ON", + active_directory_auth="Enabled", + entra_id_admins=[], + log_checkpoints="ON", + log_connections="ON", + log_disconnections="ON", + connection_throttling="ON", + log_retention_days="3", + firewall=[], + geo_redundant_backup=geo_redundant_backup, + ) + + +class Test_postgresql_flexible_server_geo_redundant_backup_enabled: + def test_no_postgresql_flexible_servers(self): + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_postgresql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Disabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_postgresql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Enabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py new file mode 100644 index 0000000000..190c8629ce --- /dev/null +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py @@ -0,0 +1,168 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.postgresql.postgresql_service import Server +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +def _make_server(server_id, server_name, high_availability_mode): + return Server( + id=server_id, + name=server_name, + resource_group="resource_group", + location="eastus", + require_secure_transport="ON", + active_directory_auth="Enabled", + entra_id_admins=[], + log_checkpoints="ON", + log_connections="ON", + log_disconnections="ON", + connection_throttling="ON", + log_retention_days="3", + firewall=[], + high_availability_mode=high_availability_mode, + ) + + +class Test_postgresql_flexible_server_high_availability_enabled: + def test_no_postgresql_flexible_servers(self): + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_postgresql_high_availability_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Disabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have high availability enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_postgresql_high_availability_not_set(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, None)] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have high availability enabled." + ) + + def test_postgresql_high_availability_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [ + _make_server(server_id, server_name, "ZoneRedundant") + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has high availability enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on_test.py index 13644730b3..ee4bcb346d 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_checkpoints_on/postgresql_flexible_server_log_checkpoints_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.postgresql.postgresql_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_log_checkpoints_on: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -33,6 +38,9 @@ class Test_postgresql_flexible_server_log_checkpoints_on: def test_flexible_servers_log_checkpoints_off(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -75,7 +83,7 @@ class Test_postgresql_flexible_server_log_checkpoints_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_checkpoints disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_checkpoints disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -84,6 +92,9 @@ class Test_postgresql_flexible_server_log_checkpoints_on: def test_flexible_servers_log_checkpoints_on(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -126,7 +137,7 @@ class Test_postgresql_flexible_server_log_checkpoints_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_checkpoints enabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_checkpoints enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on_test.py index 0377cb172f..d48f12b53a 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_connections_on/postgresql_flexible_server_log_connections_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.postgresql.postgresql_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_log_connections_on: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -33,6 +38,9 @@ class Test_postgresql_flexible_server_log_connections_on: def test_flexible_servers_log_connections_off(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -75,7 +83,7 @@ class Test_postgresql_flexible_server_log_connections_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_connections disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_connections disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -84,6 +92,9 @@ class Test_postgresql_flexible_server_log_connections_on: def test_flexible_servers_log_connections_on(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -126,7 +137,7 @@ class Test_postgresql_flexible_server_log_connections_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_connections enabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_connections enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on_test.py index 91f80e53d0..f860723dfc 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_disconnections_on/postgresql_flexible_server_log_disconnections_on_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.postgresql.postgresql_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_log_disconnections_on: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -33,6 +38,9 @@ class Test_postgresql_flexible_server_log_disconnections_on: def test_flexible_servers_log_connections_off(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -75,7 +83,7 @@ class Test_postgresql_flexible_server_log_disconnections_on: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_disconnections disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_disconnections disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -84,6 +92,9 @@ class Test_postgresql_flexible_server_log_disconnections_on: def test_flexible_servers_log_connections_on(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -126,7 +137,7 @@ class Test_postgresql_flexible_server_log_disconnections_on: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_disconnections enabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_disconnections enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3_test.py index 005969eb4a..046b1f9062 100644 --- a/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_log_retention_days_greater_3/postgresql_flexible_server_log_retention_days_greater_3_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.postgresql.postgresql_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_postgresql_flexible_server_log_retention_days_greater_3: def test_no_postgresql_flexible_servers(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_client.flexible_servers = {} with ( @@ -33,6 +38,9 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: def test_flexible_servers_no_log_retention_days(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) postgresql_client.flexible_servers = { @@ -75,7 +83,7 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_retention disabled" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_retention disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -84,6 +92,9 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: def test_flexible_servers_log_retention_days_3(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) log_retention_days = "3" @@ -127,7 +138,7 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_retention set to {log_retention_days}" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_retention set to {log_retention_days}" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -136,6 +147,9 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: def test_flexible_servers_log_retention_days_4(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) log_retention_days = "4" @@ -179,7 +193,7 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_retention set to {log_retention_days}" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_retention set to {log_retention_days}" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name @@ -188,6 +202,9 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: def test_flexible_servers_log_retention_days_8(self): postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } postgresql_server_name = "Postgres Flexible Server Name" postgresql_server_id = str(uuid4()) log_retention_days = "8" @@ -231,7 +248,7 @@ class Test_postgresql_flexible_server_log_retention_days_greater_3: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has log_retention set to {log_retention_days}" + == f"Flexible Postgresql server {postgresql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has log_retention set to {log_retention_days}" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == postgresql_server_name diff --git a/tests/providers/azure/services/postgresql/postgresql_service_test.py b/tests/providers/azure/services/postgresql/postgresql_service_test.py index f36b5675ec..f372de8844 100644 --- a/tests/providers/azure/services/postgresql/postgresql_service_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_service_test.py @@ -1,4 +1,7 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError from prowler.providers.azure.services.postgresql.postgresql_service import ( EntraIdAdmin, @@ -115,6 +118,44 @@ class Test_SqlServer_Service: == "ON" ) + def test_get_connection_throttling_missing_parameter_returns_none(self): + # PostgreSQL v18 removed the "connection_throttle.enable" parameter; when + # it is genuinely absent the Azure SDK raises ResourceNotFoundError, and + # the service treats that as "not enabled" (quiet None) instead of + # aborting the whole subscription's server inventory. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = ResourceNotFoundError( + "The configuration 'connection_throttle.enable' does not exist for " + "server version 18." + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result is None + mock_logger.error.assert_not_called() + + def test_get_connection_throttling_unexpected_error_propagates(self): + # Any other failure (permissions, throttling, transient API errors) must + # NOT be swallowed into None: that would make the downstream check report + # the server as having throttling disabled, hiding a collection failure + # as a security finding. The error propagates so the per-server handler + # in _get_flexible_servers can record it as a collection failure. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = HttpResponseError( + "(AuthorizationFailed) permission denied" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with pytest.raises(HttpResponseError): + postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + def test_get_log_retention_days(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert ( @@ -138,6 +179,45 @@ class Test_SqlServer_Service: assert admins[0].principal_name == "Test Admin User" assert admins[0].object_id == "11111111-1111-1111-1111-111111111111" + def test_get_entra_id_admins_aad_not_enabled_logs_warning(self): + # A server using PostgreSQL authentication only (Entra/Azure AD auth + # disabled) is an expected state; it should be logged as a warning, not + # an error, and return an empty admin list. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Azure AD authentication is not enabled for the given server" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.warning.assert_called_once() + mock_logger.error.assert_not_called() + + def test_get_entra_id_admins_unexpected_error_logs_error(self): + # Any other failure (permissions, throttling, transient API errors) is a + # genuine problem and must still be logged as an error. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Some unexpected failure" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.error.assert_called_once() + mock_logger.warning.assert_not_called() + def test_get_firewall(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert ( @@ -161,3 +241,126 @@ class Test_SqlServer_Service: postgesql.flexible_servers[AZURE_SUBSCRIPTION_ID][0].firewall[0].end_ip == "end_ip" ) + + +def _make_server(name): + server = MagicMock() + server.id = ( + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/" + f"Microsoft.DBforPostgreSQL/flexibleServers/{name}" + ) + server.name = name + return server + + +class Test_PostgreSQL_Service_Resilience: + """Collecting one flexible server must never abort collection of the rest of + the subscription (regression: a missing/failing per-server configuration + lookup silently dropped every remaining server).""" + + def _build_service_with_client(self, mock_client): + # Skip the real network call during construction, then run the real + # collection against the mocked management client. + with patch.object(PostgreSQL, "_get_flexible_servers", return_value={}): + postgresql = PostgreSQL(set_mocked_azure_provider()) + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + return postgresql + + def test_missing_connection_throttle_config_still_collects_server(self): + # The "connection_throttle.enable" parameter was removed in PostgreSQL + # 16+, so the lookup raises ConfigurationNotExists on newer servers. + dev = _make_server("dev") + prd = _make_server("prd") + + mock_client = MagicMock() + mock_client.servers.list.return_value = [dev, prd] + server_details = MagicMock() + server_details.location = "westeurope" + mock_client.servers.get.return_value = server_details + mock_client.administrators.list_by_server.return_value = [] + mock_client.firewall_rules.list_by_server.return_value = [] + + def configurations_get(resource_group, server_name, key): + if key == "connection_throttle.enable" and server_name == "prd": + # Azure raises ResourceNotFoundError (ConfigurationNotExists) + # when the parameter does not exist on the server. + raise ResourceNotFoundError( + "(ConfigurationNotExists) The configuration " + "'connection_throttle.enable' does not exist for prd server " + "version 18." + ) + return MagicMock(value="ON") + + mock_client.configurations.get.side_effect = configurations_get + + postgresql = self._build_service_with_client(mock_client) + servers = postgresql._get_flexible_servers() + + names = sorted(server.name for server in servers[AZURE_SUBSCRIPTION_ID]) + assert names == ["dev", "prd"] + prd_server = next(s for s in servers[AZURE_SUBSCRIPTION_ID] if s.name == "prd") + assert prd_server.connection_throttling is None + dev_server = next(s for s in servers[AZURE_SUBSCRIPTION_ID] if s.name == "dev") + assert dev_server.connection_throttling == "ON" + + def test_unexpected_throttling_error_is_not_silently_collected(self): + # An unexpected failure reading "connection_throttle.enable" (e.g. a + # permission, throttling, or transient SDK error) must NOT be turned + # into connection_throttling=None: that would make the downstream check + # report the server as having throttling disabled, hiding a collection + # failure as a security finding. Only ResourceNotFoundError (the + # parameter genuinely missing) is treated as "not enabled"; anything + # else isolates to that server, which is dropped rather than fabricated. + ok = _make_server("ok") + denied = _make_server("denied") + + mock_client = MagicMock() + mock_client.servers.list.return_value = [ok, denied] + server_details = MagicMock() + server_details.location = "westeurope" + mock_client.servers.get.return_value = server_details + mock_client.administrators.list_by_server.return_value = [] + mock_client.firewall_rules.list_by_server.return_value = [] + + def configurations_get(resource_group, server_name, key): + if key == "connection_throttle.enable" and server_name == "denied": + raise HttpResponseError("(AuthorizationFailed) permission denied") + return MagicMock(value="ON") + + mock_client.configurations.get.side_effect = configurations_get + + postgresql = self._build_service_with_client(mock_client) + servers = postgresql._get_flexible_servers() + + collected = servers[AZURE_SUBSCRIPTION_ID] + # The server whose throttling lookup failed unexpectedly is dropped, + # not collected with a fabricated connection_throttling=None. + assert [server.name for server in collected] == ["ok"] + assert all(server.connection_throttling is not None for server in collected) + + def test_one_server_hard_failure_does_not_drop_others(self): + # A failure unrelated to a guarded getter (here, fetching the server + # details) must isolate to that server, not the whole subscription. + ok = _make_server("ok") + broken = _make_server("broken") + + mock_client = MagicMock() + mock_client.servers.list.return_value = [broken, ok] + mock_client.administrators.list_by_server.return_value = [] + mock_client.firewall_rules.list_by_server.return_value = [] + mock_client.configurations.get.return_value = MagicMock(value="ON") + + def servers_get(resource_group, server_name): + if server_name == "broken": + raise Exception("boom: transient failure fetching server details") + details = MagicMock() + details.location = "westeurope" + return details + + mock_client.servers.get.side_effect = servers_get + + postgresql = self._build_service_with_client(mock_client) + servers = postgresql._get_flexible_servers() + + names = [server.name for server in servers[AZURE_SUBSCRIPTION_ID]] + assert names == ["ok"] diff --git a/tests/providers/azure/services/recovery/recovery_service_test.py b/tests/providers/azure/services/recovery/recovery_service_test.py new file mode 100644 index 0000000000..93dcad1e38 --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_service_test.py @@ -0,0 +1,57 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + RecoveryBackup, +) +from tests.providers.azure.azure_fixtures import AZURE_SUBSCRIPTION_ID + +VAULT_ID = ( + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/" + "providers/Microsoft.RecoveryServices/vaults/test-vault" +) +POLICY_ID = f"{VAULT_ID}/backupPolicies/ShortPolicy" + + +class BackupClientFake: + def __init__(self, policies): + self.backup_policies = mock.MagicMock() + self.backup_policies.list.return_value = policies + + +class Test_RecoveryBackup_Service: + def test_get_backup_policies_lists_unprotected_vault_policies(self): + policy = SimpleNamespace( + id=POLICY_ID, + name="ShortPolicy", + properties=SimpleNamespace( + retention_policy=SimpleNamespace( + daily_schedule=SimpleNamespace( + retention_duration=SimpleNamespace(count=7) + ) + ) + ), + ) + client = BackupClientFake(policies=[policy]) + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_protected_items={}, + ) + recovery_backup = object.__new__(RecoveryBackup) + recovery_backup.clients = {AZURE_SUBSCRIPTION_ID: client} + + backup_policies = recovery_backup._get_backup_policies( + subscription_id=AZURE_SUBSCRIPTION_ID, + vault=vault, + ) + + client.backup_policies.list.assert_called_once_with( + vault_name="test-vault", + resource_group_name="rg1", + ) + assert list(backup_policies) == [POLICY_ID] + assert backup_policies[POLICY_ID].name == "ShortPolicy" + assert backup_policies[POLICY_ID].retention_days == 7 diff --git a/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py b/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py new file mode 100644 index 0000000000..56c7e3f5da --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py @@ -0,0 +1,191 @@ +from unittest import mock +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VAULT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.RecoveryServices/vaults/test-vault" +POLICY_ID = f"{VAULT_ID}/backupPolicies/DefaultPolicy" + + +class Test_recovery_vault_backup_policy_retention_adequate: + def test_no_subscriptions(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + + recovery_client.vaults = {} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 0 + + def test_vault_no_policies(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={}, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has no backup policies configured" in result[0].status_extended + + def test_policy_adequate_retention(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="DefaultPolicy", + retention_days=90, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90-day" in result[0].status_extended + + def test_policy_insufficient_retention(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="ShortPolicy", + retention_days=7, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "7-day" in result[0].status_extended + assert "minimum: 30" in result[0].status_extended + + def test_policy_no_retention_configured(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="NoRetentionPolicy", + retention_days=None, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no daily retention" in result[0].status_extended diff --git a/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py b/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py new file mode 100644 index 0000000000..c9f8aa27b4 --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py @@ -0,0 +1,116 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VAULT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.RecoveryServices/vaults/test-vault" + + +class Test_recovery_vault_has_protected_items: + def test_no_subscriptions(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + + recovery_client.vaults = {} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 0 + + def test_vault_with_protected_items(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_protected_items={ + "item1": BackupItem( + id="item1", + name="vm-backup", + workload_type=None, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "1 protected items" in result[0].status_extended + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == VAULT_ID + assert result[0].resource_name == "test-vault" + assert result[0].location == "eastus" + + def test_vault_empty(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="empty-vault", + location="westeurope", + backup_protected_items={}, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no protected items" in result[0].status_extended + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == VAULT_ID + assert result[0].resource_name == "empty-vault" + assert result[0].location == "westeurope" diff --git a/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py index e11294a778..e8152ab260 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py @@ -9,7 +9,9 @@ from azure.mgmt.sql.models import ( from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -17,6 +19,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_auditing_enabled: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -39,6 +44,9 @@ class Test_sqlserver_auditing_enabled: def test_sql_servers_auditing_disabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -76,7 +84,7 @@ class Test_sqlserver_auditing_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have any auditing policy configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have any auditing policy configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -85,6 +93,9 @@ class Test_sqlserver_auditing_enabled: def test_sql_servers_auditing_enabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -122,7 +133,7 @@ class Test_sqlserver_auditing_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has an auditing policy configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has an auditing policy configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days_test.py b/tests/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days_test.py index d74632d357..fe3b8e9d3e 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_auditing_retention_90_days/sqlserver_auditing_retention_90_days_test.py @@ -5,7 +5,9 @@ from azure.mgmt.sql.models import ServerBlobAuditingPolicy from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_auditing_retention_90_days: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -35,6 +40,9 @@ class Test_sqlserver_auditing_retention_90_days: def test_sql_servers_auditing_policy_disabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -74,7 +82,7 @@ class Test_sqlserver_auditing_retention_90_days: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has auditing disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has auditing disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -83,6 +91,9 @@ class Test_sqlserver_auditing_retention_90_days: def test_sql_servers_auditing_retention_less_than_90_days(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -124,7 +135,7 @@ class Test_sqlserver_auditing_retention_90_days: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has auditing retention less than 91 days." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has auditing retention less than 91 days." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -133,6 +144,9 @@ class Test_sqlserver_auditing_retention_90_days: def test_sql_servers_auditing_retention_greater_than_90_days(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -174,7 +188,7 @@ class Test_sqlserver_auditing_retention_90_days: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has auditing retention greater than 90 days." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has auditing retention greater than 90 days." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -185,6 +199,9 @@ class Test_sqlserver_auditing_retention_90_days: self, ): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -227,7 +244,7 @@ class Test_sqlserver_auditing_retention_90_days: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has auditing retention greater than 90 days." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has auditing retention greater than 90 days." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -238,6 +255,9 @@ class Test_sqlserver_auditing_retention_90_days: self, ): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -280,7 +300,7 @@ class Test_sqlserver_auditing_retention_90_days: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has auditing retention less than 91 days." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has auditing retention less than 91 days." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled_test.py index 823455d385..7699a7a9ee 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_azuread_administrator_enabled/sqlserver_azuread_administrator_enabled_test.py @@ -5,7 +5,9 @@ from azure.mgmt.sql.models import ServerExternalAdministrator from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_azuread_administrator_enabled: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -35,6 +40,9 @@ class Test_sqlserver_azuread_administrator_enabled: def test_sql_servers_azuread_no_administrator(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -72,7 +80,7 @@ class Test_sqlserver_azuread_administrator_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have an Active Directory administrator." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have an Active Directory administrator." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -81,6 +89,9 @@ class Test_sqlserver_azuread_administrator_enabled: def test_sql_servers_azuread_administrator_no_active_directory(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -120,7 +131,7 @@ class Test_sqlserver_azuread_administrator_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have an Active Directory administrator." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have an Active Directory administrator." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -129,6 +140,9 @@ class Test_sqlserver_azuread_administrator_enabled: def test_sql_servers_azuread_administrator_active_directory(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -168,7 +182,7 @@ class Test_sqlserver_azuread_administrator_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has an Active Directory administrator." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has an Active Directory administrator." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled_test.py index 41bf400be6..73474a51da 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_microsoft_defender_enabled/sqlserver_microsoft_defender_enabled_test.py @@ -5,7 +5,9 @@ from azure.mgmt.sql.models import ServerSecurityAlertPolicy from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_microsoft_defender_enabled: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -35,6 +40,9 @@ class Test_sqlserver_microsoft_defender_enabled: def test_sql_servers_no_security_alert_policies(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -73,6 +81,9 @@ class Test_sqlserver_microsoft_defender_enabled: def test_sql_servers_microsoft_defender_disabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -111,7 +122,7 @@ class Test_sqlserver_microsoft_defender_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has microsoft defender disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has microsoft defender disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -120,6 +131,9 @@ class Test_sqlserver_microsoft_defender_enabled: def test_sql_servers_microsoft_defender_enabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -158,7 +172,7 @@ class Test_sqlserver_microsoft_defender_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has microsoft defender enabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has microsoft defender enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version_test.py b/tests/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version_test.py index 0c6a7649b5..df7a4d6eb0 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_recommended_minimal_tls_version/sqlserver_recommended_minimal_tls_version_test.py @@ -8,7 +8,9 @@ from prowler.providers.azure.services.sqlserver.sqlserver_service import ( Server, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -16,6 +18,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_recommended_minimal_tls_version: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -42,6 +47,9 @@ class Test_sqlserver_recommended_minimal_tls_version: def test_sql_servers_deprecated_minimal_tls_version(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database_name = "Database Name" @@ -95,7 +103,7 @@ class Test_sqlserver_recommended_minimal_tls_version: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} is using TLS version 1.0 as minimal accepted which is not recommended. Please use one of the recommended versions: {', '.join(sqlserver_client.audit_config['recommended_minimal_tls_versions'])}." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using TLS version 1.0 as minimal accepted which is not recommended. Please use one of the recommended versions: {', '.join(sqlserver_client.audit_config['recommended_minimal_tls_versions'])}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -104,6 +112,9 @@ class Test_sqlserver_recommended_minimal_tls_version: def test_sql_servers_no_minimal_tls_version(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database_name = "Database Name" @@ -157,7 +168,7 @@ class Test_sqlserver_recommended_minimal_tls_version: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} is using TLS version as minimal accepted which is not recommended. Please use one of the recommended versions: {', '.join(sqlserver_client.audit_config['recommended_minimal_tls_versions'])}." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using TLS version as minimal accepted which is not recommended. Please use one of the recommended versions: {', '.join(sqlserver_client.audit_config['recommended_minimal_tls_versions'])}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -166,6 +177,9 @@ class Test_sqlserver_recommended_minimal_tls_version: def test_sql_servers_minimal_tls_version(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database_name = "Database Name" @@ -219,7 +233,7 @@ class Test_sqlserver_recommended_minimal_tls_version: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} is using version 1.2 as minimal accepted which is recommended." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} is using version 1.2 as minimal accepted which is recommended." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk_test.py b/tests/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk_test.py index 73c9046940..4dcdffc0e7 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_tde_encrypted_with_cmk/sqlserver_tde_encrypted_with_cmk_test.py @@ -8,7 +8,9 @@ from prowler.providers.azure.services.sqlserver.sqlserver_service import ( Server, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -16,6 +18,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_tde_encrypted_with_cmk: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -38,6 +43,9 @@ class Test_sqlserver_tde_encrypted_with_cmk: def test_no_sql_servers_databases(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -76,6 +84,9 @@ class Test_sqlserver_tde_encrypted_with_cmk: def test_sql_servers_encryption_protector_service_managed(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database = Database( @@ -125,7 +136,7 @@ class Test_sqlserver_tde_encrypted_with_cmk: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has TDE disabled without CMK." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE disabled without CMK." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -134,6 +145,9 @@ class Test_sqlserver_tde_encrypted_with_cmk: def test_sql_servers_database_encryption_disabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database = Database( @@ -183,7 +197,7 @@ class Test_sqlserver_tde_encrypted_with_cmk: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has TDE disabled with CMK." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE disabled with CMK." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -192,6 +206,9 @@ class Test_sqlserver_tde_encrypted_with_cmk: def test_sql_servers_database_encryption_enabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database = Database( @@ -241,7 +258,204 @@ class Test_sqlserver_tde_encrypted_with_cmk: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has TDE enabled with CMK." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE enabled with CMK." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == sql_server_name + assert result[0].resource_id == sql_server_id + assert result[0].location == "location" + + def test_sql_servers_master_database_disabled_user_database_enabled(self): + # System "master" database always reports TDE Disabled in Azure SQL + # and is not customer-controllable. It must not fail a server whose + # user databases are correctly encrypted with CMK (PROWLER-1760). + sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + sql_server_name = "SQL Server Name" + sql_server_id = str(uuid4()) + master_database = Database( + id="master_id", + name="master", + type="type", + location="location", + managed_by="managed_by", + tde_encryption=TransparentDataEncryption(status="Disabled"), + ) + user_database = Database( + id="user_id", + name="DynamicBudgets_Intacct", + type="type", + location="location", + managed_by="managed_by", + tde_encryption=TransparentDataEncryption(status="Enabled"), + ) + sqlserver_client.sql_servers = { + AZURE_SUBSCRIPTION_ID: [ + Server( + id=sql_server_id, + name=sql_server_name, + location="location", + public_network_access="", + minimal_tls_version="", + administrators=None, + auditing_policies=None, + firewall_rules=None, + databases=[master_database, user_database], + encryption_protector=EncryptionProtector( + server_key_type="AzureKeyVault" + ), + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk.sqlserver_client", + new=sqlserver_client, + ), + ): + from prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk import ( + sqlserver_tde_encrypted_with_cmk, + ) + + check = sqlserver_tde_encrypted_with_cmk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE enabled with CMK." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == sql_server_name + assert result[0].resource_id == sql_server_id + assert result[0].location == "location" + + def test_sql_servers_only_master_database(self): + # A server whose only database is the system "master" has no user + # databases to evaluate, so it must not produce a finding. + sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + sql_server_name = "SQL Server Name" + sql_server_id = str(uuid4()) + master_database = Database( + id="master_id", + name="MASTER", + type="type", + location="location", + managed_by="managed_by", + tde_encryption=TransparentDataEncryption(status="Disabled"), + ) + sqlserver_client.sql_servers = { + AZURE_SUBSCRIPTION_ID: [ + Server( + id=sql_server_id, + name=sql_server_name, + location="location", + public_network_access="", + minimal_tls_version="", + administrators=None, + auditing_policies=None, + firewall_rules=None, + databases=[master_database], + encryption_protector=EncryptionProtector( + server_key_type="AzureKeyVault" + ), + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk.sqlserver_client", + new=sqlserver_client, + ), + ): + from prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk import ( + sqlserver_tde_encrypted_with_cmk, + ) + + check = sqlserver_tde_encrypted_with_cmk() + result = check.execute() + assert len(result) == 0 + + def test_sql_servers_master_disabled_user_database_disabled(self): + # Filtering out "master" must not mask a genuinely failing user + # database: a disabled user DB still fails even with CMK. + sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + sql_server_name = "SQL Server Name" + sql_server_id = str(uuid4()) + master_database = Database( + id="master_id", + name="master", + type="type", + location="location", + managed_by="managed_by", + tde_encryption=TransparentDataEncryption(status="Disabled"), + ) + user_database = Database( + id="user_id", + name="DynamicBudgets_Intacct", + type="type", + location="location", + managed_by="managed_by", + tde_encryption=TransparentDataEncryption(status="Disabled"), + ) + sqlserver_client.sql_servers = { + AZURE_SUBSCRIPTION_ID: [ + Server( + id=sql_server_id, + name=sql_server_name, + location="location", + public_network_access="", + minimal_tls_version="", + administrators=None, + auditing_policies=None, + firewall_rules=None, + databases=[master_database, user_database], + encryption_protector=EncryptionProtector( + server_key_type="AzureKeyVault" + ), + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk.sqlserver_client", + new=sqlserver_client, + ), + ): + from prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk import ( + sqlserver_tde_encrypted_with_cmk, + ) + + check = sqlserver_tde_encrypted_with_cmk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE disabled with CMK." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled_test.py index ff782535e5..3de0dae8aa 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_tde_encryption_enabled/sqlserver_tde_encryption_enabled_test.py @@ -8,7 +8,9 @@ from prowler.providers.azure.services.sqlserver.sqlserver_service import ( Server, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -16,6 +18,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_tde_encryption_enabled: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -38,6 +43,9 @@ class Test_sqlserver_tde_encryption_enabled: def test_no_sql_servers_databases(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -76,6 +84,9 @@ class Test_sqlserver_tde_encryption_enabled: def test_sql_servers_database_encryption_disabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database_name = "Database Name" @@ -125,7 +136,7 @@ class Test_sqlserver_tde_encryption_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Database {database_name} from SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has TDE disabled" + == f"Database {database_name} from SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE disabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == database_name @@ -134,6 +145,9 @@ class Test_sqlserver_tde_encryption_enabled: def test_sql_servers_database_encryption_enabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database_name = "Database Name" @@ -183,7 +197,7 @@ class Test_sqlserver_tde_encryption_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Database {database_name} from SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has TDE enabled" + == f"Database {database_name} from SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == database_name @@ -192,6 +206,9 @@ class Test_sqlserver_tde_encryption_enabled: def test_sql_servers_database_encryption_disabled_on_master_db(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database_master_name = "MASTER" @@ -251,7 +268,7 @@ class Test_sqlserver_tde_encryption_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Database {database_name} from SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has TDE enabled" + == f"Database {database_name} from SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE enabled" ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == database_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access_test.py b/tests/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access_test.py index d3c951e6b3..744460a3f1 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_unrestricted_inbound_access/sqlserver_unrestricted_inbound_access_test.py @@ -5,7 +5,9 @@ from azure.mgmt.sql.models import FirewallRule from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -13,6 +15,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_unrestricted_inbound_access: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -35,6 +40,9 @@ class Test_sqlserver_unrestricted_inbound_access: def test_sql_servers_unrestricted_inbound_access(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -76,7 +84,7 @@ class Test_sqlserver_unrestricted_inbound_access: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has firewall rules allowing 0.0.0.0-255.255.255.255." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has firewall rules allowing 0.0.0.0-255.255.255.255." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -85,6 +93,9 @@ class Test_sqlserver_unrestricted_inbound_access: def test_sql_servers_restricted_inbound_access(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -126,7 +137,7 @@ class Test_sqlserver_unrestricted_inbound_access: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have firewall rules allowing 0.0.0.0-255.255.255.255." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have firewall rules allowing 0.0.0.0-255.255.255.255." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled_test.py index 917c7c9651..4c5f59e54c 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_va_emails_notifications_admins_enabled/sqlserver_va_emails_notifications_admins_enabled_test.py @@ -8,7 +8,9 @@ from azure.mgmt.sql.models import ( from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -16,6 +18,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_va_emails_notifications_admins_enabled: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -38,6 +43,9 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: def test_sql_servers_no_vulnerability_assessment(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -78,7 +86,7 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -87,6 +95,9 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: def test_sql_servers_no_vulnerability_assessment_no_admin_emails(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -132,7 +143,7 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled but no scan reports configured for subscription admins." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled but no scan reports configured for subscription admins." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -141,6 +152,9 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: def test_sql_servers_vulnerability_assessment_admin_emails_false(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -186,7 +200,7 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled but no scan reports configured for subscription admins." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled but no scan reports configured for subscription admins." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -195,6 +209,9 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: def test_sql_servers_vulnerability_assessment_no_email_subscription_admins(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -240,7 +257,7 @@ class Test_sqlserver_va_emails_notifications_admins_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled and scan reports configured for subscription admins." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled and scan reports configured for subscription admins." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled_test.py index e9af2d8b23..dcdbad7aee 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_va_periodic_recurring_scans_enabled/sqlserver_va_periodic_recurring_scans_enabled_test.py @@ -8,7 +8,9 @@ from azure.mgmt.sql.models import ( from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -16,6 +18,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_va_periodic_recurring_scans_enabled: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -38,6 +43,9 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: def test_sql_servers_no_vulnerability_assessment(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -78,7 +86,7 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -87,6 +95,9 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: def test_sql_servers_no_vulnerability_assessment_storage_container_path(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -129,7 +140,7 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -138,6 +149,9 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: def test_sql_servers_vulnerability_assessment_recuring_scans_disabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -183,7 +197,7 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled but no recurring scans." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled but no recurring scans." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -192,6 +206,9 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: def test_sql_servers_vulnerability_assessment_recuring_scans_enabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -237,7 +254,7 @@ class Test_sqlserver_va_periodic_recurring_scans_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has periodic recurring scans enabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has periodic recurring scans enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured_test.py b/tests/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured_test.py index ee1c15cc68..b085147446 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_va_scan_reports_configured/sqlserver_va_scan_reports_configured_test.py @@ -8,7 +8,9 @@ from azure.mgmt.sql.models import ( from prowler.providers.azure.services.sqlserver.sqlserver_service import Server from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -16,6 +18,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_va_scan_reports_configured: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -38,6 +43,9 @@ class Test_sqlserver_va_scan_reports_configured: def test_sql_servers_no_vulnerability_assessment(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -78,7 +86,7 @@ class Test_sqlserver_va_scan_reports_configured: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -87,6 +95,9 @@ class Test_sqlserver_va_scan_reports_configured: def test_sql_servers_no_vulnerability_assessment_emails(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -132,7 +143,7 @@ class Test_sqlserver_va_scan_reports_configured: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled but no scan reports configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled but no scan reports configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -141,6 +152,9 @@ class Test_sqlserver_va_scan_reports_configured: def test_sql_servers_vulnerability_assessment_emails_none(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -186,7 +200,7 @@ class Test_sqlserver_va_scan_reports_configured: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled and scan reports configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled and scan reports configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -195,6 +209,9 @@ class Test_sqlserver_va_scan_reports_configured: def test_sql_servers_vulnerability_assessment_no_email_subscription_admins(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -240,7 +257,7 @@ class Test_sqlserver_va_scan_reports_configured: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled and scan reports configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled and scan reports configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -249,6 +266,9 @@ class Test_sqlserver_va_scan_reports_configured: def test_sql_servers_vulnerability_assessment_both_emails(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) sqlserver_client.sql_servers = { @@ -294,7 +314,7 @@ class Test_sqlserver_va_scan_reports_configured: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled and scan reports configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled and scan reports configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled_test.py index cd0f881d0e..148b34c07b 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_vulnerability_assessment_enabled/sqlserver_vulnerability_assessment_enabled_test.py @@ -12,7 +12,9 @@ from prowler.providers.azure.services.sqlserver.sqlserver_service import ( Server, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -20,6 +22,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_sqlserver_vulnerability_assessment_enabled: def test_no_sql_servers(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sqlserver_client.sql_servers = {} with ( @@ -42,6 +47,9 @@ class Test_sqlserver_vulnerability_assessment_enabled: def test_sql_servers_no_vulnerability_assessment(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database = Database( @@ -92,7 +100,7 @@ class Test_sqlserver_vulnerability_assessment_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -101,6 +109,9 @@ class Test_sqlserver_vulnerability_assessment_enabled: def test_sql_servers_no_vulnerability_assessment_path(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database = Database( @@ -153,7 +164,7 @@ class Test_sqlserver_vulnerability_assessment_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment disabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name @@ -162,6 +173,9 @@ class Test_sqlserver_vulnerability_assessment_enabled: def test_sql_servers_vulnerability_assessment_enabled(self): sqlserver_client = mock.MagicMock + sqlserver_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } sql_server_name = "SQL Server Name" sql_server_id = str(uuid4()) database = Database( @@ -214,7 +228,7 @@ class Test_sqlserver_vulnerability_assessment_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has vulnerability assessment enabled." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has vulnerability assessment enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py b/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py index 6593e90a6c..9eb232a456 100644 --- a/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py +++ b/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_account_key_access_disabled: def test_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_account_key_access_disabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -80,7 +84,7 @@ class Test_storage_account_key_access_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has shared key access enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has shared key access enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -91,6 +95,7 @@ class Test_storage_account_key_access_disabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -133,7 +138,7 @@ class Test_storage_account_key_access_disabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has shared key access disabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has shared key access disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled_test.py b/tests/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled_test.py new file mode 100644 index 0000000000..e66338f231 --- /dev/null +++ b/tests/providers/azure/services/storage/storage_account_public_network_access_disabled/storage_account_public_network_access_disabled_test.py @@ -0,0 +1,147 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_storage_account_public_network_access_disabled: + def test_no_storage_accounts(self): + storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.storage_accounts = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled import ( + storage_account_public_network_access_disabled, + ) + + check = storage_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 0 + + def _account(self, name, public_network_access): + return Account( + id=str(uuid4()), + name=name, + resouce_group_name="rg", + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=False, + public_network_access=public_network_access, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], + key_expiration_period_in_days=None, + location="westeurope", + ) + + def test_public_network_access_disabled(self): + storage_account_name = "Test Storage Account" + account = self._account(storage_account_name, "Disabled") + storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.storage_accounts = {AZURE_SUBSCRIPTION_ID: [account]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled import ( + storage_account_public_network_access_disabled, + ) + + check = storage_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == storage_account_name + assert result[0].resource_id == account.id + + def test_public_network_access_enabled(self): + storage_account_name = "Test Storage Account" + account = self._account(storage_account_name, "Enabled") + storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.storage_accounts = {AZURE_SUBSCRIPTION_ID: [account]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled import ( + storage_account_public_network_access_disabled, + ) + + check = storage_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + + def test_public_network_access_unset_fails(self): + storage_account_name = "Test Storage Account" + account = self._account(storage_account_name, None) + storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.storage_accounts = {AZURE_SUBSCRIPTION_ID: [account]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_account_public_network_access_disabled.storage_account_public_network_access_disabled import ( + storage_account_public_network_access_disabled, + ) + + check = storage_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) diff --git a/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py b/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py index 8aaa2768d5..12765c37a5 100644 --- a/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py +++ b/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_blob_public_access_level_is_disabled: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_blob_public_access_level_is_disabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -79,7 +83,7 @@ class Test_storage_blob_public_access_level_is_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has allow blob public access enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has allow blob public access enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -90,6 +94,7 @@ class Test_storage_blob_public_access_level_is_disabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -131,7 +136,7 @@ class Test_storage_blob_public_access_level_is_disabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has allow blob public access disabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has allow blob public access disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py b/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py index 357c63a935..b3b800a225 100644 --- a/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,6 +12,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_blob_versioning_is_enabled: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -34,6 +37,7 @@ class Test_storage_blob_versioning_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_account_blob_properties = None with ( mock.patch( @@ -83,6 +87,7 @@ class Test_storage_blob_versioning_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -141,7 +146,7 @@ class Test_storage_blob_versioning_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has blob versioning enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has blob versioning enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -152,6 +157,7 @@ class Test_storage_blob_versioning_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -210,7 +216,7 @@ class Test_storage_blob_versioning_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have blob versioning enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have blob versioning enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py b/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py index e90665d613..e9c433afb4 100644 --- a/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py +++ b/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_cross_tenant_replication_disabled: def test_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_cross_tenant_replication_disabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -80,7 +84,7 @@ class Test_storage_cross_tenant_replication_disabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has cross-tenant replication enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has cross-tenant replication enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -91,6 +95,7 @@ class Test_storage_cross_tenant_replication_disabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -133,7 +138,7 @@ class Test_storage_cross_tenant_replication_disabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has cross-tenant replication disabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has cross-tenant replication disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py b/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py index 9c667b372d..68be9d87c6 100644 --- a/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py +++ b/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_default_network_access_rule_is_denied: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_default_network_access_rule_is_denied: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -79,7 +83,7 @@ class Test_storage_default_network_access_rule_is_denied: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has network access rule set to Allow." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has network access rule set to Allow." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -90,6 +94,7 @@ class Test_storage_default_network_access_rule_is_denied: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -131,7 +136,7 @@ class Test_storage_default_network_access_rule_is_denied: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has network access rule set to Deny." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has network access rule set to Deny." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py b/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py index 99b7874250..33b20f0900 100644 --- a/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_default_to_entra_authorization_enabled: def test_no_storage_accounts(self): storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_default_to_entra_authorization_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account Entra Auth Enabled" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -80,7 +84,7 @@ class Test_storage_default_to_entra_authorization_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Default to Microsoft Entra authorization is enabled for storage account {storage_account_name}." + == f"Default to Microsoft Entra authorization is enabled for storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -91,6 +95,7 @@ class Test_storage_default_to_entra_authorization_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account Entra Auth Disabled" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -133,7 +138,7 @@ class Test_storage_default_to_entra_authorization_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Default to Microsoft Entra authorization is not enabled for storage account {storage_account_name}." + == f"Default to Microsoft Entra authorization is not enabled for storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py b/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py index d65978c2ff..8b2c19f41d 100644 --- a/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_ensure_azure_services_are_trusted_to_access_is_enabled: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_ensure_azure_services_are_trusted_to_access_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -79,7 +83,7 @@ class Test_storage_ensure_azure_services_are_trusted_to_access_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not allow trusted Microsoft services to access this storage account." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not allow trusted Microsoft services to access this storage account." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -90,6 +94,7 @@ class Test_storage_ensure_azure_services_are_trusted_to_access_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -131,7 +136,7 @@ class Test_storage_ensure_azure_services_are_trusted_to_access_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} allows trusted Microsoft services to access this storage account." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows trusted Microsoft services to access this storage account." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py b/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py index 7f9803800c..305f840729 100644 --- a/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_ensure_encryption_with_customer_managed_keys: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_ensure_encryption_with_customer_managed_keys: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -79,7 +83,7 @@ class Test_storage_ensure_encryption_with_customer_managed_keys: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not encrypt with CMKs." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not encrypt with CMKs." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -90,6 +94,7 @@ class Test_storage_ensure_encryption_with_customer_managed_keys: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -131,7 +136,7 @@ class Test_storage_ensure_encryption_with_customer_managed_keys: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} encrypts with CMKs." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} encrypts with CMKs." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py b/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py index e2c97b7e2e..56517e9e05 100644 --- a/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py @@ -9,7 +9,9 @@ from prowler.providers.azure.services.storage.storage_service import ( SMBProtocolSettings, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -17,6 +19,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_ensure_file_shares_soft_delete_is_enabled: def test_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -41,6 +44,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -85,6 +89,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} retention_policy = DeleteRetentionPolicy(enabled=False, days=0) file_service_properties = FileServiceProperties( id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/prowler-resource-group/providers/Microsoft.Storage/storageAccounts/{storage_account_name}/fileServices/default", @@ -137,7 +142,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"File share soft delete is not enabled for storage account {storage_account_name}." + == f"File share soft delete is not enabled for storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -148,6 +153,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} retention_policy = DeleteRetentionPolicy(enabled=True, days=7) file_service_properties = FileServiceProperties( id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/prowler-resource-group/providers/Microsoft.Storage/storageAccounts/{storage_account_name}/fileServices/default", @@ -200,7 +206,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"File share soft delete is enabled for storage account {storage_account_name} with a retention period of {retention_policy.days} days." + == f"File share soft delete is enabled for storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} with a retention period of {retention_policy.days} days." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py b/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py index 16ffe488bb..c3ea126e22 100644 --- a/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_ensure_minimum_tls_version_12: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_ensure_minimum_tls_version_12: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -79,7 +83,7 @@ class Test_storage_ensure_minimum_tls_version_12: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have TLS version set to 1.2." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have TLS version set to 1.2." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -90,6 +94,7 @@ class Test_storage_ensure_minimum_tls_version_12: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -131,7 +136,7 @@ class Test_storage_ensure_minimum_tls_version_12: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has TLS version set to 1.2." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TLS version set to 1.2." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py b/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py index 2652085907..5f3d2581fd 100644 --- a/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py @@ -7,7 +7,9 @@ from prowler.providers.azure.services.storage.storage_service import ( PrivateEndpointConnection, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -15,6 +17,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_ensure_private_endpoints_in_storage_accounts: def test_storage_ensure_private_endpoints_in_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -41,6 +44,7 @@ class Test_storage_ensure_private_endpoints_in_storage_accounts: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -82,7 +86,7 @@ class Test_storage_ensure_private_endpoints_in_storage_accounts: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have private endpoint connections." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have private endpoint connections." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -95,6 +99,7 @@ class Test_storage_ensure_private_endpoints_in_storage_accounts: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -142,7 +147,7 @@ class Test_storage_ensure_private_endpoints_in_storage_accounts: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has private endpoint connections." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has private endpoint connections." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py b/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py index acb5920815..c6f2d27e0f 100644 --- a/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py @@ -8,7 +8,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -16,6 +18,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_ensure_soft_delete_is_enabled: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -40,6 +43,7 @@ class Test_storage_ensure_soft_delete_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_account_blob_properties = None storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -87,6 +91,7 @@ class Test_storage_ensure_soft_delete_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_account_blob_properties = BlobProperties( id="id", name="name", @@ -139,7 +144,7 @@ class Test_storage_ensure_soft_delete_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has soft delete disabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has soft delete disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -152,6 +157,7 @@ class Test_storage_ensure_soft_delete_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_account_blob_properties = BlobProperties( id="id", name="name", @@ -204,7 +210,7 @@ class Test_storage_ensure_soft_delete_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has soft delete enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has soft delete enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py b/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py index cfe2f5a00b..cabf9bbd5c 100644 --- a/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_geo_redundant_enabled: def test_no_storage_accounts(self): storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account GRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Standard_GRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -81,7 +85,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Geo-redundant storage {replication_setting} enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -92,6 +96,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account RAGRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Standard_RAGRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -135,7 +140,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Geo-redundant storage {replication_setting} enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -146,6 +151,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account GZRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Standard_GZRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -189,7 +195,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Geo-redundant storage {replication_setting} enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -200,6 +206,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account RAGZRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Standard_RAGZRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -243,7 +250,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has Geo-redundant storage {replication_setting} enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has Geo-redundant storage {replication_setting} enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -254,6 +261,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account LRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Standard_LRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -297,7 +305,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have Geo-redundant storage enabled, it has {replication_setting} instead." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -308,6 +316,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account ZRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Standard_ZRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -351,7 +360,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have Geo-redundant storage enabled, it has {replication_setting} instead." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -362,6 +371,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account Premium LRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Premium_LRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -405,7 +415,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have Geo-redundant storage enabled, it has {replication_setting} instead." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -416,6 +426,7 @@ class Test_storage_geo_redundant_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account Premium ZRS" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} replication_setting = "Premium_ZRS" storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ @@ -459,7 +470,7 @@ class Test_storage_geo_redundant_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have Geo-redundant storage enabled, it has {replication_setting} instead." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have Geo-redundant storage enabled, it has {replication_setting} instead." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py b/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py index c66fe2dcfd..91a59f101f 100644 --- a/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_infrastructure_encryption_is_enabled: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_infrastructure_encryption_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -79,7 +83,7 @@ class Test_storage_infrastructure_encryption_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has infrastructure encryption disabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has infrastructure encryption disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -90,6 +94,7 @@ class Test_storage_infrastructure_encryption_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -131,7 +136,7 @@ class Test_storage_infrastructure_encryption_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has infrastructure encryption enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has infrastructure encryption enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py b/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py index 480a0737dc..8f5df69f72 100644 --- a/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py +++ b/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_key_rotation_90_dayss: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -39,6 +42,7 @@ class Test_storage_key_rotation_90_dayss: storage_account_name = "Test Storage Account" expiration_days = 91 storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -80,7 +84,7 @@ class Test_storage_key_rotation_90_dayss: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has an invalid key expiration period of {expiration_days} days." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has an invalid key expiration period of {expiration_days} days." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -92,6 +96,7 @@ class Test_storage_key_rotation_90_dayss: storage_account_name = "Test Storage Account" expiration_days = 90 storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -133,7 +138,7 @@ class Test_storage_key_rotation_90_dayss: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has a key expiration period of {expiration_days} days." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a key expiration period of {expiration_days} days." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -144,6 +149,7 @@ class Test_storage_key_rotation_90_dayss: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -185,7 +191,7 @@ class Test_storage_key_rotation_90_dayss: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has no key expiration period set." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has no key expiration period set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py b/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py index cd3c8ab408..0143153caf 100644 --- a/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py @@ -6,7 +6,9 @@ from prowler.providers.azure.services.storage.storage_service import ( NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_secure_transfer_required_is_enabled: def test_storage_no_storage_accounts(self): storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( @@ -38,6 +41,7 @@ class Test_storage_secure_transfer_required_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -79,7 +83,7 @@ class Test_storage_secure_transfer_required_is_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has secure transfer required disabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has secure transfer required disabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name @@ -90,6 +94,7 @@ class Test_storage_secure_transfer_required_is_enabled: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -131,7 +136,7 @@ class Test_storage_secure_transfer_required_is_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has secure transfer required enabled." + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has secure transfer required enabled." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_service_test.py b/tests/providers/azure/services/storage/storage_service_test.py index 3d75fa5000..67fba33877 100644 --- a/tests/providers/azure/services/storage/storage_service_test.py +++ b/tests/providers/azure/services/storage/storage_service_test.py @@ -42,6 +42,7 @@ def mock_storage_get_storage_accounts(_): enable_https_traffic_only=False, infrastructure_encryption=False, allow_blob_public_access=False, + public_network_access="Disabled", network_rule_set=NetworkRuleSet( bypass="AzureServices", default_action="Allow" ), @@ -97,6 +98,10 @@ class Test_Storage_Service: storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].allow_blob_public_access is False ) + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].public_network_access + == "Disabled" + ) assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].network_rule_set is not None diff --git a/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py b/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py index db82c09df4..e213080d05 100644 --- a/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py +++ b/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py @@ -9,7 +9,9 @@ from prowler.providers.azure.services.storage.storage_service import ( SMBProtocolSettings, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -17,6 +19,8 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_smb_channel_encryption_with_secure_algorithm: def test_no_storage_accounts(self): storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.audit_config = {} storage_client.storage_accounts = {} with ( mock.patch( @@ -40,6 +44,8 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.audit_config = {} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -92,6 +98,8 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: ), ) storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.audit_config = {} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -132,7 +140,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == ( - f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have SMB channel encryption enabled for file shares." + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have SMB channel encryption enabled for file shares." ) def test_not_recommended_encryption(self): @@ -148,6 +156,8 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: ), ) storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.audit_config = {} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -188,7 +198,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == ( - f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have SMB channel encryption with a secure algorithm for file shares since it supports AES-128-GCM." + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows insecure algorithms for SMB channel encryption on file shares since it supports AES-128-GCM and only AES-256-GCM is recommended." ) def test_recommended_encryption(self): @@ -204,6 +214,8 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: ), ) storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.audit_config = {} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -244,5 +256,126 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == ( - f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has a secure algorithm for SMB channel encryption (AES-256-GCM) enabled for file shares since it supports AES-256-GCM." + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} only allows secure algorithms for SMB channel encryption on file shares since it supports AES-256-GCM." + ) + + def test_recommended_algorithm_mixed_with_weak_algorithm(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_service_properties = FileServiceProperties( + id="id1", + name="fs1", + type="type1", + share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=["AES-128-CCM", "AES-256-GCM"], supported_versions=[] + ), + ) + storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.audit_config = {} + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name="rg", + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=[], + file_service_properties=file_service_properties, + ) + ] + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm import ( + storage_smb_channel_encryption_with_secure_algorithm, + ) + + check = storage_smb_channel_encryption_with_secure_algorithm() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows insecure algorithms for SMB channel encryption on file shares since it supports AES-128-CCM, AES-256-GCM and only AES-256-GCM is recommended." + ) + + def test_custom_recommended_algorithms_from_config(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_service_properties = FileServiceProperties( + id="id1", + name="fs1", + type="type1", + share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=["AES-128-GCM", "AES-256-GCM"], supported_versions=[] + ), + ) + storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + storage_client.audit_config = { + "recommended_smb_channel_encryption_algorithms": [ + "AES-128-GCM", + "AES-256-GCM", + ] + } + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name="rg", + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=[], + file_service_properties=file_service_properties, + ) + ] + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm import ( + storage_smb_channel_encryption_with_secure_algorithm, + ) + + check = storage_smb_channel_encryption_with_secure_algorithm() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} only allows secure algorithms for SMB channel encryption on file shares since it supports AES-128-GCM, AES-256-GCM." ) diff --git a/tests/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest_test.py b/tests/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest_test.py index 4194c7ae55..33b83fcca8 100644 --- a/tests/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest_test.py +++ b/tests/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest_test.py @@ -9,7 +9,9 @@ from prowler.providers.azure.services.storage.storage_service import ( SMBProtocolSettings, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -17,6 +19,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_storage_smb_protocol_version_is_latest: def test_no_storage_accounts(self): storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = {} with ( mock.patch( @@ -40,6 +43,7 @@ class Test_storage_smb_protocol_version_is_latest: storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -92,6 +96,7 @@ class Test_storage_smb_protocol_version_is_latest: ), ) storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -132,7 +137,7 @@ class Test_storage_smb_protocol_version_is_latest: assert len(result) == 1 assert result[0].status == "PASS" assert ( - f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} allows only the latest SMB protocol version (SMB3.1.1) for file shares." + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows only the latest SMB protocol version (SMB3.1.1) for file shares." in result[0].status_extended ) @@ -149,6 +154,7 @@ class Test_storage_smb_protocol_version_is_latest: ), ) storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -189,7 +195,7 @@ class Test_storage_smb_protocol_version_is_latest: assert len(result) == 1 assert result[0].status == "FAIL" assert ( - f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} allows SMB protocol versions: SMB2.1, SMB3.1.1. Only the latest SMB protocol version (SMB3.1.1) should be allowed." + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows SMB protocol versions: SMB2.1, SMB3.1.1. Only the latest SMB protocol version (SMB3.1.1) should be allowed." in result[0].status_extended ) @@ -206,6 +212,7 @@ class Test_storage_smb_protocol_version_is_latest: ), ) storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -258,6 +265,7 @@ class Test_storage_smb_protocol_version_is_latest: ), ) storage_client = mock.MagicMock() + storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -298,6 +306,6 @@ class Test_storage_smb_protocol_version_is_latest: assert len(result) == 1 assert result[0].status == "FAIL" assert ( - f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} allows SMB protocol versions: SMB3.1.1, SMB3.0. Only the latest SMB protocol version (SMB3.1.1) should be allowed." + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows SMB protocol versions: SMB3.1.1, SMB3.0. Only the latest SMB protocol version (SMB3.1.1) should be allowed." in result[0].status_extended ) diff --git a/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py index a99be2ea54..0992055930 100644 --- a/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py +++ b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py @@ -2,7 +2,9 @@ from unittest import mock from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,7 +12,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_backup_enabled: def test_vm_backup_enabled_no_subscriptions(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} recovery_client = mock.MagicMock + recovery_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} recovery_client.vaults = {} @@ -38,8 +42,12 @@ class Test_vm_backup_enabled: def test_no_vms(self): mock_vm_client = mock.MagicMock() + mock_vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} mock_recovery_client = mock.MagicMock() + mock_recovery_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {}} with ( mock.patch( @@ -69,7 +77,11 @@ class Test_vm_backup_enabled: vault_id = str(uuid4()) vault_name = "vault1" mock_vm_client = mock.MagicMock() + mock_vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mock_recovery_client = mock.MagicMock() + mock_recovery_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -139,7 +151,7 @@ class Test_vm_backup_enabled: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is protected by Azure Backup (vault: {vault_name})." + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} is protected by Azure Backup (vault: {vault_name})." ) def test_vm_not_protected_by_backup(self): @@ -148,7 +160,11 @@ class Test_vm_backup_enabled: vault_id = str(uuid4()) vault_name = "vault1" mock_vm_client = mock.MagicMock() + mock_vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mock_recovery_client = mock.MagicMock() + mock_recovery_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -218,7 +234,7 @@ class Test_vm_backup_enabled: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup." + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} is not protected by Azure Backup." ) def test_vm_protected_by_backup_case_insensitive(self): @@ -227,7 +243,11 @@ class Test_vm_backup_enabled: vault_id = str(uuid4()) vault_name = "vault1" mock_vm_client = mock.MagicMock() + mock_vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mock_recovery_client = mock.MagicMock() + mock_recovery_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -297,7 +317,7 @@ class Test_vm_backup_enabled: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is protected by Azure Backup (vault: {vault_name})." + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} is protected by Azure Backup (vault: {vault_name})." ) def test_vm_protected_by_backup_non_vm_workload(self): @@ -306,7 +326,11 @@ class Test_vm_backup_enabled: vault_id = str(uuid4()) vault_name = "vault1" mock_vm_client = mock.MagicMock() + mock_vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} mock_recovery_client = mock.MagicMock() + mock_recovery_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -376,5 +400,5 @@ class Test_vm_backup_enabled: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup." + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} is not protected by Azure Backup." ) diff --git a/tests/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size_test.py b/tests/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size_test.py index 26f548bbc1..ca86d36b0e 100644 --- a/tests/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size_test.py +++ b/tests/providers/azure/services/vm/vm_desired_sku_size/vm_desired_sku_size_test.py @@ -8,7 +8,9 @@ from prowler.providers.azure.services.vm.vm_service import ( VirtualMachine, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -17,6 +19,7 @@ class Test_vm_desired_sku_size: def test_vm_no_subscriptions(self): """Test when there are no subscriptions.""" vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} vm_client.audit_config = {} @@ -41,6 +44,7 @@ class Test_vm_desired_sku_size: def test_vm_subscriptions_empty(self): """Test when subscriptions exist but have no VMs.""" vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} vm_client.audit_config = {} @@ -66,6 +70,7 @@ class Test_vm_desired_sku_size: """Test VM using a SKU size that is in the default configuration.""" vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -113,13 +118,14 @@ class Test_vm_desired_sku_size: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using desired SKU size Standard_A8_v2 in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMTest is using desired SKU size Standard_A8_v2 in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_using_desired_sku_size_custom_config(self): """Test VM using a SKU size that is in the custom configuration.""" vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -169,13 +175,14 @@ class Test_vm_desired_sku_size: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using desired SKU size Standard_B1s in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMTest is using desired SKU size Standard_B1s in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_using_non_desired_sku_size_default_config(self): """Test VM using a SKU size that is not in the default configuration.""" vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -223,13 +230,14 @@ class Test_vm_desired_sku_size: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using Standard_B1s which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMTest is using Standard_B1s which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_using_non_desired_sku_size_custom_config(self): """Test VM using a SKU size that is not in the custom configuration.""" vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -279,13 +287,14 @@ class Test_vm_desired_sku_size: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using Standard_A8_v2 which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMTest is using Standard_A8_v2 which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_with_none_vm_size(self): """Test VM with None vm_size.""" vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -333,7 +342,7 @@ class Test_vm_desired_sku_size: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using None which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMTest is using None which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_multiple_vms_different_statuses(self): @@ -343,6 +352,7 @@ class Test_vm_desired_sku_size: vm_id_3 = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id_1: VirtualMachine( @@ -433,7 +443,7 @@ class Test_vm_desired_sku_size: assert pass_result.resource_id == vm_id_1 assert ( pass_result.status_extended - == f"VM VMApproved is using desired SKU size Standard_A8_v2 in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMApproved is using desired SKU size Standard_A8_v2 in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) # Find the FAIL result @@ -446,7 +456,7 @@ class Test_vm_desired_sku_size: assert fail_result.resource_id == vm_id_2 assert ( fail_result.status_extended - == f"VM VMNotApproved is using Standard_B1s which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMNotApproved is using Standard_B1s which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) # Find the second PASS result @@ -459,7 +469,7 @@ class Test_vm_desired_sku_size: assert pass_result_2.resource_id == vm_id_3 assert ( pass_result_2.status_extended - == f"VM VMAnotherApproved is using desired SKU size Standard_DS3_v2 in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMAnotherApproved is using desired SKU size Standard_DS3_v2 in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_multiple_subscriptions(self): @@ -469,6 +479,7 @@ class Test_vm_desired_sku_size: subscription_2 = "subscription-2" vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id_1: VirtualMachine( @@ -553,6 +564,7 @@ class Test_vm_desired_sku_size: """Test when the desired SKU sizes configuration is empty.""" vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -600,13 +612,14 @@ class Test_vm_desired_sku_size: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using Standard_A8_v2 which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMTest is using Standard_A8_v2 which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_case_sensitive_sku_size_matching(self): """Test that SKU size matching is case sensitive.""" vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -656,5 +669,5 @@ class Test_vm_desired_sku_size: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using standard_a8_v2 which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_ID}." + == f"VM VMTest is using standard_a8_v2 which is not a desired SKU size in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) diff --git a/tests/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk_test.py b/tests/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk_test.py index 1eb8da64c4..98b14125bf 100644 --- a/tests/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk_test.py +++ b/tests/providers/azure/services/vm/vm_ensure_attached_disks_encrypted_with_cmk/vm_ensure_attached_disks_encrypted_with_cmk_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.vm.vm_service import Disk from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_ensure_attached_disks_encrypted_with_cmk: def test_vm_no_subscriptions(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = {} with ( @@ -33,6 +36,7 @@ class Test_vm_ensure_attached_disks_encrypted_with_cmk: def test_vm_subscription_empty(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -57,6 +61,7 @@ class Test_vm_ensure_attached_disks_encrypted_with_cmk: disk_id = str(uuid4()) resource_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id: Disk( @@ -93,13 +98,14 @@ class Test_vm_ensure_attached_disks_encrypted_with_cmk: assert result[0].location == "location" assert ( result[0].status_extended - == f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_subscription_one_disk_attached_encrypt_cmk(self): disk_id = str(uuid4()) resource_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id: Disk( @@ -136,7 +142,7 @@ class Test_vm_ensure_attached_disks_encrypted_with_cmk: assert result[0].location == "location" assert ( result[0].status_extended - == f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_subscription_two_disk_attached_encrypt_cmk_and_pk(self): @@ -145,6 +151,7 @@ class Test_vm_ensure_attached_disks_encrypted_with_cmk: disk_id_2 = str(uuid4()) resource_id_2 = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id_1: Disk( @@ -188,7 +195,7 @@ class Test_vm_ensure_attached_disks_encrypted_with_cmk: assert result[0].location == "location" assert ( result[0].status_extended - == f"Disk '{disk_id_1}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id_1}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[1].status == "PASS" assert result[1].resource_id == resource_id_2 @@ -196,13 +203,14 @@ class Test_vm_ensure_attached_disks_encrypted_with_cmk: assert result[1].location == "location2" assert ( result[1].status_extended - == f"Disk '{disk_id_2}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id_2}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_unattached_disk_encrypt_cmk(self): disk_id = str(uuid4()) resource_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id: Disk( diff --git a/tests/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk_test.py b/tests/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk_test.py index 78d7920666..1ac8b72500 100644 --- a/tests/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk_test.py +++ b/tests/providers/azure/services/vm/vm_ensure_unattached_disks_encrypted_with_cmk/vm_ensure_unattached_disks_encrypted_with_cmk_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.vm.vm_service import Disk from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_ensure_unattached_disks_encrypted_with_cmk: def test_vm_no_subscriptions(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = {} with ( @@ -33,6 +36,7 @@ class Test_vm_ensure_unattached_disks_encrypted_with_cmk: def test_vm_subscription_empty(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -57,6 +61,7 @@ class Test_vm_ensure_unattached_disks_encrypted_with_cmk: disk_id = str(uuid4()) resource_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id: Disk( @@ -93,13 +98,14 @@ class Test_vm_ensure_unattached_disks_encrypted_with_cmk: assert result[0].location == "location" assert ( result[0].status_extended - == f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_one_unattached_disk_encrypt_cmk(self): disk_id = str(uuid4()) resource_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id: Disk( @@ -136,7 +142,7 @@ class Test_vm_ensure_unattached_disks_encrypted_with_cmk: assert result[0].location == "location" assert ( result[0].status_extended - == f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_subscription_two_unattached_disk_encrypt_cmk_and_pk(self): @@ -145,6 +151,7 @@ class Test_vm_ensure_unattached_disks_encrypted_with_cmk: disk_id_2 = str(uuid4()) resource_id_2 = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id_1: Disk( @@ -188,7 +195,7 @@ class Test_vm_ensure_unattached_disks_encrypted_with_cmk: assert result[0].location == "location" assert ( result[0].status_extended - == f"Disk '{disk_id_1}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id_1}' is not encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) assert result[1].status == "PASS" assert result[1].resource_id == resource_id_2 @@ -196,13 +203,14 @@ class Test_vm_ensure_unattached_disks_encrypted_with_cmk: assert result[1].location == "location2" assert ( result[1].status_extended - == f"Disk '{disk_id_2}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Disk '{disk_id_2}' is encrypted with a customer-managed key in subscription {AZURE_SUBSCRIPTION_DISPLAY}." ) def test_vm_attached_disk_encrypt_cmk(self): disk_id = str(uuid4()) resource_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.disks = { AZURE_SUBSCRIPTION_ID: { disk_id: Disk( diff --git a/tests/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images_test.py b/tests/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images_test.py index 035ec5db3b..582e952374 100644 --- a/tests/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images_test.py +++ b/tests/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images_test.py @@ -3,7 +3,9 @@ from uuid import uuid4 from prowler.providers.azure.services.vm.vm_service import VirtualMachine from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -11,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_ensure_using_approved_images: def test_no_subscriptions(self): vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} with ( mock.patch( @@ -32,6 +35,7 @@ class Test_vm_ensure_using_approved_images: def test_empty_vms_in_subscription(self): vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} with ( mock.patch( @@ -64,6 +68,7 @@ class Test_vm_ensure_using_approved_images: image_reference=approved_image_id, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} with ( mock.patch( @@ -86,7 +91,7 @@ class Test_vm_ensure_using_approved_images: assert result[0].resource_name == "VMTestApproved" assert result[0].resource_id == vm_id assert result[0].subscription == AZURE_SUBSCRIPTION_ID - expected_status_extended = f"VM VMTestApproved in subscription {AZURE_SUBSCRIPTION_ID} is using an approved machine image: custom-image." + expected_status_extended = f"VM VMTestApproved in subscription {AZURE_SUBSCRIPTION_DISPLAY} is using an approved machine image: custom-image." assert result[0].status_extended == expected_status_extended def test_vm_with_not_approved_image(self): @@ -102,6 +107,7 @@ class Test_vm_ensure_using_approved_images: image_reference=not_approved_image_id, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} with ( mock.patch( @@ -124,7 +130,7 @@ class Test_vm_ensure_using_approved_images: assert result[0].resource_name == "VMTestNotApproved" assert result[0].resource_id == vm_id assert result[0].subscription == AZURE_SUBSCRIPTION_ID - expected_status_extended = f"VM VMTestNotApproved in subscription {AZURE_SUBSCRIPTION_ID} is not using an approved machine image." + expected_status_extended = f"VM VMTestNotApproved in subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using an approved machine image." assert result[0].status_extended == expected_status_extended def test_vm_with_missing_image_reference(self): @@ -139,6 +145,7 @@ class Test_vm_ensure_using_approved_images: image_reference=None, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} with ( mock.patch( @@ -161,5 +168,5 @@ class Test_vm_ensure_using_approved_images: assert result[0].resource_name == "VMTestNoImageRef" assert result[0].resource_id == vm_id assert result[0].subscription == AZURE_SUBSCRIPTION_ID - expected_status_extended = f"VM VMTestNoImageRef in subscription {AZURE_SUBSCRIPTION_ID} is not using an approved machine image." + expected_status_extended = f"VM VMTestNoImageRef in subscription {AZURE_SUBSCRIPTION_DISPLAY} is not using an approved machine image." assert result[0].status_extended == expected_status_extended diff --git a/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py b/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py index 3c494d861c..46b15ac994 100644 --- a/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py +++ b/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py @@ -11,7 +11,9 @@ from prowler.providers.azure.services.vm.vm_service import ( VirtualMachine, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -19,6 +21,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_ensure_using_managed_disks: def test_vm_no_subscriptions(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} with ( @@ -41,6 +44,7 @@ class Test_vm_ensure_using_managed_disks: def test_vm_subscriptions(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -64,6 +68,7 @@ class Test_vm_ensure_using_managed_disks: def test_vm_ensure_using_managed_disks(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -115,12 +120,13 @@ class Test_vm_ensure_using_managed_disks: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest is using managed disks in subscription {AZURE_SUBSCRIPTION_ID}" + == f"VM VMTest is using managed disks in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) def test_vm_using_not_managed_os_disk(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -172,12 +178,13 @@ class Test_vm_ensure_using_managed_disks: assert result[0].location == "location" assert ( result[0].status_extended - == f"VM VMTest is not using managed disks in subscription {AZURE_SUBSCRIPTION_ID}" + == f"VM VMTest is not using managed disks in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) def test_vm_using_not_managed_data_disks(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -231,5 +238,5 @@ class Test_vm_ensure_using_managed_disks: assert result[0].location == "location" assert ( result[0].status_extended - == f"VM VMTest is not using managed disks in subscription {AZURE_SUBSCRIPTION_ID}" + == f"VM VMTest is not using managed disks in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) diff --git a/tests/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled_test.py b/tests/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled_test.py index 03991f8049..eb37546cec 100644 --- a/tests/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled_test.py +++ b/tests/providers/azure/services/vm/vm_jit_access_enabled/vm_jit_access_enabled_test.py @@ -5,6 +5,7 @@ from prowler.providers.azure.services.defender.defender_service import JITPolicy from prowler.providers.azure.services.vm.vm_service import VirtualMachine from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -12,8 +13,10 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_jit_access_enabled: def test_no_subscriptions(self): vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.jit_policies = {} with ( mock.patch( @@ -39,8 +42,10 @@ class Test_vm_jit_access_enabled: def test_no_vms(self): vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.jit_policies = {AZURE_SUBSCRIPTION_ID: {}} with ( mock.patch( @@ -77,8 +82,10 @@ class Test_vm_jit_access_enabled: storage_profile=None, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} jit_policy = JITPolicy( id="policy1", name="JITPolicy1", @@ -128,8 +135,10 @@ class Test_vm_jit_access_enabled: storage_profile=None, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} # JIT policy does not include this VM jit_policy = JITPolicy( id="policy1", @@ -184,8 +193,10 @@ class Test_vm_jit_access_enabled: storage_profile=None, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {upper_vm_id: vm}} defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} jit_policy = JITPolicy( id="policy1", name="JITPolicy1", @@ -240,10 +251,12 @@ class Test_vm_jit_access_enabled: storage_profile=None, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: {vm_id_1: vm1, vm_id_2: vm2} } defender_client = mock.MagicMock() + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} jit_policy_1 = JITPolicy( id="policy1", name="JITPolicy1", diff --git a/tests/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication_test.py b/tests/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication_test.py index 5d400ac7bf..428c6adc85 100644 --- a/tests/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication_test.py +++ b/tests/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication_test.py @@ -7,6 +7,7 @@ from prowler.providers.azure.services.vm.vm_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -14,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_linux_enforce_ssh_authentication: def test_no_subscriptions(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} with ( @@ -36,6 +38,7 @@ class Test_vm_linux_enforce_ssh_authentication: def test_empty_subscription(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -59,6 +62,7 @@ class Test_vm_linux_enforce_ssh_authentication: def test_linux_vm_password_auth_disabled(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -100,6 +104,7 @@ class Test_vm_linux_enforce_ssh_authentication: def test_linux_vm_password_auth_enabled(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( @@ -141,6 +146,7 @@ class Test_vm_linux_enforce_ssh_authentication: def test_non_linux_vm(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = { AZURE_SUBSCRIPTION_ID: { vm_id: VirtualMachine( diff --git a/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py b/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py index 532e1b0b63..22dd59ce27 100644 --- a/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py +++ b/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py @@ -3,6 +3,7 @@ from uuid import uuid4 from prowler.providers.azure.services.vm.vm_service import VirtualMachineScaleSet from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, ) @@ -85,7 +86,7 @@ class Test_vm_scaleset_associated_with_load_balancer: assert result[0].resource_name == "compliant-vmss" assert result[0].location == "eastus" expected_status_extended = ( - f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' " f"is associated with load balancer backend pool(s): bepool." ) assert result[0].status_extended == expected_status_extended @@ -125,7 +126,7 @@ class Test_vm_scaleset_associated_with_load_balancer: assert result[0].resource_name == "noncompliant-vmss" assert result[0].location == "westeurope" expected_status_extended = ( - f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' " f"is not associated with any load balancer backend pool." ) assert result[0].status_extended == expected_status_extended @@ -172,14 +173,14 @@ class Test_vm_scaleset_associated_with_load_balancer: for r in result: if r.resource_name == "compliant-vmss": expected_status_extended = ( - f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' " f"is associated with load balancer backend pool(s): bepool." ) assert r.status == "PASS" assert r.status_extended == expected_status_extended elif r.resource_name == "noncompliant-vmss": expected_status_extended = ( - f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' " f"is not associated with any load balancer backend pool." ) assert r.status == "FAIL" @@ -216,6 +217,6 @@ class Test_vm_scaleset_associated_with_load_balancer: check = vm_scaleset_associated_with_load_balancer() result = check.execute() assert len(result) == 1 - expected_status_extended = f"Scale set '' in subscription '{AZURE_SUBSCRIPTION_ID}' is not associated with any load balancer backend pool." + expected_status_extended = f"Scale set '' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' is not associated with any load balancer backend pool." assert result[0].status == "FAIL" assert result[0].status_extended == expected_status_extended diff --git a/tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py b/tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py index 27d36f0697..6d28175066 100644 --- a/tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py +++ b/tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py @@ -3,6 +3,7 @@ from uuid import uuid4 from prowler.providers.azure.services.vm.vm_service import VirtualMachineScaleSet from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, ) @@ -83,7 +84,7 @@ class Test_vm_scaleset_not_empty: assert result[0].resource_id == vmss_id assert result[0].resource_name == "empty-vmss" assert result[0].location == "eastus" - expected_status_extended = f"Scale set 'empty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' is empty: no VM instances present." + expected_status_extended = f"Scale set 'empty-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' is empty: no VM instances present." assert result[0].status_extended == expected_status_extended def test_scale_set_with_instances(self): @@ -121,7 +122,7 @@ class Test_vm_scaleset_not_empty: assert result[0].resource_id == vmss_id assert result[0].resource_name == "nonempty-vmss" assert result[0].location == "westeurope" - expected_status_extended = f"Scale set 'nonempty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' has {len(instance_ids)} VM instances." + expected_status_extended = f"Scale set 'nonempty-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' has {len(instance_ids)} VM instances." assert result[0].status_extended == expected_status_extended def test_multiple_scale_sets(self): @@ -165,10 +166,10 @@ class Test_vm_scaleset_not_empty: assert len(result) == 2 for r in result: if r.resource_name == "empty-vmss": - expected_status_extended = f"Scale set 'empty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' is empty: no VM instances present." + expected_status_extended = f"Scale set 'empty-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' is empty: no VM instances present." assert r.status == "FAIL" assert r.status_extended == expected_status_extended elif r.resource_name == "nonempty-vmss": - expected_status_extended = f"Scale set 'nonempty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' has {len(instance_ids)} VM instances." + expected_status_extended = f"Scale set 'nonempty-vmss' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}' has {len(instance_ids)} VM instances." assert r.status == "PASS" assert r.status_extended == expected_status_extended diff --git a/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py b/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py index 28aab1b38b..70b1cf638f 100644 --- a/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py +++ b/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py @@ -3,6 +3,7 @@ from uuid import uuid4 from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -10,7 +11,9 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_sufficient_daily_backup_retention_period: def test_no_subscriptions(self): vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} recovery_client = mock.MagicMock() + recovery_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} recovery_client.vaults = {} with ( @@ -37,7 +40,9 @@ class Test_vm_sufficient_daily_backup_retention_period: def test_no_vms(self): vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} recovery_client = mock.MagicMock() + recovery_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {}} with ( @@ -118,7 +123,9 @@ class Test_vm_sufficient_daily_backup_retention_period: backup_policies={policy_id: backup_policy}, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} recovery_client = mock.MagicMock() + recovery_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} vm_client.audit_config = { @@ -212,7 +219,9 @@ class Test_vm_sufficient_daily_backup_retention_period: backup_policies={policy_id: backup_policy}, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} recovery_client = mock.MagicMock() + recovery_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} vm_client.audit_config = { @@ -306,7 +315,9 @@ class Test_vm_sufficient_daily_backup_retention_period: backup_policies={policy_id: backup_policy}, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} recovery_client = mock.MagicMock() + recovery_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} vm_client.audit_config = { @@ -391,7 +402,9 @@ class Test_vm_sufficient_daily_backup_retention_period: backup_policies={}, ) vm_client = mock.MagicMock() + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} recovery_client = mock.MagicMock() + recovery_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} with ( diff --git a/tests/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled_test.py b/tests/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled_test.py index 83ab63acce..364267fbed 100644 --- a/tests/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled_test.py +++ b/tests/providers/azure/services/vm/vm_trusted_launch_enabled/vm_trusted_launch_enabled_test.py @@ -10,7 +10,9 @@ from prowler.providers.azure.services.vm.vm_service import ( VirtualMachine, ) from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, set_mocked_azure_provider, ) @@ -18,6 +20,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_vm_trusted_launch_enabled: def test_vm_no_subscriptions(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {} with ( mock.patch( @@ -39,6 +42,7 @@ class Test_vm_trusted_launch_enabled: def test_vm_no_vm(self): vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} with ( mock.patch( @@ -61,6 +65,7 @@ class Test_vm_trusted_launch_enabled: def test_vm_trusted_launch_enabled(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -111,12 +116,13 @@ class Test_vm_trusted_launch_enabled: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest has trusted launch enabled in subscription {AZURE_SUBSCRIPTION_ID}" + == f"VM VMTest has trusted launch enabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) def test_vm_trusted_launch_disabled(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -168,12 +174,13 @@ class Test_vm_trusted_launch_enabled: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest has trusted launch disabled in subscription {AZURE_SUBSCRIPTION_ID}" + == f"VM VMTest has trusted launch disabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) def test_vm_no_security_profile(self): vm_id = str(uuid4()) vm_client = mock.MagicMock + vm_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -219,5 +226,5 @@ class Test_vm_trusted_launch_enabled: assert result[0].resource_id == vm_id assert ( result[0].status_extended - == f"VM VMTest has trusted launch disabled in subscription {AZURE_SUBSCRIPTION_ID}" + == f"VM VMTest has trusted launch disabled in subscription {AZURE_SUBSCRIPTION_DISPLAY}" ) diff --git a/tests/providers/cloudflare/cloudflare_provider_test.py b/tests/providers/cloudflare/cloudflare_provider_test.py index 58b7eb8086..c3ba6d75e2 100644 --- a/tests/providers/cloudflare/cloudflare_provider_test.py +++ b/tests/providers/cloudflare/cloudflare_provider_test.py @@ -433,6 +433,29 @@ class TestCloudflareValidateCredentials: with pytest.raises(CloudflareNoAccountsError): CloudflareProvider.validate_credentials(session) + def test_validate_credentials_breaks_on_repeated_account_ids(self): + """Pagination must stop when the SDK repeats account IDs to avoid infinite loops.""" + + def repeating_accounts(): + account = MagicMock() + account.id = ACCOUNT_ID + while True: + yield account + + mock_client = MagicMock() + mock_client.user.get.side_effect = Exception("Some other error") + mock_client.accounts.list.return_value = repeating_accounts() + + session = CloudflareSession( + client=mock_client, + api_token=API_TOKEN, + api_key=None, + api_email=None, + ) + + # Must return without hanging; repeated IDs break the loop. + CloudflareProvider.validate_credentials(session) + class TestCloudflareTestConnection: """Tests for test_connection method.""" diff --git a/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py index 0d7d91bc5f..6bd3aace97 100644 --- a/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py +++ b/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py @@ -136,3 +136,79 @@ class Test_zone_waf_enabled: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + + def test_zone_waf_disabled_paid_plan_includes_hint(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + plan="Pro Website", + settings=CloudflareZoneSettings( + waf="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "WAF is not enabled" in result[0].status_extended + assert "false positive" in result[0].status_extended + assert "Cloudflare dashboard" in result[0].status_extended + + def test_zone_waf_disabled_free_plan_includes_unavailable_hint(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + plan="Free Website", + settings=CloudflareZoneSettings( + waf="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "WAF is not enabled" in result[0].status_extended + assert "not available on the Cloudflare Free plan" in ( + result[0].status_extended + ) + assert "false positive" not in result[0].status_extended diff --git a/tests/providers/common/common_arguments_test.py b/tests/providers/common/common_arguments_test.py new file mode 100644 index 0000000000..fa96550f19 --- /dev/null +++ b/tests/providers/common/common_arguments_test.py @@ -0,0 +1,53 @@ +from prowler.providers.common.arguments import ( + validate_asff_usage, + validate_sarif_usage, +) + + +class TestValidateAsffUsage: + def test_asff_with_aws_provider(self): + valid, msg = validate_asff_usage("aws", ["json-asff"]) + assert valid is True + assert msg == "" + + def test_asff_with_non_aws_provider(self): + valid, msg = validate_asff_usage("gcp", ["json-asff"]) + assert valid is False + assert "aws" in msg + + def test_no_asff_in_formats(self): + valid, msg = validate_asff_usage("gcp", ["csv", "html"]) + assert valid is True + + def test_no_output_formats(self): + valid, msg = validate_asff_usage("aws", None) + assert valid is True + + +class TestValidateSarifUsage: + def test_sarif_with_iac_provider(self): + valid, msg = validate_sarif_usage("iac", ["sarif"]) + assert valid is True + assert msg == "" + + def test_sarif_with_non_iac_provider(self): + valid, msg = validate_sarif_usage("aws", ["sarif"]) + assert valid is False + assert "iac" in msg + + def test_sarif_with_other_provider(self): + valid, msg = validate_sarif_usage("gcp", ["csv", "sarif"]) + assert valid is False + assert "gcp" in msg + + def test_no_sarif_in_formats(self): + valid, msg = validate_sarif_usage("aws", ["csv", "html"]) + assert valid is True + + def test_no_output_formats(self): + valid, msg = validate_sarif_usage("iac", None) + assert valid is True + + def test_empty_output_formats(self): + valid, msg = validate_sarif_usage("aws", []) + assert valid is True diff --git a/tests/providers/external/__init__.py b/tests/providers/external/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py new file mode 100644 index 0000000000..ed367ad518 --- /dev/null +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -0,0 +1,2875 @@ +""" +Tests for dynamic provider loading via entry points. + +Covers: provider discovery, check discovery, check execution, +CLI argument registration, compliance frameworks, parser integration, +and all dispatch fallbacks for external providers. +""" + +from argparse import Namespace +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.common.provider import Provider + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_entry_point(name, value, group): + """Create a mock entry point.""" + ep = MagicMock() + ep.name = name + ep.value = value + ep.group = group + return ep + + +class FakeExternalProvider(Provider): + """Minimal Provider subclass for testing the dynamic contract.""" + + _type = "fakeexternal" + _cli_help_text = "Fake External Provider" + + def __init__(self): + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock(host_id="fake-host-1") + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + @classmethod + def from_cli_args(cls, _arguments, _fixer_config): + cls() + + def get_output_options(self, _arguments, _bulk_checks_metadata): + return MagicMock(output_directory="/tmp", output_filename="fake") + + def get_stdout_detail(self, finding): + return "fake-detail" + + def get_finding_sort_key(self): + return "region" + + def get_summary_entity(self): + return ("Fake Host", "fake-host-1") + + def get_finding_output_data(self, check_output): + return { + "auth_method": "fake", + "account_uid": "fake-account", + "account_name": "fake", + "resource_name": "fake-resource", + "resource_uid": "fake-uid", + "region": "local", + } + + def get_mutelist_finding_args(self): + return {"host_id": self.identity.host_id} + + def display_compliance_table( + self, + findings, + _bulk_checks_metadata, + _compliance_framework, + _output_filename, + output_directory, # referenced via name elsewhere in tests + _compliance_overview, + ): + return True + + def get_html_assessment_summary(self): + return "
    Fake Assessment
    " + + def generate_compliance_output( + self, + findings, + _bulk_compliance_frameworks, + _input_compliance_frameworks, + output_options, + generated_outputs, + ): + generated_outputs["compliance"].append("fake-compliance-output") + + @classmethod + def init_parser(cls, parser_instance): + pass + + +class FakeToolWrapperProvider(Provider): + """External provider that declares itself a tool wrapper.""" + + _type = "faketoolwrapper" + is_external_tool_provider = True + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock() + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + +class FakePureContractProvider(Provider): + """External provider that honors the from_cli_args type hint literally: + returns an instance without calling Provider.set_global_provider() from + __init__. Used to verify the call site wires the returned instance.""" + + _type = "fakepure" + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock(host_id="fake-pure-1") + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + @classmethod + def from_cli_args(cls, _arguments, _fixer_config): + # Literal contract: return the instance, no side-effect in __init__. + return cls() + + +class FakeProviderNoHelpText(Provider): + """Provider without _cli_help_text.""" + + _type = "nohelptext" + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock() + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clear_ep_cache(): + """Clear the entry point provider cache before each test.""" + Provider._ep_providers = {} + yield + Provider._ep_providers = {} + + +@pytest.fixture +def fake_provider(): + """Create and register a FakeExternalProvider.""" + p = FakeExternalProvider() + yield p + Provider._global = None + + +# =========================================================================== +# 1. Provider Discovery & Loading +# =========================================================================== + + +class TestProviderDiscovery: + """Tests 1-7: get_available_providers, _load_ep_provider, get_providers_help_text.""" + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_get_available_providers_merges_builtin_and_entrypoint(self, mock_ep): + """Test 1: get_available_providers returns both built-in and entry point providers.""" + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg.provider:Cls", "prowler.providers"), + ] + + providers = Provider.get_available_providers() + + # Built-in providers from actual prowler package + assert "aws" in providers + # External provider from entry point + assert "fakeexternal" in providers + assert "common" not in providers + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_get_available_providers_deduplicates(self, mock_ep): + """Test 2: Same provider name in built-in and entry point appears once.""" + # "aws" exists as built-in AND as entry point + mock_ep.return_value = [ + _make_entry_point("aws", "pkg.provider:Cls", "prowler.providers"), + ] + + providers = Provider.get_available_providers() + + assert providers.count("aws") == 1 + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_loads_class(self, mock_ep): + """Test 3: _load_ep_provider loads the class from entry point.""" + mock_ep.return_value = [ + _make_entry_point( + "fakeexternal", "pkg:FakeExternalProvider", "prowler.providers" + ), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls = Provider._load_ep_provider("fakeexternal") + + assert cls is FakeExternalProvider + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_returns_none_for_unknown(self, mock_ep): + """Test 4: _load_ep_provider returns None for unknown provider.""" + mock_ep.return_value = [] + + cls = Provider._load_ep_provider("nonexistent") + + assert cls is None + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_caches_result(self, mock_ep): + """Test 5: _load_ep_provider caches the loaded class.""" + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls1 = Provider._load_ep_provider("fakeexternal") + cls2 = Provider._load_ep_provider("fakeexternal") + + assert cls1 is cls2 + # load() should only be called once due to caching + mock_ep.return_value[0].load.assert_called_once() + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_caches_misses(self, mock_ep): + """A miss (unknown provider) must also be cached so repeated lookups + do not re-iterate entry_points(). Aligns with tool_wrapper._ep_class_cache, + which already caches None on miss.""" + mock_ep.return_value = [] + + first = Provider._load_ep_provider("nonexistent") + second = Provider._load_ep_provider("nonexistent") + + assert first is None + assert second is None + # entry_points() should only be called once across the two lookups + mock_ep.assert_called_once() + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_reads_cli_help_text( + self, mock_providers, mock_load + ): + """Test 6: get_providers_help_text reads _cli_help_text from entry point provider.""" + mock_providers.return_value = ["fakeexternal"] + mock_load.return_value = FakeExternalProvider + + help_text = Provider.get_providers_help_text() + + assert help_text["fakeexternal"] == "Fake External Provider" + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_empty_without_cli_help_text( + self, mock_providers, mock_load + ): + """Test 7: get_providers_help_text returns empty string without _cli_help_text.""" + mock_providers.return_value = ["nohelptext"] + mock_load.return_value = FakeProviderNoHelpText + + help_text = Provider.get_providers_help_text() + + assert help_text["nohelptext"] == "" + + +class TestSdkOnly: + """The ``sdk_only`` flag (default True) and Provider.get_app_providers.""" + + def test_base_contract_defaults_to_sdk_only(self): + # The default must be True so nothing leaks into the app implicitly. + assert Provider.sdk_only is True + + def test_external_provider_without_flag_is_sdk_only(self): + # FakeExternalProvider does not override the flag -> inherits True. + assert FakeExternalProvider.sdk_only is True + + @patch("prowler.providers.common.provider.Provider.get_class") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_app_providers_filters_out_sdk_only( + self, mock_available, mock_get_class + ): + app_cls = type("AppProvider", (Provider,), {"sdk_only": False}) + sdk_cls = type("SdkProvider", (Provider,), {"sdk_only": True}) + mock_available.return_value = ["appone", "sdkone", "apptwo"] + mock_get_class.side_effect = lambda name: { + "appone": app_cls, + "sdkone": sdk_cls, + "apptwo": app_cls, + }[name] + + app_providers = Provider.get_app_providers() + + assert app_providers == ["appone", "apptwo"] + + @patch("prowler.providers.common.provider.Provider.get_class") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_app_providers_excludes_provider_that_fails_to_load( + self, mock_available, mock_get_class + ): + # A provider whose class cannot be imported is treated as sdk_only + # (excluded) so a broken plug-in never leaks into the app. + app_cls = type("AppProvider", (Provider,), {"sdk_only": False}) + mock_available.return_value = ["appone", "broken"] + + def _get_class(name): + if name == "broken": + raise ImportError("missing transitive dep") + return app_cls + + mock_get_class.side_effect = _get_class + + assert Provider.get_app_providers() == ["appone"] + + def test_app_exposed_builtins_declare_sdk_only_false(self): + # The providers implemented end-to-end in the API/UI must opt in. + app_providers = set(Provider.get_app_providers()) + for name in ( + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "mongodbatlas", + "iac", + "oraclecloud", + "alibabacloud", + "cloudflare", + "openstack", + "image", + "googleworkspace", + "vercel", + "okta", + ): + assert name in app_providers, f"{name} should be exposed to the app" + + def test_sdk_only_builtins_are_hidden_from_app(self): + # Built-ins not implemented in the API stay SDK-only via the default. + app_providers = set(Provider.get_app_providers()) + for name in ("llm", "nhn", "scaleway", "stackit"): + assert name not in app_providers, f"{name} must be hidden from the app" + + +class TestIsToolWrapperProvider: + """Tests for Provider.is_tool_wrapper_provider — the helper that combines the + built-in EXTERNAL_TOOL_PROVIDERS frozenset with the is_external_tool_provider + class attribute of entry-point providers.""" + + def test_returns_true_for_builtin_tool_wrappers(self): + # iac/llm/image are in the EXTERNAL_TOOL_PROVIDERS frozenset (fast path) + assert Provider.is_tool_wrapper_provider("iac") is True + assert Provider.is_tool_wrapper_provider("llm") is True + assert Provider.is_tool_wrapper_provider("image") is True + + def test_returns_false_for_regular_builtin_providers(self): + # Regular built-ins must not be classified as tool wrappers + assert Provider.is_tool_wrapper_provider("aws") is False + assert Provider.is_tool_wrapper_provider("gcp") is False + assert Provider.is_tool_wrapper_provider("github") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_true_for_external_provider_declaring_flag(self, mock_ep): + # External plugin explicitly declares is_external_tool_provider = True + mock_ep.return_value = [ + _make_entry_point("faketoolwrapper", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeToolWrapperProvider + + assert Provider.is_tool_wrapper_provider("faketoolwrapper") is True + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_external_provider_without_flag(self, mock_ep): + # External plugin without the flag (default False) is treated as regular + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + assert Provider.is_tool_wrapper_provider("fakeexternal") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_unknown_provider(self, mock_ep): + mock_ep.return_value = [] + + assert Provider.is_tool_wrapper_provider("does-not-exist") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_none_provider(self, mock_ep): + # Pydantic validators may pass None when values.get("Provider") is missing + mock_ep.return_value = [] + + assert Provider.is_tool_wrapper_provider(None) is False + + +class TestIsBuiltinProvider: + """Tests for Provider.is_builtin — the helper that discriminates built-in + providers from external ones before attempting the import, so transitive + dependency failures in built-ins don't get silently re-routed to entry points.""" + + def test_returns_true_for_builtin_provider(self): + assert Provider.is_builtin("aws") is True + assert Provider.is_builtin("github") is True + + def test_returns_false_for_unknown_provider(self): + assert Provider.is_builtin("nonexistent_xyz") is False + + @patch("prowler.providers.common.provider.importlib.util.find_spec") + def test_returns_false_when_find_spec_raises(self, mock_find_spec): + # Certain namespace package edge cases raise ValueError/ImportError — + # helper should swallow and return False rather than propagate. + mock_find_spec.side_effect = ValueError("namespace package edge case") + + assert Provider.is_builtin("some_provider") is False + + +class TestInitProvidersParserBuiltinDependencyFailure: + """Selective fail-loud: init captures failures silently, enforce emits + warning for non-invoked and exits for the invoked broken provider.""" + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_builtin_with_missing_transitive_dep_fails_loudly( + self, mock_import, mock_is_builtin + ): + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'boto3'") + + parser = MagicMock() + parser._providers = ["aws"] + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws"], + ): + init_providers_parser(parser) + assert "aws" in parser._builtin_load_failures + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + def test_external_provider_does_not_touch_builtin_path( + self, mock_load_ep, mock_is_builtin + ): + from prowler.providers.common.arguments import init_providers_parser + + mock_is_builtin.return_value = False + ext_cls = MagicMock() + ext_cls.init_parser = MagicMock() + mock_load_ep.return_value = ext_cls + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["fakeexternal"], + ): + init_providers_parser(parser) + + ext_cls.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_unrelated_builtin_failure_does_not_abort_when_other_provider_invoked( + self, mock_import, mock_is_builtin + ): + """Broken stackit + invoked aws → warning, no abort.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise ImportError("No module named 'stackit.objectstorage'") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + assert "stackit" in parser._builtin_load_failures + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "-h"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_no_provider_invoked_failure_does_not_abort( + self, mock_import, mock_is_builtin + ): + """`prowler -h` + broken built-in → warning, help still renders.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'stackit.objectstorage'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "microsoft365"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_microsoft365_alias_still_triggers_fail_loud( + self, mock_import, mock_is_builtin + ): + """Alias `microsoft365 → m365` must be normalised before matching.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'msgraph'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["m365"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "oci"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_oci_alias_still_triggers_fail_loud( + self, mock_import, mock_is_builtin + ): + """Alias `oci → oraclecloud` must be normalised before matching.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'oci'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["oraclecloud"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "--output-directory", "stackit"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_flag_value_matching_provider_name_not_treated_as_invoked( + self, mock_import, mock_is_builtin + ): + """Flag-first invocation → invoked is 'aws' (default), not the flag's value.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise ImportError("No module named 'stackit.objectstorage'") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_builtin_non_import_error_fails_loudly( + self, mock_import, mock_is_builtin + ): + """Non-ImportError in invoked provider → still fail-loud.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = RuntimeError("Unexpected error in aws init_parser") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_unrelated_builtin_non_import_error_does_not_abort( + self, mock_import, mock_is_builtin + ): + """Non-ImportError in unrelated provider → warning, no abort.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise RuntimeError("Unexpected error in stackit init_parser") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + +class TestParseArgsOverrideAlignment: + """Regression: `parse(args=...)` overrides sys.argv AFTER __init__ ran; + the selective fail-loud must read argv at enforce time, not init time.""" + + def test_enforce_reads_current_sys_argv_not_init_time_sys_argv(self): + """Init with argv=['prowler','-h'] (no provider) captures stackit + failure silently. Enforce with argv=['prowler','stackit'] must + fail-loud — proving alignment under parse(args=...).""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + def import_side_effect(path): + if "stackit" in path: + raise ImportError("No module named 'stackit.objectstorage'") + return MagicMock() + + parser = MagicMock() + + with ( + patch( + "prowler.providers.common.arguments.Provider.is_builtin", + return_value=True, + ), + patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ), + patch( + "prowler.providers.common.arguments.import_module", + side_effect=import_side_effect, + ), + ): + # Phase 1: __init__ with ambient argv = ['prowler', '-h'] + with patch("sys.argv", ["prowler", "-h"]): + init_providers_parser(parser) + # Failure captured silently — no SystemExit during init + assert "stackit" in parser._builtin_load_failures + + # Phase 2: parse(args=...) overrode sys.argv → stackit invoked + with patch("sys.argv", ["prowler", "stackit"]): + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + def test_enforce_reads_current_sys_argv_for_no_invocation(self): + """Inverse: init's argv invokes stackit, but parse(args=['prowler', + '-h']) overrides. Enforce must NOT fail-loud.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + def import_side_effect(path): + if "stackit" in path: + raise ImportError("No module named 'stackit.objectstorage'") + return MagicMock() + + parser = MagicMock() + + with ( + patch( + "prowler.providers.common.arguments.Provider.is_builtin", + return_value=True, + ), + patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ), + patch( + "prowler.providers.common.arguments.import_module", + side_effect=import_side_effect, + ), + ): + # Phase 1: __init__ with ambient argv pretending stackit invoked + with patch("sys.argv", ["prowler", "stackit"]): + init_providers_parser(parser) + assert "stackit" in parser._builtin_load_failures + + # Phase 2: parse(args=['prowler', '-h']) overrode sys.argv → + # no provider invoked anymore → enforce must NOT exit + with patch("sys.argv", ["prowler", "-h"]): + enforce_invoked_provider_loaded(parser) + + +class TestInitGlobalProviderBuiltinDependencyFailure: + """Same contract as TestInitProvidersParserBuiltinDependencyFailure but + for the provider class import path in init_global_provider.""" + + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.import_module") + def test_builtin_with_missing_transitive_dep_fails_loudly( + self, mock_import, mock_is_builtin + ): + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'boto3'") + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + with pytest.raises(SystemExit): + Provider.init_global_provider(args) + Provider._global = None + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_handles_load_exception(self, mock_ep): + """_load_ep_provider returns None when ep.load() raises.""" + ep = _make_entry_point("broken", "pkg:Cls", "prowler.providers") + ep.load.side_effect = Exception("Import failed") + mock_ep.return_value = [ep] + + cls = Provider._load_ep_provider("broken") + + assert cls is None + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_builtin_path( + self, mock_providers, mock_is_builtin, mock_import + ): + """get_providers_help_text reads _cli_help_text from a built-in provider module.""" + import types + + mock_providers.return_value = ["fakebuiltin"] + mock_is_builtin.return_value = True + + mock_cls = type( + "FakebuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} + ) + mock_module = types.ModuleType("fake_module") + mock_module.FakebuiltinProvider = mock_cls + mock_import.return_value = mock_module + + help_text = Provider.get_providers_help_text() + + assert help_text["fakebuiltin"] == "Built-in Help" + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_generic_exception( + self, mock_providers, mock_import + ): + """get_providers_help_text handles generic exceptions with empty string.""" + mock_providers.return_value = ["broken"] + mock_import.side_effect = RuntimeError("Unexpected error") + + help_text = Provider.get_providers_help_text() + + assert help_text["broken"] == "" + + +# =========================================================================== +# 2. Provider Initialization +# =========================================================================== + + +class TestProviderInitialization: + """Tests 8-9: init_global_provider fallback to entry point.""" + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_fallback_to_entry_point( + self, mock_import, mock_load_ep, mock_config + ): + """Test 8: init_global_provider falls back to entry point when built-in fails.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakeExternalProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakeexternal", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakeExternalProvider) + Provider._global = None + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_exits_for_unknown_provider( + self, mock_import, mock_load_ep, mock_config + ): + """Test 9: init_global_provider exits when provider not found anywhere.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = None + mock_config.return_value = {} + + args = Namespace( + provider="nonexistent", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + with pytest.raises(SystemExit): + Provider.init_global_provider(args) + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_wires_instance_returned_by_from_cli_args( + self, mock_import, mock_load_ep, mock_config + ): + """A provider that implements from_cli_args as a pure function (returns + the instance without calling set_global_provider from __init__) is + correctly wired as the global provider by init_global_provider.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakePureContractProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakepure", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakePureContractProvider) + Provider._global = None + + @pytest.mark.parametrize( + "plugin_name", + [ + "awsx", + "aws_lite", + "azure_gov", + "gcp_org", + "github_enterprise", + "iac_v2", + ], + ) + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_external_with_builtin_substring_uses_from_cli_args( + self, mock_import, mock_load_ep, mock_config, plugin_name + ): + """Regression guard for the substring footgun in the dispatch chain. + + An external plug-in whose name contains a built-in substring + (e.g. `awsx`, `aws_lite`, `azure_gov`, `gcp_org`, `github_enterprise`, + `iac_v2`) MUST be routed to the dynamic else and instantiated via + `from_cli_args` — not silently captured by the built-in branch whose + name happens to be a substring of the plug-in name. See PR #10700 + review. + """ + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakeExternalProvider + mock_config.return_value = {} + + # Namespace deliberately omits the kwargs of any built-in branch + # (no `aws_retries_max_attempts`, `az_cli_auth`, `personal_access_token`, + # etc.). If equality dispatch is broken and the plug-in is misrouted to + # a built-in branch, attribute access will raise and the global never + # gets wired. + args = Namespace( + provider=plugin_name, + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakeExternalProvider) + Provider._global = None + + @patch("prowler.providers.common.provider.logger") + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_init_global_provider_warns_when_plugin_shadowed_by_builtin( + self, mock_is_builtin, mock_import, mock_entry_points, mock_config, mock_logger + ): + """Regression guard: when a plug-in registers a provider name that + collides with a built-in, the BUILT-IN wins and a warning is emitted + naming the shadowed plug-in. Shadow detection matches by entry-point + name only — the plug-in is never `ep.load()`-ed just to warn, so its + module code cannot run during a built-in run. See PR #10700 review + (HugoPBrito, Alan-TheGentleman). + """ + # Simulate a built-in `aws` that exists, AND a plug-in registered + # under the same `aws` name via entry points. + mock_is_builtin.return_value = True + shadow_ep = MagicMock() + shadow_ep.name = "aws" # plug-in shadowing the built-in name + mock_entry_points.return_value = [shadow_ep] + mock_import.return_value = MagicMock( + AwsProvider=MagicMock(side_effect=lambda **_kw: None) + ) + mock_config.return_value = {} + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + aws_retries_max_attempts=3, + role=None, + session_duration=None, + external_id=None, + role_session_name=None, + mfa=None, + profile=None, + region=None, + excluded_region=None, + organizations_role=None, + scan_unused_services=False, + resource_tag=None, + resource_arn=None, + mutelist_file=None, + ) + + Provider._global = None + try: + Provider.init_global_provider(args) + except BaseException: + # The AwsProvider mock is fake and the dispatch may sys.exit on + # the simulated failure; we only care about the warning emitted + # before the dispatch happens. + pass + finally: + Provider._global = None + + # Warning was emitted naming the shadowed plug-in + warning_msgs = [ + call.args[0] + for call in mock_logger.warning.call_args_list + if call.args and "Plug-in provider 'aws'" in call.args[0] + ] + assert warning_msgs, "expected a warning about the shadowed plug-in 'aws'" + assert "IGNORED" in warning_msgs[0] + # Shadow detected by name only — plug-in code never executed to warn + shadow_ep.load.assert_not_called() + + +# =========================================================================== +# 3. Check Discovery +# =========================================================================== + + +class TestCheckDiscovery: + """Tests 10-14: _recover_ep_checks, recover_checks_from_provider.""" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_discovers_checks(self, mock_spec, mock_ep): + """Test 10: _recover_ep_checks discovers checks from entry points.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point("my_check", "pkg.checks.my_check", "prowler.checks.fake"), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/path/to/pkg/checks/my_check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("fake") + + assert len(checks) == 1 + assert checks[0][0] == "my_check" + assert checks[0][1] == "/path/to/pkg/checks" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + def test_recover_ep_checks_empty_without_entry_points(self, mock_ep): + """Test 11: _recover_ep_checks returns empty list with no entry points.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [] + + checks = _recover_ep_checks("fake") + + assert checks == [] + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_handles_broken_entry_point(self, mock_spec, mock_ep): + """Test 12: _recover_ep_checks handles failed entry points gracefully.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point("broken_check", "pkg.broken", "prowler.checks.fake"), + ] + mock_spec.side_effect = Exception("Module not found") + + checks = _recover_ep_checks("fake") + + assert checks == [] + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_handles_external_provider_without_services( + self, mock_find_spec, mock_ep_checks + ): + """Test 13: recover_checks_from_provider doesn't crash for external providers. + + With find_spec returning None (built-in package doesn't exist), discovery + falls through to entry points cleanly — no ModuleNotFoundError catch + needed. + """ + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # not a built-in + mock_ep_checks.return_value = [("ext_check", "/path/to/check")] + + checks = recover_checks_from_provider("fakeexternal") + + assert len(checks) == 1 + assert checks[0][0] == "ext_check" + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.list_modules") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_combines_builtin_and_entry_points( + self, mock_find_spec, mock_list_modules, mock_ep_checks + ): + """Test 14: recover_checks_from_provider combines built-in and entry point checks.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = MagicMock() # built-in package exists + + # Simulate a built-in module + builtin_module = MagicMock() + builtin_module.name = "prowler.providers.aws.services.ec2.check_a.check_a" + builtin_module.module_finder.path = "/builtin/path" + mock_list_modules.return_value = [builtin_module] + + mock_ep_checks.return_value = [("check_b", "/external/path")] + + checks = recover_checks_from_provider("aws") + + check_names = [c[0] for c in checks] + assert "check_a" in check_names + assert "check_b" in check_names + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_filters_by_service(self, mock_spec, mock_ep): + """Service filter keeps only entry points whose dotted path includes + `.services.{service}.` — mirroring the built-in package filter.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point( + "container_has_no_root_user", + "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user", + "prowler.checks.dockerdesktop", + ), + _make_entry_point( + "image_is_signed", + "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed", + "prowler.checks.dockerdesktop", + ), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/some/path/check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("dockerdesktop", service="container") + + assert len(checks) == 1 + assert checks[0][0] == "container_has_no_root_user" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_without_service_returns_all(self, mock_spec, mock_ep): + """Without a service filter, all entry points for the provider are returned.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point( + "container_has_no_root_user", + "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user", + "prowler.checks.dockerdesktop", + ), + _make_entry_point( + "image_is_signed", + "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed", + "prowler.checks.dockerdesktop", + ), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/some/path/check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("dockerdesktop") + + assert len(checks) == 2 + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_external_provider_with_service( + self, mock_find_spec, mock_ep_checks + ): + """External provider with --service: built-in package doesn't exist, + but entry points are still consulted and return the requested service's + checks. No premature sys.exit.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # not a built-in + mock_ep_checks.return_value = [("container_check", "/ext/path")] + + checks = recover_checks_from_provider("dockerdesktop", service="container") + + assert len(checks) == 1 + assert checks[0][0] == "container_check" + mock_ep_checks.assert_called_once_with("dockerdesktop", "container") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_unknown_service_fails_cleanly( + self, mock_find_spec, mock_ep_checks + ): + """A typo or unknown service (not in built-ins nor in entry points) + fails with a clear error message, not a silent empty result.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None + mock_ep_checks.return_value = [] + + with pytest.raises(SystemExit): + recover_checks_from_provider("aws", service="typo_service") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_builtin_with_new_external_service( + self, mock_find_spec, mock_ep_checks + ): + """Built-in provider with a new service added via entry points: + the built-in package for that specific service doesn't exist (find_spec + returns None), but entry points pick it up. The gate `if not service:` + that previously skipped entry points when --service was passed is removed.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # built-in for new_aws_service doesn't exist + mock_ep_checks.return_value = [("new_check", "/ext/path")] + + checks = recover_checks_from_provider("aws", service="new_aws_service") + + assert len(checks) == 1 + assert checks[0][0] == "new_check" + mock_ep_checks.assert_called_once_with("aws", "new_aws_service") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.list_modules") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_surfaces_error_when_builtin_service_import_fails( + self, mock_find_spec, mock_list_modules, mock_ep_checks + ): + """Regression guard: when a built-in service's package exists but one + of its modules fails to import (e.g. a broken transitive dependency), + the error must surface via the global exception handler — not be + silently swallowed and replaced by an entry-point plug-in that happens + to share a name. See PR #10700 review (HugoPBrito).""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = MagicMock() # built-in service exists + mock_list_modules.side_effect = ImportError("missing transitive dep: foo") + + # Even if a plug-in registers checks for the same service, it must NOT + # silently take over — the import error wins. + mock_ep_checks.return_value = [("evil_check", "/evil/path")] + + with pytest.raises(SystemExit): + recover_checks_from_provider("aws", service="ec2") + + +# =========================================================================== +# 4. Check Execution +# =========================================================================== + + +class TestCheckExecution: + """Tests 15-17: _resolve_check_module.""" + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_builtin_first(self, mock_import, mock_find_spec): + """Test 15: _resolve_check_module resolves built-in checks first.""" + from prowler.lib.check.check import _resolve_check_module + + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_find_spec.return_value = MagicMock() # built-in package exists + + result = _resolve_check_module("aws", "ec2", "my_check") + + assert result is mock_module + mock_import.assert_called_once_with( + "prowler.providers.aws.services.ec2.my_check.my_check" + ) + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_fallback_to_entry_point( + self, mock_import_check, mock_find_spec + ): + """Test 16: _resolve_check_module falls back to entry point when built-in is absent.""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = None # built-in does not exist + + mock_ext_module = MagicMock() + ep = _make_entry_point( + "my_check", "ext_pkg.checks.my_check", "prowler.checks.fake" + ) + + with ( + patch("importlib.metadata.entry_points", return_value=[ep]), + patch("importlib.import_module", return_value=mock_ext_module) as mock_imp, + ): + result = _resolve_check_module("fake", "svc", "my_check") + + assert result is mock_ext_module + mock_imp.assert_called_with("ext_pkg.checks.my_check") + mock_import_check.assert_not_called() + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_builtin_wins_over_entry_point( + self, mock_import_check, mock_find_spec + ): + """Regression guard: when both a built-in and an entry-point check + exist with the same CheckID, the BUILT-IN wins. Plug-ins extend + Prowler with new checks but cannot silently override existing + built-ins — a security tool prefers fail-loud predictability over + permissive overrides. CheckMetadata.get_bulk applies the same + precedence (first-write-wins) and emits a warning. See PR #10700 + review (HugoPBrito).""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = MagicMock() # built-in exists + builtin_module = MagicMock() + mock_import_check.return_value = builtin_module + + # Plug-in registers same CheckID — must NOT take precedence + ep = _make_entry_point( + "ec2_instance_public_ip", + "plug_pkg.checks.ec2_instance_public_ip", + "prowler.checks.aws", + ) + + with ( + patch("importlib.metadata.entry_points", return_value=[ep]), + patch("importlib.import_module") as mock_imp, + ): + result = _resolve_check_module("aws", "ec2", "ec2_instance_public_ip") + + assert result is builtin_module + mock_import_check.assert_called_once_with( + "prowler.providers.aws.services.ec2.ec2_instance_public_ip.ec2_instance_public_ip" + ) + # Plug-in must NOT be loaded when a built-in with the same CheckID exists + mock_imp.assert_not_called() + + @patch("prowler.lib.check.check.importlib.metadata.entry_points") + @patch("prowler.lib.check.check.importlib.util.find_spec") + def test_resolve_check_module_raises_when_not_found(self, mock_find_spec, mock_ep): + """Test 17: _resolve_check_module raises ModuleNotFoundError when both fail.""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = None + mock_ep.return_value = [] + + with pytest.raises(ModuleNotFoundError, match="not found"): + _resolve_check_module("fake", "svc", "nonexistent_check") + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_surfaces_error_when_builtin_import_fails( + self, mock_import_check, mock_find_spec + ): + """Regression guard: when no plug-in entry-point overrides the + check, a built-in whose module exists but fails to import (e.g. + broken transitive dependency) MUST surface the real error instead + of being silently treated as 'not found'. See PR #10700 review + (HugoPBrito).""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = MagicMock() # built-in module exists + mock_import_check.side_effect = ImportError("missing transitive dep: foo") + + # No plug-in override — the built-in's import failure must propagate + with patch("importlib.metadata.entry_points", return_value=[]): + with pytest.raises(ImportError, match="missing transitive dep"): + _resolve_check_module("aws", "ec2", "ec2_instance_public_ip") + + +# =========================================================================== +# 5. CLI Arguments +# =========================================================================== + + +class TestCLIArguments: + """Tests 18-19: init_providers_parser fallback.""" + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_fallback_to_init_parser( + self, mock_import, mock_providers, mock_load_ep + ): + """Test 18: init_providers_parser falls back to cls.init_parser for external providers.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["fakeexternal"] + mock_import.side_effect = ImportError("No built-in arguments module") + mock_load_ep.return_value = FakeExternalProvider + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_no_crash_without_init_parser( + self, mock_import, mock_providers, mock_load_ep + ): + """Test 19: init_providers_parser doesn't crash if provider has no init_parser.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["nohelptext"] + mock_import.side_effect = ImportError("No built-in") + # FakeProviderNoHelpText has no init_parser + mock_load_ep.return_value = FakeProviderNoHelpText + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_handles_init_parser_exception( + self, mock_import, mock_providers, mock_load_ep + ): + """init_providers_parser handles exception when init_parser raises.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["fakeexternal"] + mock_import.side_effect = ImportError("No built-in") + + broken_cls = MagicMock() + broken_cls.init_parser.side_effect = RuntimeError("Parser init failed") + mock_load_ep.return_value = broken_cls + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + +# =========================================================================== +# 6. Compliance +# =========================================================================== + + +class TestCompliance: + """Tests 20-23: compliance discovery and loading.""" + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_discovers_dirs(self, mock_ep): + """Test 20: _get_ep_compliance_dirs discovers compliance directories.""" + from prowler.config.config import _get_ep_compliance_dirs + + mock_module = MagicMock() + mock_module.__path__ = ["/path/to/compliance"] + ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs["fakeexternal"] == ["/path/to/compliance"] + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_file_fallback(self, mock_ep): + """_get_ep_compliance_dirs uses __file__ when module has no __path__.""" + from prowler.config.config import _get_ep_compliance_dirs + + mock_module = MagicMock(spec=[]) + mock_module.__file__ = "/path/to/compliance/__init__.py" + del mock_module.__path__ + ep = _make_entry_point("ext", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs["ext"] == ["/path/to/compliance"] + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_handles_load_exception(self, mock_ep): + """_get_ep_compliance_dirs handles ep.load() exception gracefully.""" + from prowler.config.config import _get_ep_compliance_dirs + + ep = _make_entry_point("broken", "pkg.compliance", "prowler.compliance") + ep.load.side_effect = Exception("Load failed") + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs == {} + + @patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_includes_external(self, mock_dirs): + """Test 21: get_available_compliance_frameworks includes external compliance.""" + import json + import os + import tempfile + + from prowler.config.config import get_available_compliance_frameworks + + # Create a temp dir with a compliance JSON + with tempfile.TemporaryDirectory() as tmpdir: + json_path = os.path.join(tmpdir, "custom_1.0_ext.json") + with open(json_path, "w") as f: + json.dump({"Framework": "Custom", "Provider": "ext"}, f) + + mock_dirs.return_value = {"ext": [tmpdir]} + + frameworks = get_available_compliance_frameworks("ext") + + assert "custom_1.0_ext" in frameworks + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_available_compliance_includes_external_universal(self, mock_ep): + """External universal frameworks under prowler.compliance.universal are + listed, for a provider and for the provider=None case that feeds + --compliance choices.""" + import json + import os + import tempfile + + from prowler.config.config import get_available_compliance_frameworks + + with tempfile.TemporaryDirectory() as tmpdir: + framework = { + "framework": "CustomUniversal", + "name": "Custom Universal", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "customuniversal_1.0.json"), "w") as f: + json.dump(framework, f) + + module = MagicMock() + module.__path__ = [tmpdir] + ep = _make_entry_point( + "anyname", "pkg.compliance", "prowler.compliance.universal" + ) + ep.load.return_value = module + mock_ep.side_effect = lambda group: ( + [ep] if group == "prowler.compliance.universal" else [] + ) + + assert "customuniversal_1.0" in get_available_compliance_frameworks("aws") + assert "customuniversal_1.0" in get_available_compliance_frameworks(None) + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep): + """Test 22: Compliance.get_bulk loads external compliance JSON.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + # Create a valid compliance JSON + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "Custom", + "Name": "Custom Framework", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test framework", + "Requirements": [], + } + json_path = os.path.join(tmpdir, "custom_1.0_fakeexternal.json") + with open(json_path, "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "custom_1.0_fakeexternal" in bulk + assert bulk["custom_1.0_fakeexternal"].Framework == "Custom" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_skips_non_legacy_external_json( + self, mock_list_modules, mock_ep + ): + """A universal-schema JSON registered under prowler.compliance is skipped, + not aborting the run via sys.exit.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "framework": "Universal", + "name": "Universal Framework", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "universal_1.0.json"), "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point("aws", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("aws") + + assert "universal_1.0" not in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep): + """Compliance.get_bulk uses __file__ when external module has no __path__.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "Custom", + "Name": "Custom File Fallback", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test", + "Requirements": [], + } + json_path = os.path.join(tmpdir, "custom_file_fakeexternal.json") + with open(json_path, "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock(spec=[]) + mock_module.__file__ = os.path.join(tmpdir, "__init__.py") + del mock_module.__path__ + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "custom_file_fakeexternal" in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_handles_external_exception( + self, mock_list_modules, mock_ep + ): + """Compliance.get_bulk handles exception when loading external compliance.""" + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance") + ep.load.side_effect = Exception("Load failed") + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert bulk == {} + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_builtin_wins_on_duplicate( + self, mock_list_modules, mock_ep + ): + """Test 23: Compliance.get_bulk built-in wins on duplicate framework names.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + mock_ep.return_value = [] + + # If both exist with same key, built-in (loaded first) should win + # Since we have no built-in modules mocked, just verify external loads + # The actual dedup logic: `if name not in bulk_compliance_frameworks` + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "CIS", + "Name": "CIS Test", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test", + "Requirements": [], + } + with open(os.path.join(tmpdir, "dup_framework.json"), "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "dup_framework" in bulk + + @pytest.mark.parametrize( + "provider, framework_segments", + [ + # `cloud` is a substring of THREE built-in modules at once. + ("cloud", ["alibabacloud", "cloudflare", "oraclecloud"]), + ("git", ["github"]), + ("work", ["googleworkspace"]), + ("open", ["openstack"]), + ], + ) + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_matches_provider_segment_exactly( + self, mock_list_modules, mock_ep, provider, framework_segments + ): + """Regression: a provider whose name is a substring of one or more + framework modules must NOT load them. The old `provider in name` + check captured overlapping built-ins (e.g. `cloud` matched + alibabacloud, cloudflare and oraclecloud). See PR #10700 review + (Alan-TheGentleman). + """ + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_ep.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + # The substring path the old code would have read from. + os.mkdir(os.path.join(tmpdir, provider)) + json_data = { + "Framework": "Custom", + "Name": f"Should not load for '{provider}'", + "Version": "1.0", + "Provider": provider, + "Description": "Test", + "Requirements": [], + } + with open(os.path.join(tmpdir, provider, "wrong.json"), "w") as f: + json.dump(json_data, f) + + modules = [] + for segment in framework_segments: + module = MagicMock() + module.name = f"prowler.compliance.{segment}" + module.module_finder.path = tmpdir + modules.append(module) + mock_list_modules.return_value = modules + + bulk = Compliance.get_bulk(provider) + + # Exact-segment match: the provider is not any of these modules. + assert "wrong" not in bulk + assert bulk == {} + + +# =========================================================================== +# 7. Parser +# =========================================================================== + + +class TestParser: + """Tests 24-27: parser dynamic discovery.""" + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_discovers_new_providers(self, mock_providers, mock_help): + """Test 24: Parser discovers providers not in known_providers.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "fakeexternal", + ] + mock_help.return_value = {"fakeexternal": "Fake External Provider"} + + parser = ProwlerArgumentParser() + + assert "fakeexternal" in parser.parser.format_usage() + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_appends_to_epilog_with_help_text(self, mock_providers, mock_help): + """Test 25: Parser appends new providers to epilog with _cli_help_text.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "fakeexternal", + ] + mock_help.return_value = {"fakeexternal": "Fake External Provider"} + + parser = ProwlerArgumentParser() + epilog = parser.parser.epilog + + assert "fakeexternal" in epilog + assert "Fake External Provider" in epilog + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_skips_epilog_entry_without_help_text( + self, mock_providers, mock_help + ): + """Test 26: Parser doesn't add epilog entry if _cli_help_text is empty.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "nohelptext", + ] + mock_help.return_value = {"nohelptext": ""} + + parser = ProwlerArgumentParser() + epilog = parser.parser.epilog + + # Should appear in usage/csv but NOT in the descriptive epilog listing + assert "nohelptext" in parser.parser.format_usage() + # No line with "nohelptext Something" in epilog + epilog_lines = [ + line.strip() for line in epilog.splitlines() if "nohelptext" in line + ] + assert len(epilog_lines) == 0 or all( + "nohelptext" in line and line.strip() == "nohelptext" or "{" in line + for line in epilog_lines + ) + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_does_not_duplicate_known_providers(self, mock_providers, mock_help): + """Test 27: Parser doesn't duplicate providers already in the known list.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + # No new providers + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + ] + mock_help.return_value = {} + + parser = ProwlerArgumentParser() + usage = parser.parser.format_usage() + + # aws should appear exactly once in usage + assert usage.count("aws") == 1 + + +# =========================================================================== +# 8. Dispatch Fallbacks +# =========================================================================== + + +class TestDispatchFallbacks: + """Tests 28-34: all else clause fallbacks for external providers.""" + + def test_stdout_report_calls_get_stdout_detail(self, fake_provider): + """Test 28: stdout_report else clause calls provider.get_stdout_detail.""" + from prowler.lib.outputs.outputs import stdout_report + + finding = MagicMock() + finding.check_metadata.Provider = "fakeexternal" + finding.status = "FAIL" + finding.muted = False + finding.status_extended = "test" + + with patch("builtins.print") as mock_print: + stdout_report( + finding, "\033[31m", True, ["FAIL"], False, provider=fake_provider + ) + + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + assert "fake-detail" in printed + + def test_stdout_report_resolves_provider_when_none(self, fake_provider): + """stdout_report resolves provider via get_global_provider when not passed.""" + from prowler.lib.outputs.outputs import stdout_report + + finding = MagicMock() + finding.check_metadata.Provider = "fakeexternal" + finding.status = "FAIL" + finding.muted = False + finding.status_extended = "test" + + with patch("builtins.print") as mock_print: + stdout_report(finding, "\033[31m", True, ["FAIL"], False) + + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + assert "fake-detail" in printed + + def test_report_propagates_provider_to_stdout_report(self): + """Regression guard: report() must pass its local `provider` through to + stdout_report so the dynamic else does not fall back to the global + singleton. With Provider._global cleared, the call chain still has to + work for an external provider — proving the argument is being used + instead of `Provider.get_global_provider()`. See PR #10700 review + (HugoPBrito).""" + from prowler.lib.outputs.outputs import report + from prowler.providers.common.provider import Provider + + local_provider = FakeExternalProvider.__new__(FakeExternalProvider) + + finding = MagicMock() + finding.status = "PASS" + finding.muted = False + finding.region = "x" + finding.check_metadata.Provider = "fakeexternal" + finding.status_extended = "test" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = [] + output_options.fixer = False + + # Clear the global singleton so any unintended fallback would crash. + previous_global = Provider._global + Provider._global = None + try: + with patch("builtins.print") as mock_print: + report([finding], local_provider, output_options) + + # report() prints the finding line plus an empty separator when + # verbose is set; we only care that the finding was rendered using + # the local provider's `get_stdout_detail` ("fake-detail"). + printed = "".join( + call.args[0] for call in mock_print.call_args_list if call.args + ) + assert "fake-detail" in printed + finally: + Provider._global = previous_global + + def test_report_sort_calls_get_finding_sort_key(self, fake_provider): + """Test 29: report else clause calls provider.get_finding_sort_key.""" + from prowler.lib.outputs.outputs import report + + finding1 = MagicMock() + finding1.status = "PASS" + finding1.muted = False + finding1.region = "b-region" + finding1.check_metadata.Provider = "fakeexternal" + finding1.status_extended = "test1" + + finding2 = MagicMock() + finding2.status = "PASS" + finding2.muted = False + finding2.region = "a-region" + finding2.check_metadata.Provider = "fakeexternal" + finding2.status_extended = "test2" + + output_options = MagicMock() + output_options.verbose = False + output_options.status = [] + + findings = [finding1, finding2] + report(findings, fake_provider, output_options) + + # Should be sorted by region (get_finding_sort_key returns "region") + assert findings[0].region == "a-region" + assert findings[1].region == "b-region" + + def test_display_summary_table_calls_get_summary_entity(self, fake_provider): + """Test 30: display_summary_table else clause calls provider.get_summary_entity.""" + from prowler.lib.outputs.summary_table import display_summary_table + + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.check_metadata.ServiceName = "test_service" + finding.check_metadata.Provider = "fakeexternal" + finding.check_metadata.Severity = "high" + + output_options = MagicMock() + output_options.output_directory = "/tmp" + output_options.output_filename = "test" + output_options.output_modes = [] + + with patch("builtins.print") as mock_print: + display_summary_table([finding], fake_provider, output_options) + + printed_text = " ".join(str(c) for c in mock_print.call_args_list) + assert "Fake Host" in printed_text or "fake-host-1" in printed_text + + def test_generate_output_calls_get_finding_output_data(self, fake_provider): + """Test 31: finding.generate_output else clause calls provider.get_finding_output_data.""" + from prowler.lib.check.models import ( + CheckMetadata, + Code, + Recommendation, + Remediation, + ) + from prowler.lib.outputs.finding import Finding + + metadata = CheckMetadata( + Provider="fakeexternal", + CheckID="test_check", + CheckTitle="Test check title", + CheckType=[], + ServiceName="test", + SubServiceName="", + ResourceIdTemplate="", + Severity="high", + ResourceType="Test", + ResourceGroup="", + Description="Test description", + Risk="Test risk", + RelatedUrl="", + Remediation=Remediation( + Code=Code(CLI="", NativeIaC="", Other="", Terraform=""), + Recommendation=Recommendation( + Text="Fix it", Url="https://hub.prowler.com/check/test_check" + ), + ), + Categories=[], + DependsOn=[], + RelatedTo=[], + Notes="", + ) + + check_output = MagicMock() + check_output.check_metadata = metadata + check_output.status = "FAIL" + check_output.status_extended = "test failed" + check_output.muted = False + check_output.resource = {} + check_output.resource_details = "" + check_output.resource_tags = {} + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = False + output_options.bulk_checks_metadata = {} + + finding = Finding.generate_output(fake_provider, check_output, output_options) + + assert finding.auth_method == "fake" + assert finding.account_uid == "fake-account" + assert finding.resource_name == "fake-resource" + assert finding.region == "local" + + def test_output_options_calls_get_output_options(self, fake_provider): + """Test 32: __main__.py else clause calls provider.get_output_options.""" + result = fake_provider.get_output_options(MagicMock(), {}) + + assert result is not None + assert hasattr(result, "output_directory") + + def test_html_assessment_calls_get_html_assessment_summary(self, fake_provider): + """Test 33: html.py fallback calls provider.get_html_assessment_summary.""" + from prowler.lib.outputs.html.html import HTML + + result = HTML.get_assessment_summary(fake_provider) + + assert "
    Fake Assessment
    " in result + + def test_compliance_output_calls_generate_compliance_output(self, fake_provider): + """Test 34: __main__.py else clause calls provider.generate_compliance_output.""" + generated_outputs = {"compliance": []} + + fake_provider.generate_compliance_output( + [], + {}, + set(), + MagicMock(), + generated_outputs, + ) + + assert "fake-compliance-output" in generated_outputs["compliance"] + + +# =========================================================================== +# 9. Base Contract Defaults +# =========================================================================== + + +class TestBaseContractDefaults: + """Tests for Provider base class default implementations.""" + + def test_get_scan_arguments_passes_secret_through(self): + """Base get_scan_arguments returns the secret unchanged when no mutelist.""" + kwargs = FakeProviderNoHelpText.get_scan_arguments("uid", {"token": "x"}) + + assert kwargs == {"token": "x"} + + def test_get_scan_arguments_adds_mutelist_content(self): + """Base get_scan_arguments adds mutelist_content when provided.""" + kwargs = FakeProviderNoHelpText.get_scan_arguments( + "uid", {"token": "x"}, {"Mutelist": {}} + ) + + assert kwargs == {"token": "x", "mutelist_content": {"Mutelist": {}}} + + def test_get_scan_arguments_preserves_empty_mutelist_content(self): + """Base get_scan_arguments passes an explicit empty mutelist through so it + is not mistaken for an absent mutelist that triggers provider defaults.""" + kwargs = FakeProviderNoHelpText.get_scan_arguments("uid", {"token": "x"}, {}) + + assert kwargs == {"token": "x", "mutelist_content": {}} + + def test_get_connection_arguments_passes_secret_through(self): + """Base get_connection_arguments returns the secret unchanged.""" + kwargs = FakeProviderNoHelpText.get_connection_arguments("uid", {"token": "x"}) + + assert kwargs == {"token": "x"} + + def test_get_credentials_schema_defaults_to_empty(self): + """Base get_credentials_schema declares no schema by default.""" + assert FakeProviderNoHelpText.get_credentials_schema() == {} + + def test_from_cli_args_raises_not_implemented(self): + """Base Provider.from_cli_args raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + FakeProviderNoHelpText.from_cli_args(MagicMock(), {}) + + def test_get_output_options_raises_not_implemented(self): + """Base Provider.get_output_options raises NotImplementedError; the + generic default is applied at the call site via default_output_options.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_output_options(MagicMock(), {}) + + def test_default_output_options_builds_generic_default(self): + """default_output_options returns a generic ProviderOutputOptions so an + external provider without get_output_options still produces output + instead of aborting the run.""" + from prowler.config.config import output_file_timestamp + from prowler.providers.common.models import ( + ProviderOutputOptions, + default_output_options, + ) + + provider = FakeProviderNoHelpText() + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename=None, + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = default_output_options(provider, arguments, {}) + + assert isinstance(output_options, ProviderOutputOptions) + assert ( + output_options.output_filename + == f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + + def test_default_output_options_honors_explicit_filename(self): + """A user-supplied output_filename is preserved by default_output_options.""" + from prowler.providers.common.models import default_output_options + + provider = FakeProviderNoHelpText() + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename="custom-name", + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = default_output_options(provider, arguments, {}) + + assert output_options.output_filename == "custom-name" + + def test_get_stdout_detail_raises_not_implemented(self): + """Base Provider.get_stdout_detail raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_stdout_detail(MagicMock()) + + def test_get_finding_sort_key_returns_none(self): + """Base Provider.get_finding_sort_key returns None.""" + provider = FakeProviderNoHelpText() + assert provider.get_finding_sort_key() is None + + def test_get_summary_entity_returns_type_and_account_default(self): + """Base Provider.get_summary_entity returns (type, account_id) so the + summary table is not silently dropped for providers that don't override + it.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + + provider = FakeProviderNoHelpText() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(account_id="acc-123"), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "acc-123" + + def test_get_summary_entity_defaults_account_to_empty_string(self): + """When the identity has no account_id, audited_entities falls back to ''.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + + provider = FakeProviderNoHelpText() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "" + + def test_get_finding_output_data_raises_not_implemented(self): + """Base Provider.get_finding_output_data raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_finding_output_data(MagicMock()) + + def test_get_html_assessment_summary_raises_not_implemented(self): + """Base Provider.get_html_assessment_summary raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_html_assessment_summary() + + def test_generate_compliance_output_raises_not_implemented(self): + """Base Provider.generate_compliance_output raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.generate_compliance_output([], {}, set(), MagicMock(), {}) + + def test_get_mutelist_finding_args_raises_not_implemented(self): + """Base Provider.get_mutelist_finding_args raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_mutelist_finding_args() + + def test_display_compliance_table_raises_not_implemented(self): + """Base Provider.display_compliance_table raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.display_compliance_table([], {}, "fw", "out", "/tmp", False) + + def test_is_external_tool_provider_defaults_to_false(self): + """Base Provider.is_external_tool_provider returns False.""" + provider = FakeProviderNoHelpText() + assert provider.is_external_tool_provider is False + + +# =========================================================================== +# 10. Mutelist Dispatch for External Providers +# =========================================================================== + + +class TestMutelistDispatch: + """Tests for mutelist integration with external providers.""" + + def test_get_mutelist_finding_args_returns_identity(self, fake_provider): + """External provider returns identity kwargs for mutelist.""" + args = fake_provider.get_mutelist_finding_args() + + assert args == {"host_id": "fake-host-1"} + + def test_mutelist_dispatch_calls_external_provider(self, fake_provider): + """execute() uses get_mutelist_finding_args for unknown provider types.""" + from prowler.lib.check.check import execute + + # Create a mock check that returns one finding + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.check_metadata.Provider = "fakeexternal" + + check = MagicMock() + check.execute.return_value = [finding] + check.CheckID = "fake_check" + check.ServiceName = "fake_service" + check.Severity.value = "high" + + # Setup mutelist on the provider + fake_provider.mutelist = MagicMock() + fake_provider.mutelist.mutelist = {"Accounts": {}} + fake_provider.mutelist.is_finding_muted.return_value = True + + output_options = MagicMock() + output_options.status = [] + output_options.unix_timestamp = False + + execute(check, fake_provider, None, output_options) + + # is_finding_muted should have been called with host_id + finding + fake_provider.mutelist.is_finding_muted.assert_called_once_with( + host_id="fake-host-1", finding=finding + ) + + +# =========================================================================== +# 11. Compliance Table Dispatch for External Providers +# =========================================================================== + + +class TestComplianceTableDispatch: + """Tests for compliance table display with external providers.""" + + def test_display_compliance_table_delegates_to_provider(self, fake_provider): + """display_compliance_table uses provider method for unknown frameworks.""" + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + fake_provider.display_compliance_table = MagicMock(return_value=True) + + display_compliance_table( + [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False + ) + + fake_provider.display_compliance_table.assert_called_once_with( + [], + {}, + "custom_1.0_fakeexternal", + "out", + "/tmp", + False, + ) + + def test_display_compliance_table_falls_back_to_generic(self, fake_provider): + """display_compliance_table falls back to generic when provider returns False.""" + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + fake_provider.display_compliance_table = MagicMock(return_value=False) + + with patch( + "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table" + ) as mock_generic: + display_compliance_table( + [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False + ) + + mock_generic.assert_called_once() + + def test_display_compliance_table_falls_back_on_not_implemented(self): + """display_compliance_table falls back to generic when NotImplementedError.""" + # Use a provider that doesn't implement display_compliance_table + provider = FakeProviderNoHelpText() + Provider.set_global_provider(provider) + + with patch( + "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table" + ) as mock_generic: + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + display_compliance_table( + [], {}, "unknown_1.0_nohelptext", "out", "/tmp", False + ) + + mock_generic.assert_called_once() + Provider._global = None + + +# =========================================================================== +# 12. Provider.get_class — Public side-effect-free class resolver +# =========================================================================== + + +class TestGetClass: + """Tests for Provider.get_class(provider) — the public, side-effect-free + class resolver that unblocks the Django API and other callers that need + a provider class without triggering CLI side-effects (sys.exit, global + provider mutation).""" + + # ----------------------------------------------------------------------- + # T1: Built-in provider resolves to correct class + # ----------------------------------------------------------------------- + + def test_get_class_builtin_returns_correct_class(self): + """get_class('aws') returns AwsProvider — identity check.""" + from prowler.providers.aws.aws_provider import AwsProvider + + cls = Provider.get_class("aws") + + assert cls is AwsProvider + + # ----------------------------------------------------------------------- + # T2: External entry-point provider resolves + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_external_ep_returns_class(self, mock_is_builtin, mock_ep): + """get_class resolves an external entry-point provider and returns that class.""" + mock_is_builtin.return_value = False + mock_ep.return_value = [ + _make_entry_point( + "fakeexternal", "pkg:FakeExternalProvider", "prowler.providers" + ), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls = Provider.get_class("fakeexternal") + + assert cls is FakeExternalProvider + + # ----------------------------------------------------------------------- + # T3: Unknown provider raises, does NOT call sys.exit + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_unknown_raises_and_does_not_sys_exit( + self, mock_is_builtin, mock_ep + ): + """get_class raises for an unknown provider and never calls sys.exit.""" + mock_is_builtin.return_value = False + mock_ep.return_value = [] + + # Assert ImportError specifically to enforce the public API contract + # (not a broad Exception). SystemExit belongs in init_global_provider's + # wrapper, not in the pure resolver. + with pytest.raises(ImportError): + Provider.get_class("totally_unknown_xyz_provider") + + # ----------------------------------------------------------------------- + # T4: get_class is PURE for built-ins — no collision warning, no EP call + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.logger") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_builtin_with_ep_shadow_is_pure( + self, mock_is_builtin, mock_import, mock_load_ep, mock_logger + ): + """get_class for a built-in with a same-named EP is PURE: + - returns the built-in class + - does NOT emit a collision warning + - does NOT call _load_ep_provider (so _ep_providers cache stays empty for + this key, proving no side-effect) + """ + import types + + mock_is_builtin.return_value = True + mock_load_ep.return_value = FakeExternalProvider # plug-in shadow present + + fake_module = types.ModuleType("fake_builtin_module") + fake_builtin_cls = type("AwsProvider", (Provider,), {"_type": "aws"}) + fake_module.AwsProvider = fake_builtin_cls + mock_import.return_value = fake_module + + cls = Provider.get_class("aws") + + # Built-in class returned + assert cls is fake_builtin_cls + # No collision warning emitted — that is now init_global_provider's job + warning_msgs = [ + call.args[0] + for call in mock_logger.warning.call_args_list + if call.args and "IGNORED" in call.args[0] + ] + assert not warning_msgs, ( + "get_class must NOT emit a collision warning; " + "init_global_provider owns that responsibility" + ) + # _load_ep_provider must NOT have been called for the built-in path + mock_load_ep.assert_not_called() + # _ep_providers cache must not contain 'aws' (no side-effect) + assert "aws" not in Provider._ep_providers + + # ----------------------------------------------------------------------- + # T4b: Built-in module missing its expected class raises ImportError + # and does NOT fall back to a same-named entry point + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_builtin_missing_class_raises_importerror( + self, mock_is_builtin, mock_import, mock_load_ep + ): + """When is_builtin is True but the module does not define the expected + provider class, get_class raises ImportError and does NOT fall back to a + same-named entry point — falling back would contradict is_builtin and + silently return a foreign class.""" + import types + + mock_is_builtin.return_value = True + # Module imports fine but lacks the expected `Provider` attribute. + empty_module = types.ModuleType("empty_builtin_module") + mock_import.return_value = empty_module + + with pytest.raises(ImportError): + Provider.get_class("aws") + + # Must NOT fall back to entry points for a (broken) built-in. + mock_load_ep.assert_not_called() + + # ----------------------------------------------------------------------- + # T4c: Entry point resolving to a non-Provider class raises ImportError + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_external_ep_not_provider_subclass_raises_importerror( + self, mock_is_builtin, mock_ep + ): + """When an entry point resolves to an object that is not a Provider + subclass, get_class raises ImportError instead of returning it, so the + public contract (a Provider subclass) is enforced rather than trusted.""" + + class NotAProvider: + pass + + mock_is_builtin.return_value = False + mock_ep.return_value = [ + _make_entry_point("rogue", "pkg:NotAProvider", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = NotAProvider + + with pytest.raises(ImportError): + Provider.get_class("rogue") + + # ----------------------------------------------------------------------- + # T4d: Contract — every built-in provider stays resolvable via get_class + # ----------------------------------------------------------------------- + + @pytest.mark.parametrize( + "provider", + [ + name + for name in Provider.get_available_providers() + if Provider.is_builtin(name) + ], + ) + def test_get_class_resolves_every_builtin_provider(self, provider): + """Contract test over all built-in providers: each one must remain + resolvable through get_class and return a Provider subclass whose name + follows the `{Capitalized}Provider` convention. This pins the naming + convention as the built-in resolution contract, so a future provider + that breaks it fails here instead of silently at runtime in a caller + (e.g. the API).""" + cls = Provider.get_class(provider) + + assert isinstance(cls, type) and issubclass(cls, Provider) + assert cls.__name__ == f"{provider.capitalize()}Provider" + + # ----------------------------------------------------------------------- + # T5: Regression — init_global_provider still resolves external correctly + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + def test_init_global_provider_still_resolves_external_via_get_class( + self, mock_load_ep, mock_config + ): + """Regression: init_global_provider continues to work for external providers + after the class-resolution block is delegated to get_class. + + 'fakepure' is not a built-in, so is_builtin() returns False and get_class + takes the entry-point path. This verifies the FakePureContractProvider path + (pure from_cli_args returning an instance) still works — i.e., + init_global_provider correctly wires the returned instance as global provider. + """ + mock_load_ep.return_value = FakePureContractProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakepure", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakePureContractProvider) + Provider._global = None + + # ----------------------------------------------------------------------- + # T6: Regression — get_providers_help_text returns same text after refactor + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_identical_after_refactor_external( + self, mock_providers, mock_load_ep + ): + """get_providers_help_text returns identical _cli_help_text for an external + provider both before and after the refactor to use get_class internally.""" + mock_providers.return_value = ["fakeexternal"] + mock_load_ep.return_value = FakeExternalProvider + + help_text = Provider.get_providers_help_text() + + # Must match the known _cli_help_text on FakeExternalProvider + assert help_text["fakeexternal"] == "Fake External Provider" + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_identical_after_refactor_builtin( + self, mock_providers, mock_is_builtin, mock_import + ): + """get_providers_help_text returns identical _cli_help_text for a built-in + provider both before and after the refactor to use get_class internally. + is_builtin is mocked to True so get_class takes the built-in import path.""" + import types + + mock_providers.return_value = ["fakebuiltin"] + mock_is_builtin.return_value = True + mock_cls = type( + "FakebuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} + ) + mock_module = types.ModuleType("fake_module") + mock_module.FakebuiltinProvider = mock_cls + mock_import.return_value = mock_module + + help_text = Provider.get_providers_help_text() + + assert help_text["fakebuiltin"] == "Built-in Help" + + # ----------------------------------------------------------------------- + # T7: init_global_provider emits collision warning (not get_class) + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_init_global_provider_emits_collision_warning_for_builtin_ep_shadow( + self, mock_is_builtin, mock_import, mock_entry_points, mock_config, caplog + ): + """init_global_provider (not get_class) emits the collision warning + when a built-in provider has a same-named entry-point plug-in registered. + + This is the counterpart to test_get_class_builtin_with_ep_shadow_is_pure: + the warning responsibility moved OUT of get_class and INTO + init_global_provider, so users still see the message on CLI invocation + but prowler --help and API calls (which never hit init_global_provider) + do not spuriously emit it. The shadow is detected by entry-point name + only — the plug-in is never loaded to warn. + """ + import logging + import types + + mock_is_builtin.return_value = True + shadow_ep = MagicMock() + shadow_ep.name = "aws" # plug-in shadowing the built-in name + mock_entry_points.return_value = [shadow_ep] + + fake_module = types.ModuleType("fake_builtin_module") + fake_module.AwsProvider = MagicMock(side_effect=lambda **_kw: None) + mock_import.return_value = fake_module + mock_config.return_value = {} + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + aws_retries_max_attempts=3, + role=None, + session_duration=None, + external_id=None, + role_session_name=None, + mfa=None, + profile=None, + region=None, + excluded_region=None, + organizations_role=None, + scan_unused_services=False, + resource_tag=None, + resource_arn=None, + mutelist_file=None, + ) + + Provider._global = None + with caplog.at_level(logging.WARNING, logger="prowler"): + try: + Provider.init_global_provider(args) + except BaseException: + # AwsProvider mock is fake; dispatch may fail — only the + # warning emitted BEFORE dispatch matters here. + pass + Provider._global = None + + collision_warnings = [ + r.message + for r in caplog.records + if "Plug-in provider 'aws'" in r.message and "IGNORED" in r.message + ] + assert collision_warnings, ( + "init_global_provider must emit the collision warning when a " + "same-named EP plug-in exists for a built-in provider" + ) + # Shadow detected by name only — the plug-in is never loaded to warn. + shadow_ep.load.assert_not_called() diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index 99eb88d25b..1abcaf5bf0 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -41,7 +41,7 @@ def set_mocked_gcp_provider( return provider -def mock_api_client(GCPService, service, api_version, _): +def mock_api_client(_GCPService, service, _api_version, _): client = MagicMock() mock_api_projects_calls(client) @@ -126,7 +126,11 @@ def mock_api_projects_calls(client: MagicMock): "etag": "BwWWja0YfJA=", "version": 3, } - # Used by compute client and cloudresourcemanager + # Used by compute client and cloudresourcemanager. + # `enable-oslogin` covers the documented uppercase form (TRUE); + # `enable-oslogin-2fa` covers the lowercase form (true) that GCP's + # `constraints/compute.requireOsLogin` org-policy controller writes + # in production. The service-layer parser must handle both casings. client.projects().get().execute.return_value = { "projectNumber": "123456789012", "commonInstanceMetadata": { @@ -139,6 +143,10 @@ def mock_api_projects_calls(client: MagicMock): "key": "enable-oslogin", "value": "FALSE", }, + { + "key": "enable-oslogin-2fa", + "value": "true", + }, { "key": "testing-key", "value": "TRUE", @@ -703,6 +711,9 @@ def mock_api_instances_calls(client: MagicMock, service: str): "databaseVersion": "MYSQL_5_7", "region": "us-central1", "ipAddresses": [{"type": "PRIMARY", "ipAddress": "66.66.66.66"}], + "diskEncryptionConfiguration": { + "kmsKeyName": "projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1" + }, "settings": { "ipConfiguration": { "requireSsl": True, @@ -711,6 +722,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": True}, "databaseFlags": [], + "availabilityType": "REGIONAL", }, }, { @@ -726,6 +738,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": False}, "databaseFlags": [], + "availabilityType": "ZONAL", }, }, ] @@ -1323,6 +1336,7 @@ def mock_api_images_calls(client: MagicMock): client.images().list_next.return_value = None def mock_get_image_iam_policy(project, resource): + del project return_value = MagicMock() if resource == "test-image-1": return_value.execute.return_value = { diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index 7d2bea9f88..2e80ac7899 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -13,6 +13,7 @@ from prowler.config.config import ( ) from prowler.providers.common.models import Connection from prowler.providers.gcp.exceptions.exceptions import ( + GCPGetOrganizationProjectsError, GCPInvalidProviderIdError, GCPNoAccesibleProjectsError, GCPTestConnectionError, @@ -91,6 +92,7 @@ class TestGCPProvider: "shodan_api_key": None, "max_unused_account_days": 180, "storage_min_retention_days": 90, + "secretmanager_max_rotation_days": 90, "mig_min_zones": 2, "max_snapshot_age_days": 90, } @@ -1077,3 +1079,66 @@ class TestGCPProvider: assert gcp_provider.skip_api_check is True mocked_is_api_active.assert_not_called() + + def test_get_projects_organization_id_permission_denied_raises(self): + """When --organization-id is set and the Cloud Asset API returns a 403, + get_projects must raise GCPGetOrganizationProjectsError instead of + silently falling back to the service account's home project. + + Regression test for https://github.com/prowler-cloud/prowler/issues/11250. + """ + from googleapiclient.errors import HttpError + + forbidden_response = MagicMock(status=403, reason="Forbidden") + http_error = HttpError( + resp=forbidden_response, + content=b'{"error": {"code": 403, "message": "Permission denied on resource organization"}}', + uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets", + ) + + asset_service = MagicMock() + asset_service.assets.return_value.list.return_value.execute.side_effect = ( + http_error + ) + + with patch( + "prowler.providers.gcp.gcp_provider.discovery.build", + return_value=asset_service, + ): + with pytest.raises(GCPGetOrganizationProjectsError): + GcpProvider.get_projects( + credentials=MagicMock(), + organization_id="test-organization-id", + credentials_file="test_credentials_file", + ) + + def test_get_projects_organization_id_cloud_asset_api_disabled_raises(self): + """When --organization-id is set and the Cloud Asset API is disabled, + get_projects must raise GCPGetOrganizationProjectsError with the + enable-API remediation rather than swallowing the error.""" + from googleapiclient.errors import HttpError + + disabled_response = MagicMock(status=403, reason="Forbidden") + http_error = HttpError( + resp=disabled_response, + content=b'{"error": {"message": "Cloud Asset API has not been used in project 123 before or it is disabled."}}', + uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets", + ) + + asset_service = MagicMock() + asset_service.assets.return_value.list.return_value.execute.side_effect = ( + http_error + ) + + with patch( + "prowler.providers.gcp.gcp_provider.discovery.build", + return_value=asset_service, + ): + with pytest.raises(GCPGetOrganizationProjectsError) as exc_info: + GcpProvider.get_projects( + credentials=MagicMock(), + organization_id="test-organization-id", + credentials_file="test_credentials_file", + ) + + assert "Cloud Asset API" in str(exc_info.value) diff --git a/tests/providers/gcp/services/cloudfunction/__init__.py b/tests/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py new file mode 100644 index 0000000000..8428e56d86 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py @@ -0,0 +1,208 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +def _function_id(name: str) -> str: + return ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/functions/{name}" + ) + + +class Test_cloudfunction_function_inside_vpc: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 + + def test_function_with_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + connector = ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/connectors/my-connector" + ) + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-vpc"), + name="fn-vpc", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=connector, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Cloud Function fn-vpc is connected to a VPC via connector: {connector}." + ) + assert result[0].resource_id == "fn-vpc" + assert result[0].resource_name == "fn-vpc" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_without_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cloud Function fn-public is not connected to any VPC network." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_with_empty_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-empty"), + name="fn-empty", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector="", + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-deploy"), + name="fn-deploy", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DEPLOYING", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py new file mode 100644 index 0000000000..615890bd58 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py @@ -0,0 +1,216 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_not_publicly_accessible." + "cloudfunction_function_not_publicly_accessible" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +def _function_id(name: str) -> str: + return ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/functions/{name}" + ) + + +class Test_cloudfunction_function_not_publicly_accessible: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_function_private(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-private"), + name="fn-private", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=False, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cloud Function fn-private is not publicly accessible." + ) + assert result[0].resource_id == "fn-private" + assert result[0].resource_name == "fn-private" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_publicly_accessible(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=True, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Cloud Function fn-public is publicly invocable " + "(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_functions_mixed(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-private"), + name="fn-private", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=False, + ), + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=True, + ), + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 2 + + by_id = {r.resource_id: r for r in result} + assert by_id["fn-private"].status == "PASS" + assert by_id["fn-public"].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-deleting"), + name="fn-deleting", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DELETING", + publicly_accessible=True, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py new file mode 100644 index 0000000000..d97b80336b --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py @@ -0,0 +1,319 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +_LOCATION_ID = "us-central1" +_FUNCTION_NAME = "my-function" +_FUNCTION_ID = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{_FUNCTION_NAME}" +) +_RUN_SERVICE = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/services/{_FUNCTION_NAME}" +) +_CONNECTOR = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/connectors/my-connector" +) + + +def _make_cloudfunction_client(functions_list, iam_bindings=None): + """Return a mock GCP API client for the Cloud Functions v2 service.""" + client = MagicMock() + + client.projects().locations().list().execute.return_value = { + "locations": [{"locationId": _LOCATION_ID}] + } + client.projects().locations().list_next.return_value = None + + client.projects().locations().functions().list().execute.return_value = { + "functions": functions_list + } + client.projects().locations().functions().list_next.return_value = None + + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().locations().functions().getIamPolicy = mock_get_iam_policy + + return client + + +def _make_run_client(iam_bindings=None): + """Return a mock Cloud Run v2 client for gen2 IAM policy lookups.""" + client = MagicMock() + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().locations().services().getIamPolicy = mock_get_iam_policy + return client + + +class TestCloudFunctionService: + def test_get_functions_with_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": { + "service": _RUN_SERVICE, + "vpcConnector": _CONNECTOR, + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=_make_run_client(), + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.id == _FUNCTION_ID + assert fn.name == _FUNCTION_NAME + assert fn.project_id == GCP_PROJECT_ID + assert fn.location == _LOCATION_ID + assert fn.state == "ACTIVE" + assert fn.environment == "GEN_2" + assert fn.service == _RUN_SERVICE + assert fn.vpc_connector == _CONNECTOR + assert fn.publicly_accessible is False + + def test_get_functions_without_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/no-vpc-func", + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": { + "service": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/services/no-vpc-func", + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=_make_run_client(), + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == "no-vpc-func" + assert fn.vpc_connector is None + assert fn.publicly_accessible is False + + def test_get_functions_iam_policy_gen2_all_users(self): + """Gen2 functions: allUsers binding lives on the Cloud Run service.""" + + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["allUsers"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is True + + def test_get_functions_iam_policy_gen2_all_authenticated_users(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["allAuthenticatedUsers"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is True + + def test_get_functions_iam_policy_gen2_not_public(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["serviceAccount:sa@project.iam.gserviceaccount.com"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is False + + def test_get_functions_iam_policy_gen1_all_users(self): + """Gen1 functions: IAM binding lives on the Cloud Functions resource itself.""" + + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_1", + "serviceConfig": {}, + } + ], + iam_bindings=[ + { + "role": "roles/cloudfunctions.invoker", + "members": ["allUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].environment == "GEN_1" + assert cf_client.functions[0].publicly_accessible is True diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled_test.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled_test.py new file mode 100644 index 0000000000..e2e6e3f9e4 --- /dev/null +++ b/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/cloudsql_instance_cmek_encryption_enabled_test.py @@ -0,0 +1,277 @@ +from unittest import mock +from unittest.mock import MagicMock, patch + +from tests.providers.gcp.gcp_fixtures import ( + GCP_EU1_LOCATION, + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + + +class Test_cloudsql_instance_cmek_encryption_enabled: + def test_no_instances(self): + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled import ( + cloudsql_instance_cmek_encryption_enabled, + ) + + cloudsql_client.instances = [] + check = cloudsql_instance_cmek_encryption_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_cmek_enabled(self): + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled import ( + cloudsql_instance_cmek_encryption_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-cmek", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + cmek_key_name="projects/123456789012/locations/europe-west1/keyRings/my-ring/cryptoKeys/my-key", + ) + ] + check = cloudsql_instance_cmek_encryption_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "db-cmek" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_cmek_not_configured(self): + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled import ( + cloudsql_instance_cmek_encryption_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-google-managed", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + cmek_key_name=None, + ) + ] + check = cloudsql_instance_cmek_encryption_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "db-google-managed" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_cmek_empty_string(self): + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled import ( + cloudsql_instance_cmek_encryption_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-empty-key", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + cmek_key_name="", + ) + ] + check = cloudsql_instance_cmek_encryption_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "db-empty-key" + + def test_unsupported_instance_type_skipped(self): + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled import ( + cloudsql_instance_cmek_encryption_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="external-primary", + version="MYSQL_8_0", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=False, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + instance_type="ON_PREMISES_INSTANCE", + cmek_key_name=None, + ), + Instance( + name="db-cmek", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + instance_type="CLOUD_SQL_INSTANCE", + cmek_key_name="projects/p/locations/europe-west1/keyRings/r/cryptoKeys/k", + ), + ] + check = cloudsql_instance_cmek_encryption_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "db-cmek" + assert result[0].status == "PASS" + + def test_service_parser_missing_disk_encryption(self): + """Exercise the real service parser path when diskEncryptionConfiguration is absent.""" + + def mock_api_client_without_disk_encryption(*_args, **_kwargs): + client = MagicMock() + client.instances().list().execute.return_value = { + "items": [ + { + "name": "db-no-encryption-config", + "databaseVersion": "POSTGRES_14", + "region": "us-central1", + "ipAddresses": [], + "settings": { + "ipConfiguration": {"requireSsl": True}, + "backupConfiguration": {"enabled": True}, + "databaseFlags": [], + }, + } + ] + } + client.instances().list_next.return_value = None + return client + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client_without_disk_encryption, + ), + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]), + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + CloudSQL, + ) + + cloudsql_client = CloudSQL( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + assert len(cloudsql_client.instances) == 1 + assert cloudsql_client.instances[0].cmek_key_name is None + + with patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled.cloudsql_client", + new=cloudsql_client, + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_cmek_encryption_enabled.cloudsql_instance_cmek_encryption_enabled import ( + cloudsql_instance_cmek_encryption_enabled, + ) + + check = cloudsql_instance_cmek_encryption_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "db-no-encryption-config" diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py new file mode 100644 index 0000000000..82c49fb89e --- /dev/null +++ b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_EU1_LOCATION, + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + + +class Test_cloudsql_instance_high_availability_enabled: + """Tests for the cloudsql_instance_high_availability_enabled check.""" + + def test_no_instances(self): + """No Cloud SQL instances → no findings.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + + cloudsql_client.instances = [] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_ha_enabled(self): + """A REGIONAL primary instance → PASS.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-ha", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="REGIONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "db-ha" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_ha_disabled(self): + """A ZONAL primary instance → FAIL with current availability in status_extended.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-zonal", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended + assert result[0].resource_id == "db-zonal" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_read_replica_skipped(self): + """Read replicas (instance_type != CLOUD_SQL_INSTANCE) are skipped.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-replica", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + instance_type="READ_REPLICA_INSTANCE", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_default_availability_type_fails(self): + """An instance missing availabilityType defaults to ZONAL (service layer) and must FAIL.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-default", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + # availability_type omitted → model default "ZONAL" + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_service_test.py b/tests/providers/gcp/services/cloudsql/cloudsql_service_test.py index fcc6473559..b5bb846e9e 100644 --- a/tests/providers/gcp/services/cloudsql/cloudsql_service_test.py +++ b/tests/providers/gcp/services/cloudsql/cloudsql_service_test.py @@ -43,6 +43,11 @@ class TestCloudSQLService: {"value": "test"} ] assert cloudsql_client.instances[0].flags == [] + assert cloudsql_client.instances[0].instance_type == "CLOUD_SQL_INSTANCE" + assert ( + cloudsql_client.instances[0].cmek_key_name + == "projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1" + ) assert cloudsql_client.instances[0].project_id == GCP_PROJECT_ID assert cloudsql_client.instances[1].name == "instance2" @@ -62,12 +67,14 @@ class TestCloudSQLService: {"value": "test"} ] assert cloudsql_client.instances[1].flags == [] + assert cloudsql_client.instances[1].instance_type == "CLOUD_SQL_INSTANCE" + assert cloudsql_client.instances[1].cmek_key_name is None assert cloudsql_client.instances[1].project_id == GCP_PROJECT_ID def test_instances_without_backup_configuration(self): """Test that CloudSQL service handles instances without backupConfiguration field""" - def mock_api_client_without_backup_config(*args, **kwargs): + def mock_api_client_without_backup_config(*_args, **_kwargs): from unittest.mock import MagicMock client = MagicMock() @@ -119,7 +126,7 @@ class TestCloudSQLService: def test_instances_with_empty_backup_configuration(self): """Test that CloudSQL service handles instances with empty backupConfiguration""" - def mock_api_client_with_empty_backup_config(*args, **kwargs): + def mock_api_client_with_empty_backup_config(*_args, **_kwargs): from unittest.mock import MagicMock client = MagicMock() @@ -170,7 +177,7 @@ class TestCloudSQLService: def test_instances_without_settings_fields(self): """Test that CloudSQL service handles instances with minimal settings""" - def mock_api_client_with_minimal_settings(*args, **kwargs): + def mock_api_client_with_minimal_settings(*_args, **_kwargs): from unittest.mock import MagicMock client = MagicMock() diff --git a/tests/providers/gcp/services/compute/compute_service_test.py b/tests/providers/gcp/services/compute/compute_service_test.py index 28a3466a5d..2c408e8a39 100644 --- a/tests/providers/gcp/services/compute/compute_service_test.py +++ b/tests/providers/gcp/services/compute/compute_service_test.py @@ -34,6 +34,7 @@ class TestComputeService: assert len(compute_client.compute_projects) == 1 assert compute_client.compute_projects[0].id == GCP_PROJECT_ID assert compute_client.compute_projects[0].enable_oslogin + assert compute_client.compute_projects[0].enable_oslogin_2fa assert len(compute_client.instances) == 2 assert compute_client.instances[0].name == "instance1" diff --git a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py index d76200734d..79c0eb23c3 100644 --- a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py +++ b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py @@ -179,3 +179,60 @@ class Test_iam_service_account_unused: assert result[1].project_id == GCP_PROJECT_ID assert result[1].location == GCP_US_CENTER1_LOCATION assert result[1].resource == iam_client.service_accounts[1] + + def test_iam_service_account_disabled(self): + iam_client = mock.MagicMock() + monitoring_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.iam_client", + new=iam_client, + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import ServiceAccount + from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import ( + iam_service_account_unused, + ) + + iam_client.project_ids = [GCP_PROJECT_ID] + iam_client.region = GCP_US_CENTER1_LOCATION + + iam_client.service_accounts = [ + ServiceAccount( + name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com", + email="disabled-sa@my-project.iam.gserviceaccount.com", + display_name="Disabled service account", + keys=[], + project_id=GCP_PROJECT_ID, + uniqueId="999888877776666", + disabled=True, + ) + ] + + # The account is absent from the usage metrics, so a non-disabled + # account here would FAIL. Being disabled must take precedence and + # PASS, since a disabled account cannot authenticate or be used. + monitoring_client.sa_api_metrics = set() + monitoring_client.audit_config = {"max_unused_account_days": 30} + + check = iam_service_account_unused() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used." + ) + assert result[0].resource_id == iam_client.service_accounts[0].email + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].resource == iam_client.service_accounts[0] diff --git a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py index 5776c858f5..6600921c9f 100644 --- a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py +++ b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py @@ -1,4 +1,3 @@ -import datetime from unittest import mock from tests.providers.gcp.gcp_fixtures import ( @@ -34,7 +33,7 @@ class Test_kms_key_rotation_enabled: result = check.execute() assert len(result) == 0 - def test_kms_key_no_next_rotation_time_and_no_rotation_period(self): + def test_kms_key_without_rotation_period(self): kms_client = mock.MagicMock() with ( @@ -86,14 +85,14 @@ class Test_kms_key_rotation_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + == f"Key {kms_client.crypto_keys[0].name} does not have automatic rotation enabled." ) assert result[0].resource_id == kms_client.crypto_keys[0].id assert result[0].resource_name == kms_client.crypto_keys[0].name assert result[0].location == kms_client.crypto_keys[0].location assert result[0].project_id == kms_client.crypto_keys[0].project_id - def test_kms_key_no_next_rotation_time_and_big_rotation_period(self): + def test_kms_key_with_long_rotation_period(self): kms_client = mock.MagicMock() with ( @@ -135,471 +134,26 @@ class Test_kms_key_rotation_enabled: project_id=GCP_PROJECT_ID, key_ring=keyring.name, location=keylocation.name, + # Rotation period greater than 90 days still counts as enabled rotation_period="8776000s", members=["user:jane@example.com"], ) ] - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_no_next_rotation_time_and_appropriate_rotation_period(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - key_ring=keyring.name, - location=keylocation.name, - rotation_period="7776000s", - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_no_rotation_period_and_big_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - key_ring=keyring.name, - location=keylocation.name, - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_no_rotation_period_and_appropriate_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - key_ring=keyring.name, - location=keylocation.name, - # Next rotation time of now + 30 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+30) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_greater_90_days_and_big_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="8776000s", - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_greater_90_days_and_appropriate_next_rotation_time( - self, - ): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="8776000s", - # Next rotation time of now + 30 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+30) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_less_90_days_and_big_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="7776000s", - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_less_90_days_and_appropriate_next_rotation_time( - self, - ): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="7776000s", - # Next rotation time of now + 30 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+30) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - check = kms_key_rotation_enabled() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + == f"Key {kms_client.crypto_keys[0].name} has automatic rotation enabled." ) assert result[0].resource_id == kms_client.crypto_keys[0].id assert result[0].resource_name == kms_client.crypto_keys[0].name assert result[0].location == kms_client.crypto_keys[0].location assert result[0].project_id == kms_client.crypto_keys[0].project_id - def test_kms_key_rotation_with_fractional_seconds(self): + def test_kms_key_with_short_rotation_period(self): kms_client = mock.MagicMock() with ( @@ -639,13 +193,9 @@ class Test_kms_key_rotation_enabled: name="key1", id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", project_id=GCP_PROJECT_ID, - rotation_period="7776000s", - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), key_ring=keyring.name, location=keylocation.name, + rotation_period="7776000s", members=["user:jane@example.com"], ) ] @@ -653,10 +203,10 @@ class Test_kms_key_rotation_enabled: check = kms_key_rotation_enabled() result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + == f"Key {kms_client.crypto_keys[0].name} has automatic rotation enabled." ) assert result[0].resource_id == kms_client.crypto_keys[0].id assert result[0].resource_name == kms_client.crypto_keys[0].name diff --git a/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py b/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py new file mode 100644 index 0000000000..e200b9674d --- /dev/null +++ b/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py @@ -0,0 +1,728 @@ +import datetime +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + + +class Test_kms_key_rotation_max_90_days: + def test_kms_no_key(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + kms_client.crypto_keys = [] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 0 + + def test_kms_key_no_next_rotation_time_and_no_rotation_period(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_next_rotation_time_and_big_rotation_period(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + rotation_period="8776000s", + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_next_rotation_time_and_appropriate_rotation_period(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + rotation_period="7776000s", + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_rotation_period_and_big_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_rotation_period_and_appropriate_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + # Next rotation time of now + 30 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+30) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_greater_90_days_and_big_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="8776000s", + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_greater_90_days_and_appropriate_next_rotation_time( + self, + ): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="8776000s", + # Next rotation time of now + 30 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+30) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_less_90_days_and_big_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_less_90_days_and_appropriate_next_rotation_time( + self, + ): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time of now + 30 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+30) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_with_fractional_seconds(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_next_rotation_time_without_fractional_seconds(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time without fractional seconds, within 90 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=30) + ).strftime("%Y-%m-%dT%H:%M:%SZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py index a38f97d81c..c0dc6b0a06 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py @@ -259,3 +259,176 @@ class Test_logging_log_metric_filter_and_alert_for_audit_configuration_changes_e assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally) instead of + being falsely failed.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + # Bucket-scoped central metric, in the scanned logging project. + logging_client.metrics = [ + Metric( + name="central-audit-config-metric", + type="logging.googleapis.com/user/central-audit-config-metric", + filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + # Org-level aggregated sink routing the child's logs to that bucket. + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-audit-config-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py index e2b7b2d068..e9eeabf430 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py @@ -397,3 +397,173 @@ class Test_logging_log_metric_filter_and_alert_for_bucket_permission_changes_ena assert result[0].resource_name == "GCP Project" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py index 563b4e49ac..5347e3e42e 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py @@ -346,3 +346,173 @@ class Test_logging_log_metric_filter_and_alert_for_compute_configuration_changes fail_result = [r for r in result if r.status == "FAIL"][0] assert fail_result.project_id == project_id_2 assert "no log metric filters" in fail_result.status_extended + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.serviceName="compute.googleapis.com"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.serviceName="compute.googleapis.com"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py index 4ec94be657..18d1807255 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_custom_role_changes_enabled: assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py index 0ea0798e03..3adb48e567 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py @@ -392,3 +392,173 @@ class Test_logging_log_metric_filter_and_alert_for_project_ownership_changes_ena assert result[0].resource_name == "GCP Project" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py index 1a8a1d0da3..9444f0cc61 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_sql_instance_configuration_ch assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="cloudsql.instances.update"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="cloudsql.instances.update"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py index a9460e6b46..3d34a3c295 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_ena assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py index 9c59d56a81..a71a21ceb6 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled: assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py index 254c41bb5f..3a7f41a485 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_route_changes_ena assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py index 0396130c2f..e466a17314 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -1,4 +1,6 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +import pytest from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( @@ -66,3 +68,324 @@ class TestLoggingService: == "resource.type=gae_app AND severity>=ERROR" ) assert logging_client.metrics[1].project_id == GCP_PROJECT_ID + + def test_org_sinks_fetched_when_project_has_organization(self): + """_get_org_sinks() appends org-level sinks when projects have an org.""" + from prowler.providers.gcp.models import GCPOrganization, GCPProject + + org_id = "999888777" + provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + provider.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"), + ) + } + + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = { + "sinks": [ + { + "name": "org-sink", + "destination": "storage.googleapis.com/org-bucket", + "filter": "all", + "includeChildren": True, + } + ] + } + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = {"metrics": []} + mock_client.projects().metrics().list_next.return_value = None + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + return_value=mock_client, + ), + ): + logging_svc = Logging(provider) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}" + ] + assert len(org_sinks) == 1 + assert org_sinks[0].name == "org-sink" + assert org_sinks[0].include_children is True + assert org_sinks[0].filter == "all" + + def test_org_sinks_skipped_when_no_organization(self): + """_get_org_sinks() adds nothing when projects have no organization.""" + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id.startswith("organizations/") + ] + assert org_sinks == [] + + def test_get_metrics_populates_bucket_name(self): + """_get_metrics() captures a metric's bucketName (for aggregated-sink crediting).""" + bucket = "projects/central-logging-project/locations/eu/buckets/central-bucket" + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = {"sinks": []} + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = { + "metrics": [ + { + "name": "central-metric", + "metricDescriptor": { + "type": "logging.googleapis.com/user/central-metric" + }, + "filter": "severity>=ERROR", + "bucketName": bucket, + } + ] + } + mock_client.projects().metrics().list_next.return_value = None + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + return_value=mock_client, + ), + ): + logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + metrics = [m for m in logging_svc.metrics if m.name == "central-metric"] + assert len(metrics) == 1 + assert metrics[0].bucket_name == bucket + + +class TestGetProjectsCoveredByAggregatedMetric: + """Unit tests for the aggregated-sink crediting helper: one positive case and the + guards that must NOT credit a project (so the metric-filter checks never false-pass). + """ + + FILTER = 'protoPayload.methodName="SetIamPolicy"' + ORG = "111222333" + BUCKET = "projects/central-logging-project/locations/eu/buckets/central-bucket" + + def _clients( + self, + *, + include_children=True, + bucket_name=None, + sink_destination=None, + sink_filter="all", + with_alert=True, + project_org_id=None, + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_service import Metric, Sink + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + bucket_name = self.BUCKET if bucket_name is None else bucket_name + sink_destination = ( + f"logging.googleapis.com/{self.BUCKET}" + if sink_destination is None + else sink_destination + ) + project_org_id = self.ORG if project_org_id is None else project_org_id + + logging_client = MagicMock() + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=project_org_id, name=f"organizations/{project_org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter=self.FILTER, + project_id="central-logging-project", + bucket_name=bucket_name, + ) + ] + logging_client.sinks = [ + Sink( + name="org-sink", + destination=sink_destination, + filter=sink_filter, + project_id=f"organizations/{self.ORG}", + include_children=include_children, + ) + ] + monitoring_client = MagicMock() + monitoring_client.alert_policies = ( + [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + if with_alert + else [] + ) + return logging_client, monitoring_client + + def _run(self, logging_client, monitoring_client): + from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, + ) + + return get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, self.FILTER + ) + + def test_covered_when_all_conditions_met(self): + logging_client, monitoring_client = self._clients() + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_not_covered_without_alert(self): + logging_client, monitoring_client = self._clients(with_alert=False) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_metric_not_bucket_scoped(self): + logging_client, monitoring_client = self._clients(bucket_name="") + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_not_include_children(self): + logging_client, monitoring_client = self._clients(include_children=False) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_filter_is_restrictive(self): + logging_client, monitoring_client = self._clients( + sink_filter='resource.type="gce_instance"' + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_filter_omits_activity_stream(self): + """A sink that routes cloudaudit streams but NOT Admin Activity (here, + data_access only) does not deliver the entries the CIS metric filters + match, so it must not be credited — right service, wrong stream.""" + logging_client, monitoring_client = self._clients( + sink_filter="logName: /logs/cloudaudit.googleapis.com%2Fdata_access" + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_covered_when_sink_filter_carries_activity_stream_encoded(self): + """A sink filtered to the cloudaudit streams (URL-encoded logName form, + as returned by the Logging API) delivers every Admin Activity entry the + CIS metric filters can match, so it must be credited.""" + logging_client, monitoring_client = self._clients( + sink_filter=( + "logName: /logs/cloudaudit.googleapis.com%2Factivity OR " + "logName: /logs/cloudaudit.googleapis.com%2Fdata_access" + ) + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_covered_when_sink_filter_carries_activity_stream_plain(self): + logging_client, monitoring_client = self._clients( + sink_filter='logName="projects/p/logs/cloudaudit.googleapis.com/activity"' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + @pytest.mark.parametrize( + "sink_filter", + [ + # --- Negation: the stream is named but excluded. --- + 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + '-logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + 'NOT log_id("cloudaudit.googleapis.com/activity")', + # "!=" inequality (and its spaced form) excludes the stream. + 'logName!="projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + 'logName != "projects/p/logs/cloudaudit.googleapis.com/activity"', + # Activity negated inside a compound filter. + 'resource.type="gce_instance" AND ' + 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + # --- Restriction: the stream is named but AND-narrowed, so only a + # subset of Admin Activity entries reaches the bucket. --- + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + 'AND resource.type="gce_instance"', + 'log_id("cloudaudit.googleapis.com/activity") ' + 'AND resource.type="gce_instance"', + 'logName="projects/p/logs/cloudaudit.googleapis.com/activity" ' + "AND severity>=ERROR", + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + 'AND protoPayload.methodName="SetIamPolicy"', + # --- OR-ed with a non-audit predicate: fail closed, since we credit + # only unions of provable Cloud Audit stream selectors. --- + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + "OR severity>=ERROR", + ], + ) + def test_not_covered_when_sink_filter_negated_or_restrictive(self, sink_filter): + """A filter that names the Admin Activity stream but negates, narrows, or + mixes in an unprovable predicate is not credited — we credit only filters + we can prove deliver every Admin Activity entry the CIS metrics match.""" + logging_client, monitoring_client = self._clients(sink_filter=sink_filter) + assert self._run(logging_client, monitoring_client) == {} + + def test_covered_when_activity_logname_has_hyphenated_path(self): + """A hyphen in the project path must not be mistaken for the ``-`` (NOT) + negation operator — the activity stream is still delivered.""" + logging_client, monitoring_client = self._clients( + sink_filter='logName="projects/my-project/logs/cloudaudit.googleapis.com/activity"' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_covered_when_sink_filter_uses_log_id_selector(self): + """The ``log_id()`` form is an equivalent positive full-coverage selector + of the Admin Activity stream and is credited like the ``logName`` form.""" + logging_client, monitoring_client = self._clients( + sink_filter='log_id("cloudaudit.googleapis.com/activity")' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_not_covered_when_sink_destination_bucket_differs(self): + logging_client, monitoring_client = self._clients( + sink_destination="logging.googleapis.com/projects/x/locations/eu/buckets/other" + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_project_org_differs(self): + logging_client, monitoring_client = self._clients(project_org_id="999999999") + assert self._run(logging_client, monitoring_client) == {} diff --git a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py index b9c6481d22..6ced615f65 100644 --- a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py +++ b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock, patch -from prowler.providers.gcp.models import GCPProject +from prowler.providers.gcp.models import GCPOrganization, GCPProject from tests.providers.gcp.gcp_fixtures import ( GCP_EU1_LOCATION, GCP_PROJECT_ID, @@ -268,6 +268,7 @@ class Test_logging_sink_created: sink.name = None sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -311,9 +312,10 @@ class Test_logging_sink_created: ) # Create a MagicMock sink object without name attribute - sink = MagicMock(spec=["filter", "project_id"]) + sink = MagicMock(spec=["filter", "project_id", "include_children"]) sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -336,3 +338,175 @@ class Test_logging_sink_created: assert result[0].resource_id == "unknown" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_with_include_children_passes(self): + """Projects covered by an org-level sink with includeChildren=True should PASS.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "org-sink" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_without_include_children_fails(self): + """Projects NOT covered by includeChildren should still FAIL if no direct project sink.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink-no-children", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=False, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + + def test_project_sink_takes_precedence_over_org_sink(self): + """A direct project sink should be reported even when an org-level sink also covers the project.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="project-sink", + destination="storage.googleapis.com/project-bucket", + filter="all", + project_id=GCP_PROJECT_ID, + ), + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ), + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "project-sink" + assert result[0].project_id == GCP_PROJECT_ID diff --git a/tests/providers/gcp/services/secretmanager/__init__.py b/tests/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py new file mode 100644 index 0000000000..f925966f3c --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py @@ -0,0 +1,169 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.secretmanager." + "secretmanager_secret_not_publicly_accessible." + "secretmanager_secret_not_publicly_accessible" +) +_CLIENT_PATH = f"{_CHECK_PATH}.secretmanager_client" + + +def _secret_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/secrets/{name}" + + +class Test_secretmanager_secret_not_publicly_accessible: + def test_no_secrets(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + + secretmanager_client.secrets = [] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_secret_private(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Secret secret-private is not publicly accessible." + ) + assert result[0].resource_id == "secret-private" + assert result[0].resource_name == "secret-private" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_secret_publicly_accessible(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Secret secret-public is publicly accessible " + "(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + assert result[0].resource_id == "secret-public" + assert result[0].resource_name == "secret-public" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_secrets_mixed(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ), + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ), + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 2 + + by_id = {r.resource_id: r for r in result} + assert by_id["secret-private"].status == "PASS" + assert by_id["secret-public"].status == "FAIL" diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled_test.py new file mode 100644 index 0000000000..497dd7428b --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled_test.py @@ -0,0 +1,385 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.secretmanager." + "secretmanager_secret_rotation_enabled." + "secretmanager_secret_rotation_enabled" +) +_CLIENT_PATH = f"{_CHECK_PATH}.secretmanager_client" + + +def _secret_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/secrets/{name}" + + +class Test_secretmanager_secret_rotation_enabled: + def test_no_secrets(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + + secretmanager_client.secrets = [] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 0 + + def test_rotation_within_90_days_pass(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-rotated"), + name="secret-rotated", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Secret secret-rotated has automatic rotation enabled with a period of 90 days." + ) + assert result[0].resource_id == "secret-rotated" + assert result[0].resource_name == "secret-rotated" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_rotation_period_exceeds_max_fail(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-stale"), + name="secret-stale", + project_id=GCP_PROJECT_ID, + rotation_period="9504000s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "exceeds the 90-day maximum" in result[0].status_extended + assert result[0].resource_id == "secret-stale" + + def test_rotation_period_one_second_over_max_fail(self): + """90 days + 1 second must fail — comparison is on seconds, not floored days.""" + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + # 90 days = 7_776_000 seconds. Add 1 second. + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-90d-plus-1s"), + name="secret-90d-plus-1s", + project_id=GCP_PROJECT_ID, + rotation_period="7776001s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "exceeds the 90-day maximum" in result[0].status_extended + + def test_no_rotation_fail(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-no-rotation"), + name="secret-no-rotation", + project_id=GCP_PROJECT_ID, + rotation_period=None, + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Secret secret-no-rotation does not have automatic rotation enabled." + ) + + def test_fractional_seconds_period_pass(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-fractional"), + name="secret-fractional", + project_id=GCP_PROJECT_ID, + rotation_period="2592000.500000000s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_sub_day_rotation_period_pass(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-sub-day"), + name="secret-sub-day", + project_id=GCP_PROJECT_ID, + rotation_period="3600s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_overdue_next_rotation_fail(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-overdue"), + name="secret-overdue", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + next_rotation_time="2020-01-01T00:00:00Z", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "overdue" in result[0].status_extended + + def test_invalid_rotation_period_format_fail(self): + """Unparseable rotation_period falls back to None → FAIL with no-rotation message.""" + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-bad-period"), + name="secret-bad-period", + project_id=GCP_PROJECT_ID, + rotation_period="not-a-duration", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Secret secret-bad-period does not have automatic rotation enabled." + ) + + def test_invalid_next_rotation_time_fail_closed(self): + """Unparseable next_rotation_time fails closed → FAIL as overdue.""" + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-bad-timestamp"), + name="secret-bad-timestamp", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + next_rotation_time="not-a-timestamp", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "overdue" in result[0].status_extended diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py new file mode 100644 index 0000000000..f393b10a59 --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py @@ -0,0 +1,231 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + + +def _make_secretmanager_client(secrets_list, iam_bindings=None): + """Return a mock GCP API client for the Secret Manager service.""" + client = MagicMock() + + client.projects().secrets().list().execute.return_value = {"secrets": secrets_list} + client.projects().secrets().list_next.return_value = None + + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().secrets().getIamPolicy = mock_get_iam_policy + + return client + + +class TestSecretManagerService: + def test_get_secrets(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/my-secret", + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "my-secret" + assert secret.id == f"projects/{GCP_PROJECT_ID}/secrets/my-secret" + assert secret.project_id == GCP_PROJECT_ID + assert secret.location == "global" + assert secret.rotation_period is None + assert secret.next_rotation_time is None + assert secret.publicly_accessible is False + + def test_get_secrets_with_rotation(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/secret-with-rotation", + "rotation": { + "rotationPeriod": "7776000s", + "nextRotationTime": "2026-09-01T00:00:00Z", + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "secret-with-rotation" + assert secret.rotation_period == "7776000s" + assert secret.next_rotation_time == "2026-09-01T00:00:00Z" + + def test_get_secrets_with_null_rotation(self): + """API returning explicit `rotation: null` must not break enumeration.""" + + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/null-rotation", + "rotation": None, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "null-rotation" + assert secret.rotation_period is None + assert secret.next_rotation_time is None + + def test_get_secrets_iam_policy_all_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/public-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_all_authenticated_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/auth-users-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allAuthenticatedUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_not_public(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/private-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["user:alice@example.com"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is False diff --git a/tests/providers/github/github_provider_test.py b/tests/providers/github/github_provider_test.py index a87a35e038..00a3c33cbc 100644 --- a/tests/providers/github/github_provider_test.py +++ b/tests/providers/github/github_provider_test.py @@ -12,6 +12,8 @@ from prowler.providers.github.exceptions.exceptions import ( GithubInvalidCredentialsError, GithubInvalidProviderIdError, GithubInvalidTokenError, + GithubRepoListFileNotFoundError, + GithubRepoListFileReadError, GithubSetUpIdentityError, GithubSetUpSessionError, ) @@ -708,3 +710,81 @@ class Test_GithubProvider_Scoping: assert provider_none.repositories == [] assert provider_none.organizations == [] + + +class TestGitHubProviderLoadReposFromFile: + """Tests for GithubProvider._load_repos_from_file""" + + def _make_provider(self): + """Create a GithubProvider instance with mocked session/identity.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=PAT_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + return_value=GithubIdentityInfo( + account_id=ACCOUNT_ID, + account_name=ACCOUNT_NAME, + account_url=ACCOUNT_URL, + ), + ), + ): + provider = GithubProvider( + personal_access_token=PAT_TOKEN, + ) + return provider + + def test_load_repos_from_file_happy_path(self, tmp_path): + provider = self._make_provider() + repo_file = tmp_path / "repos.txt" + repo_file.write_text("owner/repo-a\nowner/repo-b\nowner/repo-c\n") + + provider._load_repos_from_file(str(repo_file)) + + assert "owner/repo-a" in provider.repositories + assert "owner/repo-b" in provider.repositories + assert "owner/repo-c" in provider.repositories + + def test_load_repos_from_file_comments_and_blanks(self, tmp_path): + provider = self._make_provider() + repo_file = tmp_path / "repos.txt" + repo_file.write_text( + "# This is a comment\n" + "\n" + "owner/repo-a\n" + " # Another comment\n" + " \n" + "owner/repo-b\n" + ) + + provider._load_repos_from_file(str(repo_file)) + + assert provider.repositories == ["owner/repo-a", "owner/repo-b"] + + def test_load_repos_from_file_not_found(self): + provider = self._make_provider() + + with pytest.raises(GithubRepoListFileNotFoundError): + provider._load_repos_from_file("/nonexistent/path/repos.txt") + + def test_load_repos_from_file_exceeds_max_lines(self, tmp_path): + provider = self._make_provider() + repo_file = tmp_path / "repos.txt" + # Write MAX_REPO_LIST_LINES + 1 lines to trigger the guard + lines = [f"owner/repo-{i}" for i in range(provider.MAX_REPO_LIST_LINES + 1)] + repo_file.write_text("\n".join(lines) + "\n") + + with pytest.raises(GithubRepoListFileReadError): + provider._load_repos_from_file(str(repo_file)) + + def test_load_repos_from_file_skips_long_names(self, tmp_path): + provider = self._make_provider() + repo_file = tmp_path / "repos.txt" + long_name = "a" * (provider.MAX_REPO_NAME_LENGTH + 1) + repo_file.write_text(f"owner/valid-repo\n{long_name}\nowner/also-valid\n") + + provider._load_repos_from_file(str(repo_file)) + + assert provider.repositories == ["owner/valid-repo", "owner/also-valid"] diff --git a/tests/providers/github/lib/arguments/github_arguments_test.py b/tests/providers/github/lib/arguments/github_arguments_test.py index fd2f813b6e..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""" @@ -82,13 +85,14 @@ class Test_GitHubArguments: arguments.init_parser(mock_github_args) # Verify scoping arguments were added - assert self.mock_scoping_group.add_argument.call_count == 2 + assert self.mock_scoping_group.add_argument.call_count == 3 # Check that all scoping arguments are present calls = self.mock_scoping_group.add_argument.call_args_list scoping_args = [call[0][0] for call in calls] assert "--repository" in scoping_args + assert "--repo-list-file" in scoping_args assert "--organization" in scoping_args def test_repository_argument_configuration(self): @@ -277,6 +281,33 @@ class Test_GitHubArguments_Integration: assert args.repository == ["owner1/repo1"] assert args.organization == ["org1"] + def test_real_argument_parsing_with_repo_list_file(self): + """Test parsing arguments with repo-list-file scoping""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + common_parser = argparse.ArgumentParser(add_help=False) + + mock_github_args = MagicMock() + mock_github_args.subparsers = subparsers + mock_github_args.common_providers_parser = common_parser + + arguments.init_parser(mock_github_args) + + # Parse arguments with repo-list-file + args = parser.parse_args( + [ + "github", + "--personal-access-token", + "test-token", + "--repo-list-file", + "/path/to/repos.txt", + ] + ) + + assert args.personal_access_token == "test-token" + assert args.repo_list_file == "/path/to/repos.txt" + assert args.repository is None + def test_real_argument_parsing_empty_scoping(self): """Test parsing arguments with empty scoping values""" parser = argparse.ArgumentParser() 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/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py index b4d385fdf1..c57393b314 100644 --- a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py @@ -155,3 +155,123 @@ class Test_repository_default_branch_deletion_disabled_test: result[0].status_extended == f"Repository {repo_name} does deny default branch deletion." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + 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=False, + branch_deletion_source="ruleset", + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=default_branch, + private=False, + archived=False, + pushed_at=now, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled import ( + repository_default_branch_deletion_disabled, + ) + + check = repository_default_branch_deletion_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + 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, + branch_deletion_source="ruleset_not_active", + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=default_branch, + private=False, + archived=False, + pushed_at=now, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled import ( + repository_default_branch_deletion_disabled, + ) + + check = repository_default_branch_deletion_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has default branch deletion disabled in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py index b35393764e..81ab6bf0cc 100644 --- a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_disallows_force_push_test: result[0].status_extended == f"Repository {repo_name} does deny force pushes on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + allow_force_pushes_source="ruleset", + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push import ( + repository_default_branch_disallows_force_push, + ) + + check = repository_default_branch_disallows_force_push() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + allow_force_pushes_source="ruleset_not_active", + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push import ( + repository_default_branch_disallows_force_push, + ) + + check = repository_default_branch_disallows_force_push() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has force pushes disallowed in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews_test.py b/tests/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews_test.py new file mode 100644 index 0000000000..38c0025c8f --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews_test.py @@ -0,0 +1,218 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_default_branch_dismisses_stale_reviews: + + def test_no_repositories(self): + """Cas limite : aucun repo → aucun résultat attendu.""" + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 0 + + def test_dismiss_stale_reviews_disabled(self): + """FAIL : le repo ne révoque pas les approbations obsolètes.""" + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=False, + allow_force_pushes=False, + branch_deletion=False, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + dismiss_stale_reviews=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not dismiss stale pull request approvals when new commits are pushed." + ) + + def test_dismiss_stale_reviews_enabled(self): + """PASS : le repo révoque bien les approbations obsolètes.""" + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + dismiss_stale_reviews=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} does dismiss stale pull request approvals when new commits are pushed." + ) + + def test_dismiss_stale_reviews_ruleset_not_active(self): + """FAIL : le ruleset existe mais n'est pas actif.""" + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=False, + branch_deletion=False, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + dismiss_stale_reviews=False, + dismiss_stale_reviews_source="ruleset_not_active", + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has dismiss stale pull request approvals configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py index 296c3d78e5..fbc7e22d68 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_protection_applies_to_admins_test: result[0].status_extended == f"Repository {repo_name} does enforce administrators to be subject to the same branch protection rules as other users." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + enforce_admins_source="ruleset", + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins import ( + repository_default_branch_protection_applies_to_admins, + ) + + check = repository_default_branch_protection_applies_to_admins() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + 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, + enforce_admins_source="ruleset_not_active", + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins import ( + repository_default_branch_protection_applies_to_admins, + ) + + check = repository_default_branch_protection_applies_to_admins() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has a ruleset that would apply to administrators, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py index 67bce91575..5fce4e7553 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_protection_enabled_test: result[0].status_extended == f"Repository {repo_name} does enforce branch protection on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + protected_source="ruleset", + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled import ( + repository_default_branch_protection_enabled, + ) + + check = repository_default_branch_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + protected_source="ruleset_not_active", + 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, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled import ( + repository_default_branch_protection_enabled, + ) + + check = repository_default_branch_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has a ruleset configured on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py index ab3d579b75..fc52676845 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py @@ -147,3 +147,117 @@ class Test_repository_default_branch_requires_codeowners_review: result[0].status_extended == f"Repository {repo_name} requires code owner approval for changes to owned code." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_code_owner_reviews_source="ruleset", + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + 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_code_owner_reviews_source="ruleset_not_active", + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has code owner approval configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py index 8f5a82bbd8..d283fd1b26 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_conversation_resolution_test: result[0].status_extended == f"Repository {repo_name} does require conversation resolution on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + conversation_resolution_source="ruleset", + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution import ( + repository_default_branch_requires_conversation_resolution, + ) + + check = repository_default_branch_requires_conversation_resolution() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + 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, + conversation_resolution_source="ruleset_not_active", + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution import ( + repository_default_branch_requires_conversation_resolution, + ) + + check = repository_default_branch_requires_conversation_resolution() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has conversation resolution configured in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py index b072396804..99c6abcd29 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_linear_history_test: result[0].status_extended == f"Repository {repo_name} does require linear history on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + required_linear_history_source="ruleset", + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history import ( + repository_default_branch_requires_linear_history, + ) + + check = repository_default_branch_requires_linear_history() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + required_linear_history_source="ruleset_not_active", + 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, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history import ( + repository_default_branch_requires_linear_history, + ) + + check = repository_default_branch_requires_linear_history() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has linear history configured in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py index ddb9e89df6..72fb5470a8 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py @@ -207,3 +207,117 @@ class Test_repository_default_branch_requires_multiple_approvals: result[0].status_extended == f"Repository {repo_name} does enforce at least 2 approvals for code changes." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=2, + approval_count_source="ruleset", + 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, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals import ( + repository_default_branch_requires_multiple_approvals, + ) + + check = repository_default_branch_requires_multiple_approvals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=True, + approval_count=0, + approval_count_source="ruleset_not_active", + 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, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals import ( + repository_default_branch_requires_multiple_approvals, + ) + + check = repository_default_branch_requires_multiple_approvals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has at least 2 approvals configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py index 5785dd2a14..0f6f0112a7 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py @@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_signed_commits: result[0].status_extended == f"Repository {repo_name} does require signed commits on default branch ({default_branch})." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + require_signed_commits_source="ruleset", + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + 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, + require_signed_commits_source="ruleset_not_active", + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has signed commits configured in a ruleset on default branch ({default_branch}), but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py index 1ec8acc3e0..da36b36865 100644 --- a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py @@ -151,3 +151,121 @@ class Test_repository_default_branch_status_checks_required_test: result[0].status_extended == f"Repository {repo_name} does enforce status checks." ) + + def test_active_ruleset_passes(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + private=False, + default_branch=Branch( + name=default_branch, + 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=True, + status_checks_source="ruleset", + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + status_checks=True, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required import ( + repository_default_branch_status_checks_required, + ) + + check = repository_default_branch_status_checks_required() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_ruleset_fails(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + 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, + status_checks_source="ruleset_not_active", + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + status_checks=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + private=False, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required import ( + repository_default_branch_status_checks_required, + ) + + check = repository_default_branch_status_checks_required() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has status checks configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 932bf2c766..d0f3573e49 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -137,7 +137,7 @@ class Test_Repository_GraphQL: provider.repositories = [] provider.organizations = [] - with patch.object(Repository, "__init__", lambda x, y: None): + with patch.object(Repository, "__init__", lambda *_: None): repository_service = Repository(provider) mock_client = MagicMock() repository_service.clients = [mock_client] @@ -165,7 +165,7 @@ class Test_Repository_GraphQL: provider.repositories = [] provider.organizations = [] - with patch.object(Repository, "__init__", lambda x, y: None): + with patch.object(Repository, "__init__", lambda *_: None): repository_service = Repository(provider) repository_service.clients = [MagicMock()] repository_service.provider = provider @@ -193,7 +193,7 @@ class Test_Repository_GraphQL: provider.repositories = [] provider.organizations = [] - with patch.object(Repository, "__init__", lambda x, y: None): + with patch.object(Repository, "__init__", lambda *_: None): repository_service = Repository(provider) repository_service.clients = [MagicMock()] repository_service.provider = provider @@ -462,3 +462,485 @@ class Test_Repository_ErrorHandling: # Should log rate limit error mock_logger.error.assert_called() assert "Rate limit exceeded" in str(mock_logger.error.call_args) + + +class Test_Repository_BranchProtectionRulesets: + def setup_method(self): + self.repository_service = Repository.__new__(Repository) + self.repository_service.provider = set_mocked_github_provider() + self.repository_service.clients = [] + self.repository_service.audit_config = None + self.repository_service.fixer_config = None + + def _build_repo( + self, + *, + branch_protected: bool, + dismiss_stale_reviews: bool = False, + ruleset_details: list[dict] | None = None, + ): + repo = MagicMock() + repo.id = 1 + repo.name = "repo1" + repo.owner.login = "owner1" + repo.full_name = "owner1/repo1" + repo.default_branch = "main" + repo.private = False + repo.archived = False + repo.pushed_at = datetime.now(timezone.utc) + repo.delete_branch_on_merge = False + repo.security_and_analysis = None + repo.get_contents.side_effect = [None, None, None, None] + repo.get_dependabot_alerts.side_effect = Exception( + "403 Forbidden: Dependabot alerts are disabled for this repository." + ) + + branch = MagicMock() + branch.protected = branch_protected + branch.get_required_signatures.return_value = False + + protection = MagicMock() + protection.required_linear_history = False + protection.allow_force_pushes = False + protection.allow_deletions = False + protection.required_status_checks = None + protection.enforce_admins = False + protection.required_conversation_resolution = False + + if branch_protected: + protection.required_pull_request_reviews = MagicMock( + required_approving_review_count=1, + require_code_owner_reviews=False, + dismiss_stale_reviews=dismiss_stale_reviews, + ) + else: + protection.required_pull_request_reviews = None + + branch.get_protection.return_value = protection + repo.get_branch.return_value = branch + + ruleset_details = ruleset_details or [] + repo._requester.requestJsonAndCheck.side_effect = [ + (None, [{"id": ruleset["id"]} for ruleset in ruleset_details]), + *[(None, ruleset) for ruleset in ruleset_details], + ] + + return repo + + def _build_pull_request_ruleset(self, *, enforcement: str, include: list[str]): + return { + "id": 101, + "name": "Dismiss stale reviews", + "target": "branch", + "source_type": "Repository", + "source": "owner1/repo1", + "enforcement": enforcement, + "conditions": {"ref_name": {"include": include, "exclude": []}}, + "rules": [ + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": True, + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_approving_review_count": 1, + "required_review_thread_resolution": False, + }, + } + ], + } + + def _build_ruleset( + self, + *, + enforcement, + include, + rules, + bypass_actors=None, + ruleset_id=201, + ): + return { + "id": ruleset_id, + "name": "Branch protection ruleset", + "target": "branch", + "source_type": "Repository", + "source": "owner1/repo1", + "enforcement": enforcement, + "bypass_actors": bypass_actors or [], + "conditions": {"ref_name": {"include": include, "exclude": []}}, + "rules": rules, + } + + def test_process_repository_uses_classic_branch_protection(self): + repo = self._build_repo(branch_protected=True, dismiss_stale_reviews=True) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is True + assert repos[1].default_branch.dismiss_stale_reviews_source == "classic" + + def test_process_repository_uses_active_ruleset_for_default_branch(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_pull_request_ruleset( + enforcement="active", include=["~DEFAULT_BRANCH"] + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is True + assert repos[1].default_branch.dismiss_stale_reviews_source == "ruleset" + + def test_process_repository_treats_all_branches_ruleset_as_default_branch(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_pull_request_ruleset(enforcement="active", include=["~ALL"]) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is True + assert repos[1].default_branch.dismiss_stale_reviews_source == "ruleset" + + def test_process_repository_marks_inactive_ruleset_as_fail_signal(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_pull_request_ruleset( + enforcement="disabled", include=["~DEFAULT_BRANCH"] + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is False + assert ( + repos[1].default_branch.dismiss_stale_reviews_source == "ruleset_not_active" + ) + + def test_ruleset_non_fast_forward_disallows_force_push(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.allow_force_pushes is False + assert repos[1].default_branch.allow_force_pushes_source == "ruleset" + + def test_ruleset_non_fast_forward_inactive_keeps_force_push_allowed(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.allow_force_pushes is True + assert repos[1].default_branch.allow_force_pushes_source == "ruleset_not_active" + + def test_classic_protection_takes_precedence_over_inactive_ruleset(self): + # Classic protection already disallows force pushes, so an inactive ruleset + # must not downgrade the result. + repo = self._build_repo( + branch_protected=True, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.allow_force_pushes is False + assert repos[1].default_branch.allow_force_pushes_source == "classic" + + def test_ruleset_required_signatures_requires_signed_commits(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "required_signatures"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.require_signed_commits is True + assert repos[1].default_branch.require_signed_commits_source == "ruleset" + + def test_ruleset_required_linear_history(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "required_linear_history"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.required_linear_history is True + assert repos[1].default_branch.required_linear_history_source == "ruleset" + + def test_ruleset_required_status_checks(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [{"context": "ci/build"}], + "strict_required_status_checks_policy": True, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.status_checks is True + assert repos[1].default_branch.status_checks_source == "ruleset" + + def test_ruleset_required_status_checks_without_configured_checks(self): + # A required_status_checks rule with an empty list enforces nothing, so it + # must not be treated as a passing status-checks requirement. + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [], + "strict_required_status_checks_policy": True, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.status_checks is False + assert repos[1].default_branch.status_checks_source is None + + def test_ruleset_deletion_disables_branch_deletion(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "deletion"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.branch_deletion is False + assert repos[1].default_branch.branch_deletion_source == "ruleset" + + def test_active_ruleset_marks_default_branch_protected(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.protected is True + assert repos[1].default_branch.protected_source == "ruleset" + + def test_ruleset_pull_request_parameters_map_to_attributes(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": False, + "require_code_owner_review": True, + "require_last_push_approval": False, + "required_approving_review_count": 2, + "required_review_thread_resolution": True, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + branch = repos[1].default_branch + assert branch.require_pull_request is True + assert branch.require_pull_request_source == "ruleset" + assert branch.require_code_owner_reviews is True + assert branch.require_code_owner_reviews_source == "ruleset" + assert branch.conversation_resolution is True + assert branch.conversation_resolution_source == "ruleset" + assert branch.approval_count == 2 + assert branch.approval_count_source == "ruleset" + + def test_inactive_ruleset_approval_count_is_fail_signal(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + }, + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.approval_count == 0 + assert repos[1].default_branch.approval_count_source == "ruleset_not_active" + + def test_active_ruleset_without_bypass_actors_applies_to_admins(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + bypass_actors=[], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.enforce_admins is True + assert repos[1].default_branch.enforce_admins_source == "ruleset" + + def test_active_ruleset_with_bypass_actors_does_not_apply_to_admins(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="active", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + bypass_actors=[ + { + "actor_id": 1, + "actor_type": "RepositoryRole", + "bypass_mode": "always", + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + # Admins can bypass, so the rulesets do not enforce protection for them. + assert repos[1].default_branch.enforce_admins is False + assert repos[1].default_branch.enforce_admins_source is None + + def test_inactive_ruleset_with_bypass_actors_is_not_admin_fail_signal(self): + # A disabled ruleset that has bypass actors would not apply to admins even if + # activated, so it must not raise the enforce-admins ruleset_not_active signal. + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_ruleset( + enforcement="disabled", + include=["~DEFAULT_BRANCH"], + rules=[{"type": "non_fast_forward"}], + bypass_actors=[ + { + "actor_id": 1, + "actor_type": "RepositoryRole", + "bypass_mode": "always", + } + ], + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.enforce_admins is False + assert repos[1].default_branch.enforce_admins_source is None + # The branch is still reported as protected-but-inactive regardless of bypass. + assert repos[1].default_branch.protected_source == "ruleset_not_active" diff --git a/tests/providers/googleworkspace/googleworkspace_fixtures.py b/tests/providers/googleworkspace/googleworkspace_fixtures.py index c823c21339..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 = { @@ -43,15 +47,60 @@ USER_3 = { } +# Role data for Directory API role tests +SUPER_ADMIN_ROLE_ID = "13801188331880449" +SEED_ADMIN_ROLE_ID = "13801188331880451" +GROUPS_ADMIN_ROLE_ID = "13801188331880450" + +ROLE_SUPER_ADMIN = { + "roleId": SUPER_ADMIN_ROLE_ID, + "roleName": "Super Admin", + "roleDescription": "Super Admin", + "isSystemRole": True, + "isSuperAdminRole": True, +} + +# Google automatically assigns _SEED_ADMIN_ROLE to the first account that +# created the domain. It is a super-admin-capable system role with a +# different name, so it must also be excluded when counting "extra" roles. +ROLE_SEED_ADMIN = { + "roleId": SEED_ADMIN_ROLE_ID, + "roleName": "_SEED_ADMIN_ROLE", + "roleDescription": "Super Admin", + "isSystemRole": True, + "isSuperAdminRole": True, +} + +ROLE_GROUPS_ADMIN = { + "roleId": GROUPS_ADMIN_ROLE_ID, + "roleName": "_GROUPS_ADMIN_ROLE", + "roleDescription": "Groups Administrator", + "isSystemRole": True, + "isSuperAdminRole": False, +} + + def set_mocked_googleworkspace_provider( identity: GoogleWorkspaceIdentityInfo = GoogleWorkspaceIdentityInfo( 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 new file mode 100644 index 0000000000..4581ffa527 --- /dev/null +++ b/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py @@ -0,0 +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""" + 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 ( + svc._is_customer_level_policy( + {"policyQuery": {"orgUnit": f"orgUnits/{ROOT_OU_ID}"}} + ) + is True + ) + + def test_sub_org_unit_rejected(self): + """Policy targeting a sub-OU is not customer-level""" + svc = _make_service() + assert ( + 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 ( + 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 ( + svc._is_customer_level_policy( + { + "policyQuery": { + "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/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled_test.py b/tests/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled_test.py new file mode 100644 index 0000000000..662f5324cd --- /dev/null +++ b/tests/providers/googleworkspace/services/additionalservices/additionalservices_external_groups_disabled/additionalservices_external_groups_disabled_test.py @@ -0,0 +1,128 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServicesPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestAdditionalServicesExternalGroupsDisabled: + def test_pass_groups_disabled(self): + """Test PASS when external Google Groups access is disabled""" + 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.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled.additionalservices_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled import ( + additionalservices_external_groups_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = AdditionalServicesPolicies( + groups_service_state="DISABLED" + ) + + check = additionalservices_external_groups_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 == "Additional Services Policies" + assert findings[0].resource_id == "additionalServicesPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_groups_enabled(self): + """Test FAIL when external Google Groups access is enabled""" + 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.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled.additionalservices_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled import ( + additionalservices_external_groups_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = AdditionalServicesPolicies( + groups_service_state="ENABLED" + ) + + check = additionalservices_external_groups_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): + """Test FAIL when no explicit policy is set (None) - Google default is ON (insecure)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled.additionalservices_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled import ( + additionalservices_external_groups_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = AdditionalServicesPolicies(groups_service_state=None) + + check = additionalservices_external_groups_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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled.additionalservices_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_external_groups_disabled.additionalservices_external_groups_disabled import ( + additionalservices_external_groups_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = AdditionalServicesPolicies() + + check = additionalservices_external_groups_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/additionalservices/additionalservices_service_test.py b/tests/providers/googleworkspace/services/additionalservices/additionalservices_service_test.py new file mode 100644 index 0000000000..85d9afea45 --- /dev/null +++ b/tests/providers/googleworkspace/services/additionalservices/additionalservices_service_test.py @@ -0,0 +1,234 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestAdditionalServicesService: + def test_fetch_policies_groups_off(self): + """Test fetching Additional Services policy with Groups OFF""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_credentials = MagicMock() + mock_session = MagicMock() + mock_session.credentials = mock_credentials + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/groups.service_status", + "value": { + "serviceState": "DISABLED", + }, + } + }, + ] + } + 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.additionalservices.additionalservices_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServices, + ) + + additional_services = AdditionalServices(mock_provider) + + assert additional_services.policies_fetched is True + assert additional_services.policies.groups_service_state == "DISABLED" + + def test_fetch_policies_groups_on(self): + """Test fetching Additional Services policy with Groups ON""" + 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": [ + { + "setting": { + "type": "settings/groups.service_status", + "value": { + "serviceState": "ENABLED", + }, + } + }, + ] + } + 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.additionalservices.additionalservices_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServices, + ) + + additional_services = AdditionalServices(mock_provider) + + assert additional_services.policies_fetched is True + assert additional_services.policies.groups_service_state == "ENABLED" + + def test_fetch_policies_empty_response(self): + """Test handling empty policies response""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.additionalservices.additionalservices_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServices, + ) + + additional_services = AdditionalServices(mock_provider) + + assert additional_services.policies_fetched is True + assert additional_services.policies.groups_service_state is None + + def test_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.additionalservices.additionalservices_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServices, + ) + + additional_services = AdditionalServices(mock_provider) + + assert additional_services.policies_fetched is False + assert additional_services.policies.groups_service_state is None + + def test_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails to construct the client""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.additionalservices.additionalservices_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServices, + ) + + additional_services = AdditionalServices(mock_provider) + + assert additional_services.policies_fetched is False + assert additional_services.policies.groups_service_state is None + + def test_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.additionalservices.additionalservices_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServices, + ) + + additional_services = AdditionalServices(mock_provider) + + assert additional_services.policies_fetched is False + assert additional_services.policies.groups_service_state is None + + def test_additional_services_policies_model(self): + """Test AdditionalServicesPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import ( + AdditionalServicesPolicies, + ) + + policies = AdditionalServicesPolicies(groups_service_state="DISABLED") + + assert policies.groups_service_state == "DISABLED" diff --git a/tests/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning_test.py b/tests/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning_test.py new file mode 100644 index 0000000000..5ff2cee8f6 --- /dev/null +++ b/tests/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning_test.py @@ -0,0 +1,129 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + CalendarPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestCalendarExternalInvitationsWarning: + def test_pass_warnings_enabled(self): + """Test PASS when external invitation warnings are enabled""" + 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.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning import ( + calendar_external_invitations_warning, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + external_invitations_warning=True + ) + + check = calendar_external_invitations_warning() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == "Calendar Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_warnings_disabled(self): + """Test FAIL when external invitation warnings are disabled""" + 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.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning import ( + calendar_external_invitations_warning, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + external_invitations_warning=False + ) + + check = calendar_external_invitations_warning() + 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): + """Test PASS when no explicit policy is set (None) — Google default is secure (enabled)""" + 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.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning import ( + calendar_external_invitations_warning, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + external_invitations_warning=None + ) + + check = calendar_external_invitations_warning() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning import ( + calendar_external_invitations_warning, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = False + mock_calendar_client.policies = CalendarPolicies() + + check = calendar_external_invitations_warning() + findings = check.execute() + + assert len(findings) == 0 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 new file mode 100644 index 0000000000..175955f4b6 --- /dev/null +++ b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py @@ -0,0 +1,167 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + CalendarPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestCalendarExternalSharingPrimaryCalendar: + def test_pass_free_busy_only(self): + """Test PASS when external sharing is restricted to free/busy only""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar import ( + calendar_external_sharing_primary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + primary_calendar_external_sharing="EXTERNAL_FREE_BUSY_ONLY" + ) + + check = calendar_external_sharing_primary_calendar() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "free/busy information only" in findings[0].status_extended + assert findings[0].resource_name == "Calendar Policies" + assert findings[0].resource_id == "calendarPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == CalendarPolicies( + primary_calendar_external_sharing="EXTERNAL_FREE_BUSY_ONLY" + ).dict() + ) + + def test_fail_read_only(self): + """Test FAIL when external sharing allows read-only access""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar import ( + calendar_external_sharing_primary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + primary_calendar_external_sharing="EXTERNAL_ALL_INFO_READ_ONLY" + ) + + check = calendar_external_sharing_primary_calendar() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "EXTERNAL_ALL_INFO_READ_ONLY" in findings[0].status_extended + assert "free/busy information only" in findings[0].status_extended + + def test_fail_read_write(self): + """Test FAIL when external sharing allows read-write access""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar import ( + calendar_external_sharing_primary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + primary_calendar_external_sharing="EXTERNAL_ALL_INFO_READ_WRITE" + ) + + check = calendar_external_sharing_primary_calendar() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "EXTERNAL_ALL_INFO_READ_WRITE" in findings[0].status_extended + + def test_pass_using_default(self): + """Test PASS when no explicit policy is set (None) — Google default is secure (free/busy only)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar import ( + calendar_external_sharing_primary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + primary_calendar_external_sharing=None + ) + + check = calendar_external_sharing_primary_calendar() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar import ( + calendar_external_sharing_primary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = False + mock_calendar_client.policies = CalendarPolicies() + + check = calendar_external_sharing_primary_calendar() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar_test.py b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar_test.py new file mode 100644 index 0000000000..b513860125 --- /dev/null +++ b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar_test.py @@ -0,0 +1,160 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + CalendarPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestCalendarExternalSharingSecondaryCalendar: + def test_pass_free_busy_only(self): + """Test PASS when external sharing is restricted to free/busy only""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar import ( + calendar_external_sharing_secondary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + secondary_calendar_external_sharing="EXTERNAL_FREE_BUSY_ONLY" + ) + + check = calendar_external_sharing_secondary_calendar() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "free/busy information only" in findings[0].status_extended + assert findings[0].resource_name == "Calendar Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_read_only(self): + """Test FAIL when external sharing allows read-only access""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar import ( + calendar_external_sharing_secondary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + secondary_calendar_external_sharing="EXTERNAL_ALL_INFO_READ_ONLY" + ) + + check = calendar_external_sharing_secondary_calendar() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "EXTERNAL_ALL_INFO_READ_ONLY" in findings[0].status_extended + assert "free/busy information only" in findings[0].status_extended + + def test_fail_read_write_manage(self): + """Test FAIL when external sharing allows read-write-manage access""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar import ( + calendar_external_sharing_secondary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + secondary_calendar_external_sharing="EXTERNAL_ALL_INFO_READ_WRITE_MANAGE" + ) + + check = calendar_external_sharing_secondary_calendar() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "EXTERNAL_ALL_INFO_READ_WRITE_MANAGE" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar import ( + calendar_external_sharing_secondary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = True + mock_calendar_client.policies = CalendarPolicies( + secondary_calendar_external_sharing=None + ) + + check = calendar_external_sharing_secondary_calendar() + 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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar.calendar_client" + ) as mock_calendar_client, + ): + from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar import ( + calendar_external_sharing_secondary_calendar, + ) + + mock_calendar_client.provider = mock_provider + mock_calendar_client.policies_fetched = False + mock_calendar_client.policies = CalendarPolicies() + + check = calendar_external_sharing_secondary_calendar() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/calendar/calendar_service_test.py b/tests/providers/googleworkspace/services/calendar/calendar_service_test.py new file mode 100644 index 0000000000..760624e0ae --- /dev/null +++ b/tests/providers/googleworkspace/services/calendar/calendar_service_test.py @@ -0,0 +1,311 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestCalendarService: + def test_calendar_fetch_policies_all_settings(self): + """Test fetching all 3 calendar 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 the actual Cloud Identity Policy API v1 response shape: + # - "type" (not "name"), prefixed with "settings/" + # - inner value field names are camelCase + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/calendar.primary_calendar_max_allowed_external_sharing", + "value": { + "maxAllowedExternalSharing": "EXTERNAL_FREE_BUSY_ONLY" + }, + } + }, + { + "setting": { + "type": "settings/calendar.secondary_calendar_max_allowed_external_sharing", + "value": { + "maxAllowedExternalSharing": "EXTERNAL_ALL_INFO_READ_ONLY" + }, + } + }, + { + "setting": { + "type": "settings/calendar.external_invitations", + "value": {"warnOnInvite": 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.calendar.calendar_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + Calendar, + ) + + calendar = Calendar(mock_provider) + + assert calendar.policies_fetched is True + assert ( + calendar.policies.primary_calendar_external_sharing + == "EXTERNAL_FREE_BUSY_ONLY" + ) + assert ( + calendar.policies.secondary_calendar_external_sharing + == "EXTERNAL_ALL_INFO_READ_ONLY" + ) + assert calendar.policies.external_invitations_warning is True + + def test_calendar_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.calendar.calendar_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + Calendar, + ) + + calendar = Calendar(mock_provider) + + assert calendar.policies_fetched is True + assert calendar.policies.primary_calendar_external_sharing is None + assert calendar.policies.secondary_calendar_external_sharing is None + assert calendar.policies.external_invitations_warning is None + + def test_calendar_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.calendar.calendar_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + Calendar, + ) + + calendar = Calendar(mock_provider) + + assert calendar.policies_fetched is False + assert calendar.policies.primary_calendar_external_sharing is None + assert calendar.policies.secondary_calendar_external_sharing is None + assert calendar.policies.external_invitations_warning is None + + def test_calendar_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.calendar.calendar_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + Calendar, + ) + + calendar = Calendar(mock_provider) + + assert calendar.policies_fetched is False + assert calendar.policies.primary_calendar_external_sharing is None + assert calendar.policies.secondary_calendar_external_sharing is None + assert calendar.policies.external_invitations_warning is None + + def test_calendar_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.calendar.calendar_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + Calendar, + ) + + calendar = Calendar(mock_provider) + + assert calendar.policies_fetched is False + assert calendar.policies.primary_calendar_external_sharing is None + assert calendar.policies.secondary_calendar_external_sharing is None + assert calendar.policies.external_invitations_warning is None + + def test_calendar_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() + # Response includes policies at different org levels: + # customer-level (no policyQuery), OU-level, and group-level + mock_policies_list.execute.return_value = { + "policies": [ + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/calendar.primary_calendar_max_allowed_external_sharing", + "value": { + "maxAllowedExternalSharing": "EXTERNAL_FREE_BUSY_ONLY" + }, + } + }, + { + # OU-level: has policyQuery.orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sales_team"}, + "setting": { + "type": "settings/calendar.primary_calendar_max_allowed_external_sharing", + "value": { + "maxAllowedExternalSharing": "EXTERNAL_ALL_INFO_READ_WRITE" + }, + }, + }, + { + # Group-level: has policyQuery.group → should be skipped + "policyQuery": {"group": "groups/contractors"}, + "setting": { + "type": "settings/calendar.external_invitations", + "value": {"warnOnInvite": False}, + }, + }, + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/calendar.external_invitations", + "value": {"warnOnInvite": 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.calendar.calendar_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + Calendar, + ) + + calendar = Calendar(mock_provider) + + assert calendar.policies_fetched is True + # Customer-level values should be stored + assert ( + calendar.policies.primary_calendar_external_sharing + == "EXTERNAL_FREE_BUSY_ONLY" + ) + assert calendar.policies.external_invitations_warning is True + # OU-level value (EXTERNAL_ALL_INFO_READ_WRITE) should NOT have overwritten + # Group-level value (warnOnInvite: False) should NOT have overwritten + + def test_calendar_policies_model(self): + """Test CalendarPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.calendar.calendar_service import ( + CalendarPolicies, + ) + + policies = CalendarPolicies( + primary_calendar_external_sharing="EXTERNAL_FREE_BUSY_ONLY", + secondary_calendar_external_sharing="EXTERNAL_ALL_INFO_READ_WRITE", + external_invitations_warning=True, + ) + + assert policies.primary_calendar_external_sharing == "EXTERNAL_FREE_BUSY_ONLY" + assert ( + policies.secondary_calendar_external_sharing + == "EXTERNAL_ALL_INFO_READ_WRITE" + ) + assert policies.external_invitations_warning is True diff --git a/tests/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled_test.py new file mode 100644 index 0000000000..a476321754 --- /dev/null +++ b/tests/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled_test.py @@ -0,0 +1,119 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestChatAppsInstallationDisabled: + 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.chat.chat_apps_installation_disabled.chat_apps_installation_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled import ( + chat_apps_installation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(enable_apps=False) + + check = chat_apps_installation_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 == "Chat Policies" + assert findings[0].resource_id == "chatPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert findings[0].resource == ChatPolicies(enable_apps=False).dict() + + def test_fail_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.chat.chat_apps_installation_disabled.chat_apps_installation_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled import ( + chat_apps_installation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(enable_apps=True) + + check = chat_apps_installation_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_pass_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.chat.chat_apps_installation_disabled.chat_apps_installation_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled import ( + chat_apps_installation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(enable_apps=None) + + check = chat_apps_installation_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.chat.chat_apps_installation_disabled.chat_apps_installation_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled import ( + chat_apps_installation_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = ChatPolicies() + + check = chat_apps_installation_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled_test.py new file mode 100644 index 0000000000..429375ebe5 --- /dev/null +++ b/tests/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled_test.py @@ -0,0 +1,149 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestChatExternalFileSharingDisabled: + 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.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled import ( + chat_external_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(external_file_sharing="NO_FILES") + + check = chat_external_file_sharing_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 == "Chat Policies" + assert findings[0].resource_id == "chatPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == ChatPolicies(external_file_sharing="NO_FILES").dict() + ) + + def test_fail_all_files(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.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled import ( + chat_external_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(external_file_sharing="ALL_FILES") + + check = chat_external_file_sharing_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ALL_FILES" in findings[0].status_extended + + def test_fail_images_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.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled import ( + chat_external_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(external_file_sharing="IMAGES_ONLY") + + check = chat_external_file_sharing_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "IMAGES_ONLY" 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.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled import ( + chat_external_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(external_file_sharing=None) + + check = chat_external_file_sharing_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.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled import ( + chat_external_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = ChatPolicies() + + check = chat_external_file_sharing_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted_test.py b/tests/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted_test.py new file mode 100644 index 0000000000..09fa3d2dbc --- /dev/null +++ b/tests/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted_test.py @@ -0,0 +1,154 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestChatExternalMessagingRestricted: + def test_pass_external_chat_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.chat.chat_external_messaging_restricted.chat_external_messaging_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_messaging_restricted.chat_external_messaging_restricted import ( + chat_external_messaging_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(allow_external_chat=False) + + check = chat_external_messaging_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == "Chat Policies" + assert findings[0].resource_id == "chatPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource == ChatPolicies(allow_external_chat=False).dict() + ) + + def test_pass_trusted_domains(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.chat.chat_external_messaging_restricted.chat_external_messaging_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_messaging_restricted.chat_external_messaging_restricted import ( + chat_external_messaging_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies( + allow_external_chat=True, + external_chat_restriction="TRUSTED_DOMAINS", + ) + + check = chat_external_messaging_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "restricted to allowed domains" in findings[0].status_extended + + def test_fail_no_restriction(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.chat.chat_external_messaging_restricted.chat_external_messaging_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_messaging_restricted.chat_external_messaging_restricted import ( + chat_external_messaging_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies( + allow_external_chat=True, + external_chat_restriction="NO_RESTRICTION", + ) + + check = chat_external_messaging_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not restricted" in findings[0].status_extended + + def test_pass_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.chat.chat_external_messaging_restricted.chat_external_messaging_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_messaging_restricted.chat_external_messaging_restricted import ( + chat_external_messaging_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies() + + check = chat_external_messaging_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + 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.chat.chat_external_messaging_restricted.chat_external_messaging_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_messaging_restricted.chat_external_messaging_restricted import ( + chat_external_messaging_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = ChatPolicies() + + check = chat_external_messaging_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted_test.py b/tests/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted_test.py new file mode 100644 index 0000000000..608ae11d01 --- /dev/null +++ b/tests/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted_test.py @@ -0,0 +1,155 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestChatExternalSpacesRestricted: + def test_pass_spaces_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.chat.chat_external_spaces_restricted.chat_external_spaces_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_spaces_restricted.chat_external_spaces_restricted import ( + chat_external_spaces_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(external_spaces_enabled=False) + + check = chat_external_spaces_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == "Chat Policies" + assert findings[0].resource_id == "chatPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == ChatPolicies(external_spaces_enabled=False).dict() + ) + + def test_pass_trusted_domains(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.chat.chat_external_spaces_restricted.chat_external_spaces_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_spaces_restricted.chat_external_spaces_restricted import ( + chat_external_spaces_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies( + external_spaces_enabled=True, + external_spaces_domain_allowlist_mode="TRUSTED_DOMAINS", + ) + + check = chat_external_spaces_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "restricted to allowed domains" in findings[0].status_extended + + def test_fail_all_domains(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.chat.chat_external_spaces_restricted.chat_external_spaces_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_spaces_restricted.chat_external_spaces_restricted import ( + chat_external_spaces_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies( + external_spaces_enabled=True, + external_spaces_domain_allowlist_mode="ALL_DOMAINS", + ) + + check = chat_external_spaces_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not restricted" 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.chat.chat_external_spaces_restricted.chat_external_spaces_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_spaces_restricted.chat_external_spaces_restricted import ( + chat_external_spaces_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies() + + check = chat_external_spaces_restricted() + 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.chat.chat_external_spaces_restricted.chat_external_spaces_restricted.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_external_spaces_restricted.chat_external_spaces_restricted import ( + chat_external_spaces_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = ChatPolicies() + + check = chat_external_spaces_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled_test.py new file mode 100644 index 0000000000..712bb21fa5 --- /dev/null +++ b/tests/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled_test.py @@ -0,0 +1,119 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestChatIncomingWebhooksDisabled: + 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.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled import ( + chat_incoming_webhooks_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(enable_webhooks=False) + + check = chat_incoming_webhooks_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 == "Chat Policies" + assert findings[0].resource_id == "chatPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert findings[0].resource == ChatPolicies(enable_webhooks=False).dict() + + def test_fail_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.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled import ( + chat_incoming_webhooks_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(enable_webhooks=True) + + check = chat_incoming_webhooks_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_pass_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.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled import ( + chat_incoming_webhooks_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(enable_webhooks=None) + + check = chat_incoming_webhooks_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.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled import ( + chat_incoming_webhooks_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = ChatPolicies() + + check = chat_incoming_webhooks_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled_test.py new file mode 100644 index 0000000000..297d00548d --- /dev/null +++ b/tests/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled_test.py @@ -0,0 +1,122 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestChatInternalFileSharingDisabled: + 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.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled import ( + chat_internal_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(internal_file_sharing="NO_FILES") + + check = chat_internal_file_sharing_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 == "Chat Policies" + assert findings[0].resource_id == "chatPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == ChatPolicies(internal_file_sharing="NO_FILES").dict() + ) + + def test_fail_all_files(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.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled import ( + chat_internal_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(internal_file_sharing="ALL_FILES") + + check = chat_internal_file_sharing_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ALL_FILES" 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.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled import ( + chat_internal_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = ChatPolicies(internal_file_sharing=None) + + check = chat_internal_file_sharing_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.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled.chat_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled import ( + chat_internal_file_sharing_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = ChatPolicies() + + check = chat_internal_file_sharing_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/chat/chat_service_test.py b/tests/providers/googleworkspace/services/chat/chat_service_test.py new file mode 100644 index 0000000000..673f1eb3cd --- /dev/null +++ b/tests/providers/googleworkspace/services/chat/chat_service_test.py @@ -0,0 +1,440 @@ +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 TestChatService: + def test_chat_fetch_policies_all_settings(self): + """Test fetching all 4 Chat 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/chat.chat_file_sharing", + "value": { + "externalFileSharing": "NO_FILES", + "internalFileSharing": "IMAGES_ONLY", + }, + } + }, + { + "setting": { + "type": "settings/chat.external_chat_restriction", + "value": { + "allowExternalChat": True, + "externalChatRestriction": "TRUSTED_DOMAINS", + }, + } + }, + { + "setting": { + "type": "settings/chat.chat_external_spaces", + "value": { + "enabled": True, + "domainAllowlistMode": "TRUSTED_DOMAINS", + }, + } + }, + { + "setting": { + "type": "settings/chat.chat_apps_access", + "value": { + "enableApps": False, + "enableWebhooks": 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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + assert chat.policies_fetched is True + assert chat.policies.external_file_sharing == "NO_FILES" + assert chat.policies.internal_file_sharing == "IMAGES_ONLY" + assert chat.policies.allow_external_chat is True + assert chat.policies.external_chat_restriction == "TRUSTED_DOMAINS" + assert chat.policies.external_spaces_enabled is True + assert ( + chat.policies.external_spaces_domain_allowlist_mode == "TRUSTED_DOMAINS" + ) + assert chat.policies.enable_apps is False + assert chat.policies.enable_webhooks is False + + def test_chat_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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + assert chat.policies_fetched is True + assert chat.policies.external_file_sharing is None + assert chat.policies.allow_external_chat is None + assert chat.policies.enable_apps is None + assert chat.policies.enable_webhooks is None + + def test_chat_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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + assert chat.policies_fetched is False + assert chat.policies.external_file_sharing is None + + def test_chat_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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + assert chat.policies_fetched is False + assert chat.policies.external_file_sharing is None + + def test_chat_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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + assert chat.policies_fetched is False + assert chat.policies.external_file_sharing is None + + def test_chat_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/chat.chat_apps_access", + "value": {"enableApps": False, "enableWebhooks": False}, + } + }, + { + # OU-level: has policyQuery.orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sales_team"}, + "setting": { + "type": "settings/chat.chat_apps_access", + "value": {"enableApps": True, "enableWebhooks": True}, + }, + }, + { + # Group-level: has policyQuery.group → should be skipped + "policyQuery": {"group": "groups/contractors"}, + "setting": { + "type": "settings/chat.chat_file_sharing", + "value": { + "externalFileSharing": "ALL_FILES", + "internalFileSharing": "ALL_FILES", + }, + }, + }, + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/chat.chat_file_sharing", + "value": { + "externalFileSharing": "NO_FILES", + "internalFileSharing": "NO_FILES", + }, + } + }, + ] + } + 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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + assert chat.policies_fetched is True + assert chat.policies.enable_apps is False + assert chat.policies.external_file_sharing == "NO_FILES" + + def test_chat_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/chat.chat_apps_access", + "value": {"enableApps": False, "enableWebhooks": True}, + }, + }, + { + # Sub-OU: different orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sub_ou_sales"}, + "setting": { + "type": "settings/chat.chat_file_sharing", + "value": { + "externalFileSharing": "ALL_FILES", + "internalFileSharing": "ALL_FILES", + }, + }, + }, + ] + } + 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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + assert chat.policies_fetched is True + # Root OU policy accepted + assert chat.policies.enable_apps is False + assert chat.policies.enable_webhooks is True + # Sub-OU policy skipped + assert chat.policies.external_file_sharing is None + + def test_chat_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 Chat data + page1_response = { + "policies": [ + { + "setting": { + "type": "settings/chat.chat_apps_access", + "value": {"enableApps": False, "enableWebhooks": 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.chat.chat_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.chat.chat_service import ( + Chat, + ) + + chat = Chat(mock_provider) + + # Page 1 data was stored + assert chat.policies.enable_apps is False + # But policies_fetched must be False because page 2 failed + assert chat.policies_fetched is False + + def test_chat_policies_model(self): + """Test ChatPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.chat.chat_service import ( + ChatPolicies, + ) + + policies = ChatPolicies( + external_file_sharing="NO_FILES", + internal_file_sharing="IMAGES_ONLY", + allow_external_chat=True, + external_chat_restriction="TRUSTED_DOMAINS", + external_spaces_enabled=True, + external_spaces_domain_allowlist_mode="TRUSTED_DOMAINS", + enable_apps=False, + enable_webhooks=False, + ) + + assert policies.external_file_sharing == "NO_FILES" + assert policies.internal_file_sharing == "IMAGES_ONLY" + assert policies.allow_external_chat is True + assert policies.external_chat_restriction == "TRUSTED_DOMAINS" + assert policies.external_spaces_enabled is True + assert policies.external_spaces_domain_allowlist_mode == "TRUSTED_DOMAINS" + assert policies.enable_apps is False + assert policies.enable_webhooks is False diff --git a/tests/providers/googleworkspace/services/directory/directory_service_test.py b/tests/providers/googleworkspace/services/directory/directory_service_test.py index 83c7e594c0..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,14 @@ 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, + ROLE_SUPER_ADMIN, + SEED_ADMIN_ROLE_ID, + SUPER_ADMIN_ROLE_ID, USER_1, USER_2, USER_3, @@ -25,6 +33,24 @@ class TestDirectoryService: mock_service.users().list.return_value = mock_users_list mock_service.users().list_next.return_value = None + # Mock roles response + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = { + "items": [ + {"assignedTo": "user1-id", "roleId": SUPER_ADMIN_ROLE_ID}, + {"assignedTo": "user2-id", "roleId": SUPER_ADMIN_ROLE_ID}, + ] + } + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + with ( patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -42,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 @@ -67,6 +94,17 @@ class TestDirectoryService: mock_service.users().list.return_value = mock_users_list mock_service.users().list_next.return_value = None + # Mock roles response + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = {"items": []} + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = {"items": []} + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + with ( patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -84,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""" @@ -97,6 +137,16 @@ class TestDirectoryService: mock_service = MagicMock() mock_service.users().list.side_effect = Exception("API Error") + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = {"items": []} + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = {"items": []} + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + with ( patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -130,3 +180,193 @@ class TestDirectoryService: assert user.id == "test-id" assert user.email == "test@test-company.com" assert user.is_admin is True + assert user.role_assignments == [] + + def test_directory_list_roles(self): + """Test that _list_roles correctly builds a roleId-to-roleName mapping""" + 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 empty users + mock_users_list = MagicMock() + mock_users_list.execute.return_value = {"users": []} + mock_service.users().list.return_value = mock_users_list + mock_service.users().list_next.return_value = None + + # Mock roles response + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = {"items": []} + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.directory.directory_service import ( + Directory, + ) + + directory = Directory(mock_provider) + + super_admin_role = directory._roles[SUPER_ADMIN_ROLE_ID] + assert super_admin_role.name == "Super Admin" + assert super_admin_role.description == "Super Admin" + assert super_admin_role.is_super_admin_role is True + + groups_admin_role = directory._roles[GROUPS_ADMIN_ROLE_ID] + assert groups_admin_role.name == "_GROUPS_ADMIN_ROLE" + assert groups_admin_role.description == "Groups Administrator" + assert groups_admin_role.is_super_admin_role is False + + def test_directory_role_assignments_populated(self): + """Test that role assignments are fetched and resolved for super admins""" + 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 users - one super admin + mock_users_list = MagicMock() + mock_users_list.execute.return_value = {"users": [USER_1]} + mock_service.users().list.return_value = mock_users_list + mock_service.users().list_next.return_value = None + + # Mock roles + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = { + "items": [ + {"assignedTo": "user1-id", "roleId": SUPER_ADMIN_ROLE_ID}, + {"assignedTo": "user1-id", "roleId": GROUPS_ADMIN_ROLE_ID}, + ] + } + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.directory.directory_service import ( + Directory, + ) + + directory = Directory(mock_provider) + + user = directory.users["user1-id"] + role_names = [r.name for r in user.role_assignments] + role_descriptions = [r.description for r in user.role_assignments] + assert "Super Admin" in role_names + assert "_GROUPS_ADMIN_ROLE" in role_names + assert "Groups Administrator" in role_descriptions + assert len(user.role_assignments) == 2 + assert user.is_admin is True + + def test_directory_second_super_admin_detected_via_role_assignments(self): + """Regression: a second super admin whose users.list().isAdmin still + reads False (e.g. API propagation lag, or only holding + _SEED_ADMIN_ROLE) must still be recognised as a super admin through + the Role Assignments API, AND any extra non-super-admin roles they + hold must be surfaced on their User object.""" + 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() + + stale_user_1 = { + "id": "user1-id", + "primaryEmail": "admin1@test-company.com", + "isAdmin": False, + } + stale_user_2 = { + "id": "user2-id", + "primaryEmail": "admin2@test-company.com", + "isAdmin": False, + } + mock_users_list = MagicMock() + mock_users_list.execute.return_value = {"users": [stale_user_1, stale_user_2]} + mock_service.users().list.return_value = mock_users_list + mock_service.users().list_next.return_value = None + + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_SEED_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = { + "items": [ + {"assignedTo": "user1-id", "roleId": SEED_ADMIN_ROLE_ID}, + {"assignedTo": "user2-id", "roleId": SUPER_ADMIN_ROLE_ID}, + {"assignedTo": "user2-id", "roleId": GROUPS_ADMIN_ROLE_ID}, + ] + } + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.directory.directory_service import ( + Directory, + ) + + directory = Directory(mock_provider) + + user1 = directory.users["user1-id"] + user2 = directory.users["user2-id"] + assert user1.is_admin is True + assert user2.is_admin is True + + assert [r.name for r in user1.role_assignments] == ["_SEED_ADMIN_ROLE"] + user2_role_names = {r.name for r in user2.role_assignments} + assert user2_role_names == {"Super Admin", "_GROUPS_ADMIN_ROLE"} 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..2b5df2db09 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.directory.directory_service import User from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -54,8 +53,10 @@ class TestDirectorySuperAdminCount: assert findings[0].status == "PASS" 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_name == "Directory Users" + assert findings[0].resource_id == "directoryUsers" 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 new file mode 100644 index 0000000000..c3341159bd --- /dev/null +++ b/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py @@ -0,0 +1,452 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.directory.directory_service import ( + Role, + User, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +SUPER_ADMIN_ROLE = Role( + id="13801188331880449", + name="Super Admin", + description="Super Admin", + is_super_admin_role=True, +) +SEED_ADMIN_ROLE = Role( + id="13801188331880451", + name="_SEED_ADMIN_ROLE", + description="Super Admin", + is_super_admin_role=True, +) +GROUPS_ADMIN_ROLE = Role( + id="13801188331880450", + name="_GROUPS_ADMIN_ROLE", + description="Groups Administrator", + is_super_admin_role=False, +) +USER_MANAGEMENT_ADMIN_ROLE = Role( + id="13801188331880452", + name="_USER_MANAGEMENT_ADMIN_ROLE", + description="User Management Administrator", + is_super_admin_role=False, +) +CUSTOM_ROLE_NO_DESCRIPTION = Role( + id="13801188331880453", + name="custom-helpdesk-role", + description="", + is_super_admin_role=False, +) + + +class TestDirectorySuperAdminOnlyAdminRoles: + def test_pass_super_admins_only_super_admin_role(self): + """Test PASS when super admins have only the Super Admin role""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + "admin2-id": User( + id="admin2-id", + email="admin2@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + "user1-id": User( + id="user1-id", + email="user@test-company.com", + is_admin=False, + role_assignments=[], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "used only for super admin activities" in findings[0].status_extended + assert findings[0].resource_name == "Directory Users" + assert findings[0].resource_id == "directoryUsers" + 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. + + _SEED_ADMIN_ROLE is auto-assigned by Google to the original domain + creator and has isSuperAdminRole=True, so it must not count as an + "extra" role. + """ + users = { + "admin1-id": User( + id="admin1-id", + email="playground@prowler.cloud", + is_admin=True, + role_assignments=[SEED_ADMIN_ROLE], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "_SEED_ADMIN_ROLE" not in findings[0].status_extended + + def test_pass_super_admin_with_both_super_admin_and_seed_admin(self): + """Test PASS when admin holds both Super Admin and _SEED_ADMIN_ROLE""" + users = { + "admin1-id": User( + id="admin1-id", + email="playground@prowler.cloud", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, SEED_ADMIN_ROLE], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_fail_super_admin_with_additional_roles(self): + """Test FAIL when a super admin also has additional admin roles""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, GROUPS_ADMIN_ROLE], + ), + "user1-id": User( + id="user1-id", + email="user@test-company.com", + is_admin=False, + role_assignments=[], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + 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 "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 == "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): + """Test FAIL when a _SEED_ADMIN_ROLE holder also has extra roles""" + users = { + "admin1-id": User( + id="admin1-id", + email="playground@prowler.cloud", + is_admin=True, + role_assignments=[SEED_ADMIN_ROLE, GROUPS_ADMIN_ROLE], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "playground@prowler.cloud" in findings[0].status_extended + 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""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, GROUPS_ADMIN_ROLE], + ), + "admin2-id": User( + id="admin2-id", + email="admin2@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, USER_MANAGEMENT_ADMIN_ROLE], + ), + "admin3-id": User( + id="admin3-id", + email="admin3@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + 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""" + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = {} + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 0 + + def test_non_super_admin_with_roles_not_flagged(self): + """Test that users who are not super admins are ignored even if they have roles""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + "delegated1-id": User( + id="delegated1-id", + email="delegated@test-company.com", + is_admin=False, + role_assignments=[GROUPS_ADMIN_ROLE], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "delegated@test-company.com" not in findings[0].status_extended + + def test_pass_super_admin_with_empty_role_assignments(self): + """Test PASS when super admin has no role assignments (edge case)""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_fail_custom_role_without_description_falls_back_to_name(self): + """A custom role with an empty description should be displayed + using its name as a fall-back, so the FAIL message is never blank + for users that genuinely hold extra roles.""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, CUSTOM_ROLE_NO_DESCRIPTION], + ), + } + + 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.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + 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_access_checker_recipients_only/drive_access_checker_recipients_only_test.py b/tests/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only_test.py new file mode 100644 index 0000000000..e200836bcd --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only_test.py @@ -0,0 +1,155 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveAccessCheckerRecipientsOnly: + def test_pass_recipients_only(self): + """Test PASS when Access Checker is set to recipients only""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only import ( + drive_access_checker_recipients_only, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + access_checker_suggestions="RECIPIENTS_ONLY" + ) + + check = drive_access_checker_recipients_only() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "recipients only" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_recipients_or_audience(self): + """Test FAIL when Access Checker allows audience suggestions""" + 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.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only import ( + drive_access_checker_recipients_only, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + access_checker_suggestions="RECIPIENTS_OR_AUDIENCE" + ) + + check = drive_access_checker_recipients_only() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "RECIPIENTS_OR_AUDIENCE" in findings[0].status_extended + + def test_fail_recipients_or_audience_or_public(self): + """Test FAIL when Access Checker allows public suggestions""" + 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.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only import ( + drive_access_checker_recipients_only, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + access_checker_suggestions="RECIPIENTS_OR_AUDIENCE_OR_PUBLIC" + ) + + check = drive_access_checker_recipients_only() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "RECIPIENTS_OR_AUDIENCE_OR_PUBLIC" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only import ( + drive_access_checker_recipients_only, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(access_checker_suggestions=None) + + check = drive_access_checker_recipients_only() + 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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only import ( + drive_access_checker_recipients_only, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_access_checker_recipients_only() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled_test.py b/tests/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled_test.py new file mode 100644 index 0000000000..7225974c68 --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled_test.py @@ -0,0 +1,121 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveDesktopAccessDisabled: + def test_pass_desktop_disabled(self): + """Test PASS when Drive for desktop is disabled""" + 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.drive.drive_desktop_access_disabled.drive_desktop_access_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_desktop_access_disabled.drive_desktop_access_disabled import ( + drive_desktop_access_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_drive_for_desktop=False) + + check = drive_desktop_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 == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_desktop_enabled(self): + """Test FAIL when Drive for desktop is enabled""" + 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.drive.drive_desktop_access_disabled.drive_desktop_access_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_desktop_access_disabled.drive_desktop_access_disabled import ( + drive_desktop_access_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_drive_for_desktop=True) + + check = drive_desktop_access_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): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.drive.drive_desktop_access_disabled.drive_desktop_access_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_desktop_access_disabled.drive_desktop_access_disabled import ( + drive_desktop_access_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_drive_for_desktop=None) + + check = drive_desktop_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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_desktop_access_disabled.drive_desktop_access_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_desktop_access_disabled.drive_desktop_access_disabled import ( + drive_desktop_access_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_desktop_access_disabled() + findings = check.execute() + + assert len(findings) == 0 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 new file mode 100644 index 0000000000..3b66837460 --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py @@ -0,0 +1,126 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveExternalSharingWarnUsers: + def test_pass_warning_enabled(self): + """Test PASS when external sharing warning is enabled""" + 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.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users import ( + drive_external_sharing_warn_users, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(warn_for_external_sharing=True) + + check = drive_external_sharing_warn_users() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].resource_id == "drivePolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == DrivePolicies(warn_for_external_sharing=True).dict() + ) + + def test_fail_warning_disabled(self): + """Test FAIL when external sharing warning is explicitly disabled""" + 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.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users import ( + drive_external_sharing_warn_users, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(warn_for_external_sharing=False) + + check = drive_external_sharing_warn_users() + 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): + """Test PASS when no explicit policy is set (None) — Google default is secure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users import ( + drive_external_sharing_warn_users, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(warn_for_external_sharing=None) + + check = drive_external_sharing_warn_users() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users import ( + drive_external_sharing_warn_users, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_external_sharing_warn_users() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content_test.py b/tests/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content_test.py new file mode 100644 index 0000000000..b089d52d58 --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content_test.py @@ -0,0 +1,157 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveInternalUsersDistributeContent: + def test_pass_eligible_internal_users(self): + """Test PASS when content distribution is restricted to eligible internal users""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content import ( + drive_internal_users_distribute_content, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_distributing_content="ELIGIBLE_INTERNAL_USERS" + ) + + check = drive_internal_users_distribute_content() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "ELIGIBLE_INTERNAL_USERS" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_allowed(self): + """Test PASS when content distribution is set to NONE (nobody can distribute)""" + 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.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content import ( + drive_internal_users_distribute_content, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_distributing_content="NONE" + ) + + check = drive_internal_users_distribute_content() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "NONE" in findings[0].status_extended + + def test_fail_all_eligible_users(self): + """Test FAIL when all users (including external) can distribute content""" + 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.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content import ( + drive_internal_users_distribute_content, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_distributing_content="ALL_ELIGIBLE_USERS" + ) + + check = drive_internal_users_distribute_content() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ALL_ELIGIBLE_USERS" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content import ( + drive_internal_users_distribute_content, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_distributing_content=None + ) + + check = drive_internal_users_distribute_content() + 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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content import ( + drive_internal_users_distribute_content, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_internal_users_distribute_content() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled_test.py b/tests/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled_test.py new file mode 100644 index 0000000000..8d6a0efcff --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled_test.py @@ -0,0 +1,121 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDrivePublishingFilesDisabled: + def test_pass_publishing_disabled(self): + """Test PASS when publishing files to web is disabled""" + 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.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import ( + drive_publishing_files_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_publishing_files=False) + + check = drive_publishing_files_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 == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_publishing_enabled(self): + """Test FAIL when publishing files to web is enabled""" + 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.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import ( + drive_publishing_files_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_publishing_files=True) + + check = drive_publishing_files_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): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import ( + drive_publishing_files_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_publishing_files=None) + + check = drive_publishing_files_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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import ( + drive_publishing_files_disabled, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_publishing_files_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_service_test.py b/tests/providers/googleworkspace/services/drive/drive_service_test.py new file mode 100644 index 0000000000..cbdd56679c --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_service_test.py @@ -0,0 +1,349 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestDriveService: + def test_drive_fetch_policies_all_settings(self): + """Test fetching all 3 Drive and Docs 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 the actual Cloud Identity Policy API v1 response shape: + # - "type" (not "name"), prefixed with "settings/" + # - inner value field names are camelCase + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/drive_and_docs.external_sharing", + "value": { + "externalSharingMode": "ALLOWLISTED_DOMAINS", + "warnForExternalSharing": True, + "warnForSharingOutsideAllowlistedDomains": True, + "allowPublishingFiles": False, + "accessCheckerSuggestions": "RECIPIENTS_ONLY", + "allowedPartiesForDistributingContent": "ELIGIBLE_INTERNAL_USERS", + }, + } + }, + { + "setting": { + "type": "settings/drive_and_docs.shared_drive_creation", + "value": { + "allowSharedDriveCreation": True, + "allowManagersToOverrideSettings": False, + "allowNonMemberAccess": False, + "allowedPartiesForDownloadPrintCopy": "EDITORS_ONLY", + }, + } + }, + { + "setting": { + "type": "settings/drive_and_docs.drive_for_desktop", + "value": {"allowDriveForDesktop": 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.drive.drive_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.drive.drive_service import ( + Drive, + ) + + drive = Drive(mock_provider) + + assert drive.policies_fetched is True + assert drive.policies.external_sharing_mode == "ALLOWLISTED_DOMAINS" + assert drive.policies.warn_for_external_sharing is True + assert drive.policies.warn_for_sharing_outside_allowlisted_domains is True + assert drive.policies.allow_publishing_files is False + assert drive.policies.access_checker_suggestions == "RECIPIENTS_ONLY" + assert ( + drive.policies.allowed_parties_for_distributing_content + == "ELIGIBLE_INTERNAL_USERS" + ) + assert drive.policies.allow_shared_drive_creation is True + assert drive.policies.allow_managers_to_override_settings is False + assert drive.policies.allow_non_member_access is False + assert ( + drive.policies.allowed_parties_for_download_print_copy == "EDITORS_ONLY" + ) + assert drive.policies.allow_drive_for_desktop is False + + def test_drive_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.drive.drive_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.drive.drive_service import ( + Drive, + ) + + drive = Drive(mock_provider) + + assert drive.policies_fetched is True + assert drive.policies.external_sharing_mode is None + assert drive.policies.warn_for_external_sharing is None + assert drive.policies.allow_publishing_files is None + assert drive.policies.allow_shared_drive_creation is None + assert drive.policies.allow_drive_for_desktop is None + + def test_drive_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.drive.drive_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.drive.drive_service import ( + Drive, + ) + + drive = Drive(mock_provider) + + assert drive.policies_fetched is False + assert drive.policies.external_sharing_mode is None + assert drive.policies.allow_shared_drive_creation is None + assert drive.policies.allow_drive_for_desktop is None + + def test_drive_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.drive.drive_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.drive.drive_service import ( + Drive, + ) + + drive = Drive(mock_provider) + + assert drive.policies_fetched is False + assert drive.policies.external_sharing_mode is None + assert drive.policies.allow_shared_drive_creation is None + assert drive.policies.allow_drive_for_desktop is None + + def test_drive_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.drive.drive_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.drive.drive_service import ( + Drive, + ) + + drive = Drive(mock_provider) + + assert drive.policies_fetched is False + assert drive.policies.external_sharing_mode is None + assert drive.policies.allow_shared_drive_creation is None + assert drive.policies.allow_drive_for_desktop is None + + def test_drive_policies_model(self): + """Test DrivePolicies Pydantic model""" + from prowler.providers.googleworkspace.services.drive.drive_service import ( + DrivePolicies, + ) + + policies = DrivePolicies( + external_sharing_mode="ALLOWLISTED_DOMAINS", + warn_for_external_sharing=True, + warn_for_sharing_outside_allowlisted_domains=True, + allow_publishing_files=False, + access_checker_suggestions="RECIPIENTS_ONLY", + allowed_parties_for_distributing_content="ELIGIBLE_INTERNAL_USERS", + allow_shared_drive_creation=True, + allow_managers_to_override_settings=False, + allow_non_member_access=False, + allowed_parties_for_download_print_copy="EDITORS_ONLY", + allow_drive_for_desktop=False, + ) + + assert policies.external_sharing_mode == "ALLOWLISTED_DOMAINS" + assert policies.warn_for_external_sharing is True + assert policies.allow_publishing_files is False + assert policies.access_checker_suggestions == "RECIPIENTS_ONLY" + assert policies.allow_shared_drive_creation is True + assert policies.allow_managers_to_override_settings is False + assert policies.allow_non_member_access is False + assert policies.allowed_parties_for_download_print_copy == "EDITORS_ONLY" + assert policies.allow_drive_for_desktop is False + + def test_drive_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() + # Response includes 3 policies of the same type at different org levels: + # customer-level (no policyQuery), OU-level, and group-level + mock_policies_list.execute.return_value = { + "policies": [ + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/drive_and_docs.external_sharing", + "value": { + "externalSharingMode": "ALLOWLISTED_DOMAINS", + "warnForExternalSharing": True, + }, + } + }, + { + # OU-level: has policyQuery.orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sales_team"}, + "setting": { + "type": "settings/drive_and_docs.external_sharing", + "value": { + "externalSharingMode": "ALLOWED", + "warnForExternalSharing": False, + }, + }, + }, + { + # Group-level: has policyQuery.group → should be skipped + "policyQuery": {"group": "groups/contractors"}, + "setting": { + "type": "settings/drive_and_docs.external_sharing", + "value": { + "externalSharingMode": "DISALLOWED", + "warnForExternalSharing": False, + }, + }, + }, + { + # Customer-level shared drive creation + "setting": { + "type": "settings/drive_and_docs.shared_drive_creation", + "value": {"allowSharedDriveCreation": True}, + } + }, + { + # OU-level shared drive creation → should be skipped + "policyQuery": {"orgUnit": "orgUnits/engineering"}, + "setting": { + "type": "settings/drive_and_docs.shared_drive_creation", + "value": {"allowSharedDriveCreation": 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.drive.drive_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.drive.drive_service import ( + Drive, + ) + + drive = Drive(mock_provider) + + assert drive.policies_fetched is True + # Customer-level values should be stored + assert drive.policies.external_sharing_mode == "ALLOWLISTED_DOMAINS" + assert drive.policies.warn_for_external_sharing is True + assert drive.policies.allow_shared_drive_creation is True + # OU/group-level values (ALLOWED, False, False) should NOT have overwritten diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed_test.py new file mode 100644 index 0000000000..6ad0222f06 --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed_test.py @@ -0,0 +1,123 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveSharedDriveCreationAllowed: + def test_pass_creation_allowed(self): + """Test PASS when users are allowed to create shared drives""" + 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.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed import ( + drive_shared_drive_creation_allowed, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_shared_drive_creation=True) + + check = drive_shared_drive_creation_allowed() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "allowed" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_creation_disabled(self): + """Test FAIL when users are prevented from creating shared drives""" + 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.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed import ( + drive_shared_drive_creation_allowed, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allow_shared_drive_creation=False + ) + + check = drive_shared_drive_creation_allowed() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "prevented" in findings[0].status_extended + + def test_pass_using_default(self): + """Test PASS when no explicit policy is set (None) — Google default is secure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed import ( + drive_shared_drive_creation_allowed, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_shared_drive_creation=None) + + check = drive_shared_drive_creation_allowed() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed import ( + drive_shared_drive_creation_allowed, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_shared_drive_creation_allowed() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy_test.py new file mode 100644 index 0000000000..4a7a926745 --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy_test.py @@ -0,0 +1,157 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveSharedDriveDisableDownloadPrintCopy: + def test_pass_editors_only(self): + """Test PASS when download/print/copy is restricted to editors only""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import ( + drive_shared_drive_disable_download_print_copy, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_download_print_copy="EDITORS_ONLY" + ) + + check = drive_shared_drive_disable_download_print_copy() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "EDITORS_ONLY" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_managers_only(self): + """Test PASS when download/print/copy is restricted to managers only""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import ( + drive_shared_drive_disable_download_print_copy, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_download_print_copy="MANAGERS_ONLY" + ) + + check = drive_shared_drive_disable_download_print_copy() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "MANAGERS_ONLY" in findings[0].status_extended + + def test_fail_all_allowed(self): + """Test FAIL when all users (including viewers/commenters) can download/print/copy""" + 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.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import ( + drive_shared_drive_disable_download_print_copy, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_download_print_copy="ALL" + ) + + check = drive_shared_drive_disable_download_print_copy() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ALL" in findings[0].status_extended + + def test_pass_using_default(self): + """Test PASS when no explicit policy is set (None) — Google default is secure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import ( + drive_shared_drive_disable_download_print_copy, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allowed_parties_for_download_print_copy=None + ) + + check = drive_shared_drive_disable_download_print_copy() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import ( + drive_shared_drive_disable_download_print_copy, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_shared_drive_disable_download_print_copy() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override_test.py new file mode 100644 index 0000000000..99e4af7d16 --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveSharedDriveManagersCannotOverride: + def test_pass_override_disabled(self): + """Test PASS when managers cannot override shared drive settings""" + 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.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import ( + drive_shared_drive_managers_cannot_override, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allow_managers_to_override_settings=False + ) + + check = drive_shared_drive_managers_cannot_override() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "cannot override" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_override_allowed(self): + """Test FAIL when managers can override shared drive settings""" + 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.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import ( + drive_shared_drive_managers_cannot_override, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allow_managers_to_override_settings=True + ) + + check = drive_shared_drive_managers_cannot_override() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "allowed to override" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import ( + drive_shared_drive_managers_cannot_override, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + allow_managers_to_override_settings=None + ) + + check = drive_shared_drive_managers_cannot_override() + 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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import ( + drive_shared_drive_managers_cannot_override, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_shared_drive_managers_cannot_override() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access_test.py new file mode 100644 index 0000000000..b25fa33cf0 --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access_test.py @@ -0,0 +1,121 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveSharedDriveMembersOnlyAccess: + def test_pass_non_member_access_disabled(self): + """Test PASS when non-member access to shared drive files is disabled""" + 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.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import ( + drive_shared_drive_members_only_access, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_non_member_access=False) + + check = drive_shared_drive_members_only_access() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "members only" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_non_member_access_enabled(self): + """Test FAIL when non-members can be added to shared drive files""" + 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.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import ( + drive_shared_drive_members_only_access, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_non_member_access=True) + + check = drive_shared_drive_members_only_access() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "non-members" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import ( + drive_shared_drive_members_only_access, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(allow_non_member_access=None) + + check = drive_shared_drive_members_only_access() + 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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import ( + drive_shared_drive_members_only_access, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_shared_drive_members_only_access() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains_test.py b/tests/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains_test.py new file mode 100644 index 0000000000..337afdc9aa --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveSharingAllowlistedDomains: + def test_pass_allowlisted_domains(self): + """Test PASS when external sharing is restricted to allowlisted domains""" + 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.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains import ( + drive_sharing_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + external_sharing_mode="ALLOWLISTED_DOMAINS" + ) + + check = drive_sharing_allowlisted_domains() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "allowlisted domains" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_allowed(self): + """Test FAIL when external sharing is set to ALLOWED (unrestricted)""" + 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.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains import ( + drive_sharing_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(external_sharing_mode="ALLOWED") + + check = drive_sharing_allowlisted_domains() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ALLOWED" in findings[0].status_extended + + def test_fail_disallowed(self): + """Test FAIL when external sharing is DISALLOWED (stricter than allowlist but not the expected value)""" + 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.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains import ( + drive_sharing_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + external_sharing_mode="DISALLOWED" + ) + + check = drive_sharing_allowlisted_domains() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "DISALLOWED" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) but fetch succeeded""" + 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.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains import ( + drive_sharing_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies(external_sharing_mode=None) + + check = drive_sharing_allowlisted_domains() + 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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains import ( + drive_sharing_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_sharing_allowlisted_domains() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains_test.py b/tests/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains_test.py new file mode 100644 index 0000000000..1aadeb924b --- /dev/null +++ b/tests/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestDriveWarnSharingWithAllowlistedDomains: + def test_pass_warning_enabled(self): + """Test PASS when warning for sharing with allowlisted domains is enabled""" + 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.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains import ( + drive_warn_sharing_with_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + warn_for_sharing_outside_allowlisted_domains=True + ) + + check = drive_warn_sharing_with_allowlisted_domains() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "warned" in findings[0].status_extended + assert findings[0].resource_name == "Drive Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_warning_disabled(self): + """Test FAIL when warning for sharing with allowlisted domains is disabled""" + 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.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains import ( + drive_warn_sharing_with_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + warn_for_sharing_outside_allowlisted_domains=False + ) + + check = drive_warn_sharing_with_allowlisted_domains() + 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): + """Test PASS when no explicit policy is set (None) — Google default is secure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains import ( + drive_warn_sharing_with_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = True + mock_drive_client.policies = DrivePolicies( + warn_for_sharing_outside_allowlisted_domains=None + ) + + check = drive_warn_sharing_with_allowlisted_domains() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains.drive_client" + ) as mock_drive_client, + ): + from prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains import ( + drive_warn_sharing_with_allowlisted_domains, + ) + + mock_drive_client.provider = mock_provider + mock_drive_client.policies_fetched = False + mock_drive_client.policies = DrivePolicies() + + check = drive_warn_sharing_with_allowlisted_domains() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled_test.py new file mode 100644 index 0000000000..a57bee1ff1 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailAnomalousAttachmentProtectionEnabled: + 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_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import ( + gmail_anomalous_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_anomalous_attachment_protection=True, + anomalous_attachment_protection_consequence="WARNING", + ) + + check = gmail_anomalous_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "WARNING" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_action(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_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import ( + gmail_anomalous_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_anomalous_attachment_protection=True, + anomalous_attachment_protection_consequence="NO_ACTION", + ) + + check = gmail_anomalous_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import ( + gmail_anomalous_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_anomalous_attachment_protection=False, + anomalous_attachment_protection_consequence="WARNING", + ) + + check = gmail_anomalous_attachment_protection_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_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import ( + gmail_anomalous_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_anomalous_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "insecure 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_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import ( + gmail_anomalous_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_anomalous_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 0 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..2de8a79b9b --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py @@ -0,0 +1,122 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + assert findings[0].resource_id == "gmailPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == GmailPolicies(enable_auto_forwarding=False).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/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..1dde307b71 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py @@ -0,0 +1,123 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py new file mode 100644 index 0000000000..de18c6d9c6 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailDomainSpoofingProtectionEnabled: + 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_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import ( + gmail_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_domain_name_spoofing=True, + domain_spoofing_consequence="SPAM_FOLDER", + ) + + check = gmail_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "SPAM_FOLDER" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_action(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_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import ( + gmail_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_domain_name_spoofing=True, + domain_spoofing_consequence="NO_ACTION", + ) + + check = gmail_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import ( + gmail_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_domain_name_spoofing=False, + domain_spoofing_consequence="WARNING", + ) + + check = gmail_domain_spoofing_protection_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_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import ( + gmail_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_domain_spoofing_protection_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_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import ( + gmail_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py new file mode 100644 index 0000000000..d30284b852 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailEmployeeNameSpoofingProtectionEnabled: + 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_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import ( + gmail_employee_name_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_employee_name_spoofing=True, + employee_name_spoofing_consequence="SPAM_FOLDER", + ) + + check = gmail_employee_name_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "SPAM_FOLDER" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_action(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_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import ( + gmail_employee_name_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_employee_name_spoofing=True, + employee_name_spoofing_consequence="NO_ACTION", + ) + + check = gmail_employee_name_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import ( + gmail_employee_name_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_employee_name_spoofing=False, + employee_name_spoofing_consequence="WARNING", + ) + + check = gmail_employee_name_spoofing_protection_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_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import ( + gmail_employee_name_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_employee_name_spoofing_protection_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_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import ( + gmail_employee_name_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_employee_name_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled_test.py new file mode 100644 index 0000000000..cc41ff3f66 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailEncryptedAttachmentProtectionEnabled: + 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_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import ( + gmail_encrypted_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_encrypted_attachment_protection=True, + encrypted_attachment_protection_consequence="QUARANTINE", + ) + + check = gmail_encrypted_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "QUARANTINE" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_action(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_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import ( + gmail_encrypted_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_encrypted_attachment_protection=True, + encrypted_attachment_protection_consequence="NO_ACTION", + ) + + check = gmail_encrypted_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import ( + gmail_encrypted_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_encrypted_attachment_protection=False, + encrypted_attachment_protection_consequence="WARNING", + ) + + check = gmail_encrypted_attachment_protection_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_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import ( + gmail_encrypted_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_encrypted_attachment_protection_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_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import ( + gmail_encrypted_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_encrypted_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 0 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..823f7ee899 --- /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,123 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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/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..17b0bf7624 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py @@ -0,0 +1,117 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py new file mode 100644 index 0000000000..b06092ebe5 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py @@ -0,0 +1,186 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailGroupsSpoofingProtectionEnabled: + 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_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import ( + gmail_groups_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_groups_spoofing=True, + groups_spoofing_consequence="SPAM_FOLDER", + ) + + check = gmail_groups_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "all groups" in findings[0].status_extended + assert "SPAM_FOLDER" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_private_groups_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_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import ( + gmail_groups_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_groups_spoofing=True, + groups_spoofing_visibility_type="PRIVATE_GROUPS_ONLY", + groups_spoofing_consequence="SPAM_FOLDER", + ) + + check = gmail_groups_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "private groups only" in findings[0].status_extended + assert "SPAM_FOLDER" in findings[0].status_extended + + def test_fail_no_action(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_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import ( + gmail_groups_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_groups_spoofing=True, + groups_spoofing_consequence="NO_ACTION", + ) + + check = gmail_groups_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import ( + gmail_groups_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_groups_spoofing=False, + groups_spoofing_consequence="WARNING", + ) + + check = gmail_groups_spoofing_protection_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_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import ( + gmail_groups_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_groups_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "insecure 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_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import ( + gmail_groups_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_groups_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py new file mode 100644 index 0000000000..b319b8fdb1 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailInboundDomainSpoofingProtectionEnabled: + 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_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import ( + gmail_inbound_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_inbound_domain_spoofing=True, + inbound_domain_spoofing_consequence="QUARANTINE", + ) + + check = gmail_inbound_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "QUARANTINE" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_action(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_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import ( + gmail_inbound_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_inbound_domain_spoofing=True, + inbound_domain_spoofing_consequence="NO_ACTION", + ) + + check = gmail_inbound_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import ( + gmail_inbound_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_inbound_domain_spoofing=False, + inbound_domain_spoofing_consequence="WARNING", + ) + + check = gmail_inbound_domain_spoofing_protection_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_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import ( + gmail_inbound_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_inbound_domain_spoofing_protection_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_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import ( + gmail_inbound_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_inbound_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 0 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..e471d030ff --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py @@ -0,0 +1,117 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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/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..2fe105df42 --- /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,117 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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/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..d5f87ac2c8 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py @@ -0,0 +1,182 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled_test.py new file mode 100644 index 0000000000..9f04178457 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailScriptAttachmentProtectionEnabled: + 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_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import ( + gmail_script_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_script_attachment_protection=True, + script_attachment_protection_consequence="QUARANTINE", + ) + + check = gmail_script_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "QUARANTINE" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_action(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_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import ( + gmail_script_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_script_attachment_protection=True, + script_attachment_protection_consequence="NO_ACTION", + ) + + check = gmail_script_attachment_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import ( + gmail_script_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + enable_script_attachment_protection=False, + script_attachment_protection_consequence="WARNING", + ) + + check = gmail_script_attachment_protection_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_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import ( + gmail_script_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_script_attachment_protection_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_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import ( + gmail_script_attachment_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_script_attachment_protection_enabled() + 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..cdfa29ed14 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_service_test.py @@ -0,0 +1,519 @@ +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": { + "enableEncryptedAttachmentProtection": True, + "encryptedAttachmentProtectionConsequence": "SPAM_FOLDER", + "enableAttachmentWithScriptsProtection": True, + "scriptAttachmentProtectionConsequence": "QUARANTINE", + "enableAnomalousAttachmentProtection": True, + "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": { + "detectDomainNameSpoofing": True, + "domainSpoofingConsequence": "SPAM_FOLDER", + "detectEmployeeNameSpoofing": True, + "employeeNameSpoofingConsequence": "SPAM_FOLDER", + "detectDomainSpoofingFromUnauthenticatedSenders": True, + "inboundDomainSpoofingConsequence": "QUARANTINE", + "detectUnauthenticatedEmails": True, + "unauthenticatedEmailConsequence": "WARNING", + "detectGroupsSpoofing": True, + "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.enable_encrypted_attachment_protection is True + assert ( + gmail.policies.encrypted_attachment_protection_consequence + == "SPAM_FOLDER" + ) + assert gmail.policies.enable_script_attachment_protection is True + assert ( + gmail.policies.script_attachment_protection_consequence == "QUARANTINE" + ) + assert gmail.policies.enable_anomalous_attachment_protection is True + 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.detect_domain_name_spoofing is True + assert gmail.policies.domain_spoofing_consequence == "SPAM_FOLDER" + assert gmail.policies.detect_employee_name_spoofing is True + assert gmail.policies.employee_name_spoofing_consequence == "SPAM_FOLDER" + assert gmail.policies.detect_inbound_domain_spoofing is True + assert gmail.policies.inbound_domain_spoofing_consequence == "QUARANTINE" + assert gmail.policies.detect_unauthenticated_emails is True + assert gmail.policies.unauthenticated_email_consequence == "WARNING" + assert gmail.policies.detect_groups_spoofing is True + 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, + enable_encrypted_attachment_protection=True, + encrypted_attachment_protection_consequence="SPAM_FOLDER", + enable_script_attachment_protection=True, + script_attachment_protection_consequence="QUARANTINE", + enable_anomalous_attachment_protection=True, + anomalous_attachment_protection_consequence="WARNING", + enable_shortener_scanning=True, + enable_external_image_scanning=True, + enable_aggressive_warnings_on_untrusted_links=True, + detect_domain_name_spoofing=True, + domain_spoofing_consequence="SPAM_FOLDER", + detect_employee_name_spoofing=True, + employee_name_spoofing_consequence="SPAM_FOLDER", + detect_inbound_domain_spoofing=True, + inbound_domain_spoofing_consequence="QUARANTINE", + detect_unauthenticated_emails=True, + unauthenticated_email_consequence="WARNING", + detect_groups_spoofing=True, + 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.enable_encrypted_attachment_protection is True + assert policies.encrypted_attachment_protection_consequence == "SPAM_FOLDER" + assert policies.enable_shortener_scanning is True + assert policies.detect_domain_name_spoofing 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/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..11fbe29098 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py @@ -0,0 +1,117 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled_test.py new file mode 100644 index 0000000000..3a8fd0d337 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled_test.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGmailUnauthenticatedEmailProtectionEnabled: + 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_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import ( + gmail_unauthenticated_email_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_unauthenticated_emails=True, + unauthenticated_email_consequence="WARNING", + ) + + check = gmail_unauthenticated_email_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "WARNING" in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_action(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_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import ( + gmail_unauthenticated_email_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_unauthenticated_emails=True, + unauthenticated_email_consequence="NO_ACTION", + ) + + check = gmail_unauthenticated_email_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "no action" in findings[0].status_extended + + def test_fail_protection_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_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import ( + gmail_unauthenticated_email_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_unauthenticated_emails=False, + unauthenticated_email_consequence="WARNING", + ) + + check = gmail_unauthenticated_email_protection_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_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import ( + gmail_unauthenticated_email_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies() + + check = gmail_unauthenticated_email_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "insecure 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_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import ( + gmail_unauthenticated_email_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GmailPolicies() + + check = gmail_unauthenticated_email_protection_enabled() + findings = check.execute() + + assert len(findings) == 0 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..70fc539836 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py @@ -0,0 +1,123 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + 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 == "Gmail Policies" + 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_fail_insecure_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 == "FAIL" + assert "insecure 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/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py new file mode 100644 index 0000000000..b91457f7b0 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py @@ -0,0 +1,271 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGroupsCreationRestricted: + def test_pass_all_restricted(self): + """Test PASS when all creation settings are secure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly restricted" in findings[0].status_extended + assert findings[0].resource_name == "Groups Policies" + assert findings[0].resource_id == "groupsPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + ).dict() + ) + + def test_fail_users_in_domain(self): + """Test FAIL when anyone in org can create groups""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="USERS_IN_DOMAIN", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "USERS_IN_DOMAIN" in findings[0].status_extended + + def test_fail_external_members_allowed(self): + """Test FAIL when external members are allowed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=True, + owners_can_allow_incoming_mail_from_public=False, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "external members" in findings[0].status_extended + + def test_fail_incoming_mail_allowed(self): + """Test FAIL when incoming email from outside is allowed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=True, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "incoming email" in findings[0].status_extended + + def test_fail_all_defaults_none(self): + """Test FAIL when all settings are None — only USERS_IN_DOMAIN default is insecure""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies() + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + # Only creation access level has an insecure default (USERS_IN_DOMAIN) + assert "ADMIN_ONLY" in findings[0].status_extended + assert "incoming email" not in findings[0].status_extended + assert "external members" not in findings[0].status_extended + + def test_pass_admin_only_others_none(self): + """Test PASS when creation is ADMIN_ONLY and boolean settings are None (secure defaults)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ADMIN_ONLY", + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly restricted" in findings[0].status_extended + + def test_fail_multiple_issues(self): + """Test FAIL with all three sub-settings non-compliant""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + create_groups_access_level="ANYONE_CAN_CREATE", + owners_can_allow_external_members=True, + owners_can_allow_incoming_mail_from_public=True, + ) + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ANYONE_CAN_CREATE" in findings[0].status_extended + assert "external members" in findings[0].status_extended + assert "incoming email" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import ( + groups_creation_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GroupsPolicies() + + check = groups_creation_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py new file mode 100644 index 0000000000..ec25683728 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py @@ -0,0 +1,132 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGroupsExternalAccessRestricted: + def test_pass_domain_users_only(self): + """Test PASS when external access is set to DOMAIN_USERS_ONLY""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + collaboration_capability="DOMAIN_USERS_ONLY" + ) + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "private" in findings[0].status_extended + assert findings[0].resource_name == "Groups Policies" + assert findings[0].resource_id == "groupsPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == GroupsPolicies(collaboration_capability="DOMAIN_USERS_ONLY").dict() + ) + + def test_fail_anyone_can_access(self): + """Test FAIL when external access is set to ANYONE_CAN_ACCESS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + collaboration_capability="ANYONE_CAN_ACCESS" + ) + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ANYONE_CAN_ACCESS" in findings[0].status_extended + + def test_pass_no_policy_set(self): + """Test PASS when no explicit policy is set (None) - Google default is DOMAIN_USERS_ONLY (secure)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies(collaboration_capability=None) + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import ( + groups_external_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GroupsPolicies() + + check = groups_external_access_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/groups/groups_service_test.py b/tests/providers/googleworkspace/services/groups/groups_service_test.py new file mode 100644 index 0000000000..86b031a8a2 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_service_test.py @@ -0,0 +1,287 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestGroupsService: + def test_fetch_policies_all_settings(self): + """Test fetching all Groups for Business policy settings from Cloud Identity API""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_credentials = MagicMock() + mock_session = MagicMock() + mock_session.credentials = mock_credentials + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "DOMAIN_USERS_ONLY", + "createGroupsAccessLevel": "ADMIN_ONLY", + "ownersCanAllowExternalMembers": False, + "ownersCanAllowIncomingMailFromPublic": False, + "viewTopicsDefaultAccessLevel": "GROUP_MEMBERS", + "ownersCanHideGroups": False, + "newGroupsAreHidden": False, + }, + } + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is True + assert groups.policies.collaboration_capability == "DOMAIN_USERS_ONLY" + assert groups.policies.create_groups_access_level == "ADMIN_ONLY" + assert groups.policies.owners_can_allow_external_members is False + assert groups.policies.owners_can_allow_incoming_mail_from_public is False + assert groups.policies.view_topics_default_access_level == "GROUP_MEMBERS" + assert groups.policies.owners_can_hide_groups is False + assert groups.policies.new_groups_are_hidden is False + + def test_fetch_policies_empty_response(self): + """Test handling empty policies response""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is True + assert groups.policies.collaboration_capability is None + assert groups.policies.create_groups_access_level is None + assert groups.policies.owners_can_allow_external_members is None + assert groups.policies.owners_can_allow_incoming_mail_from_public is None + assert groups.policies.view_topics_default_access_level is None + + def test_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is False + assert groups.policies.collaboration_capability is None + + def test_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails to construct the client""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is False + assert groups.policies.collaboration_capability is None + + def test_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is False + assert groups.policies.collaboration_capability is None + + def test_fetch_policies_ignores_ou_and_group_level(self): + """Test that OU-level and group-level policies are skipped, only customer-level used""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + # Customer-level: no policyQuery → should be used + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "DOMAIN_USERS_ONLY", + "createGroupsAccessLevel": "ADMIN_ONLY", + }, + } + }, + { + # OU-level: has policyQuery.orgUnit → should be skipped + "policyQuery": {"orgUnit": "orgUnits/sales_team"}, + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "ANYONE_CAN_ACCESS", + }, + }, + }, + { + # Group-level: has policyQuery.group → should be skipped + "policyQuery": {"group": "groups/contractors"}, + "setting": { + "type": "settings/groups_for_business.groups_sharing", + "value": { + "collaborationCapability": "ANYONE_CAN_ACCESS", + }, + }, + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.groups.groups_service import ( + Groups, + ) + + groups = Groups(mock_provider) + + assert groups.policies_fetched is True + assert groups.policies.collaboration_capability == "DOMAIN_USERS_ONLY" + assert groups.policies.create_groups_access_level == "ADMIN_ONLY" + + def test_policies_model(self): + """Test GroupsPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, + ) + + policies = GroupsPolicies( + collaboration_capability="DOMAIN_USERS_ONLY", + create_groups_access_level="ADMIN_ONLY", + owners_can_allow_external_members=False, + owners_can_allow_incoming_mail_from_public=False, + view_topics_default_access_level="GROUP_MEMBERS", + owners_can_hide_groups=False, + new_groups_are_hidden=False, + ) + + assert policies.collaboration_capability == "DOMAIN_USERS_ONLY" + assert policies.create_groups_access_level == "ADMIN_ONLY" + assert policies.owners_can_allow_external_members is False + assert policies.owners_can_allow_incoming_mail_from_public is False + assert policies.view_topics_default_access_level == "GROUP_MEMBERS" + assert policies.owners_can_hide_groups is False + assert policies.new_groups_are_hidden is False diff --git a/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py new file mode 100644 index 0000000000..761073cc89 --- /dev/null +++ b/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py @@ -0,0 +1,165 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.groups.groups_service import ( + GroupsPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestGroupsViewConversationsRestricted: + def test_pass_group_members(self): + """Test PASS when view conversations is set to GROUP_MEMBERS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + view_topics_default_access_level="GROUP_MEMBERS" + ) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "group members" in findings[0].status_extended + assert findings[0].resource_name == "Groups Policies" + assert findings[0].resource_id == "groupsPolicies" + assert findings[0].customer_id == CUSTOMER_ID + assert ( + findings[0].resource + == GroupsPolicies( + view_topics_default_access_level="GROUP_MEMBERS" + ).dict() + ) + + def test_fail_domain_users(self): + """Test FAIL when view conversations is set to DOMAIN_USERS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + view_topics_default_access_level="DOMAIN_USERS" + ) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "DOMAIN_USERS" in findings[0].status_extended + + def test_fail_anyone_can_view(self): + """Test FAIL when view conversations is set to ANYONE_CAN_VIEW_TOPICS""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies( + view_topics_default_access_level="ANYONE_CAN_VIEW_TOPICS" + ) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ANYONE_CAN_VIEW_TOPICS" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) - Google default is DOMAIN_USERS (insecure)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GroupsPolicies(view_topics_default_access_level=None) + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "default" in findings[0].status_extended + assert "all organization users" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import ( + groups_view_conversations_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = GroupsPolicies() + + check = groups_view_conversations_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted_test.py b/tests/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted_test.py new file mode 100644 index 0000000000..8da2a4326d --- /dev/null +++ b/tests/providers/googleworkspace/services/marketplace/marketplace_apps_access_restricted/marketplace_apps_access_restricted_test.py @@ -0,0 +1,152 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + MarketplacePolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestMarketplaceAppsAccessRestricted: + def test_pass_allow_listed_apps(self): + """Test PASS when Marketplace access is restricted to admin-approved apps""" + 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.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted.marketplace_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted import ( + marketplace_apps_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = MarketplacePolicies(access_level="ALLOW_LISTED_APPS") + + check = marketplace_apps_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "restricted" in findings[0].status_extended + assert findings[0].resource_name == "Marketplace Policies" + assert findings[0].resource_id == "marketplacePolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_allow_none(self): + """Test PASS when Marketplace app installation is fully blocked""" + 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.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted.marketplace_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted import ( + marketplace_apps_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = MarketplacePolicies(access_level="ALLOW_NONE") + + check = marketplace_apps_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "blocked" in findings[0].status_extended + + def test_fail_allow_all(self): + """Test FAIL when Marketplace allows all apps""" + 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.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted.marketplace_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted import ( + marketplace_apps_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = MarketplacePolicies(access_level="ALLOW_ALL") + + check = marketplace_apps_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "any app" in findings[0].status_extended + + def test_fail_no_policy_set(self): + """Test FAIL when no explicit policy is set (None) - Google default is ALLOW_ALL (insecure)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted.marketplace_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted import ( + marketplace_apps_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = MarketplacePolicies(access_level=None) + + check = marketplace_apps_access_restricted() + 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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted.marketplace_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_apps_access_restricted.marketplace_apps_access_restricted import ( + marketplace_apps_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = MarketplacePolicies() + + check = marketplace_apps_access_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/marketplace/marketplace_service_test.py b/tests/providers/googleworkspace/services/marketplace/marketplace_service_test.py new file mode 100644 index 0000000000..8c549cd0d7 --- /dev/null +++ b/tests/providers/googleworkspace/services/marketplace/marketplace_service_test.py @@ -0,0 +1,279 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestMarketplaceService: + def test_fetch_policies_allow_all(self): + """Test fetching Marketplace policy with ALLOW_ALL access level""" + 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/workspace_marketplace.apps_access_options", + "value": { + "accessLevel": "ALLOW_ALL", + }, + } + }, + ] + } + 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.marketplace.marketplace_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, + ) + + marketplace = Marketplace(mock_provider) + + assert marketplace.policies_fetched is True + assert marketplace.policies.access_level == "ALLOW_ALL" + + def test_fetch_policies_allow_listed_apps(self): + """Test fetching Marketplace policy with ALLOW_LISTED_APPS access 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": [ + { + "setting": { + "type": "settings/workspace_marketplace.apps_access_options", + "value": { + "accessLevel": "ALLOW_LISTED_APPS", + }, + } + }, + ] + } + 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.marketplace.marketplace_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, + ) + + marketplace = Marketplace(mock_provider) + + assert marketplace.policies_fetched is True + assert marketplace.policies.access_level == "ALLOW_LISTED_APPS" + + def test_fetch_policies_allow_none(self): + """Test fetching Marketplace policy with ALLOW_NONE access 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": [ + { + "setting": { + "type": "settings/workspace_marketplace.apps_access_options", + "value": { + "accessLevel": "ALLOW_NONE", + }, + } + }, + ] + } + 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.marketplace.marketplace_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, + ) + + marketplace = Marketplace(mock_provider) + + assert marketplace.policies_fetched is True + assert marketplace.policies.access_level == "ALLOW_NONE" + + def test_fetch_policies_empty_response(self): + """Test handling empty policies response""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.marketplace.marketplace_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, + ) + + marketplace = Marketplace(mock_provider) + + assert marketplace.policies_fetched is True + assert marketplace.policies.access_level is None + + def test_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.marketplace.marketplace_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, + ) + + marketplace = Marketplace(mock_provider) + + assert marketplace.policies_fetched is False + assert marketplace.policies.access_level is None + + def test_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails to construct the client""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.marketplace.marketplace_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, + ) + + marketplace = Marketplace(mock_provider) + + assert marketplace.policies_fetched is False + assert marketplace.policies.access_level is None + + def test_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.marketplace.marketplace_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + Marketplace, + ) + + marketplace = Marketplace(mock_provider) + + assert marketplace.policies_fetched is False + assert marketplace.policies.access_level is None + + def test_marketplace_policies_model(self): + """Test MarketplacePolicies Pydantic model""" + from prowler.providers.googleworkspace.services.marketplace.marketplace_service import ( + MarketplacePolicies, + ) + + policies = MarketplacePolicies(access_level="ALLOW_LISTED_APPS") + + assert policies.access_level == "ALLOW_LISTED_APPS" diff --git a/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py new file mode 100644 index 0000000000..cf1a6128c4 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "User granted Admin privilege" + + +class TestRulesAdminPrivilegeGrantedAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py new file mode 100644 index 0000000000..f907b2f89e --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Gmail potential employee spoofing" + + +class TestRulesGmailEmployeeSpoofingAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py new file mode 100644 index 0000000000..90ec845258 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Government-backed attacks" + + +class TestRulesGovernmentBackedAttacksAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py new file mode 100644 index 0000000000..ace6c29d13 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Leaked password" + + +class TestRulesLeakedPasswordAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py new file mode 100644 index 0000000000..db1b8056e7 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "User's password changed" + + +class TestRulesPasswordChangedAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_service_test.py b/tests/providers/googleworkspace/services/rules/rules_service_test.py new file mode 100644 index 0000000000..6368df4bcb --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_service_test.py @@ -0,0 +1,323 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestRulesService: + def test_fetch_fully_configured_rule(self): + """Test fetching a system-defined alert rule with all 3 conditions met.""" + 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": [ + { + "setting": { + "type": "settings/rule.system_defined_alerts", + "value": { + "displayName": "Suspicious login", + "description": "Google detected a suspicious sign-in.", + "action": { + "alertCenterAction": { + "recipients": [{"allSuperAdmins": True}], + "alertCenterConfig": {"severity": "LOW"}, + } + }, + "state": "ACTIVE", + }, + } + }, + ] + } + 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.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is True + assert len(rules.system_defined_alerts) == 8 + + suspicious_login = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Suspicious login" + ) + assert suspicious_login.state == "ACTIVE" + assert suspicious_login.email_notifications_enabled is True + assert suspicious_login.all_super_admins is True + assert suspicious_login.severity == "LOW" + + def test_fetch_rule_without_email_notifications(self): + """Test a rule that is ACTIVE but has no email recipients configured.""" + 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": [ + { + "setting": { + "type": "settings/rule.system_defined_alerts", + "value": { + "displayName": "Government-backed attacks", + "description": "Google believes a user is targeted.", + "action": { + "alertCenterAction": { + "alertCenterConfig": {"severity": "HIGH"} + } + }, + "state": "ACTIVE", + }, + } + }, + ] + } + 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.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + gov_attack = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Government-backed attacks" + ) + assert gov_attack.state == "ACTIVE" + assert gov_attack.email_notifications_enabled is False + assert gov_attack.all_super_admins is False + assert gov_attack.severity == "HIGH" + + def test_empty_response_fills_defaults(self): + """Test that all 8 rules get default values when API returns nothing.""" + 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.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is True + assert len(rules.system_defined_alerts) == 8 + + # All defaults should have no severity (not returned by API) + for alert in rules.system_defined_alerts: + assert alert.severity is None + + # INACTIVE defaults: no email notifications + password_changed = next( + a + for a in rules.system_defined_alerts + if a.display_name == "User's password changed" + ) + assert password_changed.state == "INACTIVE" + assert password_changed.email_notifications_enabled is False + assert password_changed.all_super_admins is False + + admin_privilege = next( + a + for a in rules.system_defined_alerts + if a.display_name == "User granted Admin privilege" + ) + assert admin_privilege.state == "INACTIVE" + assert admin_privilege.email_notifications_enabled is False + assert admin_privilege.all_super_admins is False + + # ACTIVE defaults: email notifications ON with all super admins + suspicious_login = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Suspicious login" + ) + assert suspicious_login.state == "ACTIVE" + assert suspicious_login.email_notifications_enabled is True + assert suspicious_login.all_super_admins is True + + gov_attacks = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Government-backed attacks" + ) + assert gov_attacks.state == "ACTIVE" + assert gov_attacks.email_notifications_enabled is True + assert gov_attacks.all_super_admins is True + + def test_api_error_sets_policies_fetched_false(self): + """Test that API errors result in policies_fetched being False.""" + 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.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is False + + def test_build_service_returns_none(self): + """Test early return when _build_service fails.""" + 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.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is False + assert len(rules.system_defined_alerts) == 0 + + def test_non_cis_rules_are_ignored(self): + """Test that system-defined rules not in the 8 CIS rules are ignored.""" + 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": [ + { + "setting": { + "type": "settings/rule.system_defined_alerts", + "value": { + "displayName": "Device compromised", + "description": "A device has been compromised.", + "state": "ACTIVE", + }, + } + }, + ] + } + 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.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert len(rules.system_defined_alerts) == 8 + names = {a.display_name for a in rules.system_defined_alerts} + assert "Device compromised" not in names + + def test_system_defined_alert_model(self): + """Test SystemDefinedAlert Pydantic model defaults.""" + from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, + ) + + alert = SystemDefinedAlert(display_name="Test rule") + assert alert.state == "INACTIVE" + assert alert.severity is None + assert alert.email_notifications_enabled is False + assert alert.all_super_admins is False diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py new file mode 100644 index 0000000000..acd60756f0 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "User suspended due to suspicious activity" + + +class TestRulesSuspiciousActivitySuspensionAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py new file mode 100644 index 0000000000..aab70797dc --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Suspicious login" + + +class TestRulesSuspiciousLoginAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py new file mode 100644 index 0000000000..f1596ace67 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Suspicious programmatic login" + + +class TestRulesSuspiciousProgrammaticLoginAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + 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.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + 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.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + 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.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + 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.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py new file mode 100644 index 0000000000..13335e22cb --- /dev/null +++ b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py @@ -0,0 +1,499 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestSecurityService: + def test_fetch_policies_all_security_settings(self): + """Test fetching security policies 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() + + # First call: security.* settings + mock_security_list = MagicMock() + mock_security_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/security.two_step_verification_enrollment", + "value": { + "allowEnrollment": True, + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_enforcement", + "value": { + "enforcedFrom": "2026-05-25T15:27:52.352Z", + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_enforcement_factor", + "value": { + "allowedSignInFactorSet": "ALL", + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_device_trust", + "value": { + "allowTrustingDevice": True, + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_grace_period", + "value": { + "enrollmentGracePeriod": "0s", + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_sign_in_code", + "value": { + "backupCodeExceptionPeriod": "86400s", + }, + } + }, + { + "setting": { + "type": "settings/security.super_admin_account_recovery", + "value": { + "enableAccountRecovery": True, + }, + } + }, + { + "setting": { + "type": "settings/security.user_account_recovery", + "value": { + "enableAccountRecovery": False, + }, + } + }, + { + "setting": { + "type": "settings/security.password", + "value": { + "allowedStrength": "STRONG", + "minimumLength": 8, + "maximumLength": 100, + "enforceRequirementsAtLogin": False, + "allowReuse": False, + "expirationDuration": "0s", + }, + } + }, + { + "setting": { + "type": "settings/security.session_controls", + "value": { + "webSessionDuration": "1209600s", + }, + } + }, + { + "setting": { + "type": "settings/security.less_secure_apps", + "value": { + "allowLessSecureApps": False, + }, + } + }, + { + "setting": { + "type": "settings/security.advanced_protection_program", + "value": { + "enableAdvancedProtectionSelfEnrollment": True, + "securityCodeOption": "CODES_NOT_ALLOWED", + }, + } + }, + { + "setting": { + "type": "settings/security.login_challenges", + "value": { + "enableEmployeeIdChallenge": False, + }, + } + }, + { + "setting": { + "type": "settings/security.passkeys_restriction", + "value": { + "allowedPasskeysType": "ANY_DEVICE_OR_PLATFORM", + }, + } + }, + ] + } + + # Second call: api_controls.* settings + mock_api_controls_list = MagicMock() + mock_api_controls_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/api_controls.internal_apps", + "value": { + "trustInternalApps": True, + }, + } + }, + { + "setting": { + "type": "settings/api_controls.google_services", + "value": { + "services": [ + {"scopesGroup": "DRIVE_ALL", "isEnabled": True}, + {"scopesGroup": "GMAIL_HIGH_RISK", "isEnabled": False}, + ], + }, + } + }, + ] + } + + # Third call: rule.dlp + mock_dlp_list = MagicMock() + mock_dlp_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/rule.dlp", + "value": { + "displayName": "PII Detection", + "triggers": ["google.workspace.drive.file.v1.share"], + "state": "ACTIVE", + }, + } + }, + ] + } + + mock_service.policies().list.side_effect = [ + mock_security_list, + mock_api_controls_list, + mock_dlp_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.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.two_sv_allow_enrollment is True + assert security.policies.two_sv_enforced_from == "2026-05-25T15:27:52.352Z" + assert security.policies.two_sv_allowed_factor_set == "ALL" + assert security.policies.two_sv_allow_trusting_device is True + assert security.policies.two_sv_enrollment_grace_period == "0s" + assert security.policies.two_sv_backup_code_exception_period == "86400s" + assert security.policies.super_admin_recovery_enabled is True + assert security.policies.user_recovery_enabled is False + assert security.policies.password_minimum_length == 8 + assert security.policies.password_allowed_strength == "STRONG" + assert security.policies.password_allow_reuse is False + assert security.policies.password_enforce_at_login is False + assert security.policies.password_expiration_duration == "0s" + assert security.policies.web_session_duration == "1209600s" + assert security.policies.less_secure_apps_allowed is False + assert security.policies.advanced_protection_enrollment is True + assert ( + security.policies.advanced_protection_security_code_option + == "CODES_NOT_ALLOWED" + ) + assert security.policies.login_challenge_employee_id is False + assert security.policies.passkeys_type == "ANY_DEVICE_OR_PLATFORM" + assert security.policies.trust_internal_apps is True + assert security.policies.google_services_restricted is True + assert security.policies.dlp_drive_rules_exist is True + + def test_fetch_policies_empty_response(self): + """Test handling empty policies response across all namespaces""" + 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_empty = MagicMock() + mock_empty.execute.return_value = {"policies": []} + mock_service.policies().list.side_effect = [ + mock_empty, + mock_empty, + mock_empty, + ] + 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.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.two_sv_enforced_from is None + assert security.policies.super_admin_recovery_enabled is None + assert security.policies.password_minimum_length is None + assert security.policies.web_session_duration is None + assert security.policies.less_secure_apps_allowed is None + assert security.policies.trust_internal_apps is None + assert security.policies.dlp_drive_rules_exist is None + + def test_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is False + + def test_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails""" + 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.security.security_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is False + + def test_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is False + + def test_fetch_policies_google_services_no_restricted(self): + """Test google_services with all services enabled sets restricted to False""" + 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_empty = MagicMock() + mock_empty.execute.return_value = {"policies": []} + + mock_api_list = MagicMock() + mock_api_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/api_controls.google_services", + "value": { + "services": [ + {"scopesGroup": "DRIVE_ALL", "isEnabled": True}, + {"scopesGroup": "GMAIL_ALL", "isEnabled": True}, + ], + }, + } + }, + ] + } + + mock_service.policies().list.side_effect = [ + mock_empty, + mock_api_list, + mock_empty, + ] + 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.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.google_services_restricted is False + + def test_dlp_rule_without_drive_trigger_ignored(self): + """Test that DLP rules without Drive triggers don't set dlp_drive_rules_exist""" + 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_empty = MagicMock() + mock_empty.execute.return_value = {"policies": []} + + mock_dlp_list = MagicMock() + mock_dlp_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/rule.dlp", + "value": { + "displayName": "Gmail Only Rule", + "triggers": ["google.workspace.gmail.email.v1.send"], + "state": "ACTIVE", + }, + } + }, + ] + } + + mock_service.policies().list.side_effect = [ + mock_empty, + mock_empty, + mock_dlp_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.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.dlp_drive_rules_exist is None + + def test_security_policies_model(self): + """Test SecurityPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, + ) + + policies = SecurityPolicies( + two_sv_enforced_from="2026-05-25T15:27:52.352Z", + super_admin_recovery_enabled=False, + password_minimum_length=14, + web_session_duration="43200s", + ) + + assert policies.two_sv_enforced_from == "2026-05-25T15:27:52.352Z" + assert policies.super_admin_recovery_enabled is False + assert policies.password_minimum_length == 14 + assert policies.web_session_duration == "43200s" diff --git a/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py b/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py new file mode 100644 index 0000000000..a6d6a549a9 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py @@ -0,0 +1,156 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurity2svEnforced: + def test_pass_2sv_enforced(self): + """Test PASS when 2-Step Verification enforcement is active""" + 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.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + two_sv_enforced_from="2026-05-25T15:27:52.352Z" + ) + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "active" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_none_not_configured(self): + """Test FAIL when 2-Step Verification enforcement is not configured (None)""" + 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.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_enforced_from=None) + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not configured" in findings[0].status_extended + + def test_fail_empty_off(self): + """Test FAIL when 2-Step Verification enforcement is set to OFF (empty string)""" + 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.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_enforced_from="") + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "OFF" in findings[0].status_extended + + def test_fail_epoch_enforcement_off(self): + """Test FAIL when API returns epoch zero timestamp (enforcement OFF)""" + 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.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + two_sv_enforced_from="1970-01-01T00:00:00Z" + ) + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "OFF" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py b/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py new file mode 100644 index 0000000000..522164f341 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py @@ -0,0 +1,126 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurity2svHardwareKeysAdmins: + def test_pass_passkey_only(self): + """Test PASS when 2SV enforcement requires security keys only""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + two_sv_allowed_factor_set="PASSKEY_ONLY" + ) + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "security keys only" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_all_methods_allowed(self): + """Test FAIL when 2SV enforcement allows ALL methods""" + 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.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_allowed_factor_set="ALL") + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ALL" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when 2SV enforcement factor is not configured (None)""" + 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.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_allowed_factor_set=None) + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured_test.py b/tests/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured_test.py new file mode 100644 index 0000000000..86f25229ae --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured_test.py @@ -0,0 +1,163 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityAdvancedProtectionConfigured: + def test_pass_properly_configured(self): + """Test PASS when Advanced Protection is configured with enrollment and codes blocked""" + 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.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=True, + advanced_protection_security_code_option="CODES_NOT_ALLOWED", + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "configured" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_codes_allowed(self): + """Test FAIL when enrollment is enabled but security codes are allowed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=True, + advanced_protection_security_code_option="ALLOWED_WITHOUT_REMOTE_ACCESS", + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not properly configured" in findings[0].status_extended + + def test_fail_enrollment_disabled(self): + """Test FAIL when enrollment is disabled""" + 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.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=False, + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not properly configured" in findings[0].status_extended + + def test_fail_enrollment_unset(self): + """Test FAIL when enrollment is None (not configured)""" + 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.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=None, + advanced_protection_security_code_option="CODES_NOT_ALLOWED", + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enrollment is not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted_test.py b/tests/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted_test.py new file mode 100644 index 0000000000..ad11b50a02 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityAppAccessRestricted: + def test_pass_access_restricted(self): + """Test PASS when application access to Google services is restricted""" + 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.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(google_services_restricted=True) + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "restricted" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_access_unrestricted(self): + """Test FAIL when application access to Google services is unrestricted""" + 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.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(google_services_restricted=False) + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "unrestricted" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when application access is not configured (None)""" + 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.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(google_services_restricted=None) + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py b/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py new file mode 100644 index 0000000000..402c7b4f2d --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityDlpDriveRulesConfigured: + def test_pass_dlp_rules_configured(self): + """Test PASS when DLP policies for Google Drive are configured""" + 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.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=True) + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "configured" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_dlp_rules(self): + """Test FAIL when no DLP policies for Google Drive are configured""" + 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.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=False) + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active" in findings[0].status_extended + + def test_fail_none_no_dlp_rules(self): + """Test FAIL when DLP rules existence is None (no active rules)""" + 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.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None) + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted_test.py b/tests/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted_test.py new file mode 100644 index 0000000000..2e0aafc1fb --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityInternalAppsTrusted: + def test_pass_internal_apps_trusted(self): + """Test PASS when internal domain-owned apps are trusted""" + 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.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(trust_internal_apps=True) + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "trusted" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when internal apps trust is None (secure default)""" + 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.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(trust_internal_apps=None) + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_internal_apps_not_trusted(self): + """Test FAIL when internal domain-owned apps are not trusted""" + 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.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(trust_internal_apps=False) + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not trusted" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled_test.py b/tests/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled_test.py new file mode 100644 index 0000000000..6dc9d53416 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityLessSecureAppsDisabled: + def test_pass_less_secure_apps_disabled(self): + """Test PASS when less secure app access is explicitly disabled""" + 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.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(less_secure_apps_allowed=False) + + check = security_less_secure_apps_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 == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when less secure app access is None (secure default)""" + 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.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(less_secure_apps_allowed=None) + + check = security_less_secure_apps_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_less_secure_apps_enabled(self): + """Test FAIL when less secure app access is enabled""" + 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.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(less_secure_apps_allowed=True) + + check = security_less_secure_apps_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_less_secure_apps_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured_test.py b/tests/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured_test.py new file mode 100644 index 0000000000..8cc084ca84 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityLoginChallengesConfigured: + def test_pass_employee_id_challenge_disabled(self): + """Test PASS when employee ID login challenge is disabled""" + 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.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(login_challenge_employee_id=False) + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when employee ID login challenge is None (secure default)""" + 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.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(login_challenge_employee_id=None) + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_employee_id_challenge_enabled(self): + """Test FAIL when employee ID login challenge is enabled""" + 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.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(login_challenge_employee_id=True) + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py b/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py new file mode 100644 index 0000000000..850bf243a9 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py @@ -0,0 +1,210 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityPasswordPolicyStrong: + def test_pass_strong_password_policy(self): + """Test PASS when password policy meets CIS requirements""" + 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.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="31536000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "meets CIS requirements" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_weak_password_policy(self): + """Test FAIL when password policy does not meet CIS requirements""" + 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.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=8, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=False, + password_expiration_duration="0s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not meet" in findings[0].status_extended + + def test_fail_none_all_defaults(self): + """Test FAIL when all password policy fields are None (defaults)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=None, + password_allowed_strength=None, + password_allow_reuse=None, + password_enforce_at_login=None, + password_expiration_duration=None, + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not meet" in findings[0].status_extended + + def test_fail_strength_unset_treated_as_missing(self): + """Test FAIL when password_allowed_strength is None even with other fields strong""" + 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.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength=None, + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="31536000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "password strength is not configured" in findings[0].status_extended + + def test_fail_min_length_unset_reports_not_configured(self): + """Test FAIL message uses 'not configured' when password_minimum_length is None""" + 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.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=None, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="31536000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "minimum length is not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py b/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py new file mode 100644 index 0000000000..668b6aea78 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py @@ -0,0 +1,152 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecuritySessionDurationLimited: + def test_pass_session_12_hours(self): + """Test PASS when session duration is set to 12 hours""" + 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.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration="43200s") + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "12 hours" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_long_session_duration(self): + """Test FAIL when session duration is too long (336 hours)""" + 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.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration="1209600s") + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "336 hours" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when session duration is not explicitly configured (None)""" + 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.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration=None) + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not explicitly configured" in findings[0].status_extended + + def test_fail_unparseable_duration(self): + """Test FAIL when session duration has an unexpected format instead of crashing""" + 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.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration="invalid") + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not parseable" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled_test.py b/tests/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled_test.py new file mode 100644 index 0000000000..d9a1f306b8 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecuritySuperAdminRecoveryDisabled: + def test_pass_recovery_disabled(self): + """Test PASS when Super Admin account recovery is explicitly disabled""" + 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.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(super_admin_recovery_enabled=False) + + check = security_super_admin_recovery_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 == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when Super Admin account recovery is None (secure default)""" + 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.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(super_admin_recovery_enabled=None) + + check = security_super_admin_recovery_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_recovery_enabled(self): + """Test FAIL when Super Admin account recovery is enabled""" + 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.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(super_admin_recovery_enabled=True) + + check = security_super_admin_recovery_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_super_admin_recovery_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled_test.py b/tests/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled_test.py new file mode 100644 index 0000000000..c8a519dbe0 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityUserRecoveryEnabled: + def test_pass_recovery_enabled(self): + """Test PASS when user account recovery is enabled""" + 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.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(user_recovery_enabled=True) + + check = security_user_recovery_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 == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_recovery_disabled(self): + """Test FAIL when user account recovery is disabled""" + 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.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(user_recovery_enabled=False) + + check = security_user_recovery_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "disabled" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when user account recovery is None (not explicitly configured)""" + 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.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(user_recovery_enabled=None) + + check = security_user_recovery_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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_user_recovery_enabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled_test.py b/tests/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled_test.py new file mode 100644 index 0000000000..ff668f5cf5 --- /dev/null +++ b/tests/providers/googleworkspace/services/sites/sites_service_disabled/sites_service_disabled_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.sites.sites_service import ( + SitesPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSitesServiceDisabled: + def test_pass_service_disabled(self): + """Test PASS when Sites service is disabled (OFF for everyone)""" + 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.sites.sites_service_disabled.sites_service_disabled.sites_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.sites.sites_service_disabled.sites_service_disabled import ( + sites_service_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SitesPolicies(service_state="DISABLED") + + check = sites_service_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 == "Sites Policies" + assert findings[0].resource_id == "sitesPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_service_enabled(self): + """Test FAIL when Sites service is enabled (ON for everyone)""" + 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.sites.sites_service_disabled.sites_service_disabled.sites_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.sites.sites_service_disabled.sites_service_disabled import ( + sites_service_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SitesPolicies(service_state="ENABLED") + + check = sites_service_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): + """Test FAIL when no explicit policy is set (None) - Google default is ON (insecure)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.sites.sites_service_disabled.sites_service_disabled.sites_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.sites.sites_service_disabled.sites_service_disabled import ( + sites_service_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SitesPolicies(service_state=None) + + check = sites_service_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): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.sites.sites_service_disabled.sites_service_disabled.sites_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.sites.sites_service_disabled.sites_service_disabled import ( + sites_service_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SitesPolicies() + + check = sites_service_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/sites/sites_service_test.py b/tests/providers/googleworkspace/services/sites/sites_service_test.py new file mode 100644 index 0000000000..63a473238d --- /dev/null +++ b/tests/providers/googleworkspace/services/sites/sites_service_test.py @@ -0,0 +1,234 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestSitesService: + def test_fetch_policies_service_status_off(self): + """Test fetching Sites policy with service status OFF""" + 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/sites.service_status", + "value": { + "serviceState": "DISABLED", + }, + } + }, + ] + } + 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.sites.sites_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.sites.sites_service import ( + Sites, + ) + + sites = Sites(mock_provider) + + assert sites.policies_fetched is True + assert sites.policies.service_state == "DISABLED" + + def test_fetch_policies_service_status_on(self): + """Test fetching Sites policy with service status ON""" + 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": [ + { + "setting": { + "type": "settings/sites.service_status", + "value": { + "serviceState": "ENABLED", + }, + } + }, + ] + } + 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.sites.sites_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.sites.sites_service import ( + Sites, + ) + + sites = Sites(mock_provider) + + assert sites.policies_fetched is True + assert sites.policies.service_state == "ENABLED" + + def test_fetch_policies_empty_response(self): + """Test handling empty policies response""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.sites.sites_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.sites.sites_service import ( + Sites, + ) + + sites = Sites(mock_provider) + + assert sites.policies_fetched is True + assert sites.policies.service_state is None + + def test_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.sites.sites_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.sites.sites_service import ( + Sites, + ) + + sites = Sites(mock_provider) + + assert sites.policies_fetched is False + assert sites.policies.service_state is None + + def test_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails to construct the client""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.sites.sites_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.sites.sites_service import ( + Sites, + ) + + sites = Sites(mock_provider) + + assert sites.policies_fetched is False + assert sites.policies.service_state is None + + def test_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.sites.sites_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.sites.sites_service import ( + Sites, + ) + + sites = Sites(mock_provider) + + assert sites.policies_fetched is False + assert sites.policies.service_state is None + + def test_sites_policies_model(self): + """Test SitesPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.sites.sites_service import ( + SitesPolicies, + ) + + policies = SitesPolicies(service_state="DISABLED") + + assert policies.service_state == "DISABLED" diff --git a/tests/providers/iac/iac_fixtures.py b/tests/providers/iac/iac_fixtures.py index 2e769a732a..b2d205940e 100644 --- a/tests/providers/iac/iac_fixtures.py +++ b/tests/providers/iac/iac_fixtures.py @@ -259,7 +259,13 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = { "Title": "Example vulnerability", "Description": "This is an example vulnerability", "Severity": "high", - "PrimaryURL": "https://example.com/cve-2023-1234", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234", + "References": [ + "https://avd.aquasec.com/nvd/cve-2023-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + "https://www.cve.org/CVERecord?id=CVE-2023-1234", + "https://security.example.com/advisories/CVE-2023-1234", + ], } ], "Secrets": [], @@ -268,6 +274,39 @@ SAMPLE_TRIVY_VULNERABILITY_OUTPUT = { ] } +SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = { + "VulnerabilityID": "CVE-2023-5678", + "Title": "Vulnerability without cve.org reference", + "Description": "This vulnerability includes references but no cve.org reference", + "Severity": "high", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-5678", + "References": [ + "https://avd.aquasec.com/nvd/cve-2023-5678", + "https://nvd.nist.gov/vuln/detail/CVE-2023-5678", + "https://security.example.com/advisories/CVE-2023-5678", + ], +} + +SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES = { + "VulnerabilityID": "CVE-2023-9012", + "Title": "Fallback CVE vulnerability", + "Description": "This vulnerability requires building the URL from VulnerabilityID", + "Severity": "medium", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-9012", +} + +SAMPLE_TRIVY_NON_CVE_VULNERABILITY = { + "VulnerabilityID": "GHSA-abcd-1234-efgh", + "Title": "Non-CVE vulnerability", + "Description": "This advisory has no CVE identifier", + "Severity": "high", + "PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "References": [ + "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "https://github.com/advisories/GHSA-abcd-1234-efgh", + ], +} + # Sample Trivy output with secrets SAMPLE_TRIVY_SECRET_OUTPUT = { "Results": [ diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index fc98c097d8..8b4b51000a 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -20,6 +20,9 @@ from tests.providers.iac.iac_fixtures import ( SAMPLE_KUBERNETES_CHECK, SAMPLE_PASSED_CHECK, SAMPLE_SKIPPED_CHECK, + SAMPLE_TRIVY_NON_CVE_VULNERABILITY, + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES, SAMPLE_YAML_CHECK, get_empty_trivy_output, get_invalid_trivy_output, @@ -57,13 +60,15 @@ class TestIacProvider: assert isinstance(report, CheckReportIAC) assert report.status == "FAIL" + # Trivy emits "AVD-AWS-0001"; Hub indexes it without the AVD- prefix. + expected_url = "https://hub.prowler.com/check/AWS-0001" assert report.check_metadata.Provider == "iac" assert report.check_metadata.CheckID == SAMPLE_FAILED_CHECK["ID"] assert report.check_metadata.CheckTitle == SAMPLE_FAILED_CHECK["Title"] assert report.check_metadata.Severity == "low" - assert report.check_metadata.RelatedUrl == SAMPLE_FAILED_CHECK.get( - "PrimaryURL", "" - ) + assert report.check_metadata.Remediation.Recommendation.Url == expected_url + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [expected_url] def test_iac_provider_process_finding_passed(self): """Test processing a passed finding""" @@ -79,6 +84,101 @@ class TestIacProvider: assert report.check_metadata.CheckTitle == SAMPLE_PASSED_CHECK["Title"] assert report.check_metadata.Severity == "low" + def test_iac_provider_process_vulnerability_prefers_cve_reference_and_filters_aqua( + self, + ): + """Test CVE findings use cve.org and exclude Aqua references.""" + provider = IacProvider() + + report = provider._process_finding( + { + "VulnerabilityID": "CVE-2023-1234", + "Title": "Example vulnerability", + "Description": "This is an example vulnerability", + "Severity": "high", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-1234", + "References": [ + "https://avd.aquasec.com/nvd/cve-2023-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + "https://www.cve.org/CVERecord?id=CVE-2023-1234", + "https://security.example.com/advisories/CVE-2023-1234", + ], + }, + "package.json", + "nodejs", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2023-1234" + ) + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2023-1234" + ] + + def test_iac_provider_process_vulnerability_builds_cve_org_for_nvd_reference( + self, + ): + """Test official CVE URL is built when only NVD is provided.""" + provider = IacProvider() + + report = provider._process_finding( + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, + "package.json", + "nodejs", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2023-5678" + ) + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2023-5678" + ] + + def test_iac_provider_process_vulnerability_builds_cve_org_when_references_missing( + self, + ): + """Test CVE URL is built from VulnerabilityID when references are absent.""" + provider = IacProvider() + + report = provider._process_finding( + SAMPLE_TRIVY_VULNERABILITY_WITHOUT_REFERENCES, + "package.json", + "nodejs", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2023-9012" + ) + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2023-9012" + ] + + def test_iac_provider_process_non_cve_vulnerability_falls_back_to_github_advisory( + self, + ): + """Non-CVE vulnerabilities (GHSA-…) point to GitHub Security Advisories.""" + provider = IacProvider() + + report = provider._process_finding( + SAMPLE_TRIVY_NON_CVE_VULNERABILITY, + "package.json", + "nodejs", + ) + + expected_url = ( + "https://github.com/advisories/" + f"{SAMPLE_TRIVY_NON_CVE_VULNERABILITY['VulnerabilityID'].upper()}" + ) + assert report.check_metadata.Remediation.Recommendation.Url == expected_url + assert report.check_metadata.RelatedUrl == "" + assert report.check_metadata.AdditionalURLs == [expected_url] + @patch("subprocess.run") def test_iac_provider_run_scan_success(self, mock_subprocess): """Test successful IAC scan with Trivy""" diff --git a/tests/providers/image/image_fixtures.py b/tests/providers/image/image_fixtures.py index 920a7f225a..bf5e12df32 100644 --- a/tests/providers/image/image_fixtures.py +++ b/tests/providers/image/image_fixtures.py @@ -11,6 +11,12 @@ SAMPLE_VULNERABILITY_FINDING = { "Title": "OpenSSL Buffer Overflow", "Description": "A buffer overflow vulnerability in OpenSSL allows remote attackers to execute arbitrary code.", "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-1234", + "References": [ + "https://avd.aquasec.com/nvd/cve-2024-1234", + "https://nvd.nist.gov/vuln/detail/CVE-2024-1234", + "https://www.cve.org/CVERecord?id=CVE-2024-1234", + "https://security.alpinelinux.org/vuln/CVE-2024-1234", + ], } # Sample secret finding from Trivy @@ -45,6 +51,50 @@ SAMPLE_UNKNOWN_SEVERITY_FINDING = { "Description": "An issue with unknown severity.", } +SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE = { + "VulnerabilityID": "CVE-2024-5678", + "PkgID": "libcrypto3@3.3.2-r0", + "PkgName": "libcrypto3", + "InstalledVersion": "3.3.2-r0", + "FixedVersion": "3.3.2-r1", + "Severity": "HIGH", + "Title": "OpenSSL advisory without cve.org reference", + "Description": "A vulnerability with references but no cve.org reference.", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5678", + "References": [ + "https://avd.aquasec.com/nvd/cve-2024-5678", + "https://nvd.nist.gov/vuln/detail/CVE-2024-5678", + "https://security.alpinelinux.org/vuln/CVE-2024-5678", + ], +} + +SAMPLE_CVE_WITHOUT_REFERENCES_FINDING = { + "VulnerabilityID": "CVE-2024-9012", + "PkgID": "busybox@1.36.1-r8", + "PkgName": "busybox", + "InstalledVersion": "1.36.1-r8", + "FixedVersion": "1.36.1-r9", + "Severity": "MEDIUM", + "Title": "Busybox fallback CVE", + "Description": "A vulnerability without explicit references.", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-9012", +} + +SAMPLE_NON_CVE_VULNERABILITY_FINDING = { + "VulnerabilityID": "GHSA-abcd-1234-efgh", + "PkgID": "custompkg@0.0.1", + "PkgName": "custompkg", + "InstalledVersion": "0.0.1", + "Severity": "HIGH", + "Title": "Non-CVE advisory", + "Description": "An advisory without a CVE identifier.", + "PrimaryURL": "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "References": [ + "https://avd.aquasec.com/nvd/ghsa-abcd-1234-efgh", + "https://github.com/advisories/GHSA-abcd-1234-efgh", + ], +} + # Sample image SHA for testing (first 12 chars of a sha256 digest) SAMPLE_IMAGE_SHA = "c1aabb73d233" SAMPLE_IMAGE_ID = f"sha256:{SAMPLE_IMAGE_SHA}abcdef1234567890" diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index 941685f662..92c4236dd8 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from prowler.lib.check.models import CheckReportImage +from prowler.providers.common.provider import Provider from prowler.providers.image.exceptions.exceptions import ( ImageInvalidConfigScannerError, ImageInvalidNameError, @@ -20,14 +21,16 @@ from prowler.providers.image.exceptions.exceptions import ( ImageScanError, ImageTrivyBinaryNotFoundError, ) -from prowler.providers.common.provider import Provider from prowler.providers.image.image_provider import ImageProvider from tests.providers.image.image_fixtures import ( + SAMPLE_CVE_WITHOUT_REFERENCES_FINDING, SAMPLE_IMAGE_SHA, SAMPLE_MISCONFIGURATION_FINDING, + SAMPLE_NON_CVE_VULNERABILITY_FINDING, SAMPLE_SECRET_FINDING, SAMPLE_UNKNOWN_SEVERITY_FINDING, SAMPLE_VULNERABILITY_FINDING, + SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, get_empty_trivy_output, get_invalid_trivy_output, get_multi_type_trivy_output, @@ -148,6 +151,77 @@ class TestImageProvider: assert report.check_metadata.Categories == ["vulnerabilities"] assert report.check_metadata.RelatedUrl == "" + def test_process_finding_vulnerability_prefers_cve_reference_and_filters_aqua(self): + """Test CVE findings use cve.org and exclude Aqua references.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_VULNERABILITY_FINDING, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2024-1234" + ) + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2024-1234" + ] + + def test_process_finding_vulnerability_builds_cve_org_when_only_nvd_reference( + self, + ): + """Test official CVE URL is built when only NVD is provided.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_VULNERABILITY_WITHOUT_CVE_ORG_REFERENCE, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2024-5678" + ) + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2024-5678" + ] + + def test_process_finding_vulnerability_builds_cve_org_when_references_missing(self): + """Test CVE URL is built from VulnerabilityID when references are absent.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_CVE_WITHOUT_REFERENCES_FINDING, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert ( + report.check_metadata.Remediation.Recommendation.Url + == "https://www.cve.org/CVERecord?id=CVE-2024-9012" + ) + assert report.check_metadata.AdditionalURLs == [ + "https://www.cve.org/CVERecord?id=CVE-2024-9012" + ] + + def test_process_finding_non_cve_vulnerability_does_not_fallback_to_aqua(self): + """Test non-CVE vulnerabilities do not keep Aqua links.""" + provider = _make_provider() + + report = provider._process_finding( + SAMPLE_NON_CVE_VULNERABILITY_FINDING, + "alpine:3.18", + "alpine:3.18 (alpine 3.18.0)", + ) + + assert report.check_metadata.Remediation.Recommendation.Url == "" + assert report.check_metadata.AdditionalURLs == [ + "https://github.com/advisories/GHSA-abcd-1234-efgh" + ] + def test_process_finding_secret(self): """Test processing a secret finding (identified by RuleID).""" provider = _make_provider() @@ -345,6 +419,24 @@ class TestImageProvider: ) mock_adapter.list_repositories.assert_called_once() + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_test_connection_registry_url_with_https_scheme(self, mock_factory): + """Registry URL with https:// scheme is normalised before adapter creation.""" + mock_adapter = MagicMock() + mock_adapter.list_repositories.return_value = ["repo1"] + mock_factory.return_value = mock_adapter + + result = ImageProvider.test_connection(image="https://my-registry.example.com") + + assert result.is_connected is True + mock_factory.assert_called_once_with( + registry_url="my-registry.example.com", + username=None, + password=None, + token=None, + ) + mock_adapter.list_repositories.assert_called_once() + def test_build_status_extended(self): """Test status message content for different finding types.""" provider = _make_provider() @@ -659,6 +751,27 @@ class TestImageProviderRegistryAuth: assert "Docker login" in output +class TestStripScheme: + @pytest.mark.parametrize( + "raw,expected", + [ + ("https://my-registry.example.com", "my-registry.example.com"), + ("http://my-registry.example.com", "my-registry.example.com"), + ("HTTPS://My-Registry.Example.Com", "My-Registry.Example.Com"), + ("Http://localhost:5000", "localhost:5000"), + ("my-registry.example.com", "my-registry.example.com"), + ("https://", ""), + ("https://https://nested.example.com", "https://nested.example.com"), + ( + "ftp://not-a-supported-scheme.example.com", + "ftp://not-a-supported-scheme.example.com", + ), + ], + ) + def test_strip_scheme(self, raw, expected): + assert ImageProvider._strip_scheme(raw) == expected + + class TestExtractRegistry: def test_docker_hub_simple(self): assert ImageProvider._extract_registry("alpine:3.18") is None @@ -698,6 +811,24 @@ class TestExtractRegistry: def test_bare_image_name(self): assert ImageProvider._extract_registry("nginx") is None + def test_https_scheme_bare_hostname_returns_none(self): + """Bare scheme-prefixed hostname has no image path, so no registry is extracted.""" + assert ( + ImageProvider._extract_registry("https://my-registry.example.com") is None + ) + + def test_http_scheme_with_port_stripped(self): + assert ( + ImageProvider._extract_registry("http://localhost:5000/myimage:latest") + == "localhost:5000" + ) + + def test_https_scheme_with_path_stripped(self): + assert ( + ImageProvider._extract_registry("https://ghcr.io/org/image:tag") + == "ghcr.io" + ) + class TestIsRegistryUrl: def test_bare_ecr_hostname(self): @@ -728,6 +859,16 @@ class TestIsRegistryUrl: def test_dockerhub_namespace(self): assert not ImageProvider._is_registry_url("library/alpine") + def test_https_scheme_bare_hostname(self): + assert ImageProvider._is_registry_url("https://my-registry.example.com") + + def test_http_scheme_bare_hostname_with_port(self): + assert ImageProvider._is_registry_url("http://my-registry.example.com:5000") + + def test_https_scheme_image_reference_not_registry(self): + """A scheme-prefixed full image reference is still an image, not a registry URL.""" + assert not ImageProvider._is_registry_url("https://ghcr.io/myorg/repo:tag") + class TestTestRegistryConnection: @patch("prowler.providers.image.image_provider.create_registry_adapter") @@ -1185,3 +1326,58 @@ class TestInitGlobalProviderRegistryEnumeration: # The "other/lib" repo should be filtered out by --image-filter assert not any("other/lib" in img for img in provider.images) assert len(provider.images) == 3 + + +class TestRegistryListMode: + """Regression test: `prowler image --registry --registry-list` crashes. + + When --registry-list is passed, ImageProvider._enumerate_registry sets + _listing_only = True and __init__ returns early — before calling + Provider.set_global_provider(self). The caller in __main__.py then calls + global_provider.print_credentials() on a None reference, raising + AttributeError: 'NoneType' object has no attribute 'print_credentials'. + """ + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + @patch("prowler.providers.common.provider.load_and_validate_config_file") + def test_registry_list_does_not_crash(self, mock_load_config, mock_adapter_factory): + """Reproduce the --registry-list crash by running the same sequence + as __main__.py: init_global_provider, get_global_provider, + then print_credentials.""" + mock_load_config.return_value = {} + + adapter = MagicMock() + adapter.list_repositories.return_value = ["myorg/app"] + adapter.list_tags.return_value = ["v1.0", "latest"] + mock_adapter_factory.return_value = adapter + + arguments = Namespace( + provider="image", + config_file=None, + fixer_config=None, + images=None, + image_list_file=None, + scanners=["vuln"], + image_config_scanners=None, + trivy_severity=None, + ignore_unfixed=False, + timeout="5m", + registry="myregistry.io", + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + registry_list_images=True, + ) + + # Reproduce the exact crash sequence from __main__.py lines 289-294: + # Provider.init_global_provider(args) + # global_provider = Provider.get_global_provider() + # global_provider.print_credentials() + with mock.patch.object(Provider, "_global", None): + Provider.init_global_provider(arguments) + global_provider = Provider.get_global_provider() + + # This is the line that crashes: global_provider is None so + # .print_credentials() raises AttributeError. + global_provider.print_credentials() diff --git a/tests/providers/image/lib/registry/test_dockerhub_adapter.py b/tests/providers/image/lib/registry/test_dockerhub_adapter.py index 930e872bba..46cc29368c 100644 --- a/tests/providers/image/lib/registry/test_dockerhub_adapter.py +++ b/tests/providers/image/lib/registry/test_dockerhub_adapter.py @@ -1,3 +1,4 @@ +import socket from unittest.mock import MagicMock, patch import pytest @@ -11,6 +12,18 @@ from prowler.providers.image.exceptions.exceptions import ( from prowler.providers.image.lib.registry.dockerhub_adapter import DockerHubAdapter +@pytest.fixture(autouse=True) +def _default_dns_resolves_public(monkeypatch): + """Resolve every host to a public IP unless a test overrides this.""" + + def _stub(_host, *_a, **_kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))] + + monkeypatch.setattr( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", _stub + ) + + class TestDockerHubAdapterInit: def test_extract_namespace_simple(self): assert DockerHubAdapter._extract_namespace("docker.io/myorg") == "myorg" @@ -241,3 +254,35 @@ class TestDockerHubEmptyTokens: adapter = DockerHubAdapter("docker.io/myorg", username="u", password="p") with pytest.raises(ImageRegistryAuthError, match="empty token"): adapter._get_registry_token("myorg/myapp") + + +class TestDockerHubPaginationLinkValidator: + """Server-controlled pagination links must be validated (PRWLRHELP-2103 class).""" + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_tag_pagination_to_metadata_ip_is_rejected(self, mock_request): + token_resp = MagicMock(status_code=200) + token_resp.json.return_value = {"token": "tok"} + tags_resp = MagicMock( + status_code=200, + headers={"Link": '; rel="next"'}, + ) + tags_resp.json.return_value = {"tags": ["v1"]} + mock_request.side_effect = [token_resp, tags_resp] + adapter = DockerHubAdapter("docker.io/myorg") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter.list_tags("myorg/myapp") + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_tag_pagination_to_unrelated_host_is_rejected(self, mock_request): + token_resp = MagicMock(status_code=200) + token_resp.json.return_value = {"token": "tok"} + tags_resp = MagicMock( + status_code=200, + headers={"Link": '; rel="next"'}, + ) + tags_resp.json.return_value = {"tags": ["v1"]} + mock_request.side_effect = [token_resp, tags_resp] + adapter = DockerHubAdapter("docker.io/myorg") + with pytest.raises(ImageRegistryAuthError, match="unrelated"): + adapter.list_tags("myorg/myapp") diff --git a/tests/providers/image/lib/registry/test_oci_adapter.py b/tests/providers/image/lib/registry/test_oci_adapter.py index b1006ea6cd..b814019504 100644 --- a/tests/providers/image/lib/registry/test_oci_adapter.py +++ b/tests/providers/image/lib/registry/test_oci_adapter.py @@ -1,4 +1,5 @@ import base64 +import socket from unittest.mock import MagicMock, patch import pytest @@ -12,6 +13,35 @@ from prowler.providers.image.exceptions.exceptions import ( from prowler.providers.image.lib.registry.oci_adapter import OciRegistryAdapter +def _fake_getaddrinfo(host_to_ip: dict): + """Build a getaddrinfo stub that resolves names from host_to_ip.""" + + def _stub(host, *_args, **_kwargs): + if host not in host_to_ip: + raise socket.gaierror(f"unresolved host: {host}") + ip = host_to_ip[host] + family = socket.AF_INET6 if ":" in ip else socket.AF_INET + return [(family, socket.SOCK_STREAM, 0, "", (ip, 0))] + + return _stub + + +@pytest.fixture(autouse=True) +def _default_dns_resolves_public(monkeypatch): + """Make every host resolve to a public IP by default. + + Individual tests may override with their own patch on + ``prowler.providers.image.lib.registry.base.socket.getaddrinfo``. + """ + + def _stub(_host, *_a, **_kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))] + + monkeypatch.setattr( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", _stub + ) + + class TestOciAdapterInit: def test_normalise_url_adds_https(self): adapter = OciRegistryAdapter("myregistry.io") @@ -56,7 +86,7 @@ class TestOciAdapterAuth: ping_resp = MagicMock( status_code=401, headers={ - "Www-Authenticate": 'Bearer realm="https://auth.example.com/token",service="registry"' + "Www-Authenticate": 'Bearer realm="https://auth.reg.io/token",service="registry"' }, ) token_resp = MagicMock(status_code=200) @@ -288,70 +318,323 @@ class TestOciAdapterRetry: class TestOciAdapterNextPageUrl: def test_no_link_header(self): + adapter = OciRegistryAdapter("https://reg.io") resp = MagicMock(headers={}) - assert OciRegistryAdapter._next_page_url(resp) is None + assert adapter._next_page_url(resp) is None def test_link_header_with_next(self): + adapter = OciRegistryAdapter("https://reg.io") resp = MagicMock( headers={"Link": '; rel="next"'} ) - assert ( - OciRegistryAdapter._next_page_url(resp) - == "https://reg.io/v2/_catalog?n=200&last=b" - ) + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"reg.io": "8.8.8.8"}), + ): + assert ( + adapter._next_page_url(resp) + == "https://reg.io/v2/_catalog?n=200&last=b" + ) def test_link_header_relative_url(self): + adapter = OciRegistryAdapter("https://reg.io") resp = MagicMock( headers={"Link": '; rel="next"'}, url="https://reg.io/v2/_catalog?n=200", ) - assert ( - OciRegistryAdapter._next_page_url(resp) - == "https://reg.io/v2/_catalog?n=200&last=b" - ) + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"reg.io": "8.8.8.8"}), + ): + assert ( + adapter._next_page_url(resp) + == "https://reg.io/v2/_catalog?n=200&last=b" + ) def test_link_header_no_next(self): + adapter = OciRegistryAdapter("https://reg.io") resp = MagicMock( headers={"Link": '; rel="prev"'} ) - assert OciRegistryAdapter._next_page_url(resp) is None + assert adapter._next_page_url(resp) is None -class TestOciAdapterSSRF: +class TestOutboundUrlValidator: + """Centralized SSRF defense (PRWLRHELP-2103). + + Layers under test: + A. Parser unification — validator and connector use the same parser. + B. DNS resolution — reject hostnames pointing to non-public IPs. + C. Same registrable-domain — reject realm/pagination on unrelated hosts. + """ + + # --- A: scheme + literal IP rejection --- + def test_reject_file_scheme(self): - adapter = OciRegistryAdapter("reg.io") - with pytest.raises(ImageRegistryAuthError, match="disallowed scheme"): - adapter._validate_realm_url("file:///etc/passwd") + adapter = OciRegistryAdapter("reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="scheme"): + adapter._validate_outbound_url("file:///etc/passwd") def test_reject_ftp_scheme(self): - adapter = OciRegistryAdapter("reg.io") - with pytest.raises(ImageRegistryAuthError, match="disallowed scheme"): - adapter._validate_realm_url("ftp://evil.com/token") + adapter = OciRegistryAdapter("reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="scheme"): + adapter._validate_outbound_url("ftp://reg.example.com/token") - def test_reject_private_ip(self): - adapter = OciRegistryAdapter("reg.io") - with pytest.raises(ImageRegistryAuthError, match="private/loopback"): - adapter._validate_realm_url("https://10.0.0.1/token") + def test_reject_private_ip_literal(self): + adapter = OciRegistryAdapter("reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://10.0.0.1/token") - def test_reject_loopback(self): - adapter = OciRegistryAdapter("reg.io") - with pytest.raises(ImageRegistryAuthError, match="private/loopback"): - adapter._validate_realm_url("https://127.0.0.1/token") + def test_reject_loopback_ip_literal(self): + adapter = OciRegistryAdapter("reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://127.0.0.1/token") - def test_reject_link_local(self): - adapter = OciRegistryAdapter("reg.io") - with pytest.raises(ImageRegistryAuthError, match="private/loopback"): - adapter._validate_realm_url("https://169.254.169.254/latest/meta-data") + def test_reject_link_local_ip_literal(self): + adapter = OciRegistryAdapter("reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://169.254.169.254/latest/meta-data") - def test_accept_public_https(self): - adapter = OciRegistryAdapter("reg.io") - # Should not raise - adapter._validate_realm_url("https://auth.example.com/token") + def test_reject_ipv6_loopback(self): + adapter = OciRegistryAdapter("reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://[::1]/token") - def test_accept_hostname_not_ip(self): - adapter = OciRegistryAdapter("reg.io") - # Hostnames (not IPs) should pass even if they resolve to private IPs - adapter._validate_realm_url("https://internal.corp.com/token") + # --- A: parser-mismatch bypass (the headlining PRWLRHELP-2103 PoC) --- + + def test_reject_parser_mismatch_bypass(self): + """Reporter PoC: the literal URL parses two different ways. + + urlparse() sees host = 180.101.51.73 (public, would have been allowed) + requests connects to 127.0.0.1:6666 (loopback) + The validator must canonicalise via PreparedRequest and reject. + """ + adapter = OciRegistryAdapter("reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url( + "http://127.0.0.1:6666\\\\@180.101.51.73/token", + enforce_origin=False, + ) + + # --- B: DNS resolution to non-public IPs --- + + def test_reject_hostname_resolving_to_loopback(self): + adapter = OciRegistryAdapter("https://reg.example.com") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo( + {"reg.example.com": "8.8.8.8", "localhost": "127.0.0.1"} + ), + ): + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url( + "https://localhost/token", enforce_origin=False + ) + + def test_reject_hostname_resolving_to_metadata_ip(self): + adapter = OciRegistryAdapter("https://reg.example.com") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo( + { + "reg.example.com": "8.8.8.8", + "metadata.aws.internal": "169.254.169.254", + } + ), + ): + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url( + "https://metadata.aws.internal/", enforce_origin=False + ) + + def test_unresolvable_host_passes_validator(self): + """getaddrinfo failure is not the validator's concern — let requests fail naturally.""" + adapter = OciRegistryAdapter("https://reg.example.com") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=socket.gaierror("nope"), + ): + # Same eTLD+1, unresolvable — validator should not raise. + adapter._validate_outbound_url( + "https://nx.example.com/token", enforce_origin=True + ) + + # --- C: same registrable-domain enforcement --- + + def test_accept_same_etld1(self): + adapter = OciRegistryAdapter("https://registry-1.docker.io") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo( + {"registry-1.docker.io": "8.8.8.8", "auth.docker.io": "8.8.4.4"} + ), + ): + canonical = adapter._validate_outbound_url("https://auth.docker.io/token") + assert canonical == "https://auth.docker.io/token" + + def test_accept_same_host(self): + adapter = OciRegistryAdapter("https://ghcr.io") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"ghcr.io": "8.8.8.8"}), + ): + adapter._validate_outbound_url("https://ghcr.io/token") + + def test_reject_unrelated_host(self): + adapter = OciRegistryAdapter("https://registry-1.docker.io") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo( + {"registry-1.docker.io": "8.8.8.8", "attacker.com": "1.1.1.1"} + ), + ): + with pytest.raises(ImageRegistryAuthError, match="unrelated"): + adapter._validate_outbound_url("https://attacker.com/token") + + def test_enforce_origin_false_allows_unrelated_public_host(self): + """When validating the registry URL itself (the trust anchor), + we don't compare it to itself — pass enforce_origin=False.""" + adapter = OciRegistryAdapter("https://reg.example.com") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"public.elsewhere.io": "8.8.8.8"}), + ): + adapter._validate_outbound_url( + "https://public.elsewhere.io/", enforce_origin=False + ) + + # --- Returns canonical URL for the caller to use --- + + def test_returns_canonical_url(self): + adapter = OciRegistryAdapter("https://ghcr.io") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"ghcr.io": "8.8.8.8"}), + ): + canonical = adapter._validate_outbound_url("https://ghcr.io/token") + assert canonical == "https://ghcr.io/token" + + +class TestObtainBearerTokenAppliesValidator: + """Integration: malicious Www-Authenticate realm must be rejected before the second call.""" + + @patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"reg.example.com": "8.8.8.8"}), + ) + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_realm_with_parser_mismatch_payload_is_rejected( + self, mock_request, _mock_dns + ): + ping_resp = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": ( + 'Bearer realm="http://127.0.0.1:6666\\\\@180.101.51.73/token",' + 'service="registry"' + ) + }, + ) + mock_request.return_value = ping_resp + adapter = OciRegistryAdapter("https://reg.example.com") + with pytest.raises(ImageRegistryAuthError): + adapter._ensure_auth() + # Only the ping should have happened — not the realm GET. + assert mock_request.call_count == 1 + + @patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"reg.example.com": "8.8.8.8"}), + ) + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_realm_pointing_to_unrelated_host_is_rejected( + self, mock_request, _mock_dns + ): + ping_resp = MagicMock( + status_code=401, + headers={"Www-Authenticate": 'Bearer realm="https://attacker.com/token"'}, + ) + mock_request.return_value = ping_resp + adapter = OciRegistryAdapter("https://reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="unrelated"): + adapter._ensure_auth() + assert mock_request.call_count == 1 + + +class TestPaginationLinkValidator: + """The Link: rel=next URL is server-controlled and must go through the validator.""" + + @patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"reg.example.com": "8.8.8.8"}), + ) + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_oci_pagination_to_unrelated_host_is_rejected( + self, mock_request, _mock_dns + ): + ping_resp = MagicMock(status_code=200) + catalog_page = MagicMock( + status_code=200, + headers={"Link": '; rel="next"'}, + ) + catalog_page.json.return_value = {"repositories": ["a"]} + mock_request.side_effect = [ping_resp, catalog_page] + adapter = OciRegistryAdapter("https://reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="unrelated"): + adapter.list_repositories() + + @patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"reg.example.com": "8.8.8.8"}), + ) + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_oci_pagination_to_metadata_ip_is_rejected(self, mock_request, _mock_dns): + ping_resp = MagicMock(status_code=200) + catalog_page = MagicMock( + status_code=200, + headers={"Link": '; rel="next"'}, + ) + catalog_page.json.return_value = {"repositories": ["a"]} + mock_request.side_effect = [ping_resp, catalog_page] + adapter = OciRegistryAdapter("https://reg.example.com") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter.list_repositories() + + +class TestCrossOriginAuthorizationStrip: + """Bearer/Basic credentials must not leak to a host different from the registry.""" + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_bearer_not_sent_to_different_host(self, mock_request): + resp_200 = MagicMock(status_code=200) + mock_request.return_value = resp_200 + adapter = OciRegistryAdapter("https://reg.example.com") + adapter._bearer_token = "secret-bearer" + # _do_authed_request — call with a different host + adapter._do_authed_request("GET", "https://other.example.com/v2/_catalog") + sent_headers = mock_request.call_args.kwargs.get("headers", {}) + assert "Authorization" not in sent_headers + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_bearer_is_sent_to_same_host(self, mock_request): + resp_200 = MagicMock(status_code=200) + mock_request.return_value = resp_200 + adapter = OciRegistryAdapter("https://reg.example.com") + adapter._bearer_token = "secret-bearer" + adapter._do_authed_request("GET", "https://reg.example.com/v2/_catalog") + sent_headers = mock_request.call_args.kwargs.get("headers", {}) + assert sent_headers.get("Authorization") == "Bearer secret-bearer" + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_basic_auth_not_sent_to_different_host(self, mock_request): + resp_200 = MagicMock(status_code=200) + mock_request.return_value = resp_200 + adapter = OciRegistryAdapter( + "https://reg.example.com", username="u", password="p" + ) + adapter._basic_auth_verified = True + adapter._do_authed_request("GET", "https://other.example.com/v2/_catalog") + assert mock_request.call_args.kwargs.get("auth") is None class TestOciAdapterEmptyToken: @@ -360,7 +643,7 @@ class TestOciAdapterEmptyToken: ping_resp = MagicMock( status_code=401, headers={ - "Www-Authenticate": 'Bearer realm="https://auth.example.com/token",service="registry"' + "Www-Authenticate": 'Bearer realm="https://auth.reg.io/token",service="registry"' }, ) token_resp = MagicMock(status_code=200) @@ -375,7 +658,7 @@ class TestOciAdapterEmptyToken: ping_resp = MagicMock( status_code=401, headers={ - "Www-Authenticate": 'Bearer realm="https://auth.example.com/token",service="registry"' + "Www-Authenticate": 'Bearer realm="https://auth.reg.io/token",service="registry"' }, ) token_resp = MagicMock(status_code=200) diff --git a/tests/providers/kubernetes/services/core/conftest.py b/tests/providers/kubernetes/services/core/conftest.py new file mode 100644 index 0000000000..9b0becd33c --- /dev/null +++ b/tests/providers/kubernetes/services/core/conftest.py @@ -0,0 +1,74 @@ +import importlib +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +def make_container( + name="app", + image="nginx:1.25", + resources=None, + liveness_probe=None, + readiness_probe=None, +): + return Container( + name=name, + image=image, + command=None, + ports=None, + env=None, + security_context={}, + resources=resources, + liveness_probe=liveness_probe, + readiness_probe=readiness_probe, + ) + + +def make_pod( + containers=None, + init_containers=None, + ephemeral_containers=None, + name="test-pod", + uid="test-pod-uid", +): + return Pod( + name=name, + uid=uid, + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=False, + host_ipc=False, + host_network=False, + security_context={}, + containers=containers or {}, + init_containers=init_containers or {}, + ephemeral_containers=ephemeral_containers or {}, + ) + + +def make_core_client(pods): + core_client = mock.MagicMock() + core_client.pods = pods + return core_client + + +def run_check(module_path, class_name, core_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch(f"{module_path}.core_client", new=core_client), + ): + check_module = importlib.import_module(module_path) + check = getattr(check_module, class_name)() + return check.execute() diff --git a/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py b/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py new file mode 100644 index 0000000000..41663daab3 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py @@ -0,0 +1,82 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = ( + "prowler.providers.kubernetes.services.core.core_cpu_limits_set.core_cpu_limits_set" +) +CLASS = "core_cpu_limits_set" + + +class TestCoreCpuLimitsSet: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_cpu_limit_set_pass(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": "500m"}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod regular containers have CPU limits configured." + ) + + def test_cpu_limit_missing_fail(self): + pod = make_pod(containers={"app": make_container(resources=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a CPU limit configured." + ) + + def test_empty_cpu_limit_fail(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": ""}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", resources={"limits": {"cpu": "500m"}} + ), + "sidecar": make_container(name="sidecar", resources={"limits": {}}), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a CPU limit configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": "500m"}})}, + init_containers={"init": make_container(name="init", resources=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", resources=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py b/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py new file mode 100644 index 0000000000..47fcec1b1e --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py @@ -0,0 +1,80 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_cpu_requests_set.core_cpu_requests_set" +CLASS = "core_cpu_requests_set" + + +class TestCoreCpuRequestsSet: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_cpu_request_set_pass(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": "100m"}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod regular containers have CPU requests configured." + ) + + def test_cpu_request_missing_fail(self): + pod = make_pod(containers={"app": make_container(resources={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a CPU request configured." + ) + + def test_empty_cpu_request_fail(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": ""}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", resources={"requests": {"cpu": "100m"}} + ), + "sidecar": make_container(name="sidecar", resources=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a CPU request configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": "100m"}})}, + init_containers={"init": make_container(name="init", resources=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", resources=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py b/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py new file mode 100644 index 0000000000..a5e62e8157 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py @@ -0,0 +1,572 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed" +CLASS = "core_image_tag_fixed" + + +class Test_core_image_tag_fixed: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 0 + + def test_image_tag_fixed(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_pod_without_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) + + def test_image_tag_latest(self): + container = Container( + name="test-container", + image="nginx:latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_tag_blank(self): + container = Container( + name="test-container", + image="nginx", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_with_digest(self): + container = Container( + name="test-container", + image="nginx@sha256:abc123def456", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_image_with_registry_port_and_no_tag(self): + container = Container( + name="test-container", + image="localhost:5000/nginx", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_tag_uppercase_latest(self): + container = Container( + name="test-container", + image="nginx:Latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_with_empty_tag(self): + container = Container( + name="test-container", + image="nginx:", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_mixed_containers_fails_on_first_unfixed_image(self): + fixed_container = Container( + name="fixed-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + latest_container = Container( + name="latest-container", + image="busybox:latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "fixed-container": fixed_container, + "latest-container": latest_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container latest-container with image 'busybox:latest' that does not use a fixed tag." + ) + + def test_init_container_image_tag_latest(self): + pod = make_pod( + containers={"app": make_container(image="nginx:1.25.3")}, + init_containers={ + "init": make_container(name="init", image="busybox:latest") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container init with image 'busybox:latest' that does not use a fixed tag." + ) + + def test_ephemeral_container_image_tag_blank(self): + pod = make_pod( + containers={"app": make_container(image="nginx:1.25.3")}, + ephemeral_containers={ + "debug": make_container(name="debug", image="busybox") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container debug with image 'busybox' that does not use a fixed tag." + ) + + def test_init_and_ephemeral_image_tags_fixed_without_regular_containers(self): + pod = make_pod( + containers=None, + init_containers={"init": make_container(name="init", image="busybox:1.36")}, + ephemeral_containers={ + "debug": make_container(name="debug", image="debug@sha256:abc123") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) diff --git a/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py b/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py new file mode 100644 index 0000000000..cc2d1915a5 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py @@ -0,0 +1,83 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_liveness_probe_configured.core_liveness_probe_configured" +CLASS = "core_liveness_probe_configured" + + +class TestCoreLivenessProbeConfigured: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_liveness_probe_configured_pass(self): + pod = make_pod( + containers={"app": make_container(liveness_probe={"http_get": {}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has liveness probes configured for all regular containers." + ) + + def test_liveness_probe_missing_fail(self): + pod = make_pod(containers={"app": make_container(liveness_probe=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a liveness probe configured." + ) + + def test_empty_liveness_probe_fail(self): + pod = make_pod(containers={"app": make_container(liveness_probe={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a liveness probe configured." + ) + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container(name="app", liveness_probe={"http_get": {}}), + "sidecar": make_container(name="sidecar", liveness_probe=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a liveness probe configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(liveness_probe={"http_get": {}})}, + init_containers={"init": make_container(name="init", liveness_probe=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", liveness_probe=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py b/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py new file mode 100644 index 0000000000..f3e37ae7f7 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py @@ -0,0 +1,313 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +class Test_core_memory_limits_set: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 0 + + def test_memory_limits_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi", "cpu": "500m"}, + "requests": {"memory": "64Mi", "cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory limits set on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_memory_limits_set_with_no_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory limits set on all containers." + ) + + def test_memory_limits_not_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory limits set on container test-container." + ) + + def test_memory_limits_missing_memory_key(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"cpu": "500m"}, + "requests": {"memory": "64Mi"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_mixed_containers_fails_on_missing_memory_limit(self): + limited_container = Container( + name="limited-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={"limits": {"memory": "128Mi"}}, + ) + unlimited_container = Container( + name="unlimited-container", + image="busybox:1.36", + command=None, + ports=None, + env=None, + security_context={}, + resources={"requests": {"memory": "64Mi"}}, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "limited-container": limited_container, + "unlimited-container": unlimited_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory limits set on container unlimited-container." + ) diff --git a/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py b/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py new file mode 100644 index 0000000000..8da3225abd --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py @@ -0,0 +1,313 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +class Test_core_memory_requests_set: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 0 + + def test_memory_requests_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi", "cpu": "500m"}, + "requests": {"memory": "64Mi", "cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory requests set on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_memory_requests_set_with_no_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory requests set on all containers." + ) + + def test_memory_requests_not_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory requests set on container test-container." + ) + + def test_memory_requests_missing_memory_key(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi"}, + "requests": {"cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_mixed_containers_fails_on_missing_memory_request(self): + requested_container = Container( + name="requested-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={"requests": {"memory": "64Mi"}}, + ) + unrequested_container = Container( + name="unrequested-container", + image="busybox:1.36", + command=None, + ports=None, + env=None, + security_context={}, + resources={"limits": {"memory": "128Mi"}}, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "requested-container": requested_container, + "unrequested-container": unrequested_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory requests set on container unrequested-container." + ) diff --git a/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py b/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py new file mode 100644 index 0000000000..ca316545ae --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py @@ -0,0 +1,83 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_readiness_probe_configured.core_readiness_probe_configured" +CLASS = "core_readiness_probe_configured" + + +class TestCoreReadinessProbeConfigured: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_readiness_probe_configured_pass(self): + pod = make_pod( + containers={"app": make_container(readiness_probe={"http_get": {}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has readiness probes configured for all regular containers." + ) + + def test_readiness_probe_missing_fail(self): + pod = make_pod(containers={"app": make_container(readiness_probe=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a readiness probe configured." + ) + + def test_empty_readiness_probe_fail(self): + pod = make_pod(containers={"app": make_container(readiness_probe={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a readiness probe configured." + ) + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container(name="app", readiness_probe={"http_get": {}}), + "sidecar": make_container(name="sidecar", readiness_probe=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a readiness probe configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(readiness_probe={"http_get": {}})}, + init_containers={"init": make_container(name="init", readiness_probe=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", readiness_probe=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/rbac/lib/role_permissions_test.py b/tests/providers/kubernetes/services/rbac/lib/role_permissions_test.py index 696550c06c..94028547e9 100644 --- a/tests/providers/kubernetes/services/rbac/lib/role_permissions_test.py +++ b/tests/providers/kubernetes/services/rbac/lib/role_permissions_test.py @@ -6,90 +6,92 @@ from prowler.providers.kubernetes.services.rbac.rbac_service import Rule class TestCheckRolePermissions: def test_is_rule_allowing_permissions(self): - # Define some sample rules, resources, and verbs for testing rules = [ - # Rule 1: Allows 'get' and 'list' on 'pods' and 'services' Rule(resources=["pods", "services"], verbs=["get", "list"]), - # Rule 2: Allows 'create' and 'delete' on 'deployments' Rule(resources=["deployments"], verbs=["create", "delete"]), ] - resources = ["pods", "deployments"] - verbs = ["get", "create"] - - assert is_rule_allowing_permissions(rules, resources, verbs) + assert is_rule_allowing_permissions( + rules, ["pods", "deployments"], ["get", "create"] + ) def test_no_permissions(self): - # Test when there are no rules - rules = [] - resources = ["pods", "deployments"] - verbs = ["get", "create"] - - assert not is_rule_allowing_permissions(rules, resources, verbs) + assert not is_rule_allowing_permissions([], ["pods"], ["get"]) def test_no_matching_rules(self): - # Test when there are rules, but none match the specified resources and verbs rules = [ Rule(resources=["services"], verbs=["get", "list"]), Rule(resources=["pods"], verbs=["create", "delete"]), ] - resources = ["deployments", "configmaps"] - verbs = ["get", "create"] - - assert not is_rule_allowing_permissions(rules, resources, verbs) + assert not is_rule_allowing_permissions( + rules, ["deployments", "configmaps"], ["get", "create"] + ) def test_empty_rules(self): - # Test when the rules list is empty - rules = [] - resources = ["pods", "deployments"] - verbs = ["get", "create"] - - assert not is_rule_allowing_permissions(rules, resources, verbs) + assert not is_rule_allowing_permissions([], ["pods"], ["get"]) def test_empty_resources_and_verbs(self): - # Test when resources and verbs are empty lists - rules = [ - Rule(resources=["pods"], verbs=["get"]), - Rule(resources=["services"], verbs=["list"]), - ] - resources = [] - verbs = [] - - assert not is_rule_allowing_permissions(rules, resources, verbs) + rules = [Rule(resources=["pods"], verbs=["get"])] + assert not is_rule_allowing_permissions(rules, [], []) def test_matching_rule_with_empty_resources_or_verbs(self): - # Test when a rule matches, but either resources or verbs are empty + rules = [Rule(resources=["pods"], verbs=["get"])] + assert not is_rule_allowing_permissions(rules, [], ["get"]) + assert not is_rule_allowing_permissions(rules, ["pods"], []) + + def test_rule_with_non_matching_api_group(self): + rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=["apps"])] + assert not is_rule_allowing_permissions(rules, ["pods"], ["get"]) + + def test_rule_with_matching_api_group(self): + rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=[""])] + assert is_rule_allowing_permissions(rules, ["pods"], ["get"]) + + def test_default_api_group_is_core(self): + rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=None)] + assert is_rule_allowing_permissions(rules, ["pods"], ["get"]) + + def test_rule_with_empty_api_groups_does_not_match_non_core_request(self): + rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=None)] + assert not is_rule_allowing_permissions( + rules, ["pods"], ["get"], ["admissionregistration.k8s.io"] + ) + + def test_non_core_rule_does_not_match_without_api_groups_argument(self): rules = [ - Rule(resources=["pods"], verbs=["get"]), - Rule(resources=["services"], verbs=["list"]), + Rule( + resources=["validatingwebhookconfigurations"], + verbs=["create"], + apiGroups=["admissionregistration.k8s.io"], + ) ] - resources = [] - verbs = ["get"] + assert not is_rule_allowing_permissions( + rules, ["validatingwebhookconfigurations"], ["create"] + ) - assert not is_rule_allowing_permissions(rules, resources, verbs) - - resources = ["pods"] - verbs = [] - - assert not is_rule_allowing_permissions(rules, resources, verbs) - - def test_rule_with_ignored_api_groups(self): - # Test when a rule has apiGroups that are not relevant + def test_explicit_non_core_api_group(self): rules = [ - Rule(resources=["pods"], verbs=["get"], apiGroups=["test"]), - Rule(resources=["services"], verbs=["list"], apiGroups=["test2"]), + Rule( + resources=["validatingwebhookconfigurations"], + verbs=["create"], + apiGroups=["admissionregistration.k8s.io"], + ) ] - resources = ["pods"] - verbs = ["get"] + assert is_rule_allowing_permissions( + rules, + ["validatingwebhookconfigurations"], + ["create"], + ["admissionregistration.k8s.io"], + ) - assert not is_rule_allowing_permissions(rules, resources, verbs) + def test_rule_with_wildcard_api_group(self): + rules = [Rule(resources=["pods"], verbs=["get"], apiGroups=["*"])] + assert is_rule_allowing_permissions(rules, ["pods"], ["get"]) + assert is_rule_allowing_permissions(rules, ["pods"], ["get"], ["apps"]) - def test_rule_with_relevant_api_groups(self): - # Test when a rule has apiGroups that are relevant - rules = [ - Rule(resources=["pods"], verbs=["get"], apiGroups=["", "v1"]), - Rule(resources=["services"], verbs=["list"], apiGroups=["test2"]), - ] - resources = ["pods"] - verbs = ["get"] + def test_rule_with_wildcard_resources(self): + rules = [Rule(resources=["*"], verbs=["get"], apiGroups=[""])] + assert is_rule_allowing_permissions(rules, ["pods"], ["get"]) - assert is_rule_allowing_permissions(rules, resources, verbs) + def test_rule_with_wildcard_verbs(self): + rules = [Rule(resources=["pods"], verbs=["*"], apiGroups=[""])] + assert is_rule_allowing_permissions(rules, ["pods"], ["get"]) diff --git a/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml new file mode 100644 index 0000000000..60a3edef84 --- /dev/null +++ b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "E1AF1B6C-1111-2222-3333-444455556666": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "admin" diff --git a/tests/providers/linode/lib/mutelist/linode_mutelist_test.py b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py new file mode 100644 index 0000000000..f122e5bcae --- /dev/null +++ b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py @@ -0,0 +1,98 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist + +MUTELIST_FIXTURE_PATH = ( + "tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml" +) + + +class Test_linode_mutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = LinodeMutelist(mutelist_path=MUTELIST_FIXTURE_PATH) + + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + assert mutelist.mutelist == mutelist_fixture + assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH + + def test_get_mutelist_file_from_local_file_non_existent(self): + mutelist_path = "tests/providers/linode/lib/mutelist/fixtures/not_present" + mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = LinodeMutelist(mutelist_content=mutelist_fixture) + + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path is None + + def test_is_finding_muted(self): + mutelist_content = { + "Accounts": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["admin"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) + + def test_is_finding_not_muted(self): + mutelist_content = { + "Accounts": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["other-user"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert not mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) diff --git a/tests/providers/linode/linode_fixtures.py b/tests/providers/linode/linode_fixtures.py new file mode 100644 index 0000000000..5786ffe881 --- /dev/null +++ b/tests/providers/linode/linode_fixtures.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + +# Linode Identity +USERNAME = "admin" +EMAIL = "admin@example.com" +ACCOUNT_ID = "E1AF1B6C-1111-2222-3333-444455556666" + +# Linode Credentials +TOKEN = "fake-linode-token-for-testing" + + +def set_mocked_linode_provider( + username: str = USERNAME, + email: str = EMAIL, + account_id: str = ACCOUNT_ID, +): + """Return a mocked LinodeProvider with identity and session set.""" + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + provider.session = LinodeSession( + client=MagicMock(), + token=TOKEN, + ) + return provider diff --git a/tests/providers/linode/linode_metadata_test.py b/tests/providers/linode/linode_metadata_test.py new file mode 100644 index 0000000000..9409c468af --- /dev/null +++ b/tests/providers/linode/linode_metadata_test.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from prowler.lib.check.models import CheckMetadata + +EXPECTED_SERVICE_NAMES = { + "administration": "administration", + "compute": "compute", + "networking": "networking", +} + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_is_valid(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + assert metadata.Provider == "linode" + assert metadata.CheckID == metadata_file.stem.replace(".metadata", "") + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_checks_metadata_use_canonical_hub_urls(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + url = metadata.Remediation.Recommendation.Url + assert not url.startswith( + "https://hub.prowler.com/checks/linode/" + ), f"{metadata_file}: non-canonical hub URL {url}" + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_uses_product_area_service_names(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + service_folder = metadata_file.relative_to( + Path("prowler/providers/linode/services") + ).parts[0] + + assert metadata.ServiceName == EXPECTED_SERVICE_NAMES[service_folder] diff --git a/tests/providers/linode/linode_provider_test.py b/tests/providers/linode/linode_provider_test.py new file mode 100644 index 0000000000..67df6ccc20 --- /dev/null +++ b/tests/providers/linode/linode_provider_test.py @@ -0,0 +1,146 @@ +import os +from unittest import mock + +import pytest + +from prowler.providers.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeInvalidRegionError, +) +from prowler.providers.linode.linode_provider import LinodeProvider +from prowler.providers.linode.models import LinodeIdentityInfo, LinodeSession +from tests.providers.linode.linode_fixtures import ( + ACCOUNT_ID, + EMAIL, + TOKEN, + USERNAME, +) + + +class TestLinodeProvider_setup_session: + def test_missing_token_raises_credentials_error(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.setup_session() + + def test_returns_session_with_token(self): + session = LinodeProvider.setup_session(token=TOKEN) + assert isinstance(session, LinodeSession) + assert session.token == TOKEN + assert session.client is not None + + def test_reads_token_from_env(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": TOKEN}, clear=False): + session = LinodeProvider.setup_session() + assert session.token == TOKEN + + +class TestLinodeProvider_setup_identity: + def _build_session(self): + client = mock.MagicMock() + return LinodeSession(client=client, token=TOKEN) + + def test_resolves_identity_from_profile_and_account(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + + account = mock.MagicMock() + account.euuid = ACCOUNT_ID + session.client.account.return_value = account + + identity = LinodeProvider.setup_identity(session) + + assert isinstance(identity, LinodeIdentityInfo) + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id == ACCOUNT_ID + + def test_invalid_token_raises_authentication_error(self): + # An invalid token fails the profile call (any valid token can read its + # own profile), so the scan must abort instead of returning empty data. + session = self._build_session() + session.client.profile.side_effect = Exception("[401] Invalid Token") + + with pytest.raises(LinodeAuthenticationError): + LinodeProvider.setup_identity(session) + + def test_identity_with_account_failure_still_returns(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + session.client.account.side_effect = Exception("forbidden") + + identity = LinodeProvider.setup_identity(session) + + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id is None + + +class TestLinodeProvider_test_connection: + def test_successful_connection(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + session = mock.MagicMock() + session.client.profile.return_value = mock.MagicMock() + mock_session.return_value = session + + conn = LinodeProvider.test_connection(token=TOKEN, raise_on_exception=False) + + assert conn.is_connected is True + + def test_missing_credentials(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + conn = LinodeProvider.test_connection(token=None, raise_on_exception=False) + assert conn.is_connected is False + + def test_connection_failure_raises_when_requested(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + mock_session.side_effect = LinodeCredentialsError( + file="test", message="No token" + ) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.test_connection(token=None, raise_on_exception=True) + + +class TestLinodeProvider_validate_regions: + def _session_with_regions(self, region_ids): + client = mock.MagicMock() + client.regions.return_value = [mock.MagicMock(id=rid) for rid in region_ids] + return LinodeSession(client=client, token=TOKEN) + + def test_no_regions_returns_none(self): + session = self._session_with_regions(["eu-central", "us-east"]) + assert LinodeProvider.validate_regions(session, None) is None + + def test_valid_regions_returns_set(self): + session = self._session_with_regions(["eu-central", "us-east", "ap-south"]) + result = LinodeProvider.validate_regions(session, ["eu-central", "us-east"]) + assert result == {"eu-central", "us-east"} + + def test_invalid_region_raises(self): + session = self._session_with_regions(["eu-central", "us-east"]) + with pytest.raises(LinodeInvalidRegionError): + LinodeProvider.validate_regions(session, ["eu-central", "nonexistent"]) + + def test_regions_api_failure_does_not_block(self): + # If the public regions list cannot be fetched, the scan proceeds with + # the requested regions instead of failing. + client = mock.MagicMock() + client.regions.side_effect = Exception("regions API error") + session = LinodeSession(client=client, token=TOKEN) + + result = LinodeProvider.validate_regions(session, ["eu-central"]) + + assert result == {"eu-central"} diff --git a/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py new file mode 100644 index 0000000000..a4e77c662a --- /dev/null +++ b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py @@ -0,0 +1,97 @@ +from unittest import mock + +from prowler.providers.linode.services.administration.administration_service import ( + User, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_administration_user_2fa_enabled: + def test_no_users(self): + administration_client = mock.MagicMock() + administration_client.users = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_user_with_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="admin", + email="admin@example.com", + tfa_enabled=True, + restricted=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "admin" + assert result[0].resource_id == "admin" + + def test_user_without_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="dev-user", + email="dev@example.com", + tfa_enabled=False, + restricted=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "dev-user" + assert result[0].resource_id == "dev-user" diff --git a/tests/providers/linode/services/administration/linode_administration_service_test.py b/tests/providers/linode/services/administration/linode_administration_service_test.py new file mode 100644 index 0000000000..b8393c073a --- /dev/null +++ b/tests/providers/linode/services/administration/linode_administration_service_test.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + + +def _mock_user(username="admin", email="admin@example.com", tfa=True, restricted=False): + user = MagicMock() + user.username = username + user.email = email + user.tfa_enabled = tfa + user.restricted = restricted + return user + + +def _build_service(account_users_return=None, account_users_side_effect=None): + """Build an AdministrationService with an isolated mock client.""" + service = object.__new__(AdministrationService) + service.users = [] + + # Build isolated mock hierarchy for client.account.users() + # Must explicitly create the users callable as a fresh MagicMock + # because check tests contaminate MagicMock class with users=[...] + users_callable = MagicMock() + if account_users_side_effect: + users_callable.side_effect = account_users_side_effect + else: + users_callable.return_value = account_users_return or [] + + account_mock = MagicMock() + account_mock.users = users_callable + + client_mock = MagicMock() + client_mock.account = account_mock + service.client = client_mock + return service + + +class TestLinodeAdministrationService: + def test_describe_users_parses_correctly(self): + mock_users = [ + _mock_user("admin", "admin@example.com", True, False), + _mock_user("reader", "reader@example.com", False, True), + ] + + service = _build_service(account_users_return=mock_users) + service._describe_users() + + assert len(service.users) == 2 + assert service.users[0].username == "admin" + assert service.users[0].tfa_enabled is True + assert service.users[1].username == "reader" + assert service.users[1].restricted is True + + def test_describe_users_handles_empty_list(self): + service = _build_service(account_users_return=[]) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_api_error(self): + service = _build_service(account_users_side_effect=Exception("API error")) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_null_fields(self): + """A user with null tfa_enabled/email must still be included with safe + defaults instead of being dropped by a ValidationError.""" + user = MagicMock() + user.username = "partial" + user.email = None + user.tfa_enabled = None + user.restricted = None + + service = _build_service(account_users_return=[user]) + service._describe_users() + + assert len(service.users) == 1 + assert service.users[0].username == "partial" + assert service.users[0].email == "" + assert service.users[0].tfa_enabled is False + assert service.users[0].restricted is False + + def test_describe_users_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(account_users_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_users() + + assert len(service.users) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "account:read_only" in logged diff --git a/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py new file mode 100644 index 0000000000..93e19fa6b1 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_backups_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_backups_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has the Backup service enabled." + ) + + def test_instance_backups_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have the Backup service enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py new file mode 100644 index 0000000000..d5023d2f94 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_disk_encryption_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_disk_encryption_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="enabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has disk encryption enabled." + ) + + def test_instance_disk_encryption_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="disabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have disk encryption enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py new file mode 100644 index 0000000000..db5ddaacd0 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_watchdog_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_watchdog_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has Watchdog (Lassie) enabled." + ) + + def test_instance_watchdog_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have Watchdog (Lassie) enabled." + ) diff --git a/tests/providers/linode/services/compute/linode_compute_service_test.py b/tests/providers/linode/services/compute/linode_compute_service_test.py new file mode 100644 index 0000000000..09b1a54e84 --- /dev/null +++ b/tests/providers/linode/services/compute/linode_compute_service_test.py @@ -0,0 +1,143 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.compute.compute_service import ( + ComputeService, +) + + +def _mock_instance( + id=1, + label="my-instance", + region="us-east", + status="running", + backups_enabled=True, + disk_encryption="enabled", + watchdog_enabled=True, + tags=None, +): + inst = MagicMock() + inst.id = id + inst.label = label + region_mock = MagicMock() + region_mock.id = region + inst.region = region_mock + inst.status = status + backups = MagicMock() + backups.enabled = backups_enabled + inst.backups = backups + inst.disk_encryption = disk_encryption + inst.watchdog_enabled = watchdog_enabled + inst.tags = tags or [] + return inst + + +def _build_service(linode_instances_return=None, linode_instances_side_effect=None): + """Build a ComputeService with an isolated mock client.""" + service = object.__new__(ComputeService) + service.instances = [] + + # Build isolated mock hierarchy for client.linode.instances() + # Must explicitly create the instances callable as a fresh MagicMock + # because check tests contaminate MagicMock class with instances=[...] + instances_callable = MagicMock() + if linode_instances_side_effect: + instances_callable.side_effect = linode_instances_side_effect + else: + instances_callable.return_value = linode_instances_return or [] + + linode_mock = MagicMock() + linode_mock.instances = instances_callable + + client_mock = MagicMock() + client_mock.linode = linode_mock + service.client = client_mock + return service + + +class TestLinodeComputeService: + def test_describe_instances_parses_correctly(self): + mock_instances = [ + _mock_instance(id=1, label="web-1", region="us-east"), + _mock_instance(id=2, label="db-1", region="eu-west", backups_enabled=False), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert len(service.instances) == 2 + assert service.instances[0].label == "web-1" + assert service.instances[0].region == "us-east" + assert service.instances[0].backups_enabled is True + assert service.instances[1].label == "db-1" + assert service.instances[1].backups_enabled is False + + def test_describe_instances_handles_empty_list(self): + service = _build_service(linode_instances_return=[]) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_handles_api_error(self): + service = _build_service(linode_instances_side_effect=Exception("API error")) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(linode_instances_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_instances() + + assert len(service.instances) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "linodes:read_only" in logged + + def test_describe_instances_disk_encryption(self): + mock_instances = [ + _mock_instance(id=1, disk_encryption="enabled"), + _mock_instance(id=2, disk_encryption="disabled"), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert service.instances[0].disk_encryption == "enabled" + assert service.instances[1].disk_encryption == "disabled" + + def test_describe_instances_region_filter_keeps_only_matching(self): + mock_instances = [ + _mock_instance(id=1, label="eu", region="eu-central"), + _mock_instance(id=2, label="us", region="us-east"), + _mock_instance(id=3, label="eu-2", region="eu-central"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = {"eu-central"} + + service._describe_instances() + + assert len(service.instances) == 2 + assert {i.label for i in service.instances} == {"eu", "eu-2"} + assert all(i.region == "eu-central" for i in service.instances) + + def test_describe_instances_no_region_filter_keeps_all(self): + mock_instances = [ + _mock_instance(id=1, region="eu-central"), + _mock_instance(id=2, region="us-east"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = None + + service._describe_instances() + + assert len(service.instances) == 2 diff --git a/tests/providers/linode/services/networking/linode_networking_service_test.py b/tests/providers/linode/services/networking/linode_networking_service_test.py new file mode 100644 index 0000000000..cd4f43f423 --- /dev/null +++ b/tests/providers/linode/services/networking/linode_networking_service_test.py @@ -0,0 +1,193 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + + +def _mock_rule( + protocol="TCP", ports="22", ipv4=None, ipv6=None, action="ACCEPT", label="" +): + rule = MagicMock() + rule.protocol = protocol + rule.ports = ports + rule.action = action + rule.label = label + addresses = MagicMock() + addresses.ipv4 = ipv4 or [] + addresses.ipv6 = ipv6 or [] + rule.addresses = addresses + return rule + + +def _mock_firewall( + id=1, label="my-fw", status="enabled", inbound=None, outbound=None, tags=None +): + fw = MagicMock() + fw.id = id + fw.label = label + fw.status = status + fw.tags = tags or [] + rules = MagicMock() + rules.inbound = inbound or [] + rules.outbound = outbound or [] + rules.inbound_policy = "DROP" + rules.outbound_policy = "DROP" + fw.rules = rules + return fw + + +def _build_service( + networking_firewalls_return=None, networking_firewalls_side_effect=None +): + """Build a NetworkingService instance with a properly isolated mock client.""" + service = object.__new__(NetworkingService) + service.firewalls = [] + + firewalls_callable = MagicMock() + if networking_firewalls_side_effect: + firewalls_callable.side_effect = networking_firewalls_side_effect + else: + firewalls_callable.return_value = networking_firewalls_return or [] + + networking_mock = MagicMock() + networking_mock.firewalls = firewalls_callable + + client_mock = MagicMock() + client_mock.networking = networking_mock + service.client = client_mock + return service + + +class TestLinodeNetworkingService: + def test_describe_firewalls_parses_correctly(self): + inbound_rules = [ + _mock_rule("TCP", "22", ipv4=["192.168.1.0/24"]), + _mock_rule("TCP", "443", ipv4=["0.0.0.0/0"]), + ] + mock_fws = [ + _mock_firewall(id=1, label="prod-fw", inbound=inbound_rules), + ] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "prod-fw" + assert len(service.firewalls[0].inbound_rules) == 2 + assert service.firewalls[0].inbound_rules[0].ports == "22" + assert service.firewalls[0].inbound_rules[0].addresses_ipv4 == [ + "192.168.1.0/24" + ] + assert service.firewalls[0].inbound_rules[1].addresses_ipv4 == ["0.0.0.0/0"] + + def test_describe_firewalls_handles_empty_list(self): + service = _build_service(networking_firewalls_return=[]) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_handles_api_error(self): + service = _build_service( + networking_firewalls_side_effect=Exception("API error") + ) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(networking_firewalls_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_firewalls() + + assert len(service.firewalls) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "firewall:read_only" in logged + + def test_describe_firewalls_device_fetch_error_yields_none_count(self): + """A devices fetch failure must leave attached_devices_count as None + (undetermined) rather than 0, to avoid a false 'not assigned' FAIL.""" + + class _NoDevicesFw: + id = 5 + label = "no-devices-fw" + status = "enabled" + tags = [] + + @property + def devices(self): + raise Exception("devices API error") + + @property + def rules(self): + r = MagicMock() + r.inbound = [] + r.outbound = [] + r.inbound_policy = "DROP" + r.outbound_policy = "DROP" + return r + + service = _build_service(networking_firewalls_return=[_NoDevicesFw()]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].attached_devices_count is None + + def test_describe_firewalls_handles_null_rule_fields(self): + """Rule fields returned as explicit null must fall back to defaults + instead of raising a ValidationError that drops the whole firewall.""" + rule = MagicMock() + rule.protocol = None + rule.ports = None + rule.action = None + rule.label = None + addresses = MagicMock() + addresses.ipv4 = None + addresses.ipv6 = None + rule.addresses = addresses + + mock_fws = [_mock_firewall(id=6, label="null-rule-fw", inbound=[rule])] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + parsed = service.firewalls[0].inbound_rules[0] + assert parsed.protocol == "TCP" + assert parsed.action == "ACCEPT" + assert parsed.ports == "" + assert parsed.addresses_ipv4 == [] + assert parsed.addresses_ipv6 == [] + assert parsed.label == "" + + def test_describe_firewalls_handles_rules_fetch_error(self): + """Firewall is still added even if rules fail to load.""" + + class _BrokenFw: + id = 1 + label = "broken-fw" + status = "enabled" + tags = [] + devices = [] + + @property + def rules(self): + raise Exception("rules API error") + + fw = _BrokenFw() + + service = _build_service(networking_firewalls_return=[fw]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "broken-fw" + assert len(service.firewalls[0].inbound_rules) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py new file mode 100644 index 0000000000..72d8a9f454 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py @@ -0,0 +1,142 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_assigned_to_devices: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="assigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=2, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "assigned-fw" + + def test_firewall_not_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="unassigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=0, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "unassigned-fw" + + def test_firewall_device_count_undetermined_is_skipped(self): + # attached_devices_count is None when the devices fetch failed; the + # firewall must be skipped rather than reported as a false FAIL. + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=102, + label="undetermined-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=None, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py new file mode 100644 index 0000000000..1543a45656 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_inbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_inbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="ACCEPT", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py new file mode 100644 index 0000000000..5a586d710c --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_outbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_outbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="ACCEPT", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py new file mode 100644 index 0000000000..9b8272c2f2 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_inbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-inbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-inbound-fw" + + def test_inbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-inbound-fw", + status="enabled", + inbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https", + ) + ], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-inbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py new file mode 100644 index 0000000000..658569c940 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_outbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-outbound-fw" + + def test_outbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https-egress", + ) + ], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-outbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py new file mode 100644 index 0000000000..632d94ca2e --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_status_enabled: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_enabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="enabled-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "enabled-fw" + + def test_firewall_disabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="disabled-fw", + status="disabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "disabled-fw" diff --git a/tests/providers/m365/lib/powershell/m365_powershell_test.py b/tests/providers/m365/lib/powershell/m365_powershell_test.py index 84ae2ab129..30e2eedcad 100644 --- a/tests/providers/m365/lib/powershell/m365_powershell_test.py +++ b/tests/providers/m365/lib/powershell/m365_powershell_test.py @@ -101,9 +101,9 @@ class Testm365PowerShell: # Call original init_credential to verify application authentication setup M365PowerShell.init_credential(session, credentials) - session.execute.assert_any_call('$clientID = "test_client_id"') - session.execute.assert_any_call('$clientSecret = "test_client_secret"') - session.execute.assert_any_call('$tenantID = "test_tenant_id"') + session.execute.assert_any_call("$clientID = 'test_client_id'") + session.execute.assert_any_call("$clientSecret = 'test_client_secret'") + session.execute.assert_any_call("$tenantID = 'test_tenant_id'") session.execute.assert_any_call( '$graphtokenBody = @{ Grant_Type = "client_credentials"; Scope = "https://graph.microsoft.com/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }' ) @@ -112,6 +112,128 @@ class Testm365PowerShell: ) session.close() + @patch("subprocess.Popen") + def test_init_credential_special_chars_in_secret(self, mock_popen): + """Test that secrets with $, !, #, and ' are preserved via single-quote escaping.""" + mock_process = MagicMock() + mock_popen.return_value = mock_process + credentials = M365Credentials( + client_id="test_client_id", + client_secret="Pa$$w0rd!#'", + tenant_id="test_tenant_id", + ) + identity = M365IdentityInfo( + identity_id="test_id", + identity_type="Service Principal", + tenant_id="test_tenant", + tenant_domain="example.com", + tenant_domains=["example.com"], + location="test_location", + ) + with patch.object(M365PowerShell, "init_credential"): + session = M365PowerShell(credentials, identity) + + session.execute = MagicMock() + M365PowerShell.init_credential(session, credentials) + + # Single quotes prevent PowerShell $$ expansion; + # embedded ' is escaped as '' per PowerShell convention + session.execute.assert_any_call("$clientSecret = 'Pa$$w0rd!#'''") + + @pytest.mark.parametrize( + "secret, expected_command", + [ + # Plain secret: single quotes behave like the old double quotes. + ("simplesecret", "$clientSecret = 'simplesecret'"), + # $ must NOT expand as a PowerShell variable. + ("Pa$$w0rd", "$clientSecret = 'Pa$$w0rd'"), + # $(...) subexpression must stay literal and never execute. + ("a$(whoami)b", "$clientSecret = 'a$(whoami)b'"), + # ${var} expansion must stay literal too. + ("a${env:PATH}b", "$clientSecret = 'a${env:PATH}b'"), + # Backtick is a literal char inside single quotes (not an escape). + ("pass`word", "$clientSecret = 'pass`word'"), + # Double quotes are literal inside single quotes. + ('pa"ss"word', "$clientSecret = 'pa\"ss\"word'"), + # A single quote is doubled per PowerShell escaping rules. + ("O'Brien", "$clientSecret = 'O''Brien'"), + # Consecutive single quotes each get doubled. + ("a''b", "$clientSecret = 'a''''b'"), + # A payload with quotes and metacharacters stays a single literal + # string and cannot break out of the quoting. + ( + "'; Remove-Item -Recurse -Force; '", + "$clientSecret = '''; Remove-Item -Recurse -Force; '''", + ), + # Other shell metacharacters are preserved verbatim (no sanitize()). + ("p@ss!#%&;w0rd", "$clientSecret = 'p@ss!#%&;w0rd'"), + # Newline embedded in the secret is preserved verbatim. + ("line1\nline2", "$clientSecret = 'line1\nline2'"), + # Empty secret renders an empty single-quoted string. + ("", "$clientSecret = ''"), + # None secret is coerced to an empty single-quoted string. + (None, "$clientSecret = ''"), + ], + ) + @patch("subprocess.Popen") + def test_init_credential_secret_escaping_edge_cases( + self, mock_popen, secret, expected_command + ): + """The client_secret is single-quote escaped, preserving every special + character verbatim with no PowerShell expansion or subexpression + evaluation.""" + mock_process = MagicMock() + mock_popen.return_value = mock_process + credentials = M365Credentials( + client_id="test_client_id", + client_secret=secret, + tenant_id="test_tenant_id", + ) + identity = M365IdentityInfo( + identity_id="test_id", + identity_type="Service Principal", + tenant_id="test_tenant", + tenant_domain="example.com", + tenant_domains=["example.com"], + location="test_location", + ) + with patch.object(M365PowerShell, "init_credential"): + session = M365PowerShell(credentials, identity) + + session.execute = MagicMock() + M365PowerShell.init_credential(session, credentials) + + session.execute.assert_any_call(expected_command) + + @patch("subprocess.Popen") + def test_init_credential_sanitizes_client_and_tenant_id(self, mock_popen): + """client_id and tenant_id are sanitized in the Application Auth path, + stripping shell metacharacters while keeping UUID-safe characters.""" + mock_process = MagicMock() + mock_popen.return_value = mock_process + credentials = M365Credentials( + client_id="abc-123; Remove-Item", + client_secret="secret", + tenant_id="def-456 && whoami", + ) + identity = M365IdentityInfo( + identity_id="test_id", + identity_type="Service Principal", + tenant_id="test_tenant", + tenant_domain="example.com", + tenant_domains=["example.com"], + location="test_location", + ) + with patch.object(M365PowerShell, "init_credential"): + session = M365PowerShell(credentials, identity) + + session.execute = MagicMock() + M365PowerShell.init_credential(session, credentials) + + # sanitize() removes ';', spaces and '&' but keeps letters, digits and '-'. + session.execute.assert_any_call("$clientID = 'abc-123Remove-Item'") + session.execute.assert_any_call("$tenantID = 'def-456whoami'") + @patch("subprocess.Popen") def test_remove_ansi(self, mock_popen): credentials = M365Credentials( @@ -562,6 +684,12 @@ class Testm365PowerShell: assert result is True assert session.execute.call_count == 3 + # The secret must be referenced as a bare PowerShell variable. Wrapping it + # in double quotes ("$clientSecret") would re-expand special chars that were + # correctly escaped during assignment, so assert the exact command. + session.execute.assert_any_call( + "$SecureSecret = ConvertTo-SecureString $clientSecret -AsPlainText -Force" + ) session.execute_connect.assert_called_once_with( 'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"' ) diff --git a/tests/providers/m365/m365_provider_test.py b/tests/providers/m365/m365_provider_test.py index f2354ea7e7..9f4a151249 100644 --- a/tests/providers/m365/m365_provider_test.py +++ b/tests/providers/m365/m365_provider_test.py @@ -1,6 +1,7 @@ +import asyncio import base64 import os -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch from uuid import uuid4 import pytest @@ -1535,19 +1536,17 @@ class TestM365Provider: TENANT_ID, CLIENT_ID, None, b"fake_certificate_data", certificate_path ) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_content_success( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with valid certificate content""" certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8") - # Mock the async call - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}] + # Mock the async call result + mock_asyncio_run.return_value = [{"id": "domain.com"}] # Mock credential and graph client mock_credential = MagicMock() @@ -1563,19 +1562,17 @@ class TestM365Provider: mock_cert_cred.assert_called_once() mock_graph.assert_called_once_with(credentials=mock_credential) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_content_failure( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with certificate content that fails validation""" certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8") # Mock the async call to return empty result (invalid certificate) - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = None + mock_asyncio_run.return_value = None # Mock credential and graph client mock_credential = MagicMock() @@ -1591,19 +1588,17 @@ class TestM365Provider: assert "certificate content is not valid" in str(exception.value) @patch("builtins.open", mock_open(read_data=b"fake_certificate_data")) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_path_success( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with valid certificate path""" certificate_path = "/path/to/cert.pem" - # Mock the async call - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}] + # Mock the async call result + mock_asyncio_run.return_value = [{"id": "domain.com"}] # Mock credential and graph client mock_credential = MagicMock() @@ -1618,19 +1613,17 @@ class TestM365Provider: mock_graph.assert_called_once_with(credentials=mock_credential) @patch("builtins.open", mock_open(read_data=b"fake_certificate_data")) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_path_failure( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with certificate path that fails validation""" certificate_path = "/path/to/cert.pem" # Mock the async call to return empty result (invalid certificate) - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = None + mock_asyncio_run.return_value = None # Mock credential and graph client mock_credential = MagicMock() @@ -1804,3 +1797,94 @@ class TestM365Provider: assert "Missing environment variable M365_CERTIFICATE_CONTENT" in str( exception.value ) + + +class TestM365ProviderEventLoop: + """Regression for Celery workers on Python 3.12 where + asyncio.get_event_loop() raised + `RuntimeError: There is no current event loop in thread 'MainThread'.` + M365Provider.setup_identity and M365Provider.validate_static_credentials + must work without a pre-existing loop in the current thread.""" + + def _without_event_loop(self, callable_): + # Simulate the Celery worker state: no event loop registered for the + # current thread. + asyncio.set_event_loop(None) + try: + return callable_() + finally: + # Re-arm a loop so sibling tests that rely on the default don't + # bleed into each other. + asyncio.set_event_loop(asyncio.new_event_loop()) + + def test_setup_identity_succeeds_without_active_event_loop(self): + domain = MagicMock() + domain.id = "tenant.onmicrosoft.com" + domain.is_default = True + + org = MagicMock() + org.id = TENANT_ID + + graph_client = MagicMock() + graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[domain])) + graph_client.organization.get = AsyncMock(return_value=MagicMock(value=[org])) + + session = MagicMock() + # `setup_identity` reads `session.credentials[0]._credential.client_id` + # when sp_env_auth is True to populate identity.identity_id. + session.credentials = [MagicMock()] + session.credentials[0]._credential.client_id = CLIENT_ID + + def call(): + with patch( + "prowler.providers.m365.m365_provider.GraphServiceClient", + return_value=graph_client, + ): + return M365Provider.setup_identity( + sp_env_auth=True, + browser_auth=False, + az_cli_auth=False, + certificate_auth=False, + session=session, + ) + + identity = self._without_event_loop(call) + + assert isinstance(identity, M365IdentityInfo) + assert identity.tenant_id == TENANT_ID + graph_client.domains.get.assert_awaited_once() + graph_client.organization.get.assert_awaited_once() + + def test_verify_client_certificate_content_without_active_event_loop(self): + # `verify_client` is the function the Sentry trace exercises through + # certificate-based credential validation; it must run an asyncio + # coroutine to call `client.domains.get()` and previously relied on + # `asyncio.get_event_loop()`. + graph_client = MagicMock() + graph_client.domains.get = AsyncMock( + return_value=MagicMock(value=[MagicMock()]) + ) + + def call(): + with ( + patch("prowler.providers.m365.m365_provider.CertificateCredential"), + patch( + "prowler.providers.m365.m365_provider.GraphServiceClient", + return_value=graph_client, + ), + patch( + "prowler.providers.m365.m365_provider.base64.b64decode", + return_value=b"cert-bytes", + ), + ): + M365Provider.verify_client( + tenant_id=TENANT_ID, + client_id=CLIENT_ID, + client_secret=None, + certificate_content="dGVzdA==", + certificate_path=None, + ) + + # Must not raise "There is no current event loop in thread 'MainThread'.". + self._without_event_loop(call) + graph_client.domains.get.assert_awaited_once() 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..4eedba05e1 100644 --- a/tests/providers/m365/services/admincenter/admincenter_service_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_service_test.py @@ -214,3 +214,87 @@ 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" + + +def test_admincenter__get_groups_handles_pagination(): + admincenter_service = AdminCenter.__new__(AdminCenter) + + groups_response_page_one = SimpleNamespace( + value=[ + SimpleNamespace( + id="id-1", + display_name="First Unified Group", + visibility="Private", + group_types=["Unified"], + ) + ], + odata_next_link="next-link", + ) + groups_response_page_two = SimpleNamespace( + value=[ + SimpleNamespace( + id="id-2", + display_name="Second Unified Group", + visibility="Public", + group_types=["Unified"], + ) + ], + odata_next_link=None, + ) + + groups_with_url_builder = SimpleNamespace( + get=AsyncMock(return_value=groups_response_page_two) + ) + with_url_mock = MagicMock(return_value=groups_with_url_builder) + groups_builder = SimpleNamespace( + get=AsyncMock(return_value=groups_response_page_one), + with_url=with_url_mock, + ) + admincenter_service.client = SimpleNamespace(groups=groups_builder) + + groups = asyncio.run(admincenter_service._get_groups()) + + assert len(groups) == 2 + assert groups_builder.get.await_count == 1 + with_url_mock.assert_called_once_with("next-link") + assert groups["id-1"].name == "First Unified Group" + assert groups["id-2"].name == "Second Unified Group" + assert groups["id-2"].visibility == "Public" diff --git a/tests/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused_test.py b/tests/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused_test.py new file mode 100644 index 0000000000..437ac76277 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_app_registration_client_secret_unused/entra_app_registration_client_secret_unused_test.py @@ -0,0 +1,282 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + AppRegistration, + PasswordCredential, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_app_registration_client_secret_unused: + def test_no_app_registrations(self): + """No app registrations in tenant: no findings.""" + 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_app_registration_client_secret_unused.entra_app_registration_client_secret_unused.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_client_secret_unused.entra_app_registration_client_secret_unused import ( + entra_app_registration_client_secret_unused, + ) + + entra_client.app_registrations = {} + + check = entra_app_registration_client_secret_unused() + result = check.execute() + + assert len(result) == 0 + + def test_app_no_password_credentials(self): + """App with no password credentials: expected PASS.""" + app_id = str(uuid4()) + app_name = "Test App Clean" + 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_app_registration_client_secret_unused.entra_app_registration_client_secret_unused.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_client_secret_unused.entra_app_registration_client_secret_unused import ( + entra_app_registration_client_secret_unused, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + password_credentials=[], + ) + } + + check = entra_app_registration_client_secret_unused() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"App registration {app_name} does not use password credentials." + ) + assert result[0].resource_name == app_name + assert result[0].resource_id == app_id + + def test_app_with_one_password_credential(self): + """App with one password credential: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Test App With Secret" + 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_app_registration_client_secret_unused.entra_app_registration_client_secret_unused.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_client_secret_unused.entra_app_registration_client_secret_unused import ( + entra_app_registration_client_secret_unused, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + password_credentials=[ + PasswordCredential( + key_id=str(uuid4()), + display_name="My Secret", + start_date_time="2024-01-01T00:00:00Z", + end_date_time="2025-01-01T00:00:00Z", + ), + ], + ) + } + + check = entra_app_registration_client_secret_unused() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 password credential(s)" in result[0].status_extended + assert "My Secret" in result[0].status_extended + assert result[0].resource_name == app_name + assert result[0].resource_id == app_id + + def test_app_with_expired_password_credential_still_fails(self): + """App with an expired password credential: still expected FAIL.""" + app_id = str(uuid4()) + app_name = "Legacy App" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + expired = datetime.now(timezone.utc) - timedelta(days=30) + + 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_app_registration_client_secret_unused.entra_app_registration_client_secret_unused.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_client_secret_unused.entra_app_registration_client_secret_unused import ( + entra_app_registration_client_secret_unused, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + password_credentials=[ + PasswordCredential( + key_id=str(uuid4()), + display_name="old-secret", + end_date_time=expired, + ), + ], + ) + } + + check = entra_app_registration_client_secret_unused() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 password credential(s)" in result[0].status_extended + assert result[0].resource_name == app_name + + def test_app_with_multiple_password_credentials(self): + """App with multiple password credentials: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Test App Multiple Secrets" + 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_app_registration_client_secret_unused.entra_app_registration_client_secret_unused.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_client_secret_unused.entra_app_registration_client_secret_unused import ( + entra_app_registration_client_secret_unused, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + password_credentials=[ + PasswordCredential( + key_id=str(uuid4()), + display_name="Secret 1", + end_date_time="2025-06-01T00:00:00Z", + ), + PasswordCredential( + key_id=str(uuid4()), + display_name="Secret 2", + end_date_time="2025-12-01T00:00:00Z", + ), + ], + ) + } + + check = entra_app_registration_client_secret_unused() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "2 password credential(s)" in result[0].status_extended + assert result[0].resource_name == app_name + + def test_multiple_apps_mixed(self): + """Multiple apps: one clean, one with secrets.""" + app_id_pass = str(uuid4()) + app_name_pass = "Clean App" + app_id_fail = str(uuid4()) + app_name_fail = "App With Secret" + 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_app_registration_client_secret_unused.entra_app_registration_client_secret_unused.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_client_secret_unused.entra_app_registration_client_secret_unused import ( + entra_app_registration_client_secret_unused, + ) + + entra_client.app_registrations = { + app_id_pass: AppRegistration( + id=app_id_pass, + app_id=str(uuid4()), + name=app_name_pass, + password_credentials=[], + ), + app_id_fail: AppRegistration( + id=app_id_fail, + app_id=str(uuid4()), + name=app_name_fail, + password_credentials=[ + PasswordCredential( + key_id=str(uuid4()), + display_name="Legacy Secret", + ), + ], + ), + } + + check = entra_app_registration_client_secret_unused() + result = check.execute() + + assert len(result) == 2 + + result_pass = next(r for r in result if r.resource_id == app_id_pass) + result_fail = next(r for r in result if r.resource_id == app_id_fail) + + assert result_pass.status == "PASS" + assert result_fail.status == "FAIL" + assert "Legacy Secret" in result_fail.status_extended diff --git a/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py b/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py index 31e7c06a1d..1147b64e8e 100644 --- a/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py +++ b/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py @@ -67,6 +67,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -104,6 +105,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -142,6 +144,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -178,6 +181,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -228,6 +232,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -275,6 +280,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -321,6 +327,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -368,6 +375,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -422,6 +430,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -457,6 +466,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -500,3 +510,117 @@ class Test_entra_break_glass_account_fido2_security_key_registered: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].resource_name == "BreakGlass1" + + def test_user_registration_details_permission_error(self): + """Test FAIL when there's a permission error reading user registration details.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + policy_id = str(uuid4()) + bg_user_id = str(uuid4()) + + entra_client.conditional_access_policies = { + policy_id: _make_policy(policy_id, excluded_users=[bg_user_id]), + } + entra_client.users = { + bg_user_id: User( + id=bg_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Cannot verify FIDO2 security key registration for break glass account BreakGlass1" + in result[0].status_extended + ) + assert "AuditLog.Read.All" in result[0].status_extended + assert result[0].resource_name == "BreakGlass1" + assert result[0].resource_id == bg_user_id + + def test_user_registration_details_permission_error_with_missing_user(self): + """Per-user emission and missing-user short-circuit on the error path. + + Two break-glass user IDs are excluded from all CAPs, but only one is + present in ``entra_client.users``. With ``user_registration_details_error`` + set, the present user must produce one preventive FAIL anchored to the + real user; the missing user must be skipped by the existing + ``if not user: continue`` guard rather than crash or yield a synthetic + finding. + """ + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + policy_id = str(uuid4()) + present_user_id = str(uuid4()) + missing_user_id = str(uuid4()) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id, + excluded_users=[present_user_id, missing_user_id], + ), + } + entra_client.users = { + present_user_id: User( + id=present_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + # missing_user_id intentionally absent — exercises the + # `if not user: continue` short-circuit inside the loop. + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + # One finding for the present user; the missing one is skipped. + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Cannot verify FIDO2 security key registration for break glass account BreakGlass1" + in result[0].status_extended + ) + assert "AuditLog.Read.All" in result[0].status_extended + assert result[0].resource == entra_client.users[present_user_id] + assert result[0].resource_name == "BreakGlass1" + assert result[0].resource_id == present_user_id diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users_test.py new file mode 100644 index 0000000000..f9991b0e49 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_all_apps_all_users/entra_conditional_access_policy_all_apps_all_users_test.py @@ -0,0 +1,858 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationEnforcedRestrictions, + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users" + + +def _make_session_controls(): + """Return default session controls for test policies.""" + return SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ) + + +def _make_conditions( + included_applications=None, + included_users=None, + excluded_applications=None, + excluded_users=None, + excluded_groups=None, + excluded_roles=None, +): + """Return Conditions with the given application and user scopes.""" + return Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=excluded_groups or [], + included_users=included_users or ["All"], + excluded_users=excluded_users or [], + included_roles=[], + excluded_roles=excluded_roles or [], + ), + client_app_types=[], + user_risk_levels=[], + ) + + +def _make_grant_controls(built_in_controls=None): + """Return GrantControls with the given built-in controls.""" + return GrantControls( + built_in_controls=built_in_controls or [ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + authentication_strength=None, + ) + + +class Test_entra_conditional_access_policy_all_apps_all_users: + def test_no_conditional_access_policies(self): + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy covers all cloud apps and all users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_disabled(self): + policy_id = str(uuid4()) + display_name = "All Apps All Users Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions(), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy covers all cloud apps and all users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_excluding_roles(self): + policy_id = str(uuid4()) + display_name = "All Users Except Role Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions( + excluded_roles=["62e90394-69f5-4237-9190-012177145e10"], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} targets all cloud apps and all users but includes exclusions. Review excluded users/groups/roles/applications and verify compensating policies protect excluded identities and apps." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_not_targeting_all_apps(self): + policy_id = str(uuid4()) + display_name = "Specific App Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions( + included_applications=["some-app-id"], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy covers all cloud apps and all users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_not_targeting_all_users(self): + policy_id = str(uuid4()) + display_name = "Specific Users Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions( + included_users=["some-user-id"], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy covers all cloud apps and all users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_excluding_applications(self): + policy_id = str(uuid4()) + display_name = "All Apps Except One Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions( + excluded_applications=["excluded-app-id"], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} targets all cloud apps and all users but includes exclusions. Review excluded users/groups/roles/applications and verify compensating policies protect excluded identities and apps." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_excluding_users(self): + policy_id = str(uuid4()) + display_name = "All Users Except One Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions( + excluded_users=["excluded-user-id"], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} targets all cloud apps and all users but includes exclusions. Review excluded users/groups/roles/applications and verify compensating policies protect excluded identities and apps." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_only_password_change(self): + policy_id = str(uuid4()) + display_name = "Password Change Only Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions(), + grant_controls=_make_grant_controls( + built_in_controls=[ + ConditionalAccessGrantControl.PASSWORD_CHANGE + ], + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy covers all cloud apps and all users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_enabled_for_reporting(self): + policy_id = str(uuid4()) + display_name = "All Apps All Users - Report Only" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions(), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} covers all cloud apps and all users but is only in report-only mode." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_enabled_all_apps_all_users(self): + policy_id = str(uuid4()) + display_name = "All Apps All Users Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions(), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} covers all cloud apps and all users." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_multiple_policies_first_disabled_second_enabled(self): + disabled_id = str(uuid4()) + enabled_id = str(uuid4()) + enabled_name = "All Apps All Users - Enabled" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + disabled_id: ConditionalAccessPolicy( + id=disabled_id, + display_name="All Apps All Users - Disabled", + conditions=_make_conditions(), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.DISABLED, + ), + enabled_id: ConditionalAccessPolicy( + id=enabled_id, + display_name=enabled_name, + conditions=_make_conditions(), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {enabled_name} covers all cloud apps and all users." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[enabled_id].dict() + ) + assert result[0].resource_name == enabled_name + assert result[0].resource_id == enabled_id + assert result[0].location == "global" + + def test_policy_with_block_grant_control(self): + policy_id = str(uuid4()) + display_name = "Block All Apps All Users" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=_make_conditions(), + grant_controls=_make_grant_controls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} covers all cloud apps and all users." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_manual_policy_does_not_prevent_later_pass(self): + manual_policy_id = str(uuid4()) + pass_policy_id = str(uuid4()) + manual_display_name = "All Apps All Users With Exclusions" + pass_display_name = "All Apps All Users Enforced" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + manual_policy_id: ConditionalAccessPolicy( + id=manual_policy_id, + display_name=manual_display_name, + conditions=_make_conditions( + excluded_users=["excluded-user-id"], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ), + pass_policy_id: ConditionalAccessPolicy( + id=pass_policy_id, + display_name=pass_display_name, + conditions=_make_conditions(), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {pass_display_name} covers all cloud apps and all users." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[pass_policy_id].dict() + ) + assert result[0].resource_name == pass_display_name + assert result[0].resource_id == pass_policy_id + + def test_policy_no_application_conditions(self): + policy_id = str(uuid4()) + display_name = "No App Conditions Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=None, + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy covers all cloud apps and all users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_no_user_conditions(self): + policy_id = str(uuid4()) + display_name = "No User Conditions Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_all_apps_all_users.entra_conditional_access_policy_all_apps_all_users import ( + entra_conditional_access_policy_all_apps_all_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=None, + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_all_apps_all_users() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy covers all cloud apps and all users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile_test.py index 71856429f8..f74d6ae4e2 100644 --- a/tests/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile_test.py +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile_test.py @@ -222,7 +222,7 @@ class Test_entra_conditional_access_policy_approved_client_app_required_for_mobi assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' reports the requirement of approved client apps or app protection for mobile devices but does not enforce it." + == f"Conditional Access Policy {display_name} reports the requirement of approved client apps or app protection for mobile devices but does not enforce it." ) assert ( result[0].resource @@ -312,7 +312,7 @@ class Test_entra_conditional_access_policy_approved_client_app_required_for_mobi assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + == f"Conditional Access Policy {display_name} requires approved client apps or app protection for mobile devices." ) assert ( result[0].resource @@ -738,7 +738,7 @@ class Test_entra_conditional_access_policy_approved_client_app_required_for_mobi assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + == f"Conditional Access Policy {display_name} requires approved client apps or app protection for mobile devices." ) assert ( result[0].resource @@ -827,7 +827,7 @@ class Test_entra_conditional_access_policy_approved_client_app_required_for_mobi assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + == f"Conditional Access Policy {display_name} requires approved client apps or app protection for mobile devices." ) assert ( result[0].resource @@ -963,7 +963,7 @@ class Test_entra_conditional_access_policy_approved_client_app_required_for_mobi assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Conditional Access Policy '{enabled_name}' requires approved client apps or app protection for mobile devices." + == f"Conditional Access Policy {enabled_name} requires approved client apps or app protection for mobile devices." ) assert ( result[0].resource @@ -1052,7 +1052,7 @@ class Test_entra_conditional_access_policy_approved_client_app_required_for_mobi assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + == f"Conditional Access Policy {display_name} requires approved client apps or app protection for mobile devices." ) assert ( result[0].resource diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms_test.py new file mode 100644 index 0000000000..f1ae073b6f --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_unknown_device_platforms/entra_conditional_access_policy_block_unknown_device_platforms_test.py @@ -0,0 +1,702 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + PlatformConditions, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms" + +KNOWN_PLATFORMS = ["android", "iOS", "windows", "macOS", "linux"] + + +def _make_session_controls() -> SessionControls: + """Return a minimal SessionControls instance for testing.""" + return SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ) + + +class Test_entra_conditional_access_policy_block_unknown_device_platforms: + """Tests for the entra_conditional_access_policy_block_unknown_device_platforms check.""" + + def test_no_conditional_access_policies(self): + """Test FAIL when there are no Conditional Access policies.""" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access from unknown or unsupported device platforms." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_disabled(self): + """Test FAIL when the only matching policy is disabled.""" + policy_id = str(uuid4()) + display_name = "Block Unknown Platforms" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=KNOWN_PLATFORMS, + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access from unknown or unsupported device platforms." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_enabled_for_reporting_only(self): + """Test FAIL when the matching policy is only in report-only mode.""" + policy_id = str(uuid4()) + display_name = "Block Unknown Platforms" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=KNOWN_PLATFORMS, + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Conditional Access Policy Block Unknown Platforms reports blocking unknown or unsupported device platforms but does not enforce it." + ) + assert result[0].resource_name == "Block Unknown Platforms" + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_no_platform_conditions(self): + """Test FAIL when the policy has no platform conditions configured.""" + policy_id = str(uuid4()) + display_name = "Block Unknown Platforms" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=None, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access from unknown or unsupported device platforms." + ) + + def test_policy_does_not_include_all_platforms(self): + """Test FAIL when the policy includes specific platforms instead of all.""" + policy_id = str(uuid4()) + display_name = "Block Specific Platforms" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_missing_excluded_known_platforms(self): + """Test FAIL when the policy includes all platforms but does not exclude all known ones.""" + policy_id = str(uuid4()) + display_name = "Incomplete Platform Exclusion" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + # Only exclude 3 of 5 known platforms + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=["android", "iOS", "windows"], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_no_block_grant_control(self): + """Test FAIL when the policy has correct platform conditions but does not block.""" + policy_id = str(uuid4()) + display_name = "MFA Unknown Platforms" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=KNOWN_PLATFORMS, + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_limited_to_specific_users_and_apps(self): + """Test PASS when a scoped policy still blocks unknown device platforms.""" + policy_id = str(uuid4()) + display_name = "Scoped Unknown Platform Block" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[ + "00000000-0000-0000-0000-000000000001" + ], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=["11111111-1111-1111-1111-111111111111"], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=KNOWN_PLATFORMS, + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy Scoped Unknown Platform Block blocks access from unknown or unsupported device platforms." + ) + + def test_policy_enabled_and_compliant(self): + """Test PASS when an enabled policy blocks unknown device platforms correctly.""" + policy_id = str(uuid4()) + display_name = "Block Unknown Platforms" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=KNOWN_PLATFORMS, + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy Block Unknown Platforms blocks access from unknown or unsupported device platforms." + ) + assert result[0].resource_name == "Block Unknown Platforms" + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_mixed_policies_report_only_and_enabled(self): + """Test PASS when both report-only and enabled compliant policies exist.""" + report_policy_id = str(uuid4()) + enabled_policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_unknown_device_platforms.entra_conditional_access_policy_block_unknown_device_platforms import ( + entra_conditional_access_policy_block_unknown_device_platforms, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + base_conditions = Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=KNOWN_PLATFORMS, + ), + ) + grant_controls = GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + authentication_strength=None, + ) + + entra_client.conditional_access_policies = { + report_policy_id: ConditionalAccessPolicy( + id=report_policy_id, + display_name="Report Only Policy", + conditions=base_conditions, + grant_controls=grant_controls, + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ), + enabled_policy_id: ConditionalAccessPolicy( + id=enabled_policy_id, + display_name="Enforced Block Policy", + conditions=base_conditions, + grant_controls=grant_controls, + session_controls=_make_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_conditional_access_policy_block_unknown_device_platforms() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Enforced Block Policy" + assert result[0].resource_id == enabled_policy_id diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced_test.py new file mode 100644 index 0000000000..52cef307f0 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced/entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced_test.py @@ -0,0 +1,697 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessPolicyState, + Conditions, + DeviceConditions, + DeviceFilterMode, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + SignInFrequencyType, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced" + + +def _make_policy( + policy_id, + display_name, + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + sign_in_frequency_enabled=True, + sign_in_frequency_interval=SignInFrequencyInterval.TIME_BASED, + sign_in_frequency_value=1, + sign_in_frequency_type=SignInFrequencyType.HOURS, + device_filter_mode=None, + device_filter_rule=None, +): + """Create a ConditionalAccessPolicy with the given parameters.""" + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + device_conditions=DeviceConditions( + device_filter_mode=device_filter_mode, + device_filter_rule=device_filter_rule, + ), + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=sign_in_frequency_enabled, + frequency=sign_in_frequency_value, + type=sign_in_frequency_type, + interval=sign_in_frequency_interval, + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced: + """Tests for sign-in frequency enforcement on non-corporate devices.""" + + def test_entra_no_conditional_access_policies(self): + """Test FAIL when no conditional access policies exist.""" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = {} + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy enforces sign-in frequency for non-corporate devices." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_entra_policy_disabled(self): + """Test FAIL when a qualifying policy is disabled.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Disabled Policy", + state=ConditionalAccessPolicyState.DISABLED, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.isCompliant -ne True -or device.trustType -ne "ServerAD"', + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_policy_enabled_for_reporting(self): + """Test FAIL when policy is enabled for reporting but not enforcing.""" + policy_id = str(uuid4()) + display_name = "Reporting Only Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.isCompliant -ne True -or device.trustType -ne "ServerAD"', + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} reports sign-in frequency for non-corporate devices but does not enforce it." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_entra_policy_missing_all_users(self): + """Test FAIL when policy does not target all users.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Limited Users Policy", + included_users=["user1@example.com"], + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_missing_all_applications(self): + """Test FAIL when policy does not target all applications.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Limited Apps Policy", + included_applications=["Office365"], + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_sign_in_frequency_not_enabled(self): + """Test FAIL when sign-in frequency is not enabled.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="No Sign-In Freq Policy", + sign_in_frequency_enabled=False, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_sign_in_frequency_not_time_based(self): + """Test FAIL when sign-in frequency interval is not time-based.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="EveryTime Policy", + sign_in_frequency_interval=SignInFrequencyInterval.EVERY_TIME, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_no_device_filter(self): + """Test FAIL when policy has no device filter.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="No Device Filter Policy", + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_device_filter_include_compliant(self): + """Test PASS with include mode device filter targeting non-compliant devices.""" + policy_id = str(uuid4()) + display_name = "Sign-In Freq Include Filter" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.isCompliant -ne True -or device.trustType -ne "ServerAD"', + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} enforces sign-in frequency for non-corporate devices." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_entra_policy_device_filter_exclude_compliant(self): + """Test PASS with exclude mode device filter excluding corporate devices.""" + policy_id = str(uuid4()) + display_name = "Sign-In Freq Exclude Filter" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + device_filter_mode=DeviceFilterMode.EXCLUDE, + device_filter_rule='device.isCompliant -eq True -and device.trustType -eq "ServerAD"', + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_entra_policy_device_filter_unrelated_rule(self): + """Test FAIL when device filter rule does not target corporate device properties.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Unrelated Filter Policy", + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.displayName -contains "kiosk"', + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_device_filter_include_corporate_devices(self): + """Test FAIL when include mode targets only corporate devices.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Corporate Devices Policy", + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -eq True", + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_device_filter_exclude_non_corporate_devices(self): + """Test FAIL when exclude mode excludes non-corporate devices.""" + policy_id = str(uuid4()) + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Exclude Unmanaged Devices Policy", + device_filter_mode=DeviceFilterMode.EXCLUDE, + device_filter_rule="device.isCompliant -eq False", + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_multiple_policies_one_compliant(self): + """Test PASS when at least one policy among multiple is compliant.""" + policy_id_1 = str(uuid4()) + policy_id_2 = str(uuid4()) + display_name_2 = "Compliant Sign-In Freq Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id_1: _make_policy( + policy_id=policy_id_1, + display_name="Non-Compliant Policy", + sign_in_frequency_enabled=False, + ), + policy_id_2: _make_policy( + policy_id=policy_id_2, + display_name=display_name_2, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.trustType -ne "ServerAD"', + ), + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name_2 + assert result[0].resource_id == policy_id_2 + + def test_entra_policy_with_trust_type_only(self): + """Test PASS with device filter referencing only trustType.""" + policy_id = str(uuid4()) + display_name = "TrustType Filter Policy" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced.entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced import ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + device_filter_mode=DeviceFilterMode.EXCLUDE, + device_filter_rule='device.trustType -eq "ServerAD"', + ) + } + + check = ( + entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded_test.py new file mode 100644 index 0000000000..d072713f04 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded_test.py @@ -0,0 +1,711 @@ +"""Tests for the entra_conditional_access_policy_directory_sync_account_excluded check.""" + +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationEnforcedRestrictions, + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32" + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded" + + +def _default_session_controls(): + """Return default session controls for test policies.""" + return SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ) + + +def _default_grant_controls(): + """Return default grant controls requiring MFA for test policies.""" + return GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + authentication_strength=None, + ) + + +class Test_entra_conditional_access_policy_directory_sync_account_excluded: + """Test class for Directory Sync Account exclusion check.""" + + def test_no_conditional_access_policies(self): + """Test PASS when no Conditional Access policies exist.""" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No Conditional Access Policy targets all users and all cloud apps, so no Directory Synchronization Accounts exclusion is needed." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_disabled(self): + """Test PASS when only a disabled policy exists targeting all users and apps.""" + policy_id = str(uuid4()) + display_name = "Require MFA for All Users" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No Conditional Access Policy targets all users and all cloud apps, so no Directory Synchronization Accounts exclusion is needed." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_policy_targets_specific_users(self): + """Test PASS when the policy targets specific users, not all users.""" + policy_id = str(uuid4()) + display_name = "Require MFA for Admins" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=["some-group-id"], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No Conditional Access Policy targets all users and all cloud apps, so no Directory Synchronization Accounts exclusion is needed." + ) + + def test_policy_targets_specific_apps(self): + """Test PASS when the policy targets specific apps, not all apps.""" + policy_id = str(uuid4()) + display_name = "Require MFA for Office 365" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["some-app-id"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No Conditional Access Policy targets all users and all cloud apps, so no Directory Synchronization Accounts exclusion is needed." + ) + + def test_policy_enabled_without_sync_exclusion(self): + """Test FAIL when an enabled policy targets all users and all apps but does not exclude the sync role.""" + policy_id = str(uuid4()) + display_name = "Require MFA for All Users" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} does not exclude the Directory Synchronization Accounts role, which may break Entra Connect sync." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_report_only_without_sync_exclusion(self): + """Test FAIL when a report-only policy targets all users and apps without excluding the sync role.""" + policy_id = str(uuid4()) + display_name = "Report Only - Require MFA" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} reports excluding the Directory Synchronization Accounts role but does not enforce it." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_enabled_with_sync_exclusion(self): + """Test PASS when an enabled policy targets all users and apps and excludes the sync role.""" + policy_id = str(uuid4()) + display_name = "Require MFA for All Users" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[ + DIRECTORY_SYNC_ROLE_TEMPLATE_ID, + ], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} excludes the Directory Synchronization Accounts role." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_with_sync_role_and_other_excluded_roles(self): + """Test PASS when the sync role is excluded alongside other roles.""" + policy_id = str(uuid4()) + display_name = "Require MFA for All Users" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[ + "some-other-role-id", + DIRECTORY_SYNC_ROLE_TEMPLATE_ID, + ], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} excludes the Directory Synchronization Accounts role." + ) + + def test_multiple_policies_mixed_results(self): + """Test multiple policies where one excludes sync role and another does not.""" + policy_id_pass = str(uuid4()) + policy_id_fail = str(uuid4()) + display_name_pass = "MFA Policy - With Exclusion" + display_name_fail = "MFA Policy - Without Exclusion" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id_pass: ConditionalAccessPolicy( + id=policy_id_pass, + display_name=display_name_pass, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[ + DIRECTORY_SYNC_ROLE_TEMPLATE_ID, + ], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ), + policy_id_fail: ConditionalAccessPolicy( + id=policy_id_fail, + display_name=display_name_fail, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 2 + + pass_results = [r for r in result if r.status == "PASS"] + fail_results = [r for r in result if r.status == "FAIL"] + + assert len(pass_results) == 1 + assert len(fail_results) == 1 + + assert pass_results[0].resource_name == display_name_pass + assert pass_results[0].resource_id == policy_id_pass + assert ( + pass_results[0].status_extended + == f"Conditional Access Policy {display_name_pass} excludes the Directory Synchronization Accounts role." + ) + + assert fail_results[0].resource_name == display_name_fail + assert fail_results[0].resource_id == policy_id_fail + assert ( + fail_results[0].status_extended + == f"Conditional Access Policy {display_name_fail} does not exclude the Directory Synchronization Accounts role, which may break Entra Connect sync." + ) + + def test_policy_with_wrong_excluded_role(self): + """Test FAIL when the policy excludes a different role but not the sync role.""" + policy_id = str(uuid4()) + display_name = "Require MFA for All Users" + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_directory_sync_account_excluded.entra_conditional_access_policy_directory_sync_account_excluded import ( + entra_conditional_access_policy_directory_sync_account_excluded, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=["some-other-role-id"], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=_default_grant_controls(), + session_controls=_default_session_controls(), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_directory_sync_account_excluded() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} does not exclude the Directory Synchronization Accounts role, which may break Entra Connect sync." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops_test.py new file mode 100644 index 0000000000..c120034e1e --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops_test.py @@ -0,0 +1,191 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_explicitly_targets_azure_devops.entra_conditional_access_policy_explicitly_targets_azure_devops" + +AZURE_DEVOPS_APP_ID = "499b84ac-1321-427f-aa17-267ca6975798" + + +def _make_session_controls(): + """Return default session controls for test policies.""" + return SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ) + + +def _make_conditions(included_applications=None): + """Return Conditions with the given included applications.""" + return Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ) + + +def _make_grant_controls(): + """Return default grant controls for test policies.""" + return GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + authentication_strength=None, + ) + + +def _make_policy(state, included_applications=None, display_name="Azure DevOps Policy"): + """Return a ConditionalAccessPolicy for tests.""" + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=_make_conditions(included_applications=included_applications), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=state, + ) + + +class Test_entra_conditional_access_policy_explicitly_targets_azure_devops: + def _run_check(self, policies): + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_explicitly_targets_azure_devops.entra_conditional_access_policy_explicitly_targets_azure_devops import ( + entra_conditional_access_policy_explicitly_targets_azure_devops, + ) + + entra_client.conditional_access_policies = policies + + check = entra_conditional_access_policy_explicitly_targets_azure_devops() + return check.execute() + + def test_no_conditional_access_policies(self): + result = self._run_check({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_enabled_policy_targets_azure_devops(self): + policy = _make_policy( + ConditionalAccessPolicyState.ENABLED, + included_applications=[AZURE_DEVOPS_APP_ID], + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {policy.display_name} explicitly targets Azure DevOps." + ) + assert result[0].resource_name == policy.display_name + assert result[0].resource_id == policy.id + + def test_enabled_policy_targets_all_apps_only(self): + policy = _make_policy( + ConditionalAccessPolicyState.ENABLED, included_applications=["All"] + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + def test_disabled_policy_targets_azure_devops(self): + policy = _make_policy( + ConditionalAccessPolicyState.DISABLED, + included_applications=[AZURE_DEVOPS_APP_ID], + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + def test_report_only_policy_targets_azure_devops(self): + policy = _make_policy( + ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_applications=[AZURE_DEVOPS_APP_ID], + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + def test_multiple_policies_one_targets_azure_devops(self): + non_matching = _make_policy( + ConditionalAccessPolicyState.ENABLED, + included_applications=["All"], + display_name="All Apps Policy", + ) + matching = _make_policy( + ConditionalAccessPolicyState.ENABLED, + included_applications=["some-other-app", AZURE_DEVOPS_APP_ID], + display_name="Dedicated Azure DevOps Policy", + ) + result = self._run_check({non_matching.id: non_matching, matching.id: matching}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == matching.id + assert ( + result[0].status_extended + == f"Conditional Access Policy {matching.display_name} explicitly targets Azure DevOps." + ) diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users_test.py new file mode 100644 index 0000000000..5fb8f985c2 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users_test.py @@ -0,0 +1,705 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ALL_GUEST_USER_TYPES, + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + ExternalTenantsMembershipKind, + GrantControlOperator, + GrantControls, + GuestOrExternalUserType, + GuestsOrExternalUsers, + PersistentBrowser, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users" + + +def build_policy( + *, + display_name: str, + state: ConditionalAccessPolicyState, + included_applications: list[str] | None = None, + included_users: list[str] | None = None, + excluded_users: list[str] | None = None, + built_in_controls: list[ConditionalAccessGrantControl] | None = None, + authentication_strength: str | None = None, + included_guests_or_external_users: GuestsOrExternalUsers | None = None, + excluded_guests_or_external_users: GuestsOrExternalUsers | None = None, +): + """Build a ConditionalAccessPolicy for testing.""" + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or [], + excluded_users=excluded_users or [], + included_roles=[], + excluded_roles=[], + included_guests_or_external_users=included_guests_or_external_users, + excluded_guests_or_external_users=excluded_guests_or_external_users, + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=built_in_controls or [], + operator=GrantControlOperator.OR, + authentication_strength=authentication_strength, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=None, + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_mfa_enforced_for_guest_users: + """Tests for the entra_conditional_access_policy_mfa_enforced_for_guest_users check.""" + + def test_no_conditional_access_policies(self): + """Test FAIL when there are no Conditional Access policies.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + entra_client.conditional_access_policies = {} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_disabled_policy_is_skipped(self): + """Test FAIL when the only matching policy is disabled.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests", + state=ConditionalAccessPolicyState.DISABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_enabled_targeting_all_users_with_mfa(self): + """Test PASS when an enabled policy targets all users with MFA for all apps.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for All Users", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["All"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy MFA for All Users requires MFA for guest users." + ) + assert result[0].resource_id == policy.id + assert result[0].resource_name == "MFA for All Users" + + def test_policy_enabled_targeting_guests_or_external_users(self): + """Test PASS when an enabled policy specifically targets GuestsOrExternalUsers.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guest Users", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy MFA for Guest Users requires MFA for guest users." + ) + assert result[0].resource_id == policy.id + + def test_policy_enabled_targeting_all_guest_types_via_included_guests(self): + """Test PASS when policy targets all six guest types via included_guests_or_external_users.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for All Guest Types", + state=ConditionalAccessPolicyState.ENABLED, + built_in_controls=[ConditionalAccessGrantControl.MFA], + included_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=list(ALL_GUEST_USER_TYPES), + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == policy.id + + def test_policy_with_authentication_strength_passes(self): + """Test PASS when policy uses authentication strength instead of MFA built-in control.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="Auth Strength for Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + authentication_strength="Phishing-resistant MFA", + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == policy.id + + def test_policy_only_password_change_fails(self): + """Test FAIL when the policy only requires password change.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="Password Change for Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.PASSWORD_CHANGE], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_not_targeting_all_apps_fails(self): + """Test FAIL when the policy does not target all cloud applications.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests Specific App", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=["some-specific-app-id"], + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_not_targeting_guests_fails(self): + """Test FAIL when the policy does not target guest users.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Specific Users", + state=ConditionalAccessPolicyState.ENABLED, + included_users=[str(uuid4())], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_with_partial_guest_types_fails(self): + """Test FAIL when policy only targets some guest types but not all six.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Some Guests", + state=ConditionalAccessPolicyState.ENABLED, + built_in_controls=[ConditionalAccessGrantControl.MFA], + included_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=[ + GuestOrExternalUserType.B2B_COLLABORATION_GUEST, + GuestOrExternalUserType.INTERNAL_GUEST, + ], + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_with_excluded_guest_types_fails(self): + """Test FAIL when the policy excludes guest/external user types.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests with Exclusions", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["All"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + excluded_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=[ + GuestOrExternalUserType.SERVICE_PROVIDER, + ], + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_excluding_guests_via_excluded_users_fails(self): + """Test FAIL when the policy excludes GuestsOrExternalUsers.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Users Except Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["All"], + excluded_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_with_selected_external_tenants_fails(self): + """Test FAIL when the policy only targets selected external tenants.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Selected External Tenants", + state=ConditionalAccessPolicyState.ENABLED, + built_in_controls=[ConditionalAccessGrantControl.MFA], + included_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=list(ALL_GUEST_USER_TYPES), + external_tenants_membership_kind=ExternalTenantsMembershipKind.ENUMERATED, + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_reporting_only_policy_fails_with_detail(self): + """Test FAIL with detail when the matching policy is in report-only mode.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests Report Only", + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Conditional Access Policy MFA for Guests Report Only targets guest users with MFA but is only in report-only mode." + ) + assert result[0].resource_id == policy.id + assert result[0].resource_name == "MFA for Guests Report Only" + + def test_no_application_conditions_fails(self): + """Test FAIL when the policy has no application conditions.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + policy_id = str(uuid4()) + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="No App Conditions", + conditions=Conditions( + application_conditions=None, + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["GuestsOrExternalUsers"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=None, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_no_mfa_grant_control_fails(self): + """Test FAIL when the policy does not require MFA as a grant control.""" + 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(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="Compliant Device for Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.COMPLIANT_DEVICE], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py new file mode 100644 index 0000000000..6d3e7786cf --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py @@ -0,0 +1,466 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +def _make_policy( + *, + display_name="Test Policy", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + excluded_users=None, + included_groups=None, + excluded_groups=None, + included_roles=None, + excluded_roles=None, +): + """Build a ConditionalAccessPolicy with the minimum fields required by the model.""" + policy_id = str(uuid4()) + policy = ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_users=included_users or [], + excluded_users=excluded_users or [], + included_groups=included_groups or [], + excluded_groups=excluded_groups or [], + included_roles=included_roles or [], + excluded_roles=excluded_roles or [], + ), + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode=""), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + return policy_id, policy + + +def _entra_client_mock(): + client = mock.MagicMock() + client.audited_tenant = "audited_tenant" + client.audited_domain = DOMAIN + # Default to clean resolution; individual tests override as needed. + client.unresolved_directory_object_references = set() + client.errored_directory_object_references = set() + return client + + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_conditional_access_policy_no_deleted_object_references." + "entra_conditional_access_policy_no_deleted_object_references.entra_client" +) + + +class Test_entra_conditional_access_policy_no_deleted_object_references: + def test_no_policies(self): + """No Conditional Access policies in tenant: no findings.""" + entra_client = _entra_client_mock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 0 + + def test_sentinel_only_references_pass(self): + """Policy with only sentinel values ('All', 'GuestsOrExternalUsers') passes.""" + entra_client = _entra_client_mock() + policy_id, policy = _make_policy( + display_name="MFA For All", + included_users=["All"], + excluded_users=["GuestsOrExternalUsers"], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "references no deleted directory objects" in result[0].status_extended + ) + assert result[0].resource_id == policy_id + assert result[0].resource_name == "MFA For All" + + def test_all_references_resolve_pass(self): + """Policy with real identifiers, none in the unresolved set: PASS.""" + entra_client = _entra_client_mock() + live_user = str(uuid4()) + live_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Targeted Policy", + included_users=[live_user], + included_groups=[live_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_deleted_user_in_include_fails(self): + """Policy referencing a deleted user in includeUsers fails with type+side reported.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Require MFA", + included_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 deleted directory object(s)" in result[0].status_extended + assert "users:" in result[0].status_extended + assert deleted_user in result[0].status_extended + assert "(include)" in result[0].status_extended + + def test_deleted_group_in_exclude_fails(self): + """Policy referencing a deleted group in excludeGroups fails with exclude side reported.""" + entra_client = _entra_client_mock() + deleted_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Block Legacy Auth", + included_users=["All"], + excluded_groups=[deleted_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("group", deleted_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "groups:" in result[0].status_extended + assert "(exclude)" in result[0].status_extended + + def test_deleted_role_in_disabled_policy_still_fails(self): + """Disabled policy with a stale role reference still FAILs (per spec).""" + entra_client = _entra_client_mock() + deleted_role = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Legacy Admin Policy", + state=ConditionalAccessPolicyState.DISABLED, + included_roles=[deleted_role], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("role", deleted_role) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "roles:" in result[0].status_extended + assert deleted_role in result[0].status_extended + + def test_orphans_grouped_by_type_across_collections(self): + """A single policy with orphans of every type aggregates them grouped by type.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + deleted_group = str(uuid4()) + deleted_role = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Composite Policy", + included_users=[deleted_user], + excluded_groups=[deleted_group], + included_roles=[deleted_role], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user), + ("group", deleted_group), + ("role", deleted_role), + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "3 deleted directory object(s)" in result[0].status_extended + assert "users:" in result[0].status_extended + assert "groups:" in result[0].status_extended + assert "roles:" in result[0].status_extended + + def test_report_only_policy_failure_notes_mode(self): + """A report-only policy with an orphan FAILs and flags the not-yet-enforced state.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Report Only MFA", + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "report-only mode" in result[0].status_extended + + def test_unverified_reference_is_manual(self): + """A reference that errored (non-404) yields MANUAL, not PASS.""" + entra_client = _entra_client_mock() + errored_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Throttled Lookup Policy", + included_users=["All"], + excluded_groups=[errored_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + entra_client.errored_directory_object_references = { + ("group", errored_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be fully evaluated" in result[0].status_extended + assert errored_group in result[0].status_extended + + def test_orphan_takes_precedence_over_unverified(self): + """A confirmed deletion FAILs even when another reference is unverified.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + errored_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Mixed Policy", + included_users=[deleted_user], + excluded_groups=[errored_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + entra_client.errored_directory_object_references = { + ("group", errored_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 deleted directory object(s)" in result[0].status_extended + assert "could not be verified" in result[0].status_extended + + def test_multiple_policies_mixed(self): + """Two policies: one clean, one with an orphan. Distinct PASS/FAIL findings.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + + clean_id, clean_policy = _make_policy( + display_name="Clean Policy", + included_users=["All"], + ) + dirty_id, dirty_policy = _make_policy( + display_name="Stale Reference Policy", + excluded_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = { + clean_id: clean_policy, + dirty_id: dirty_policy, + } + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 2 + + clean_result = next(r for r in result if r.resource_id == clean_id) + dirty_result = next(r for r in result if r.resource_id == dirty_id) + + assert clean_result.status == "PASS" + assert dirty_result.status == "FAIL" + assert "(exclude)" in dirty_result.status_extended diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps_test.py new file mode 100644 index 0000000000..30629c2a29 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_exclusion_gaps/entra_conditional_access_policy_no_exclusion_gaps_test.py @@ -0,0 +1,424 @@ +import re +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + PlatformConditions, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps" +DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32" + + +def _policy( + display_name="Policy", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + excluded_users=None, + included_groups=None, + excluded_groups=None, + included_roles=None, + excluded_roles=None, + included_applications=None, + excluded_applications=None, + include_platforms=None, + exclude_platforms=None, + block=False, +) -> ConditionalAccessPolicy: + """Build a fully-populated ConditionalAccessPolicy for tests. + + Args: + display_name: Policy display name. + state: Policy state (default ENABLED). + included_users: Included user IDs, or None. + excluded_users: Excluded user IDs, or None. + included_groups: Included group IDs, or None. + excluded_groups: Excluded group IDs, or None. + included_roles: Included role template IDs, or None. + excluded_roles: Excluded role template IDs, or None. + included_applications: Included application IDs, or None. + excluded_applications: Excluded application IDs, or None. + include_platforms: Included platform names, or None. + exclude_platforms: Excluded platform names, or None. + block: Whether the policy uses a Block grant control (default False). + + Returns: + A ConditionalAccessPolicy instance with the specified conditions. + """ + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or [], + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=included_groups or [], + excluded_groups=excluded_groups or [], + included_users=included_users or [], + excluded_users=excluded_users or [], + included_roles=included_roles or [], + excluded_roles=excluded_roles or [], + ), + platform_conditions=PlatformConditions( + include_platforms=include_platforms or [], + exclude_platforms=exclude_platforms or [], + ), + ), + grant_controls=GrantControls( + built_in_controls=( + [ConditionalAccessGrantControl.BLOCK] + if block + else [ConditionalAccessGrantControl.MFA] + ), + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=state, + ) + + +def _run( + policies: list[ConditionalAccessPolicy], + users=None, + groups=None, + service_principals=None, +) -> list: + """Run the check with a mocked entra_client holding the given policies. + + Args: + policies: ConditionalAccessPolicy objects to inject into the mocked client. + users: Optional id -> User mapping used to resolve display names. + groups: Optional list of Group objects used to resolve display names. + service_principals: Optional id -> ServicePrincipal mapping for app names. + + Returns: + The list of check report objects returned by ``execute()``. + """ + 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(f"{CHECK_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps import ( + entra_conditional_access_policy_no_exclusion_gaps, + ) + + entra_client.conditional_access_policies = {p.id: p for p in policies} + entra_client.users = users or {} + entra_client.groups = groups or [] + entra_client.service_principals = service_principals or {} + check = entra_conditional_access_policy_no_exclusion_gaps() + return check.execute() + + +class Test_entra_conditional_access_policy_no_exclusion_gaps: + """Tests for the Conditional Access exclusion-gap check. + + Verifies that objects excluded from enabled Conditional Access policies stay + in scope of another enabled policy (explicitly or via the type's wildcard), + with the directory-sync role and break-glass accounts treated as intended + exclusions. + """ + + def test_no_policies(self): + result = _run([]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "No enabled Conditional Access policies" in result[0].status_extended + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_only_disabled_policies(self): + result = _run( + [ + _policy( + state=ConditionalAccessPolicyState.DISABLED, + included_users=["All"], + excluded_users=["user-1"], + ) + ] + ) + assert result[0].status == "PASS" + assert "No enabled Conditional Access policies" in result[0].status_extended + + def test_report_only_policies_out_of_scope(self): + # An exclusion in a report-only policy must not be evaluated. + result = _run( + [ + _policy( + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=["All"], + excluded_users=["orphan-user"], + ) + ] + ) + assert result[0].status == "PASS" + assert "No enabled Conditional Access policies" in result[0].status_extended + + def test_no_exclusions_used(self): + result = _run([_policy(included_users=["All"], included_applications=["All"])]) + assert result[0].status == "PASS" + assert "no coverage gaps are possible" in result[0].status_extended + + def test_exclusion_covered_by_another_policy(self): + # Policy A excludes user-1; Policy B includes user-1 explicitly -> covered. + result = _run( + [ + _policy( + display_name="A", included_users=["All"], excluded_users=["user-1"] + ), + _policy(display_name="B", included_users=["user-1"]), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_user_exclusion_gap(self): + # user-1 is excluded but never included anywhere -> FAIL. + result = _run( + [ + _policy( + display_name="A", included_users=["All"], excluded_users=["user-1"] + ) + ] + ) + assert result[0].status == "FAIL" + assert "users: user-1" in result[0].status_extended + + def test_gap_reports_display_name_when_resolvable(self): + # A resolvable user shows its display name; an unresolved user (e.g. + # deleted but still referenced) falls back to its raw ID. + from prowler.providers.m365.services.entra.entra_service import User + + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1", "ghost-2"], + ) + ], + users={ + "user-1": User( + id="user-1", + name="Alice Admin", + on_premises_sync_enabled=False, + ) + }, + ) + assert result[0].status == "FAIL" + assert "Alice Admin" in result[0].status_extended + assert "user-1" not in result[0].status_extended + # Unresolved ID still surfaces as the raw identifier. + assert "ghost-2" in result[0].status_extended + + def test_group_and_role_gaps_reported_by_type(self): + result = _run( + [ + _policy( + display_name="P", + included_users=["All"], + excluded_groups=["group-x"], + excluded_roles=["role-y"], + ) + ] + ) + assert result[0].status == "FAIL" + assert "groups: group-x" in result[0].status_extended + assert "roles: role-y" in result[0].status_extended + + def test_application_exclusion_gap(self): + result = _run( + [ + _policy( + display_name="AppPolicy", + included_applications=["All"], + excluded_applications=["app-123"], + ) + ] + ) + assert result[0].status == "FAIL" + assert "applications: app-123" in result[0].status_extended + + def test_application_exclusion_covered(self): + result = _run( + [ + _policy( + display_name="A", + included_applications=["All"], + excluded_applications=["app-123"], + ), + _policy(display_name="B", included_applications=["app-123"]), + ] + ) + assert result[0].status == "PASS" + + def test_exclusion_covered_by_all_wildcard_in_another_policy(self): + # Policy A excludes user-1; Policy B targets "All" users and does NOT + # exclude user-1, so user-1 stays in scope of B -> covered -> PASS. + # The "All" wildcard of the policy that excludes the user (A) must not + # count, but the wildcard of an unrelated policy (B) does. + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1"], + ), + _policy(display_name="B", included_users=["All"]), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_exclusion_only_wildcard_is_self_excluding_is_gap(self): + # The only "All" users policy is the one that excludes user-1, and no + # other policy covers user-1 -> real gap -> FAIL. This is the case + # #11375's global-union "All" handling would have wrongly passed. + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1"], + ), + _policy( + display_name="B", + included_users=["All"], + excluded_users=["user-1"], + ), + ] + ) + assert result[0].status == "FAIL" + assert "users: user-1" in result[0].status_extended + + def test_platform_exclusions_are_ignored(self): + # Platform exclusions are scoping conditions, not principals removed from + # enforcement, so they are out of scope even with no covering policy. + result = _run( + [ + _policy( + display_name="MDM", + included_users=["All"], + exclude_platforms=["android", "ios", "macos", "linux"], + ) + ] + ) + assert result[0].status == "PASS" + + def test_directory_sync_role_exclusion_skipped(self): + # Dir-sync role excluded with no fallback must NOT be a gap. + result = _run( + [ + _policy( + display_name="P", + included_users=["All"], + excluded_roles=[DIRECTORY_SYNC_ROLE_TEMPLATE_ID], + ) + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_emergency_access_user_exclusion_skipped(self): + # A break-glass user excluded from EVERY enabled blocking policy is an + # intended gap and must not be reported. + emergency = "breakglass-user" + result = _run( + [ + _policy( + display_name="Block1", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + _policy( + display_name="Block2", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_emergency_access_ignores_report_only_blocking_policy(self): + # A break-glass user excluded from every ENABLED blocking policy is an + # intended gap, even if a report-only (non-enforced) blocking policy that + # does NOT exclude them also exists. Report-only policies must not dilute + # the emergency determination. + emergency = "breakglass-user" + result = _run( + [ + _policy( + display_name="Block1", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + _policy( + display_name="Block2", + block=True, + included_users=["All"], + excluded_users=[emergency], + ), + _policy( + display_name="ReportOnlyBlock", + block=True, + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=["All"], + ), + ] + ) + assert result[0].status == "PASS" + assert "compensating control" in result[0].status_extended + + def test_mixed_gap_and_covered(self): + # user-1 covered, user-2 orphaned -> FAIL listing only user-2. + result = _run( + [ + _policy( + display_name="A", + included_users=["All"], + excluded_users=["user-1", "user-2"], + ), + _policy(display_name="B", included_users=["user-1"]), + ] + ) + assert result[0].status == "FAIL" + assert "user-2" in result[0].status_extended + # user-1 is covered, so it must not appear as a gap (whitespace-robust). + assert not re.search(r"\busers:\s*user-1\b", result[0].status_extended) diff --git a/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py new file mode 100644 index 0000000000..6c8696da55 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py @@ -0,0 +1,339 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DirectorySyncSettings, + Organization, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_directory_sync_object_takeover_blocked." + "entra_directory_sync_object_takeover_blocked" +) + + +def _hybrid_org(): + return Organization( + id="org-001", + name="Hybrid Org", + on_premises_sync_enabled=True, + ) + + +def _cloud_only_org(): + return Organization( + id="org-001", + name="Cloud Only Org", + on_premises_sync_enabled=False, + ) + + +class Test_entra_directory_sync_object_takeover_blocked: + def test_both_blocks_enabled(self): + """PASS when both soft-match and hard-match blocks are enabled.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "blocks both soft-match and hard-match" in result[0].status_extended + + def test_soft_match_disabled(self): + """FAIL when soft-match block is disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + + def test_hard_match_disabled(self): + """FAIL when hard-match block is disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_both_blocks_disabled(self): + """FAIL when both blocks are disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_cloud_only_tenant(self): + """PASS when tenant is cloud-only and no sync object is returned.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_cloud_only_tenant_with_sync_object_returned(self): + """PASS for cloud-only tenants even when Graph returns a sync object. + + Microsoft Graph returns an onPremisesSynchronization object (with all + features disabled) for cloud-only tenants. The check must not treat the + disabled flags as a FAIL when on-premises sync is not enabled. + """ + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="tenant-id", + password_sync_enabled=False, + seamless_sso_enabled=False, + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_permission_error_hybrid(self): + """MANUAL when permissions are insufficient for a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot verify" in result[0].status_extended + assert "Insufficient privileges" in result[0].status_extended + + def test_permission_error_cloud_only(self): + """PASS when settings cannot be read but the tenant is cloud-only.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_hybrid_no_settings_returned(self): + """MANUAL when a hybrid tenant returns no directory sync settings.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "no directory sync settings were returned" in result[0].status_extended + ) diff --git a/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py b/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py index db2fd1d193..cbbd9c6886 100644 --- a/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py +++ b/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py @@ -47,7 +47,7 @@ class Test_entra_emergency_access_exclusion: assert result[0].status == "PASS" assert ( result[0].status_extended - == "No enabled Conditional Access policies found. Emergency access exclusions are not required." + == "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required." ) assert result[0].resource == {} assert result[0].resource_name == "Conditional Access Policies" @@ -98,7 +98,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -122,7 +122,7 @@ class Test_entra_emergency_access_exclusion: assert result[0].status == "PASS" assert ( result[0].status_extended - == "No enabled Conditional Access policies found. Emergency access exclusions are not required." + == "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required." ) def test_entra_no_emergency_access_exclusion(self): @@ -172,7 +172,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -207,7 +207,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -230,7 +230,7 @@ class Test_entra_emergency_access_exclusion: assert len(result) == 1 assert result[0].status == "FAIL" assert ( - "No user or group is excluded as emergency access from all 2 enabled Conditional Access policies" + "No user or group is excluded as emergency access from all 2 enabled Conditional Access policies with a Block grant control" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" @@ -283,7 +283,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -318,7 +318,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -352,7 +352,7 @@ class Test_entra_emergency_access_exclusion: assert result[0].status == "PASS" assert "BreakGlass1" in result[0].status_extended assert ( - "excluded from all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies with a Block grant control" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" @@ -405,7 +405,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -440,7 +440,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -474,7 +474,7 @@ class Test_entra_emergency_access_exclusion: assert result[0].status == "PASS" assert "BreakGlassGroup" in result[0].status_extended assert ( - "excluded from all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies with a Block grant control" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" @@ -528,7 +528,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -563,7 +563,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -605,7 +605,7 @@ class Test_entra_emergency_access_exclusion: assert "BreakGlass1" in result[0].status_extended assert "BreakGlassGroup" in result[0].status_extended assert ( - "excluded from all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies with a Block grant control" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" @@ -658,7 +658,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -693,7 +693,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -729,7 +729,7 @@ class Test_entra_emergency_access_exclusion: assert result[0].resource_id == "conditionalAccessPolicies" assert "BreakGlass1" in result[0].status_extended assert ( - "excluded from all 1 enabled Conditional Access policies" + "excluded from all 1 enabled Conditional Access policies with a Block grant control" in result[0].status_extended ) @@ -781,7 +781,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -816,7 +816,7 @@ class Test_entra_emergency_access_exclusion: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.MFA], + built_in_controls=[ConditionalAccessGrantControl.BLOCK], operator=GrantControlOperator.AND, ), session_controls=SessionControls( @@ -850,8 +850,316 @@ class Test_entra_emergency_access_exclusion: assert result[0].status == "PASS" assert "BreakGlass1" in result[0].status_extended assert ( - "excluded from all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies with a Block grant control" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_only_non_blocking_policies(self): + """PASS when the tenant has only non-blocking policies (no Block grant).""" + policy_id = str(uuid4()) + 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_emergency_access_exclusion.entra_emergency_access_exclusion.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion import ( + entra_emergency_access_exclusion, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="MFA for all", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_emergency_access_exclusion() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required." + ) + + def test_entra_user_excluded_from_blocking_but_included_in_non_blocking(self): + """PASS when only Block-grant exclusions are required (non-blocking inclusion is ignored).""" + block_policy_id = str(uuid4()) + mfa_policy_id = str(uuid4()) + emergency_user_id = "emergency-access-user" + 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_emergency_access_exclusion.entra_emergency_access_exclusion.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion import ( + entra_emergency_access_exclusion, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + block_policy_id: ConditionalAccessPolicy( + id=block_policy_id, + display_name="Block legacy auth", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[emergency_user_id], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.AND, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + mfa_policy_id: ConditionalAccessPolicy( + id=mfa_policy_id, + display_name="MFA for all", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + entra_client.users = { + emergency_user_id: User( + id=emergency_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + entra_client.groups = [] + + check = entra_emergency_access_exclusion() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "BreakGlass1" in result[0].status_extended + assert ( + "excluded from all 1 enabled Conditional Access policies with a Block grant control" + in result[0].status_extended + ) + + def test_entra_user_excluded_only_from_subset_of_blocking_policies(self): + """FAIL when the user is excluded from one Block policy but not the other.""" + block_policy_id_1 = str(uuid4()) + block_policy_id_2 = str(uuid4()) + emergency_user_id = "emergency-access-user" + 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_emergency_access_exclusion.entra_emergency_access_exclusion.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion import ( + entra_emergency_access_exclusion, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + block_policy_id_1: ConditionalAccessPolicy( + id=block_policy_id_1, + display_name="Block 1", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[emergency_user_id], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.AND, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + block_policy_id_2: ConditionalAccessPolicy( + id=block_policy_id_2, + display_name="Block 2", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.AND, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + entra_client.users = { + emergency_user_id: User( + id=emergency_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + entra_client.groups = [] + + check = entra_emergency_access_exclusion() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No user or group is excluded as emergency access from all 2 enabled Conditional Access policies with a Block grant control." + ) diff --git a/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py b/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py index bfd898cb26..5661ca1724 100644 --- a/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py +++ b/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py @@ -107,7 +107,9 @@ class Test_entra_managed_device_required_for_authentication: type=None, interval=SignInFrequencyInterval.TIME_BASED, ), - application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), ), state=ConditionalAccessPolicyState.DISABLED, ) @@ -186,7 +188,9 @@ class Test_entra_managed_device_required_for_authentication: type=None, interval=SignInFrequencyInterval.TIME_BASED, ), - application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), ), state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, ) @@ -269,7 +273,9 @@ class Test_entra_managed_device_required_for_authentication: type=None, interval=SignInFrequencyInterval.TIME_BASED, ), - application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), ), state=ConditionalAccessPolicyState.ENABLED, ) diff --git a/tests/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles_test.py b/tests/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles_test.py new file mode 100644 index 0000000000..5fc9dc2dfb --- /dev/null +++ b/tests/providers/m365/services/entra/entra_service_principal_no_secrets_for_permanent_tier0_roles/entra_service_principal_no_secrets_for_permanent_tier0_roles_test.py @@ -0,0 +1,424 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + KeyCredential, + PasswordCredential, + ServicePrincipal, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_service_principal_no_secrets_for_permanent_tier0_roles: + """Tests for the entra_service_principal_no_secrets_for_permanent_tier0_roles check.""" + + def test_no_service_principals(self): + """No service principals configured: expected no findings.""" + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = {} + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 0 + + def test_service_principal_no_secrets_no_roles(self): + """Service principal without secrets and no Tier 0 roles: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="TestApp", + app_id=str(uuid4()), + password_credentials=[], + key_credentials=[ + KeyCredential(key_id=str(uuid4()), display_name="cert1") + ], + directory_role_template_ids=[], + ) + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not use client secrets" in result[0].status_extended + assert result[0].resource_id == sp_id + assert result[0].resource_name == "TestApp" + + def test_service_principal_with_secrets_no_tier0_roles(self): + """Service principal with secrets but no Tier 0 roles: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + non_tier0_role = "4a5d8f65-41da-4de4-8968-e035b65339cf" # Reports Reader + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="AppWithSecrets", + app_id=str(uuid4()), + password_credentials=[ + PasswordCredential(key_id=str(uuid4()), display_name="secret1") + ], + key_credentials=[], + directory_role_template_ids=[non_tier0_role], + ) + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "no permanent Tier 0" in result[0].status_extended + assert result[0].resource_id == sp_id + + def test_service_principal_no_secrets_with_tier0_roles(self): + """Service principal without secrets but with Tier 0 roles: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + global_admin_role = "62e90394-69f5-4237-9190-012177145e10" + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="CertBasedApp", + app_id=str(uuid4()), + password_credentials=[], + key_credentials=[ + KeyCredential(key_id=str(uuid4()), display_name="cert1") + ], + directory_role_template_ids=[global_admin_role], + ) + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not use client secrets" in result[0].status_extended + assert result[0].resource_id == sp_id + + def test_service_principal_with_secrets_and_tier0_role(self): + """Service principal with secrets and Tier 0 role: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + global_admin_role = "62e90394-69f5-4237-9190-012177145e10" + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="VulnerableApp", + app_id=str(uuid4()), + password_credentials=[ + PasswordCredential(key_id=str(uuid4()), display_name="secret1") + ], + key_credentials=[], + directory_role_template_ids=[global_admin_role], + ) + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses client secrets" in result[0].status_extended + assert "Control Plane" in result[0].status_extended + assert result[0].resource_id == sp_id + assert result[0].resource_name == "VulnerableApp" + + def test_service_principal_with_secrets_and_multiple_tier0_roles(self): + """Service principal with secrets and multiple Tier 0 roles: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + global_admin_role = "62e90394-69f5-4237-9190-012177145e10" + priv_role_admin = "e8611ab8-c189-46e8-94e1-60213ab1f814" + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="HighRiskApp", + app_id=str(uuid4()), + password_credentials=[ + PasswordCredential(key_id=str(uuid4()), display_name="secret1"), + PasswordCredential(key_id=str(uuid4()), display_name="secret2"), + ], + key_credentials=[], + directory_role_template_ids=[ + global_admin_role, + priv_role_admin, + ], + ) + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "2 Control Plane" in result[0].status_extended + + def test_multiple_service_principals_mixed(self): + """Multiple service principals with mixed states: mixed results.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id_pass = str(uuid4()) + sp_id_fail = str(uuid4()) + global_admin_role = "62e90394-69f5-4237-9190-012177145e10" + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id_pass: ServicePrincipal( + id=sp_id_pass, + name="SafeApp", + app_id=str(uuid4()), + password_credentials=[], + key_credentials=[ + KeyCredential(key_id=str(uuid4()), display_name="cert1") + ], + directory_role_template_ids=[global_admin_role], + ), + sp_id_fail: ServicePrincipal( + id=sp_id_fail, + name="UnsafeApp", + app_id=str(uuid4()), + password_credentials=[ + PasswordCredential(key_id=str(uuid4()), display_name="secret1") + ], + key_credentials=[], + directory_role_template_ids=[global_admin_role], + ), + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 2 + statuses = {r.resource_id: r.status for r in result} + assert statuses[sp_id_pass] == "PASS" + assert statuses[sp_id_fail] == "FAIL" + + def test_service_principal_only_expired_secrets_with_tier0_role(self): + """Service principal whose only client secrets are expired: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + global_admin_role = "62e90394-69f5-4237-9190-012177145e10" + expired = datetime.now(timezone.utc) - timedelta(days=1) + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="LegacyApp", + app_id=str(uuid4()), + password_credentials=[ + PasswordCredential( + key_id=str(uuid4()), + display_name="expired-secret", + end_date_time=expired, + ) + ], + key_credentials=[], + directory_role_template_ids=[global_admin_role], + ) + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not use client secrets" in result[0].status_extended + + def test_service_principal_active_and_expired_secrets_with_tier0_role(self): + """Service principal with at least one active client secret: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + global_admin_role = "62e90394-69f5-4237-9190-012177145e10" + expired = datetime.now(timezone.utc) - timedelta(days=1) + future = datetime.now(timezone.utc) + timedelta(days=180) + + 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_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import ( + entra_service_principal_no_secrets_for_permanent_tier0_roles, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="MixedSecretsApp", + app_id=str(uuid4()), + password_credentials=[ + PasswordCredential( + key_id=str(uuid4()), + display_name="old", + end_date_time=expired, + ), + PasswordCredential( + key_id=str(uuid4()), + display_name="current", + end_date_time=future, + ), + ], + key_credentials=[], + directory_role_template_ids=[global_admin_role], + ) + } + + check = entra_service_principal_no_secrets_for_permanent_tier0_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses client secrets" in result[0].status_extended diff --git a/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py new file mode 100644 index 0000000000..e62f9eaa08 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py @@ -0,0 +1,299 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ServicePrincipal +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +GLOBAL_ADMIN_ROLE = "62e90394-69f5-4237-9190-012177145e10" +PRIV_ROLE_ADMIN = "e8611ab8-c189-46e8-94e1-60213ab1f814" + + +class Test_entra_service_principal_privileged_role_no_owners: + """Tests for the entra_service_principal_privileged_role_no_owners check.""" + + def test_no_service_principals(self): + """No service principals configured: expected no findings.""" + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = {} + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 0 + + def test_service_principal_no_tier0_roles(self): + """Service principal without Tier 0 roles: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="NonPrivilegedApp", + app_id=str(uuid4()), + directory_role_template_ids=[], + sp_owner_ids=[], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == sp_id + assert result[0].resource_name == "NonPrivilegedApp" + assert ( + "no permanent Tier 0 directory role assignments" + in result[0].status_extended + ) + + def test_service_principal_tier0_no_owners(self): + """Privileged SP with no owners on SP or app: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="SecureApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == sp_id + assert result[0].resource_name == "SecureApp" + assert "no owners" in result[0].status_extended + + def test_service_principal_tier0_with_sp_owners(self): + """Privileged SP with owners on SP only: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="RiskyApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[owner_id], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == sp_id + assert "1 owner(s)" in result[0].status_extended + assert "1 on the service principal" in result[0].status_extended + assert "0 on the parent app registration" in result[0].status_extended + + def test_service_principal_tier0_with_app_owners(self): + """Privileged SP with owners on parent app only: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + app_owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="AppRegOwnerRisk", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[], + app_owner_ids=[app_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 owner(s)" in result[0].status_extended + assert "0 on the service principal" in result[0].status_extended + assert "1 on the parent app registration" in result[0].status_extended + + def test_service_principal_tier0_with_both_owners(self): + """Privileged SP with distinct owners on both SP and app: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + sp_owner_id = str(uuid4()) + app_owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="HighRiskApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE, PRIV_ROLE_ADMIN], + sp_owner_ids=[sp_owner_id], + app_owner_ids=[app_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "2 permanent Tier 0 directory role(s)" in result[0].status_extended + assert "2 owner(s)" in result[0].status_extended + + def test_service_principal_tier0_same_owner_on_sp_and_app(self): + """Same principal owns both SP and parent app: owner count deduplicated.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + shared_owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="DualOwnedApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[shared_owner_id], + app_owner_ids=[shared_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 owner(s)" in result[0].status_extended + assert "1 on the service principal" in result[0].status_extended + assert "1 on the parent app registration" in result[0].status_extended 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..86e8e38f22 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 @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta, timezone from unittest import mock from uuid import uuid4 @@ -11,6 +12,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -53,6 +55,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -95,6 +98,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -153,6 +157,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -191,6 +196,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None with ( mock.patch( @@ -237,3 +243,439 @@ 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 + entra_client.user_registration_details_error = None + + 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 + entra_client.user_registration_details_error = None + + 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_future_hire_member_user_not_checked(self): + """Future-hire member user is not active yet: expected no results.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None + + 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="Future Hire", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + employee_hire_date=datetime.now(timezone.utc) + timedelta(days=1), + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 0 + + def test_naive_future_hire_member_user_not_checked(self): + """Naive future-hire datetimes are treated as UTC and skipped.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None + + 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="Future Hire", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + employee_hire_date=( + datetime.now(timezone.utc) + timedelta(days=1) + ).replace(tzinfo=None), + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 0 + + def test_current_hire_member_user_is_checked(self): + """Current-hire member user is active now: expected evaluation.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None + + 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="Current Hire", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + employee_hire_date=datetime.now(timezone.utc), + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == "User Current Hire is not MFA capable." + assert result[0].resource == entra_client.users[user_id] + assert result[0].resource_name == "Current Hire" + assert result[0].resource_id == user_id + + 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 + entra_client.user_registration_details_error = None + + 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 + entra_client.user_registration_details_error = None + + 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 + + def test_user_registration_details_permission_error(self): + """Test FAIL when there's a permission error reading user registration details.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" + + 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, + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Cannot verify MFA capability for user Test User" + in result[0].status_extended + ) + assert "AuditLog.Read.All" in result[0].status_extended + assert result[0].resource == entra_client.users[user_id] + assert result[0].resource_name == "Test User" + assert result[0].resource_id == user_id + + def test_user_registration_details_permission_error_skips_guest_and_disabled(self): + """CIS-scope skip (Guest, disabled) still applies on the permission-error path. + + With ``user_registration_details_error`` set, only enabled member users + should receive a per-user "Cannot verify MFA capability" FAIL — guests + and disabled members are filtered out before the error branch runs. + """ + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" + + 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_id = str(uuid4()) + guest_id = str(uuid4()) + disabled_member_id = str(uuid4()) + entra_client.users = { + member_id: User( + id=member_id, + name="Enabled Member", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + ), + guest_id: User( + id=guest_id, + name="Guest User", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Guest", + ), + disabled_member_id: User( + id=disabled_member_id, + name="Disabled Member", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=False, + user_type="Member", + ), + } + + check = entra_users_mfa_capable() + result = check.execute() + + # Only the enabled member should be reported — Guest and + # disabled member are skipped before the error branch. + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Cannot verify MFA capability for user Enabled Member" + in result[0].status_extended + ) + assert "AuditLog.Read.All" in result[0].status_extended + assert result[0].resource == entra_client.users[member_id] + assert result[0].resource_name == "Enabled Member" + assert result[0].resource_id == member_id diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index d83219793e..ba6247c7bd 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -410,6 +411,7 @@ class Test_Entra_Service: id="user-1", display_name="User 1", on_premises_sync_enabled=True, + employee_hire_date=datetime(2026, 6, 10, tzinfo=timezone.utc), ), SimpleNamespace( id="user-2", @@ -521,16 +523,111 @@ class Test_Entra_Service: assert len(users) == 6 assert users_builder.get.await_count == 1 - assert users_builder.get.await_args.kwargs == {} + # The Graph users.get() call must request accountEnabled, userType and + # onPremisesSyncEnabled via $select. They are not part of the default + # property set, and omitting them causes disabled guest users to leak + # into checks like entra_users_mfa_capable (issue #10921). + request_configuration = users_builder.get.await_args.kwargs[ + "request_configuration" + ] + assert set(request_configuration.query_parameters.select) == { + "id", + "displayName", + "userType", + "accountEnabled", + "onPremisesSyncEnabled", + "employeeHireDate", + } with_url_mock.assert_called_once_with("next-link") assert users["user-1"].directory_roles_ids == ["role-template-1"] assert users["user-6"].directory_roles_ids == ["role-template-1"] + # When Graph does not return accountEnabled (legacy SimpleNamespace + # fixtures) we still honour the EXO PowerShell fallback for backwards + # compatibility. assert users["user-6"].account_enabled is False assert users["user-1"].is_mfa_capable is True assert users["user-2"].is_mfa_capable is False assert users["user-1"].authentication_methods == ["fido2SecurityKey"] assert users["user-6"].authentication_methods == ["mobilePhone"] assert users["user-2"].authentication_methods == [] + assert users["user-1"].employee_hire_date == datetime( + 2026, 6, 10, tzinfo=timezone.utc + ) + + def test__get_users_uses_graph_account_enabled_for_disabled_guests(self): + """Regression test for https://github.com/prowler-cloud/prowler/issues/10921. + + Disabled guest users do not appear in EXO's ``Get-User`` output, so the + previous code resolved their ``account_enabled`` from the EXO map, + defaulted it to ``True`` and surfaced them as failing findings in + ``entra_users_mfa_capable``. The Graph ``accountEnabled`` value must be + used as the source of truth so disabled guests are excluded. + """ + entra_service = Entra.__new__(Entra) + # Empty EXO map mirrors the production scenario where the disabled guest + # is absent from Get-User results. + entra_service.user_accounts_status = {} + + graph_users = [ + SimpleNamespace( + id="member-1", + display_name="Member User", + on_premises_sync_enabled=False, + account_enabled=True, + user_type="Member", + ), + SimpleNamespace( + id="guest-1", + display_name="Disabled Guest", + on_premises_sync_enabled=False, + account_enabled=False, + user_type="Guest", + ), + SimpleNamespace( + id="guest-2", + display_name="Enabled Guest", + on_premises_sync_enabled=False, + account_enabled=True, + user_type="Guest", + ), + ] + users_response = SimpleNamespace( + value=graph_users, + odata_next_link=None, + ) + users_builder = SimpleNamespace( + get=AsyncMock(return_value=users_response), + with_url=MagicMock(), + ) + directory_roles_builder = SimpleNamespace( + get=AsyncMock(return_value=SimpleNamespace(value=[])), + by_directory_role_id=MagicMock(), + ) + registration_details_builder = SimpleNamespace( + get=AsyncMock(return_value=SimpleNamespace(value=[], odata_next_link=None)), + with_url=MagicMock(), + ) + reports_builder = SimpleNamespace( + authentication_methods=SimpleNamespace( + user_registration_details=registration_details_builder + ) + ) + + entra_service.client = SimpleNamespace( + users=users_builder, + directory_roles=directory_roles_builder, + reports=reports_builder, + ) + + users = asyncio.run(entra_service._get_users()) + + assert len(users) == 3 + assert users["member-1"].account_enabled is True + assert users["member-1"].user_type == "Member" + assert users["guest-1"].account_enabled is False + assert users["guest-1"].user_type == "Guest" + assert users["guest-2"].account_enabled is True + assert users["guest-2"].user_type == "Guest" def test__get_user_registration_details_handles_pagination(self): entra_service = Entra.__new__(Entra) @@ -573,10 +670,11 @@ class Test_Entra_Service: ) ) - registration_details = asyncio.run( + registration_details, error_message = asyncio.run( entra_service._get_user_registration_details() ) + assert error_message is None assert registration_details == { "user-1": { "is_mfa_capable": True, @@ -593,3 +691,376 @@ class Test_Entra_Service: registration_builder.get.assert_awaited() registration_builder.with_url.assert_called_once_with("next-link") registration_builder_next.get.assert_awaited() + + def test__get_user_registration_details_returns_error_on_permission_denied(self): + """Test that 403 Authorization_RequestDenied returns an empty dict and + a descriptive error message naming the missing AuditLog.Read.All permission. + """ + from msgraph.generated.models.o_data_errors.main_error import MainError + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + odata_error = ODataError() + odata_error.error = MainError() + odata_error.error.code = "Authorization_RequestDenied" + + registration_builder = SimpleNamespace(get=AsyncMock(side_effect=odata_error)) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + reports=SimpleNamespace( + authentication_methods=SimpleNamespace( + user_registration_details=registration_builder + ) + ) + ) + + registration_details, error_message = asyncio.run( + entra_service._get_user_registration_details() + ) + + assert registration_details == {} + assert error_message is not None + assert "AuditLog.Read.All" in error_message + assert "user registration details" in error_message + + def test__get_service_principals_filters_third_party_owners(self): + """Service principals owned by another tenant must not be returned.""" + # Mixed-case input to verify the service normalizes both sides before + # comparison, so a Graph response that returns the owner id in upper + # case still matches the lower-case identity in the provider. + tenant_id_in = "AAAAAAAA-1111-1111-1111-111111111111" + tenant_id_lower = tenant_id_in.lower() + microsoft_tenant_id = "f8cdef31-a31e-4b4a-93e4-5f571e91255a" + + owned_sp = SimpleNamespace( + id="sp-owned", + display_name="Customer App", + app_id="app-owned", + app_owner_organization_id=tenant_id_in, + password_credentials=[ + SimpleNamespace( + key_id="cred-1", + display_name="secret", + end_date_time=None, + ) + ], + key_credentials=[], + ) + first_party_sp = SimpleNamespace( + id="sp-first-party", + display_name="Microsoft Graph", + app_id="app-graph", + app_owner_organization_id=microsoft_tenant_id, + password_credentials=[ + SimpleNamespace( + key_id="cred-2", + display_name="secret", + end_date_time=None, + ) + ], + key_credentials=[], + ) + third_party_sp = SimpleNamespace( + id="sp-third-party", + display_name="Some Vendor App", + app_id="app-vendor", + app_owner_organization_id="22222222-2222-2222-2222-222222222222", + password_credentials=[], + key_credentials=[], + ) + + sp_response = SimpleNamespace( + value=[owned_sp, first_party_sp, third_party_sp], + odata_next_link=None, + ) + + empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None) + + role_assignments_builder = SimpleNamespace( + get=AsyncMock(return_value=empty_assignments_response) + ) + role_management_builder = SimpleNamespace( + directory=SimpleNamespace( + role_assignments=role_assignments_builder, + ) + ) + + service_principals_builder = SimpleNamespace( + get=AsyncMock(return_value=sp_response), + with_url=MagicMock(), + ) + + # The /applications endpoint returns no entries for this case, so the + # service-level test just exercises the customer-owned filter, not the + # secret join. + applications_response = SimpleNamespace(value=[], odata_next_link=None) + applications_builder = SimpleNamespace( + get=AsyncMock(return_value=applications_response), + with_url=MagicMock(), + ) + + entra_service = Entra.__new__(Entra) + entra_service.tenant_id = tenant_id_lower + entra_service.client = SimpleNamespace( + service_principals=service_principals_builder, + role_management=role_management_builder, + applications=applications_builder, + ) + + result = asyncio.run(entra_service._get_service_principals()) + + assert set(result.keys()) == {"sp-owned"} + assert result["sp-owned"].app_owner_organization_id == tenant_id_lower + + def test__get_service_principals_merges_application_credentials(self): + """Secrets registered on the parent Application must be attributed to the SP.""" + tenant_id = "11111111-1111-1111-1111-111111111111" + + # SP returned by Graph with NO password_credentials of its own (the + # common case in production when the secret was added through "App + # registrations > Certificates & secrets"). + sp_without_sp_level_secret = SimpleNamespace( + id="sp-owned", + display_name="m365-dev", + app_id="app-owned", + app_owner_organization_id=tenant_id, + password_credentials=[], + key_credentials=[], + ) + sp_response = SimpleNamespace( + value=[sp_without_sp_level_secret], odata_next_link=None + ) + + # The corresponding Application carries the actual secret. + future = datetime(2099, 1, 1, tzinfo=timezone.utc) + application = SimpleNamespace( + id="app-object", + app_id="app-owned", + password_credentials=[ + SimpleNamespace( + key_id="cred-app", + display_name="app-level-secret", + end_date_time=future, + ), + ], + key_credentials=[], + ) + applications_response = SimpleNamespace( + value=[application], odata_next_link=None + ) + + empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None) + + entra_service = Entra.__new__(Entra) + entra_service.tenant_id = tenant_id + entra_service.client = SimpleNamespace( + service_principals=SimpleNamespace( + get=AsyncMock(return_value=sp_response), + with_url=MagicMock(), + ), + applications=SimpleNamespace( + get=AsyncMock(return_value=applications_response), + with_url=MagicMock(), + ), + role_management=SimpleNamespace( + directory=SimpleNamespace( + role_assignments=SimpleNamespace( + get=AsyncMock(return_value=empty_assignments_response), + ), + ) + ), + ) + + result = asyncio.run(entra_service._get_service_principals()) + + merged = result["sp-owned"] + assert len(merged.password_credentials) == 1 + assert merged.password_credentials[0].key_id == "cred-app" + assert merged.password_credentials[0].display_name == "app-level-secret" + assert merged.password_credentials[0].is_active() + + def test__resolve_identifiers_for_type_flags_only_404(self): + """Only HTTP 404 / Request_ResourceNotFound mark an id as deleted. + + Transient errors (5xx, throttling) and successful resolutions must + never be added to the unresolved set — that is the contract the check + relies on to avoid false positives during Graph outages. + """ + from msgraph.generated.models.o_data_errors.main_error import MainError + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_by_status = "deleted-status-404" + deleted_by_code = "deleted-code-rnf" + transient = "transient-503" + live = "live-user" + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None # status code alone is enough + + error_rnf = ODataError() + error_rnf.response_status_code = None + error_rnf.error = MainError() + error_rnf.error.code = "Request_ResourceNotFound" + + error_503 = ODataError() + error_503.response_status_code = 503 + error_503.error = MainError() + error_503.error.code = "ServiceUnavailable" + + user_builders = { + deleted_by_status: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + deleted_by_code: SimpleNamespace(get=AsyncMock(side_effect=error_rnf)), + transient: SimpleNamespace(get=AsyncMock(side_effect=error_503)), + live: SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(id=live))), + } + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + users=SimpleNamespace( + by_user_id=MagicMock(side_effect=lambda uid: user_builders[uid]) + ) + ) + + unresolved = set() + errored = set() + asyncio.run( + entra_service._resolve_identifiers_for_type( + "user", set(user_builders), unresolved, errored + ) + ) + + assert unresolved == { + ("user", deleted_by_status), + ("user", deleted_by_code), + } + # The transient 503 must be recorded as errored (unverified), never as + # deleted and never silently dropped. + assert errored == {("user", transient)} + + def test__resolve_identifiers_for_type_role_uses_role_definitions_endpoint(self): + """A deleted role is resolved against the roleDefinitions endpoint.""" + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_role = "deleted-role-id" + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None + + by_role_id = MagicMock( + return_value=SimpleNamespace(get=AsyncMock(side_effect=error_404)) + ) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + role_management=SimpleNamespace( + directory=SimpleNamespace( + role_definitions=SimpleNamespace( + by_unified_role_definition_id=by_role_id + ) + ) + ) + ) + + unresolved = set() + errored = set() + asyncio.run( + entra_service._resolve_identifiers_for_type( + "role", {deleted_role}, unresolved, errored + ) + ) + + assert unresolved == {("role", deleted_role)} + assert errored == set() + by_role_id.assert_called_once_with(deleted_role) + + def test__resolve_directory_object_references_skips_sentinels_and_dedups(self): + """End-to-end resolver: sentinels are never queried, ids are deduped + across policies, and only deleted ids land in the unresolved set.""" + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_user = "deleted-user-id" + live_user = "live-user-id" + deleted_group = "deleted-group-id" + errored_group = "errored-group-id" + + def _user_conditions(**kwargs): + base = { + "included_users": [], + "excluded_users": [], + "included_groups": [], + "excluded_groups": [], + "included_roles": [], + "excluded_roles": [], + } + base.update(kwargs) + return SimpleNamespace(**base) + + def _policy(user_conditions): + return SimpleNamespace( + conditions=SimpleNamespace(user_conditions=user_conditions) + ) + + policies = { + "policy-a": _policy( + _user_conditions( + included_users=["All", deleted_user, live_user], + excluded_groups=[deleted_group, errored_group], + ) + ), + # Same deleted_user referenced again — must be resolved only once. + "policy-b": _policy( + _user_conditions( + included_users=[deleted_user], + excluded_users=["GuestsOrExternalUsers"], + ) + ), + # Policy without user conditions must be skipped without error. + "policy-c": SimpleNamespace( + conditions=SimpleNamespace(user_conditions=None) + ), + } + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None + + error_503 = ODataError() + error_503.response_status_code = 503 + error_503.error = None + + user_builders = { + deleted_user: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + live_user: SimpleNamespace( + get=AsyncMock(return_value=SimpleNamespace(id=live_user)) + ), + } + group_builders = { + deleted_group: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + errored_group: SimpleNamespace(get=AsyncMock(side_effect=error_503)), + } + by_user_id = MagicMock(side_effect=lambda uid: user_builders[uid]) + by_group_id = MagicMock(side_effect=lambda gid: group_builders[gid]) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + users=SimpleNamespace(by_user_id=by_user_id), + groups=SimpleNamespace(by_group_id=by_group_id), + ) + + unresolved, errored = asyncio.run( + entra_service._resolve_directory_object_references(policies) + ) + + assert unresolved == { + ("user", deleted_user), + ("group", deleted_group), + } + # The 503 group is unverified, not deleted — it lands in errored. + assert errored == {("group", errored_group)} + # Sentinels are filtered before any Graph call; only the two real user + # ids are queried, and the deduped deleted_user is queried exactly once. + queried_users = {call.args[0] for call in by_user_id.call_args_list} + assert queried_users == {deleted_user, live_user} + assert user_builders[deleted_user].get.await_count == 1 diff --git a/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py new file mode 100644 index 0000000000..9ec073a922 --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py @@ -0,0 +1,300 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_exchange_mailbox_primary_smtp_uses_custom_domain: + + def test_powershell_unavailable_manual(self): + """MANUAL: Exchange Online PowerShell unavailable (mailboxes is None).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.mailboxes = None + + 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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "PowerShell" in result[0].status_extended + assert result[0].resource_name == "Exchange Online Mailboxes" + assert result[0].resource_id == "exchange_mailboxes" + + def test_empty_tenant_no_findings(self): + """Empty tenant (no mailboxes) produces zero findings, not MANUAL.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.mailboxes = [] + + 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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert result == [] + + def test_custom_domain_passes(self): + """PASS: Mailbox primary SMTP uses a custom domain.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.com", + name="User One", + primary_smtp_address="user1@contoso.com", + recipient_type_details="UserMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "custom domain" in result[0].status_extended + assert result[0].resource_name == "User One" + assert result[0].resource_id == "user1@contoso.com" + assert result[0].location == "global" + + def test_onmicrosoft_domain_fails(self): + """FAIL: Mailbox primary SMTP uses .onmicrosoft.com.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.onmicrosoft.com", + name="User One", + primary_smtp_address="user1@contoso.onmicrosoft.com", + recipient_type_details="UserMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ".onmicrosoft.com" in result[0].status_extended + assert result[0].resource_name == "User One" + assert result[0].resource_id == "user1@contoso.onmicrosoft.com" + assert result[0].location == "global" + + def test_mixed_mailboxes(self): + """Test multiple mailboxes with mixed domain status.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.com", + name="User One", + primary_smtp_address="user1@contoso.com", + recipient_type_details="UserMailbox", + ), + Mailbox( + identity="shared@contoso.onmicrosoft.com", + name="Shared Mailbox", + primary_smtp_address="shared@contoso.onmicrosoft.com", + recipient_type_details="SharedMailbox", + ), + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 2 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Mailbox user1@contoso.com (UserMailbox) has primary SMTP address user1@contoso.com using a custom domain." + ) + assert result[1].status == "FAIL" + assert ( + result[1].status_extended + == "Mailbox shared@contoso.onmicrosoft.com (SharedMailbox) has primary SMTP address shared@contoso.onmicrosoft.com using the .onmicrosoft.com domain instead of a custom domain." + ) + + def test_room_mailbox_custom_domain(self): + """PASS: Room mailbox using a custom domain.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="boardroom@contoso.com", + name="Board Room", + primary_smtp_address="boardroom@contoso.com", + recipient_type_details="RoomMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "boardroom@contoso.com" + assert result[0].location == "global" + + def test_equipment_mailbox_onmicrosoft(self): + """FAIL: Equipment mailbox using .onmicrosoft.com.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="projector@contoso.onmicrosoft.com", + name="Projector", + primary_smtp_address="projector@contoso.onmicrosoft.com", + recipient_type_details="EquipmentMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "projector@contoso.onmicrosoft.com" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_fixer_test.py b/tests/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_fixer_test.py new file mode 100644 index 0000000000..672a5b1c73 --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_fixer_test.py @@ -0,0 +1,61 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_exchange_organization_delicensing_resiliency_enabled_fixer: + def test_creates_new_powershell_session(self): + created_session = mock.MagicMock() + created_session.connect_exchange_online.return_value = True + created_session.execute.return_value = "" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer.M365PowerShell", + return_value=created_session, + ) as mocked_powershell, + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer import ( + fixer, + ) + + assert fixer() + mocked_powershell.assert_called_once() + created_session.connect_exchange_online.assert_called_once() + created_session.execute.assert_any_call( + "Set-OrganizationConfig -DelayedDelicensingEnabled $true", + timeout=30, + ) + created_session.close.assert_called_once() + + def test_logs_power_shell_execution_error(self): + created_session = mock.MagicMock() + created_session.connect_exchange_online.return_value = True + created_session.execute.return_value = "Access is denied." + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer.M365PowerShell", + return_value=created_session, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer.logger.error", + ) as mocked_logger_error, + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer import ( + fixer, + ) + + assert not fixer() + mocked_logger_error.assert_any_call( + 'PowerShell execution failed while running "Set-OrganizationConfig -DelayedDelicensingEnabled $true": Access is denied.' + ) + created_session.close.assert_called_once() diff --git a/tests/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_test.py b/tests/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_test.py new file mode 100644 index 0000000000..d9423afa4d --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_organization_delicensing_resiliency_enabled/exchange_organization_delicensing_resiliency_enabled_test.py @@ -0,0 +1,278 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +ORGANIZATION_KWARGS = dict( + name="test-org", + guid="org-guid", + audit_disabled=False, + oauth_enabled=True, + mailtips_enabled=True, + mailtips_external_recipient_enabled=False, + mailtips_group_metrics_enabled=True, + mailtips_large_audience_threshold=25, +) + + +class Test_exchange_organization_delicensing_resiliency_enabled: + def test_no_organization(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + + 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.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import ( + exchange_organization_delicensing_resiliency_enabled, + ) + + check = exchange_organization_delicensing_resiliency_enabled() + result = check.execute() + assert len(result) == 0 + + def test_delicensing_resiliency_disabled_above_threshold(self): + """Disabled + >= 5000 total licenses -> FAIL (fixer confirms eligibility).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import ( + exchange_organization_delicensing_resiliency_enabled, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Organization, + ) + + exchange_client.organization_config = Organization( + **ORGANIZATION_KWARGS, + delayed_delicensing_enabled=False, + total_paid_licenses=6000, + ) + + check = exchange_organization_delicensing_resiliency_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "preventive FAIL" in result[0].status_extended + assert result[0].resource_name == "test-org" + assert result[0].resource_id == "org-guid" + assert result[0].location == "global" + + def test_delicensing_resiliency_disabled_at_threshold(self): + """Disabled + exactly 5000 total licenses -> FAIL.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import ( + exchange_organization_delicensing_resiliency_enabled, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Organization, + ) + + exchange_client.organization_config = Organization( + **ORGANIZATION_KWARGS, + delayed_delicensing_enabled=False, + total_paid_licenses=5000, + ) + + check = exchange_organization_delicensing_resiliency_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_delicensing_resiliency_disabled_below_threshold(self): + """Disabled + < 5000 total licenses -> PASS (not applicable).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import ( + exchange_organization_delicensing_resiliency_enabled, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Organization, + ) + + exchange_client.organization_config = Organization( + **ORGANIZATION_KWARGS, + delayed_delicensing_enabled=False, + total_paid_licenses=4999, + ) + + check = exchange_organization_delicensing_resiliency_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "not applicable" in result[0].status_extended + assert "4999 total licenses" in result[0].status_extended + + def test_delicensing_resiliency_disabled_licenses_unknown(self): + """Disabled + unknown license count -> FAIL.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import ( + exchange_organization_delicensing_resiliency_enabled, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Organization, + ) + + exchange_client.organization_config = Organization( + **ORGANIZATION_KWARGS, + delayed_delicensing_enabled=False, + total_paid_licenses=None, + ) + + check = exchange_organization_delicensing_resiliency_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "preventive FAIL" in result[0].status_extended + + def test_delicensing_resiliency_enabled(self): + """Enabled -> PASS regardless of license count.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import ( + exchange_organization_delicensing_resiliency_enabled, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Organization, + ) + + exchange_client.organization_config = Organization( + **ORGANIZATION_KWARGS, + delayed_delicensing_enabled=True, + total_paid_licenses=6000, + ) + + check = exchange_organization_delicensing_resiliency_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is enabled" in result[0].status_extended + assert result[0].resource == exchange_client.organization_config.dict() + assert result[0].resource_name == "test-org" + assert result[0].resource_id == "org-guid" + assert result[0].location == "global" + + def test_delicensing_resiliency_enabled_below_threshold(self): + """Enabled + below threshold -> still PASS (enabled always wins).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import ( + exchange_organization_delicensing_resiliency_enabled, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Organization, + ) + + exchange_client.organization_config = Organization( + **ORGANIZATION_KWARGS, + delayed_delicensing_enabled=True, + total_paid_licenses=100, + ) + + check = exchange_organization_delicensing_resiliency_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is enabled" in result[0].status_extended diff --git a/tests/providers/m365/services/exchange/exchange_service_test.py b/tests/providers/m365/services/exchange/exchange_service_test.py index 3f0e62cc3b..8d812e3bb3 100644 --- a/tests/providers/m365/services/exchange/exchange_service_test.py +++ b/tests/providers/m365/services/exchange/exchange_service_test.py @@ -161,6 +161,18 @@ def mock_exchange_get_shared_mailboxes(_): ] +async def mock_exchange_get_total_paid_licenses(_): + return 6000 + + +async def mock_exchange_get_total_paid_licenses_none(_): + return None + + +@patch( + "prowler.providers.m365.services.exchange.exchange_service.Exchange._get_total_paid_licenses", + new=mock_exchange_get_total_paid_licenses, +) class Test_Exchange_Service: def test_get_client(self): with ( @@ -201,6 +213,7 @@ class Test_Exchange_Service: assert organization_config.mailtips_external_recipient_enabled is False assert organization_config.mailtips_group_metrics_enabled is True assert organization_config.mailtips_large_audience_threshold == 25 + assert organization_config.total_paid_licenses == 6000 exchange_client.powershell.close() @@ -481,3 +494,125 @@ class Test_Exchange_Service: assert shared_mailboxes[1].identity == "info@contoso.com" exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + return_value=[ + { + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox", + }, + { + "Identity": "room@contoso.com", + "DisplayName": "Boardroom", + "PrimarySmtpAddress": "room@contoso.com", + "RecipientTypeDetails": "RoomMailbox", + }, + { + "Identity": "DiscoverySearchMailbox{D919BA05}", + "DisplayName": "Discovery Search Mailbox", + "PrimarySmtpAddress": "DiscoverySearchMailbox@contoso.onmicrosoft.com", + "RecipientTypeDetails": "DiscoveryMailbox", + }, + { + "Identity": "SystemMailbox{1f05a927}", + "DisplayName": "Microsoft Exchange", + "PrimarySmtpAddress": "SystemMailbox@contoso.onmicrosoft.com", + "RecipientTypeDetails": "SystemMailbox", + }, + ], + ) + def test_get_mailboxes_excludes_system_types(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + mailboxes = exchange_client.mailboxes + assert mailboxes is not None + assert len(mailboxes) == 2 + identities = {m.identity for m in mailboxes} + assert identities == {"user1@contoso.com", "room@contoso.com"} + assert all( + m.recipient_type_details not in {"DiscoveryMailbox", "SystemMailbox"} + for m in mailboxes + ) + exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + return_value={ + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox", + }, + ) + def test_get_mailboxes_single_dict(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + mailboxes = exchange_client.mailboxes + assert mailboxes is not None + assert len(mailboxes) == 1 + assert mailboxes[0].identity == "user1@contoso.com" + exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + side_effect=Exception("Get-EXOMailbox failed"), + ) + def test_get_mailboxes_returns_none_on_exception(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + assert exchange_client.mailboxes is None + exchange_client.powershell.close() + + def test_get_total_paid_licenses_none(self): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch.object( + Exchange, + "_get_organization_config", + mock_exchange_get_organization_config, + ), + mock.patch.object( + Exchange, + "_get_total_paid_licenses", + mock_exchange_get_total_paid_licenses_none, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + assert exchange_client.organization_config.total_paid_licenses is None + exchange_client.powershell.close() diff --git a/tests/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default_test.py b/tests/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default_test.py new file mode 100644 index 0000000000..3dc51076eb --- /dev/null +++ b/tests/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default_test.py @@ -0,0 +1,163 @@ +from unittest import mock + +from prowler.providers.m365.services.intune.intune_service import IntuneSettings +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + + +class Test_intune_device_compliance_policy_unassigned_devices_not_compliant_by_default: + def test_secure_by_default_true(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = IntuneSettings(secure_by_default=True) + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Not compliant." + ) + + def test_secure_by_default_false(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = IntuneSettings(secure_by_default=False) + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Compliant. " + "Change the default to Not compliant in Intune settings." + ) + + def test_secure_by_default_none(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = IntuneSettings(secure_by_default=None) + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy could not be verified " + "because Microsoft Graph did not return the secure-by-default " + "compliance setting." + ) + + def test_settings_is_none(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = None + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy could not be verified " + "because Microsoft Graph did not return the secure-by-default " + "compliance setting." + ) + + def test_verification_error_returns_manual(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = None + intune_client.verification_error = ( + "Could not read Microsoft Intune device management settings." + ) + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy could not be verified. " + "Could not read Microsoft Intune device management settings." + ) diff --git a/tests/providers/m365/services/intune/intune_service_test.py b/tests/providers/m365/services/intune/intune_service_test.py index 3e6762391f..1e273d04e9 100644 --- a/tests/providers/m365/services/intune/intune_service_test.py +++ b/tests/providers/m365/services/intune/intune_service_test.py @@ -349,6 +349,60 @@ def test_intune_get_settings_null_settings(): assert settings.secure_by_default is None +def test_intune_get_settings_retries_without_select_when_settings_missing(): + """Test _get_settings retries without $select when settings are omitted.""" + intune = Intune.__new__(Intune) + + selected_response = SimpleNamespace(settings=None) + full_response = SimpleNamespace(settings=SimpleNamespace(secure_by_default=True)) + + mock_client = mock.MagicMock() + mock_client.device_management.get = AsyncMock( + side_effect=[selected_response, full_response] + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + settings, error = loop.run_until_complete(intune._get_settings()) + finally: + loop.close() + + assert error is None + assert settings is not None + assert settings.secure_by_default is True + assert mock_client.device_management.get.await_count == 2 + + +def test_intune_get_settings_retries_without_select_when_value_missing(): + """Test _get_settings retries without $select when secure_by_default is omitted.""" + intune = Intune.__new__(Intune) + + selected_response = SimpleNamespace( + settings=SimpleNamespace(secure_by_default=None) + ) + full_response = SimpleNamespace(settings=SimpleNamespace(secure_by_default=False)) + + mock_client = mock.MagicMock() + mock_client.device_management.get = AsyncMock( + side_effect=[selected_response, full_response] + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + settings, error = loop.run_until_complete(intune._get_settings()) + finally: + loop.close() + + assert error is None + assert settings is not None + assert settings.secure_by_default is False + assert mock_client.device_management.get.await_count == 2 + + def test_intune_get_settings_exception(): """Test _get_settings handles exceptions gracefully.""" intune = Intune.__new__(Intune) diff --git a/tests/providers/okta/exceptions/okta_exceptions_test.py b/tests/providers/okta/exceptions/okta_exceptions_test.py new file mode 100644 index 0000000000..28ea120d4e --- /dev/null +++ b/tests/providers/okta/exceptions/okta_exceptions_test.py @@ -0,0 +1,78 @@ +import pytest + +from prowler.providers.okta.exceptions.exceptions import ( + OktaBaseException, + OktaCredentialsError, + OktaEnvironmentVariableError, + OktaInsufficientPermissionsError, + OktaInvalidCredentialsError, + OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, + OktaPrivateKeyFileError, + OktaSetUpIdentityError, + OktaSetUpSessionError, +) + +EXPECTED_CODES = { + OktaEnvironmentVariableError: 14000, + OktaSetUpSessionError: 14001, + OktaSetUpIdentityError: 14002, + OktaInvalidCredentialsError: 14003, + OktaInvalidOrgDomainError: 14004, + OktaPrivateKeyFileError: 14005, + OktaInsufficientPermissionsError: 14006, + OktaInvalidProviderIdError: 14007, +} + + +class Test_OktaExceptions: + def test_all_codes_in_reserved_range(self): + codes = [c for c, _ in OktaBaseException.OKTA_ERROR_CODES.keys()] + assert all(14000 <= c <= 14999 for c in codes) + assert len(codes) == len(set(codes)) # unique + + def test_all_subclasses_inherit_from_credentials_error(self): + for exc_cls in EXPECTED_CODES: + assert issubclass(exc_cls, OktaCredentialsError) + assert issubclass(exc_cls, OktaBaseException) + + @pytest.mark.parametrize("exc_cls,code", list(EXPECTED_CODES.items())) + def test_each_exception_carries_its_code(self, exc_cls, code): + exc = exc_cls() + assert exc.code == code + assert exc.source == "Okta" + assert exc.message # populated from OKTA_ERROR_CODES + assert exc.remediation # populated from OKTA_ERROR_CODES + + @pytest.mark.parametrize("exc_cls", list(EXPECTED_CODES.keys())) + def test_custom_message_overrides_default(self, exc_cls): + custom = "specific error context" + exc = exc_cls(message=custom) + assert exc.message == custom + + def test_str_format_includes_class_code_and_message(self): + exc = OktaInvalidOrgDomainError(message="bad url") + rendered = str(exc) + assert "OktaInvalidOrgDomainError" in rendered + assert "[14004]" in rendered + assert "bad url" in rendered + + def test_original_exception_appended_to_str(self): + original = ValueError("network down") + exc = OktaSetUpIdentityError(original_exception=original) + rendered = str(exc) + assert "network down" in rendered + + def test_can_be_raised_and_caught(self): + with pytest.raises(OktaInvalidCredentialsError) as info: + raise OktaInvalidCredentialsError(message="bad token") + assert info.value.code == 14003 + assert "bad token" in str(info.value) + + def test_caught_as_credentials_error_base(self): + with pytest.raises(OktaCredentialsError): + raise OktaPrivateKeyFileError(message="empty") + + def test_caught_as_okta_base_exception(self): + with pytest.raises(OktaBaseException): + raise OktaEnvironmentVariableError(message="missing org url") diff --git a/tests/providers/okta/lib/arguments/okta_arguments_test.py b/tests/providers/okta/lib/arguments/okta_arguments_test.py new file mode 100644 index 0000000000..0e3fb9c1a1 --- /dev/null +++ b/tests/providers/okta/lib/arguments/okta_arguments_test.py @@ -0,0 +1,62 @@ +from unittest.mock import MagicMock + +from prowler.providers.okta.lib.arguments import arguments + + +class TestOktaArguments: + def setup_method(self): + self.mock_parser = MagicMock() + self.mock_subparsers = MagicMock() + self.mock_okta_parser = MagicMock() + + self.mock_parser.add_subparsers.return_value = self.mock_subparsers + self.mock_subparsers.add_parser.return_value = self.mock_okta_parser + + def test_init_parser_creates_subparser(self): + mock_args = MagicMock() + mock_args.subparsers = self.mock_subparsers + mock_args.common_providers_parser = MagicMock() + + arguments.init_parser(mock_args) + + self.mock_subparsers.add_parser.assert_called_once_with( + "okta", + parents=[mock_args.common_providers_parser], + help="Okta Provider", + ) + + def test_init_parser_registers_non_secret_flags(self): + mock_args = MagicMock() + mock_args.subparsers = self.mock_subparsers + mock_args.common_providers_parser = MagicMock() + + auth_group = MagicMock() + self.mock_okta_parser.add_argument_group.return_value = auth_group + + arguments.init_parser(mock_args) + + registered = {call.args[0] for call in auth_group.add_argument.call_args_list} + assert registered == { + "--okta-org-domain", + "--okta-client-id", + "--okta-scopes", + } + + def test_secret_flags_not_registered(self): + """Private key material must never be a CLI flag — env-only.""" + mock_args = MagicMock() + mock_args.subparsers = self.mock_subparsers + mock_args.common_providers_parser = MagicMock() + + auth_group = MagicMock() + self.mock_okta_parser.add_argument_group.return_value = auth_group + + arguments.init_parser(mock_args) + + registered = {call.args[0] for call in auth_group.add_argument.call_args_list} + assert "--okta-private-key" not in registered + assert "--okta-private-key-file" not in registered + + def test_no_sensitive_arguments_constant(self): + """No SENSITIVE_ARGUMENTS frozenset needed — no secret flags exist.""" + assert not hasattr(arguments, "SENSITIVE_ARGUMENTS") diff --git a/tests/providers/okta/lib/mutelist/fixtures/okta_mutelist.yaml b/tests/providers/okta/lib/mutelist/fixtures/okta_mutelist.yaml new file mode 100644 index 0000000000..f443b6182b --- /dev/null +++ b/tests/providers/okta/lib/mutelist/fixtures/okta_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "acme.okta.com": + Checks: + "signon_global_session_idle_timeout_15min": + Regions: + - "*" + Resources: + - "pol-default" diff --git a/tests/providers/okta/lib/mutelist/okta_mutelist_test.py b/tests/providers/okta/lib/mutelist/okta_mutelist_test.py new file mode 100644 index 0000000000..d41bfbcc03 --- /dev/null +++ b/tests/providers/okta/lib/mutelist/okta_mutelist_test.py @@ -0,0 +1,104 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist + +MUTELIST_FIXTURE_PATH = "tests/providers/okta/lib/mutelist/fixtures/okta_mutelist.yaml" + + +class TestOktaMutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = OktaMutelist(mutelist_path=MUTELIST_FIXTURE_PATH) + + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + assert mutelist.mutelist == mutelist_fixture + assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH + + def test_get_mutelist_file_from_local_file_non_existent(self): + mutelist_path = "tests/providers/okta/lib/mutelist/fixtures/not_present" + mutelist = OktaMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = OktaMutelist(mutelist_content=mutelist_fixture) + + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path is None + + def test_is_finding_muted_match(self): + mutelist_content = { + "Accounts": { + "acme.okta.com": { + "Checks": { + "signon_global_session_idle_timeout_15min": { + "Regions": ["*"], + "Resources": ["Default Policy"], + } + } + } + } + } + mutelist = OktaMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata.CheckID = "signon_global_session_idle_timeout_15min" + finding.resource_name = "Default Policy" + finding.resource_tags = [] + + assert mutelist.is_finding_muted(finding, org_domain="acme.okta.com") is True + + def test_is_finding_muted_no_match(self): + mutelist_content = { + "Accounts": { + "acme.okta.com": { + "Checks": { + "signon_global_session_idle_timeout_15min": { + "Regions": ["*"], + "Resources": ["Default Policy"], + } + } + } + } + } + mutelist = OktaMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata.CheckID = "signon_global_session_idle_timeout_15min" + finding.resource_name = "Some Other Policy" + finding.resource_tags = [] + + assert mutelist.is_finding_muted(finding, org_domain="acme.okta.com") is False + + def test_is_finding_muted_no_match_on_different_org(self): + mutelist_content = { + "Accounts": { + "acme.okta.com": { + "Checks": { + "signon_global_session_idle_timeout_15min": { + "Regions": ["*"], + "Resources": ["*"], + } + } + } + } + } + mutelist = OktaMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata.CheckID = "signon_global_session_idle_timeout_15min" + finding.resource_name = "Default Policy" + finding.resource_tags = [] + + assert mutelist.is_finding_muted(finding, org_domain="other.okta.com") is False diff --git a/tests/providers/okta/lib/service/pagination_test.py b/tests/providers/okta/lib/service/pagination_test.py new file mode 100644 index 0000000000..a14baeb51e --- /dev/null +++ b/tests/providers/okta/lib/service/pagination_test.py @@ -0,0 +1,147 @@ +"""Tests for the shared Okta pagination helpers in +`prowler.providers.okta.lib.service.pagination`. + +Covers `next_after_cursor` (extracts the `after` query param from an +RFC 5988 `Link: rel="next"` header) and `paginate` (drains all pages +of an SDK list call by following the cursor). + +These tests were carved out of `network_zone_service_test.py` when its +local pagination helpers were replaced by the shared module — they now +cover code that six Okta services depend on. +""" + +import asyncio +from types import SimpleNamespace + +from prowler.providers.okta.lib.service.pagination import ( + next_after_cursor, + paginate, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +class Test_next_after_cursor: + """Behaviours previously covered in `network_zone_service_test.py` + under `Test_network_zone_pagination` — relocated here when the + local helper was replaced by the shared module. + """ + + def test_returns_none_when_response_is_none(self): + assert next_after_cursor(None) is None + + def test_returns_none_when_no_link_header(self): + assert next_after_cursor(_resp({})) is None + + def test_extracts_next_after_cursor(self): + link = ( + '; rel="self", ' + '; rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) == "next-page" + + def test_reads_lowercase_link_header(self): + # aiohttp's `CIMultiDict` is case-insensitive in practice, but + # callers occasionally pass a dict, so we check both spellings. + link = '; rel="next"' + assert next_after_cursor(_resp({"link": link})) == "cursor-1" + + def test_next_link_without_after_query_returns_none(self): + link = ( + '; rel="self", ' + '; rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) is None + + def test_no_next_segment_returns_none(self): + link = '; rel="self"' + assert next_after_cursor(_resp({"Link": link})) is None + + def test_url_decodes_after_cursor(self): + # `parse_qs` decodes percent-encoded values — opaque cursors with + # `=` or `+` must round-trip through callers that re-encode. + link = ( + "; " 'rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) == "cursor=abc+1" + + +class Test_paginate: + def test_returns_items_for_single_page_response(self): + async def fetch(_after): + return (["a", "b"], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == ["a", "b"] + assert err is None + + def test_drains_multiple_pages(self): + link = '; rel="next"' + seen_cursors: list = [] + + async def fetch(after): + seen_cursors.append(after) + if after is None: + return (["a"], _resp({"link": link}), None) + return (["b"], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == ["a", "b"] + assert err is None + assert seen_cursors == [None, "p2"] + + def test_returns_empty_when_first_page_is_empty(self): + async def fetch(_after): + return ([], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == [] + assert err is None + + def test_returns_empty_and_error_when_first_page_fails(self): + async def fetch(_after): + return ([], _resp({}), Exception("forbidden")) + + items, err = _run(paginate(fetch)) + assert items == [] + assert str(err) == "forbidden" + + def test_returns_partial_items_when_subsequent_page_errors(self): + # Carved out of `network_zone_service_test.py`'s + # `test_pagination_returns_partial_items_when_second_page_errors`. + link = '; rel="next"' + + async def fetch(after): + if after is None: + return (["page-1"], _resp({"link": link}), None) + return ([], _resp({}), Exception("page failed")) + + items, err = _run(paginate(fetch)) + assert items == ["page-1"] + assert str(err) == "page failed" + + def test_accepts_early_error_two_tuple_shape(self): + # The Okta SDK returns `(items, err)` on request-build failures + # (no response) and `(items, resp, err)` on transport responses. + # `paginate` reads `result[-1]` for err so the 2-tuple shape is + # handled — verify explicitly. + async def fetch(_after): + return ([], Exception("create failed")) + + items, err = _run(paginate(fetch)) + assert items == [] + assert str(err) == "create failed" + + def test_treats_none_items_as_empty_list(self): + async def fetch(_after): + return (None, _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == [] + assert err is None diff --git a/tests/providers/okta/lib/service/raw_fetch_test.py b/tests/providers/okta/lib/service/raw_fetch_test.py new file mode 100644 index 0000000000..173e78888a --- /dev/null +++ b/tests/providers/okta/lib/service/raw_fetch_test.py @@ -0,0 +1,152 @@ +"""Tests for the raw-JSON HTTP helpers in +`prowler.providers.okta.lib.service.raw_fetch`. + +Covers `get_json` (single-shot) and `get_json_paginated` +(drains list endpoints via the `Link: rel="next"` cursor). +""" + +import asyncio +import json +from unittest import mock + +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json, + get_json_paginated, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _mock_response(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +class Test_get_json: + def test_returns_parsed_json_on_success(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return ({"url": "/x"}, None) + + async def execute(_req): + return (_mock_response(), json.dumps({"hello": "world"}), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json(client, "/x")) == {"hello": "world"} + + def test_returns_none_on_create_request_error(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return (None, Exception("boom")) + + client._request_executor.create_request = create + assert _run(get_json(client, "/x")) is None + + def test_returns_none_on_execute_error(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return ({"url": "/x"}, None) + + async def execute(_req): + return (_mock_response(), None, Exception("boom")) + + client._request_executor.create_request = create + client._request_executor.execute = execute + assert _run(get_json(client, "/x")) is None + + +class Test_get_json_paginated: + def test_drains_all_pages_following_link_rel_next(self): + # Two pages: first carries `Link: <…?after=cur1>; rel="next"`, + # second has no `next`, so iteration stops. + client = mock.MagicMock() + + page1 = [{"id": "a"}, {"id": "b"}] + page2 = [{"id": "c"}] + page1_headers = { + "link": '; rel="next"' + } + + seen_urls = [] + + async def create(**kwargs): + seen_urls.append(kwargs["url"]) + return ({"url": kwargs["url"]}, None) + + async def execute(request): + if "after=cur1" in request["url"]: + return (_mock_response({}), json.dumps(page2), None) + return (_mock_response(page1_headers), json.dumps(page1), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + items = _run(get_json_paginated(client, "/api/v1/items", page_size=2)) + + assert items == [{"id": "a"}, {"id": "b"}, {"id": "c"}] + assert len(seen_urls) == 2 + assert "limit=2" in seen_urls[0] + # The cursor was carried into the second request. + assert "after=cur1" in seen_urls[1] + assert "limit=2" in seen_urls[1] + + def test_single_page_terminates_immediately(self): + client = mock.MagicMock() + + async def create(**kwargs): + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), json.dumps([{"id": "only"}]), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json_paginated(client, "/api/v1/items")) == [{"id": "only"}] + + def test_returns_none_when_response_is_not_a_list(self): + client = mock.MagicMock() + + async def create(**kwargs): + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), json.dumps({"error": "nope"}), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json_paginated(client, "/api/v1/items")) is None + + def test_preserves_existing_query_string_and_overrides_limit(self): + # Caller already passes `type=USER_LIFECYCLE` — pagination must + # merge `limit` without clobbering existing params. + client = mock.MagicMock() + seen = [] + + async def create(**kwargs): + seen.append(kwargs["url"]) + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), "[]", None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + _run( + get_json_paginated( + client, "/api/v1/policies?type=USER_LIFECYCLE", page_size=50 + ) + ) + + assert "type=USER_LIFECYCLE" in seen[0] + assert "limit=50" in seen[0] diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py new file mode 100644 index 0000000000..5c3d0e43c3 --- /dev/null +++ b/tests/providers/okta/okta_fixtures.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock + +from prowler.providers.okta.models import OktaIdentityInfo, OktaSession + +OKTA_ORG_DOMAIN = "acme.okta.com" +OKTA_CLIENT_ID = "0oa1234567890abcdef" +OKTA_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----" + + +def set_mocked_okta_provider( + session: OktaSession = None, + identity: OktaIdentityInfo = None, + audit_config: dict = None, +): + if session is None: + session = OktaSession( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + scopes=[ + "okta.policies.read", + "okta.brands.read", + "okta.apps.read", + "okta.authenticators.read", + "okta.networkZones.read", + "okta.apiTokens.read", + "okta.roles.read", + "okta.groups.read", + "okta.logStreams.read", + "okta.idps.read", + ], + private_key=OKTA_PRIVATE_KEY, + ) + if identity is None: + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=[ + "okta.policies.read", + "okta.brands.read", + "okta.apps.read", + "okta.authenticators.read", + "okta.networkZones.read", + "okta.apiTokens.read", + "okta.roles.read", + "okta.groups.read", + "okta.logStreams.read", + "okta.idps.read", + ], + ) + + provider = MagicMock() + provider.type = "okta" + provider.auth_method = "OAuth 2.0 (private-key JWT)" + provider.session = session + provider.identity = identity + provider.audit_config = audit_config or {} + return provider diff --git a/tests/providers/okta/okta_provider_test.py b/tests/providers/okta/okta_provider_test.py new file mode 100644 index 0000000000..5f2757edae --- /dev/null +++ b/tests/providers/okta/okta_provider_test.py @@ -0,0 +1,594 @@ +import base64 +import json +from unittest import mock + +import pytest + +from prowler.providers.okta.exceptions.exceptions import ( + OktaEnvironmentVariableError, + OktaInsufficientPermissionsError, + OktaInvalidCredentialsError, + OktaInvalidOrgDomainError, + OktaInvalidProviderIdError, + OktaPrivateKeyFileError, + OktaSetUpIdentityError, +) +from prowler.providers.okta.models import OktaIdentityInfo, OktaSession +from prowler.providers.okta.okta_provider import DEFAULT_SCOPES, OktaProvider +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + OKTA_PRIVATE_KEY, +) + + +def _make_jwt(payload: dict) -> str: + """Build an unsigned JWT carrying the given payload dict. + + The signature segment is irrelevant — `_decode_token_scopes` reads + the payload without verification. + """ + + def _b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + header = _b64u(json.dumps({"alg": "none"}).encode()) + body = _b64u(json.dumps(payload).encode()) + return f"{header}.{body}.sig" + + +class Test_OktaProvider_decode_token_scopes: + def test_returns_scopes_from_list_scp_claim(self): + token = _make_jwt({"scp": ["okta.policies.read", "okta.brands.read"]}) + assert OktaProvider._decode_token_scopes(token) == [ + "okta.policies.read", + "okta.brands.read", + ] + + def test_returns_scopes_from_space_separated_scp_string(self): + token = _make_jwt({"scp": "okta.policies.read okta.brands.read"}) + assert OktaProvider._decode_token_scopes(token) == [ + "okta.policies.read", + "okta.brands.read", + ] + + def test_returns_empty_list_when_token_is_none(self): + assert OktaProvider._decode_token_scopes(None) == [] + + def test_returns_empty_list_when_token_is_empty_string(self): + assert OktaProvider._decode_token_scopes("") == [] + + def test_returns_empty_list_when_scp_claim_missing(self): + token = _make_jwt({"sub": "client-id"}) + assert OktaProvider._decode_token_scopes(token) == [] + + def test_returns_empty_list_when_token_is_malformed(self): + assert OktaProvider._decode_token_scopes("not.a.jwt-with-bad-base64!!") == [] + + def test_returns_empty_list_when_payload_is_not_json(self): + bad = base64.urlsafe_b64encode(b"not json").rstrip(b"=").decode() + assert OktaProvider._decode_token_scopes(f"hdr.{bad}.sig") == [] + + +@pytest.fixture +def _clear_okta_env(monkeypatch): + for var in ( + "OKTA_ORG_DOMAIN", + "OKTA_CLIENT_ID", + "OKTA_PRIVATE_KEY", + "OKTA_PRIVATE_KEY_FILE", + "OKTA_SCOPES", + ): + monkeypatch.delenv(var, raising=False) + + +class Test_OktaProvider_validate_arguments: + def test_missing_all_three_raises_combined(self, _clear_okta_env): + with pytest.raises(OktaEnvironmentVariableError) as exc: + OktaProvider.validate_arguments() + msg = str(exc.value) + assert "OKTA_ORG_DOMAIN" in msg + assert "OKTA_CLIENT_ID" in msg + assert "OKTA_PRIVATE_KEY" in msg + + def test_only_org_domain_missing(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + with pytest.raises(OktaEnvironmentVariableError) as exc: + OktaProvider.validate_arguments( + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file=str(key_file), + ) + assert "OKTA_ORG_DOMAIN" in str(exc.value) + + def test_accepts_private_key_content_in_place_of_file(self, _clear_okta_env): + OktaProvider.validate_arguments( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key=OKTA_PRIVATE_KEY, + ) + + def test_all_present_via_args(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + OktaProvider.validate_arguments( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file=str(key_file), + ) + + def test_all_present_via_env(self, monkeypatch, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + monkeypatch.setenv("OKTA_ORG_DOMAIN", OKTA_ORG_DOMAIN) + monkeypatch.setenv("OKTA_CLIENT_ID", OKTA_CLIENT_ID) + monkeypatch.setenv("OKTA_PRIVATE_KEY_FILE", str(key_file)) + OktaProvider.validate_arguments() + + +class Test_OktaProvider_setup_session: + def test_rejects_domain_with_scheme(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + with pytest.raises(OktaInvalidOrgDomainError): + OktaProvider.setup_session( + org_domain="https://acme.okta.com", + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + ) + + def test_rejects_domain_with_trailing_slash(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + with pytest.raises(OktaInvalidOrgDomainError): + OktaProvider.setup_session( + org_domain="acme.okta.com/", + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + ) + + def test_rejects_non_okta_tld(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + with pytest.raises(OktaInvalidOrgDomainError): + OktaProvider.setup_session( + org_domain="login.example.com", + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + ) + + def test_accepts_all_okta_managed_tlds(self, _clear_okta_env, tmp_path): + # Mirrors the domain whitelist used by the Okta SDK + # (okta.config.config_validator) so that gov/mil tenants — exactly the + # audience most likely to care about the DISA STIG check — are not + # turned away at provider init. + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + for domain in ( + "acme.oktapreview.com", + "acme.okta-emea.com", + "acme.okta-gov.com", + "acme.okta.mil", + "acme.okta-miltest.com", + "acme.trex-govcloud.com", + ): + session = OktaProvider.setup_session( + org_domain=domain, + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + ) + assert session.org_domain == domain + + def test_unreadable_private_key_file_raises(self, _clear_okta_env): + with pytest.raises(OktaPrivateKeyFileError): + OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key_file="/nonexistent/path.pem", + ) + + def test_happy_path_uses_default_scopes(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + session = OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + ) + assert session.org_domain == OKTA_ORG_DOMAIN + assert session.client_id == OKTA_CLIENT_ID + assert session.private_key == OKTA_PRIVATE_KEY + assert session.scopes == DEFAULT_SCOPES + + def test_custom_scopes_parsed_from_csv(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + session = OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + scopes="okta.policies.read, okta.apps.read ,okta.users.read", + ) + assert session.scopes == [ + "okta.policies.read", + "okta.apps.read", + "okta.users.read", + ] + + def test_custom_scopes_accepts_list_input(self, _clear_okta_env, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + session = OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + scopes=["okta.policies.read", "okta.apps.read", "okta.users.read"], + ) + assert session.scopes == [ + "okta.policies.read", + "okta.apps.read", + "okta.users.read", + ] + + def test_custom_scopes_flattens_mixed_list_and_csv(self, _clear_okta_env, tmp_path): + # Mirrors how argparse nargs="+" delivers values when a user + # passes "--okta-scopes a,b c" — a list whose first element still + # contains a comma. + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + session = OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + scopes=["okta.policies.read,okta.apps.read", "okta.users.read"], + ) + assert session.scopes == [ + "okta.policies.read", + "okta.apps.read", + "okta.users.read", + ] + + def test_org_domain_normalized_lowercase_and_trimmed( + self, _clear_okta_env, tmp_path + ): + # The provider lowercases and strips whitespace so that + # " ACME.okta.com " is accepted as "acme.okta.com". + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + session = OktaProvider.setup_session( + org_domain=" ACME.okta.com ", + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + ) + assert session.org_domain == OKTA_ORG_DOMAIN + + def test_accepts_private_key_via_content_arg(self, _clear_okta_env): + session = OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key=OKTA_PRIVATE_KEY, + ) + assert session.private_key == OKTA_PRIVATE_KEY + + def test_accepts_private_key_via_env_var(self, monkeypatch): + monkeypatch.setenv("OKTA_PRIVATE_KEY", OKTA_PRIVATE_KEY) + monkeypatch.delenv("OKTA_PRIVATE_KEY_FILE", raising=False) + session = OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + ) + assert session.private_key == OKTA_PRIVATE_KEY + + def test_content_takes_precedence_over_file(self, _clear_okta_env, tmp_path): + # File has stale content; explicit content arg should win. + key_file = tmp_path / "stale.pem" + key_file.write_text("STALE CONTENT FROM FILE") + fresh_key = "-----BEGIN PRIVATE KEY-----\nFRESH\n-----END PRIVATE KEY-----" + session = OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key=fresh_key, + private_key_file=str(key_file), + ) + assert session.private_key == fresh_key + + +class Test_OktaProvider_setup_identity: + def _session(self, tmp_path): + key_file = tmp_path / "key.pem" + key_file.write_text(OKTA_PRIVATE_KEY) + return OktaProvider.setup_session( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + private_key_file=str(key_file), + ) + + def test_synthesizes_identity_and_probes_successfully( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def fake_list_policies(*_a, **_k): + return ([], mock.MagicMock(headers={}), None) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked_client_cls.return_value = mocked + identity = OktaProvider.setup_identity(session) + + assert identity.org_domain == OKTA_ORG_DOMAIN + assert identity.client_id == OKTA_CLIENT_ID + + def test_populates_granted_scopes_from_access_token_scp_claim( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def fake_list_policies(*_a, **_k): + return ([], mock.MagicMock(headers={}), None) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked._request_executor._oauth._access_token = _make_jwt( + {"scp": ["okta.policies.read", "okta.brands.read"]} + ) + mocked_client_cls.return_value = mocked + identity = OktaProvider.setup_identity(session) + + assert identity.granted_scopes == [ + "okta.policies.read", + "okta.brands.read", + ] + + def test_granted_scopes_empty_when_token_unavailable( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def fake_list_policies(*_a, **_k): + return ([], mock.MagicMock(headers={}), None) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked._request_executor._oauth._access_token = None + mocked_client_cls.return_value = mocked + identity = OktaProvider.setup_identity(session) + + assert identity.granted_scopes == [] + + def test_raises_invalid_credentials_when_probe_returns_error( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def failing_list_policies(*_a, **_k): + return ([], None, Exception("E0000011: Invalid token")) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing_list_policies + mocked_client_cls.return_value = mocked + with pytest.raises(OktaInvalidCredentialsError): + OktaProvider.setup_identity(session) + + def test_raises_insufficient_permissions_on_scope_error( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def failing_list_policies(*_a, **_k): + return ([], None, Exception("invalid_scope: policies.read missing")) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing_list_policies + mocked_client_cls.return_value = mocked + with pytest.raises(OktaInsufficientPermissionsError): + OktaProvider.setup_identity(session) + + def test_raises_insufficient_permissions_on_forbidden( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def failing_list_policies(*_a, **_k): + return ([], None, Exception("403 Forbidden")) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing_list_policies + mocked_client_cls.return_value = mocked + with pytest.raises(OktaInsufficientPermissionsError): + OktaProvider.setup_identity(session) + + def test_raises_insufficient_permissions_on_consent_required( + self, _clear_okta_env, tmp_path + ): + # When zero requested scopes are consented on the service app, Okta + # rejects the token request with HTTP 400 `consent_required` rather + # than `invalid_scope` — must still be classified as a permission + # gap so the user is pointed at the Okta API Scopes tab, not at + # credential troubleshooting. + session = self._session(tmp_path) + + async def failing_list_policies(*_a, **_k): + return ( + [], + None, + Exception( + "Okta HTTP 400 consent_required You are not allowed any " + "of the requested scopes." + ), + ) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing_list_policies + mocked_client_cls.return_value = mocked + with pytest.raises(OktaInsufficientPermissionsError): + OktaProvider.setup_identity(session) + + def test_wraps_unexpected_errors_in_setup_identity_error( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def boom(*_a, **_k): + raise RuntimeError("network down") + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = boom + mocked_client_cls.return_value = mocked + with pytest.raises(OktaSetUpIdentityError): + OktaProvider.setup_identity(session) + + +def _mock_setup_paths(): + """Patches that bypass the real SDK during provider construction.""" + session = OktaSession( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + scopes=list(DEFAULT_SCOPES), + private_key=OKTA_PRIVATE_KEY, + ) + identity = OktaIdentityInfo(org_domain=OKTA_ORG_DOMAIN, client_id=OKTA_CLIENT_ID) + return ( + mock.patch.object(OktaProvider, "validate_arguments"), + mock.patch.object(OktaProvider, "setup_session", return_value=session), + mock.patch.object(OktaProvider, "setup_identity", return_value=identity), + ) + + +class Test_OktaProvider_init: + def test_init_end_to_end(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + ) + + assert provider.type == "okta" + assert provider.auth_method == "OAuth 2.0 (private-key JWT)" + assert provider.identity.org_domain == OKTA_ORG_DOMAIN + assert provider.identity.client_id == OKTA_CLIENT_ID + assert provider.session.scopes == DEFAULT_SCOPES + assert provider.audit_config is not None + assert provider.mutelist is not None + + +class Test_OktaProvider_test_connection: + def test_success(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + ) + assert connection.is_connected is True + assert connection.error is None + + def test_returns_error_when_raise_disabled(self, _clear_okta_env): + connection = OktaProvider.test_connection(raise_on_exception=False) + assert connection.is_connected is False + assert connection.error is not None + + def test_raises_when_raise_enabled(self, _clear_okta_env): + with pytest.raises(OktaEnvironmentVariableError): + OktaProvider.test_connection() + + def test_provider_id_match_succeeds(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id=OKTA_ORG_DOMAIN, + ) + assert connection.is_connected is True + assert connection.error is None + + def test_provider_id_match_is_case_insensitive(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id=OKTA_ORG_DOMAIN.upper(), + ) + assert connection.is_connected is True + + def test_provider_id_mismatch_raises(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + with pytest.raises(OktaInvalidProviderIdError): + OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id="other.okta.com", + ) + + def test_provider_id_mismatch_returns_error_when_raise_disabled( + self, _clear_okta_env, tmp_path + ): + validate_p, session_p, identity_p = _mock_setup_paths() + with validate_p, session_p, identity_p: + connection = OktaProvider.test_connection( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + provider_id="other.okta.com", + raise_on_exception=False, + ) + assert connection.is_connected is False + assert isinstance(connection.error, OktaInvalidProviderIdError) + + +class Test_OktaProvider_print_credentials: + def test_invokes_print_boxes_with_org_and_client(self, _clear_okta_env, tmp_path): + validate_p, session_p, identity_p = _mock_setup_paths() + with ( + validate_p, + session_p, + identity_p, + mock.patch( + "prowler.providers.okta.okta_provider.print_boxes" + ) as mock_print, + ): + provider = OktaProvider( + okta_org_domain=OKTA_ORG_DOMAIN, + okta_client_id=OKTA_CLIENT_ID, + okta_private_key_file="/tmp/key.pem", + ) + provider.print_credentials() + + mock_print.assert_called_once() + rendered = " ".join(mock_print.call_args.args[0]) + assert OKTA_ORG_DOMAIN in rendered + assert OKTA_CLIENT_ID in rendered + assert "OAuth 2.0" in rendered diff --git a/tests/providers/okta/services/api_token/api_token_fixtures.py b/tests/providers/okta/services/api_token/api_token_fixtures.py new file mode 100644 index 0000000000..63a67c1605 --- /dev/null +++ b/tests/providers/okta/services/api_token/api_token_fixtures.py @@ -0,0 +1,46 @@ +from unittest import mock + +from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_api_token_client( + tokens: dict = None, + known_network_zone_ids: set[str] = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.api_tokens = tokens or {} + client.known_network_zone_ids = known_network_zone_ids or {"nzo-corp"} + client.missing_scope = missing_scope or { + "api_tokens": None, + "network_zones": None, + "user_roles": None, + "user_groups": None, + } + client.provider = set_mocked_okta_provider() + return client + + +def api_token( + token_id: str = "00Tabcdefg1234567890", + name: str = "CI token", + *, + user_id: str = "00uabcdefg1234567890", + network_connection: str = "ZONE", + network_includes: list[str] = None, + network_excludes: list[str] = None, + owner_roles: list[str] = None, +): + return OktaApiToken( + id=token_id, + name=name, + client_name="Okta API", + user_id=user_id, + network_connection=network_connection, + network_includes=( + network_includes if network_includes is not None else ["nzo-corp"] + ), + network_excludes=network_excludes or [], + owner_roles=owner_roles or ["READ_ONLY_ADMIN"], + ) diff --git a/tests/providers/okta/services/api_token/api_token_service_test.py b/tests/providers/okta/services/api_token/api_token_service_test.py new file mode 100644 index 0000000000..0958bdaaad --- /dev/null +++ b/tests/providers/okta/services/api_token/api_token_service_test.py @@ -0,0 +1,616 @@ +import json +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.apitoken.api_token_service import ApiToken +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_token( + token_id: str = "00Tabcdefg1234567890", + name: str = "CI token", + *, + user_id: str = "00uabcdefg1234567890", + connection: str = "ZONE", + include: list[str] = None, + exclude: list[str] = None, +): + return SimpleNamespace( + id=token_id, + name=name, + client_name="Okta API", + user_id=user_id, + network=SimpleNamespace( + connection=connection, + include=include if include is not None else ["nzo-corp"], + exclude=exclude or [], + ), + ) + + +def _sdk_role(role_type: str): + return SimpleNamespace(type=role_type, label=role_type.replace("_", " ").title()) + + +def _sdk_role_wrapped(role_type: str): + """Mimic `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper + holding the real StandardRole on `.actual_instance`. The Okta SDK + actually returns this shape; treating it like the bare role yields + `type=None, label=None` and the role silently vanishes from the + check. + """ + inner = _sdk_role(role_type) + return SimpleNamespace(actual_instance=inner, type=None, label=None) + + +def _sdk_zone(zone_id: str, name: str): + return SimpleNamespace(id=zone_id, name=name) + + +def _sdk_group(group_id: str): + return SimpleNamespace(id=group_id) + + +async def _empty_list(*_a, **_k): + return ([], _resp({}), None) + + +class Test_ApiToken_service: + def test_fetches_tokens_roles_and_known_network_zones(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(user_id, *_a, **_k): + assert user_id == token.user_id + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + + service = ApiToken(provider) + + assert set(service.api_tokens.keys()) == {token.id} + assert service.api_tokens[token.id].network_connection == "ZONE" + assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"] + assert service.known_network_zone_ids == {"nzo-corp", "Corporate"} + + def test_role_fetch_error_keeps_token_with_empty_roles(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_roles_error(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + async def fake_list_network_zones(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_roles_error + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [] + + def test_falls_back_to_raw_roles_when_sdk_role_is_empty(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(user_id, *_a, **_k): + assert user_id == token.user_id + return ([SimpleNamespace(type=None, label=None)], _resp({}), None) + + async def fake_create_request(*_a, **_k): + return ("raw-role-request", None) + + async def fake_execute(request, *_a, **_k): + assert request == "raw-role-request" + return ( + _resp({}), + json.dumps( + [ + { + "id": "ra-super-admin", + "type": "SUPER_ADMIN", + "label": "Super Administrator", + } + ] + ), + None, + ) + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked._list_assigned_roles_for_user_serialize.return_value = ( + "GET", + "/api/v1/users/00uabcdefg1234567890/roles", + {}, + None, + None, + ) + mocked._request_executor.create_request = fake_create_request + mocked._request_executor.execute = fake_execute + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] + + def test_paginates_known_network_zones_for_token_validation(self): + provider = set_mocked_okta_provider() + token = _sdk_token(include=["nzo-page-2"]) + next_link = '; rel="next"' + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **kwargs): + if kwargs.get("after") is None: + return ( + [_sdk_zone("nzo-page-1", "First")], + _resp({"link": next_link}), + None, + ) + return ([_sdk_zone("nzo-page-2", "Second")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.known_network_zone_ids == { + "nzo-page-1", + "First", + "nzo-page-2", + "Second", + } + + def test_falls_back_to_raw_network_zones_when_sdk_listing_fails(self): + provider = set_mocked_okta_provider() + token = _sdk_token(include=["nzo-raw"]) + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **_k): + raise ValueError("EnhancedDynamicNetworkZone SDK deserialization failed") + + async def fake_create_request(*_a, **_k): + return ("raw-zones-request", None) + + async def fake_execute(request, *_a, **_k): + assert request == "raw-zones-request" + return ( + _resp({}), + json.dumps([{"id": "nzo-raw", "name": "Raw Corporate"}]), + None, + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_network_zones = fake_list_network_zones + mocked._list_network_zones_serialize.return_value = ( + "GET", + "/api/v1/zones", + {}, + None, + None, + ) + mocked._request_executor.create_request = fake_create_request + mocked._request_executor.execute = fake_execute + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.known_network_zone_ids == {"nzo-raw", "Raw Corporate"} + + def test_returns_empty_on_token_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + async def fake_list_network_zones(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = failing + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens == {} + + def test_missing_api_token_scope_skips_dependent_api_calls(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.networkZones.read", "okta.roles.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("API calls should not run without apiTokens scope") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fail_if_called + mocked.list_network_zones = fail_if_called + mocked.list_assigned_roles_for_user = fail_if_called + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["api_tokens"] == "okta.apiTokens.read" + assert service.api_tokens == {} + assert service.known_network_zone_ids == set() + + def test_missing_network_zone_scope_skips_zone_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apiTokens.read", "okta.roles.read"], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_network_zones should not be called") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_network_zones = fail_if_called + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["network_zones"] == "okta.networkZones.read" + assert service.known_network_zone_ids == set() + assert set(service.api_tokens.keys()) == {token.id} + + def test_missing_role_scope_skips_role_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apiTokens.read", "okta.networkZones.read"], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_assigned_roles_for_user should not be called") + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fail_if_called + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["user_roles"] == "okta.roles.read" + assert service.api_tokens[token.id].owner_roles == [] + + +class Test_ApiToken_service_group_inherited_roles: + """Verifies effective-role resolution combines direct + group-inherited. + + Okta's `/api/v1/users/{userId}/roles` returns only directly-assigned + admin roles. Roles inherited via group membership — the common path + for Super Admin on trial tenants — are invisible to that endpoint. + The service must enumerate the user's groups and combine each + group's role assignments. + """ + + def test_group_inherited_super_admin_surfaces(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([], _resp({}), None) + + async def fake_user_groups(user_id, *_a, **_k): + assert user_id == token.user_id + return ( + [_sdk_group("0gp-admins"), _sdk_group("0gp-eng")], + _resp({}), + None, + ) + + async def fake_group_roles(group_id, *_a, **_k): + if group_id == "0gp-admins": + return ([_sdk_role("SUPER_ADMIN")], _resp({}), None) + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] + + def test_direct_plus_group_roles_combined_and_deduped(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ([_sdk_group("0gp-1")], _resp({}), None) + + async def fake_group_roles(*_a, **_k): + # READ_ONLY_ADMIN already comes from the direct path; the + # dedupe should keep a single entry. SUPER_ADMIN is new. + return ( + [_sdk_role("READ_ONLY_ADMIN"), _sdk_role("SUPER_ADMIN")], + _resp({}), + None, + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [ + "READ_ONLY_ADMIN", + "SUPER_ADMIN", + ] + + def test_role_resolution_cached_per_user_and_group(self): + provider = set_mocked_okta_provider() + token_a = _sdk_token(token_id="00Ttoken-a", user_id="00uowner-1") + token_b = _sdk_token(token_id="00Ttoken-b", user_id="00uowner-1") + token_c = _sdk_token(token_id="00Ttoken-c", user_id="00uowner-2") + + direct_calls: list[str] = [] + groups_calls: list[str] = [] + group_role_calls: list[str] = [] + + async def fake_list_api_tokens(*_a, **_k): + return ([token_a, token_b, token_c], _resp({}), None) + + async def fake_direct_roles(user_id, *_a, **_k): + direct_calls.append(user_id) + return ([], _resp({}), None) + + async def fake_user_groups(user_id, *_a, **_k): + groups_calls.append(user_id) + return ([_sdk_group("0gp-shared")], _resp({}), None) + + async def fake_group_roles(group_id, *_a, **_k): + group_role_calls.append(group_id) + return ([_sdk_role("HELP_DESK_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + # Owner 00uowner-1 appears twice but is resolved once. + assert sorted(direct_calls) == ["00uowner-1", "00uowner-2"] + assert sorted(groups_calls) == ["00uowner-1", "00uowner-2"] + # Shared group resolved once even though both owners belong to it. + assert group_role_calls == ["0gp-shared"] + for token in (token_a, token_b, token_c): + assert service.api_tokens[token.id].owner_roles == ["HELP_DESK_ADMIN"] + + def test_missing_groups_scope_falls_back_to_direct_only(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=[ + "okta.apiTokens.read", + "okta.networkZones.read", + "okta.roles.read", + ], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError( + "list_user_groups must not be called without okta.groups.read" + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fail_if_called + mocked.list_group_assigned_roles = fail_if_called + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["user_groups"] == "okta.groups.read" + assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"] + + def test_wrapped_oneof_role_shape_is_unwrapped(self): + """Regression: the SDK returns each role as a oneOf wrapper with + the real StandardRole on `.actual_instance`. The previous + `_role_to_string` read `.type`/`.label` from the wrapper, got + None back, and produced an empty `owner_roles` — causing a + Super Admin token to silently PASS the check.""" + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role_wrapped("SUPER_ADMIN")], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ([_sdk_group("0gp-extra")], _resp({}), None) + + async def fake_group_roles(*_a, **_k): + return ([_sdk_role_wrapped("APP_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [ + "SUPER_ADMIN", + "APP_ADMIN", + ] + + def test_group_role_fetch_failure_does_not_drop_other_groups(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ( + [_sdk_group("0gp-broken"), _sdk_group("0gp-good")], + _resp({}), + None, + ) + + async def fake_group_roles(group_id, *_a, **_k): + if group_id == "0gp-broken": + raise RuntimeError("upstream parse failure") + return ([_sdk_role("SUPER_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] diff --git a/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py new file mode 100644 index 0000000000..6177d4b422 --- /dev/null +++ b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.api_token.api_token_fixtures import ( + api_token, + build_api_token_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.apitoken." + "apitoken_not_super_admin.apitoken_not_super_admin.api_token_client" +) + + +def _run_check(api_token_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=api_token_client), + ): + from prowler.providers.okta.services.apitoken.apitoken_not_super_admin.apitoken_not_super_admin import ( + apitoken_not_super_admin, + ) + + return apitoken_not_super_admin().execute() + + +class Test_apitoken_not_super_admin: + def test_no_tokens_returns_no_findings(self): + findings = _run_check(build_api_token_client({})) + assert findings == [] + + def test_missing_api_token_scope_is_manual(self): + findings = _run_check( + build_api_token_client( + {}, + missing_scope={"api_tokens": "okta.apiTokens.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.apiTokens.read" in findings[0].status_extended + assert "okta.roles.read" in findings[0].status_extended + assert "okta.groups.read" in findings[0].status_extended + + def test_missing_user_roles_scope_is_manual(self): + token = api_token(owner_roles=[]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_roles": "okta.roles.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == token.id + assert "okta.roles.read" in findings[0].status_extended + + def test_token_owner_without_super_admin_passes(self): + token = api_token(owner_roles=["READ_ONLY_ADMIN"]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == token.id + + def test_token_owner_with_super_admin_fails(self): + token = api_token(owner_roles=["SUPER_ADMIN"]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Super Admin" in findings[0].status_extended + + def test_missing_groups_scope_adds_best_effort_caveat_on_pass(self): + token = api_token(owner_roles=["READ_ONLY_ADMIN"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_groups": "okta.groups.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Group-inherited roles were not checked" in findings[0].status_extended + assert "okta.groups.read" in findings[0].status_extended + + def test_missing_groups_scope_does_not_caveat_when_owner_is_super_admin(self): + token = api_token(owner_roles=["SUPER_ADMIN"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_groups": "okta.groups.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Super Admin" in findings[0].status_extended + assert "Group-inherited" not in findings[0].status_extended diff --git a/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py new file mode 100644 index 0000000000..9da6f92939 --- /dev/null +++ b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py @@ -0,0 +1,114 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.api_token.api_token_fixtures import ( + api_token, + build_api_token_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.apitoken." + "apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone.api_token_client" +) + + +def _run_check(api_token_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=api_token_client), + ): + from prowler.providers.okta.services.apitoken.apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone import ( + apitoken_restricted_to_network_zone, + ) + + return apitoken_restricted_to_network_zone().execute() + + +class Test_apitoken_restricted_to_network_zone: + def test_no_tokens_returns_no_findings(self): + findings = _run_check(build_api_token_client({})) + assert findings == [] + + def test_missing_api_token_scope_is_manual(self): + findings = _run_check( + build_api_token_client( + {}, + missing_scope={"api_tokens": "okta.apiTokens.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.apiTokens.read" in findings[0].status_extended + assert "okta.networkZones.read" in findings[0].status_extended + + def test_missing_network_zone_scope_is_manual(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-corp"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == token.id + assert "okta.networkZones.read" in findings[0].status_extended + + def test_missing_network_zone_scope_still_fails_anywhere_token(self): + token = api_token(network_connection="ANYWHERE", network_includes=[]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "from any IP" in findings[0].status_extended + + def test_token_restricted_to_known_network_zone_passes(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-corp"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-corp"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == token.id + + def test_token_with_only_excluded_network_zone_fails(self): + token = api_token( + network_connection="ZONE", + network_includes=[], + network_excludes=["nzo-blocked"], + ) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-blocked"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not allowlist" in findings[0].status_extended + + def test_token_open_to_anywhere_fails(self): + token = api_token(network_connection="ANYWHERE", network_includes=[]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "from any IP" in findings[0].status_extended + + def test_token_restricted_to_unknown_zone_fails(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-missing"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-corp"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "unknown Network Zone" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required_test.py b/tests/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required_test.py new file mode 100644 index 0000000000..e968c991a3 --- /dev/null +++ b/tests/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required_test.py @@ -0,0 +1,149 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + admin_console_app, + auth_policy_rule, + build_application_client, + catch_all_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_admin_console_mfa_required." + "application_admin_console_mfa_required.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_admin_console_mfa_required.application_admin_console_mfa_required import ( + application_admin_console_mfa_required, + ) + + return application_admin_console_mfa_required().execute() + + +class Test_application_admin_console_mfa_required: + def test_pass_when_top_rule_enforces_2fa(self): + app = admin_console_app( + rules=[ + auth_policy_rule(name="MFA Required", priority=1, factor_mode="2FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "MFA Required" in findings[0].status_extended + assert "factorMode=2FA" in findings[0].status_extended + + def test_fail_when_top_rule_is_1fa(self): + app = admin_console_app( + rules=[ + auth_policy_rule(name="Password Only", priority=1, factor_mode="1FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "Password Only" in findings[0].status_extended + assert "factorMode=1FA" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_enforces_2fa(self): + app = admin_console_app(rules=[catch_all_rule(priority=1, factor_mode="2FA")]) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_pass_when_top_active_rule_is_not_priority_one(self): + # Top active rule is whichever has the lowest priority value; the + # check does not pin to `priority == 1` specifically because Okta + # indexes Access Policy rule priorities inconsistently. Here the + # only non-default rule sits at priority=2 and is still the top. + app = admin_console_app( + rules=[ + auth_policy_rule(name="MFA Required", priority=2, factor_mode="2FA"), + catch_all_rule(priority=3), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "MFA Required" in findings[0].status_extended + assert "factorMode=2FA" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = admin_console_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_inactive_rule_skipped(self): + # An inactive custom rule must be skipped; the active Catch-all + # then becomes the top rule. The check evaluates the catch-all + # directly (no `factor_mode` set on it in the fixture) and FAILs. + app = admin_console_app( + rules=[ + auth_policy_rule( + name="MFA Required", + priority=1, + factor_mode="2FA", + status="INACTIVE", + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "Catch-all Rule" in findings[0].status_extended + assert "does not enforce multifactor authentication" in ( + findings[0].status_extended + ) diff --git a/tests/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication_test.py b/tests/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication_test.py new file mode 100644 index 0000000000..9ceac0697d --- /dev/null +++ b/tests/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication_test.py @@ -0,0 +1,129 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + admin_console_app, + auth_policy_rule, + build_application_client, + catch_all_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_admin_console_phishing_resistant_authentication." + "application_admin_console_phishing_resistant_authentication.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_admin_console_phishing_resistant_authentication.application_admin_console_phishing_resistant_authentication import ( + application_admin_console_phishing_resistant_authentication, + ) + + return application_admin_console_phishing_resistant_authentication().execute() + + +class Test_application_admin_console_phishing_resistant_authentication: + def test_pass_when_top_rule_requires_phishing_resistant(self): + app = admin_console_app( + rules=[ + auth_policy_rule( + name="Phishing Resistant", priority=1, phishing_resistant=True + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Phishing Resistant" in findings[0].status_extended + + def test_fail_when_top_rule_does_not_require(self): + app = admin_console_app( + rules=[ + auth_policy_rule( + name="Loose Rule", priority=1, phishing_resistant=False + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "does not enforce phishing-resistant" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_requires_phishing_resistant(self): + app = admin_console_app( + rules=[catch_all_rule(priority=1, phishing_resistant=True)] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_pass_when_top_active_rule_is_not_priority_one(self): + # The check does not pin to `priority == 1` — Okta indexes Access + # Policy rule priorities inconsistently. The top active rule is + # whichever has the lowest priority value among active rules. + app = admin_console_app( + rules=[ + auth_policy_rule( + name="Phishing Resistant", priority=2, phishing_resistant=True + ), + catch_all_rule(priority=3), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Phishing Resistant" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = admin_console_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min_test.py b/tests/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min_test.py new file mode 100644 index 0000000000..08943cd690 --- /dev/null +++ b/tests/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min_test.py @@ -0,0 +1,90 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + admin_console_settings, + build_application_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_admin_console_session_idle_timeout_15min." + "application_admin_console_session_idle_timeout_15min.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_admin_console_session_idle_timeout_15min.application_admin_console_session_idle_timeout_15min import ( + application_admin_console_session_idle_timeout_15min, + ) + + return application_admin_console_session_idle_timeout_15min().execute() + + +class Test_application_admin_console_session_idle_timeout_15min: + def test_pass_at_threshold(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=15) + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "15 minutes" in findings[0].status_extended + + def test_pass_below_threshold(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=10) + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "10 minutes" in findings[0].status_extended + + def test_fail_above_threshold(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=60) + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "exceeding the configured threshold" in findings[0].status_extended + + def test_fail_when_idle_timeout_missing(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=None) + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "does not define" in findings[0].status_extended + + def test_manual_when_settings_unavailable(self): + client = build_application_client(admin_console_settings=None) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "Could not retrieve" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": "okta.apps.read", + "built_in_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=30), + audit_config={"okta_admin_console_idle_timeout_max_minutes": 60}, + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "threshold of 60 minutes" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced_test.py b/tests/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced_test.py new file mode 100644 index 0000000000..92e75a6e17 --- /dev/null +++ b/tests/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced_test.py @@ -0,0 +1,192 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + auth_policy_rule, + build_application_client, + catch_all_rule, + integrated_app, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_authentication_policy_network_zone_enforced." + "application_authentication_policy_network_zone_enforced.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_authentication_policy_network_zone_enforced.application_authentication_policy_network_zone_enforced import ( + application_authentication_policy_network_zone_enforced, + ) + + return application_authentication_policy_network_zone_enforced().execute() + + +class Test_application_authentication_policy_network_zone_enforced: + def test_pass_when_all_active_nondefault_rules_are_zoned_and_catch_all_denies(self): + app = integrated_app( + "0oa-google", + "google_workspace", + label="Google Workspace", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + auth_policy_rule( + name="Block Risky", + priority=2, + network_connection="ZONE", + network_zones_exclude=["zone-risky"], + ), + catch_all_rule(priority=3, access="DENY"), + ], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Google Workspace" in findings[0].status_extended + assert "Catch-all Rule" in findings[0].status_extended + + def test_fail_when_nondefault_rule_has_no_network_zone(self): + app = integrated_app( + "0oa-salesforce", + "salesforce", + label="Salesforce", + rules=[ + auth_policy_rule(name="Allow Users", priority=1), + catch_all_rule(priority=2, access="DENY"), + ], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "without Network Zones" in findings[0].status_extended + assert "Allow Users" in findings[0].status_extended + + def test_fail_when_catch_all_rule_does_not_deny(self): + app = integrated_app( + "0oa-box", + "box", + label="Box", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + catch_all_rule(priority=2, access="ALLOW"), + ], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "`Access is` to `DENY`" in findings[0].status_extended + + def test_fail_when_no_active_nondefault_rules_exist(self): + app = integrated_app( + "0oa-slack", + "slack", + label="Slack", + rules=[catch_all_rule(priority=1, access="DENY")], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "no active non-default rules" in findings[0].status_extended + + def test_fail_when_no_access_policy_is_bound(self): + app = integrated_app( + "0oa-zoom", + "zoom", + label="Zoom", + rules=[], + access_policy_id=None, + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_inactive_apps_are_skipped(self): + inactive = integrated_app( + "0oa-inactive", + "dropbox", + label="Dropbox", + status="INACTIVE", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + catch_all_rule(priority=2, access="DENY"), + ], + ) + active = integrated_app( + "0oa-active", + "github", + label="GitHub", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + catch_all_rule(priority=2, access="DENY"), + ], + ) + findings = _run_check( + build_application_client( + integrated_apps={inactive.id: inactive, active.id: active} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "GitHub" + assert findings[0].status == "PASS" + + def test_manual_when_apps_scope_missing(self): + findings = _run_check( + build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": "okta.apps.read", + "access_policies": None, + } + ) + ) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + assert findings[0].resource_name == "Okta integrated applications" + assert findings[0].resource_id == "okta-integrated-apps-scope-missing" + + def test_manual_when_policy_scope_missing(self): + findings = _run_check( + build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + ) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert findings[0].resource_name == "Okta integrated applications" + assert findings[0].resource_id == "okta-integrated-apps-scope-missing" + + def test_manual_when_no_active_apps_returned(self): + findings = _run_check(build_application_client(integrated_apps={})) + assert findings[0].status == "MANUAL" + assert "No active Okta applications" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required_test.py b/tests/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required_test.py new file mode 100644 index 0000000000..2a07c82ea8 --- /dev/null +++ b/tests/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required_test.py @@ -0,0 +1,106 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + auth_policy_rule, + build_application_client, + catch_all_rule, + dashboard_app, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_dashboard_mfa_required." + "application_dashboard_mfa_required.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_dashboard_mfa_required.application_dashboard_mfa_required import ( + application_dashboard_mfa_required, + ) + + return application_dashboard_mfa_required().execute() + + +class Test_application_dashboard_mfa_required: + def test_pass_when_top_rule_enforces_2fa(self): + app = dashboard_app( + rules=[ + auth_policy_rule(name="MFA Required", priority=1, factor_mode="2FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "MFA Required" in findings[0].status_extended + + def test_fail_when_top_rule_is_1fa(self): + app = dashboard_app( + rules=[ + auth_policy_rule(name="Password Only", priority=1, factor_mode="1FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "Password Only" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_enforces_2fa(self): + app = dashboard_app(rules=[catch_all_rule(priority=1, factor_mode="2FA")]) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = dashboard_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication_test.py b/tests/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication_test.py new file mode 100644 index 0000000000..10b6a977e6 --- /dev/null +++ b/tests/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication_test.py @@ -0,0 +1,110 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + auth_policy_rule, + build_application_client, + catch_all_rule, + dashboard_app, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_dashboard_phishing_resistant_authentication." + "application_dashboard_phishing_resistant_authentication.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_dashboard_phishing_resistant_authentication.application_dashboard_phishing_resistant_authentication import ( + application_dashboard_phishing_resistant_authentication, + ) + + return application_dashboard_phishing_resistant_authentication().execute() + + +class Test_application_dashboard_phishing_resistant_authentication: + def test_pass_when_top_rule_requires_phishing_resistant(self): + app = dashboard_app( + rules=[ + auth_policy_rule( + name="Phishing Resistant", priority=1, phishing_resistant=True + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Phishing Resistant" in findings[0].status_extended + + def test_fail_when_top_rule_does_not_require(self): + app = dashboard_app( + rules=[ + auth_policy_rule( + name="Loose Rule", priority=1, phishing_resistant=False + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "does not enforce phishing-resistant" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_requires_phishing_resistant(self): + app = dashboard_app(rules=[catch_all_rule(priority=1, phishing_resistant=True)]) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = dashboard_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_fixtures.py b/tests/providers/okta/services/application/application_fixtures.py new file mode 100644 index 0000000000..4c157a1255 --- /dev/null +++ b/tests/providers/okta/services/application/application_fixtures.py @@ -0,0 +1,175 @@ +"""Shared helpers for `application` service check tests. + +Mirrors `signon_fixtures.py`. The four authentication-policy checks +(MFA + phishing-resistant for Okta Admin Console and Okta Dashboard) +and the Admin Console idle-timeout check all consume the same client +shape, so the helpers stay close to the signon equivalents. +""" + +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, + DASHBOARD_APP_NAME, + AdminConsoleAppSettings, + AuthenticationPolicy, + AuthenticationPolicyRule, + OktaBuiltInApp, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_application_client( + admin_console_settings: AdminConsoleAppSettings = None, + built_in_apps: dict = None, + integrated_apps: dict = None, + audit_config: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.admin_console_app_settings = admin_console_settings + client.built_in_apps = built_in_apps or {} + client.integrated_apps = integrated_apps or {} + client.provider = set_mocked_okta_provider() + client.audit_config = audit_config or {} + # Default to "all scopes granted" so existing tests keep working. + client.missing_scope = missing_scope or { + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": None, + } + return client + + +def admin_console_settings( + idle_timeout: int = None, + max_lifetime: int = None, +) -> AdminConsoleAppSettings: + return AdminConsoleAppSettings( + session_idle_timeout_minutes=idle_timeout, + session_max_lifetime_minutes=max_lifetime, + ) + + +def auth_policy_rule( + name: str = "Catch-all Rule", + *, + priority: int = 1, + status: str = "ACTIVE", + is_default: bool = False, + factor_mode: str = None, + phishing_resistant: bool = False, + constraints_count: int = 0, + verification_method_type: str = "ASSURANCE", + access: str = "ALLOW", + network_connection: str = None, + network_zones_include: list[str] = None, + network_zones_exclude: list[str] = None, +): + return AuthenticationPolicyRule( + id=f"rule-{name.lower().replace(' ', '-')}", + name=name, + priority=priority, + status=status, + is_default=is_default, + factor_mode=factor_mode, + possession_phishing_resistant_required=phishing_resistant, + constraints_count=constraints_count, + verification_method_type=verification_method_type, + access=access, + network_connection=network_connection, + network_zones_include=network_zones_include or [], + network_zones_exclude=network_zones_exclude or [], + ) + + +def catch_all_rule( + priority: int = 2, + *, + factor_mode: str = None, + phishing_resistant: bool = False, + access: str = "ALLOW", + network_connection: str = None, + network_zones_include: list[str] = None, + network_zones_exclude: list[str] = None, +): + return auth_policy_rule( + name="Catch-all Rule", + priority=priority, + is_default=True, + factor_mode=factor_mode, + phishing_resistant=phishing_resistant, + access=access, + network_connection=network_connection, + network_zones_include=network_zones_include, + network_zones_exclude=network_zones_exclude, + ) + + +def admin_console_app( + rules: list = None, + *, + access_policy_id: str = "rstadminconsole", + label: str = "Okta Admin Console", + status: str = "ACTIVE", +): + policy = ( + AuthenticationPolicy(id=access_policy_id, rules=rules or []) + if access_policy_id is not None + else None + ) + return OktaBuiltInApp( + id="0oaadminconsole", + name=ADMIN_CONSOLE_APP_NAME, + label=label, + status=status, + access_policy_id=access_policy_id, + access_policy=policy, + ) + + +def dashboard_app( + rules: list = None, + *, + access_policy_id: str = "rstdashboard", + label: str = "Okta Dashboard", + status: str = "ACTIVE", +): + policy = ( + AuthenticationPolicy(id=access_policy_id, rules=rules or []) + if access_policy_id is not None + else None + ) + return OktaBuiltInApp( + id="0oadashboard", + name=DASHBOARD_APP_NAME, + label=label, + status=status, + access_policy_id=access_policy_id, + access_policy=policy, + ) + + +def integrated_app( + app_id: str, + name: str, + *, + rules: list = None, + access_policy_id: str = "rstapp", + label: str = "", + status: str = "ACTIVE", +): + policy = ( + AuthenticationPolicy(id=access_policy_id, rules=rules or []) + if access_policy_id is not None + else None + ) + return OktaBuiltInApp( + id=app_id, + name=name, + label=label or name, + status=status, + access_policy_id=access_policy_id, + access_policy=policy, + ) diff --git a/tests/providers/okta/services/application/application_service_test.py b/tests/providers/okta/services/application/application_service_test.py new file mode 100644 index 0000000000..042882f285 --- /dev/null +++ b/tests/providers/okta/services/application/application_service_test.py @@ -0,0 +1,750 @@ +import json +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.application.application_service import ( + Application, + AuthenticationPolicy, + OktaBuiltInApp, + _policy_id_from_href, +) +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + set_mocked_okta_provider, +) + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_app( + app_id: str, + name: str, + *, + access_policy_href: str = None, + label: str = "", + status: str = "ACTIVE", +): + a = mock.MagicMock() + a.id = app_id + a.name = name + a.label = label + a.status = status + if access_policy_href is None: + a.links = None + else: + a.links.access_policy.href = access_policy_href + return a + + +def _fake_rule( + *, + rule_id: str = "rule-1", + name: str = "Catch-all Rule", + priority: int = 1, + status: str = "ACTIVE", + system: bool = False, + factor_mode: str = None, + phishing_resistant: str = None, + access: str = "ALLOW", + network_connection: str = None, + network_include: list[str] = None, + network_exclude: list[str] = None, +): + r = mock.MagicMock() + r.id = rule_id + r.name = name + r.priority = priority + r.status = status + r.system = system + r.actions.app_sign_on.verification_method.factor_mode = factor_mode + r.actions.app_sign_on.verification_method.type = "ASSURANCE" + r.actions.app_sign_on.access = access + r.conditions.network.connection = network_connection + r.conditions.network.include = network_include or [] + r.conditions.network.exclude = network_exclude or [] + if phishing_resistant is None: + r.actions.app_sign_on.verification_method.constraints = [] + else: + constraint = mock.MagicMock() + constraint.possession.phishing_resistant = phishing_resistant + r.actions.app_sign_on.verification_method.constraints = [constraint] + return r + + +def _fake_admin_console_settings(idle: int = 15, lifetime: int = 720): + s = mock.MagicMock() + s.session_idle_timeout_minutes = idle + s.session_max_lifetime_minutes = lifetime + return s + + +class Test_policy_id_from_href: + def test_returns_trailing_segment(self): + href = "https://acme.okta.com/api/v1/policies/rst123" + assert _policy_id_from_href(href) == "rst123" + + def test_strips_trailing_slash(self): + assert ( + _policy_id_from_href("https://acme.okta.com/api/v1/policies/rst123/") + == "rst123" + ) + + def test_handles_relative_path(self): + assert _policy_id_from_href("/api/v1/policies/rst-abc") == "rst-abc" + + def test_none_returns_none(self): + assert _policy_id_from_href(None) is None + + def test_empty_returns_none(self): + assert _policy_id_from_href("") is None + + +def _patch_sdk(**methods): + """Returns a context manager that patches OktaSDKClient with the given async methods.""" + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_Application_service: + def test_fetches_admin_console_settings_and_built_in_apps(self): + provider = set_mocked_okta_provider() + + admin_console_app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rstadminconsole", + label="Okta Admin Console", + ) + dashboard_app = _fake_app( + "0oadashboard", + "okta_enduser", + access_policy_href="https://acme.okta.com/api/v1/policies/rstdashboard", + label="Okta Dashboard", + ) + + async def fake_get_first_party(_app_name): + return ( + _fake_admin_console_settings(idle=15, lifetime=720), + _resp({}), + None, + ) + + async def fake_list_applications(*_a, **kwargs): + name_filter = kwargs.get("filter", "") + if "saasure" in name_filter: + return ([admin_console_app], _resp({}), None) + if "okta_enduser" in name_filter: + return ([dashboard_app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_list_policy_rules(_policy_id, **_k): + rule = _fake_rule(name="Top", priority=1, factor_mode="2FA") + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_get_first_party, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + + assert service.admin_console_app_settings.session_idle_timeout_minutes == 15 + assert service.admin_console_app_settings.session_max_lifetime_minutes == 720 + assert set(service.built_in_apps.keys()) == {"saasure", "okta_enduser"} + admin = service.built_in_apps["saasure"] + assert isinstance(admin, OktaBuiltInApp) + assert admin.access_policy_id == "rstadminconsole" + assert isinstance(admin.access_policy, AuthenticationPolicy) + assert admin.access_policy.rules[0].factor_mode == "2FA" + + def test_missing_admin_console_settings_endpoint_returns_none(self): + provider = set_mocked_okta_provider() + + async def failing_settings(_app_name): + return (None, _resp({}), Exception("404 Not Found")) + + async def fake_list_applications(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=failing_settings, + list_applications=fake_list_applications, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + assert service.admin_console_app_settings is None + assert service.built_in_apps == {} + + def test_built_in_app_without_access_policy_link(self): + provider = set_mocked_okta_provider() + admin_console_app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href=None, + label="Okta Admin Console", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_list_applications(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([admin_console_app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_list_policy_rules(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert admin.access_policy_id is None + assert admin.access_policy is None + + def test_paginates_list_applications_via_link_header(self): + provider = set_mocked_okta_provider() + page1 = _fake_app("0oa-page-1", "saasure") + page2 = _fake_app( + "0oa-page-2", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst1", + ) + next_link = '; rel="next"' + + calls = [] + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_list_applications(*_a, **kwargs): + name_filter = kwargs.get("filter", "") + if "saasure" in name_filter: + if kwargs.get("after") is None: + calls.append("page1") + return ([page1], _resp({"link": next_link}), None) + calls.append("page2") + return ([page2], _resp({}), None) + return ([], _resp({}), None) + + async def fake_list_policy_rules(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + + assert calls == ["page1", "page2"] + # Pagination returns both, but we only keep the first match per + # canonical name. Make sure that path doesn't break. + assert "saasure" in service.built_in_apps + + def test_returns_empty_on_apps_api_error(self): + provider = set_mocked_okta_provider() + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def failing_apps(*_a, **_k): + return ([], _resp({}), Exception("E0000007: scope not found")) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=failing_apps, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + assert service.built_in_apps == {} + + def test_skips_fetch_when_apps_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read"], + ) + provider = set_mocked_okta_provider(identity=identity) + + list_apps_called = False + get_settings_called = False + + async def fake_settings(_app_name): + nonlocal get_settings_called + get_settings_called = True + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **_k): + nonlocal list_apps_called + list_apps_called = True + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + assert list_apps_called is False + assert get_settings_called is False + assert service.built_in_apps == {} + assert service.admin_console_app_settings is None + assert service.missing_scope["admin_console_app_settings"] == "okta.apps.read" + assert service.missing_scope["built_in_apps"] == "okta.apps.read" + assert service.missing_scope["integrated_apps"] == "okta.apps.read" + assert service.missing_scope["access_policies"] is None + + def test_skips_policy_fetch_when_policies_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.apps.read"], + ) + provider = set_mocked_okta_provider(identity=identity) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + # When only one scope is missing, we still expose + # admin_console_app_settings (uses okta.apps.read which IS granted) + # but skip the joint built_in_apps+policies path. + assert service.admin_console_app_settings is not None + assert service.built_in_apps == {} + assert service.missing_scope["admin_console_app_settings"] is None + assert service.missing_scope["built_in_apps"] is None + assert service.missing_scope["integrated_apps"] is None + assert service.missing_scope["access_policies"] == "okta.policies.read" + + def test_unknown_granted_scopes_falls_back_to_attempting_fetch(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=[], + ) + provider = set_mocked_okta_provider(identity=identity) + + called = {"settings": False, "apps": False} + + async def fake_settings(_app_name): + called["settings"] = True + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **_k): + called["apps"] = True + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=mock.AsyncMock(), + ): + Application(provider) + + assert called["settings"] is True + assert called["apps"] is True + + def test_phishing_resistant_constraint_picked_up_from_rule(self): + provider = set_mocked_okta_provider() + app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-pr", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_rules(*_a, **_k): + rule = _fake_rule( + factor_mode="2FA", phishing_resistant="REQUIRED", priority=1 + ) + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert ( + admin.access_policy.rules[0].possession_phishing_resistant_required is True + ) + assert admin.access_policy.rules[0].factor_mode == "2FA" + + def test_network_zone_condition_picked_up_from_rule(self): + provider = set_mocked_okta_provider() + app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-net", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_rules(*_a, **_k): + rule = _fake_rule( + priority=1, + access="DENY", + network_connection="ZONE", + network_exclude=["zone-blocked"], + ) + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert admin.access_policy.rules[0].access == "DENY" + assert admin.access_policy.rules[0].network_connection == "ZONE" + assert admin.access_policy.rules[0].network_zones_exclude == ["zone-blocked"] + + def test_optional_phishing_resistant_not_treated_as_required(self): + provider = set_mocked_okta_provider() + app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-opt", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_rules(*_a, **_k): + rule = _fake_rule( + factor_mode="2FA", phishing_resistant="OPTIONAL", priority=1 + ) + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert ( + admin.access_policy.rules[0].possession_phishing_resistant_required is False + ) + + def test_lists_integrated_apps_on_demand(self): + provider = set_mocked_okta_provider() + built_in_admin = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-admin", + label="Okta Admin Console", + ) + custom_app = _fake_app( + "0oacustom", + "google_workspace", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-custom", + label="Google Workspace", + ) + next_link = '; rel="next"' + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_list_applications(*_a, **kwargs): + name_filter = kwargs.get("filter", "") + if name_filter: + if "saasure" in name_filter: + return ([built_in_admin], _resp({}), None) + if "okta_enduser" in name_filter: + return ([], _resp({}), None) + if kwargs.get("after") is None: + return ([built_in_admin], _resp({"link": next_link}), None) + return ([custom_app], _resp({}), None) + + async def fake_list_policy_rules(_policy_id, **_k): + return ( + [_fake_rule(priority=1, network_include=["zone-corp"])], + _resp({}), + None, + ) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + apps = service.integrated_apps + + assert set(apps.keys()) == {"0oaadminconsole", "0oacustom"} + assert apps["0oacustom"].label == "Google Workspace" + assert apps["0oacustom"].access_policy_id == "rst-custom" + + +class Test_Application_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback for the Okta SDK enum-validator bug. + + The Okta Management API returns values (e.g. lowercase `"password"` + in `KnowledgeConstraint.types`) that the SDK's pydantic field + validators reject as ValidationError. Without a fallback the entire + policy fetch crashes; with the fallback we evaluate the rules + correctly via raw JSON. + """ + + def _build_service_with_validation_error_then_raw_success( + self, raw_rules_payload, app_filter_match="saasure" + ): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + admin = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-admin", + label="Okta Admin Console", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if app_filter_match in (kwargs.get("filter") or ""): + return ([admin], _resp({}), None) + return ([], _resp({}), None) + + async def failing_list_policy_rules(*_a, **_k): + try: + # Trigger a real pydantic ValidationError so we exercise + # the exact exception type the SDK raises in production. + from okta.models.knowledge_constraint import KnowledgeConstraint + + KnowledgeConstraint(types=["password"]) + except ValidationError as ve: + raise ve + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/policies/rst-admin/rules"}, None) + + async def fake_raw_execute(_request): + return (None, json.dumps(raw_rules_payload), None) + + sdk_mock = mock.MagicMock() + sdk_mock.get_first_party_app_settings = fake_settings + sdk_mock.list_applications = fake_apps + sdk_mock.list_policy_rules = failing_list_policy_rules + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + from prowler.providers.okta.services.application.application_service import ( + Application as _Application, + ) + + return _Application(provider) + + def test_raw_fallback_projects_factor_mode_and_phishing_resistant(self): + rules_payload = [ + { + "id": "rul-1", + "name": "Top Rule", + "priority": 1, + "status": "ACTIVE", + "system": False, + "actions": { + "appSignOn": { + "access": "ALLOW", + "verificationMethod": { + "type": "ASSURANCE", + "factorMode": "2FA", + "constraints": [ + { + "knowledge": {"types": ["password"]}, + "possession": {"phishingResistant": "REQUIRED"}, + } + ], + }, + } + }, + "conditions": { + "network": { + "connection": "ZONE", + "include": ["nzo-corp"], + "exclude": [], + } + }, + } + ] + service = self._build_service_with_validation_error_then_raw_success( + rules_payload + ) + + admin = service.built_in_apps["saasure"] + assert admin.access_policy is not None + assert len(admin.access_policy.rules) == 1 + rule = admin.access_policy.rules[0] + assert rule.factor_mode == "2FA" + assert rule.possession_phishing_resistant_required is True + assert rule.network_connection == "ZONE" + assert rule.network_zones_include == ["nzo-corp"] + assert rule.is_default is False + assert rule.priority == 1 + + def test_raw_fallback_handles_empty_rules(self): + service = self._build_service_with_validation_error_then_raw_success([]) + admin = service.built_in_apps["saasure"] + assert admin.access_policy is not None + assert admin.access_policy.rules == [] + + +class Test_Application_service_per_app_isolation: + """One app's fetch failure must not erase the other app's findings.""" + + def test_dashboard_still_returned_when_admin_console_policy_fetch_fails(self): + provider = set_mocked_okta_provider() + admin = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-broken", + label="Okta Admin Console", + ) + dashboard = _fake_app( + "0oadashboard", + "okta_enduser", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-dash", + label="Okta Dashboard", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + f = kwargs.get("filter") or "" + if "saasure" in f: + return ([admin], _resp({}), None) + if "okta_enduser" in f: + return ([dashboard], _resp({}), None) + return ([], _resp({}), None) + + async def fake_policy_rules(policy_id, **_k): + if policy_id == "rst-broken": + raise RuntimeError("simulated unexpected SDK failure") + return ( + [ + _fake_rule( + name="Top", + priority=1, + factor_mode="2FA", + phishing_resistant="REQUIRED", + ) + ], + _resp({}), + None, + ) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_policy_rules, + ): + service = Application(provider) + + # Admin Console: app captured, access_policy set to None due to + # isolated failure during rule fetch. + admin_model = service.built_in_apps["saasure"] + assert admin_model.access_policy is None + # Dashboard: succeeded — its rule is fully resolved. + dashboard_model = service.built_in_apps["okta_enduser"] + assert dashboard_model.access_policy is not None + assert dashboard_model.access_policy.rules[0].factor_mode == "2FA" + + def test_integrated_apps_one_app_failure_does_not_drop_others(self): + provider = set_mocked_okta_provider() + good = _fake_app( + "0oa-good", + "custom_good", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-good", + label="Good App", + ) + bad = _fake_app( + "0oa-bad", + "custom_bad", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-bad", + label="Bad App", + ) + + async def fake_settings(_): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + f = kwargs.get("filter") or "" + if f: + return ([], _resp({}), None) + return ([good, bad], _resp({}), None) + + async def fake_policy_rules(policy_id, **_k): + if policy_id == "rst-bad": + raise RuntimeError("simulated failure") + return ( + [_fake_rule(name="Top", priority=1, factor_mode="1FA")], + _resp({}), + None, + ) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_policy_rules, + ): + service = Application(provider) + apps = service.integrated_apps + + assert set(apps.keys()) == {"0oa-good", "0oa-bad"} + assert apps["0oa-good"].access_policy is not None + assert apps["0oa-bad"].access_policy is None diff --git a/tests/providers/okta/services/authenticator/authenticator_fixtures.py b/tests/providers/okta/services/authenticator/authenticator_fixtures.py new file mode 100644 index 0000000000..f15603221b --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_fixtures.py @@ -0,0 +1,76 @@ +from unittest import mock + +from prowler.providers.okta.services.authenticator.authenticator_service import ( + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_authenticator_client( + password_policies: dict = None, + authenticators: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.password_policies = password_policies or {} + client.authenticators = authenticators or {} + client.missing_scope = missing_scope or { + "password_policies": None, + "authenticators": None, + } + client.provider = set_mocked_okta_provider() + return client + + +def password_policy( + policy_id: str = "pol-password", + name: str = "Default Password Policy", + *, + status: str = "ACTIVE", + priority: int = 1, + max_attempts: int = 3, + min_length: int = 15, + min_upper_case: int = 1, + min_lower_case: int = 1, + min_number: int = 1, + min_symbol: int = 1, + min_age_minutes: int = 1440, + max_age_days: int = 60, + history_count: int = 5, + common_password_check: bool = True, +): + return PasswordPolicy( + id=policy_id, + name=name, + status=status, + priority=priority, + max_attempts=max_attempts, + min_length=min_length, + min_upper_case=min_upper_case, + min_lower_case=min_lower_case, + min_number=min_number, + min_symbol=min_symbol, + min_age_minutes=min_age_minutes, + max_age_days=max_age_days, + history_count=history_count, + common_password_check=common_password_check, + ) + + +def authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + name: str = "Okta Verify", + *, + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return OktaAuthenticator( + id=auth_id, + key=key, + name=name, + status=status, + type="app", + fips=fips, + ) diff --git a/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py new file mode 100644 index 0000000000..467dc44611 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_okta_verify_fips_compliant." + "authenticator_okta_verify_fips_compliant.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_okta_verify_fips_compliant.authenticator_okta_verify_fips_compliant import ( + authenticator_okta_verify_fips_compliant, + ) + + return authenticator_okta_verify_fips_compliant().execute() + + +class Test_authenticator_okta_verify_fips_compliant: + def test_okta_verify_fips_required_passes(self): + okta_verify = authenticator(key="okta_verify", fips="REQUIRED") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == okta_verify.id + + def test_okta_verify_without_fips_required_fails(self): + okta_verify = authenticator(key="okta_verify", fips="OPTIONAL") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "FIPS" in findings[0].status_extended + + def test_missing_okta_verify_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Okta Verify authenticator is missing." in findings[0].status_extended + + def test_inactive_okta_verify_surfaces_current_status(self): + okta_verify = authenticator(key="okta_verify", status="INACTIVE") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py new file mode 100644 index 0000000000..c0e49deb8c --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py @@ -0,0 +1,60 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_common_password_check.authenticator_password_common_password_check.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_common_password_check.authenticator_password_common_password_check import ( + authenticator_password_common_password_check, + ) + + return authenticator_password_common_password_check().execute() + + +class Test_authenticator_password_common_password_check: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_missing_password_policies_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + {}, + missing_scope={"password_policies": "okta.policies.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(common_password_check=True) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(common_password_check=False) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py new file mode 100644 index 0000000000..8483f46552 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase import ( + authenticator_password_complexity_lowercase, + ) + + return authenticator_password_complexity_lowercase().execute() + + +class Test_authenticator_password_complexity_lowercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_lower_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_lower_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py new file mode 100644 index 0000000000..cac8f8b444 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_number.authenticator_password_complexity_number.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_number.authenticator_password_complexity_number import ( + authenticator_password_complexity_number, + ) + + return authenticator_password_complexity_number().execute() + + +class Test_authenticator_password_complexity_number: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_number=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_number=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py new file mode 100644 index 0000000000..93ed18a36d --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_symbol.authenticator_password_complexity_symbol.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_symbol.authenticator_password_complexity_symbol import ( + authenticator_password_complexity_symbol, + ) + + return authenticator_password_complexity_symbol().execute() + + +class Test_authenticator_password_complexity_symbol: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_symbol=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_symbol=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py new file mode 100644 index 0000000000..74ab626e52 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase import ( + authenticator_password_complexity_uppercase, + ) + + return authenticator_password_complexity_uppercase().execute() + + +class Test_authenticator_password_complexity_uppercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_upper_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_upper_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py new file mode 100644 index 0000000000..e6741c53c2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_history_5.authenticator_password_history_5.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_history_5.authenticator_password_history_5 import ( + authenticator_password_history_5, + ) + + return authenticator_password_history_5().execute() + + +class Test_authenticator_password_history_5: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(history_count=5) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(history_count=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py new file mode 100644 index 0000000000..35c7026e11 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3 import ( + authenticator_password_lockout_threshold_3, + ) + + return authenticator_password_lockout_threshold_3().execute() + + +class Test_authenticator_password_lockout_threshold_3: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_attempts=3) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_attempts=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py new file mode 100644 index 0000000000..938b29a290 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d import ( + authenticator_password_maximum_age_60d, + ) + + return authenticator_password_maximum_age_60d().execute() + + +class Test_authenticator_password_maximum_age_60d: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_age_days=60) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_age_days=61) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py new file mode 100644 index 0000000000..0fbe562330 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h import ( + authenticator_password_minimum_age_24h, + ) + + return authenticator_password_minimum_age_24h().execute() + + +class Test_authenticator_password_minimum_age_24h: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_age_minutes=1440) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_age_minutes=1439) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py new file mode 100644 index 0000000000..2608c42998 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py @@ -0,0 +1,62 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_length_15.authenticator_password_minimum_length_15.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_length_15.authenticator_password_minimum_length_15 import ( + authenticator_password_minimum_length_15, + ) + + return authenticator_password_minimum_length_15().execute() + + +class Test_authenticator_password_minimum_length_15: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_length=15) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_length=14) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id + + def test_multiple_active_policies_emit_one_finding_each(self): + compliant = password_policy(policy_id="pol-good", name="Strict", min_length=15) + weak = password_policy( + policy_id="pol-weak", name="Weak", min_length=8, priority=2 + ) + findings = _run_check( + build_authenticator_client({compliant.id: compliant, weak.id: weak}) + ) + assert len(findings) == 2 + by_name = {finding.resource_name: finding for finding in findings} + assert by_name["Strict"].status == "PASS" + assert by_name["Weak"].status == "FAIL" diff --git a/tests/providers/okta/services/authenticator/authenticator_service_test.py b/tests/providers/okta/services/authenticator/authenticator_service_test.py new file mode 100644 index 0000000000..d66af92ca2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_service_test.py @@ -0,0 +1,188 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_password_policy( + policy_id: str = "pol-password", name: str = "Default", common_exclude=True +): + return SimpleNamespace( + id=policy_id, + name=name, + priority=1, + status="ACTIVE", + system=True, + settings=SimpleNamespace( + password=SimpleNamespace( + lockout=SimpleNamespace(max_attempts=3), + complexity=SimpleNamespace( + min_length=15, + min_upper_case=1, + min_lower_case=1, + min_number=1, + min_symbol=1, + dictionary=SimpleNamespace( + common=SimpleNamespace(exclude=common_exclude) + ), + ), + age=SimpleNamespace( + min_age_minutes=1440, + max_age_days=60, + history_count=5, + ), + ) + ), + ) + + +def _sdk_authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return SimpleNamespace( + id=auth_id, + key=key, + name="Okta Verify" if key == "okta_verify" else "Smart Card IdP", + status=status, + type="app", + settings=SimpleNamespace(compliance=SimpleNamespace(fips=fips)), + ) + + +class Test_Authenticator_service: + def test_fetches_password_policies_and_authenticators(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy() + okta_verify = _sdk_authenticator() + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([okta_verify], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + + service = Authenticator(provider) + + assert isinstance(service.password_policies[policy.id], PasswordPolicy) + assert service.password_policies[policy.id].min_length == 15 + assert service.password_policies[policy.id].common_password_check is True + assert isinstance(service.authenticators[okta_verify.id], OktaAuthenticator) + assert service.authenticators[okta_verify.id].fips == "REQUIRED" + + def test_common_password_exclude_false_is_not_compliant(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy(common_exclude=False) + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies[policy.id].common_password_check is False + + def test_paginates_password_policies(self): + provider = set_mocked_okta_provider() + page_1 = _sdk_password_policy("pol-1", "First") + page_2 = _sdk_password_policy("pol-2", "Second") + quote = chr(34) + next_link = ( + "; " + f"rel={quote}next{quote}" + ) + calls = [] + + async def fake_list_policies(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page_1], _resp({"link": next_link}), None) + return ([page_2], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert calls == [None, "cursor-2"] + assert set(service.password_policies.keys()) == {"pol-1", "pol-2"} + + def test_missing_scopes_skip_dependent_api_calls(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apps.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("Authenticator API calls should not run") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fail_if_called + mocked.list_authenticators = fail_if_called + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.missing_scope["password_policies"] == "okta.policies.read" + assert service.missing_scope["authenticators"] == "okta.authenticators.read" + assert service.password_policies == {} + assert service.authenticators == {} + + def test_returns_empty_collections_on_api_errors(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing + mocked.list_authenticators = failing + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies == {} + assert service.authenticators == {} diff --git a/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py new file mode 100644 index 0000000000..c85527f0be --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_smart_card_active.authenticator_smart_card_active.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_smart_card_active.authenticator_smart_card_active import ( + authenticator_smart_card_active, + ) + + return authenticator_smart_card_active().execute() + + +class Test_authenticator_smart_card_active: + def test_smart_card_active_passes(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="ACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == smart_card.id + + def test_missing_smart_card_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not active" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended + + def test_inactive_smart_card_fails(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="INACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended diff --git a/tests/providers/okta/services/idp/idp_fixtures.py b/tests/providers/okta/services/idp/idp_fixtures.py new file mode 100644 index 0000000000..8ebc449717 --- /dev/null +++ b/tests/providers/okta/services/idp/idp_fixtures.py @@ -0,0 +1,44 @@ +"""Shared helpers for `idp` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_idp_client( + identity_providers: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.identity_providers = identity_providers or {} + client.provider = set_mocked_okta_provider() + client.audit_config = {} + client.missing_scope = missing_scope or {"identity_providers": None} + return client + + +def smart_card_idp( + idp_id: str = "0oa-x509", + name: str = "CAC IdP", + status: str = "ACTIVE", + issuer: str = "CN=DOD ROOT CA 6", + kid: str = "kid-abc-123", +): + return OktaIdentityProvider( + id=idp_id, + name=name, + type="X509", + status=status, + trust_issuer=issuer, + trust_kid=kid, + ) + + +def non_smart_card_idp( + idp_id: str = "0oa-saml", + name: str = "Corporate SAML", + type: str = "SAML2", + status: str = "ACTIVE", +): + return OktaIdentityProvider(id=idp_id, name=name, type=type, status=status) diff --git a/tests/providers/okta/services/idp/idp_service_test.py b/tests/providers/okta/services/idp/idp_service_test.py new file mode 100644 index 0000000000..3c30c0d3eb --- /dev/null +++ b/tests/providers/okta/services/idp/idp_service_test.py @@ -0,0 +1,80 @@ +import json +from unittest import mock + +from okta.models.identity_provider_protocol import IdentityProviderProtocol + +from prowler.providers.okta.services.idp.idp_service import Idp, OktaIdentityProvider +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_idp(idp_id, name, type_, status="ACTIVE", issuer=None, kid=None): + # Build a real `IdentityProviderProtocol` when issuer/kid are provided + # so the test exercises the SDK's Pydantic v2 oneOf wrapper — credentials + # live on `actual_instance`, not directly on the wrapper. MagicMock + # auto-attribute-creation would otherwise hide a missed unwrap. + idp = mock.MagicMock() + idp.id = idp_id + idp.name = name + idp.type = type_ + idp.status = status + if issuer is None and kid is None: + idp.protocol = None + else: + idp.protocol = IdentityProviderProtocol.from_json( + json.dumps( + { + "type": "MTLS", + "credentials": {"trust": {"issuer": issuer, "kid": kid}}, + } + ) + ) + return idp + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_Idp_service: + def test_fetches_idps_with_trust_fields(self): + provider = set_mocked_okta_provider() + x509 = _fake_idp( + "0oa1", + "CAC", + "X509", + issuer="CN=DOD ROOT CA 6", + kid="kid-1", + ) + saml = _fake_idp("0oa2", "Corp", "SAML2") + + async def fake_list(*_a, **_k): + return ([x509, saml], _resp({}), None) + + with _patch_sdk(list_identity_providers=fake_list): + service = Idp(provider) + + assert set(service.identity_providers.keys()) == {"0oa1", "0oa2"} + assert isinstance(service.identity_providers["0oa1"], OktaIdentityProvider) + assert service.identity_providers["0oa1"].trust_issuer == "CN=DOD ROOT CA 6" + assert service.identity_providers["0oa1"].trust_kid == "kid-1" + assert service.identity_providers["0oa2"].trust_issuer is None + + def test_returns_empty_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("API failure")) + + with _patch_sdk(list_identity_providers=failing): + service = Idp(provider) + + assert service.identity_providers == {} diff --git a/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py new file mode 100644 index 0000000000..f6517e14a9 --- /dev/null +++ b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py @@ -0,0 +1,125 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.idp.idp_fixtures import ( + build_idp_client, + non_smart_card_idp, + smart_card_idp, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.idp." + "idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca.idp_client" +) + +DOD_PKI_ISSUER = "CN=DoD ID CA-59, OU=PKI, OU=DoD, O=U.S. Government, C=US" +ECA_ISSUER = "CN=ECA Root CA 4, OU=ECA, O=U.S. Government, C=US" +NON_DOD_ISSUER = "CN=ACME Internal Root, O=Acme Corp, C=US" + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.idp.idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca import ( + idp_smart_card_dod_approved_ca, + ) + + return idp_smart_card_dod_approved_ca().execute() + + +class Test_idp_smart_card_dod_approved_ca: + def test_pass_when_active_idp_chain_matches_dod_pki_pattern(self): + idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "OU=DoD" in findings[0].status_extended + assert DOD_PKI_ISSUER in findings[0].status_extended + + def test_pass_when_active_idp_chain_matches_eca_pattern(self): + idp = smart_card_idp(name="ECA Partner", issuer=ECA_ISSUER, kid="kid-e") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "OU=ECA" in findings[0].status_extended + + def test_manual_when_active_but_issuer_does_not_match_any_pattern(self): + idp = smart_card_idp(name="Custom", issuer=NON_DOD_ISSUER, kid="kid-c") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert NON_DOD_ISSUER in findings[0].status_extended + assert "okta_dod_approved_ca_issuer_patterns" in findings[0].status_extended + + def test_pass_when_audit_config_pattern_matches(self): + idp = smart_card_idp(name="Custom DOD", issuer=NON_DOD_ISSUER, kid="kid-c") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"CN=ACME Internal Root"] + } + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_audit_config_overrides_bundled_defaults(self): + # When the operator supplies a list, the bundled DEFAULT patterns + # are replaced (not merged) so customers can carve out a strict set. + idp = smart_card_idp(name="DoD", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"CN=YourTenantCustomDodCA"] + } + findings = _run_check(client) + assert findings[0].status == "MANUAL" + + def test_malformed_audit_config_pattern_skipped(self): + # An invalid regex from the operator must not crash the whole check. + idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"[invalid(regex", r"OU=DoD"] + } + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_fail_when_x509_idp_is_inactive(self): + idp = smart_card_idp(status="INACTIVE", issuer=DOD_PKI_ISSUER) + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended + + def test_fail_when_no_smart_card_idp_configured(self): + client = build_idp_client(identity_providers={"saml": non_smart_card_idp()}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert ( + "No Smart Card (X509) Identity Providers are configured" + in findings[0].status_extended + ) + assert "mutelist" in findings[0].status_extended + + def test_manual_when_idps_scope_missing(self): + client = build_idp_client( + missing_scope={"identity_providers": "okta.idps.read"} + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.idps.read" in findings[0].status_extended + + def test_multiple_x509_idps_yield_one_finding_each(self): + idp_a = smart_card_idp(idp_id="0oa-a", name="A", issuer=DOD_PKI_ISSUER) + idp_b = smart_card_idp( + idp_id="0oa-b", name="B", status="INACTIVE", issuer=DOD_PKI_ISSUER + ) + client = build_idp_client(identity_providers={idp_a.id: idp_a, idp_b.id: idp_b}) + findings = _run_check(client) + assert len(findings) == 2 + # We don't strictly assert ordering — just that both are covered. + statuses = sorted(f.status for f in findings) + assert statuses == ["FAIL", "PASS"] diff --git a/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py new file mode 100644 index 0000000000..89cc2fd8ed --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.network_zone.network_zone_fixtures import ( + build_network_zone_client, + network_zone, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.network." + "network_zone_block_anonymized_proxies." + "network_zone_block_anonymized_proxies.network_zone_client" +) + + +def _run_check(network_zone_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=network_zone_client), + ): + from prowler.providers.okta.services.network.network_zone_block_anonymized_proxies.network_zone_block_anonymized_proxies import ( + network_zone_block_anonymized_proxies, + ) + + return network_zone_block_anonymized_proxies().execute() + + +class Test_network_zone_block_anonymized_proxies: + def test_no_zones_fails(self): + findings = _run_check(build_network_zone_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Network Zone blocklist" in findings[0].status_extended + + def test_missing_network_zone_scope_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.networkZones.read" in findings[0].status_extended + + def test_sdk_network_zone_retrieval_error_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + retrieval_error="Error listing Network Zones: forbidden", + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "could not be retrieved" in findings[0].status_extended + assert "forbidden" in findings[0].status_extended + + def test_raw_network_zone_retrieval_error_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + retrieval_error="Raw Network Zones fetch (execute) failed: timeout", + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "could not be retrieved" in findings[0].status_extended + assert "timeout" in findings[0].status_extended + + def test_active_ip_blocklist_gateway_is_manual(self): + zone = network_zone(gateways=["198.51.100.10/32"]) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == zone.id + assert "manual IP blocklist" in findings[0].status_extended + assert "cannot verify full anonymizer coverage" in findings[0].status_extended + + def test_pass_with_active_enhanced_dynamic_anonymizer_blocklist(self): + zone = network_zone( + zone_id="nzo-enhanced", + name="DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=["ANONYMIZER"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Enhanced Dynamic" in findings[0].status_extended + + def test_pass_with_custom_enhanced_dynamic_anonymizer_blocklist(self): + zone = network_zone( + zone_id="nzo-custom-enhanced", + name="Custom Anonymous VPN Blocklist", + zone_type="DYNAMIC_V2", + system=False, + ip_service_categories=["VPN"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Enhanced Dynamic" in findings[0].status_extended + + def test_dynamic_blocklist_without_anonymizer_category_fails(self): + zone = network_zone( + zone_id="nzo-dynamic", + name="Dynamic Blocklist Without Anonymizers", + zone_type="DYNAMIC", + ip_service_categories=["RISKY_IPS"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended + + def test_default_enhanced_dynamic_zone_without_categories_fails(self): + zone = network_zone( + zone_id="nzo-default-enhanced", + name="DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=[], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended + + def test_existing_zones_without_anonymized_proxy_blocklist_fail(self): + policy_zone = network_zone( + zone_id="nzo-policy", + name="Corporate Policy Zone", + usage="POLICY", + gateways=["10.0.0.0/8"], + ) + inactive_blocklist = network_zone( + zone_id="nzo-inactive", + name="Inactive Blocklist", + status="INACTIVE", + gateways=["203.0.113.0/24"], + ) + findings = _run_check( + build_network_zone_client( + {policy_zone.id: policy_zone, inactive_blocklist.id: inactive_blocklist} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended diff --git a/tests/providers/okta/services/network_zone/network_zone_fixtures.py b/tests/providers/okta/services/network_zone/network_zone_fixtures.py new file mode 100644 index 0000000000..09328a5d73 --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_fixtures.py @@ -0,0 +1,42 @@ +from unittest import mock + +from prowler.providers.okta.services.network.network_zone_service import OktaNetworkZone +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_network_zone_client( + zones: dict = None, + missing_scope: dict = None, + retrieval_error: str | None = None, +): + client = mock.MagicMock() + client.network_zones = zones or {} + client.missing_scope = missing_scope or {"network_zones": None} + client.retrieval_error = retrieval_error + client.provider = set_mocked_okta_provider() + return client + + +def network_zone( + zone_id: str = "nzo-1", + name: str = "BlockedIpZone", + *, + status: str = "ACTIVE", + zone_type: str = "IP", + usage: str = "BLOCKLIST", + system: bool = False, + gateways: list[str] = None, + proxies: list[str] = None, + ip_service_categories: list[str] = None, +): + return OktaNetworkZone( + id=zone_id, + name=name, + status=status, + type=zone_type, + usage=usage, + system=system, + gateways=gateways or [], + proxies=proxies or [], + ip_service_categories=ip_service_categories or [], + ) diff --git a/tests/providers/okta/services/network_zone/network_zone_service_test.py b/tests/providers/okta/services/network_zone/network_zone_service_test.py new file mode 100644 index 0000000000..36790c1328 --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_service_test.py @@ -0,0 +1,560 @@ +import json +from types import SimpleNamespace +from unittest import mock + +from pydantic import ValidationError + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZone, + OktaNetworkZone, + _raw_zone_to_model, + _value, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_zone( + zone_id: str, + name: str, + *, + status: str = "ACTIVE", + zone_type: str = "IP", + usage: str = "BLOCKLIST", + system: bool = False, + gateways: list[str] = None, + proxies: list[str] = None, + ip_service_categories: list[str] = None, +): + return SimpleNamespace( + id=zone_id, + name=name, + status=status, + type=zone_type, + usage=usage, + system=system, + gateways=gateways or [], + proxies=proxies or [], + ip_service_categories=ip_service_categories or [], + ) + + +class _ValueObject: + def __init__(self, value: str): + self.value = value + + +class Test_value_helper: + def test_value_returns_empty_string_for_none(self): + assert _value(None) == "" + + +class Test_NetworkZone_service: + def test_fetches_ip_and_enhanced_dynamic_zones(self): + provider = set_mocked_okta_provider() + ip_zone = _sdk_zone( + "nzo-ip", + "Blocked IPs", + gateways=["203.0.113.10/32"], + ) + enhanced_zone = _sdk_zone( + "nzo-enhanced", + "DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=["ANONYMIZER"], + ) + + async def fake_list_network_zones(*_a, **_k): + return ([ip_zone, enhanced_zone], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + + service = NetworkZone(provider) + + assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"} + assert isinstance(service.network_zones["nzo-ip"], OktaNetworkZone) + assert service.network_zones["nzo-ip"].gateways == ["203.0.113.10/32"] + assert service.network_zones["nzo-enhanced"].type == "DYNAMIC_V2" + assert service.network_zones["nzo-enhanced"].ip_service_categories == [ + "ANONYMIZER" + ] + + def test_paginates_network_zones(self): + provider = set_mocked_okta_provider() + page_1 = _sdk_zone("nzo-1", "First") + page_2 = _sdk_zone("nzo-2", "Second") + next_link = '; rel="next"' + calls = [] + + async def fake_list_network_zones(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page_1], _resp({"link": next_link}), None) + return ([page_2], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert calls == [None, "cursor-2"] + assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"} + + def test_preserves_sdk_error_reason_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = failing + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert service.network_zones == {} + assert service.retrieval_error == "Error listing Network Zones: forbidden" + + def test_build_zone_extracts_sdk_network_zone_address_values(self): + from okta.models.network_zone_address import NetworkZoneAddress + + zone = _sdk_zone( + "nzo-ip", + "Blocked IPs", + gateways=[ + NetworkZoneAddress( + type="CIDR", + value="203.0.113.10/32", + ) + ], + proxies=[ + NetworkZoneAddress( + type="CIDR", + value="198.51.100.10/32", + ) + ], + ) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.gateways == ["203.0.113.10/32"] + assert built_zone.proxies == ["198.51.100.10/32"] + + def test_build_zone_normalizes_sdk_value_objects_to_strings(self): + zone = _sdk_zone( + "nzo-sdk-values", + "SDK Values", + gateways=[_ValueObject("203.0.113.10/32")], + proxies=[_ValueObject("198.51.100.10/32")], + ) + zone.asns = SimpleNamespace(include=[_ValueObject("64512")], exclude=[]) + zone.locations = SimpleNamespace(include=[_ValueObject("US")], exclude=[]) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.gateways == ["203.0.113.10/32"] + assert built_zone.proxies == ["198.51.100.10/32"] + assert built_zone.asns == ["64512"] + assert built_zone.locations == ["US"] + + def test_build_zone_extracts_sdk_enhanced_dynamic_category_values(self): + from okta.models.enhanced_dynamic_network_zone_all_of_ip_service_categories import ( + EnhancedDynamicNetworkZoneAllOfIpServiceCategories, + ) + + zone = _sdk_zone( + "nzo-enhanced", + "Enhanced Anonymizers", + zone_type="DYNAMIC_V2", + system=False, + ) + zone.ip_service_categories = EnhancedDynamicNetworkZoneAllOfIpServiceCategories( + include=["ALL_ANONYMIZERS"], + exclude=[], + ) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.ip_service_categories == ["ALL_ANONYMIZERS"] + + def test_missing_network_zone_scope_skips_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.policies.read", "okta.brands.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_network_zones should not be called") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fail_if_called + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert service.missing_scope["network_zones"] == "okta.networkZones.read" + assert service.network_zones == {} + + +class Test_NetworkZone_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback for the Okta SDK Enhanced Dynamic + Zone deserialization bug. + + The Okta Management API returns `asns.include` as a JSON array + (typically `[]`) but the SDK's `EnhancedDynamicNetworkZoneAllOfAsnsInclude` + is an object-shaped pydantic model — so listing zones raises + ValidationError. Without a fallback the whole fetch crashes and + every check FAILs as if no zones exist; with the fallback we parse + the raw JSON and STIG evaluation continues. + """ + + @staticmethod + def _trigger_real_validation_error() -> ValidationError: + try: + from okta.models.enhanced_dynamic_network_zone_all_of_asns_include import ( # noqa: E501 + EnhancedDynamicNetworkZoneAllOfAsnsInclude, + ) + + EnhancedDynamicNetworkZoneAllOfAsnsInclude.from_dict([]) + except ValidationError as ve: + return ve + raise AssertionError("Expected pydantic ValidationError from Okta SDK model") + + def _build_service_with_raw_payload( + self, raw_zones_payload, response=None, body_factory=None + ): + response_body = ( + body_factory(raw_zones_payload) + if body_factory + else json.dumps(raw_zones_payload) + ) + return self._build_service_with_raw_response(response_body, response=response) + + def _build_service_with_raw_response( + self, response_body, response=None, execute_error=None + ): + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/zones"}, None) + + async def fake_raw_execute(_request): + return (response, response_body, execute_error) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + return NetworkZone(provider) + + def test_raw_fallback_projects_ip_and_enhanced_dynamic_zones(self): + zones_payload = [ + { + "id": "nzo-ip", + "name": "Blocked IPs", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + "system": False, + "gateways": [{"type": "CIDR", "value": "203.0.113.10/32"}], + "proxies": [], + }, + { + "id": "nzo-enhanced", + "name": "DefaultEnhancedDynamicZone", + "status": "ACTIVE", + "type": "DYNAMIC_V2", + "usage": "BLOCKLIST", + "system": True, + "asns": {"include": [], "exclude": []}, + "locations": {"include": [], "exclude": []}, + "ipServiceCategories": [{"value": "ANONYMIZER"}], + }, + ] + + service = self._build_service_with_raw_payload(zones_payload) + + assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"} + ip_zone = service.network_zones["nzo-ip"] + assert ip_zone.type == "IP" + assert ip_zone.gateways == ["203.0.113.10/32"] + enhanced = service.network_zones["nzo-enhanced"] + assert enhanced.type == "DYNAMIC_V2" + assert enhanced.system is True + assert enhanced.ip_service_categories == ["ANONYMIZER"] + assert enhanced.asns == [] + assert enhanced.locations == [] + + def test_raw_fallback_handles_empty_payload(self): + service = self._build_service_with_raw_payload([]) + assert service.network_zones == {} + + def test_raw_fallback_handles_executor_error(self): + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **_k): + return (None, Exception("network down")) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = mock.AsyncMock() + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + service = NetworkZone(provider) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_execute_error(self): + service = self._build_service_with_raw_response( + None, + execute_error=Exception("timeout"), + ) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_decodes_bytes_response_body(self): + service = self._build_service_with_raw_payload( + [ + { + "id": "nzo-bytes", + "name": "Bytes", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ], + body_factory=lambda payload: json.dumps(payload).encode("utf-8"), + ) + + assert set(service.network_zones.keys()) == {"nzo-bytes"} + + def test_raw_fallback_handles_invalid_utf8_response_body(self): + service = self._build_service_with_raw_response(b"\xff") + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_invalid_json_response_body(self): + service = self._build_service_with_raw_response("{") + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_unexpected_payload_shape(self): + service = self._build_service_with_raw_payload({"id": "nzo-not-a-list"}) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_skips_non_dict_payload_items(self): + service = self._build_service_with_raw_payload( + [ + "not-a-zone", + { + "id": "nzo-valid", + "name": "Valid", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + }, + ] + ) + + assert set(service.network_zones.keys()) == {"nzo-valid"} + + def test_raw_fallback_paginates_via_link_header(self): + next_link = '; rel="next"' + page_1 = [ + { + "id": "nzo-1", + "name": "First", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ] + page_2 = [ + { + "id": "nzo-2", + "name": "Second", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ] + + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + execute_calls = [] + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "")}, None) + + async def fake_raw_execute(request): + execute_calls.append(request) + if len(execute_calls) == 1: + return ( + SimpleNamespace(headers={"link": next_link}), + json.dumps(page_1), + None, + ) + return (SimpleNamespace(headers={}), json.dumps(page_2), None) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + service = NetworkZone(provider) + + assert len(execute_calls) == 2 + assert "after=cursor-2" in execute_calls[1]["url"] + assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"} + + +class Test_raw_zone_to_model: + def test_extracts_address_values_and_categories(self): + zone = _raw_zone_to_model( + { + "id": "nzo-ip", + "name": "IPs", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + "system": False, + "gateways": [ + {"type": "CIDR", "value": "203.0.113.0/24"}, + {"type": "RANGE", "value": "198.51.100.5-198.51.100.10"}, + ], + "proxies": [{"type": "CIDR", "value": "192.0.2.0/24"}], + "ipServiceCategories": [ + {"value": "ANONYMIZER"}, + {"value": "TOR_ANONYMIZER"}, + ], + } + ) + assert zone.gateways == [ + "203.0.113.0/24", + "198.51.100.5-198.51.100.10", + ] + assert zone.proxies == ["192.0.2.0/24"] + assert zone.ip_service_categories == ["ANONYMIZER", "TOR_ANONYMIZER"] + + def test_collapses_non_list_asns_and_locations_to_empty(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "asns": {"include": [], "exclude": []}, + "locations": {"include": [], "exclude": []}, + } + ) + assert zone.asns == [] + assert zone.locations == [] + assert isinstance(zone, OktaNetworkZone) + + def test_extracts_ip_service_categories_from_raw_include_condition(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "ipServiceCategories": { + "include": ["ALL_ANONYMIZERS"], + "exclude": [], + }, + } + ) + assert zone.ip_service_categories == ["ALL_ANONYMIZERS"] + + def test_extracts_scalar_ip_service_category_condition(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "ipServiceCategories": { + "include": {"value": "VPN_ANONYMIZER"}, + "exclude": [], + }, + } + ) + assert zone.ip_service_categories == ["VPN_ANONYMIZER"] + + def test_ignores_none_address_entries_and_empty_condition_values(self): + zone = _raw_zone_to_model( + { + "id": "nzo-ip", + "gateways": [None], + "ipServiceCategories": { + "include": None, + "exclude": [], + }, + } + ) + assert zone.gateways == [] + assert zone.ip_service_categories == [] + + def test_falls_back_name_to_id_when_missing(self): + zone = _raw_zone_to_model({"id": "nzo-1"}) + assert zone.id == "nzo-1" + assert zone.name == "nzo-1" + assert zone.status == "" + assert zone.system is False diff --git a/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py b/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py new file mode 100644 index 0000000000..96406a5fd1 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py @@ -0,0 +1,257 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + DOD_BANNER_HTML_SNIPPET, + build_signon_client, + sign_in_page, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_dod_warning_banner_configured." + "signon_dod_warning_banner_configured.signon_client" +) + + +class Test_signon_dod_warning_banner_configured: + def test_manual_when_no_brands_detected(self): + signon_client = build_signon_client(sign_in_pages={}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "No Okta brands were retrieved" in findings[0].status_extended + + def test_missing_brand_scope_returns_manual_finding_naming_the_scope(self): + signon_client = build_signon_client( + missing_scope={ + "global_session_policies": None, + "sign_in_pages": "okta.brands.read", + } + ) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.brands.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended + + def test_pass_when_customized_page_contains_banner(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=True, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "DOD Notice and Consent Banner detected" in ( + findings[0].status_extended + ) + assert "customized sign-in page" in findings[0].status_extended + + def test_fail_when_customized_page_missing_banner(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=True, + page_content="

    Welcome to ACME

    ", + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not contain" in findings[0].status_extended + + def test_pass_when_default_page_contains_banner(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=False, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "default sign-in page" in findings[0].status_extended + + def test_manual_when_page_content_missing(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=False, + page_content=None, + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "could not be retrieved from the Okta API" in ( + findings[0].status_extended + ) + + def test_manual_when_fetch_error(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=False, + fetch_error="403 Forbidden: invalid_scope", + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "Could not retrieve" in findings[0].status_extended + assert "403" in findings[0].status_extended + + def test_fail_when_only_partial_banner_markers_are_present(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=True, + page_content=( + "This U.S. Government portal is for authorized use " + "only." + ), + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not contain" in findings[0].status_extended + + def test_emits_one_finding_per_brand(self): + compliant = sign_in_page( + brand_id="brand-prod", + brand_name="Prod", + is_customized=True, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", + ) + missing = sign_in_page( + brand_id="brand-sandbox", + brand_name="Sandbox", + is_customized=True, + page_content="No banner here", + ) + no_custom = sign_in_page( + brand_id="brand-legacy", + brand_name="Legacy", + is_customized=False, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", + ) + signon_client = build_signon_client( + sign_in_pages={ + "brand-prod": compliant, + "brand-sandbox": missing, + "brand-legacy": no_custom, + } + ) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 3 + by_brand = {f.resource_id: f.status for f in findings} + assert by_brand == { + "brand-prod": "PASS", + "brand-sandbox": "FAIL", + "brand-legacy": "PASS", + } diff --git a/tests/providers/okta/services/signon/signon_fixtures.py b/tests/providers/okta/services/signon/signon_fixtures.py new file mode 100644 index 0000000000..c2188670bc --- /dev/null +++ b/tests/providers/okta/services/signon/signon_fixtures.py @@ -0,0 +1,130 @@ +"""Shared helpers for `signon` service check tests. + +The original idle-timeout check test file defined these helpers locally; +they were extracted here so the four checks added on top of the same +service (`signon_global_session_lifetime_18h`, +`signon_global_session_cookies_not_persistent`, +`signon_global_session_policy_network_zone_enforced`, +`signon_dod_warning_banner_configured`) can reuse them without copy-paste. +""" + +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, + SignInPage, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_signon_client( + policies: dict = None, + audit_config: dict = None, + sign_in_pages: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.global_session_policies = policies or {} + client.provider = set_mocked_okta_provider() + client.audit_config = audit_config or {} + client.sign_in_pages = sign_in_pages or {} + # Default to "all scopes granted" so existing tests keep working. + client.missing_scope = missing_scope or { + "global_session_policies": None, + "sign_in_pages": None, + } + return client + + +def default_policy(rules): + return GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="ACTIVE", + is_default=True, + rules=rules, + ) + + +def custom_policy(rules, name: str = "Admins Policy"): + return GlobalSessionPolicy( + id="pol-custom", + name=name, + priority=1, + status="ACTIVE", + is_default=False, + rules=rules, + ) + + +def default_rule( + idle_min: int = 480, + lifetime_min: int = None, + use_persistent_cookie: bool = None, + priority: int = 2, + status: str = "ACTIVE", +): + return GlobalSessionPolicyRule( + id="rule-default", + name="Default Rule", + priority=priority, + status=status, + is_default=True, + max_session_idle_minutes=idle_min, + max_session_lifetime_minutes=lifetime_min, + use_persistent_cookie=use_persistent_cookie, + ) + + +def non_default_rule( + name: str, + *, + idle_min: int = None, + lifetime_min: int = None, + use_persistent_cookie: bool = None, + network_zones_include: list = None, + network_zones_exclude: list = None, + priority: int = 1, + status: str = "ACTIVE", +): + return GlobalSessionPolicyRule( + id=f"rule-{name.lower().replace(' ', '-')}", + name=name, + priority=priority, + status=status, + is_default=False, + max_session_idle_minutes=idle_min, + max_session_lifetime_minutes=lifetime_min, + use_persistent_cookie=use_persistent_cookie, + network_zones_include=network_zones_include or [], + network_zones_exclude=network_zones_exclude or [], + ) + + +def sign_in_page( + brand_id: str = "brand-1", + brand_name: str = "Default Brand", + is_customized: bool = True, + page_content: str = None, + fetch_error: str = None, +): + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=is_customized, + page_content=page_content, + fetch_error=fetch_error, + ) + + +# Condensed DTM-08-060 banner that covers all four marker groups the check +# requires (see BANNER_MARKER_GROUPS in the check module). Lets PASS tests +# avoid pasting the full ~1300-char banner verbatim. +DOD_BANNER_HTML_SNIPPET = ( + "
    You are accessing a U.S. Government (USG) Information System " + "(IS) that is provided for USG-authorized use only. " + "Communications using, or data stored on, this IS may be intercepted, " + "searched, monitored, and recorded.
    " +) diff --git a/tests/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent_test.py b/tests/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent_test.py new file mode 100644 index 0000000000..cd10d84be8 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent_test.py @@ -0,0 +1,189 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_cookies_not_persistent." + "signon_global_session_cookies_not_persistent.signon_client" +) + + +def _run_check(signon_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_cookies_not_persistent.signon_global_session_cookies_not_persistent import ( + signon_global_session_cookies_not_persistent, + ) + + return signon_global_session_cookies_not_persistent().execute() + + +class Test_signon_global_session_cookies_not_persistent: + def test_no_policies(self): + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_pass_when_priority_one_rule_disables_persistent_cookies(self): + policy = default_policy( + [ + non_default_rule( + "Non-persistent cookies", + use_persistent_cookie=False, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disables persistent global session cookies" in ( + findings[0].status_extended + ) + assert "priority 99, default" in findings[0].status_extended + + def test_fail_when_priority_one_rule_uses_persistent_cookies(self): + policy = default_policy( + [ + non_default_rule( + "Persistent cookies enabled", + use_persistent_cookie=True, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "allows persistent global session cookies" in ( + findings[0].status_extended + ) + + def test_fail_when_priority_one_rule_does_not_assert_setting(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-no-session", + name="No Session Block", + priority=1, + status="ACTIVE", + is_default=False, + use_persistent_cookie=None, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not assert" in findings[0].status_extended + + def test_emits_one_finding_per_policy(self): + admins_policy = custom_policy( + [ + non_default_rule( + "Sticky admin", + use_persistent_cookie=True, + priority=1, + ) + ], + name="Admins Policy", + ) + strict_default = default_policy( + [ + non_default_rule( + "Non-persistent", + use_persistent_cookie=False, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": strict_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("Sticky", use_persistent_cookie=True, priority=1)], + ) + active_default = default_policy( + [ + non_default_rule( + "Non-persistent", + use_persistent_cookie=False, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" + + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended + + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[ + non_default_rule("Compliant", use_persistent_cookie=False, priority=1) + ], + ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended diff --git a/tests/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min_test.py b/tests/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min_test.py new file mode 100644 index 0000000000..177a4b1c8f --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min_test.py @@ -0,0 +1,210 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_idle_timeout_15min." + "signon_global_session_idle_timeout_15min.signon_client" +) + + +def _run_check(signon_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( + signon_global_session_idle_timeout_15min, + ) + + return signon_global_session_idle_timeout_15min().execute() + + +class Test_signon_global_session_idle_timeout_15min: + def test_no_policies(self): + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_is_compliant(self): + policy = default_policy( + [ + non_default_rule("Strict 15min", idle_min=15, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Strict 15min" in findings[0].status_extended + assert "Default Policy" in findings[0].status_extended + assert "priority 99, default" in findings[0].status_extended + + def test_fail_when_only_default_rule(self): + policy = default_policy([default_rule(priority=1)]) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "uses 'Default Rule' as its active Priority 1 rule" in ( + findings[0].status_extended + ) + + def test_fail_when_priority_one_non_default_rule_has_null_idle(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-no-session", + name="No Session Block", + priority=1, + status="ACTIVE", + is_default=False, + max_session_idle_minutes=None, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No Session Block" in findings[0].status_extended + assert "does not define" in findings[0].status_extended + + def test_fail_when_priority_one_non_default_rule_exceeds_threshold(self): + policy = default_policy( + [ + non_default_rule("Loose 60min", idle_min=60, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Loose 60min" in findings[0].status_extended + assert "exceeding the configured threshold" in findings[0].status_extended + + def test_fail_when_compliant_non_default_rule_is_not_priority_one(self): + policy = default_policy( + [ + default_rule(priority=1), + non_default_rule("Strict 15min", idle_min=15, priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "uses 'Default Rule' as its active Priority 1 rule" in ( + findings[0].status_extended + ) + + def test_emits_one_finding_per_policy(self): + # Custom policy at priority 1 with a permissive rule + Default Policy + # with a strict rule -> two findings, ordered by policy priority. + admins_policy = custom_policy( + [ + non_default_rule("Admin Loose", idle_min=120, priority=1), + default_rule(priority=2), + ], + name="Admins Policy", + ) + strict_default = default_policy( + [ + non_default_rule("Strict 15min", idle_min=15, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": strict_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + assert "priority 99, default" in by_name["Default Policy"].status_extended + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("Loose 120min", idle_min=120, priority=1)], + ) + active_default = default_policy( + [ + non_default_rule("Strict 15min", idle_min=15, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" + + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[non_default_rule("Strict 15min", idle_min=15, priority=1)], + ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + policy = default_policy( + [ + non_default_rule("Relaxed 30min", idle_min=30, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-default": policy}, + audit_config={"okta_max_session_idle_minutes": 60}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "threshold of 60 minutes" in findings[0].status_extended diff --git a/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py b/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py new file mode 100644 index 0000000000..1a22c20573 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py @@ -0,0 +1,212 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_lifetime_18h." + "signon_global_session_lifetime_18h.signon_client" +) + + +def _run_check(signon_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + return signon_global_session_lifetime_18h().execute() + + +class Test_signon_global_session_lifetime_18h: + def test_no_policies(self): + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_is_compliant(self): + policy = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "18h rule" in findings[0].status_extended + assert "1080 minutes" in findings[0].status_extended + assert "priority 99, default" in findings[0].status_extended + + def test_fail_when_lifetime_exceeds_threshold(self): + policy = default_policy( + [ + non_default_rule("Loose 24h rule", lifetime_min=1440, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "1440 minutes" in findings[0].status_extended + assert "exceeding the configured threshold" in findings[0].status_extended + + def test_fail_when_priority_one_rule_has_no_lifetime(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-no-session", + name="No Session Block", + priority=1, + status="ACTIVE", + is_default=False, + max_session_lifetime_minutes=None, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not define" in findings[0].status_extended + + def test_fail_when_lifetime_is_disabled_with_zero(self): + policy = default_policy( + [ + non_default_rule("Unlimited Lifetime", lifetime_min=0, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "0 minutes" in findings[0].status_extended + assert "disables the maximum Okta global session lifetime" in ( + findings[0].status_extended + ) + + def test_fail_when_default_rule_is_priority_one(self): + policy = default_policy( + [ + default_rule(priority=1, lifetime_min=1080), + non_default_rule("Compliant", lifetime_min=1080, priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "uses 'Default Rule' as its active Priority 1 rule" in ( + findings[0].status_extended + ) + + def test_emits_one_finding_per_policy(self): + admins_policy = custom_policy( + [ + non_default_rule("Admin Long Lived", lifetime_min=2880, priority=1), + default_rule(priority=2), + ], + name="Admins Policy", + ) + strict_default = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": strict_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("Loose", lifetime_min=2880, priority=1)], + ) + active_default = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" + + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[non_default_rule("18h rule", lifetime_min=1080, priority=1)], + ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + policy = default_policy( + [ + non_default_rule("Relaxed 24h", lifetime_min=1440, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-default": policy}, + audit_config={"okta_max_session_lifetime_minutes": 1440}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "threshold of 1440 minutes" in findings[0].status_extended diff --git a/tests/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced_test.py b/tests/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced_test.py new file mode 100644 index 0000000000..1d3067a257 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced_test.py @@ -0,0 +1,222 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_policy_network_zone_enforced." + "signon_global_session_policy_network_zone_enforced.signon_client" +) + + +def _run_check(signon_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_policy_network_zone_enforced.signon_global_session_policy_network_zone_enforced import ( + signon_global_session_policy_network_zone_enforced, + ) + + return signon_global_session_policy_network_zone_enforced().execute() + + +class Test_signon_global_session_policy_network_zone_enforced: + def test_no_policies(self): + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_includes_zone(self): + policy = default_policy( + [ + non_default_rule( + "Allow-from-VPN", + network_zones_include=["zone-corp"], + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Allow-from-VPN" in findings[0].status_extended + assert "non-default rule" in findings[0].status_extended + assert "priority 99, default" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_excludes_zone(self): + policy = default_policy( + [ + non_default_rule( + "Block-blacklist", + network_zones_exclude=["zone-blocked"], + priority=1, + ), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Block-blacklist" in findings[0].status_extended + + def test_pass_when_only_default_rule_has_zones(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-default-zoned", + name="Default Rule", + priority=1, + status="ACTIVE", + is_default=True, + network_zones_include=["zone-corp"], + ), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "built-in Default Rule" in findings[0].status_extended + + def test_fail_when_priority_one_rule_has_no_zones(self): + policy = default_policy( + [ + non_default_rule("Plain non-default", priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Plain non-default" in findings[0].status_extended + assert "does not map" in findings[0].status_extended + + def test_fail_when_only_lower_priority_rule_has_zones(self): + policy = default_policy( + [ + non_default_rule("No-zones top", priority=1), + non_default_rule( + "Zoned-but-low", + network_zones_include=["zone-corp"], + priority=2, + ), + default_rule(priority=3), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No-zones top" in findings[0].status_extended + + def test_fail_when_only_default_rule_has_no_zones(self): + policy = default_policy([default_rule(priority=1)]) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "built-in Default Rule" in findings[0].status_extended + + def test_emits_one_finding_per_policy(self): + admins_policy = custom_policy( + [ + non_default_rule("No-zones admin", priority=1), + default_rule(priority=2), + ], + name="Admins Policy", + ) + zoned_default = default_policy( + [ + non_default_rule( + "Allow-corp", + network_zones_include=["zone-corp"], + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": zoned_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("No-zones", priority=1)], + ) + active_default = default_policy( + [ + non_default_rule( + "Allow-corp", + network_zones_include=["zone-corp"], + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" + + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[ + non_default_rule( + "Allow-corp", + network_zones_include=["zone-corp"], + priority=1, + ) + ], + ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended diff --git a/tests/providers/okta/services/signon/signon_service_test.py b/tests/providers/okta/services/signon/signon_service_test.py new file mode 100644 index 0000000000..0caeb0e680 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_service_test.py @@ -0,0 +1,434 @@ +from unittest import mock + +from prowler.providers.okta.lib.service.pagination import ( + next_after_cursor as _next_after_cursor, +) +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, + SignInPage, + Signon, +) +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + set_mocked_okta_provider, +) + + +def _fake_policy( + policy_id: str, + name: str, + system: bool = True, + priority: int | None = 1, + status: str = "ACTIVE", +): + p = mock.MagicMock() + p.id = policy_id + p.name = name + p.priority = priority + p.status = status + p.system = system + return p + + +def _fake_rule( + rule_id: str, + name: str, + *, + system: bool = False, + priority: int | None = 1, + status: str = "ACTIVE", + max_session_idle_minutes: int = None, +): + r = mock.MagicMock() + r.id = rule_id + r.name = name + r.priority = priority + r.status = status + r.system = system + r.actions.signon.session.max_session_idle_minutes = max_session_idle_minutes + r.actions.signon.session.max_session_lifetime_minutes = None + r.actions.signon.session.use_persistent_cookie = None + r.conditions.network.include = [] + r.conditions.network.exclude = [] + return r + + +def _fake_brand(brand_id: str, name: str): + b = mock.MagicMock() + b.id = brand_id + b.name = name + return b + + +def _fake_sign_in_page(page_content: str): + p = mock.MagicMock() + p.page_content = page_content + return p + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +async def _empty_brands(*_a, **_k): + return ([], _resp({}), None) + + +class Test_next_after_cursor: + def test_no_resp_returns_none(self): + assert _next_after_cursor(None) is None + + def test_no_link_header_returns_none(self): + assert _next_after_cursor(_resp({})) is None + + def test_extracts_after_param(self): + link = ( + '; rel="self", ' + '; rel="next"' + ) + assert _next_after_cursor(_resp({"link": link})) == "abc123" + + def test_link_without_next_returns_none(self): + link = '; rel="self"' + assert _next_after_cursor(_resp({"link": link})) is None + + +class Test_Signon_service: + def test_fetches_policies_and_rules(self): + provider = set_mocked_okta_provider() + + policy = _fake_policy("pol-default", "Default Policy", system=True) + rule_default = _fake_rule( + "rule-default", "Default Rule", system=True, max_session_idle_minutes=480 + ) + rule_compliant = _fake_rule( + "rule-15", "Strict 15min", system=False, max_session_idle_minutes=15 + ) + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_rules(*_a, **_k): + return ([rule_default, rule_compliant], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_policy_rules = fake_list_rules + mocked.list_brands = _empty_brands + mocked_client_cls.return_value = mocked + + service = Signon(provider) + + assert "pol-default" in service.global_session_policies + policy_obj = service.global_session_policies["pol-default"] + assert isinstance(policy_obj, GlobalSessionPolicy) + assert policy_obj.is_default is True + assert policy_obj.priority == 1 + assert policy_obj.status == "ACTIVE" + assert len(policy_obj.rules) == 2 + rules_by_name = {r.name: r for r in policy_obj.rules} + assert isinstance(rules_by_name["Default Rule"], GlobalSessionPolicyRule) + assert rules_by_name["Default Rule"].is_default is True + assert rules_by_name["Default Rule"].priority == 1 + assert rules_by_name["Default Rule"].status == "ACTIVE" + assert rules_by_name["Strict 15min"].is_default is False + assert rules_by_name["Strict 15min"].max_session_idle_minutes == 15 + + def test_paginates_via_link_header(self): + provider = set_mocked_okta_provider() + + page1_policy = _fake_policy("pol-1", "Default Policy") + page2_policy = _fake_policy("pol-2", "Custom Policy", system=False) + next_link = '; rel="next"' + + calls = [] + + async def fake_list_policies(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page1_policy], _resp({"link": next_link}), None) + return ([page2_policy], _resp({}), None) + + async def fake_list_rules(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_policy_rules = fake_list_rules + mocked.list_brands = _empty_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert calls == [None, "cursor-2"] + assert set(service.global_session_policies.keys()) == {"pol-1", "pol-2"} + + def test_returns_empty_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("E0000007: not found")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing + mocked.list_brands = _empty_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert service.global_session_policies == {} + + def test_skips_policy_fetch_when_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.brands.read"], # policies scope missing + ) + provider = set_mocked_okta_provider(identity=identity) + + list_policies_called = False + + async def fake_list_policies(*_a, **_k): + nonlocal list_policies_called + list_policies_called = True + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_brands = _empty_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert list_policies_called is False + assert service.global_session_policies == {} + assert service.missing_scope["global_session_policies"] == "okta.policies.read" + assert service.missing_scope["sign_in_pages"] is None + + def test_unknown_granted_scopes_falls_back_to_attempting_fetch(self): + # When the JWT couldn't be decoded, granted_scopes is empty and the + # service must still attempt the fetch — preserves prior behavior. + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=[], + ) + provider = set_mocked_okta_provider(identity=identity) + + list_policies_called = False + + async def fake_list_policies(*_a, **_k): + nonlocal list_policies_called + list_policies_called = True + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_brands = _empty_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert list_policies_called is True + assert service.missing_scope["global_session_policies"] is None + assert service.missing_scope["sign_in_pages"] is None + + +class Test_Signon_service_brands: + """Brand sign-in page fetching for the DOD banner check.""" + + def _build_with_brands( + self, + provider, + brands_response, + sign_in_page_responses: dict, + default_sign_in_page_responses: dict | None = None, + ): + async def fake_list_policies(*_a, **_k): + return ([], _resp({}), None) + + async def fake_list_brands(*_a, **_k): + return brands_response + + async def fake_get_sign_in_page(brand_id, *_a, **_k): + return sign_in_page_responses[brand_id] + + async def fake_get_default_sign_in_page(brand_id, *_a, **_k): + return default_sign_in_page_responses[brand_id] + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_brands = fake_list_brands + mocked.get_customized_sign_in_page = fake_get_sign_in_page + mocked.get_default_sign_in_page = fake_get_default_sign_in_page + mocked_client_cls.return_value = mocked + return Signon(provider) + + def test_fetches_brand_with_customized_page(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + page = _fake_sign_in_page("banner here") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={"brand-1": (page, _resp({}), None)}, + ) + + assert "brand-1" in service.sign_in_pages + result = service.sign_in_pages["brand-1"] + assert isinstance(result, SignInPage) + assert result.is_customized is True + assert result.page_content == "banner here" + assert result.fetch_error is None + + def test_404_falls_back_to_default_sign_in_page(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + default_page = _fake_sign_in_page("default banner here") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("404 Not Found")) + }, + default_sign_in_page_responses={"brand-1": (default_page, _resp({}), None)}, + ) + + assert service.sign_in_pages["brand-1"].is_customized is False + assert service.sign_in_pages["brand-1"].fetch_error is None + assert ( + service.sign_in_pages["brand-1"].page_content + == "default banner here" + ) + + def test_default_sign_in_page_error_captured_when_customized_page_missing(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("404 Not Found")) + }, + default_sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("403 Forbidden")) + }, + ) + + result = service.sign_in_pages["brand-1"] + assert result.is_customized is False + assert "403" in result.fetch_error + + def test_403_captured_into_fetch_error(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("403 Forbidden: invalid_scope")) + }, + default_sign_in_page_responses={}, + ) + + result = service.sign_in_pages["brand-1"] + assert result.is_customized is False + assert "403" in result.fetch_error + + def test_returns_empty_on_brands_api_error(self): + provider = set_mocked_okta_provider() + + async def fake_list_policies(*_a, **_k): + return ([], _resp({}), None) + + async def failing_brands(*_a, **_k): + return ([], _resp({}), Exception("Brands API unavailable")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_brands = failing_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert service.sign_in_pages == {} + + def test_skips_brand_fetch_when_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read"], # brands scope missing + ) + provider = set_mocked_okta_provider(identity=identity) + + async def fake_list_policies(*_a, **_k): + return ([], _resp({}), None) + + list_brands_called = False + + async def fake_list_brands(*_a, **_k): + nonlocal list_brands_called + list_brands_called = True + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_brands = fake_list_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert list_brands_called is False + assert service.sign_in_pages == {} + assert service.missing_scope["sign_in_pages"] == "okta.brands.read" + assert service.missing_scope["global_session_policies"] is None + + def test_handles_multiple_brands(self): + provider = set_mocked_okta_provider() + brand_a = _fake_brand("brand-a", "Brand A") + brand_b = _fake_brand("brand-b", "Brand B") + page_a = _fake_sign_in_page("A") + + service = self._build_with_brands( + provider, + brands_response=([brand_a, brand_b], _resp({}), None), + sign_in_page_responses={ + "brand-a": (page_a, _resp({}), None), + "brand-b": (None, _resp({}), Exception("404 not found")), + }, + default_sign_in_page_responses={ + "brand-b": ( + _fake_sign_in_page("default B"), + _resp({}), + None, + ) + }, + ) + + assert set(service.sign_in_pages.keys()) == {"brand-a", "brand-b"} + assert service.sign_in_pages["brand-a"].page_content == "A" + assert service.sign_in_pages["brand-b"].is_customized is False + assert service.sign_in_pages["brand-b"].page_content == "default B" diff --git a/tests/providers/okta/services/systemlog/systemlog_fixtures.py b/tests/providers/okta/services/systemlog/systemlog_fixtures.py new file mode 100644 index 0000000000..efc8289f43 --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_fixtures.py @@ -0,0 +1,27 @@ +"""Shared helpers for `systemlog` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_systemlog_client( + log_streams: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.log_streams = log_streams or {} + client.provider = set_mocked_okta_provider() + client.audit_config = {} + client.missing_scope = missing_scope or {"log_streams": None} + return client + + +def log_stream( + stream_id: str = "log-1", + name: str = "EventBridge stream", + status: str = "ACTIVE", + type: str = "AWS_EVENTBRIDGE", +): + return LogStream(id=stream_id, name=name, status=status, type=type) diff --git a/tests/providers/okta/services/systemlog/systemlog_service_test.py b/tests/providers/okta/services/systemlog/systemlog_service_test.py new file mode 100644 index 0000000000..63dee95dcc --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_service_test.py @@ -0,0 +1,185 @@ +import json +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.systemlog.systemlog_service import ( + LogStream, + SystemLog, +) +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + set_mocked_okta_provider, +) + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_stream( + stream_id: str, name: str, status: str = "ACTIVE", type_: str = "AWS_EVENTBRIDGE" +): + s = mock.MagicMock() + s.id = stream_id + s.name = name + s.status = status + s.type = type_ + return s + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_SystemLog_service: + def test_fetches_active_streams(self): + provider = set_mocked_okta_provider() + s1 = _fake_stream("log-1", "EventBridge") + s2 = _fake_stream("log-2", "Splunk", type_="SPLUNK_CLOUD_LOGSTREAMING") + + async def fake_list(*_a, **_k): + return ([s1, s2], _resp({}), None) + + with _patch_sdk(list_log_streams=fake_list): + service = SystemLog(provider) + + assert set(service.log_streams.keys()) == {"log-1", "log-2"} + assert isinstance(service.log_streams["log-1"], LogStream) + assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING" + + def test_returns_empty_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("E0000007")) + + with _patch_sdk(list_log_streams=failing): + service = SystemLog(provider) + + assert service.log_streams == {} + + def test_skips_fetch_when_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read"], # no logStreams scope + ) + provider = set_mocked_okta_provider(identity=identity) + + called = False + + async def fake_list(*_a, **_k): + nonlocal called + called = True + return ([], _resp({}), None) + + with _patch_sdk(list_log_streams=fake_list): + service = SystemLog(provider) + + assert called is False + assert service.log_streams == {} + assert service.missing_scope["log_streams"] == "okta.logStreams.read" + + +class Test_SystemLog_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback when the Okta SDK rejects API values. + + The SDK's `LogStreamSettingsAws.eventSourceName` validator uses the + regex `^[a-zA-Z0-9.\\-_]$` — missing the `+` quantifier, so every + multi-character name raises pydantic `ValidationError`. Without the + fallback the whole stream list is lost; with it, the raw JSON path + still surfaces each stream's id/name/status/type. + """ + + def test_raw_fallback_projects_streams_when_sdk_raises(self): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + + raw_payload = [ + { + "id": "log-1", + "name": "EventBridge prod", + "status": "ACTIVE", + "type": "AWS_EVENTBRIDGE", + }, + { + "id": "log-2", + "name": "Splunk staging", + "status": "INACTIVE", + "type": "SPLUNK_CLOUD_LOGSTREAMING", + }, + ] + + async def failing_list_log_streams(*_a, **_k): + try: + # Trigger a real pydantic ValidationError so we exercise + # the exact exception type the SDK raises in production. + from okta.models.log_stream_settings_aws import LogStreamSettingsAws + + LogStreamSettingsAws( + accountId="123456789012", + eventSourceName="MultiCharacter", + region="us-east-1", + ) + except ValidationError as ve: + raise ve + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/logStreams"}, None) + + async def fake_raw_execute(_request): + return (None, json.dumps(raw_payload), None) + + sdk = mock.MagicMock() + sdk.list_log_streams = failing_list_log_streams + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = SystemLog(provider) + + assert set(service.log_streams.keys()) == {"log-1", "log-2"} + assert service.log_streams["log-1"].status == "ACTIVE" + assert service.log_streams["log-2"].status == "INACTIVE" + assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING" + + def test_raw_fallback_handles_empty_list(self): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + raise ValidationError.from_exception_data( + title="LogStreamSettingsAws", + line_errors=[], + ) + + async def fake_create(*_a, **_k): + return ({"url": "/api/v1/logStreams"}, None) + + async def fake_execute(_req): + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_log_streams = failing + sdk._request_executor.create_request = fake_create + sdk._request_executor.execute = fake_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = SystemLog(provider) + + assert service.log_streams == {} diff --git a/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py new file mode 100644 index 0000000000..64d2bcb8fc --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py @@ -0,0 +1,73 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.systemlog.systemlog_fixtures import ( + build_systemlog_client, + log_stream, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.systemlog." + "systemlog_streaming_enabled.systemlog_streaming_enabled.systemlog_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.systemlog.systemlog_streaming_enabled.systemlog_streaming_enabled import ( + systemlog_streaming_enabled, + ) + + return systemlog_streaming_enabled().execute() + + +class Test_systemlog_streaming_enabled: + def test_pass_when_active_stream_exists(self): + client = build_systemlog_client( + log_streams={"log-1": log_stream(name="EventBridge prod")} + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "EventBridge prod" in findings[0].status_extended + + def test_pass_when_multiple_active_streams(self): + client = build_systemlog_client( + log_streams={ + "log-1": log_stream(stream_id="log-1", name="A"), + "log-2": log_stream(stream_id="log-2", name="B"), + } + ) + findings = _run_check(client) + assert len(findings) == 2 + assert all(f.status == "PASS" for f in findings) + + def test_fail_when_all_streams_inactive(self): + client = build_systemlog_client( + log_streams={"log-1": log_stream(name="A", status="INACTIVE")} + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "none are ACTIVE" in findings[0].status_extended + + def test_fail_when_no_streams_configured(self): + client = build_systemlog_client(log_streams={}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "No Okta Log Streams are configured" in findings[0].status_extended + assert "mutelist" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_systemlog_client( + missing_scope={"log_streams": "okta.logStreams.read"} + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.logStreams.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/user/user_fixtures.py b/tests/providers/okta/services/user/user_fixtures.py new file mode 100644 index 0000000000..99282c1efa --- /dev/null +++ b/tests/providers/okta/services/user/user_fixtures.py @@ -0,0 +1,55 @@ +"""Shared helpers for `user` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.user.user_service import ( + ExternalDirectoryIdp, + UserAutomation, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_user_client( + automations: dict = None, + external_directory_idps: dict = None, + audit_config: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.automations = automations or {} + client.external_directory_idps = external_directory_idps or {} + client.provider = set_mocked_okta_provider() + client.audit_config = audit_config or {} + client.missing_scope = missing_scope or { + "automations": None, + "identity_providers": None, + } + return client + + +def automation( + automation_id: str = "auto-1", + name: str = "User Inactivity", + status: str = "ACTIVE", + schedule_status: str = "ACTIVE", + inactivity_days: int = 35, + lifecycle_action: str = "SUSPENDED", + groups: list = None, +): + # `groups is None` keeps the "Everyone-equivalent" default; passing + # `groups=[]` lets a test exercise the empty-scope FAIL path. + return UserAutomation( + id=automation_id, + name=name, + status=status, + schedule_status=schedule_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=["everyone"] if groups is None else groups, + ) + + +def ad_idp(idp_id: str = "0oa-ad", name: str = "Corp AD"): + return ExternalDirectoryIdp( + id=idp_id, name=name, type="ACTIVE_DIRECTORY", status="ACTIVE" + ) diff --git a/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py new file mode 100644 index 0000000000..99cb7db6a0 --- /dev/null +++ b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py @@ -0,0 +1,165 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.user.user_fixtures import ( + ad_idp, + automation, + build_user_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.user." + "user_inactivity_automation_35d_enabled." + "user_inactivity_automation_35d_enabled.user_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.user.user_inactivity_automation_35d_enabled.user_inactivity_automation_35d_enabled import ( + user_inactivity_automation_35d_enabled, + ) + + return user_inactivity_automation_35d_enabled().execute() + + +class Test_user_inactivity_automation_35d_enabled: + def test_pass_when_compliant_automation_present(self): + client = build_user_client( + automations={"auto-1": automation(name="Inactivity 35d")} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Inactivity 35d" in findings[0].status_extended + assert "SUSPENDED" in findings[0].status_extended + + def test_pass_message_names_groups_and_asks_for_coverage_verification(self): + # Okta has no built-in Everyone group ID and group names vary by + # tenant (e.g. "pepito"), so we can't assert tenant-wide coverage + # automatically — surface the group IDs and let the operator verify. + client = build_user_client( + automations={"auto-1": automation(groups=["grp-A", "grp-B"])} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "grp-A, grp-B" in findings[0].status_extended + assert "cover every user" in findings[0].status_extended + + def test_fail_when_applies_to_no_group(self): + # An automation with empty `people.groups.include` runs against + # nobody — Okta does not implicitly cover every user. + client = build_user_client(automations={"auto-1": automation(groups=[])}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no group scope" in findings[0].status_extended + + def test_pass_when_lower_threshold(self): + # Inactivity threshold lower than the default is still compliant. + client = build_user_client( + automations={"auto-1": automation(inactivity_days=14)} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_fail_when_threshold_too_high(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=90)} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "inactivity 90d (max 35d)" in findings[0].status_extended + + def test_fail_when_status_inactive(self): + client = build_user_client( + automations={"auto-1": automation(status="INACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "status INACTIVE" in findings[0].status_extended + + def test_fail_when_schedule_inactive(self): + client = build_user_client( + automations={"auto-1": automation(schedule_status="INACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "schedule INACTIVE" in findings[0].status_extended + + def test_fail_when_wrong_lifecycle_action(self): + client = build_user_client( + automations={"auto-1": automation(lifecycle_action="ACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "action ACTIVE" in findings[0].status_extended + + def test_fail_when_no_automations(self): + client = build_user_client(automations={}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "No Okta Workflows automations" in findings[0].status_extended + + def test_fail_lists_every_missing_piece_for_unfinished_automation(self): + # Mirrors the real-world case where an admin clicks "Add Automation" + # in the UI but never configures conditions or actions. The service + # emits a placeholder UserAutomation so the check FAILs with a + # specific message instead of pretending the policy doesn't exist. + from prowler.providers.okta.services.user.user_service import UserAutomation + + shell = UserAutomation( + id="pol-1", + name="TestCheck", + status="INACTIVE", + schedule_status="INACTIVE", + inactivity_days=None, + lifecycle_action=None, + applies_to_groups=[], + policy_id="pol-1", + policy_name="TestCheck", + ) + client = build_user_client(automations={"pol-1": shell}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + msg = findings[0].status_extended + assert "TestCheck" in msg + assert "status INACTIVE" in msg + assert "schedule INACTIVE" in msg + assert "no inactivity condition" in msg + assert "action unset" in msg + + def test_manual_na_when_external_directory_idp_present(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=90)}, # non-compliant + external_directory_idps={"0oa-ad": ad_idp(name="Corp AD")}, + ) + findings = _run_check(client) + # External directory short-circuits to MANUAL N/A regardless of + # the automations state. + assert findings[0].status == "MANUAL" + assert "ACTIVE_DIRECTORY" in findings[0].status_extended + assert "Corp AD" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_user_client( + missing_scope={ + "automations": "okta.policies.read", + "identity_providers": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=60)}, + audit_config={"okta_user_inactivity_max_days": 90}, + ) + findings = _run_check(client) + assert findings[0].status == "PASS" diff --git a/tests/providers/okta/services/user/user_service_test.py b/tests/providers/okta/services/user/user_service_test.py new file mode 100644 index 0000000000..f4e0309b7b --- /dev/null +++ b/tests/providers/okta/services/user/user_service_test.py @@ -0,0 +1,477 @@ +import json +from unittest import mock + +from prowler.providers.okta.services.user.user_service import ( + ExternalDirectoryIdp, + User, + UserAutomation, + _raw_rule_to_automation, + _rule_to_automation, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_policy( + policy_id, + name="Inactivity Policy", + status="ACTIVE", + inactivity_days=35, + inactivity_unit="DAYS", + groups=None, +): + # In the actual API response, the inactivity condition and the + # group scope live on the *policy*, not on its rules — keep the + # typed fixture aligned with that shape so it mirrors raw JSON. + p = mock.MagicMock() + p.id = policy_id + p.name = name + p.status = status + if inactivity_days is None: + p.conditions.people.users.inactivity = None + else: + p.conditions.people.users.inactivity.number = inactivity_days + p.conditions.people.users.inactivity.unit = inactivity_unit + p.conditions.people.groups.include = ["everyone"] if groups is None else groups + return p + + +def _fake_rule( + rule_id="rule-1", + name="Inactivity", + status="ACTIVE", + lifecycle_action="SUSPENDED", +): + # A USER_LIFECYCLE policy rule carries only the lifecycle action; + # its `conditions` is typically empty. + r = mock.MagicMock() + r.id = rule_id + r.name = name + r.status = status + r.actions.user_lifecycle.action = lifecycle_action + return r + + +def _fake_idp(idp_type, status="ACTIVE", idp_id="0oa-1", name="x"): + idp = mock.MagicMock() + idp.id = idp_id + idp.name = name + idp.type = idp_type + idp.status = status + return idp + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_rule_to_automation: + def test_parses_inactivity_and_lifecycle(self): + policy = _fake_policy("pol-1", name="Inactivity Policy") + rule = _fake_rule(rule_id="rule-1", name="Inactivity") + m = _rule_to_automation(rule, policy) + assert isinstance(m, UserAutomation) + assert m.id == "rule-1" + assert m.status == "ACTIVE" + assert m.schedule_status == "ACTIVE" + assert m.inactivity_days == 35 + assert m.lifecycle_action == "SUSPENDED" + assert m.applies_to_groups == ["everyone"] + assert m.policy_id == "pol-1" + assert m.policy_name == "Inactivity Policy" + + def test_returns_none_when_id_missing(self): + policy = _fake_policy("pol") + bad = _fake_rule() + bad.id = "" + assert _rule_to_automation(bad, policy) is None + + def test_ignores_non_days_unit(self): + policy = _fake_policy("pol", inactivity_unit="WEEKS") + rule = _fake_rule() + m = _rule_to_automation(rule, policy) + assert m.inactivity_days is None + + def test_reads_inactivity_and_groups_from_policy_not_rule(self): + # The typed path used to read inactivity/groups from the rule; + # an SDK update that started populating `policy.conditions` + # exposed the mismatch. Locking the policy-shaped projection in. + policy = _fake_policy("pol", inactivity_days=21, groups=["grp-x"]) + rule = _fake_rule() + # Sanity: nothing inactivity-ish on the rule. + del rule.conditions + m = _rule_to_automation(rule, policy) + assert m.inactivity_days == 21 + assert m.applies_to_groups == ["grp-x"] + + +class Test_User_service: + def test_fetches_automations_via_policy_api(self): + provider = set_mocked_okta_provider() + policy = _fake_policy("pol-1") + rule = _fake_rule(rule_id="rule-1") + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_rules(*_a, **_k): + return ([rule], _resp({}), None) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = fake_list_policies + sdk.list_policy_rules = fake_list_rules + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "rule-1" in service.automations + assert service.automations["rule-1"].inactivity_days == 35 + assert service.external_directory_idps == {} + + def test_returns_empty_on_policies_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("E0000007")) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = failing + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert service.automations == {} + + def test_detects_external_directory_idp(self): + provider = set_mocked_okta_provider() + + async def empty_policies(*_a, **_k): + return ([], _resp({}), None) + + ad = _fake_idp("ACTIVE_DIRECTORY", idp_id="0oa-ad", name="Corp AD") + saml = _fake_idp("SAML2", idp_id="0oa-saml") + + async def fake_list_idps(*_a, **_k): + return ([ad, saml], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = empty_policies + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "0oa-ad" in service.external_directory_idps + assert "0oa-saml" not in service.external_directory_idps + assert isinstance( + service.external_directory_idps["0oa-ad"], ExternalDirectoryIdp + ) + + +class Test_raw_rule_to_automation: + def test_projects_inactivity_and_lifecycle(self): + # Real API shape: inactivity + groups live on the POLICY, + # lifecycle action lives on the RULE under + # `actions.updateUserLifecycle.targetStatus`. + policy = { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + "type": "USER_LIFECYCLE", + } + rule = { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "conditions": {}, + "actions": { + "updateUserLifecycle": { + "targetStatus": "SUSPENDED", + "quietPeriod": {"number": 0, "unit": "DAYS"}, + } + }, + } + m = _raw_rule_to_automation(rule, policy, "pol-1", "TestCheck", "ACTIVE") + assert isinstance(m, UserAutomation) + assert m.id == "rule-1" + assert m.status == "ACTIVE" + assert m.schedule_status == "ACTIVE" + assert m.inactivity_days == 35 + assert m.lifecycle_action == "SUSPENDED" + assert m.applies_to_groups == ["everyone"] + assert m.policy_id == "pol-1" + assert m.policy_name == "TestCheck" + + def test_returns_none_when_id_missing(self): + assert _raw_rule_to_automation({"name": "x"}, {}, "pol", "P", "ACTIVE") is None + + def test_ignores_non_days_unit(self): + policy = { + "id": "pol", + "conditions": { + "people": {"users": {"inactivity": {"number": 5, "unit": "WEEKS"}}} + }, + } + rule = {"id": "rule-2", "actions": {}} + m = _raw_rule_to_automation(rule, policy, "pol", "P", "ACTIVE") + assert m.inactivity_days is None + + def test_missing_policy_dict_gives_empty_inactivity_and_groups(self): + rule = { + "id": "rule-3", + "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}}, + } + m = _raw_rule_to_automation(rule, None, "pol", "P", "ACTIVE") + assert m.inactivity_days is None + assert m.applies_to_groups == [] + assert m.lifecycle_action == "SUSPENDED" + + +class Test_User_service_sdk_discriminator_fallback: + """Verifies the raw-JSON fallback when the SDK can't deserialize USER_LIFECYCLE. + + Okta SDK 3.4.2 ships a `Policy.from_dict` discriminator mapping that + omits `USER_LIFECYCLE`, so the typed call raises ValueError. Without + the fallback the whole automations list is lost; with it the raw + JSON path projects each rule onto a `UserAutomation` snapshot. + """ + + def test_raw_fallback_projects_user_lifecycle_policy_rules(self): + provider = set_mocked_okta_provider() + + # Real API shape: inactivity + groups on POLICY, lifecycle + # action on RULE under `actions.updateUserLifecycle.targetStatus`. + policy_payload = [ + { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "type": "USER_LIFECYCLE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + } + ] + rules_payload = [ + { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "conditions": {}, + "actions": { + "updateUserLifecycle": { + "targetStatus": "SUSPENDED", + "quietPeriod": {"number": 0, "unit": "DAYS"}, + } + }, + } + ] + + async def failing_list_policies(*_a, **_k): + raise ValueError( + "Policy failed to lookup discriminator value from {...}. " + "Discriminator property name: type, mapping: {...}" + ) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **kwargs): + url = kwargs.get("url", "") or "" + return ({"url": url}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-1/rules" in url: + return (None, json.dumps(rules_payload), None) + if "/api/v1/policies" in url: + return (None, json.dumps(policy_payload), None) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = failing_list_policies + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "rule-1" in service.automations + a = service.automations["rule-1"] + assert a.inactivity_days == 35 + assert a.lifecycle_action == "SUSPENDED" + assert a.schedule_status == "ACTIVE" + assert a.policy_id == "pol-1" + assert a.policy_name == "TestCheck" + + def test_raw_fallback_emits_shell_for_policy_with_no_rules(self): + # Mirrors the real-world tenant state where an admin clicked + # "Add Automation" in the UI but never configured conditions or + # actions. The policy exists; it has zero rules. The raw fallback + # must surface the policy as a shell UserAutomation so the check + # FAILs with a specific message instead of dropping it. + provider = set_mocked_okta_provider() + + async def failing_list_policies(*_a, **_k): + raise ValueError("missing discriminator mapping") + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "") or ""}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-empty/rules" in url: + return (None, "[]", None) + if "/api/v1/policies" in url: + return ( + None, + json.dumps( + [ + { + "id": "pol-empty", + "name": "TestCheck", + "status": "INACTIVE", + "type": "USER_LIFECYCLE", + } + ] + ), + None, + ) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = failing_list_policies + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "pol-empty" in service.automations + shell = service.automations["pol-empty"] + assert shell.name == "TestCheck" + assert shell.status == "INACTIVE" + assert shell.schedule_status == "INACTIVE" + assert shell.inactivity_days is None + assert shell.lifecycle_action is None + assert shell.applies_to_groups == [] + assert shell.policy_id == "pol-empty" + + def test_rule_typed_failure_triggers_raw_fallback_for_all_policies(self): + # When the typed `list_policies` succeeds but the typed + # `list_policy_rules` fails for a policy, the previous behavior + # was to emit a shell automation — silently misclassifying a + # valid automation as "unfinished". Now `_fetch_rules` returns + # None as a sentinel and the caller re-runs the entire + # discovery via raw JSON so no rule data is lost. + provider = set_mocked_okta_provider() + + typed_policy = _fake_policy( + "pol-1", name="TestCheck", inactivity_days=35, groups=["everyone"] + ) + + async def fake_list_policies(*_a, **_k): + return ([typed_policy], _resp({}), None) + + async def failing_list_policy_rules(*_a, **_k): + raise ValueError("KnowledgeConstraint.types expected uppercase") + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + raw_policy_payload = [ + { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "type": "USER_LIFECYCLE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + } + ] + raw_rules_payload = [ + { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}}, + } + ] + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "") or ""}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-1/rules" in url: + return (None, json.dumps(raw_rules_payload), None) + if "/api/v1/policies" in url: + return (None, json.dumps(raw_policy_payload), None) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = fake_list_policies + sdk.list_policy_rules = failing_list_policy_rules + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + # Raw-projected automation, not a shell. + assert "rule-1" in service.automations + assert service.automations["rule-1"].inactivity_days == 35 + assert service.automations["rule-1"].lifecycle_action == "SUSPENDED" diff --git a/tests/providers/openstack/openstack_provider_test.py b/tests/providers/openstack/openstack_provider_test.py index 654d109134..b36fcd97a5 100644 --- a/tests/providers/openstack/openstack_provider_test.py +++ b/tests/providers/openstack/openstack_provider_test.py @@ -631,8 +631,7 @@ class TestOpenstackProviderCloudsYaml: def test_clouds_yaml_explicit_file_path(self, tmp_path): """Test loading clouds.yaml from an explicit file path.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -644,8 +643,7 @@ clouds: project_domain_name: YamlProjectDomain region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -681,8 +679,7 @@ clouds: def test_clouds_yaml_with_explicit_cloud_name(self, tmp_path): """Test loading clouds.yaml with an explicit cloud name.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: default-cloud: auth: @@ -692,8 +689,7 @@ clouds: project_id: default-project-id region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -719,8 +715,7 @@ clouds: def test_clouds_yaml_file_without_cloud_name(self, tmp_path): """Test error when clouds.yaml file is provided without cloud name.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -729,8 +724,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError) as excinfo: OpenstackProvider(clouds_yaml_file=str(clouds_yaml)) @@ -750,8 +744,7 @@ clouds: def test_clouds_yaml_cloud_not_found(self, tmp_path): """Test error when specified cloud is not in clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: existing-cloud: auth: @@ -760,8 +753,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackCloudNotFoundError) as excinfo: OpenstackProvider( @@ -774,8 +766,7 @@ clouds: def test_clouds_yaml_missing_required_fields(self, tmp_path): """Test error when clouds.yaml is missing required fields.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: incomplete-cloud: auth: @@ -783,8 +774,7 @@ clouds: username: test-user # Missing password and other required fields region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError) as excinfo: OpenstackProvider( @@ -798,16 +788,14 @@ clouds: def test_clouds_yaml_malformed_yaml(self, tmp_path): """Test error when clouds.yaml is malformed.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: malformed-cloud: auth: auth_url: https://openstack.example.com:5000/v3 username: test-user - invalid: yaml: structure -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError): OpenstackProvider( @@ -818,8 +806,7 @@ clouds: def test_clouds_yaml_with_project_name(self, tmp_path): """Test clouds.yaml using project_name instead of project_id.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -831,8 +818,7 @@ clouds: project_domain_name: Default region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -863,8 +849,7 @@ clouds: monkeypatch.setenv("OS_REGION_NAME", "EnvRegion") clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -874,8 +859,7 @@ clouds: project_id: yaml-project-id region_name: YamlRegion identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -902,8 +886,7 @@ clouds: def test_test_connection_with_clouds_yaml(self, tmp_path): """Test static test_connection method with clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -913,8 +896,7 @@ clouds: project_id: test-project-id region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -950,8 +932,7 @@ clouds: def test_test_connection_clouds_yaml_cloud_not_found(self, tmp_path): """Test test_connection error when cloud is not in clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: existing-cloud: auth: @@ -960,8 +941,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") connection_result = OpenstackProvider.test_connection( clouds_yaml_file=str(clouds_yaml), @@ -1139,8 +1119,7 @@ clouds: def test_clouds_yaml_file_with_regions_list(self, tmp_path): """Test loading clouds.yaml file with regions list.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1152,8 +1131,7 @@ clouds: - RegionOne - RegionTwo identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -1177,8 +1155,7 @@ clouds: def test_clouds_yaml_file_with_both_regions_raises_error(self, tmp_path): """Test that clouds.yaml file with both region_name and regions raises error.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1191,8 +1168,7 @@ clouds: - RegionOne - RegionTwo identity_api_version: 3 -""" - ) +""") with pytest.raises(OpenStackAmbiguousRegionError): OpenstackProvider( @@ -1203,8 +1179,7 @@ clouds: def test_clouds_yaml_file_with_no_region_raises_error(self, tmp_path): """Test that clouds.yaml file with neither region_name nor regions raises error.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1213,8 +1188,7 @@ clouds: password: test-password project_id: test-project-id identity_api_version: 3 -""" - ) +""") with pytest.raises(OpenStackNoRegionError): OpenstackProvider( diff --git a/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py b/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py index 6be9ac48f5..85b97da0a2 100644 --- a/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/blockstorage/blockstorage_snapshot_metadata_sensitive_data/blockstorage_snapshot_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.blockstorage.blockstorage_service import ( SnapshotResource, ) @@ -141,7 +142,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-1", - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ) @@ -179,7 +180,9 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-1", - metadata={"api_key": "sk-1234567890"}, + metadata={ + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ) @@ -223,7 +226,9 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-1", - metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"}, + metadata={ + "ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n" + }, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ) @@ -277,7 +282,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: status="available", size=50, volume_id="vol-2", - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, project_id=OPENSTACK_PROJECT_ID, region=OPENSTACK_REGION, ), @@ -318,7 +323,7 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: metadata={ "environment": "production", "application": "web-app", - "db_password": "supersecret123", + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "region": "us-east", }, project_id=OPENSTACK_PROJECT_ID, @@ -348,3 +353,57 @@ class Test_blockstorage_snapshot_metadata_sensitive_data: # Verify the secret is correctly attributed to 'db_password' key assert "in metadata key 'db_password'" in result[0].status_extended assert result[0].resource_id == "snap-6" + + def test_snapshot_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + blockstorage_client = mock.MagicMock() + blockstorage_client.audit_config = {"secrets_validate": True} + blockstorage_client.snapshots = [ + SnapshotResource( + id="snap-verified", + name="Verified Secret", + status="available", + size=50, + volume_id="vol-1", + metadata={"api_key": "placeholder"}, + project_id=OPENSTACK_PROJECT_ID, + region=OPENSTACK_REGION, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data.blockstorage_client", + new=blockstorage_client, + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.blockstorage.blockstorage_snapshot_metadata_sensitive_data.blockstorage_snapshot_metadata_sensitive_data import ( + blockstorage_snapshot_metadata_sensitive_data, + ) + + check = blockstorage_snapshot_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True diff --git a/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py b/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py index e12babb23c..80927e2f9d 100644 --- a/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/blockstorage/blockstorage_volume_metadata_sensitive_data/blockstorage_volume_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.blockstorage.blockstorage_service import ( VolumeResource, ) @@ -159,7 +160,7 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -204,7 +205,9 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"api_key": "sk-1234567890"}, + metadata={ + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -255,7 +258,9 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"}, + metadata={ + "ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n" + }, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -323,7 +328,7 @@ class Test_blockstorage_volume_metadata_sensitive_data: is_bootable=False, is_multiattach=False, attachments=[], - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, availability_zone="nova", snapshot_id="", source_volume_id="", @@ -371,7 +376,7 @@ class Test_blockstorage_volume_metadata_sensitive_data: metadata={ "environment": "production", "application": "web-app", - "db_password": "supersecret123", + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "region": "us-east", }, availability_zone="nova", @@ -404,3 +409,64 @@ class Test_blockstorage_volume_metadata_sensitive_data: # Verify the secret is correctly attributed to 'db_password' key assert "in metadata key 'db_password'" in result[0].status_extended assert result[0].resource_id == "vol-6" + + def test_volume_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + blockstorage_client = mock.MagicMock() + blockstorage_client.audit_config = {"secrets_validate": True} + blockstorage_client.volumes = [ + VolumeResource( + id="vol-verified", + name="Verified Secret", + status="in-use", + size=100, + volume_type="standard", + is_encrypted=False, + is_bootable=False, + is_multiattach=False, + attachments=[], + metadata={"api_key": "placeholder"}, + availability_zone="nova", + snapshot_id="", + source_volume_id="", + project_id=OPENSTACK_PROJECT_ID, + region=OPENSTACK_REGION, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data.blockstorage_client", + new=blockstorage_client, + ), + mock.patch( + "prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.blockstorage.blockstorage_volume_metadata_sensitive_data.blockstorage_volume_metadata_sensitive_data import ( + blockstorage_volume_metadata_sensitive_data, + ) + + check = blockstorage_volume_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True diff --git a/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py b/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py index 174a8ab83f..daaffffb1f 100644 --- a/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/compute/compute_instance_metadata_sensitive_data/compute_instance_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.compute.compute_service import ComputeInstance from tests.providers.openstack.openstack_fixtures import ( OPENSTACK_PROJECT_ID, @@ -181,7 +182,7 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, user_data="", trusted_image_certificates=[], ) @@ -233,7 +234,9 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"api_key": "sk-1234567890"}, + metadata={ + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, user_data="", trusted_image_certificates=[], ) @@ -349,7 +352,9 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"ssh_key": "-----BEGIN RSA PRIVATE KEY-----"}, + metadata={ + "ssh_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUzlT9QGi8ZSr5\nk+LTRz/1TaiCCs6o1icW4cur0Q0hdBnbRJXUdjlQsgzmBvCBNkGHI8hb/RUPssvc\nDLU5kOQ3Wp2KgtbphhZ2PfpuJrzwHL1ejcJkRxegm/aTdmpoQKcxGeehAfHbmlLA\nxdfn6wPDfGji973yiRH56JRukJAaqF50HC2a/AVNC5HtZoVlbQ+WvVbYVUnPxNkv\nPpc53PjrBgWiTtdMONEqJ3jDiaqfUBt+TZYF0CFc9HgjnUniRX28OukDyLu+idOz\nFKyZxMXtqexkAvQLDW1PATpZgVQ7hJoCD8UVTXAtcgzPq5fA6AR2URiECHI6ZyL0\nUmixKfMNAgMBAAECggEAJRzp5wjdpmEgDQOkjpfGXJ6sAJUD8mmI8cTKeJWIzhdo\nDH8oVEdRJ65kl6lS6hMXWEZlJgYyrsnj3MPBnjQkKycbRCy6P59s8jwmfbsFI+iz\nFUZLXZm6i5jicGhYBRzc5hrlIYu73863RXOClAnSFDsu6K6rzfYASQFIJeRBwJfs\njqXinuun/h2zGjpiY+TtNsa8c+nC7f3sGsTzNJugDvBPWQzsnAMzXJqiyharre4V\no157XIOvdC0joIp8j/Ib1ZtMfz1K1LcgBgw0szSieIw0Rq8yQ0Ek7GtLh43jG+ap\nvcSEesTD1p4mjPXoWkPG8KYd4iwGedZaePfheVcKKQKBgQDNE03SWv18AH0d4fpB\nlFAtRybCfSvMORzBrt2oilz8wDmK+Zga5o+phCnM8v3eJy1v8BvIQ9RvwQA2uVgZ\nr701wNMpVrTsMujk83oVRhimZLk6Hyw07wmMgEHX7+izkm2Lk4Lk7Zol3VRfnWG6\nmIcUk7xB1yAs3mudsfx0VO0QyQKBgQC5wfdqCLj2hZk4sMZu8Bth+BHKChGItmDk\nAW7aNt+gaPyoryOJoi2OUO8ud8EyuqXiuslSk2pPtjvLhCppkoq6V8kmPAUzaxFk\n4nDEAxT9Un8IJ0j2ebv+koQKsBWjssbVSjrZgIcYIDK1QblgbCp2FSE3ima+V8ip\nOdNjiatWJQKBgEX8lox5nRSanhh6rIuA8DPjmmi5ix7xRs0avm7seXuQppK1R6G2\nmcTCY/mb2+Pa/vi6uuCHtZJGDaqfal+pyCr2GZp8CtapMS4hocJs37C5ozUguld+\nVIXsp4voRkQybsw5lWxHYloVxNu0vEuQDlmJabAWmNZ3OcbhnUSeTyFxAoGAFtkZ\n0owCHChwoT11Gt4jsBgwL/avE27DWigm92Y6eWOQeDsalupAyjmAQenu9Itqrgml\ni6egMu/KSQ0Xnmas86CqmC5XwWxQ9mS31BRA96u2/ky+t7pfej+RSDNCZiEuPbvk\noy4g78G+GvdbktWbH20X6dn3K0Bm6RG4w4yCa5UCgYBs0zAVs0DZmM8SUZJA/HuQ\nN6a1vKKns7xKw5N3SmX1KbDhx5LSZXfbUo2+QktE7iRf9G2f1o0q8kz9l/4AGXi1\nKJNUHupWoaQzGNrzAb27TUtFA0ocMG8KnqxjANWox5oPJS9OU5tw5H5dxeI/Senc\nkYW6eCnRzPcmBqex6Vuw4w==\n-----END PRIVATE KEY-----\n" + }, user_data="", trusted_image_certificates=[], ) @@ -431,7 +436,7 @@ class Test_compute_instance_metadata_sensitive_data: private_v6="", networks={}, has_config_drive=False, - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, user_data="", trusted_image_certificates=[], ), @@ -486,7 +491,7 @@ class Test_compute_instance_metadata_sensitive_data: metadata={ "environment": "production", "application": "web-app", - "db_password": "supersecret123", + "db_password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "region": "us-east", }, user_data="", @@ -544,7 +549,7 @@ class Test_compute_instance_metadata_sensitive_data: has_config_drive=False, metadata={ "first_key": "safe_value", - "api_key": "sk-1234567890abcdef", + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "third_key": "also_safe", }, user_data="", @@ -574,3 +579,128 @@ class Test_compute_instance_metadata_sensitive_data: # Verify the secret is correctly attributed to 'api_key' key (second in order) assert "in metadata key 'api_key'" in result[0].status_extended assert result[0].resource_id == "instance-8" + + def test_instance_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + compute_client = mock.MagicMock() + compute_client.audit_config = {"secrets_validate": True} + compute_client.instances = [ + ComputeInstance( + id="instance-verified", + name="Verified Secret", + status="ACTIVE", + flavor_id="flavor-1", + security_groups=["default"], + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + is_locked=False, + locked_reason="", + key_name="", + user_id="", + access_ipv4="", + access_ipv6="", + public_v4="", + public_v6="", + private_v4="", + private_v6="", + networks={}, + has_config_drive=False, + metadata={"api_key": "placeholder"}, + user_data="", + trusted_image_certificates=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.compute_client", + new=compute_client, + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data import ( + compute_instance_metadata_sensitive_data, + ) + + check = compute_instance_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True + + def test_scan_failure_reports_manual(self): + from prowler.lib.utils.utils import SecretsScanError + + compute_client = mock.MagicMock() + compute_client.audit_config = {"secrets_ignore_patterns": []} + compute_client.instances = [ + ComputeInstance( + id="instance-scan-fail", + name="Scan Fail", + status="ACTIVE", + flavor_id="flavor-1", + security_groups=["default"], + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + is_locked=False, + locked_reason="", + key_name="", + user_id="", + access_ipv4="", + access_ipv6="", + public_v4="", + public_v6="", + private_v4="", + private_v6="", + networks={}, + has_config_drive=False, + metadata={"api_key": "placeholder"}, + user_data="", + trusted_image_certificates=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.compute_client", + new=compute_client, + ), + mock.patch( + "prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.openstack.services.compute.compute_instance_metadata_sensitive_data.compute_instance_metadata_sensitive_data import ( + compute_instance_metadata_sensitive_data, + ) + + check = compute_instance_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not scan" in result[0].status_extended diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py index 6cae6432c7..eb92274232 100644 --- a/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py @@ -2,6 +2,7 @@ from unittest import mock +from prowler.lib.check.models import Severity from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( ObjectStorageContainer, ) @@ -157,7 +158,7 @@ class Test_objectstorage_container_metadata_sensitive_data: history_location="", sync_to="", sync_key="", - metadata={"db_password": "supersecret123"}, + metadata={"db_password": "Tr0ub4dor3xKq9vLmZ"}, ) ] @@ -217,7 +218,7 @@ class Test_objectstorage_container_metadata_sensitive_data: history_location="", sync_to="", sync_key="", - metadata={"admin_password": "secret123"}, + metadata={"admin_password": "Tr0ub4dor3xKq9vLmZ"}, ), ] @@ -241,3 +242,63 @@ class Test_objectstorage_container_metadata_sensitive_data: assert len(result) == 2 assert len([r for r in result if r.status == "PASS"]) == 1 assert len([r for r in result if r.status == "FAIL"]) == 1 + + def test_container_verified_secret_escalates_to_critical(self): + """Test that a confirmed live secret escalates the finding to CRITICAL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.audit_config = {"secrets_validate": True} + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-verified", + name="verified-secret", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={"api_key": "placeholder"}, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_openstack_provider(), + ), + mock.patch( + "prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data.objectstorage_client", + new=objectstorage_client, + ), + mock.patch( + "prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data.detect_secrets_scan_batch", + return_value={ + 0: [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 2, + "filename": "data", + "hashed_secret": "x", + "is_verified": True, + } + ] + }, + ) as mock_scan, + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_metadata_sensitive_data.objectstorage_container_metadata_sensitive_data import ( + objectstorage_container_metadata_sensitive_data, + ) + + check = objectstorage_container_metadata_sensitive_data() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + assert mock_scan.call_args.kwargs.get("validate") is True diff --git a/tests/providers/oraclecloud/oci_fixtures.py b/tests/providers/oraclecloud/oci_fixtures.py index b1ebef62d9..5d01bc5855 100644 --- a/tests/providers/oraclecloud/oci_fixtures.py +++ b/tests/providers/oraclecloud/oci_fixtures.py @@ -37,6 +37,7 @@ def set_mocked_oraclecloud_provider( signer=MagicMock(), profile="DEFAULT", ) + provider.home_region = region # Mock identity provider.identity = OCIIdentityInfo( diff --git a/tests/providers/oraclecloud/oraclecloud_provider_test.py b/tests/providers/oraclecloud/oraclecloud_provider_test.py index dd3b7b7d27..7c437a35ac 100644 --- a/tests/providers/oraclecloud/oraclecloud_provider_test.py +++ b/tests/providers/oraclecloud/oraclecloud_provider_test.py @@ -6,7 +6,7 @@ from prowler.providers.oraclecloud.exceptions.exceptions import ( OCIAuthenticationError, OCIInvalidConfigError, ) -from prowler.providers.oraclecloud.models import OCISession +from prowler.providers.oraclecloud.models import OCIIdentityInfo, OCIRegion, OCISession from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider @@ -199,3 +199,117 @@ MIIEpQIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8n0sMcD/QHWCJ7yGSEtLN2T ) assert connection.is_connected is True + + +class TestOraclecloudProviderInit: + """Tests for OraclecloudProvider initialization""" + + def test_init_with_region_set_populates_provider_state(self): + mock_session = OCISession( + config={"region": "us-ashburn-1"}, signer=None, profile="DEFAULT" + ) + mock_identity = OCIIdentityInfo( + tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample", + tenancy_name="test-tenancy", + user_id="ocid1.user.oc1..aaaaaaaexample", + region="us-ashburn-1", + profile="DEFAULT", + audited_regions=set(), + audited_compartments=[], + ) + mock_regions = [ + OCIRegion(key="us-phoenix-1", name="us-phoenix-1", is_home_region=False), + OCIRegion(key="us-ashburn-1", name="us-ashburn-1", is_home_region=True), + ] + mock_compartments = ["ocid1.compartment.oc1..aaaaaaaexample"] + with ( + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session", + return_value=mock_session, + ) as mock_setup_session, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity", + return_value=mock_identity, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit", + return_value=mock_regions, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit", + return_value=mock_compartments, + ), + patch( + "prowler.providers.common.provider.Provider.set_global_provider" + ) as mock_set_global, + ): + provider = OraclecloudProvider( + region={"us-ashburn-1"}, + config_content={"dummy": True}, + mutelist_content={"Accounts": {}}, + ) + assert mock_setup_session.call_args.kwargs["region"] == "us-ashburn-1" + assert provider.session == mock_session + assert provider.identity == mock_identity + assert provider.regions == mock_regions + assert provider.compartments == mock_compartments + assert provider.home_region == "us-ashburn-1" + mock_set_global.assert_called_once_with(provider) + + def test_home_region_uses_full_subscription_list_not_region_filter(self): + """Home region must come from the full subscription list, not the --region filter. + + When auditing a single non-home region, the tenancy home region must still be + resolved correctly so tenancy-level APIs (e.g. the Audit configuration) target it. + """ + mock_session = OCISession( + config={"region": "eu-frankfurt-1"}, signer=None, profile="DEFAULT" + ) + mock_identity = OCIIdentityInfo( + tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample", + tenancy_name="test-tenancy", + user_id="ocid1.user.oc1..aaaaaaaexample", + region="eu-frankfurt-1", + profile="DEFAULT", + audited_regions=set(), + audited_compartments=[], + ) + # The audited set is the non-home region; the full subscription list includes home + audited_regions = [ + OCIRegion( + key="eu-frankfurt-1", name="eu-frankfurt-1", is_home_region=False + ), + ] + all_subscribed_regions = [ + OCIRegion( + key="eu-frankfurt-1", name="eu-frankfurt-1", is_home_region=False + ), + OCIRegion(key="us-ashburn-1", name="us-ashburn-1", is_home_region=True), + ] + with ( + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session", + return_value=mock_session, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity", + return_value=mock_identity, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit", + side_effect=[audited_regions, all_subscribed_regions], + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit", + return_value=["ocid1.compartment.oc1..aaaaaaaexample"], + ), + patch("prowler.providers.common.provider.Provider.set_global_provider"), + ): + provider = OraclecloudProvider( + region={"eu-frankfurt-1"}, + config_content={"dummy": True}, + mutelist_content={"Accounts": {}}, + ) + + assert provider.regions == audited_regions + assert provider.home_region == "us-ashburn-1" diff --git a/tests/providers/oraclecloud/services/audit/audit_service_test.py b/tests/providers/oraclecloud/services/audit/audit_service_test.py index ab3710fa3d..9a3e73f9fb 100644 --- a/tests/providers/oraclecloud/services/audit/audit_service_test.py +++ b/tests/providers/oraclecloud/services/audit/audit_service_test.py @@ -1,6 +1,11 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from tests.providers.oraclecloud.oci_fixtures import set_mocked_oraclecloud_provider +import oci + +from tests.providers.oraclecloud.oci_fixtures import ( + OCI_TENANCY_ID, + set_mocked_oraclecloud_provider, +) class TestAuditService: @@ -8,21 +13,58 @@ class TestAuditService: """Test that audit service can be instantiated and mocked""" oraclecloud_provider = set_mocked_oraclecloud_provider() - # Mock the entire service initialization - with patch( - "prowler.providers.oraclecloud.services.audit.audit_service.Audit.__init__", - return_value=None, - ): - from prowler.providers.oraclecloud.services.audit.audit_service import Audit + mock_config_response = MagicMock() + mock_config_response.data.retention_period_days = 365 + mock_audit_client = MagicMock() + mock_audit_client.get_configuration.return_value = mock_config_response + + from prowler.providers.oraclecloud.services.audit.audit_service import Audit + + with patch.object(Audit, "_create_oci_client", return_value=mock_audit_client): audit_client = Audit(oraclecloud_provider) - # Manually set required attributes since __init__ was mocked - audit_client.service = "audit" - audit_client.provider = oraclecloud_provider - audit_client.audited_compartments = {} - audit_client.regional_clients = {} - - # Verify service name assert audit_client.service == "audit" assert audit_client.provider == oraclecloud_provider + + def test_get_configuration_uses_home_region_not_configured_region(self): + """Test Audit uses the tenancy home region instead of the configured region.""" + oraclecloud_provider = set_mocked_oraclecloud_provider(region="eu-frankfurt-1") + # The tenancy home region differs from the configured session region + oraclecloud_provider.home_region = "us-ashburn-1" + mock_config_response = MagicMock() + mock_config_response.data.retention_period_days = 365 + + mock_audit_client = MagicMock() + mock_audit_client.get_configuration.return_value = mock_config_response + + from prowler.providers.oraclecloud.services.audit.audit_service import Audit + + with patch.object( + Audit, "_create_oci_client", return_value=mock_audit_client + ) as mock_create_oci_client: + audit = Audit(oraclecloud_provider) + + mock_create_oci_client.assert_called_once_with( + oci.audit.AuditClient, + config_overrides={"region": "us-ashburn-1"}, + ) + assert audit.configuration is not None + assert audit.configuration.retention_period_days == 365 + assert audit.configuration.compartment_id == OCI_TENANCY_ID + + def test_get_configuration_handles_api_error(self): + """Test audit configuration falls back to 90 days on API errors.""" + oraclecloud_provider = set_mocked_oraclecloud_provider() + + mock_audit_client = MagicMock() + mock_audit_client.get_configuration.side_effect = Exception("404 Not Found") + + from prowler.providers.oraclecloud.services.audit.audit_service import Audit + + with patch.object(Audit, "_create_oci_client", return_value=mock_audit_client): + audit = Audit(oraclecloud_provider) + + assert audit.configuration is not None + assert audit.configuration.retention_period_days == 90 + assert audit.configuration.compartment_id == OCI_TENANCY_ID diff --git a/tests/providers/oraclecloud/services/identity/identity_service_test.py b/tests/providers/oraclecloud/services/identity/identity_service_test.py index bb695d7128..338a025d52 100644 --- a/tests/providers/oraclecloud/services/identity/identity_service_test.py +++ b/tests/providers/oraclecloud/services/identity/identity_service_test.py @@ -1,4 +1,7 @@ -from unittest.mock import patch +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from threading import Lock +from unittest.mock import MagicMock, patch from tests.providers.oraclecloud.oci_fixtures import set_mocked_oraclecloud_provider @@ -28,3 +31,184 @@ class TestIdentityService: # Verify service name assert identity_client.service == "identity" assert identity_client.provider == oraclecloud_provider + + def test_list_domains_passwords_skipped_outside_home(self): + """Domains should be skipped when not in home region.""" + with patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__init__", + return_value=None, + ): + from prowler.providers.oraclecloud.services.identity.identity_service import ( + Identity, + ) + + identity_client = Identity(None) + identity_client.service = "identity" + identity_client.provider = set_mocked_oraclecloud_provider() + identity_client.provider._home_region = "us-ashburn-1" + identity_client.audited_compartments = [ + MagicMock(id="ocid1.compartment.oc1..aaaaaaaexample") + ] + identity_client.domains = [] + identity_client._domains_lock = Lock() + identity_client.session_signer = None + identity_client.session_config = None + regional_client_ash = MagicMock() + regional_client_ash.region = "us-ashburn-1" + regional_client_chi = MagicMock() + regional_client_chi.region = "us-chicago-1" + + policy = MagicMock() + policy.id = "123" + policy.name = "Test Policy" + policy.description = "This is a test policy" + policy.min_length = 8 + policy.password_expires_after = 90 + policy.num_passwords_in_history = 5 + policy.password_expire_warning = 7 + policy.min_password_age = 1 + + domains = [] + for region in ["us-phoenix-1", "us-ashburn-1", "us-chicago-1"]: + domain = MagicMock() + domain.id = ( + "ocid1.domain.oc1.iad.aaaaaaaaexampleuniqueID" + if region == "us-chicago-1" + else "ocid1.domain.oc1.iad.aaaaaaaaexampleuniqueID2" + ) + domain.display_name = "exampledomain" + domain.description = "example" + domain.url = "https://idcs-example.identity.oraclecloud.com" + domain.home_region = region + domain.region = "us-ashburn-1" + domain.lifecycle_state = "ACTIVE" + domain.time_created = datetime.now() + domains.append(domain) + with ( + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__get_client__", + return_value=MagicMock(), + ), + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.oci.pagination.list_call_get_all_results", + return_value=MagicMock(data=domains), + ), + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.oci.identity_domains.IdentityDomainsClient", + return_value=MagicMock( + list_password_policies=lambda: MagicMock( + data=MagicMock(resources=[policy]) + ) + ), + ), + ): + identity_client.__list_domains__(regional_client_ash) + identity_client.__list_domains__(regional_client_chi) + identity_client.__list_domain_password_policies__(regional_client_ash) + identity_client.__list_domain_password_policies__(regional_client_chi) + + assert ( + len(identity_client.domains) == 2 + and any( + domain.home_region == "us-ashburn-1" + and domain.region == "us-ashburn-1" + for domain in identity_client.domains + ) + and any( + domain.home_region == "us-chicago-1" + and domain.region == "us-chicago-1" + for domain in identity_client.domains + ) + and all(len(d.password_policies) == 1 for d in identity_client.domains) + ) + + def test_list_domains_concurrent_dedupes_and_prefers_home_region(self): + """__list_domains__ runs across regions in parallel; the dedupe + must stay correct under concurrent calls (no duplicates, home + region wins).""" + with patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__init__", + return_value=None, + ): + from prowler.providers.oraclecloud.services.identity.identity_service import ( + Identity, + ) + + identity_client = Identity(None) + identity_client.service = "identity" + identity_client.provider = set_mocked_oraclecloud_provider() + identity_client.audited_compartments = [ + MagicMock(id="ocid1.compartment.oc1..aaaaaaaexample") + ] + identity_client.domains = [] + identity_client._domains_lock = Lock() + identity_client.session_signer = None + identity_client.session_config = None + + regions = [ + "us-ashburn-1", + "us-chicago-1", + "us-phoenix-1", + "eu-frankfurt-1", + ] + home_region_by_domain = { + "ocid1.domain.oc1..domainA": "us-ashburn-1", + "ocid1.domain.oc1..domainB": "us-chicago-1", + "ocid1.domain.oc1..domainC": "eu-frankfurt-1", + } + + # Each region returns the same set of domains (every domain + # is visible from every region; only one of those regions is + # actually the domain's home region). + def make_domains_for_region(_region): + ds = [] + for domain_id, home_region in home_region_by_domain.items(): + d = MagicMock() + d.id = domain_id + d.display_name = f"name-{domain_id}" + d.description = "" + d.url = "https://example.identity.oraclecloud.com" + d.home_region = home_region + d.lifecycle_state = "ACTIVE" + d.time_created = datetime.now() + ds.append(d) + return MagicMock(data=ds) + + regional_clients = [] + for region in regions: + rc = MagicMock() + rc.region = region + regional_clients.append(rc) + + with ( + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.Identity.__get_client__", + return_value=MagicMock(), + ), + patch( + "prowler.providers.oraclecloud.services.identity.identity_service.oci.pagination.list_call_get_all_results", + side_effect=lambda _list_call, compartment_id, lifecycle_state: make_domains_for_region( + compartment_id + ), + ), + ): + # Run several iterations to make any race more likely + # to surface; with the lock removed this loop fails + # frequently with duplicates. + for _ in range(20): + identity_client.domains = [] + with ThreadPoolExecutor( + max_workers=len(regional_clients) + ) as executor: + futures = [ + executor.submit(identity_client.__list_domains__, rc) + for rc in regional_clients + ] + for f in futures: + f.result() + + assert len(identity_client.domains) == len(home_region_by_domain) + by_id = {d.id: d for d in identity_client.domains} + for domain_id, home_region in home_region_by_domain.items(): + assert by_id[domain_id].region == home_region + assert by_id[domain_id].home_region == home_region diff --git a/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py b/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py new file mode 100644 index 0000000000..5b974c893c --- /dev/null +++ b/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py @@ -0,0 +1,326 @@ +from datetime import datetime +from unittest import mock + +import pytest + +from prowler.lib.check.models import Check_Report_OCI +from prowler.providers.oraclecloud.services.identity.identity_service import Policy +from tests.providers.oraclecloud.oci_fixtures import ( + OCI_COMPARTMENT_ID, + OCI_REGION, + OCI_TENANCY_ID, + set_mocked_oraclecloud_provider, +) + +CHECK_PATH = "prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped" + + +def _policy( + name: str, statements: list[str], lifecycle_state: str = "ACTIVE" +) -> Policy: + return Policy( + id=f"ocid1.policy.oc1..{name.lower().replace(' ', '-')}", + name=name, + description="Test policy", + compartment_id=OCI_COMPARTMENT_ID, + statements=statements, + time_created=datetime.now(), + lifecycle_state=lifecycle_state, + region=OCI_REGION, + ) + + +def _identity_client(policies: list[Policy]) -> mock.MagicMock: + identity_client = mock.MagicMock() + identity_client.policies = policies + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.audited_regions = [mock.MagicMock(key=OCI_REGION)] + return identity_client + + +def _run_check(policies: list[Policy]) -> list[Check_Report_OCI]: + identity_client = _identity_client(policies) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch(f"{CHECK_PATH}.identity_client", new=identity_client), + ): + from prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped import ( + identity_storage_service_level_admins_scoped, + ) + + return identity_storage_service_level_admins_scoped().execute() + + +class Test_identity_storage_service_level_admins_scoped: + def test_no_policies_passes_with_tenancy_finding(self): + result = _run_check([]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == OCI_TENANCY_ID + assert result[0].resource_name == "Tenancy" + assert ( + result[0].status_extended + == "No active storage service-level administrator policies grant manage permissions without excluding delete permissions." + ) + + def test_manage_volumes_without_delete_exclusion_fails(self): + result = _run_check( + [ + _policy( + "Volume Admins", + ["Allow group VolumeUsers to manage volumes in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Volume Admins" + assert "VOLUME_DELETE" in result[0].status_extended + assert ( + "Allow group VolumeUsers to manage volumes in tenancy" + in result[0].status_extended + ) + + def test_manage_volumes_with_delete_exclusion_passes(self): + result = _run_check( + [ + _policy( + "Volume Admins", + [ + "Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Policy 'Volume Admins' excludes required storage delete permissions from storage manage statements." + ) + + def test_delete_exclusion_parser_is_case_and_whitespace_insensitive(self): + result = _run_check( + [ + _policy( + "Volume Admins", + [ + " allow group VolumeUsers TO manage volumes in tenancy WHERE request.permission != 'volume_delete' " + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_generic_where_clause_does_not_pass(self): + result = _run_check( + [ + _policy( + "Bucket Admins", + [ + "Allow group BucketUsers to manage buckets in tenancy where request.region='iad'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BUCKET_DELETE" in result[0].status_extended + assert "request.region='iad'" in result[0].status_extended + + @pytest.mark.parametrize( + "statement", + [ + "Allow group BucketUsers to manage buckets in tenancy where ANY {request.permission!='BUCKET_DELETE', request.region='iad'}", + "Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' OR request.region='iad'", + ], + ) + def test_disjunctive_delete_exclusion_does_not_pass(self, statement): + result = _run_check([_policy("Bucket Admins", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BUCKET_DELETE" in result[0].status_extended + + def test_quoted_literals_do_not_make_delete_exclusion_disjunctive(self): + result = _run_check( + [ + _policy( + "Bucket Admins", + [ + "Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' and target.tag.namespace='any-tag'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + @pytest.mark.parametrize( + "resource,permission", + [ + ("file-systems", "FILE_SYSTEM_DELETE"), + ("mount-targets", "MOUNT_TARGET_DELETE"), + ("export-sets", "EXPORT_SET_DELETE"), + ("volumes", "VOLUME_DELETE"), + ("volume-backups", "VOLUME_BACKUP_DELETE"), + ("objects", "OBJECT_DELETE"), + ("buckets", "BUCKET_DELETE"), + ], + ) + def test_storage_resources_require_matching_delete_exclusion( + self, resource, permission + ): + fail_result = _run_check( + [ + _policy( + "Storage Admins", + [f"Allow group StorageUsers to manage {resource} in tenancy"], + ) + ] + ) + pass_result = _run_check( + [ + _policy( + "Storage Admins", + [ + f"Allow group StorageUsers to manage {resource} in tenancy where request.permission != '{permission}'" + ], + ) + ] + ) + + assert len(fail_result) == 1 + assert fail_result[0].status == "FAIL" + assert permission in fail_result[0].status_extended + assert len(pass_result) == 1 + assert pass_result[0].status == "PASS" + + def test_file_family_fails_until_all_delete_permissions_are_excluded(self): + partial_result = _run_check( + [ + _policy( + "File Admins", + [ + "Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE'}" + ], + ) + ] + ) + complete_result = _run_check( + [ + _policy( + "File Admins", + [ + "Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE'}" + ], + ) + ] + ) + + assert len(partial_result) == 1 + assert partial_result[0].status == "FAIL" + assert "EXPORT_SET_DELETE" in partial_result[0].status_extended + assert len(complete_result) == 1 + assert complete_result[0].status == "PASS" + + @pytest.mark.parametrize( + "family,missing_permission,statement", + [ + ( + "volume-family", + "VOLUME_BACKUP_DELETE", + "Allow group VolumeUsers to manage volume-family in tenancy where request.permission!='VOLUME_DELETE'", + ), + ( + "object-family", + "BUCKET_DELETE", + "Allow group BucketUsers to manage object-family in tenancy where request.permission!='OBJECT_DELETE'", + ), + ( + "all-resources", + "BUCKET_DELETE", + "Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE'}", + ), + ], + ) + def test_families_and_all_resources_fail_unless_all_delete_permissions_are_excluded( + self, family, missing_permission, statement + ): + result = _run_check([_policy("Storage Admins", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert family in result[0].status_extended + assert missing_permission in result[0].status_extended + + def test_all_resources_passes_when_all_storage_delete_permissions_are_excluded( + self, + ): + result = _run_check( + [ + _policy( + "Storage Admins", + [ + "Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE', request.permission!='BUCKET_DELETE'}" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_policies_are_ignored(self): + result = _run_check( + [ + _policy( + "Inactive Volume Admins", + ["Allow group VolumeUsers to manage volumes in tenancy"], + lifecycle_state="INACTIVE", + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" + + def test_tenant_admin_policy_is_ignored(self): + result = _run_check( + [ + _policy( + "Tenant Admin Policy", + ["Allow group Administrators to manage all-resources in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" + + def test_policies_without_storage_manage_statements_are_ignored(self): + result = _run_check( + [ + _policy( + "Network Admins", + ["Allow group NetworkUsers to manage vcns in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" diff --git a/tests/providers/scaleway/scaleway_fixtures.py b/tests/providers/scaleway/scaleway_fixtures.py new file mode 100644 index 0000000000..d65dd9f2e9 --- /dev/null +++ b/tests/providers/scaleway/scaleway_fixtures.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock + +from prowler.providers.scaleway.models import ( + ScalewayIdentityInfo, + ScalewaySession, +) +from prowler.providers.scaleway.services.iam.iam_service import ( + ScalewayAPIKey, + ScalewayUser, +) + +# Scaleway Identity +ORGANIZATION_ID = "b4ce0bfc-38fc-4c53-8757-548be64add26" +ROOT_USER_ID = "00000000-0000-0000-0000-000000000001" +MEMBER_USER_ID = "00000000-0000-0000-0000-000000000002" +APPLICATION_ID = "00000000-0000-0000-0000-000000000003" +BEARER_EMAIL = "pedro@prowler.com" + +# Scaleway Credentials +ACCESS_KEY = "SCWAE000000000000000" +SECRET_KEY = "00000000-0000-0000-0000-000000000000" + +# API Key Constants +ROOT_API_KEY = "SCWROOT00000000000000" +USER_API_KEY = "SCWUSER00000000000000" +APP_API_KEY = "SCWAPP000000000000000" + + +def set_mocked_scaleway_provider( + access_key: str = ACCESS_KEY, + secret_key: str = SECRET_KEY, + identity: ScalewayIdentityInfo = None, + audit_config: dict = None, +): + """Create a mocked ScalewayProvider for testing.""" + provider = MagicMock() + provider.type = "scaleway" + provider.session = ScalewaySession( + access_key=access_key, + secret_key=secret_key, + organization_id=ORGANIZATION_ID, + default_project_id=None, + default_region="fr-par", + client=MagicMock(), + ) + provider.identity = identity or ScalewayIdentityInfo( + organization_id=ORGANIZATION_ID, + bearer_id=ROOT_USER_ID, + bearer_type="user", + bearer_email=BEARER_EMAIL, + account_root_user_id=ROOT_USER_ID, + ) + provider.audit_config = audit_config or {} + provider.fixer_config = {} + + return provider + + +def make_user( + user_id: str = ROOT_USER_ID, + email: str = BEARER_EMAIL, + account_root_user_id: str = ROOT_USER_ID, + mfa: bool = True, +) -> ScalewayUser: + return ScalewayUser( + id=user_id, + email=email, + username=email.split("@")[0] if email else None, + organization_id=ORGANIZATION_ID, + account_root_user_id=account_root_user_id, + mfa=mfa, + type_="owner" if user_id == account_root_user_id else "member", + status="activated", + ) + + +def make_api_key( + access_key: str = USER_API_KEY, + user_id: str = MEMBER_USER_ID, + application_id: str = None, + description: str = "test key", + expires_at: str = None, +) -> ScalewayAPIKey: + return ScalewayAPIKey( + access_key=access_key, + description=description, + user_id=user_id, + application_id=application_id, + default_project_id=None, + editable=True, + managed=False, + creation_ip=None, + created_at="2026-01-01T00:00:00Z", + updated_at="2026-01-01T00:00:00Z", + expires_at=expires_at, + ) diff --git a/tests/providers/scaleway/scaleway_provider_test.py b/tests/providers/scaleway/scaleway_provider_test.py new file mode 100644 index 0000000000..074ff9297c --- /dev/null +++ b/tests/providers/scaleway/scaleway_provider_test.py @@ -0,0 +1,106 @@ +import os +from unittest import mock + +import pytest + +from prowler.providers.scaleway.exceptions.exceptions import ( + ScalewayAuthenticationError, + ScalewayCredentialsError, + ScalewayIdentityError, +) +from prowler.providers.scaleway.models import ScalewaySession +from prowler.providers.scaleway.scaleway_provider import ScalewayProvider +from tests.providers.scaleway.scaleway_fixtures import ( + ACCESS_KEY, + BEARER_EMAIL, + ORGANIZATION_ID, + ROOT_USER_ID, + SECRET_KEY, +) + + +class Test_ScalewayProvider_setup_session: + def test_missing_access_key_raises_credentials_error(self): + with mock.patch.dict( + os.environ, {"SCW_ACCESS_KEY": "", "SCW_SECRET_KEY": ""}, clear=False + ): + os.environ.pop("SCW_ACCESS_KEY", None) + os.environ.pop("SCW_SECRET_KEY", None) + with pytest.raises(ScalewayCredentialsError): + ScalewayProvider.setup_session() + + def test_returns_session_with_credentials(self): + session = ScalewayProvider.setup_session( + access_key=ACCESS_KEY, + secret_key=SECRET_KEY, + organization_id=ORGANIZATION_ID, + ) + assert isinstance(session, ScalewaySession) + assert session.access_key == ACCESS_KEY + assert session.organization_id == ORGANIZATION_ID + assert session.default_region == "fr-par" + + +class Test_ScalewayProvider_setup_identity: + def _build_session(self): + return ScalewaySession( + access_key=ACCESS_KEY, + secret_key=SECRET_KEY, + organization_id=ORGANIZATION_ID, + default_region="fr-par", + client=mock.MagicMock(), + ) + + def test_resolves_user_bearer_identity(self): + session = self._build_session() + api_key = mock.MagicMock(user_id=ROOT_USER_ID, application_id=None) + user = mock.MagicMock( + email=BEARER_EMAIL, + organization_id=ORGANIZATION_ID, + account_root_user_id=ROOT_USER_ID, + ) + + with mock.patch( + "prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API" + ) as iam_cls: + iam = iam_cls.return_value + iam.get_api_key.return_value = api_key + iam.get_user.return_value = user + + identity = ScalewayProvider.setup_identity(session) + + assert identity.organization_id == ORGANIZATION_ID + assert identity.bearer_type == "user" + assert identity.bearer_id == ROOT_USER_ID + assert identity.bearer_email == BEARER_EMAIL + assert identity.account_root_user_id == ROOT_USER_ID + + def test_missing_organization_raises_identity_error(self): + session = self._build_session() + session.organization_id = None + api_key = mock.MagicMock(user_id=None, application_id="app-id") + + with mock.patch( + "prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API" + ) as iam_cls: + iam = iam_cls.return_value + iam.get_api_key.return_value = api_key + + with pytest.raises(ScalewayIdentityError): + ScalewayProvider.setup_identity(session) + + +class Test_ScalewayProvider_validate_credentials: + def test_invalid_credentials_raise_authentication_error(self): + session = ScalewaySession( + access_key=ACCESS_KEY, + secret_key=SECRET_KEY, + organization_id=ORGANIZATION_ID, + client=mock.MagicMock(), + ) + with mock.patch( + "prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API" + ) as iam_cls: + iam_cls.return_value.get_api_key.side_effect = Exception("expired") + with pytest.raises(ScalewayAuthenticationError): + ScalewayProvider.validate_credentials(session) diff --git a/tests/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned_test.py b/tests/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned_test.py new file mode 100644 index 0000000000..2473c4118c --- /dev/null +++ b/tests/providers/scaleway/services/iam/iam_api_keys_no_root_owned/iam_api_keys_no_root_owned_test.py @@ -0,0 +1,204 @@ +from unittest import mock + +from tests.providers.scaleway.scaleway_fixtures import ( + APP_API_KEY, + APPLICATION_ID, + MEMBER_USER_ID, + ORGANIZATION_ID, + ROOT_API_KEY, + ROOT_USER_ID, + USER_API_KEY, + make_api_key, + set_mocked_scaleway_provider, +) + + +def _patch_clients(iam_client_mock): + """Patch both the provider and the iam_client singleton.""" + return [ + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_scaleway_provider(), + ), + mock.patch( + "prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client", + new=iam_client_mock, + ), + ] + + +class Test_iam_api_keys_no_root_owned: + def test_no_api_keys_returns_empty_findings(self): + iam_client = mock.MagicMock() + iam_client.users_loaded = True + iam_client.api_keys_loaded = True + iam_client.account_root_user_id = ROOT_USER_ID + iam_client.api_keys = [] + iam_client.organization_id = ORGANIZATION_ID + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_scaleway_provider(), + ), + mock.patch( + "prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client", + new=iam_client, + ), + ): + from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import ( + iam_api_keys_no_root_owned, + ) + + result = iam_api_keys_no_root_owned().execute() + assert result == [] + + def test_root_api_key_fails(self): + iam_client = mock.MagicMock() + iam_client.users_loaded = True + iam_client.api_keys_loaded = True + iam_client.account_root_user_id = ROOT_USER_ID + iam_client.api_keys = [ + make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID) + ] + iam_client.organization_id = ORGANIZATION_ID + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_scaleway_provider(), + ), + mock.patch( + "prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client", + new=iam_client, + ), + ): + from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import ( + iam_api_keys_no_root_owned, + ) + + result = iam_api_keys_no_root_owned().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == ROOT_API_KEY + assert ROOT_USER_ID in result[0].status_extended + + def test_user_api_key_passes(self): + iam_client = mock.MagicMock() + iam_client.users_loaded = True + iam_client.api_keys_loaded = True + iam_client.account_root_user_id = ROOT_USER_ID + iam_client.api_keys = [ + make_api_key(access_key=USER_API_KEY, user_id=MEMBER_USER_ID) + ] + iam_client.organization_id = ORGANIZATION_ID + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_scaleway_provider(), + ), + mock.patch( + "prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client", + new=iam_client, + ), + ): + from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import ( + iam_api_keys_no_root_owned, + ) + + result = iam_api_keys_no_root_owned().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == USER_API_KEY + + def test_application_api_key_passes(self): + iam_client = mock.MagicMock() + iam_client.users_loaded = True + iam_client.api_keys_loaded = True + iam_client.account_root_user_id = ROOT_USER_ID + iam_client.api_keys = [ + make_api_key( + access_key=APP_API_KEY, user_id=None, application_id=APPLICATION_ID + ) + ] + iam_client.organization_id = ORGANIZATION_ID + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_scaleway_provider(), + ), + mock.patch( + "prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client", + new=iam_client, + ), + ): + from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import ( + iam_api_keys_no_root_owned, + ) + + result = iam_api_keys_no_root_owned().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_users_load_failure_returns_manual(self): + iam_client = mock.MagicMock() + iam_client.users_loaded = False + iam_client.api_keys_loaded = True + iam_client.account_root_user_id = None + iam_client.api_keys = [ + make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID) + ] + iam_client.organization_id = ORGANIZATION_ID + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_scaleway_provider(), + ), + mock.patch( + "prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client", + new=iam_client, + ), + ): + from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import ( + iam_api_keys_no_root_owned, + ) + + result = iam_api_keys_no_root_owned().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not retrieve" in result[0].status_extended + + def test_root_user_unresolved_returns_manual(self): + # Data loaded fine but account_root_user_id could not be resolved + # (e.g. application-scoped key). A root-owned key must NOT slip + # through as PASS — the check degrades to MANUAL instead. + iam_client = mock.MagicMock() + iam_client.users_loaded = True + iam_client.api_keys_loaded = True + iam_client.account_root_user_id = None + iam_client.api_keys = [ + make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID) + ] + iam_client.organization_id = ORGANIZATION_ID + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_scaleway_provider(), + ), + mock.patch( + "prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client", + new=iam_client, + ), + ): + from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import ( + iam_api_keys_no_root_owned, + ) + + result = iam_api_keys_no_root_owned().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "account root user" in result[0].status_extended diff --git a/tests/providers/scaleway/services/iam/scaleway_iam_service_test.py b/tests/providers/scaleway/services/iam/scaleway_iam_service_test.py new file mode 100644 index 0000000000..cb3a05d205 --- /dev/null +++ b/tests/providers/scaleway/services/iam/scaleway_iam_service_test.py @@ -0,0 +1,138 @@ +from unittest import mock + +from prowler.providers.scaleway.models import ScalewayIdentityInfo +from prowler.providers.scaleway.services.iam.iam_service import IAM +from tests.providers.scaleway.scaleway_fixtures import ( + APPLICATION_ID, + MEMBER_USER_ID, + ORGANIZATION_ID, + ROOT_USER_ID, + USER_API_KEY, + set_mocked_scaleway_provider, +) + + +def _application_identity() -> ScalewayIdentityInfo: + """Identity produced by an application-scoped API key: the IAM API + never exposes account_root_user_id for an application bearer.""" + return ScalewayIdentityInfo( + organization_id=ORGANIZATION_ID, + bearer_id=APPLICATION_ID, + bearer_type="application", + bearer_email=None, + account_root_user_id=None, + ) + + +def _mock_user( + user_id: str, account_root_user_id: str = ROOT_USER_ID, email: str = "u@example.com" +): + user = mock.MagicMock() + user.id = user_id + user.email = email + user.username = email.split("@")[0] + user.organization_id = ORGANIZATION_ID + user.account_root_user_id = account_root_user_id + user.mfa = True + user.type_ = "owner" if user_id == account_root_user_id else "member" + user.status = "activated" + return user + + +def _mock_api_key(access_key: str, user_id: str = None, application_id: str = None): + key = mock.MagicMock() + key.access_key = access_key + key.description = "test" + key.user_id = user_id + key.application_id = application_id + key.default_project_id = None + key.editable = True + key.managed = False + key.creation_ip = None + key.created_at = None + key.updated_at = None + key.expires_at = None + return key + + +class Test_IAM_service: + def test_loads_users_and_api_keys(self): + provider = set_mocked_scaleway_provider() + + with mock.patch( + "prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API" + ) as iam_cls: + api = iam_cls.return_value + api.list_users_all.return_value = [ + _mock_user(ROOT_USER_ID), + _mock_user(MEMBER_USER_ID, email="m@example.com"), + ] + api.list_api_keys_all.return_value = [ + _mock_api_key(USER_API_KEY, user_id=MEMBER_USER_ID), + _mock_api_key("SCWAPP", application_id=APPLICATION_ID), + ] + + iam = IAM(provider) + + assert iam.users_loaded is True + assert iam.api_keys_loaded is True + assert iam.account_root_user_id == ROOT_USER_ID + assert len(iam.users) == 2 + assert len(iam.api_keys) == 2 + + def test_marks_users_unloaded_on_error(self): + provider = set_mocked_scaleway_provider() + + with mock.patch( + "prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API" + ) as iam_cls: + api = iam_cls.return_value + api.list_users_all.side_effect = Exception("denied") + api.list_api_keys_all.return_value = [] + + iam = IAM(provider) + + assert iam.users_loaded is False + assert iam.api_keys_loaded is True + # account_root_user_id comes from the audit identity, not the user + # list, so a failed user listing must not blind the root-key check. + assert iam.account_root_user_id == ROOT_USER_ID + + def test_application_key_resolves_root_user_from_user_list(self): + # Application-scoped API key: identity.account_root_user_id is None, + # so it must be recovered from the loaded user list. Otherwise the + # root-key check would silently PASS root-owned keys. + provider = set_mocked_scaleway_provider(identity=_application_identity()) + + with mock.patch( + "prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API" + ) as iam_cls: + api = iam_cls.return_value + api.list_users_all.return_value = [ + _mock_user(ROOT_USER_ID), + _mock_user(MEMBER_USER_ID, email="m@example.com"), + ] + api.list_api_keys_all.return_value = [] + + iam = IAM(provider) + + assert iam.account_root_user_id == ROOT_USER_ID + + def test_account_root_user_id_none_when_unresolvable(self): + # Application key + no user record exposes account_root_user_id: + # nothing to fall back to, so it stays None and the root-key check + # will degrade to MANUAL downstream. + provider = set_mocked_scaleway_provider(identity=_application_identity()) + + with mock.patch( + "prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API" + ) as iam_cls: + api = iam_cls.return_value + api.list_users_all.return_value = [ + _mock_user(MEMBER_USER_ID, account_root_user_id=None) + ] + api.list_api_keys_all.return_value = [] + + iam = IAM(provider) + + assert iam.account_root_user_id is None diff --git a/tests/providers/stackit/lib/arguments/stackit_arguments_test.py b/tests/providers/stackit/lib/arguments/stackit_arguments_test.py new file mode 100644 index 0000000000..36635e14d2 --- /dev/null +++ b/tests/providers/stackit/lib/arguments/stackit_arguments_test.py @@ -0,0 +1,68 @@ +import argparse + +import pytest + +from prowler.providers.stackit.lib.arguments.arguments import init_parser + + +@pytest.fixture +def parser(): + parser = argparse.ArgumentParser() + parser.common_providers_parser = argparse.ArgumentParser(add_help=False) + parser.subparsers = parser.add_subparsers(dest="provider") + init_parser(parser) + return parser + + +class TestStackITArguments: + def test_project_id_argument_is_registered(self, parser): + args = parser.parse_args( + [ + "stackit", + "--stackit-project-id", + "12345678-1234-1234-1234-123456789abc", + ] + ) + + assert args.stackit_project_id == "12345678-1234-1234-1234-123456789abc" + + def test_api_token_argument_is_not_registered(self, parser): + """Tokens were removed in favour of the service account key file.""" + with pytest.raises(SystemExit): + parser.parse_args(["stackit", "--stackit-api-token", "secret-token"]) + + def test_service_account_key_path_argument_is_registered(self, parser): + args = parser.parse_args( + [ + "stackit", + "--stackit-service-account-key-path", + "/tmp/sa-key.json", + ] + ) + assert args.stackit_service_account_key_path == "/tmp/sa-key.json" + + def test_service_account_key_path_defaults_to_none(self, parser): + args = parser.parse_args(["stackit"]) + assert args.stackit_service_account_key_path is None + + def test_service_account_key_argument_is_registered(self, parser): + args = parser.parse_args( + [ + "stackit", + "--stackit-service-account-key", + '{"keyId": "abc"}', + ] + ) + assert args.stackit_service_account_key == '{"keyId": "abc"}' + + def test_service_account_key_defaults_to_none(self, parser): + args = parser.parse_args(["stackit"]) + assert args.stackit_service_account_key is None + + def test_scan_unused_services_defaults_to_false(self, parser): + args = parser.parse_args(["stackit"]) + assert args.scan_unused_services is False + + def test_scan_unused_services_flag_sets_true(self, parser): + args = parser.parse_args(["stackit", "--scan-unused-services"]) + assert args.scan_unused_services is True diff --git a/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py b/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py new file mode 100644 index 0000000000..72e7770943 --- /dev/null +++ b/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py @@ -0,0 +1,33 @@ +from unittest.mock import MagicMock + +from prowler.providers.stackit.lib.mutelist.mutelist import StackITMutelist + + +class TestStackITMutelist: + def test_is_finding_muted_uses_project_id_as_account(self): + mutelist_content = { + "Accounts": { + "project_1": { + "Checks": { + "check_test": { + "Regions": ["*"], + "Resources": ["test_resource"], + } + } + } + } + } + + mutelist = StackITMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.project_id = "project_1" + finding.resource_id = "resource_1" + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "check_test" + finding.status = "FAIL" + finding.resource_name = "test_resource" + finding.location = "eu01" + finding.resource_tags = {} + + assert mutelist.is_finding_muted(finding=finding) diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py new file mode 100644 index 0000000000..0db0c9d6d7 --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py @@ -0,0 +1,504 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_all_traffic_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted access to all traffic." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_all_ports_none_range(self): + """Test security group with None port range (all ports) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "allows unrestricted access to all traffic" in result[0].status_extended + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_all_ports_full_range(self): + """Test security group with full port range 1-65535 - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=1, + port_range_max=65535, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "allows unrestricted access to all traffic" in result[0].status_extended + ) + + def test_security_group_all_ports_zero_range(self): + """Test security group with port range 0-65535 - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=0, + port_range_max=65535, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_limited_port_range(self): + """Test security group with limited port range - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=443, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "does not allow unrestricted access to all traffic" + in result[0].status_extended + ) + + def test_security_group_restricted_ip_all_ports(self): + """Test security group with all ports but restricted IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", # Restricted IP + port_range_min=None, + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_egress_all_ports(self): + """Test security group with egress rule for all ports - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", # Egress, not ingress + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_multiple_unrestricted_rules(self): + """Test security group with multiple unrestricted rules - should FAIL with all listed.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ), + SecurityGroupRule( + id="rule-2", + direction="ingress", + protocol="udp", + ip_range="0.0.0.0/0", + port_range_min=1, + port_range_max=65535, + ), + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "rule-1" in result[0].status_extended + assert "rule-2" in result[0].status_extended diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py new file mode 100644 index 0000000000..83a279efac --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py @@ -0,0 +1,503 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_database_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3306, + port_range_max=3306, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted database access." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_mysql_unrestricted(self): + """Test security group with MySQL port 3306 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3306, + port_range_max=3306, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MySQL (port 3306)" in result[0].status_extended + assert "allows unrestricted database access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_postgresql_unrestricted(self): + """Test security group with PostgreSQL port 5432 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=5432, + port_range_max=5432, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "PostgreSQL (port 5432)" in result[0].status_extended + + def test_security_group_mongodb_unrestricted(self): + """Test security group with MongoDB port 27017 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=27017, + port_range_max=27017, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MongoDB (port 27017)" in result[0].status_extended + + def test_security_group_multiple_databases_unrestricted(self): + """Test security group with multiple database ports unrestricted - should FAIL with all listed.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3306, + port_range_max=3306, + ), + SecurityGroupRule( + id="rule-2", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=5432, + port_range_max=5432, + ), + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MySQL (port 3306)" in result[0].status_extended + assert "PostgreSQL (port 5432)" in result[0].status_extended + + def test_security_group_database_port_range(self): + """Test security group with port range including database port - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3000, + port_range_max=4000, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MySQL (port 3306)" in result[0].status_extended + + def test_security_group_database_restricted_ip(self): + """Test security group with database port restricted to specific IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=3306, + port_range_max=3306, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "does not allow unrestricted database access" + in result[0].status_extended + ) + + def test_security_group_non_database_port(self): + """Test security group with unrestricted access but non-database port - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=80, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py new file mode 100644 index 0000000000..65e21d4883 --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py @@ -0,0 +1,389 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_rdp_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted RDP access." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_rdp_unrestricted_exact_port(self): + """Test security group with RDP port 3389 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted RDP access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_rdp_unrestricted_port_range(self): + """Test security group with port range including RDP - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3380, + port_range_max=3400, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted RDP access" in result[0].status_extended + + def test_security_group_rdp_restricted_ip(self): + """Test security group with RDP restricted to specific IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not allow unrestricted RDP access" in result[0].status_extended + + def test_security_group_different_port(self): + """Test security group with unrestricted access but different port - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=80, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_none_ip_range(self): + """Test security group with None ip_range (unrestricted) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range=None, + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py new file mode 100644 index 0000000000..cabb167e78 --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py @@ -0,0 +1,589 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_ssh_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=False, # Not in use - should be skipped + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted SSH access." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_ssh_unrestricted_exact_port(self): + """Test security group with SSH port 22 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted SSH access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_ssh_unrestricted_port_range(self): + """Test security group with port range including SSH - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=20, + port_range_max=25, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted SSH access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + + def test_security_group_ssh_restricted_ip(self): + """Test security group with SSH restricted to specific IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not allow unrestricted SSH access" in result[0].status_extended + + def test_security_group_different_port(self): + """Test security group with unrestricted access but different port - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=80, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_egress_rule(self): + """Test security group with egress rule - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", # Egress, not ingress + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_none_protocol(self): + """Test security group with None protocol (all protocols) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol=None, # None means all protocols (includes TCP) + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_none_ip_range(self): + """Test security group with None ip_range (unrestricted) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range=None, # None means unrestricted access + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_none_port_range(self): + """Test security group with None port range (all ports) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, # None means all ports + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_not_in_use_with_scan_unused_services(self): + """Unused SG with unrestricted SSH must FAIL when scan_unused_services=True.""" + iaas_client = mock.MagicMock + iaas_client.scan_unused_services = True + security_group_name = "unused-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(scan_unused_services=True), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted SSH access" in result[0].status_extended diff --git a/tests/providers/stackit/services/iaas/iaas_service_test.py b/tests/providers/stackit/services/iaas/iaas_service_test.py new file mode 100644 index 0000000000..79ea0c33de --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_service_test.py @@ -0,0 +1,515 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.stackit.exceptions.exceptions import StackITInvalidTokenError +from prowler.providers.stackit.services.iaas.iaas_service import ( + IaaSService, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +def mock_iaas_fetch_all_regions(_): + """Mock the _fetch_all_regions method to avoid real API calls.""" + + +@patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService._fetch_all_regions", + new=mock_iaas_fetch_all_regions, +) +class Test_IaaS_Service: + def test_service_initialization(self): + """Test that the IaaS service initializes correctly.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + + assert iaas_service.project_id == STACKIT_PROJECT_ID + assert iaas_service.service_account_key_path is not None + assert isinstance(iaas_service.security_groups, list) + assert isinstance(iaas_service.server_nics, list) + assert isinstance(iaas_service.in_use_sg_ids, set) + assert iaas_service.scan_unused_services is False + + def test_service_project_id(self): + """Test that the service correctly extracts project_id from provider.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + assert iaas_service.project_id == STACKIT_PROJECT_ID + + def test_service_service_account_key_path(self): + """Test that the service correctly extracts the SA key path from provider.""" + custom_path = "/tmp/custom-sa.json" + provider = set_mocked_stackit_provider(service_account_key_path=custom_path) + iaas_service = IaaSService(provider) + assert iaas_service.service_account_key_path == custom_path + + def test_security_groups_list_structure(self): + """Test that security_groups is properly initialized as a list.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + assert hasattr(iaas_service, "security_groups") + assert isinstance(iaas_service.security_groups, list) + + def test_in_use_sg_ids_set_structure(self): + """Test that in_use_sg_ids is properly initialized as a set.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + assert hasattr(iaas_service, "in_use_sg_ids") + assert isinstance(iaas_service.in_use_sg_ids, set) + + @pytest.mark.parametrize( + "method_name,client_method_name", + [ + ("_list_server_nics", "list_project_nics"), + ("_list_security_groups", "list_security_groups"), + ("_list_security_group_rules", "list_security_group_rules"), + ], + ) + def test_list_methods_propagate_api_errors(self, method_name, client_method_name): + """API/auth failures must fail the scan instead of returning empty data.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + client = MagicMock() + getattr(client, client_method_name).side_effect = StackITInvalidTokenError( + message="Invalid token" + ) + + with pytest.raises(StackITInvalidTokenError): + if method_name == "_list_security_group_rules": + getattr(iaas_service, method_name)(client, "eu01", "sg-1") + else: + getattr(iaas_service, method_name)(client, "eu01") + + def test_security_group_parsing_errors_are_skipped_locally(self): + """Malformed resources are skipped while valid resources are retained.""" + + class MalformedSecurityGroup: + @property + def id(self): + raise ValueError("malformed security group") + + iaas_service = IaaSService(set_mocked_stackit_provider()) + client = MagicMock() + client.list_security_groups.return_value = [ + MalformedSecurityGroup(), + {"id": "sg-1", "name": "valid-sg"}, + ] + client.list_security_group_rules.return_value = [] + + iaas_service._list_security_groups(client, "eu01") + + assert len(iaas_service.security_groups) == 1 + assert iaas_service.security_groups[0].id == "sg-1" + + def test_in_use_considers_all_nics_not_only_public(self): + """A SG attached to any NIC (public or private) counts as in_use.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + + # NIC without a public IP, but has a security group attached + private_nic = {"id": "nic-private", "security_groups": ["sg-private"]} + used = iaas_service._get_used_security_group_ids([private_nic]) + + assert "sg-private" in used + + def test_in_use_sg_ids_populated_via_list_server_nics(self): + """_list_server_nics marks SGs on any NIC as in_use.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + client = MagicMock() + client.list_project_nics.return_value = [ + {"id": "nic-1", "security_groups": ["sg-1"]}, + {"id": "nic-2", "security_groups": ["sg-2"]}, + ] + + iaas_service._list_server_nics(client, "eu01") + + assert "sg-1" in iaas_service.in_use_sg_ids + assert "sg-2" in iaas_service.in_use_sg_ids + + +# Test SecurityGroupRule helper methods +class Test_SecurityGroupRule: + def test_is_unrestricted_with_none(self): + """Test that None ip_range is considered unrestricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range=None, + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is True + + def test_is_unrestricted_with_cidr(self): + """Test that 0.0.0.0/0 is considered unrestricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is True + + def test_is_unrestricted_with_ipv6(self): + """Test that ::/0 is considered unrestricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="::/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is True + + def test_is_restricted_with_specific_ip(self): + """Test that specific IP range is considered restricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is False + + def test_is_ingress_true(self): + """Test that ingress direction is properly detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_ingress() is True + + def test_is_ingress_false(self): + """Test that egress direction returns false for is_ingress.""" + rule = SecurityGroupRule( + id="rule-1", + direction="egress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_ingress() is False + + def test_is_tcp_with_tcp_protocol(self): + """Test that TCP protocol is detected correctly.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is True + + def test_is_tcp_with_none_protocol(self): + """Test that None protocol is treated as TCP (all protocols).""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol=None, + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is True + + def test_is_tcp_with_all_protocol(self): + """Test that 'all' protocol is treated as TCP.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="all", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is True + + def test_is_tcp_with_udp_protocol(self): + """Test that UDP protocol returns false for is_tcp.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="udp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is False + + def test_includes_port_exact_match(self): + """Test that exact port match is detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.includes_port(22) is True + + def test_includes_port_in_range(self): + """Test that port within range is detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=20, + port_range_max=25, + ) + assert rule.includes_port(22) is True + assert rule.includes_port(20) is True + assert rule.includes_port(25) is True + + def test_includes_port_outside_range(self): + """Test that port outside range is not detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=443, + ) + assert rule.includes_port(22) is False + + def test_includes_port_with_none_range(self): + """Test that None port range means all ports.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + assert rule.includes_port(22) is True + assert rule.includes_port(443) is True + assert rule.includes_port(65535) is True + + +class Test_IaaS_Service_Extract_Items: + """Cover ``IaaSService._extract_items`` against the three response shapes the + StackIT SDK can return. The previous implementation matched ``dict`` via + ``hasattr(response, "items")`` and returned the bound method instead of the + items field. + """ + + def test_extract_items_from_sdk_model(self): + """SDK models expose ``items`` as a non-callable attribute.""" + response = MagicMock(spec=["items"]) + sentinel = [{"id": "sg-1"}, {"id": "sg-2"}] + response.items = sentinel + assert IaaSService._extract_items(response, "endpoint") is sentinel + + def test_extract_items_from_dict_with_items_key(self): + """Dict responses must use the ``items`` key, not ``dict.items()``.""" + response = {"items": [{"id": "sg-1"}]} + assert IaaSService._extract_items(response, "endpoint") == [{"id": "sg-1"}] + + def test_extract_items_from_empty_dict(self): + """An empty dict yields an empty list, not the ``dict.items`` method.""" + assert IaaSService._extract_items({}, "endpoint") == [] + + def test_extract_items_from_list(self): + """A plain list response is returned as-is.""" + response = [{"id": "sg-1"}] + assert IaaSService._extract_items(response, "endpoint") is response + + def test_extract_items_unknown_shape_returns_empty(self): + """Unknown shapes fall back to an empty list and log a warning.""" + assert IaaSService._extract_items(42, "endpoint") == [] + + def test_extract_items_ignores_dict_items_method(self): + """Regression: ``dict`` exposes ``items`` as a method; ensure the + ``isinstance(dict)`` branch wins and we do not return the bound method. + """ + result = IaaSService._extract_items({"items": ["ok"]}, "endpoint") + assert result == ["ok"] + assert not callable(result) + + +class Test_IaaS_Service_Used_Security_Group_Ids: + """Cover ``_get_used_security_group_ids``. The SDK returns NIC security + group references as ``uuid.UUID`` while a security group id is a ``str``; + they must be normalized to ``str`` so the in-use membership test matches. + """ + + def _service(self): + return object.__new__(IaaSService) + + def test_uuid_references_are_normalized_to_str(self): + from uuid import UUID + + sg_uuid = UUID("2040c1fa-72a6-47bc-a53b-f62075ae6d35") + nic = MagicMock(spec=["security_groups"]) + nic.security_groups = [sg_uuid] + + used = self._service()._get_used_security_group_ids([nic]) + + # Stored as the string form so `str(sg.id) in used` matches. + assert used == {"2040c1fa-72a6-47bc-a53b-f62075ae6d35"} + assert all(isinstance(x, str) for x in used) + + def test_dict_nic_camelcase_security_groups_key(self): + nic = {"securityGroups": ["sg-aaaa", "sg-bbbb"]} + used = self._service()._get_used_security_group_ids([nic]) + assert used == {"sg-aaaa", "sg-bbbb"} + + def test_empty_nic_security_groups(self): + nic = MagicMock(spec=["security_groups"]) + nic.security_groups = [] + assert self._service()._get_used_security_group_ids([nic]) == set() + + def test_in_use_matches_uuid_reference_end_to_end(self): + """A security group whose id (str) matches a NIC reference (UUID) must + be flagged in_use=True after a full region fetch. + """ + from uuid import UUID + + sg_id = "2040c1fa-72a6-47bc-a53b-f62075ae6d35" + client = MagicMock() + nic = MagicMock(spec=["security_groups"]) + nic.security_groups = [UUID(sg_id)] + client.list_project_nics.return_value = {"items": [nic]} + client.list_security_groups.return_value = {"items": [{"id": sg_id}]} + client.list_security_group_rules.return_value = {"items": []} + + from prowler.providers.stackit.stackit_provider import StackitProvider + + service = object.__new__(IaaSService) + service.provider = MagicMock() + service.provider.handle_api_error = StackitProvider.handle_api_error + service.project_id = STACKIT_PROJECT_ID + service.scan_unused_services = False + service.regional_clients = {"eu01": client} + service.security_groups = [] + service.server_nics = [] + service.in_use_sg_ids = set() + + service._fetch_all_regions() + + assert len(service.security_groups) == 1 + assert service.security_groups[0].in_use is True + + +class Test_IaaS_Service_Fetch_All_Regions: + """Cover ``_fetch_all_regions`` multi-region behaviour. A project is not + provisioned in every StackIT region; the region where it is absent answers + with HTTP 404. That must be skipped, not abort the whole scan (which + previously left every check failing to load with an empty report). + """ + + class _NotFound(Exception): + status = 404 + + class _Forbidden(Exception): + status = 403 + + def _service(self, regional_clients): + from prowler.providers.stackit.stackit_provider import StackitProvider + + service = object.__new__(IaaSService) + service.provider = MagicMock() + # Reuse the real centralized error handler so 401/403/404 semantics + # match production. + service.provider.handle_api_error = StackitProvider.handle_api_error + service.project_id = STACKIT_PROJECT_ID + service.scan_unused_services = True + service.regional_clients = regional_clients + service.security_groups = [] + service.server_nics = [] + service.in_use_sg_ids = set() + return service + + def _good_client(self, sg_id="sg-eu01"): + client = MagicMock() + client.list_project_nics.return_value = {"items": []} + client.list_security_groups.return_value = {"items": [{"id": sg_id}]} + client.list_security_group_rules.return_value = {"items": []} + return client + + def _missing_region_client(self): + client = MagicMock() + client.list_project_nics.side_effect = self._NotFound() + client.list_security_groups.side_effect = self._NotFound() + return client + + def test_skips_region_where_project_is_absent(self): + service = self._service( + {"eu01": self._good_client(), "eu02": self._missing_region_client()} + ) + + service._fetch_all_regions() + + # eu01 security group is collected; the eu02 404 is skipped silently. + assert [sg.id for sg in service.security_groups] == ["sg-eu01"] + + def test_403_still_aborts(self): + bad = MagicMock() + bad.list_project_nics.side_effect = self._Forbidden() + service = self._service({"eu01": bad}) + + with pytest.raises(StackITInvalidTokenError): + service._fetch_all_regions() + + +class Test_IaaS_Service_Log_Skipped_Security_Groups: + """``_log_skipped_security_groups`` should emit a hint only when groups + exist, none are in use, and ``scan_unused_services`` is off. + """ + + def _service(self, security_groups, scan_unused_services): + service = object.__new__(IaaSService) + service.scan_unused_services = scan_unused_services + service.security_groups = security_groups + return service + + def _sg(self, in_use): + sg = MagicMock() + sg.in_use = in_use + return sg + + def test_logs_when_all_skipped(self, caplog): + import logging + + service = self._service([self._sg(False), self._sg(False)], False) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" in caplog.text + + def test_no_log_when_scan_unused_services_enabled(self, caplog): + import logging + + service = self._service([self._sg(False)], True) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" not in caplog.text + + def test_no_log_when_a_group_is_in_use(self, caplog): + import logging + + service = self._service([self._sg(False), self._sg(True)], False) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" not in caplog.text + + def test_no_log_when_no_security_groups(self, caplog): + import logging + + service = self._service([], False) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" not in caplog.text diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py new file mode 100644 index 0000000000..2d1e6b7875 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py @@ -0,0 +1,150 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_access_key_expiration: + def test_no_access_keys(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 0 + + def test_access_key_with_expiration(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-123", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has an expiration date set" in result[0].status_extended + assert result[0].resource_id == "key-123" + assert result[0].resource_name == "my-key" + assert result[0].location == "eu01" + + def test_access_key_no_expiration_none(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-456", + display_name="never-expiring-key", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended + assert result[0].resource_id == "key-456" + + def test_access_key_no_expiration_sentinel(self): + """Year-0001 date is the SDK sentinel for 'never expires'.""" + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-789", + display_name="sentinel-key", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py new file mode 100644 index 0000000000..a802dac78f --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_object_lock_enabled: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 0 + + def test_bucket_object_lock_enabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].resource_name == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_object_lock_disabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py new file mode 100644 index 0000000000..59fdf73370 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_retention_policy: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 0 + + def test_bucket_with_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=30, + retention_mode="COMPLIANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "30 day(s)" in result[0].status_extended + assert "COMPLIANCE" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_without_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + retention_days=None, + retention_mode=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have a default retention policy" in result[0].status_extended + ) + assert result[0].resource_id == "my-bucket" + + def test_bucket_retention_zero_days(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=0, + retention_mode="GOVERNANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py new file mode 100644 index 0000000000..59f6559a54 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py @@ -0,0 +1,645 @@ +from types import SimpleNamespace +from unittest import mock + +import pytest + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, + ObjectStorageService, +) +from tests.providers.stackit.stackit_fixtures import STACKIT_PROJECT_ID + + +class TestObjectStorageService: + def test_list_buckets_keeps_bucket_when_retention_not_configured(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found_error = Exception("not found") + not_found_error.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = not_found_error + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "my-bucket" + assert service.buckets[0].object_lock_enabled is True + assert service.buckets[0].retention_days is None + assert service.buckets[0].retention_mode is None + + def test_list_buckets_propagates_unexpected_retention_api_errors(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + api_error = Exception("service unavailable") + api_error.status = 503 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = api_error + + with pytest.raises(Exception, match="service unavailable"): + service._list_buckets(client, "eu01") + + assert service.buckets == [] + service.provider.handle_api_error.assert_called_once_with(api_error) + + def test_init_creates_service_with_no_regions(self): + provider = mock.MagicMock() + provider.identity.project_id = STACKIT_PROJECT_ID + provider.generate_regional_clients.return_value = {} + + service = ObjectStorageService(provider) + + assert service.project_id == STACKIT_PROJECT_ID + assert service.buckets == [] + assert service.access_keys == [] + provider.generate_regional_clients.assert_called_once_with("objectstorage") + + def test_fetch_all_regions_skips_404_region(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + not_found = Exception("not found") + not_found.status = 404 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=not_found): + service._fetch_all_regions() + + assert service.buckets == [] + + def test_fetch_all_regions_reraises_non_404_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + server_error = Exception("internal server error") + server_error.status = 500 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=server_error): + with pytest.raises(Exception, match="internal server error"): + service._fetch_all_regions() + + def test_list_buckets_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = { + "buckets": [ + SimpleNamespace(name="dict-response-bucket", object_lock_enabled=True) + ] + } + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-response-bucket" + + def test_list_buckets_with_dict_bucket_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[{"name": "dict-bucket", "objectLockEnabled": True}] + ) + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-bucket" + assert service.buckets[0].object_lock_enabled is True + + def test_list_buckets_skips_unknown_bucket_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[42]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_get_default_retention_with_dict_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + + client = mock.MagicMock() + client.get_default_retention.return_value = {"days": 14, "mode": "GOVERNANCE"} + + days, mode = service._get_default_retention(client, "eu01", "my-bucket") + + assert days == 14 + assert mode == "GOVERNANCE" + + def test_list_access_keys_with_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001", display_name="main-group")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ) + + service._list_access_keys(client, "eu01") + + client.list_credentials_groups.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01" + ) + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-001" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-001" + assert service.access_keys[0].display_name == "my-key" + assert service.access_keys[0].region == "eu01" + assert service.access_keys[0].expires == "2027-01-01T00:00:00+00:00" + assert service.access_keys[0].credentials_group_id == "cg-001" + assert service.access_keys[0].credentials_group_name == "main-group" + + def test_list_access_keys_with_credentials_group_id_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace( + credentials_group_id="cg-sdk", + display_name="sdk-group", + ) + ] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-sdk" + ) + + def test_list_access_keys_collects_keys_from_multiple_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace(id="cg-001", display_name="group-one"), + SimpleNamespace(id="cg-002", display_name="group-two"), + ] + ) + client.list_access_keys.side_effect = [ + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="key-one", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ), + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-002", + display_name="key-two", + expires=None, + ) + ] + ), + ] + + service._list_access_keys(client, "eu01") + + assert client.list_access_keys.call_args_list == [ + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-001", + ), + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-002", + ), + ] + assert [key.key_id for key in service.access_keys] == ["key-001", "key-002"] + assert service.access_keys[1].expires is None + assert service.access_keys[1].has_expiration() is False + assert [key.credentials_group_id for key in service.access_keys] == [ + "cg-001", + "cg-002", + ] + + def test_list_access_keys_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = { + "credentialsGroups": [{"id": "cg-dict", "displayName": "dict-group"}] + } + client.list_access_keys.return_value = { + "accessKeys": [ + {"keyId": "key-dict", "displayName": "dict-key", "expires": None} + ] + } + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-dict" + assert service.access_keys[0].display_name == "dict-key" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + assert service.access_keys[0].credentials_group_id == "cg-dict" + assert service.access_keys[0].credentials_group_name == "dict-group" + + def test_list_access_keys_with_raw_json_response_and_null_expires(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + + def json(self): + return { + "accessKeys": [ + { + "keyId": "key-raw", + "displayName": "raw-key", + "expires": None, + } + ] + } + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-raw")] + ) + ) + self.list_access_keys = mock.MagicMock() + self.raw_call = None + + def list_access_keys_without_preload_content(self, **kwargs): + self.raw_call = kwargs + return RawResponse() + + client = FakeClient() + + service._list_access_keys(client, "eu01") + + assert client.raw_call == { + "project_id": STACKIT_PROJECT_ID, + "region": "eu01", + "credentials_group": "cg-raw", + } + client.list_access_keys.assert_not_called() + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-raw" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + + def test_list_access_keys_with_raw_data_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + data = b'{"accessKeys":[{"keyId":"key-data","displayName":"data-key"}]}' + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-data")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + service._list_access_keys(FakeClient(), "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-data" + assert service.access_keys[0].display_name == "data-key" + + def test_list_access_keys_raw_response_propagates_non_success_status(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 503 + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-error")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + with pytest.raises(Exception, match="status 503") as error: + service._list_access_keys(FakeClient(), "eu01") + + assert error.value.status == 503 + service.provider.handle_api_error.assert_called_once_with(error.value) + + def test_list_access_keys_with_dict_key_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[{"id": "cg-456", "displayName": "group-456"}] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + { + "keyId": "key-456", + "displayName": "my-dict-key", + "expires": "2028-06-01T00:00:00+00:00", + } + ] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-456" + assert service.access_keys[0].display_name == "my-dict-key" + assert service.access_keys[0].credentials_group_id == "cg-456" + + def test_list_access_keys_skips_unknown_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[42]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_keys(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-empty")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + client.list_access_keys.assert_not_called() + + def test_list_access_keys_skips_malformed_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + 42, + {}, + SimpleNamespace(id="cg-valid", display_name="valid-group"), + ] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[SimpleNamespace(key_id="key-valid")] + ) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-valid" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-valid" + + def test_fetch_all_regions_calls_both_list_methods(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + service.regional_clients = {"eu01": mock.MagicMock()} + + with ( + mock.patch.object(service, "_list_buckets") as mock_buckets, + mock.patch.object(service, "_list_access_keys") as mock_keys, + ): + service._fetch_all_regions() + + mock_buckets.assert_called_once() + mock_keys.assert_called_once() + + def test_list_buckets_handles_bucket_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + class BrokenBucket: + @property + def name(self): + raise RuntimeError("broken bucket attribute") + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[BrokenBucket()]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_list_access_keys_handles_key_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class BrokenKey: + @property + def key_id(self): + raise RuntimeError("broken key attribute") + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[BrokenKey()] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + +class TestAccessKeyModel: + def test_has_expiration_with_invalid_date_string(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="not-a-valid-date", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.has_expiration() is False + + def test_expires_within_days_when_no_expiration(self): + key = AccessKey( + key_id="k", + display_name="k", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires is None + assert key.has_expiration() is False + assert key.expires_within_days(90) is False + + def test_expires_within_days_when_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-15T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_when_not_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2030-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(30) is False + + def test_expires_within_days_with_naive_datetime(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-10T00:00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_with_sentinel_key(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is False diff --git a/tests/providers/stackit/stackit_exceptions_test.py b/tests/providers/stackit/stackit_exceptions_test.py new file mode 100644 index 0000000000..b6577305b0 --- /dev/null +++ b/tests/providers/stackit/stackit_exceptions_test.py @@ -0,0 +1,29 @@ +from prowler.providers.stackit.exceptions.exceptions import ( + StackITBaseException, + StackITInvalidTokenError, +) + + +class Test_StackIT_Exception_Catalog_Immutability: + """Regression: ``StackITBaseException.__init__`` previously assigned the + per-instance ``message`` override straight onto the class-level + ``STACKIT_ERROR_CODES`` dict, leaking the override into every later + exception of the same code raised in the same process. + """ + + def _default_message(self, code: int, class_name: str) -> str: + """Read the default message directly from the unmodified catalog.""" + return StackITBaseException.STACKIT_ERROR_CODES[(code, class_name)]["message"] + + def test_message_override_does_not_mutate_class_catalog(self): + default = self._default_message(16002, "StackITInvalidTokenError") + StackITInvalidTokenError(message="instance-specific message") + assert self._default_message(16002, "StackITInvalidTokenError") == default + + def test_sequential_overrides_do_not_leak(self): + """An override on instance A must not affect instance B.""" + default = self._default_message(16002, "StackITInvalidTokenError") + StackITInvalidTokenError(message="A") + StackITInvalidTokenError(message="B") + second_default = self._default_message(16002, "StackITInvalidTokenError") + assert second_default == default diff --git a/tests/providers/stackit/stackit_fixtures.py b/tests/providers/stackit/stackit_fixtures.py new file mode 100644 index 0000000000..7312bbc542 --- /dev/null +++ b/tests/providers/stackit/stackit_fixtures.py @@ -0,0 +1,54 @@ +from uuid import uuid4 + +from mock import MagicMock + +from prowler.providers.stackit.models import StackITIdentityInfo +from prowler.providers.stackit.stackit_provider import StackitProvider + +# StackIT Test Constants +STACKIT_PROJECT_ID = str(uuid4()) +STACKIT_SERVICE_ACCOUNT_KEY_PATH = "/tmp/stackit-sa-key.json" +STACKIT_PROJECT_NAME = "Test Project" + + +def set_mocked_stackit_provider( + service_account_key_path: str = STACKIT_SERVICE_ACCOUNT_KEY_PATH, + project_id: str = STACKIT_PROJECT_ID, + identity: StackITIdentityInfo = None, + audit_config: dict = None, + fixer_config: dict = None, + scan_unused_services: bool = False, +) -> StackitProvider: + """ + Create a mocked StackIT provider for testing. + + Args: + service_account_key_path: Path to the service account key file + (default: ``STACKIT_SERVICE_ACCOUNT_KEY_PATH`` constant) + project_id: The project ID to use (default: STACKIT_PROJECT_ID) + identity: Custom identity info (default: creates new StackITIdentityInfo) + audit_config: Audit configuration dict (default: None) + fixer_config: Fixer configuration dict (default: None) + + Returns: + MagicMock: A mocked StackitProvider instance + """ + if identity is None: + identity = StackITIdentityInfo( + project_id=project_id, + project_name=STACKIT_PROJECT_NAME, + ) + + provider = MagicMock() + provider.type = "stackit" + provider.identity = identity + provider.session = { + "project_id": project_id, + "service_account_key_path": service_account_key_path, + } + provider.audit_config = audit_config if audit_config else {} + provider.fixer_config = fixer_config if fixer_config else {} + provider.scan_unused_services = scan_unused_services + provider.auth_method = "service_account_key" + + return provider diff --git a/tests/providers/stackit/stackit_metadata_test.py b/tests/providers/stackit/stackit_metadata_test.py new file mode 100644 index 0000000000..7becf6bea5 --- /dev/null +++ b/tests/providers/stackit/stackit_metadata_test.py @@ -0,0 +1,15 @@ +from pathlib import Path + +import pytest + +from prowler.lib.check.models import CheckMetadata + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/stackit").glob("services/**/*.metadata.json")), +) +def test_stackit_check_metadata_is_valid(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + assert metadata.Provider == "stackit" + assert metadata.CheckID == metadata_file.stem.replace(".metadata", "") diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py new file mode 100644 index 0000000000..13bc7f4698 --- /dev/null +++ b/tests/providers/stackit/stackit_provider_test.py @@ -0,0 +1,478 @@ +"""Tests for StackIT Provider input validation.""" + +import types +from argparse import Namespace +from unittest.mock import patch + +import pytest + +import prowler.providers.common.provider as common_provider +import prowler.providers.stackit.stackit_provider as stackit_provider_module +from prowler.providers.common.models import Connection +from prowler.providers.stackit.exceptions.exceptions import ( + StackITAPIError, + StackITInvalidProjectIdError, + StackITInvalidTokenError, + StackITNonExistentTokenError, +) +from prowler.providers.stackit.stackit_provider import StackitProvider + + +class TestStackITProviderValidation: + """Test suite for StackIT Provider input validation.""" + + KEY_PATH = "/tmp/sa-key.json" + VALID_PROJECT_ID = "12345678-1234-1234-1234-123456789abc" + + def test_validate_arguments_valid(self): + """Test validation passes with a key path and a valid UUID.""" + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, self.KEY_PATH) + + def test_validate_arguments_empty_key_path(self): + with pytest.raises(StackITNonExistentTokenError) as exc_info: + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, "") + assert "service account credentials are required" in str(exc_info.value) + + def test_validate_arguments_none_key_path(self): + with pytest.raises(StackITNonExistentTokenError) as exc_info: + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, None) + assert "service account credentials are required" in str(exc_info.value) + + def test_validate_arguments_whitespace_only_key_path(self): + with pytest.raises(StackITNonExistentTokenError) as exc_info: + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, " ") + assert "service account credentials are required" in str(exc_info.value) + + def test_validate_arguments_empty_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("", self.KEY_PATH) + assert "project ID is required" in str(exc_info.value) + + def test_validate_arguments_none_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments(None, self.KEY_PATH) + assert "project ID is required" in str(exc_info.value) + + def test_validate_arguments_whitespace_only_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments(" ", self.KEY_PATH) + assert "project ID is required" in str(exc_info.value) + + def test_validate_arguments_invalid_uuid_format(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("not-a-valid-uuid", self.KEY_PATH) + assert "must be a valid UUID format" in str(exc_info.value) + assert "not-a-valid-uuid" in str(exc_info.value) + + def test_validate_arguments_invalid_uuid_too_short(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("1234-5678", self.KEY_PATH) + assert "must be a valid UUID format" in str(exc_info.value) + + def test_validate_arguments_uuid_without_hyphens(self): + """Python's UUID() accepts compact form.""" + StackitProvider.validate_arguments( + "12345678123412341234123456789abc", self.KEY_PATH + ) + + def test_validate_arguments_numeric_only_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("123456789012", self.KEY_PATH) + assert "must be a valid UUID format" in str(exc_info.value) + + def test_validate_arguments_uuid_with_uppercase(self): + StackitProvider.validate_arguments( + "12345678-1234-1234-1234-123456789ABC", self.KEY_PATH + ) + + def test_validate_arguments_uuid_with_braces(self): + StackitProvider.validate_arguments( + "{12345678-1234-1234-1234-123456789abc}", self.KEY_PATH + ) + + def test_validate_arguments_uuid_v4_format(self): + StackitProvider.validate_arguments( + "550e8400-e29b-41d4-a716-446655440000", self.KEY_PATH + ) + + def test_validate_arguments_both_invalid(self): + """Key path is checked first.""" + with pytest.raises(StackITNonExistentTokenError): + StackitProvider.validate_arguments("not-a-uuid", "") + + def test_validate_arguments_both_none(self): + """Key path is checked first.""" + with pytest.raises(StackITNonExistentTokenError): + StackitProvider.validate_arguments(None, None) + + def test_validate_arguments_accepts_inline_key_without_path(self): + """Inline key content alone is enough; the path can be omitted.""" + StackitProvider.validate_arguments( + self.VALID_PROJECT_ID, None, '{"keyId": "abc"}' + ) + + def test_validate_arguments_accepts_inline_key_when_path_is_empty(self): + StackitProvider.validate_arguments( + self.VALID_PROJECT_ID, "", '{"keyId": "abc"}' + ) + + def test_validate_arguments_rejects_when_inline_key_is_whitespace(self): + with pytest.raises(StackITNonExistentTokenError): + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, None, " ") + + +class TestStackITProviderInitialization: + def test_init_global_provider_passes_key_path_from_cli(self, monkeypatch): + """The service account key path and inline key content from CLI args + are forwarded to the provider constructor; tokens are not part of + the API anymore.""" + captured_kwargs = {} + + class FakeStackitProvider: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + fake_module = types.SimpleNamespace(StackitProvider=FakeStackitProvider) + arguments = Namespace( + provider="stackit", + stackit_project_id="12345678-1234-1234-1234-123456789abc", + stackit_service_account_key_path="/tmp/sa-key.json", + stackit_service_account_key='{"keyId": "abc"}', + stackit_region=None, + scan_unused_services=False, + config_file="config.yaml", + mutelist_file=None, + fixer_config="fixer_config.yaml", + ) + + monkeypatch.setattr(common_provider.Provider, "_global", None) + + with ( + patch.object(common_provider, "import_module", return_value=fake_module), + patch.object( + common_provider, "load_and_validate_config_file", return_value={} + ), + ): + common_provider.Provider.init_global_provider(arguments) + + assert "api_token" not in captured_kwargs + assert captured_kwargs["project_id"] == arguments.stackit_project_id + assert ( + captured_kwargs["service_account_key_path"] + == arguments.stackit_service_account_key_path + ) + assert ( + captured_kwargs["service_account_key"] + == arguments.stackit_service_account_key + ) + assert captured_kwargs["scan_unused_services"] is False + + def test_init_global_provider_passes_scan_unused_services_true(self, monkeypatch): + """scan_unused_services=True is forwarded to the provider constructor.""" + captured_kwargs = {} + + class FakeStackitProvider: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + fake_module = types.SimpleNamespace(StackitProvider=FakeStackitProvider) + arguments = Namespace( + provider="stackit", + stackit_project_id="12345678-1234-1234-1234-123456789abc", + stackit_service_account_key_path="/tmp/sa-key.json", + stackit_region=None, + scan_unused_services=True, + config_file="config.yaml", + mutelist_file=None, + fixer_config="fixer_config.yaml", + ) + + monkeypatch.setattr(common_provider.Provider, "_global", None) + + with ( + patch.object(common_provider, "import_module", return_value=fake_module), + patch.object( + common_provider, "load_and_validate_config_file", return_value={} + ), + ): + common_provider.Provider.init_global_provider(arguments) + + assert captured_kwargs["scan_unused_services"] is True + + +class TestStackITProviderTestConnection: + KEY_PATH = "/tmp/sa-key.json" + KEY_CONTENT = '{"keyId": "abc", "publicKey": "..."}' + PROJECT_ID = "12345678-1234-1234-1234-123456789abc" + + @pytest.fixture + def fake_stackit_resourcemanager(self, monkeypatch): + class FakeConfiguration: + def __init__(self, service_account_key_path=None, service_account_key=None): + self.service_account_key_path = service_account_key_path + self.service_account_key = service_account_key + + class FakeDefaultApi: + error = None + calls = [] + + def __init__(self, config): + self.config = config + + def get_project(self, id): + self.__class__.calls.append( + ( + self.config.service_account_key_path, + self.config.service_account_key, + id, + ) + ) + if self.__class__.error: + raise self.__class__.error + return {"name": "Test Project"} + + # The SDK is imported at module level, so patch the names bound in the + # provider module rather than sys.modules. + monkeypatch.setattr(stackit_provider_module, "Configuration", FakeConfiguration) + monkeypatch.setattr( + stackit_provider_module, "ResourceManagerDefaultApi", FakeDefaultApi + ) + return FakeDefaultApi + + def test_connection_success_with_key_path(self, fake_stackit_resourcemanager): + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + ) + + assert connection == Connection(is_connected=True) + assert fake_stackit_resourcemanager.calls == [ + (self.KEY_PATH, None, self.PROJECT_ID) + ] + + def test_connection_success_with_inline_key(self, fake_stackit_resourcemanager): + """The inline key path takes precedence over the file path.""" + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key=self.KEY_CONTENT, + ) + + assert connection == Connection(is_connected=True) + assert fake_stackit_resourcemanager.calls == [ + (None, self.KEY_CONTENT, self.PROJECT_ID) + ] + + def test_connection_inline_key_overrides_path(self, fake_stackit_resourcemanager): + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + service_account_key=self.KEY_CONTENT, + ) + + assert connection == Connection(is_connected=True) + # When the inline key is present the SDK is configured with it and + # the key path is not passed on at all. + assert fake_stackit_resourcemanager.calls == [ + (None, self.KEY_CONTENT, self.PROJECT_ID) + ] + + def test_connection_returns_error_when_raise_on_exception_is_false( + self, fake_stackit_resourcemanager + ): + fake_stackit_resourcemanager.error = RuntimeError("denied") + + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + raise_on_exception=False, + ) + + assert isinstance(connection.error, StackITAPIError) + assert "Failed to connect to StackIT using Resource Manager" in str( + connection.error + ) + + def test_connection_raises_when_raise_on_exception_is_true( + self, fake_stackit_resourcemanager + ): + fake_stackit_resourcemanager.error = RuntimeError("denied") + + with pytest.raises(StackITAPIError): + StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + raise_on_exception=True, + ) + + +class TestStackITProviderGetProjectName: + @pytest.fixture + def fake_resourcemanager(self, monkeypatch): + class FakeConfiguration: + def __init__(self, service_account_key_path=None, service_account_key=None): + self.service_account_key_path = service_account_key_path + self.service_account_key = service_account_key + + class FakeDefaultApi: + error = None + + def __init__(self, config): + pass + + def get_project(self, id): + if self.__class__.error: + raise self.__class__.error + return {"name": "My Project"} + + # The SDK is imported at module level, so patch the names bound in the + # provider module rather than sys.modules. + monkeypatch.setattr(stackit_provider_module, "Configuration", FakeConfiguration) + monkeypatch.setattr( + stackit_provider_module, "ResourceManagerDefaultApi", FakeDefaultApi + ) + return FakeDefaultApi + + def _make_provider(self): + provider = object.__new__(StackitProvider) + provider._service_account_key_path = "/tmp/sa-key.json" + provider._service_account_key = None + provider._project_id = "12345678-1234-1234-1234-123456789abc" + return provider + + def test_get_project_name_403_returns_empty_string_and_does_not_abort( + self, fake_resourcemanager + ): + """The Resource Manager lookup is cosmetic; a 403 there must NOT + abort the scan because the service account can legitimately hold + IaaS roles on the project without holding Resource Manager roles. + """ + + class Http403Error(Exception): + status = 403 + + fake_resourcemanager.error = Http403Error() + provider = self._make_provider() + + # Must not raise; returns an empty project name so the scan can + # continue and the IaaS service can decide whether *its* endpoints + # are forbidden too. + assert provider._get_project_name() == "" + + def test_get_project_name_401_raises_invalid_token_error( + self, fake_resourcemanager + ): + class Http401Error(Exception): + status = 401 + + fake_resourcemanager.error = Http401Error() + provider = self._make_provider() + + with pytest.raises(StackITInvalidTokenError): + provider._get_project_name() + + def test_get_project_name_other_error_returns_empty_string( + self, fake_resourcemanager + ): + fake_resourcemanager.error = RuntimeError("network error") + provider = self._make_provider() + + result = provider._get_project_name() + + assert result == "" + + +class Test_StackitProvider_Handle_API_Error: + """Direct coverage for ``StackitProvider.handle_api_error`` so 401 and 403 + are treated as credential failures by every service call site, not just + ``_get_project_name``. + """ + + def _http(self, status_code: int) -> Exception: + err = Exception(f"http {status_code}") + err.status = status_code + return err + + def test_401_raises_invalid_token_error(self): + with pytest.raises(StackITInvalidTokenError): + StackitProvider.handle_api_error(self._http(401)) + + def test_403_raises_invalid_token_error(self): + with pytest.raises(StackITInvalidTokenError): + StackitProvider.handle_api_error(self._http(403)) + + def test_other_http_status_is_reraised_unchanged(self): + original = self._http(500) + with pytest.raises(Exception) as excinfo: + StackitProvider.handle_api_error(original) + assert excinfo.value is original + + def test_exception_without_status_is_reraised_unchanged(self): + original = RuntimeError("network error") + with pytest.raises(RuntimeError) as excinfo: + StackitProvider.handle_api_error(original) + assert excinfo.value is original + + +class TestGenerateRegionalClients: + """Tests for StackitProvider.generate_regional_clients.""" + + def _make_provider(self): + provider = object.__new__(StackitProvider) + provider._service_account_key_path = "/tmp/sa-key.json" + provider._service_account_key = None + provider._audited_regions = None + return provider + + def _fake_classes(self): + class FakeConfig: + pass + + class FakeIaasClient: + def __init__(self, config): + pass + + class FakeObjStorageClient: + def __init__(self, config): + pass + + return FakeConfig, FakeIaasClient, FakeObjStorageClient + + def test_objectstorage_service_uses_objectstorage_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("objectstorage") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeObjStorageClient) + + def test_iaas_service_uses_iaas_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("iaas") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeIaasClient) diff --git a/tests/providers/vercel/lib/service/vercel_service_test.py b/tests/providers/vercel/lib/service/vercel_service_test.py new file mode 100644 index 0000000000..e267b84841 --- /dev/null +++ b/tests/providers/vercel/lib/service/vercel_service_test.py @@ -0,0 +1,29 @@ +from unittest import mock + +from prowler.providers.vercel.lib.service.service import VercelService + + +class TestVercelService: + def test_get_returns_none_and_logs_info_on_expected_403(self): + service = VercelService.__new__(VercelService) + service.audit_config = {"max_retries": 0} + service.service = "security" + service._team_id = None + service._base_url = "https://api.vercel.com" + + response = mock.MagicMock() + response.status_code = 403 + + service._http_session = mock.MagicMock() + service._http_session.get.return_value = response + + with mock.patch( + "prowler.providers.vercel.lib.service.service.logger" + ) as logger_mock: + result = service._get("/v1/security/firewall/config/active") + + assert result is None + logger_mock.info.assert_called_once_with( + "security - Access denied for /v1/security/firewall/config/active (403). " + "This may be caused by plan or permission restrictions." + ) diff --git a/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py index 9de3eb874a..cd19a682c9 100644 --- a/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py +++ b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py @@ -142,3 +142,41 @@ class Test_project_password_protection_enabled: == f"Project {PROJECT_NAME} does not have password protection configured for deployments." ) assert result[0].team_id == TEAM_ID + + def test_no_password_protection_hobby_plan(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="hobby", + password_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(billing_plan="hobby"), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have password protection configured for deployments. This may be expected because password protection is not available on the Vercel Hobby plan." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py index ff2517fbd3..eb8e15ddff 100644 --- a/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py +++ b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py @@ -149,3 +149,41 @@ class Test_project_production_deployment_protection_enabled: == f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments." ) assert result[0].team_id == TEAM_ID + + def test_protection_null_hobby_plan(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="hobby", + production_deployment_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(billing_plan="hobby"), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments. This may be expected because protecting production deployments is not available on the Vercel Hobby plan." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_service_test.py b/tests/providers/vercel/services/project/project_service_test.py new file mode 100644 index 0000000000..cad889905e --- /dev/null +++ b/tests/providers/vercel/services/project/project_service_test.py @@ -0,0 +1,112 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import Project +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + USER_ID, + set_mocked_vercel_provider, +) + + +class TestProjectService: + def test_list_projects_parses_security_metadata(self): + service = Project.__new__(Project) + service.provider = set_mocked_vercel_provider() + service.projects = {} + service._paginate = mock.MagicMock( + return_value=[ + { + "id": PROJECT_ID, + "name": PROJECT_NAME, + "accountId": TEAM_ID, + "security": { + "firewallEnabled": True, + "firewallConfigVersion": 42, + "managedRules": { + "owasp": {"active": True, "action": "log"}, + "ai_bots": {"active": False, "action": "deny"}, + }, + "botIdEnabled": True, + }, + } + ] + ) + + service._list_projects() + + project = service.projects[PROJECT_ID] + assert project.firewall_enabled is True + assert project.firewall_config_version == "42" + assert project.managed_rules == { + "owasp": {"active": True, "action": "log"}, + "ai_bots": {"active": False, "action": "deny"}, + } + assert project.bot_id_enabled is True + + def test_list_projects_uses_scoped_team_billing_plan(self): + service = Project.__new__(Project) + service.provider = set_mocked_vercel_provider( + billing_plan="enterprise", + team_billing_plan="hobby", + ) + service.projects = {} + service._paginate = mock.MagicMock( + return_value=[ + { + "id": PROJECT_ID, + "name": PROJECT_NAME, + "accountId": TEAM_ID, + } + ] + ) + + service._list_projects() + + project = service.projects[PROJECT_ID] + assert project.billing_plan == "hobby" + + def test_list_projects_uses_user_billing_plan_for_user_scoped_project(self): + service = Project.__new__(Project) + service.provider = set_mocked_vercel_provider( + billing_plan="enterprise", + team_billing_plan="hobby", + ) + service.projects = {} + service._paginate = mock.MagicMock( + return_value=[ + { + "id": PROJECT_ID, + "name": PROJECT_NAME, + "accountId": USER_ID, + } + ] + ) + + service._list_projects() + + project = service.projects[PROJECT_ID] + assert project.billing_plan == "enterprise" + + def test_list_projects_does_not_guess_billing_plan_without_scope(self): + service = Project.__new__(Project) + service.provider = set_mocked_vercel_provider( + billing_plan="enterprise", + team_billing_plan="hobby", + ) + service.provider.session.team_id = None + service.projects = {} + service._paginate = mock.MagicMock( + return_value=[ + { + "id": PROJECT_ID, + "name": PROJECT_NAME, + } + ] + ) + + service._list_projects() + + project = service.projects[PROJECT_ID] + assert project.billing_plan is None diff --git a/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py index 19d1ce8885..38c02040d1 100644 --- a/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py +++ b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py @@ -105,3 +105,41 @@ class Test_project_skew_protection_enabled: == f"Project {PROJECT_NAME} does not have skew protection enabled, which may cause version mismatches during deployments." ) assert result[0].team_id == TEAM_ID + + def test_skew_protection_disabled_hobby_plan(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="hobby", + skew_protection=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(billing_plan="hobby"), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import ( + project_skew_protection_enabled, + ) + + check = project_skew_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have skew protection enabled, which may cause version mismatches during deployments. This may be expected because skew protection is not available on the Vercel Hobby plan." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py index 72fc44f61e..3eb92c4a8b 100644 --- a/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py +++ b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py @@ -111,3 +111,41 @@ class Test_security_custom_rules_configured: == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any custom firewall rules configured." ) assert result[0].team_id == TEAM_ID + + def test_custom_rules_status_unavailable_hobby_plan(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="hobby", + firewall_config_accessible=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import ( + security_custom_rules_configured, + ) + + check = security_custom_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for custom firewall rules because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because custom firewall rules are not available on the Vercel Hobby plan." + ) diff --git a/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py index 20e6224a24..9d8f537229 100644 --- a/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py +++ b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py @@ -111,3 +111,41 @@ class Test_security_ip_blocking_rules_configured: == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any IP blocking rules configured." ) assert result[0].team_id == TEAM_ID + + def test_ip_rules_status_unavailable_hobby_plan(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="hobby", + firewall_config_accessible=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import ( + security_ip_blocking_rules_configured, + ) + + check = security_ip_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for IP blocking rules because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because IP blocking rules are not available on the Vercel Hobby plan." + ) diff --git a/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py index 2cc32c4e91..03cd387d45 100644 --- a/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py +++ b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py @@ -121,6 +121,7 @@ class Test_security_managed_rulesets_enabled: project_id=PROJECT_ID, project_name=PROJECT_NAME, team_id=TEAM_ID, + firewall_config_accessible=False, firewall_enabled=False, managed_rulesets=None, id=PROJECT_ID, @@ -150,6 +151,45 @@ class Test_security_managed_rulesets_enabled: assert result[0].status == "MANUAL" assert ( result[0].status_extended - == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets. Enterprise plan required to access this feature." + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets because the firewall configuration endpoint was not accessible. Manual verification is required." ) assert result[0].team_id == TEAM_ID + + def test_managed_rulesets_plan_gated_non_enterprise_scope(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="pro", + firewall_config_accessible=False, + firewall_enabled=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because some managed WAF rulesets, including the OWASP Core Ruleset, are only available on Vercel Enterprise plans." + ) diff --git a/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py index 1c00a3c358..aab3e84d71 100644 --- a/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py +++ b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py @@ -111,3 +111,41 @@ class Test_security_rate_limiting_configured: == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any rate limiting rules configured." ) assert result[0].team_id == TEAM_ID + + def test_rate_limiting_status_unavailable_hobby_plan(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="hobby", + firewall_config_accessible=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import ( + security_rate_limiting_configured, + ) + + check = security_rate_limiting_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for rate limiting rules because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because rate limiting rules are not available on the Vercel Hobby plan." + ) diff --git a/tests/providers/vercel/services/security/security_service_test.py b/tests/providers/vercel/services/security/security_service_test.py new file mode 100644 index 0000000000..17c546db90 --- /dev/null +++ b/tests/providers/vercel/services/security/security_service_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from prowler.providers.vercel.services.security.security_service import Security +from tests.providers.vercel.vercel_fixtures import PROJECT_ID, PROJECT_NAME, TEAM_ID + + +class TestSecurityService: + def test_fetch_firewall_config_reads_active_version_and_normalizes_response(self): + project = VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="pro", + ) + service = Security.__new__(Security) + service.firewall_configs = {} + + service._get = mock.MagicMock( + return_value={ + "active": { + "firewallEnabled": True, + "managedRules": { + "owasp": {"active": True, "action": "deny"}, + "ai_bots": {"active": False, "action": "deny"}, + }, + "rules": [ + { + "id": "rule-custom", + "name": "Block admin access", + "active": True, + "conditionGroup": [ + { + "conditions": [ + { + "type": "path", + "op": "pre", + "value": "/admin", + } + ] + } + ], + "action": { + "mitigate": { + "action": "deny", + } + }, + }, + { + "id": "rule-rate-limit", + "name": "Rate limit login", + "active": True, + "conditionGroup": [ + { + "conditions": [ + { + "type": "path", + "op": "eq", + "value": "/login", + } + ] + } + ], + "action": { + "mitigate": { + "action": "deny", + "rateLimit": { + "algo": "fixed_window", + "window": 60, + "limit": 10, + }, + } + }, + }, + ], + "ips": [ + { + "id": "ip-rule", + "ip": "203.0.113.7", + "action": "deny", + } + ], + }, + "draft": None, + "versions": [1], + } + ) + + service._fetch_firewall_config(project) + + service._get.assert_called_once_with( + "/v1/security/firewall/config/active", + params={"projectId": PROJECT_ID, "teamId": TEAM_ID}, + ) + + config = service.firewall_configs[PROJECT_ID] + assert config.billing_plan == "pro" + assert config.firewall_enabled is True + assert config.managed_rulesets == {"owasp": {"active": True, "action": "deny"}} + assert [rule["id"] for rule in config.custom_rules] == ["rule-custom"] + assert [rule["id"] for rule in config.rate_limiting_rules] == [ + "rule-rate-limit" + ] + assert [rule["id"] for rule in config.ip_blocking_rules] == ["ip-rule"] + + def test_fetch_firewall_config_parses_crs_managed_rulesets(self): + project = VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_config_version="1", + ) + service = Security.__new__(Security) + service.firewall_configs = {} + + service._get = mock.MagicMock( + return_value={ + "id": "waf_test", + "version": 1, + "firewallEnabled": True, + "crs": { + "gen": {"active": True, "action": "log"}, + "xss": {"active": True, "action": "deny"}, + "php": {"active": False, "action": "log"}, + }, + "rules": [], + "ips": [], + } + ) + + service._fetch_firewall_config(project) + + config = service.firewall_configs[PROJECT_ID] + assert config.firewall_enabled is True + assert config.managed_rulesets == { + "gen": {"active": True, "action": "log"}, + "xss": {"active": True, "action": "deny"}, + } + + def test_fetch_firewall_config_falls_back_to_wrapper_when_active_missing(self): + project = VercelProject(id=PROJECT_ID, name=PROJECT_NAME, team_id=TEAM_ID) + service = Security.__new__(Security) + service.firewall_configs = {} + + service._get = mock.MagicMock( + side_effect=[ + Exception("404 active config not found"), + {"active": None, "draft": None, "versions": []}, + ] + ) + + service._fetch_firewall_config(project) + + assert service._get.call_args_list == [ + mock.call( + "/v1/security/firewall/config/active", + params={"projectId": PROJECT_ID, "teamId": TEAM_ID}, + ), + mock.call( + "/v1/security/firewall/config", + params={"projectId": PROJECT_ID, "teamId": TEAM_ID}, + ), + ] + + config = service.firewall_configs[PROJECT_ID] + assert config.firewall_enabled is False + assert config.managed_rulesets == {} + assert config.custom_rules == [] + assert config.rate_limiting_rules == [] + assert config.ip_blocking_rules == [] + + def test_fetch_firewall_config_uses_project_security_metadata_when_config_empty( + self, + ): + project = VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=True, + firewall_config_version="42", + managed_rules={ + "owasp": {"active": True, "action": "log"}, + "ai_bots": {"active": False, "action": "deny"}, + }, + ) + service = Security.__new__(Security) + service.firewall_configs = {} + + service._get = mock.MagicMock( + return_value={"active": None, "draft": None, "versions": []} + ) + + service._fetch_firewall_config(project) + + service._get.assert_called_once_with( + "/v1/security/firewall/config/42", + params={"projectId": PROJECT_ID, "teamId": TEAM_ID}, + ) + + config = service.firewall_configs[PROJECT_ID] + assert config.firewall_enabled is True + assert config.managed_rulesets == {"owasp": {"active": True, "action": "log"}} + assert config.custom_rules == [] + assert config.rate_limiting_rules == [] + assert config.ip_blocking_rules == [] diff --git a/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py index 1641868175..8df46dfec6 100644 --- a/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py +++ b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py @@ -113,3 +113,83 @@ class Test_security_waf_enabled: == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have the Web Application Firewall enabled." ) assert result[0].team_id == TEAM_ID + + def test_waf_status_unavailable(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_config_accessible=False, + firewall_enabled=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be checked for WAF status because the firewall configuration endpoint was not accessible. Manual verification is required." + ) + assert result[0].team_id == TEAM_ID + + def test_waf_status_unavailable_hobby_plan(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + billing_plan="hobby", + firewall_config_accessible=False, + firewall_enabled=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be checked for WAF status because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because the Web Application Firewall is not available on the Vercel Hobby plan." + ) diff --git a/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py index 7f08db7fa1..b85e12ba3c 100644 --- a/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py +++ b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py @@ -105,3 +105,41 @@ class Test_team_directory_sync_enabled: == f"Team {TEAM_NAME} does not have directory sync (SCIM) enabled. User provisioning and deprovisioning must be managed manually." ) assert result[0].team_id == "" + + def test_directory_sync_disabled_pro_plan(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + directory_sync_enabled=False, + billing_plan="pro", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import ( + team_directory_sync_enabled, + ) + + check = team_directory_sync_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have directory sync (SCIM) enabled. User provisioning and deprovisioning must be managed manually. This may be expected because directory sync (SCIM) is only available on Vercel Enterprise plans." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py index b7f40dd653..b99b862c1f 100644 --- a/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py +++ b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py @@ -106,3 +106,42 @@ class Test_team_saml_sso_enabled: == f"Team {TEAM_NAME} does not have SAML SSO enabled." ) assert result[0].team_id == "" + + def test_saml_disabled_hobby_plan(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + billing_plan="hobby", + members=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import ( + team_saml_sso_enabled, + ) + + check = team_saml_sso_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have SAML SSO enabled. This may be expected because SAML SSO is not available on the Vercel Hobby plan." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py index 360d6cdbd8..839c42f3ad 100644 --- a/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py +++ b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py @@ -142,3 +142,41 @@ class Test_team_saml_sso_enforced: == f"Team {TEAM_NAME} does not have SAML SSO enforced." ) assert result[0].team_id == "" + + def test_saml_disabled_hobby_plan(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + billing_plan="hobby", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have SAML SSO enforced. This may be expected because SAML SSO is not available on the Vercel Hobby plan." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/vercel_fixtures.py b/tests/providers/vercel/vercel_fixtures.py index 70775bb377..3e5a21f4ef 100644 --- a/tests/providers/vercel/vercel_fixtures.py +++ b/tests/providers/vercel/vercel_fixtures.py @@ -33,6 +33,8 @@ def set_mocked_vercel_provider( team_id: str = TEAM_ID, identity: VercelIdentityInfo = None, audit_config: dict = None, + billing_plan: str = None, + team_billing_plan: str = None, ): """Create a mocked VercelProvider for testing.""" provider = MagicMock() @@ -42,15 +44,22 @@ def set_mocked_vercel_provider( team_id=team_id, http_session=MagicMock(), ) + resolved_team_billing_plan = ( + team_billing_plan if team_billing_plan is not None else billing_plan + ) + team_info = VercelTeamInfo( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + billing_plan=resolved_team_billing_plan, + ) provider.identity = identity or VercelIdentityInfo( user_id=USER_ID, username=USERNAME, email=USER_EMAIL, - team=VercelTeamInfo( - id=TEAM_ID, - name=TEAM_NAME, - slug=TEAM_SLUG, - ), + billing_plan=billing_plan, + team=team_info, + teams=[team_info], ) provider.audit_config = audit_config or {"max_retries": 3} provider.fixer_config = {} diff --git a/tests/providers/vercel/vercel_metadata_test.py b/tests/providers/vercel/vercel_metadata_test.py new file mode 100644 index 0000000000..0587c1a992 --- /dev/null +++ b/tests/providers/vercel/vercel_metadata_test.py @@ -0,0 +1,97 @@ +from prowler.lib.check.models import CheckMetadata + + +class TestVercelMetadata: + EXPECTED_CATEGORIES = { + "authentication_no_stale_tokens": [ + "trust-boundaries", + "vercel-hobby-plan", + ], + "authentication_token_not_expired": [ + "trust-boundaries", + "vercel-hobby-plan", + ], + "deployment_production_uses_stable_target": [ + "trust-boundaries", + "vercel-hobby-plan", + ], + "domain_dns_properly_configured": [ + "trust-boundaries", + "vercel-hobby-plan", + ], + "domain_ssl_certificate_valid": ["encryption", "vercel-hobby-plan"], + "domain_verified": ["trust-boundaries", "vercel-hobby-plan"], + "project_auto_expose_system_env_disabled": [ + "trust-boundaries", + "vercel-hobby-plan", + ], + "project_deployment_protection_enabled": [ + "internet-exposed", + "vercel-hobby-plan", + ], + "project_directory_listing_disabled": [ + "internet-exposed", + "vercel-hobby-plan", + ], + "project_environment_no_overly_broad_target": [ + "secrets", + "vercel-hobby-plan", + ], + "project_environment_no_secrets_in_plain_type": [ + "secrets", + "vercel-hobby-plan", + ], + "project_environment_production_vars_not_in_preview": [ + "secrets", + "vercel-hobby-plan", + ], + "project_git_fork_protection_enabled": [ + "internet-exposed", + "vercel-hobby-plan", + ], + "project_password_protection_enabled": [ + "internet-exposed", + "vercel-pro-plan", + ], + "project_production_deployment_protection_enabled": [ + "internet-exposed", + "vercel-pro-plan", + ], + "project_skew_protection_enabled": ["resilience", "vercel-pro-plan"], + "security_custom_rules_configured": [ + "internet-exposed", + "vercel-pro-plan", + ], + "security_ip_blocking_rules_configured": [ + "internet-exposed", + "vercel-pro-plan", + ], + "security_managed_rulesets_enabled": [ + "internet-exposed", + "vercel-hobby-plan", + ], + "security_rate_limiting_configured": [ + "internet-exposed", + "vercel-pro-plan", + ], + "security_waf_enabled": ["internet-exposed", "vercel-pro-plan"], + "team_directory_sync_enabled": [ + "trust-boundaries", + "vercel-enterprise-plan", + ], + "team_member_role_least_privilege": [ + "trust-boundaries", + "vercel-hobby-plan", + ], + "team_no_stale_invitations": ["trust-boundaries", "vercel-hobby-plan"], + "team_saml_sso_enabled": ["trust-boundaries", "vercel-pro-plan"], + "team_saml_sso_enforced": ["trust-boundaries", "vercel-pro-plan"], + } + + def test_vercel_checks_use_legacy_and_plan_categories(self): + vercel_metadata = CheckMetadata.get_bulk(provider="vercel") + + assert set(vercel_metadata) == set(self.EXPECTED_CATEGORIES) + + for check_id, expected_categories in self.EXPECTED_CATEGORIES.items(): + assert vercel_metadata[check_id].Categories == expected_categories diff --git a/tests/providers/vercel/vercel_provider_test.py b/tests/providers/vercel/vercel_provider_test.py index 56117ae6c4..a09e0b6d14 100644 --- a/tests/providers/vercel/vercel_provider_test.py +++ b/tests/providers/vercel/vercel_provider_test.py @@ -222,3 +222,52 @@ class TestVercelProviderTestConnection: with pytest.raises(VercelCredentialsError): VercelProvider.test_connection(raise_on_exception=True) + + +class TestVercelSessionTokenSecurity: + """The Vercel API token must never leak through serialization or repr.""" + + def test_token_is_still_accessible_as_attribute(self): + session = VercelSession(token=API_TOKEN, team_id=TEAM_ID) + + assert session.token == API_TOKEN + + def test_token_excluded_from_dict(self): + session = VercelSession(token=API_TOKEN, team_id=TEAM_ID) + + serialized = session.dict() + + assert "token" not in serialized + assert API_TOKEN not in str(serialized) + assert serialized["team_id"] == TEAM_ID + + def test_token_excluded_from_model_dump(self): + session = VercelSession(token=API_TOKEN, team_id=TEAM_ID) + + serialized = session.model_dump() + + assert "token" not in serialized + assert API_TOKEN not in str(serialized) + + def test_token_excluded_from_json(self): + session = VercelSession(token=API_TOKEN, team_id=TEAM_ID) + + serialized = session.json() + + assert "token" not in serialized + assert API_TOKEN not in serialized + + def test_token_excluded_from_model_dump_json(self): + session = VercelSession(token=API_TOKEN, team_id=TEAM_ID) + + serialized = session.model_dump_json() + + assert "token" not in serialized + assert API_TOKEN not in serialized + + def test_token_excluded_from_repr_and_str(self): + session = VercelSession(token=API_TOKEN, team_id=TEAM_ID) + + assert API_TOKEN not in repr(session) + assert API_TOKEN not in str(session) + assert "token" not in repr(session) diff --git a/ui/.dockerignore b/ui/.dockerignore index 54ed258b8c..9049298c54 100644 --- a/ui/.dockerignore +++ b/ui/.dockerignore @@ -13,5 +13,5 @@ README.md !.next/static !.next/standalone .git -.husky +.pre-commit-config.yaml scripts/setup-git-hooks.js diff --git a/ui/.gitignore b/ui/.gitignore index 3b905e64d8..b6c86be41e 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -7,6 +7,7 @@ # testing /coverage +__screenshots__/ # next.js /.next/ @@ -28,6 +29,9 @@ yarn-error.log* .env*.local .env +# Claude Code local settings +.claude/ + # vercel .vercel diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit deleted file mode 100755 index 98be29c453..0000000000 --- a/ui/.husky/pre-commit +++ /dev/null @@ -1,231 +0,0 @@ -#!/bin/bash - -# Prowler UI - Pre-Commit Hook -# Optionally validates ONLY staged files against AGENTS.md standards using Claude Code -# Controlled by CODE_REVIEW_ENABLED in .env - -set -e - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "🚀 Prowler UI - Pre-Commit Hook" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -# Load .env file (look in git root directory) -GIT_ROOT=$(git rev-parse --show-toplevel) -if [ -f "$GIT_ROOT/ui/.env" ]; then - CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" "$GIT_ROOT/ui/.env" | cut -d'=' -f2 | tr -d ' ') -elif [ -f "$GIT_ROOT/.env" ]; then - CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" "$GIT_ROOT/.env" | cut -d'=' -f2 | tr -d ' ') -elif [ -f ".env" ]; then - CODE_REVIEW_ENABLED=$(grep "^CODE_REVIEW_ENABLED" .env | cut -d'=' -f2 | tr -d ' ') -else - CODE_REVIEW_ENABLED="false" -fi - -# Normalize the value to lowercase -CODE_REVIEW_ENABLED=$(echo "$CODE_REVIEW_ENABLED" | tr '[:upper:]' '[:lower:]') - -echo -e "${BLUE}ℹ️ Code Review Status: ${CODE_REVIEW_ENABLED}${NC}" -echo "" - -# Get staged files in the UI folder only (what will be committed) -# Always use GIT_ROOT-relative pathspecs so detection works regardless of cwd -STAGED_FILES=$(git -C "$GIT_ROOT" diff --cached --name-only --diff-filter=ACM -- 'ui/' | grep -E '\.(tsx?|jsx?)$' || true) - -if [ "$CODE_REVIEW_ENABLED" = "true" ]; then - if [ -z "$STAGED_FILES" ]; then - echo -e "${YELLOW}⚠️ No TypeScript/JavaScript files staged to validate${NC}" - echo "" - else - echo -e "${YELLOW}🔍 Running Claude Code standards validation...${NC}" - echo "" - echo -e "${BLUE}📋 Files to validate:${NC}" - echo "$STAGED_FILES" | while IFS= read -r file; do echo " - $file"; done - echo "" - - echo -e "${BLUE}📤 Sending to Claude Code for validation...${NC}" - echo "" - - # Build prompt with full file contents - VALIDATION_PROMPT=$( - cat <<'PROMPT_EOF' -You are a code reviewer for the Prowler UI project. Analyze the full file contents of changed files below and validate they comply with AGENTS.md standards. - -**RULES TO CHECK:** -1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }` -2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const` -3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes. Exception: `var()` is allowed when passing colors to chart/graph components that require CSS color strings (not Tailwind classes) for their APIs. -4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")` -5. React 19: NO `useMemo`/`useCallback` without reason -6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod. -7. File Org: 1 feature = local, 2+ features = shared -8. Directives: Server Actions need "use server", clients need "use client" -9. Implement DRY, KISS principles. (example: reusable components, avoid repetition) -10. Layout must work for all the responsive breakpoints (mobile, tablet, desktop) -11. ANY types cannot be used - CRITICAL: Check for `: any` in all visible lines -12. Use the components inside components/shadcn if possible -13. Check Accessibility best practices (like alt tags in images, semantic HTML, Aria labels, etc.) - -=== FILES TO REVIEW === -PROMPT_EOF - ) - - # Add full file contents for each staged file - for file in $STAGED_FILES; do - VALIDATION_PROMPT="$VALIDATION_PROMPT - -=== FILE: $file === -$(cat "$file" 2>/dev/null || echo "Error reading file")" - done - - VALIDATION_PROMPT="$VALIDATION_PROMPT - -=== END FILES === - -**IMPORTANT: Your response MUST start with exactly one of these lines:** -STATUS: PASSED -STATUS: FAILED - -**If FAILED:** List each violation with File, Line Number, Rule Number, and Issue. -**If PASSED:** Confirm all files comply with AGENTS.md standards. - -**Start your response now with STATUS:**" - - # Send to Claude Code - if VALIDATION_OUTPUT=$(echo "$VALIDATION_PROMPT" | claude 2>&1); then - echo "$VALIDATION_OUTPUT" - echo "" - - # Check result - STRICT MODE: fail if status unclear - if echo "$VALIDATION_OUTPUT" | grep -q "^STATUS: PASSED"; then - echo "" - echo -e "${GREEN}✅ VALIDATION PASSED${NC}" - echo "" - elif echo "$VALIDATION_OUTPUT" | grep -q "^STATUS: FAILED"; then - echo "" - echo -e "${RED}❌ VALIDATION FAILED${NC}" - echo -e "${RED}Fix violations before committing${NC}" - echo "" - exit 1 - else - echo "" - echo -e "${RED}❌ VALIDATION ERROR${NC}" - echo -e "${RED}Could not determine validation status from Claude Code response${NC}" - echo -e "${YELLOW}Response must start with 'STATUS: PASSED' or 'STATUS: FAILED'${NC}" - echo "" - echo -e "${YELLOW}To bypass validation temporarily, set CODE_REVIEW_ENABLED=false in .env${NC}" - echo "" - exit 1 - fi - else - echo -e "${YELLOW}⚠️ Claude Code not available${NC}" - fi - echo "" - fi -else - echo -e "${YELLOW}⏭️ Code review disabled (CODE_REVIEW_ENABLED=false)${NC}" - echo "" -fi - -# Run healthcheck (typecheck and lint check) only if there are UI changes -if [ -z "$STAGED_FILES" ]; then - echo -e "${YELLOW}⏭️ No UI files staged, skipping healthcheck/tests/build${NC}" - echo "" - exit 0 -fi - -echo -e "${BLUE}🏥 Running healthcheck...${NC}" -echo "" - -cd "$GIT_ROOT/ui" -if pnpm run healthcheck; then - echo "" - echo -e "${GREEN}✅ Healthcheck passed${NC}" - echo "" -else - echo "" - echo -e "${RED}❌ Healthcheck failed${NC}" - echo -e "${RED}Fix type errors and linting issues before committing${NC}" - echo "" - exit 1 -fi - -# Run unit tests (targeted based on staged files) -echo -e "${BLUE}🧪 Running unit tests...${NC}" -echo "" - -# Get staged source files (exclude test files) -# Use GIT_ROOT so pathspecs are always correct regardless of cwd -STAGED_SOURCE_FILES=$(git -C "$GIT_ROOT" diff --cached --name-only --diff-filter=ACM -- 'ui/*.ts' 'ui/*.tsx' | sed 's|^ui/||' | grep -v '\.test\.\|\.spec\.\|vitest\.config\|vitest\.setup' || true) - -# Check if critical paths changed (lib/, types/, config/) -CRITICAL_PATHS_CHANGED=$(git -C "$GIT_ROOT" diff --cached --name-only -- 'ui/lib/' 'ui/types/' 'ui/config/' 'ui/middleware.ts' 'ui/vitest.config.ts' 'ui/vitest.setup.ts' || true) - -if [ -n "$CRITICAL_PATHS_CHANGED" ]; then - echo -e "${YELLOW}Critical paths changed - running ALL unit tests${NC}" - if pnpm run test:run; then - echo "" - echo -e "${GREEN}✅ Unit tests passed${NC}" - echo "" - else - echo "" - echo -e "${RED}❌ Unit tests failed${NC}" - echo -e "${RED}Fix failing tests before committing${NC}" - echo "" - exit 1 - fi -elif [ -n "$STAGED_SOURCE_FILES" ]; then - echo -e "${YELLOW}Running tests related to changed files:${NC}" - echo "$STAGED_SOURCE_FILES" | while IFS= read -r file; do [ -n "$file" ] && echo " - $file"; done - echo "" - # shellcheck disable=SC2086 # Word splitting is intentional - vitest needs each file as separate arg - if pnpm exec vitest related $STAGED_SOURCE_FILES --run; then - echo "" - echo -e "${GREEN}✅ Unit tests passed${NC}" - echo "" - else - echo "" - echo -e "${RED}❌ Unit tests failed${NC}" - echo -e "${RED}Fix failing tests before committing${NC}" - echo "" - exit 1 - fi -else - echo -e "${YELLOW}No source files changed - running ALL unit tests${NC}" - if pnpm run test:run; then - echo "" - echo -e "${GREEN}✅ Unit tests passed${NC}" - echo "" - else - echo "" - echo -e "${RED}❌ Unit tests failed${NC}" - echo -e "${RED}Fix failing tests before committing${NC}" - echo "" - exit 1 - fi -fi - -# Run build -echo -e "${BLUE}🔨 Running build...${NC}" -echo "" - -if pnpm run build; then - echo "" - echo -e "${GREEN}✅ Build passed${NC}" - echo "" -else - echo "" - echo -e "${RED}❌ Build failed${NC}" - echo -e "${RED}Fix build errors before committing${NC}" - echo "" - exit 1 -fi diff --git a/ui/.npmrc b/ui/.npmrc deleted file mode 100644 index deb20d2dc7..0000000000 --- a/ui/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -public-hoist-pattern[]=*@nextui-org/* -public-hoist-pattern[]=*@heroui/* -save-exact=true diff --git a/ui/.pre-commit-config.yaml b/ui/.pre-commit-config.yaml new file mode 100644 index 0000000000..3bc0c3b5e6 --- /dev/null +++ b/ui/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +orphan: true + +# Hooks run on commit only by default; +# NOTE: default_stages does NOT override a hook's manifest stages, so fixers shipping pre-push in their +# manifest need an explicit stages: ["pre-commit"] below to stay off push. +default_stages: [pre-commit] + +repos: + - repo: local + hooks: + # P0 - Formatters: write fixes on staged files; prek re-stages. + - id: ui-prettier + name: UI - Prettier (write, staged) + entry: pnpm exec prettier --write --ignore-unknown + language: system + pass_filenames: true + priority: 0 + + - id: ui-lint + name: UI - ESLint (fix, staged) + entry: pnpm exec eslint --fix --max-warnings 40 --no-warn-ignored + language: system + files: '\.(ts|tsx|js|jsx)$' + pass_filenames: true + priority: 1 + + # P10 - Project-wide validators (TypeScript is fundamentally project-wide). + - id: ui-typecheck + name: UI - TypeScript Check + entry: pnpm run typecheck + language: system + files: '\.(ts|tsx|js|jsx)$' + pass_filenames: false + priority: 10 + + - id: ui-tests + name: UI - Unit Tests + entry: pnpm exec vitest related --run + language: system + files: '\.(ts|tsx|js|jsx)$' + exclude: '\.test\.|\.spec\.|vitest\.config|vitest\.setup' + pass_filenames: true + priority: 10 diff --git a/ui/.prettierignore b/ui/.prettierignore index 40b878db5b..8b679ff005 100644 --- a/ui/.prettierignore +++ b/ui/.prettierignore @@ -1 +1,20 @@ -node_modules/ \ No newline at end of file +# Dependencies +node_modules/ + +# Build outputs +.next/ +.now/ +build/ +coverage/ +dist/ +esm/ +CHANGELOG.md + +# Generated files +next-env.d.ts +public/mockServiceWorker.js + +# Lockfiles +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/ui/.prettierrc.json b/ui/.prettierrc.json index 0cbbafa6cc..643c55095f 100644 --- a/ui/.prettierrc.json +++ b/ui/.prettierrc.json @@ -6,5 +6,5 @@ "useTabs": false, "semi": true, "printWidth": 80, - "plugins": ["prettier-plugin-tailwindcss"] + "plugins": ["prettier-plugin-packagejson", "prettier-plugin-tailwindcss"] } diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 40cf77dc1a..7c06cc56b1 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -1,11 +1,12 @@ # Prowler UI - AI Agent Ruleset > **Skills Reference**: For detailed patterns, use these skills: +> > - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns > - [`prowler-test-ui`](../skills/prowler-test-ui/SKILL.md) - Playwright E2E testing (comprehensive) > - [`typescript`](../skills/typescript/SKILL.md) - Const types, flat interfaces > - [`react-19`](../skills/react-19/SKILL.md) - No useMemo/useCallback, compiler -> - [`nextjs-15`](../skills/nextjs-15/SKILL.md) - App Router, Server Actions +> - [`nextjs-16`](../skills/nextjs-16/SKILL.md) - App Router, Server Actions > - [`tailwind-4`](../skills/tailwind-4/SKILL.md) - cn() utility, no var() in className > - [`zod-4`](../skills/zod-4/SKILL.md) - New API (z.email(), z.uuid()) > - [`zustand-5`](../skills/zustand-5/SKILL.md) - Selectors, persist middleware @@ -13,40 +14,46 @@ > - [`playwright`](../skills/playwright/SKILL.md) - Page Object Model, selectors > - [`vitest`](../skills/vitest/SKILL.md) - Unit testing with React Testing Library > - [`tdd`](../skills/tdd/SKILL.md) - TDD workflow (MANDATORY for UI tasks) +> - [`prowler-tour`](../skills/prowler-tour/SKILL.md) - Keep product-tour definitions aligned with the UI -### Auto-invoke Skills +## Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: -| Action | Skill | -|--------|-------| -| Add changelog entry for a PR or feature | `prowler-changelog` | -| App Router / Server Actions | `nextjs-15` | -| Building AI chat features | `ai-sdk-5` | -| Committing changes | `prowler-commit` | -| Create PR that requires changelog entry | `prowler-changelog` | -| Creating Zod schemas | `zod-4` | -| Creating a git commit | `prowler-commit` | -| Creating/modifying Prowler UI components | `prowler-ui` | -| Fixing bug | `tdd` | -| Implementing feature | `tdd` | -| Modifying component | `tdd` | -| Refactoring code | `tdd` | -| Review changelog format and conventions | `prowler-changelog` | -| Testing hooks or utilities | `vitest` | -| Update CHANGELOG.md in any component | `prowler-changelog` | -| Using Zustand stores | `zustand-5` | -| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | -| Working on task | `tdd` | -| Working with Prowler UI test helpers/pages | `prowler-test-ui` | -| Working with Tailwind classes | `tailwind-4` | -| Writing Playwright E2E tests | `playwright` | -| Writing Prowler UI E2E tests | `prowler-test-ui` | -| Writing React component tests | `vitest` | -| Writing React components | `react-19` | -| Writing TypeScript types/interfaces | `typescript` | -| Writing Vitest tests | `vitest` | -| Writing unit tests for UI | `vitest` | +| Action | Skill | +| ----------------------------------------------------------------- | ------------------- | +| Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding, updating, or removing a tour definition (\*.tour.ts) | `prowler-tour` | +| App Router / Server Actions | `nextjs-16` | +| Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating Zod schemas | `zod-4` | +| Creating a git commit | `prowler-commit` | +| Creating/modifying Prowler UI components | `prowler-ui` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | +| Fixing bug | `tdd` | +| Implementing feature | `tdd` | +| Modifying component | `tdd` | +| Refactoring code | `tdd` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | +| Review changelog format and conventions | `prowler-changelog` | +| Testing hooks or utilities | `vitest` | +| Update CHANGELOG.md in any component | `prowler-changelog` | +| Using Zustand stores | `zustand-5` | +| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | +| Working on task | `tdd` | +| Working with Prowler UI test helpers/pages | `prowler-test-ui` | +| Working with Tailwind classes | `tailwind-4` | +| Writing Playwright E2E tests | `playwright` | +| Writing Prowler UI E2E tests | `prowler-test-ui` | +| Writing React component tests | `vitest` | +| Writing React components | `react-19` | +| Writing TypeScript types/interfaces | `typescript` | +| Writing Vitest tests | `vitest` | +| Writing unit tests for UI | `vitest` | --- @@ -88,7 +95,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: ### Component Placement -``` +```text New/Existing UI? → shadcn/ui + Tailwind (NEVER HeroUI for new code) Used 1 feature? → features/{feature}/components | Used 2+? → components/{domain}/ Needs state/hooks? → "use client" | Server component? → No directive @@ -96,7 +103,7 @@ Needs state/hooks? → "use client" | Server component? → No directive ### Code Location -``` +```text Server action → actions/{feature}/{feature}.ts Data transform → actions/{feature}/{feature}.adapter.ts Types (shared 2+) → types/{domain}.ts | Types (local 1) → {feature}/types.ts @@ -137,8 +144,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const schema = z.object({ - email: z.email(), // Zod 4: z.email() not z.string().email() - id: z.uuid(), // Zod 4: z.uuid() not z.string().uuid() + email: z.email(), // Zod 4: z.email() not z.string().email() + id: z.uuid(), // Zod 4: z.uuid() not z.string().uuid() }); const form = useForm({ resolver: zodResolver(schema) }); @@ -163,8 +170,12 @@ const useStore = create( ```typescript export class FeaturePage extends BasePage { readonly submitBtn = this.page.getByRole("button", { name: "Submit" }); - async goto() { await super.goto("/path"); } - async submit() { await this.submitBtn.click(); } + async goto() { + await super.goto("/path"); + } + async submit() { + await this.submitBtn.click(); + } } test("action works", { tag: ["@critical", "@feature"] }, async ({ page }) => { @@ -179,7 +190,7 @@ test("action works", { tag: ["@critical", "@feature"] }, async ({ page }) => { ## TECH STACK -Next.js 15.5.9 | React 19.2.2 | Tailwind 4.1.13 | shadcn/ui +Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 | NextAuth 5.0.0-beta.30 | Recharts 2.15.4 > **Note**: HeroUI exists in `components/ui/` as legacy code. Do NOT add new components there. @@ -188,7 +199,7 @@ Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 | NextAuth 5.0.0-beta.30 | R ## PROJECT STRUCTURE -``` +```text ui/ ├── app/(auth)/ # Auth pages ├── app/(prowler)/ # Main app: compliance, findings, providers, scans @@ -220,11 +231,12 @@ pnpm run test:e2e:ui ## QA CHECKLIST BEFORE COMMIT -- [ ] `npm run typecheck` passes -- [ ] `npm run lint:fix` passes -- [ ] `npm run format:write` passes +- [ ] `pnpm run typecheck` passes +- [ ] `pnpm run lint:fix` passes +- [ ] `pnpm run format:write` passes - [ ] Relevant E2E tests pass - [ ] All UI states handled (loading, error, empty) - [ ] No secrets in code (use `.env.local`) +- [ ] New npm dependencies include package-health evidence (maintenance, popularity, known vulnerabilities, license, release age) and a rationale for not using existing/native alternatives. - [ ] Error messages sanitized - [ ] Server-side validation present diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 62071cfcca..e77057779d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,24 +2,335 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.23.0] (Prowler UNRELEASED) +## [1.32.0] (Prowler UNRELEASED) ### 🚀 Added +- Add `Scan Configuration` menu item under the Configuration menu (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) +- Scan configuration management page (`/scan-configurations`) to create, edit, and manage scan configurations with live YAML validation against the server JSON Schema (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) +- Surface an "invalid scan configuration" note on compliance requirements that fail solely because the applied scan config does not meet them [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) +- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659) +- CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700) + +--- + +## [1.31.1] (Prowler v5.31.1) + +### 🔄 Changed + +- Schedule Scans provider table and launch flows now use provider schedule fields, restore OSS daily scheduling, default to the next local scan hour, and clarify provider selection in launch scan [(#11684)](https://github.com/prowler-cloud/prowler/pull/11684) + +--- + +## [1.31.0] (Prowler v5.31.0) + +### 🚀 Added + +- Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629) + +### 🐞 Fixed + +- Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512) +- Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) +- Handle rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) + +### 🔐 Security + +- Bump vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages (`libcrypto3` and `libssl3`) [(#11581)](https://github.com/prowler-cloud/prowler/pull/11581) +- Bump transitive `dompurify` from 3.4.2 to 3.4.10, patching XSS sanitization bypass advisories [(#11636)](https://github.com/prowler-cloud/prowler/pull/11636) + +--- + +## [1.30.1] (Prowler v5.30.1) + +### 🐞 Fixed + +- Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542) +- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) + +### 🔄 Changed + +- Public SaaS config (Sentry, Google Tag Manager, API base/docs URL) now resolves at container runtime instead of build time; self-hosted deployments set the UI config through the new `UI_`-prefixed env vars (`UI_API_BASE_URL`, `UI_API_DOCS_URL`, `UI_GOOGLE_TAG_MANAGER_ID`, `UI_SENTRY_DSN`, `UI_SENTRY_ENVIRONMENT`), with the previous `NEXT_PUBLIC_*` names still honored as a deprecated fallback [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +### 🐞 Fixed + +- `ui/.env` template now lists only the canonical `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT` names; the deprecated `NEXT_PUBLIC_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_ENVIRONMENT` entries have been removed [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +--- + +## [1.30.0] (Prowler v5.30.0) + +### 🚀 Added + +- DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) +- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) + +### 🔄 Changed + +- Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) +- Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530) + +### 🐞 Fixed + +- Risk Pipeline Sankey chart now adapts height and node spacing for dense provider datasets, keeping provider and severity labels readable [(#11527)](https://github.com/prowler-cloud/prowler/pull/11527) + +--- + +## [1.29.3] (Prowler v5.29.3) + +### 🐞 Fixed + +- Finding drawer tabs now keep the active tab text and underline styling when tooltip state changes [(#11493)](https://github.com/prowler-cloud/prowler/pull/11493) + +--- + +## [1.29.2] (Prowler v5.29.2) + +### 🔄 Changed + +- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🐞 Fixed + +- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) +- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447) + +### 🔐 Security + +- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +--- + +## [1.29.0] (Prowler v5.29.0) + +### 🚀 Added + +- Restyle `Scan Jobs` view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258) + +### 🔄 Changed + +- Dark mode: pure-black canvas, pure-white primary text, and brighter border / input tokens for clearer separation between cards, tables, and inputs [(#11073)](https://github.com/prowler-cloud/prowler/pull/11073) +- CI workflows (`ui-tests.yml`, `ui-e2e-tests-v2.yml`) now read the Node version from `ui/.nvmrc` and the pnpm version from `package.json#packageManager` instead of hardcoded values [(#11225)](https://github.com/prowler-cloud/prowler/pull/11225) + +### 🐞 Fixed + +- Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374) +- Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376) +- Jira dispatch and provider connection-test polling no longer show a false timeout for longer-running tasks; both poll windows now extend to 60 seconds [(#11519)](https://github.com/prowler-cloud/prowler/pull/11519) + +### 🔐 Security + +- `pnpm` upgraded to 11 with supply-chain defaults consolidated in `pnpm-workspace.yaml` and `trustPolicyExclude` entries pinned to exact versions [(#11225)](https://github.com/prowler-cloud/prowler/pull/11225) +- `uuid` pinned to `11.1.1` via `pnpm-workspace.yaml#overrides` to clear `GHSA-w5hq-g745-h8pq` (missing bounds check in `v3`/`v5`/`v6` name-based generators with `buf`) in the transitive tree [(#11225)](https://github.com/prowler-cloud/prowler/pull/11225) + +--- + +## [1.28.1] (Prowler v5.28.1) + +### 🐞 Fixed + +- Large scan report ZIP downloads now stream through a Next.js Route Handler instead of buffering the full file in a Server Action [(#11330)](https://github.com/prowler-cloud/prowler/pull/11330) +- Compliance requirement findings table now respects the page size selector [(#11365)](https://github.com/prowler-cloud/prowler/pull/11365) + +--- + +## [1.28.0] (Prowler v5.28.0) + +### 🚀 Added + +- `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213) +- "Resource Metadata / Evidence" tab in the finding detail drawer [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187) + +### 🐞 Fixed + +- Resource detail panels: metadata editor now scrolls internally with the minimal scrollbar across the finding drawer and `/resources/:id`, tab labels truncate with tooltips on narrow widths, and "View in AWS Console" moved from the resource UID row to the resource actions menu [(#11325)](https://github.com/prowler-cloud/prowler/pull/11325) + +--- + +## [1.27.0] (Prowler v5.27.0) + +### 🚀 Added + +- Health endpoint at `GET /api/health` for Docker Compose liveness checks [(#11145)](https://github.com/prowler-cloud/prowler/pull/11145) +- AWS findings and resource details now expose a "View in AWS Console" link that opens the resource directly in the AWS Console via the universal `/go/view` ARN resolver [(#9172)](https://github.com/prowler-cloud/prowler/pull/9172) +- Lighthouse AI: Prowler App Finding Groups MCP tools [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140) + +### 🔄 Changed + +- Trimmed unused `npm` dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115) +- Faster, stricter pre-commit: prek lints and formats only staged UI files (husky removed), with Prettier and ESLint (`--max-warnings 40`, stale-disable detection) now covering the full UI workspace, including `public/` assets [(#11118)](https://github.com/prowler-cloud/prowler/pull/11118) +- Attack Paths graph now uses React Flow with improved layout, interactions, export, minimap, and browser test coverage [(#10686)](https://github.com/prowler-cloud/prowler/pull/10686) +- SAML ACS URL is only shown if the email domain is configured [(#11144)](https://github.com/prowler-cloud/prowler/pull/11144) +- "View Resource" action in the finding resource detail drawer is now an icon-only link rendered next to the resource name (instead of a text button in the UID row), keeping the "View in AWS Console" link unchanged [(#11193)](https://github.com/prowler-cloud/prowler/pull/11193) + +### 🐞 Fixed + +- Mute Findings modal now enforces the 100-character limit on the rule name input with a live counter and inline error, matching the existing reason field behaviour [(#11158)](https://github.com/prowler-cloud/prowler/pull/11158) +- Finding drawer no longer renders literal backticks around inline code in Risk, Description and Remediation sections [(#11142)](https://github.com/prowler-cloud/prowler/pull/11142) +- Launch Scan first-provider wizard continues after provider creation instead of resetting the Scans page [(#11136)](https://github.com/prowler-cloud/prowler/pull/11136) +- Attack Paths graph nodes now wrap long resource and finding labels, indicate truncated values with `…`, and show the full value in an immediate tooltip [(#11197)](https://github.com/prowler-cloud/prowler/pull/11197) + +### 🔐 Security + +- `npm` dependencies updated to patched versions for Next.js, Vite, LangChain, XML parsing, lodash, and related transitive packages [(#11173)](https://github.com/prowler-cloud/prowler/pull/11173) +- Hardened `npm` supply chain controls [(#11157)](https://github.com/prowler-cloud/prowler/pull/11157) + +--- + +## [1.26.1] (Prowler 5.26.1) + +### 🐞 Fixed + +- Role form Cancel buttons now return to Roles [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125) +- Shared select dropdowns stay constrained and scrollable inside modals [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125) + +--- + +## [1.26.0] (Prowler v5.26.0) + +### 🚀 Added + +- ASD Essential Eight compliance framework support [(#11071)](https://github.com/prowler-cloud/prowler/pull/11071) + +### 🔄 Changed + +- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971) +- Finding detail drawer now labels remediation actions from finding-level recommendation URLs by destination: "View CVE", "View in Prowler Hub", "View Advisory", or "View Reference", while keeping URL-only remediation cards labeled [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853) +- Finding detail drawer reorganized: status-colored banner below the resource info, dedicated Remediation tab, renamed "Findings for this resource" tab, and inline View Resource link next to the resource UID [(#11091)](https://github.com/prowler-cloud/prowler/pull/11091) +- ThreatScore compliance views: canonical pillar order across all charts and the accordion, clickable pillars on `/compliance` that anchor the detail page, Top Failed Sections always shows the full pillar set, and donut tooltip now triggers on every segment [(#10975)](https://github.com/prowler-cloud/prowler/pull/10975) + +--- + +## [1.25.3] (Prowler v5.25.3) + +### 🐞 Fixed + +- CLI command in the finding drawer no longer renders the line-number gutter, matching the original styled block while removing the leading `1` [(#11059)](https://github.com/prowler-cloud/prowler/pull/11059) + +--- + +## [1.25.2] (Prowler v5.25.2) + +### 🔄 Changed + +- Compliance cards: progress bar now spans the full card width, the passing-requirements caption sits beside the framework logo under the title, and the ISO 27001 logo asset is recentered within its tile [(#10939)](https://github.com/prowler-cloud/prowler/pull/10939) +- Findings expanded resource rows now drop the redundant cube icons, render Service and Region with the same compact label style as Last seen and Failing for, and reorder columns to Status, Resource, Provider, Severity, then field labels [(#10949)](https://github.com/prowler-cloud/prowler/pull/10949) + +--- + +## [1.25.1] (Prowler v5.25.1) + +### 🐞 Fixed + +- Compliance page export menu now scales on small screens, and frameworks load on first render without requiring a manual scan re-selection [(#10918)](https://github.com/prowler-cloud/prowler/pull/10918) + +--- + +## [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 + +- Redesign compliance page, client-side search for compliance frameworks, compact scan selector trigger, enhanced compliance cards [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767) +- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) +- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859) +- 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) +- 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) +- Fix rows-per-page selector silently ignoring changes in URL-driven tables by unifying `DataTable` pagination into a single `onPaginationChange` callback [(#10863)](https://github.com/prowler-cloud/prowler/pull/10863) + +--- + +## [1.24.4] (Prowler 5.24.4) + +### 🐞 Fixed + +- Provider wizard no longer advances to the Launch Scan step when rotating credentials [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851) +- Attack Paths scan selector now lists scans from every provider with working pagination, instead of capping the list at the first ten [(#10864)](https://github.com/prowler-cloud/prowler/pull/10864) + +--- + +## [1.24.2] (Prowler v5.24.2) + +### 🐞 Fixed + +- Default muted filter now applied consistently on the findings page and the finding-group resource drill-down, keeping muted findings hidden unless the "include muted findings" checkbox is opted in [(#10818)](https://github.com/prowler-cloud/prowler/pull/10818) + +--- + +## [1.24.1] (Prowler v5.24.1) + +### 🐞 Fixed + +- Findings and filter UX fixes: exclude muted findings by default in the resource detail drawer and finding group resource views, show category context label (for example `Status: FAIL`) on MultiSelect triggers instead of hiding the placeholder, and add a `wide` width option for filter dropdowns applied to the findings Scan filter to prevent label truncation [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734) +- Findings grouped view now handles zero-resource IaC counters, refines drawer loading states, and adds provider indicators to finding groups [(#10736)](https://github.com/prowler-cloud/prowler/pull/10736) +- Other Findings for this resource: ordering by `severity` [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Other Findings for this resource: show `delta` indicator [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Compliance: requirement findings do not show muted findings [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Latest new findings: link to finding groups order by `-severity,-last_seen_at` [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) + +### 🔒 Security + +- Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization [(#10754)](https://github.com/prowler-cloud/prowler/pull/10754) + +--- + +## [1.24.0] (Prowler v5.24.0) + +### 🚀 Added + +- Resources side drawer with redesigned detail panel [(#10673)](https://github.com/prowler-cloud/prowler/pull/10673) +- Syntax highlighting for remediation code blocks in finding groups drawer with provider-aware auto-detection (Shell, HCL, YAML, Bicep) [(#10698)](https://github.com/prowler-cloud/prowler/pull/10698) + +### 🔄 Changed + +- Attack Paths scan selection: contextual button labels based on graph availability, tooltips on disabled actions, green dot indicator for selectable scans, and a warning banner when viewing data from a previous scan cycle [(#10685)](https://github.com/prowler-cloud/prowler/pull/10685) +- Remove legacy finding detail sheet, row-details wrapper, and resource detail panel; unify findings and resources around new side drawers [(#10692)](https://github.com/prowler-cloud/prowler/pull/10692) +- Attack Paths "View Finding" now opens the finding drawer inline over the graph instead of navigating to `/findings` in a new tab, preserving graph zoom, selection, and filter state +- Attack Paths scan table: replace action buttons with radio buttons, add dedicated Graph column, use info-colored In Progress badge, remove redundant Progress column, and fix info banner variant [(#10704)](https://github.com/prowler-cloud/prowler/pull/10704) + +### 🐞 Fixed + +- Findings group resource filters now strip unsupported scan parameters, display scan name instead of provider alias in filter badges, migrate mute modal from HeroUI to shadcn, and add searchable accounts/provider type selectors [(#10662)](https://github.com/prowler-cloud/prowler/pull/10662) +- Compliance detail page header now reflects the actual provider, alias and UID of the selected scan instead of always defaulting to AWS [(#10674)](https://github.com/prowler-cloud/prowler/pull/10674) +- Provider wizard modal moved to a stable page-level host so the providers table refreshes after link, authenticate, and connection check without closing the modal [(#10675)](https://github.com/prowler-cloud/prowler/pull/10675) + +--- + +## [1.23.0] (Prowler v5.23.0) + +### 🚀 Added + +- Invitation accept smart router for handling invitation flow routing [(#10573)](https://github.com/prowler-cloud/prowler/pull/10573) +- Invitation link backward compatibility [(#10583)](https://github.com/prowler-cloud/prowler/pull/10583) +- Updated invitation link to use smart router [(#10575)](https://github.com/prowler-cloud/prowler/pull/10575) - Multi-tenant organization management: create, switch, edit, and delete organizations from the profile page [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) - Findings grouped view with drill-down table showing resources per check, resource detail drawer, infinite scroll pagination, and bulk mute support [(#10425)](https://github.com/prowler-cloud/prowler/pull/10425) - Resource events tool to Lighthouse AI [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412) +- Vercel provider: connect Vercel teams via API token, scan deployments, domains, projects, and team settings [(#10191)](https://github.com/prowler-cloud/prowler/pull/10191) ### 🔄 Changed - Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445) +- Attack Paths custom openCypher queries now link to the Prowler documentation with examples and how-to guidance instead of the upstream Cartography schema URL - Filter summary strip: removed redundant "Clear all" link next to pills (use top-bar Clear Filters instead) and switched chip variant from `outline` to `tag` for consistency [(#10481)](https://github.com/prowler-cloud/prowler/pull/10481) +### 🔐 Security + +- Bump `serialize-javascript` override from 7.0.3 to 7.0.5 to restore pnpm trusted publishing and patch CVE-2026-34043 [(#10653)](https://github.com/prowler-cloud/prowler/pull/10653) + ### 🐞 Fixed -- Deleting the active organization now switches to the target org before deleting, preventing JWT rejection from the backend [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) +- Preserve query parameters in callbackUrl during invitation flow [(#10571)](https://github.com/prowler-cloud/prowler/pull/10571) - Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446) - Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) +- Exclude service filter from finding group resources endpoint to prevent empty results when a service filter is active [(#10652)](https://github.com/prowler-cloud/prowler/pull/10652) --- diff --git a/ui/Dockerfile b/ui/Dockerfile index 9163be77cc..86673ba046 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,9 +1,10 @@ +# Keep in sync with ui/.nvmrc. FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b181aa49e22c14383e AS base LABEL maintainer="https://github.com/prowler-cloud" -# Enable corepack for pnpm -RUN corepack enable +# Patch Alpine OpenSSL runtime packages before all stages inherit the base image. +RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable # Install dependencies only when needed FROM base AS deps @@ -13,8 +14,9 @@ RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY scripts ./scripts +ENV NODE_OPTIONS=--max-old-space-size=4096 RUN corepack install && pnpm install --frozen-lockfile @@ -34,12 +36,8 @@ RUN corepack install ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION} -ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID -ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID} -ARG NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} -ARG NEXT_PUBLIC_API_DOCS_URL -ENV NEXT_PUBLIC_API_DOCS_URL=${NEXT_PUBLIC_API_DOCS_URL} + +# GTM / API base+docs URLs are runtime container env (prod stage), not build ARGs. RUN pnpm run build @@ -76,6 +74,12 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Runtime configuration is read by `node server.js` at container start and is +# NOT baked into the image. Supply it via your orchestrator (docker-compose, +# Helm/K8s): +# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) +# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT +# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet) # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output CMD ["node", "server.js"] diff --git a/ui/README.md b/ui/README.md index e5d76ff1e3..998dc84a37 100644 --- a/ui/README.md +++ b/ui/README.md @@ -2,10 +2,12 @@ This repository hosts the UI component for Prowler, providing a user-friendly web interface to interact seamlessly with Prowler's features. - ## 🚀 Production deployment + ### Docker deployment + #### Clone the repository + ```console # HTTPS git clone https://github.com/prowler-cloud/ui.git @@ -14,16 +16,21 @@ git clone https://github.com/prowler-cloud/ui.git git clone git@github.com:prowler-cloud/ui.git ``` + #### Build the Docker image + ```bash docker build -t prowler-cloud/ui . --target prod ``` + #### Run the Docker container + ```bash docker run -p 3000:3000 prowler-cloud/ui ``` ### Local deployment + #### Clone the repository ```console @@ -48,8 +55,11 @@ pnpm start ``` ## 🧪 Development deployment + ### Docker deployment + #### Clone the repository + ```console # HTTPS git clone https://github.com/prowler-cloud/ui.git @@ -58,16 +68,21 @@ git clone https://github.com/prowler-cloud/ui.git git clone git@github.com:prowler-cloud/ui.git ``` + #### Build the Docker image + ```bash docker build -t prowler-cloud/ui . --target dev ``` + #### Run the Docker container + ```bash docker run -p 3000:3000 prowler-cloud/ui ``` ### Local deployment + #### Clone the repository ```console @@ -85,10 +100,10 @@ git clone git@github.com:prowler-cloud/ui.git pnpm install ``` -**Note:** The `pnpm install` command will automatically configure Git hooks for code quality checks. If you experience issues, you can manually configure them: +**Note:** The `pnpm install` command will automatically configure prek Git hooks for code quality checks. If hooks are not installed, run from the repo root: ```bash -git config core.hooksPath "ui/.husky" +prek install ``` #### Run the development server @@ -107,47 +122,12 @@ pnpm run dev - [Framer Motion](https://www.framer.com/motion/) - [next-themes](https://github.com/pacocoursey/next-themes) -## Git Hooks & Code Review +## Git Hooks -This project uses Git hooks to maintain code quality. When you commit changes to TypeScript/JavaScript files, the pre-commit hook can optionally validate them against our coding standards using Claude Code. - -### Enabling Code Review - -To enable automatic code review on commits, add this to your `.env` file in the project root: +The UI uses [prek](https://github.com/j178/prek) for pre-commit checks, configured in [`.pre-commit-config.yaml`](.pre-commit-config.yaml). `pnpm install` runs the postinstall script that installs hooks automatically. To re-install manually: ```bash -CODE_REVIEW_ENABLED=true +prek install --overwrite ``` -When enabled, the hook will: -- ✅ Validate your staged changes against `AGENTS.md` standards -- ✅ Check for common issues (any types, incorrect imports, styling violations, etc.) -- ✅ Block commits that don't comply with the standards -- ✅ Provide helpful feedback on how to fix issues - -### Disabling Code Review - -To disable code review (faster commits, useful for quick iterations): - -```bash -CODE_REVIEW_ENABLED=false -``` - -Or remove the variable from your `.env` file. - -### Requirements - -- [Claude Code CLI](https://github.com/anthropics/claude-code) installed and authenticated -- `.env` file in the project root with `CODE_REVIEW_ENABLED` set - -### Troubleshooting - -If hooks aren't running after commits: - -```bash -# Verify hooks are configured -git config --get core.hooksPath # Should output: ui/.husky - -# Reconfigure if needed -git config core.hooksPath "ui/.husky" -``` +On each commit, prek runs Prettier and ESLint against the staged files, plus a project-wide TypeScript check and the unit tests related to the staged changes. The full Next.js build runs in CI, not on commit. diff --git a/ui/__tests__/mockServiceWorker.test.ts b/ui/__tests__/mockServiceWorker.test.ts new file mode 100644 index 0000000000..e89265c8ac --- /dev/null +++ b/ui/__tests__/mockServiceWorker.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +describe("mock service worker message hardening", () => { + it("rejects messages from unexpected origins before handling client messages", () => { + const workerSource = readFileSync( + join(process.cwd(), "public/mockServiceWorker.js"), + "utf8", + ); + + expect(workerSource).toContain("event.origin !== self.location.origin"); + expect( + workerSource.indexOf("event.origin !== self.location.origin"), + ).toBeLessThan(workerSource.indexOf("const clientId = Reflect.get")); + }); +}); diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts new file mode 100644 index 0000000000..7c76fa574b --- /dev/null +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -0,0 +1,231 @@ +import { http, HttpResponse } from "msw"; + +import type { PageFixture } from "@/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures"; +import type { + AttackPathQueriesResponse, + AttackPathQuery, + AttackPathQueryResult, + AttackPathScan, + AttackPathScansResponse, + QueryResultAttributes, +} from "@/types/attack-paths"; + +const API = process.env.UI_API_BASE_URL; + +type JsonApiErrorBody = { + errors: Array<{ detail: string; status: string }>; +}; + +const toScansApiResponse = ( + scans: AttackPathScan[], +): AttackPathScansResponse => ({ + data: scans, + links: { + first: `${API}/attack-paths-scans?page=1`, + last: `${API}/attack-paths-scans?page=1`, + next: null, + prev: null, + }, +}); + +const toQueriesApiResponse = ( + queries: AttackPathQuery[], +): AttackPathQueriesResponse => ({ + data: queries, +}); + +const toQueryResultApiResponse = ( + attrs: QueryResultAttributes, + queryId: string, +): AttackPathQueryResult => ({ + data: { + type: "attack-paths-query-run-requests", + id: queryId, + attributes: attrs, + }, +}); + +const toErrorBody = (detail: string, status: number): JsonApiErrorBody => ({ + errors: [{ detail, status: String(status) }], +}); + +const toFindingApiResponse = (fx: PageFixture, findingId: string) => { + const findingNode = fx.queryResult?.nodes.find( + (node) => node.id === findingId, + ); + const resourceNode = fx.queryResult?.nodes.find((node) => + fx.queryResult?.relationships?.some( + (rel) => + (rel.source === node.id && rel.target === findingId) || + (rel.target === node.id && rel.source === findingId), + ), + ); + const scan = fx.scans[0]; + const providerId = scan?.relationships?.provider?.data?.id ?? "provider-1"; + const resourceId = resourceNode?.id ?? "resource-1"; + + return { + data: { + type: "findings", + id: findingId, + attributes: { + uid: String(findingNode?.properties.id ?? findingId), + delta: null, + status: String(findingNode?.properties.status ?? "FAIL"), + status_extended: "Status extended", + severity: String(findingNode?.properties.severity ?? "critical"), + check_id: "attack_path_check", + muted: false, + muted_reason: null, + check_metadata: { + risk: "High", + notes: "", + checkid: "attack_path_check", + provider: "aws", + severity: String(findingNode?.properties.severity ?? "critical"), + checktype: [], + dependson: [], + relatedto: [], + categories: ["security"], + checktitle: String( + findingNode?.properties.check_title ?? "Attack path finding", + ), + compliance: null, + relatedurl: "", + description: "Attack path finding description", + remediation: { + code: { cli: "", other: "", nativeiac: "", terraform: "" }, + recommendation: { url: "", text: "Fix the finding" }, + }, + additionalurls: [], + servicename: String(resourceNode?.properties.service ?? "s3"), + checkaliases: [], + resourcetype: String(resourceNode?.labels[0] ?? "Resource"), + subservicename: "", + resourceidtemplate: "", + }, + raw_result: null, + inserted_at: "2026-04-21T10:00:00Z", + updated_at: "2026-04-21T10:05:00Z", + first_seen_at: null, + }, + relationships: { + resources: { data: [{ type: "resources", id: resourceId }] }, + scan: { data: { type: "scans", id: scan?.id ?? "scan-1" } }, + }, + }, + included: [ + { + type: "resources", + id: resourceId, + attributes: { + uid: String(resourceNode?.properties.arn ?? resourceId), + name: String(resourceNode?.properties.name ?? resourceId), + region: "us-east-1", + service: String(resourceNode?.properties.service ?? "s3"), + tags: {}, + type: String(resourceNode?.labels[0] ?? "Resource"), + inserted_at: "2026-04-21T10:00:00Z", + updated_at: "2026-04-21T10:05:00Z", + details: null, + partition: null, + }, + }, + { + type: "scans", + id: scan?.id ?? "scan-1", + attributes: { + name: "Attack path scan", + trigger: "manual", + state: scan?.attributes.state ?? "completed", + unique_resource_count: 1, + progress: scan?.attributes.progress ?? 100, + duration: scan?.attributes.duration ?? 0, + started_at: scan?.attributes.started_at ?? "2026-04-21T10:00:00Z", + inserted_at: scan?.attributes.inserted_at ?? "2026-04-21T10:00:00Z", + completed_at: scan?.attributes.completed_at ?? "2026-04-21T10:05:00Z", + scheduled_at: null, + next_scan_at: "", + }, + relationships: { + provider: { data: { type: "providers", id: providerId } }, + }, + }, + { + type: "providers", + id: providerId, + attributes: { + provider: scan?.attributes.provider_type ?? "aws", + uid: scan?.attributes.provider_uid ?? "123456789", + alias: scan?.attributes.provider_alias ?? "Provider", + connection: { + connected: true, + last_checked_at: "2026-04-21T10:00:00Z", + }, + inserted_at: "2026-04-21T10:00:00Z", + updated_at: "2026-04-21T10:05:00Z", + }, + }, + ], + }; +}; + +export const handlersForFixture = (fx: PageFixture) => [ + http.get(`${API}/attack-paths-scans`, () => + HttpResponse.json(toScansApiResponse(fx.scans)), + ), + + http.get<{ scanId: string }>( + `${API}/attack-paths-scans/:scanId/queries`, + () => + HttpResponse.json( + toQueriesApiResponse(fx.queries), + ), + ), + + http.post<{ scanId: string }>( + `${API}/attack-paths-scans/:scanId/queries/run`, + () => { + if (fx.queryError) { + return HttpResponse.json( + toErrorBody(fx.queryError.error, fx.queryError.status), + { status: fx.queryError.status }, + ); + } + if (!fx.queryResult) { + return HttpResponse.json( + toErrorBody("No data found", 404), + { status: 404 }, + ); + } + return HttpResponse.json( + toQueryResultApiResponse(fx.queryResult, fx.queryId), + ); + }, + ), + + http.post<{ scanId: string }>( + `${API}/attack-paths-scans/:scanId/queries/custom`, + () => { + if (fx.queryError) { + return HttpResponse.json( + toErrorBody(fx.queryError.error, fx.queryError.status), + { status: fx.queryError.status }, + ); + } + if (!fx.queryResult) { + return HttpResponse.json( + toErrorBody("No data found", 404), + { status: 404 }, + ); + } + return HttpResponse.json( + toQueryResultApiResponse(fx.queryResult, fx.queryId), + ); + }, + ), + + http.get<{ findingId: string }>(`${API}/findings/:findingId`, ({ params }) => + HttpResponse.json(toFindingApiResponse(fx, params.findingId)), + ), +]; diff --git a/ui/__tests__/msw/handlers/index.ts b/ui/__tests__/msw/handlers/index.ts new file mode 100644 index 0000000000..5859c31b8d --- /dev/null +++ b/ui/__tests__/msw/handlers/index.ts @@ -0,0 +1,13 @@ +import type { HttpHandler } from "msw"; + +/** + * Static handlers shared by every browser test — registered as defaults on + * the worker. Use this list for endpoints whose response doesn't change + * across tests (e.g. `/users/me`, `/tenants/current`, health checks). + * + * Per-domain dynamic handlers that depend on fixture data live in their own + * files alongside this index (e.g. `./attack-paths.ts`) and are imported + * directly by the tests that need them, then wired via + * `worker.use(...handlersForFixture(fx))`. + */ +export const handlers: HttpHandler[] = []; diff --git a/ui/__tests__/msw/worker.ts b/ui/__tests__/msw/worker.ts new file mode 100644 index 0000000000..318d6f4a03 --- /dev/null +++ b/ui/__tests__/msw/worker.ts @@ -0,0 +1,5 @@ +import { setupWorker } from "msw/browser"; + +import { handlers } from "./handlers"; + +export const worker = setupWorker(...handlers); diff --git a/ui/__tests__/render-browser.tsx b/ui/__tests__/render-browser.tsx new file mode 100644 index 0000000000..5ae56d4fec --- /dev/null +++ b/ui/__tests__/render-browser.tsx @@ -0,0 +1,25 @@ +import type { ComponentType, PropsWithChildren, ReactElement } from "react"; +import { render as vitestRender } from "vitest-browser-react"; + +const TestProviders = ({ children }: PropsWithChildren) => <>{children}; + +type RenderOptions = Parameters[1]; + +export function render(ui: ReactElement, options?: RenderOptions) { + const userWrapper = options?.wrapper as + | ComponentType + | undefined; + + const Wrapper = userWrapper + ? ({ children }: PropsWithChildren) => { + const Inner = userWrapper; + return ( + + {children} + + ); + } + : TestProviders; + + return vitestRender(ui, { ...options, wrapper: Wrapper }); +} diff --git a/ui/actions/attack-paths/queries.adapter.test.ts b/ui/actions/attack-paths/queries.adapter.test.ts index cd6bafd206..77298991f3 100644 --- a/ui/actions/attack-paths/queries.adapter.test.ts +++ b/ui/actions/attack-paths/queries.adapter.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; +import { DOCS_URLS } from "@/lib/external-urls"; import { ATTACK_PATH_QUERY_IDS, - type AttackPathCartographySchemaAttributes, type AttackPathQuery, } from "@/types/attack-paths"; @@ -22,20 +22,9 @@ const presetQuery: AttackPathQuery = { }; describe("buildAttackPathQueries", () => { - it("prepends a custom query with a schema documentation link", () => { - // Given - const schema: AttackPathCartographySchemaAttributes = { - id: "aws-0.129.0", - provider: "aws", - cartography_version: "0.129.0", - schema_url: - "https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md", - raw_schema_url: - "https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md", - }; - + it("prepends a custom query that links to the Prowler documentation", () => { // When - const result = buildAttackPathQueries([presetQuery], schema); + const result = buildAttackPathQueries([presetQuery]); // Then expect(result[0]).toMatchObject({ @@ -44,8 +33,8 @@ describe("buildAttackPathQueries", () => { name: "Custom openCypher query", short_description: "Write and run your own read-only query", documentation_link: { - text: "Cartography schema used by Prowler for AWS graphs", - link: schema.schema_url, + text: "Learn how to write custom openCypher queries", + link: DOCS_URLS.ATTACK_PATHS_CUSTOM_QUERIES, }, }, }); diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts index 0b3120bb0a..b4635ed333 100644 --- a/ui/actions/attack-paths/queries.adapter.ts +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -1,7 +1,7 @@ +import { DOCS_URLS } from "@/lib/external-urls"; import { MetaDataProps } from "@/types"; import { ATTACK_PATH_QUERY_IDS, - type AttackPathCartographySchemaAttributes, AttackPathQueriesResponse, AttackPathQuery, QUERY_PARAMETER_INPUT_TYPES, @@ -61,15 +61,12 @@ const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n) RETURN n LIMIT 25`; -const formatSchemaDocumentationLinkText = ( - schema: AttackPathCartographySchemaAttributes, -): string => { - return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`; -}; +const CUSTOM_QUERY_DOCUMENTATION_LINK = { + text: "Learn how to write custom openCypher queries", + link: DOCS_URLS.ATTACK_PATHS_CUSTOM_QUERIES, +} as const; -const createCustomQuery = ( - schema?: AttackPathCartographySchemaAttributes, -): AttackPathQuery => ({ +const createCustomQuery = (): AttackPathQuery => ({ type: "attack-paths-scans", id: ATTACK_PATH_QUERY_IDS.CUSTOM, attributes: { @@ -79,12 +76,7 @@ const createCustomQuery = ( "Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.", provider: "custom", attribution: null, - documentation_link: schema - ? { - text: formatSchemaDocumentationLinkText(schema), - link: schema.schema_url, - } - : null, + documentation_link: { ...CUSTOM_QUERY_DOCUMENTATION_LINK }, parameters: [ { name: "query", @@ -103,7 +95,6 @@ const createCustomQuery = ( export const buildAttackPathQueries = ( queries: AttackPathQuery[], - schema?: AttackPathCartographySchemaAttributes, ): AttackPathQuery[] => { - return [createCustomQuery(schema), ...queries]; + return [createCustomQuery(), ...queries]; }; diff --git a/ui/actions/attack-paths/query-result.adapter.ts b/ui/actions/attack-paths/query-result.adapter.ts index 65b33843af..93bf38aca0 100644 --- a/ui/actions/attack-paths/query-result.adapter.ts +++ b/ui/actions/attack-paths/query-result.adapter.ts @@ -131,27 +131,16 @@ export function adaptQueryResultToGraphData( // Populate findings and resources based on HAS_FINDING edges edges.forEach((edge) => { if (edge.type === "HAS_FINDING") { - const sourceId = - typeof edge.source === "string" - ? edge.source - : (edge.source as { id?: string })?.id; - const targetId = - typeof edge.target === "string" - ? edge.target - : (edge.target as { id?: string })?.id; + // Add finding to source node (resource -> finding) + const sourceNode = normalizedNodes.find((n) => n.id === edge.source); + if (sourceNode) { + sourceNode.findings.push(edge.target); + } - if (sourceId && targetId) { - // Add finding to source node (resource -> finding) - const sourceNode = normalizedNodes.find((n) => n.id === sourceId); - if (sourceNode) { - sourceNode.findings.push(targetId); - } - - // Add resource to target node (finding <- resource) - const targetNode = normalizedNodes.find((n) => n.id === targetId); - if (targetNode) { - targetNode.resources.push(sourceId); - } + // Add resource to target node (finding <- resource) + const targetNode = normalizedNodes.find((n) => n.id === edge.target); + if (targetNode) { + targetNode.resources.push(edge.source); } } }); diff --git a/ui/actions/attack-paths/scans.adapter.test.ts b/ui/actions/attack-paths/scans.adapter.test.ts new file mode 100644 index 0000000000..aa7ef1f988 --- /dev/null +++ b/ui/actions/attack-paths/scans.adapter.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + type AttackPathScan, + type AttackPathScansResponse, + SCAN_STATES, +} from "@/types/attack-paths"; + +import { adaptAttackPathScansResponse } from "./scans.adapter"; + +const makeScan = ( + id: string, + overrides: Partial = {}, +): AttackPathScan => ({ + type: "attack-paths-scans", + id, + attributes: { + state: SCAN_STATES.COMPLETED, + progress: 100, + graph_data_ready: true, + provider_alias: `alias-${id}`, + provider_type: "aws", + provider_uid: id, + inserted_at: "2026-04-23T10:00:00Z", + started_at: "2026-04-23T10:00:00Z", + completed_at: "2026-04-23T10:10:00Z", + duration: 600, + ...overrides, + }, + relationships: {} as AttackPathScan["relationships"], +}); + +describe("adaptAttackPathScansResponse", () => { + it("returns an empty list when the response is undefined", () => { + // When + const result = adaptAttackPathScansResponse(undefined); + + // Then + expect(result).toEqual({ data: [] }); + }); + + it("enriches each scan with durationLabel and isRecent", () => { + // Given a scan that completed recently + const recentCompletion = new Date( + Date.now() - 60 * 60 * 1000, + ).toISOString(); + const response: AttackPathScansResponse = { + data: [makeScan("s1", { completed_at: recentCompletion, duration: 90 })], + links: { first: "", last: "", next: null, prev: null }, + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }; + + // When + const result = adaptAttackPathScansResponse(response); + + // Then + expect(result.data).toHaveLength(1); + const enriched = result.data[0] + .attributes as (typeof result.data)[0]["attributes"] & { + durationLabel: string | null; + isRecent: boolean; + }; + expect(enriched.durationLabel).toBeDefined(); + expect(enriched.isRecent).toBe(true); + }); + + it("surfaces meta.pagination values unchanged in the adapted metadata", () => { + // Given a paginated API response + const response: AttackPathScansResponse = { + data: [makeScan("s1"), makeScan("s2")], + links: { first: "", last: "", next: null, prev: null }, + meta: { pagination: { page: 3, pages: 5, count: 42 }, version: "2.0" }, + }; + + // When + const result = adaptAttackPathScansResponse(response); + + // Then + expect(result.metadata).toEqual({ + pagination: { + page: 3, + pages: 5, + count: 42, + itemsPerPage: [5, 10, 25, 50, 100], + }, + version: "2.0", + }); + }); + + it("omits metadata when the response has no pagination info", () => { + // Given + const response: AttackPathScansResponse = { + data: [makeScan("s1")], + links: { first: "", last: "", next: null, prev: null }, + }; + + // When + const result = adaptAttackPathScansResponse(response); + + // Then + expect(result.metadata).toBeUndefined(); + }); +}); diff --git a/ui/actions/attack-paths/scans.adapter.ts b/ui/actions/attack-paths/scans.adapter.ts index e6ef4168e2..02456e58fd 100644 --- a/ui/actions/attack-paths/scans.adapter.ts +++ b/ui/actions/attack-paths/scans.adapter.ts @@ -44,18 +44,15 @@ export function adaptAttackPathScansResponse( }, })); - // Transform links to MetaDataProps format if pagination exists - const metadata: MetaDataProps | undefined = response.links + const metadata: MetaDataProps | undefined = response.meta?.pagination ? { pagination: { - // Links-based pagination doesn't have traditional page numbers - // but we preserve the structure for consistency - page: 1, - pages: 1, - count: enrichedData.length, - itemsPerPage: [10, 25, 50, 100], + page: response.meta.pagination.page, + pages: response.meta.pagination.pages, + count: response.meta.pagination.count, + itemsPerPage: [5, 10, 25, 50, 100], }, - version: "1.0", + version: response.meta.version ?? "1.0", } : undefined; diff --git a/ui/actions/attack-paths/scans.test.ts b/ui/actions/attack-paths/scans.test.ts new file mode 100644 index 0000000000..a4ae05bbcf --- /dev/null +++ b/ui/actions/attack-paths/scans.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + type AttackPathScan, + type AttackPathScansResponse, + SCAN_STATES, +} from "@/types/attack-paths"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +import { getAttackPathScans } from "./scans"; + +const makeScan = (id: string): AttackPathScan => ({ + type: "attack-paths-scans", + id, + attributes: { + state: SCAN_STATES.COMPLETED, + progress: 100, + graph_data_ready: true, + provider_alias: `alias-${id}`, + provider_type: "aws", + provider_uid: id, + inserted_at: "2026-04-23T10:00:00Z", + started_at: "2026-04-23T10:00:00Z", + completed_at: "2026-04-23T10:10:00Z", + duration: 600, + }, + relationships: {} as AttackPathScan["relationships"], +}); + +const pageResponse = ( + ids: string[], + page: number, + pages: number, + count: number, +): AttackPathScansResponse => ({ + data: ids.map(makeScan), + links: { + first: "first", + last: "last", + next: page < pages ? "next" : null, + prev: page > 1 ? "prev" : null, + }, + meta: { + pagination: { page, pages, count }, + }, +}); + +const getFetchedPageNumber = (call: unknown[]) => { + const url = new URL(String(call[0])); + return Number(url.searchParams.get("page[number]")); +}; + +describe("getAttackPathScans", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + }); + + it("requests page[size]=100 with page[number]=1 on the first call", async () => { + // Given + handleApiResponseMock.mockResolvedValueOnce(pageResponse(["s1"], 1, 1, 1)); + + // When + await getAttackPathScans(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]; + const url = new URL(String(call[0])); + expect(url.pathname).toBe("/api/v1/attack-paths-scans"); + expect(url.searchParams.get("page[number]")).toBe("1"); + expect(url.searchParams.get("page[size]")).toBe("100"); + }); + + it("iterates across every backend page and aggregates all scans", async () => { + // Given three pages totalling 22 scans + handleApiResponseMock + .mockResolvedValueOnce( + pageResponse( + Array.from({ length: 10 }, (_, i) => `a${i}`), + 1, + 3, + 22, + ), + ) + .mockResolvedValueOnce( + pageResponse( + Array.from({ length: 10 }, (_, i) => `b${i}`), + 2, + 3, + 22, + ), + ) + .mockResolvedValueOnce(pageResponse(["c0", "c1"], 3, 3, 22)); + + // When + const result = await getAttackPathScans(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls.map(getFetchedPageNumber)).toEqual([1, 2, 3]); + expect(result?.data).toHaveLength(22); + }); + + it("stops requesting when the current page equals meta.pagination.pages", async () => { + // Given a single-page response + handleApiResponseMock.mockResolvedValueOnce( + pageResponse(["only"], 1, 1, 1), + ); + + // When + const result = await getAttackPathScans(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result?.data).toHaveLength(1); + }); + + it("stops when a page returns an empty data array", async () => { + // Given the second page is unexpectedly empty + handleApiResponseMock + .mockResolvedValueOnce(pageResponse(["a0"], 1, 3, 3)) + .mockResolvedValueOnce({ + data: [], + links: { first: "", last: "", next: null, prev: null }, + meta: { pagination: { page: 2, pages: 3, count: 3 } }, + } as AttackPathScansResponse); + + // When + const result = await getAttackPathScans(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.data).toHaveLength(1); + }); + + it("returns undefined when the first fetch throws", async () => { + // Given + handleApiResponseMock.mockRejectedValueOnce(new Error("network down")); + + // When + const result = await getAttackPathScans(); + + // Then + expect(result).toBeUndefined(); + }); + + it("preserves scans from earlier pages when a later fetch throws", async () => { + // Given the first page resolves but the second page errors mid-iteration + handleApiResponseMock + .mockResolvedValueOnce(pageResponse(["a0", "a1"], 1, 3, 5)) + .mockRejectedValueOnce(new Error("network blip")); + + // When + const result = await getAttackPathScans(); + + // Then we keep the scans we already fetched instead of discarding everything + expect(result?.data).toHaveLength(2); + expect(result?.data.map((scan) => scan.id)).toEqual(["a0", "a1"]); + }); + + it("returns an empty list when the first page has no data", async () => { + // Given + handleApiResponseMock.mockResolvedValueOnce(undefined); + + // When + const result = await getAttackPathScans(); + + // Then + expect(result).toEqual({ data: [] }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/actions/attack-paths/scans.ts b/ui/actions/attack-paths/scans.ts index 11342f6ad3..ec3bc927af 100644 --- a/ui/actions/attack-paths/scans.ts +++ b/ui/actions/attack-paths/scans.ts @@ -8,33 +8,79 @@ import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths"; import { adaptAttackPathScansResponse } from "./scans.adapter"; -// Validation schema for UUID - RFC 9562/4122 compliant const UUIDSchema = z.uuid(); +const ATTACK_PATH_SCANS_PAGE_SIZE = 100; +const ATTACK_PATH_SCANS_MAX_PAGES = 50; + /** - * Fetch list of attack path scans (latest scan for each provider) + * Fetch list of attack path scans (latest scan for each provider). + * + * Iterates through every backend page so callers receive the complete + * dedup'd dataset along with an accurate total count. The underlying + * endpoint is paginated server-side (default page_size=10), so fetching + * only the first page would silently hide providers beyond that window. */ export const getAttackPathScans = async (): Promise< { data: AttackPathScan[] } | undefined > => { const headers = await getAuthHeaders({ contentType: false }); + const allScans: AttackPathScan[] = []; + let currentPage = 1; + let lastResponse: AttackPathScansResponse | undefined; + let hasMorePages = true; - try { - const response = await fetch(`${apiBaseUrl}/attack-paths-scans`, { - headers, - method: "GET", - }); + while (hasMorePages && currentPage <= ATTACK_PATH_SCANS_MAX_PAGES) { + try { + const url = new URL(`${apiBaseUrl}/attack-paths-scans`); + url.searchParams.append("page[number]", currentPage.toString()); + url.searchParams.append( + "page[size]", + ATTACK_PATH_SCANS_PAGE_SIZE.toString(), + ); - const apiResponse = (await handleApiResponse( - response, - )) as AttackPathScansResponse; - const adaptedData = adaptAttackPathScansResponse(apiResponse); + const response = await fetch(url.toString(), { + headers, + method: "GET", + }); - return { data: adaptedData.data }; - } catch (error) { - console.error("Error fetching attack path scans:", error); - return undefined; + const data = (await handleApiResponse(response)) as + | AttackPathScansResponse + | undefined; + + if (!data?.data || data.data.length === 0) { + hasMorePages = false; + continue; + } + + allScans.push(...data.data); + lastResponse = data; + + const totalPages = data.meta?.pagination?.pages ?? 1; + if (currentPage >= totalPages) { + hasMorePages = false; + } else { + currentPage++; + } + } catch (error) { + console.error("Error fetching attack path scans:", error); + if (allScans.length === 0) { + return undefined; + } + break; + } } + + if (!lastResponse) { + return { data: [] }; + } + + const adapted = adaptAttackPathScansResponse({ + ...lastResponse, + data: allScans, + }); + + return { data: adapted.data }; }; /** diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts new file mode 100644 index 0000000000..80409dd7e3 --- /dev/null +++ b/ui/actions/auth/auth.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})); + +vi.mock("next-auth", () => ({ + AuthError: class AuthError extends Error {}, +})); + +vi.mock("@/auth.config", () => ({ + signIn: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", +})); + +vi.mock("@/lib/sentry-breadcrumbs", () => ({ + addAuthEvent: vi.fn(), +})); + +import { createNewUser } from "./auth"; + +describe("auth actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("should preserve HTTP status when user creation fails", async () => { + // Given + const apiResponse = { + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }, + ], + }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify(apiResponse), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + // When + const result = await createNewUser({ + name: "Jane Doe", + email: "jane@example.com", + password: "TestPassword123!", + confirmPassword: "TestPassword123!", + company: "Prowler", + invitationToken: "invitation-token", + termsAndConditions: undefined, + isSamlMode: false, + }); + + // Then + expect(result).toEqual({ ...apiResponse, status: 400 }); + }); +}); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 8b777cad10..f59d031918 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -78,7 +78,7 @@ export const createNewUser = async (formData: SignUpFormData) => { const parsedResponse = await response.json(); if (!response.ok) { - return parsedResponse; + return { ...parsedResponse, status: response.status }; } return parsedResponse; @@ -172,6 +172,7 @@ export const getUserByMe = async (accessToken: string) => { manage_scans: userRole.attributes.manage_scans || false, manage_integrations: userRole.attributes.manage_integrations || false, manage_billing: userRole.attributes.manage_billing || false, + manage_alerts: userRole.attributes.manage_alerts || false, unlimited_visibility: userRole.attributes.unlimited_visibility || false, }; diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index b23723f670..e06b06d29d 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -6,12 +6,10 @@ import { handleApiResponse } from "@/lib/server-actions-helper"; export const getCompliancesOverview = async ({ scanId, region, - query, filters = {}, }: { scanId?: string; region?: string | string[]; - query?: string; filters?: Record; } = {}) => { const headers = await getAuthHeaders({ contentType: false }); @@ -31,8 +29,6 @@ export const getCompliancesOverview = async ({ setParam("filter[scan_id]", scanId); setParam("filter[region__in]", region); - if (query) url.searchParams.set("filter[search]", query); - try { const response = await fetch(url.toString(), { headers, @@ -46,15 +42,16 @@ export const getCompliancesOverview = async ({ }; export const getComplianceOverviewMetadataInfo = async ({ - query = "", sort = "", filters = {}, -}) => { +}: { + sort?: string; + filters?: Record; +} = {}) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/compliance-overviews/metadata`); - if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); Object.entries(filters).forEach(([key, value]) => { @@ -76,17 +73,33 @@ export const getComplianceOverviewMetadataInfo = async ({ } }; -export const getComplianceAttributes = async (complianceId: string) => { +export const getComplianceAttributes = async ( + complianceId: string, + scanId?: string, +) => { const headers = await getAuthHeaders({ contentType: false }); try { const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`); url.searchParams.append("filter[compliance_id]", complianceId); + // Pass the scan so multi-provider universal frameworks (e.g. CSA CCM) + // resolve the check IDs for the scan's provider instead of defaulting to + // the first provider that declares the framework. + if (scanId) { + url.searchParams.append("filter[scan_id]", scanId); + } const response = await fetch(url.toString(), { headers, }); + // The compliance catalog is still warming after a deploy/restart. Signal + // the page to render the "still loading" state instead of letting this + // become a thrown 5xx (which would be captured as a server error). + if (response.status === 503) { + return { warming: true as const, status: 503 }; + } + return handleApiResponse(response); } catch (error) { console.error("Error fetching compliance attributes:", error); diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts index 5874ddf7e5..c9b4a0314c 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.test.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -78,14 +78,31 @@ describe("adaptFindingGroupsResponse — malformed input", () => { check_description: null, severity: "critical", status: "FAIL", + muted: true, impacted_providers: ["aws"], resources_total: 5, resources_fail: 3, pass_count: 2, fail_count: 3, + manual_count: 1, + pass_muted_count: 0, + fail_muted_count: 3, + manual_muted_count: 0, muted_count: 0, new_count: 1, changed_count: 0, + new_fail_count: 0, + new_fail_muted_count: 1, + new_pass_count: 0, + new_pass_muted_count: 0, + new_manual_count: 0, + new_manual_muted_count: 0, + changed_fail_count: 0, + changed_fail_muted_count: 0, + changed_pass_count: 0, + changed_pass_muted_count: 0, + changed_manual_count: 0, + changed_manual_muted_count: 0, first_seen_at: null, last_seen_at: "2024-01-01T00:00:00Z", failing_since: null, @@ -101,6 +118,9 @@ describe("adaptFindingGroupsResponse — malformed input", () => { expect(result).toHaveLength(1); expect(result[0].checkId).toBe("s3_bucket_public_access"); expect(result[0].checkTitle).toBe("S3 Bucket Public Access"); + expect(result[0].muted).toBe(true); + expect(result[0].manualCount).toBe(1); + expect(result[0].newFailMutedCount).toBe(1); }); }); @@ -149,6 +169,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { id: "resource-row-1", type: "finding-group-resources", attributes: { + finding_id: "real-finding-uuid", resource: { uid: "arn:aws:s3:::my-bucket", name: "my-bucket", @@ -163,6 +184,8 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { alias: "production", }, status: "FAIL", + muted: true, + delta: "new", severity: "critical", first_seen_at: null, last_seen_at: "2024-01-01T00:00:00Z", @@ -176,7 +199,10 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { // Then expect(result).toHaveLength(1); + expect(result[0].findingId).toBe("real-finding-uuid"); expect(result[0].checkId).toBe("s3_check"); expect(result[0].resourceName).toBe("my-bucket"); + expect(result[0].delta).toBe("new"); + expect(result[0].isMuted).toBe(true); }); }); diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index 593171078d..6b78bc189a 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -19,15 +19,32 @@ interface FindingGroupAttributes { check_title: string | null; check_description: string | null; severity: string; - status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase) + status: string; // "FAIL" | "PASS" | "MANUAL" (already uppercase) + muted?: boolean; impacted_providers: string[]; resources_total: number; resources_fail: number; pass_count: number; fail_count: number; + manual_count?: number; + pass_muted_count?: number; + fail_muted_count?: number; + manual_muted_count?: number; muted_count: number; new_count: number; changed_count: number; + new_fail_count?: number; + new_fail_muted_count?: number; + new_pass_count?: number; + new_pass_muted_count?: number; + new_manual_count?: number; + new_manual_muted_count?: number; + changed_fail_count?: number; + changed_fail_muted_count?: number; + changed_pass_count?: number; + changed_pass_muted_count?: number; + changed_manual_count?: number; + changed_manual_muted_count?: number; first_seen_at: string | null; last_seen_at: string | null; failing_since: string | null; @@ -62,10 +79,33 @@ export function adaptFindingGroupsResponse( checkTitle: item.attributes.check_title || item.attributes.check_id, severity: item.attributes.severity as Severity, status: item.attributes.status as FindingStatus, + muted: + item.attributes.muted ?? + (item.attributes.muted_count > 0 && + (item.attributes.muted_count === item.attributes.resources_fail || + item.attributes.muted_count === item.attributes.resources_total)), resourcesTotal: item.attributes.resources_total, resourcesFail: item.attributes.resources_fail, + passCount: item.attributes.pass_count, + failCount: item.attributes.fail_count, + manualCount: item.attributes.manual_count ?? 0, + passMutedCount: item.attributes.pass_muted_count ?? 0, + failMutedCount: item.attributes.fail_muted_count ?? 0, + manualMutedCount: item.attributes.manual_muted_count ?? 0, newCount: item.attributes.new_count, changedCount: item.attributes.changed_count, + newFailCount: item.attributes.new_fail_count ?? 0, + newFailMutedCount: item.attributes.new_fail_muted_count ?? 0, + newPassCount: item.attributes.new_pass_count ?? 0, + newPassMutedCount: item.attributes.new_pass_muted_count ?? 0, + newManualCount: item.attributes.new_manual_count ?? 0, + newManualMutedCount: item.attributes.new_manual_muted_count ?? 0, + changedFailCount: item.attributes.changed_fail_count ?? 0, + changedFailMutedCount: item.attributes.changed_fail_muted_count ?? 0, + changedPassCount: item.attributes.changed_pass_count ?? 0, + changedPassMutedCount: item.attributes.changed_pass_muted_count ?? 0, + changedManualCount: item.attributes.changed_manual_count ?? 0, + changedManualMutedCount: item.attributes.changed_manual_muted_count ?? 0, mutedCount: item.attributes.muted_count, providers: (item.attributes.impacted_providers || []) as ProviderType[], updatedAt: item.attributes.last_seen_at || "", @@ -95,9 +135,13 @@ interface ProviderInfo { } interface FindingGroupResourceAttributes { + finding_id: string; resource: ResourceInfo; provider: ProviderInfo; status: string; + status_extended?: string; + muted?: boolean; + delta?: string | null; severity: string; first_seen_at: string | null; last_seen_at: string | null; @@ -131,20 +175,22 @@ export function adaptFindingGroupResourcesResponse( return data.map((item) => ({ id: item.id, rowType: FINDINGS_ROW_TYPE.RESOURCE, - findingId: item.id, + findingId: item.attributes.finding_id || item.id, checkId, providerType: (item.attributes.provider?.type || "aws") as ProviderType, providerAlias: item.attributes.provider?.alias || "", providerUid: item.attributes.provider?.uid || "", resourceName: item.attributes.resource?.name || "-", + resourceType: item.attributes.resource?.type || "-", resourceGroup: item.attributes.resource?.resource_group || "-", resourceUid: item.attributes.resource?.uid || "-", service: item.attributes.resource?.service || "-", region: item.attributes.resource?.region || "-", severity: (item.attributes.severity || "informational") as Severity, status: item.attributes.status, - isMuted: item.attributes.status === "MUTED", - // TODO: remove fallback once the API returns muted_reason in finding-group-resources + statusExtended: item.attributes.status_extended, + delta: item.attributes.delta || null, + isMuted: item.attributes.muted ?? item.attributes.status === "MUTED", mutedReason: item.attributes.muted_reason || undefined, firstSeenAt: item.attributes.first_seen_at, lastSeenAt: item.attributes.last_seen_at, diff --git a/ui/actions/finding-groups/finding-groups.test.ts b/ui/actions/finding-groups/finding-groups.test.ts index 0d8c2df53a..00c18e128e 100644 --- a/ui/actions/finding-groups/finding-groups.test.ts +++ b/ui/actions/finding-groups/finding-groups.test.ts @@ -12,9 +12,31 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( }), ); +// Real helpers/constants pulled from submodules that don't import server-only +// code, so the mock factory stays free of top-level variable hoisting issues +// and the vitest runtime doesn't choke on next-auth's `next/server` import. +import { + includesMutedFindings, + splitCsvFilterValues, +} from "@/lib/findings-filters"; +import { + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, +} from "@/lib/findings-sort"; + vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + includesMutedFindings, + splitCsvFilterValues, })); vi.mock("@/lib/provider-filters", () => ({ @@ -44,17 +66,87 @@ vi.mock("next/navigation", () => ({ import { getFindingGroupResources, + getFindingGroups, getLatestFindingGroupResources, + getLatestFindingGroups, } from "./finding-groups"; -// --------------------------------------------------------------------------- -// Blocker 1 + 2: FAIL-first sort and FAIL-only filter for drill-down resources -// --------------------------------------------------------------------------- - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- +describe("getFindingGroups — default sort for muted and non-muted rows", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should prefer non-muted fail counters when include muted is not active", async () => { + // When + await getFindingGroups(); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at", + ); + }); + + it("should include muted counters when filter[muted]=include is active", async () => { + // When + await getFindingGroups({ + filters: { "filter[muted]": "include" }, + }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at", + ); + }); +}); + +describe("getLatestFindingGroups — default sort for muted and non-muted rows", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should prefer non-muted fail counters when include muted is not active", async () => { + // When + await getLatestFindingGroups(); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at", + ); + }); + + it("should include muted counters when filter[muted]=include is active", async () => { + // When + await getLatestFindingGroups({ + filters: { "filter[muted]": "include" }, + }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at", + ); + }); +}); + describe("getFindingGroupResources — SSRF path traversal protection", () => { beforeEach(() => { vi.clearAllMocks(); @@ -169,7 +261,7 @@ describe("getLatestFindingGroupResources — SSRF path traversal protection", () }); // --------------------------------------------------------------------------- -// Blocker 1: Resources list must show FAIL first (sort=-status) +// Resources list keeps FAIL-first sort but no longer forces FAIL-only filtering // --------------------------------------------------------------------------- describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => { @@ -181,30 +273,32 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => { fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should include sort=-status in the API call so FAIL resources appear first", async () => { + it("should include the composite sort so FAIL resources appear first, then severity", async () => { // Given const checkId = "s3_bucket_public_access"; // When await getFindingGroupResources({ checkId }); - // Then — the URL must contain sort=-status + // Then — the URL must contain the composite sort const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.get("sort")).toBe("-status"); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-delta,-last_seen_at", + ); }); - it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => { + it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => { // Given const checkId = "s3_bucket_public_access"; // When await getFindingGroupResources({ checkId }); - // Then — the URL must contain filter[status]=FAIL + // Then — the URL should not add a hardcoded status filter const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); @@ -217,7 +311,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should include sort=-status in the API call so FAIL resources appear first", async () => { + it("should include the composite sort so FAIL resources appear first, then severity", async () => { // Given const checkId = "iam_user_mfa_enabled"; @@ -227,10 +321,12 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => // Then const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.get("sort")).toBe("-status"); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-delta,-last_seen_at", + ); }); - it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => { + it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => { // Given const checkId = "iam_user_mfa_enabled"; @@ -240,7 +336,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => // Then const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); @@ -257,7 +353,7 @@ describe("getFindingGroupResources — triangulation: params coexist", () => { fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => { + it("should send the composite sort alongside pagination params without forcing filter[status]", async () => { // Given const checkId = "s3_bucket_versioning"; @@ -269,8 +365,10 @@ describe("getFindingGroupResources — triangulation: params coexist", () => { const url = new URL(calledUrl); expect(url.searchParams.get("page[number]")).toBe("2"); expect(url.searchParams.get("page[size]")).toBe("50"); - expect(url.searchParams.get("sort")).toBe("-status"); - expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-delta,-last_seen_at", + ); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); @@ -283,7 +381,7 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", () fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => { + it("should send the composite sort alongside pagination params without forcing filter[status]", async () => { // Given const checkId = "iam_root_mfa_enabled"; @@ -295,16 +393,18 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", () const url = new URL(calledUrl); expect(url.searchParams.get("page[number]")).toBe("3"); expect(url.searchParams.get("page[size]")).toBe("20"); - expect(url.searchParams.get("sort")).toBe("-status"); - expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-delta,-last_seen_at", + ); + expect(url.searchParams.get("filter[status]")).toBeNull(); }); }); // --------------------------------------------------------------------------- -// Blocker: Duplicate filter[status] — caller-supplied status must be stripped +// Caller filters should propagate unchanged to the drill-down resources endpoint // --------------------------------------------------------------------------- -describe("getFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => { +describe("getFindingGroupResources — caller filters are preserved", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); @@ -313,23 +413,7 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => { - // Given — caller explicitly passes PASS, which must be ignored - const checkId = "s3_bucket_public_access"; - const filters = { "filter[status]": "PASS" }; - - // When - await getFindingGroupResources({ checkId, filters }); - - // Then — the final URL must have exactly one filter[status]=FAIL, not PASS - const calledUrl = fetchMock.mock.calls[0][0] as string; - const url = new URL(calledUrl); - const allStatusValues = url.searchParams.getAll("filter[status]"); - expect(allStatusValues).toHaveLength(1); - expect(allStatusValues[0]).toBe("FAIL"); - }); - - it("should not have duplicate filter[status] params when caller passes filter[status]", async () => { + it("should preserve caller filter[status] when explicitly provided", async () => { // Given const checkId = "s3_bucket_public_access"; const filters = { "filter[status]": "PASS" }; @@ -337,14 +421,80 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always // When await getFindingGroupResources({ checkId, filters }); - // Then — no duplicates + // Then const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.getAll("filter[status]")).toHaveLength(1); + const allStatusValues = url.searchParams.getAll("filter[status]"); + expect(allStatusValues).toHaveLength(1); + expect(allStatusValues[0]).toBe("PASS"); + }); + + it("should translate a single group status__in filter into filter[status] for resources", async () => { + // Given + const checkId = "s3_bucket_public_access"; + const filters = { + "filter[status__in]": "PASS", + "filter[severity__in]": "medium", + "filter[provider_type__in]": "aws", + }; + + // When + await getFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[status]")).toBe("PASS"); + expect(url.searchParams.get("filter[status__in]")).toBeNull(); + expect(url.searchParams.get("filter[severity__in]")).toBe("medium"); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws"); + }); + + it("should keep the composite sort when the resource search filter is applied", async () => { + // Given + const checkId = "s3_bucket_public_access"; + const filters = { + "filter[name__icontains]": "bucket-prod", + "filter[severity__in]": "high", + }; + + // When + await getFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-delta,-last_seen_at", + ); + expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod"); + expect(url.searchParams.get("filter[severity__in]")).toBe("high"); + }); + + it("should strip scan filters that the group resources endpoint does not accept", async () => { + // Given + const checkId = "s3_bucket_public_access"; + const filters = { + "filter[scan__in]": "scan-1", + "filter[scan_id]": "scan-1", + "filter[scan_id__in]": "scan-1,scan-2", + "filter[region__in]": "eu-west-1", + }; + + // When + await getFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[scan__in]")).toBeNull(); + expect(url.searchParams.get("filter[scan_id]")).toBeNull(); + expect(url.searchParams.get("filter[scan_id__in]")).toBeNull(); + expect(url.searchParams.get("filter[region__in]")).toBe("eu-west-1"); }); }); -describe("getLatestFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => { +describe("getLatestFindingGroupResources — caller filters are preserved", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); @@ -353,23 +503,7 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a fetchMock.mockResolvedValue(new Response("", { status: 200 })); }); - it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => { - // Given — caller explicitly passes PASS, which must be ignored - const checkId = "iam_user_mfa_enabled"; - const filters = { "filter[status]": "PASS" }; - - // When - await getLatestFindingGroupResources({ checkId, filters }); - - // Then — the final URL must have exactly one filter[status]=FAIL, not PASS - const calledUrl = fetchMock.mock.calls[0][0] as string; - const url = new URL(calledUrl); - const allStatusValues = url.searchParams.getAll("filter[status]"); - expect(allStatusValues).toHaveLength(1); - expect(allStatusValues[0]).toBe("FAIL"); - }); - - it("should not have duplicate filter[status] params when caller passes filter[status]", async () => { + it("should preserve caller filter[status] when explicitly provided", async () => { // Given const checkId = "iam_user_mfa_enabled"; const filters = { "filter[status]": "PASS" }; @@ -377,9 +511,75 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a // When await getLatestFindingGroupResources({ checkId, filters }); - // Then — no duplicates + // Then const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); - expect(url.searchParams.getAll("filter[status]")).toHaveLength(1); + const allStatusValues = url.searchParams.getAll("filter[status]"); + expect(allStatusValues).toHaveLength(1); + expect(allStatusValues[0]).toBe("PASS"); + }); + + it("should translate a single group status__in filter into filter[status] for latest resources", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + const filters = { + "filter[status__in]": "PASS", + "filter[severity__in]": "low", + "filter[provider_type__in]": "aws", + }; + + // When + await getLatestFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[status]")).toBe("PASS"); + expect(url.searchParams.get("filter[status__in]")).toBeNull(); + expect(url.searchParams.get("filter[severity__in]")).toBe("low"); + expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws"); + }); + + it("should keep the composite sort when the resource search filter is applied", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + const filters = { + "filter[name__icontains]": "instance-prod", + "filter[status__in]": "PASS,FAIL", + }; + + // When + await getLatestFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe( + "-status,-severity,-delta,-last_seen_at", + ); + expect(url.searchParams.get("filter[name__icontains]")).toBe( + "instance-prod", + ); + expect(url.searchParams.get("filter[status__in]")).toBe("PASS,FAIL"); + }); + + it("should strip scan filters before calling latest group resources", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + const filters = { + "filter[scan__in]": "scan-1", + "filter[scan_id]": "scan-1", + "filter[region__in]": "us-east-1", + }; + + // When + await getLatestFindingGroupResources({ checkId, filters }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[scan__in]")).toBeNull(); + expect(url.searchParams.get("filter[scan_id]")).toBeNull(); + expect(url.searchParams.get("filter[region__in]")).toBe("us-east-1"); }); }); diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index b31d7a5bfc..faa697cf32 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -2,7 +2,18 @@ import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import type { FindingsFilterParam } from "@/actions/findings/findings-filters"; +import { + apiBaseUrl, + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + getAuthHeaders, + includesMutedFindings, + splitCsvFilterValues, +} from "@/lib"; import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; @@ -23,21 +34,104 @@ function mapSearchFilter( return mapped; } -export const getFindingGroups = async ({ - page = 1, - pageSize = 10, - sort = "", - filters = {}, -}) => { +/** + * Filters that belong to finding-groups but are NOT valid for the + * finding-group resources sub-endpoint. These must be stripped before + * calling the resources API to avoid empty results. + */ +const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [ + "filter[service__in]", + "filter[scan__in]", + "filter[scan_id]", + "filter[scan_id__in]", +]; + +function normalizeFindingGroupResourceFilters( + filters: Record, +): Record { + const normalized = Object.fromEntries( + Object.entries(filters).filter( + ([key]) => + !FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes( + key as FindingsFilterParam, + ), + ), + ); + + const exactStatusFilter = normalized["filter[status]"]; + + if (exactStatusFilter !== undefined) { + delete normalized["filter[status__in]"]; + return normalized; + } + + const statusValues = splitCsvFilterValues(normalized["filter[status__in]"]); + if (statusValues.length === 1) { + normalized["filter[status]"] = statusValues[0]; + delete normalized["filter[status__in]"]; + } + + return normalized; +} + +// Composite sorts for finding-groups (Family B in lib/findings-sort.ts). +// The `-status,-severity,...,-last_seen_at` shape is required by the API: +// these endpoints map status/severity to weighted integer columns where +// DESC = FAIL/critical first. The intermediate `*_count` tokens are +// finding-group-specific impact tiebreakers and have no Family A analogue. +const DEFAULT_FINDING_GROUPS_SORT = composeSort( + FG_FAIL_FIRST, + FG_SEVERITY_HIGH_FIRST, + "-new_fail_count", + "-changed_fail_count", + "-fail_count", + FG_RECENT_LAST_SEEN, +); + +const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED = composeSort( + FG_FAIL_FIRST, + FG_SEVERITY_HIGH_FIRST, + "-new_fail_count", + "-changed_fail_count", + "-new_fail_muted_count", + "-changed_fail_muted_count", + "-fail_count", + "-fail_muted_count", + FG_RECENT_LAST_SEEN, +); + +const DEFAULT_FINDING_GROUP_RESOURCES_SORT = + FINDING_GROUP_RESOURCES_DEFAULT_SORT; + +interface FetchFindingGroupsParams { + page?: number; + pageSize?: number; + sort?: string; + filters?: Record; +} + +function getDefaultFindingGroupsSort( + filters: Record, +): string { + return includesMutedFindings(filters) + ? DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED + : DEFAULT_FINDING_GROUPS_SORT; +} + +async function fetchFindingGroupsEndpoint( + endpoint: string, + { page = 1, pageSize = 10, sort, filters = {} }: FetchFindingGroupsParams, +) { const headers = await getAuthHeaders({ contentType: false }); + const resolvedSort = sort ?? getDefaultFindingGroupsSort(filters); if (isNaN(Number(page)) || page < 1) redirect("/findings"); - const url = new URL(`${apiBaseUrl}/finding-groups`); + const url = new URL(`${apiBaseUrl}/${endpoint}`); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); - if (sort) url.searchParams.append("sort", sort); + if (resolvedSort) url.searchParams.append("sort", resolvedSort); appendSanitizedProviderFilters(url, mapSearchFilter(filters)); @@ -45,120 +139,60 @@ export const getFindingGroups = async ({ const response = await fetch(url.toString(), { headers }); return handleApiResponse(response); } catch (error) { - console.error("Error fetching finding groups:", error); + console.error(`Error fetching ${endpoint}:`, error); return undefined; } -}; +} -export const getLatestFindingGroups = async ({ - page = 1, - pageSize = 10, - sort = "", - filters = {}, -}) => { +export const getFindingGroups = async (params: FetchFindingGroupsParams = {}) => + fetchFindingGroupsEndpoint("finding-groups", params); + +export const getLatestFindingGroups = async ( + params: FetchFindingGroupsParams = {}, +) => fetchFindingGroupsEndpoint("finding-groups/latest", params); + +interface FetchFindingGroupResourcesParams { + checkId: string; + page?: number; + pageSize?: number; + filters?: Record; +} + +async function fetchFindingGroupResourcesEndpoint( + endpointPrefix: string, + { + checkId, + page = 1, + pageSize = 20, + filters = {}, + }: FetchFindingGroupResourcesParams, +) { const headers = await getAuthHeaders({ contentType: false }); + const normalizedFilters = normalizeFindingGroupResourceFilters(filters); - if (isNaN(Number(page)) || page < 1) redirect("/findings"); - - const url = new URL(`${apiBaseUrl}/finding-groups/latest`); + const url = new URL( + `${apiBaseUrl}/${endpointPrefix}/${encodeURIComponent(checkId)}/resources`, + ); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); - if (sort) url.searchParams.append("sort", sort); + url.searchParams.append("sort", DEFAULT_FINDING_GROUP_RESOURCES_SORT); - appendSanitizedProviderFilters(url, mapSearchFilter(filters)); + appendSanitizedProviderFilters(url, normalizedFilters); try { const response = await fetch(url.toString(), { headers }); return handleApiResponse(response); } catch (error) { - console.error("Error fetching latest finding groups:", error); + console.error(`Error fetching ${endpointPrefix} resources:`, error); return undefined; } -}; +} -export const getFindingGroupResources = async ({ - checkId, - page = 1, - pageSize = 20, - filters = {}, -}: { - checkId: string; - page?: number; - pageSize?: number; - filters?: Record; -}) => { - const headers = await getAuthHeaders({ contentType: false }); +export const getFindingGroupResources = async ( + params: FetchFindingGroupResourcesParams, +) => fetchFindingGroupResourcesEndpoint("finding-groups", params); - const url = new URL( - `${apiBaseUrl}/finding-groups/${encodeURIComponent(checkId)}/resources`, - ); - - if (page) url.searchParams.append("page[number]", page.toString()); - if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); - // sort=-status is kept for future-proofing: if the filter[status]=FAIL - // constraint is ever relaxed to allow multiple statuses, the sort ensures - // FAIL resources still appear first in the result set. - url.searchParams.append("sort", "-status"); - - appendSanitizedProviderFilters(url, filters); - - // Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL - // always wins, even if the caller passed a different filter[status] value. - // Using .set() instead of .append() prevents duplicate filter[status] params. - url.searchParams.set("filter[status]", "FAIL"); - - try { - const response = await fetch(url.toString(), { - headers, - }); - - return handleApiResponse(response); - } catch (error) { - console.error("Error fetching finding group resources:", error); - return undefined; - } -}; - -export const getLatestFindingGroupResources = async ({ - checkId, - page = 1, - pageSize = 20, - filters = {}, -}: { - checkId: string; - page?: number; - pageSize?: number; - filters?: Record; -}) => { - const headers = await getAuthHeaders({ contentType: false }); - - const url = new URL( - `${apiBaseUrl}/finding-groups/latest/${encodeURIComponent(checkId)}/resources`, - ); - - if (page) url.searchParams.append("page[number]", page.toString()); - if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); - // sort=-status is kept for future-proofing: if the filter[status]=FAIL - // constraint is ever relaxed to allow multiple statuses, the sort ensures - // FAIL resources still appear first in the result set. - url.searchParams.append("sort", "-status"); - - appendSanitizedProviderFilters(url, filters); - - // Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL - // always wins, even if the caller passed a different filter[status] value. - // Using .set() instead of .append() prevents duplicate filter[status] params. - url.searchParams.set("filter[status]", "FAIL"); - - try { - const response = await fetch(url.toString(), { - headers, - }); - - return handleApiResponse(response); - } catch (error) { - console.error("Error fetching latest finding group resources:", error); - return undefined; - } -}; +export const getLatestFindingGroupResources = async ( + params: FetchFindingGroupResourcesParams, +) => fetchFindingGroupResourcesEndpoint("finding-groups/latest", params); diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index b8781f0036..a94f34a9c4 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -115,4 +115,113 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { expect(result[0].id).toBe("finding-1"); expect(result[0].checkId).toBe("s3_check"); }); + + it("should extract resource metadata and details from the included resource", () => { + // Given — finding with an included resource exposing metadata + details + createDictMock.mockImplementation((type: string) => + type === "resources" + ? { + "resource-1": { + id: "resource-1", + attributes: { + uid: "image:python:3.12", + name: "python", + type: "Python", + details: "Python 3.12 base image", + metadata: '{"PkgName":"requests","Versions":["2.0"]}', + }, + }, + } + : {}, + ); + + const input = { + data: { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "image_vulnerability", + status: "FAIL", + severity: "critical", + check_metadata: { checktitle: "Image Vulnerability" }, + }, + relationships: { + resources: { data: [{ id: "resource-1" }] }, + scan: { data: null }, + }, + }, + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0].resourceDetails).toBe("Python 3.12 base image"); + expect(result[0].resourceMetadata).toBe( + '{"PkgName":"requests","Versions":["2.0"]}', + ); + }); + + it("should default resource metadata and details to null when absent", () => { + // Given — valid finding without an included resource + const input = { + data: [ + { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "critical", + check_metadata: { checktitle: "S3 Check" }, + }, + relationships: { + resources: { data: [] }, + scan: { data: null }, + }, + }, + ], + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result[0].resourceDetails).toBeNull(); + expect(result[0].resourceMetadata).toBeNull(); + }); + + it("should normalize a single finding response into a one-item drawer array", () => { + // Given — getFindingById returns a single JSON:API resource object + const input = { + data: { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "critical", + check_metadata: { + checktitle: "S3 Check", + }, + }, + relationships: { + resources: { data: [] }, + scan: { data: null }, + }, + }, + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0].id).toBe("finding-1"); + expect(result[0].checkTitle).toBe("S3 Check"); + }); }); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 75e8f5a81b..0312df00da 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -57,6 +57,8 @@ export interface ResourceDrawerFinding { resourceRegion: string; resourceType: string; resourceGroup: string; + resourceDetails: string | null; + resourceMetadata: Record | string | null; // Provider providerType: ProviderType; providerAlias: string; @@ -165,16 +167,18 @@ type IncludedDict = Record; * then resolves each finding's resource and provider relationships. */ interface JsonApiResponse { - data: FindingApiItem[]; + data: FindingApiItem | FindingApiItem[]; included?: Record[]; } function isJsonApiResponse(value: unknown): value is JsonApiResponse { + const data = (value as { data?: unknown })?.data; + return ( value !== null && typeof value === "object" && "data" in value && - Array.isArray((value as { data: unknown }).data) + (Array.isArray(data) || (data !== null && typeof data === "object")) ); } @@ -188,8 +192,11 @@ export function adaptFindingsByResourceResponse( const resourcesDict = createDict("resources", apiResponse) as IncludedDict; const scansDict = createDict("scans", apiResponse) as IncludedDict; const providersDict = createDict("providers", apiResponse) as IncludedDict; + const findings = Array.isArray(apiResponse.data) + ? apiResponse.data + : [apiResponse.data]; - return apiResponse.data.map((item) => { + return findings.map((item) => { const attrs = item.attributes; const meta = (attrs.check_metadata || {}) as Record; const remediationRaw = meta.remediation as @@ -255,6 +262,14 @@ export function adaptFindingsByResourceResponse( resourceRegion: (resourceAttrs.region as string | undefined) || "-", resourceType: (resourceAttrs.type as string | undefined) || "-", resourceGroup: (meta.resourcegroup as string | undefined) || "-", + resourceDetails: + (resourceAttrs.details as string | null | undefined) ?? null, + resourceMetadata: + (resourceAttrs.metadata as + | Record + | string + | null + | undefined) ?? null, // Provider providerType: ((providerAttrs.provider as string | undefined) || "aws") as ProviderType, diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts index c637e5f707..83406a518f 100644 --- a/ui/actions/findings/findings-by-resource.test.ts +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -24,9 +24,15 @@ const { getLatestFindingGroupResourcesMock: vi.fn(), })); +// Import the real sort constant directly from its submodule. Going via the +// `@/lib` barrel would pull in server-only code (next-auth) that does not +// resolve in the vitest runtime. +import { RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib/findings-sort"; + vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, })); vi.mock("@/lib/provider-filters", () => ({ @@ -43,7 +49,7 @@ vi.mock("@/actions/finding-groups", () => ({ })); import { - resolveFindingIds, + getLatestFindingsByResourceUid, resolveFindingIdsByCheckIds, resolveFindingIdsByVisibleGroupResources, } from "./findings-by-resource"; @@ -142,47 +148,6 @@ describe("resolveFindingIdsByCheckIds", () => { }); }); -describe("resolveFindingIds", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("fetch", fetchMock); - getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); - }); - - it("should use the dated findings endpoint when date or scan filters are active", async () => { - // Given - fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ - data: [{ id: "finding-1" }, { id: "finding-2" }], - }); - - // When - const result = await resolveFindingIds({ - checkId: "check-1", - resourceUids: ["resource-1", "resource-2"], - hasDateOrScanFilter: true, - filters: { - "filter[scan__in]": "scan-1", - "filter[inserted_at__gte]": "2026-03-01", - }, - }); - - // Then - expect(result).toEqual(["finding-1", "finding-2"]); - - const calledUrl = new URL(fetchMock.mock.calls[0][0]); - expect(calledUrl.pathname).toBe("/api/v1/findings"); - expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1"); - expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe( - "resource-1,resource-2", - ); - expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1"); - expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe( - "2026-03-01", - ); - }); -}); - describe("resolveFindingIdsByVisibleGroupResources", () => { beforeEach(() => { vi.clearAllMocks(); @@ -190,22 +155,18 @@ describe("resolveFindingIdsByVisibleGroupResources", () => { getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); }); - it("should resolve finding IDs from the group's visible resource UIDs instead of muting the whole check", async () => { - // Given + it("extracts finding_id directly from group resources without a second resolution round-trip", async () => { + // Given — the group resources endpoint returns finding_id in each resource getLatestFindingGroupResourcesMock .mockResolvedValueOnce({ data: [ { id: "resource-row-1", - attributes: { - resource: { uid: "resource-1" }, - }, + attributes: { finding_id: "finding-1" }, }, { id: "resource-row-2", - attributes: { - resource: { uid: "resource-2" }, - }, + attributes: { finding_id: "finding-2" }, }, ], meta: { pagination: { pages: 2 } }, @@ -214,19 +175,12 @@ describe("resolveFindingIdsByVisibleGroupResources", () => { data: [ { id: "resource-row-3", - attributes: { - resource: { uid: "resource-3" }, - }, + attributes: { finding_id: "finding-3" }, }, ], meta: { pagination: { pages: 2 } }, }); - fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ - data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }], - }); - // When const result = await resolveFindingIdsByVisibleGroupResources({ checkId: "check-1", @@ -236,8 +190,13 @@ describe("resolveFindingIdsByVisibleGroupResources", () => { resourceSearch: "visible subset", }); - // Then + // Then — finding IDs come directly from the group resources response expect(result).toEqual(["finding-1", "finding-2", "finding-3"]); + + // No second round-trip to /findings/latest + expect(fetchMock).not.toHaveBeenCalled(); + + // Group resources endpoint was paginated with correct filters expect(getLatestFindingGroupResourcesMock).toHaveBeenCalledTimes(2); expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(1, { checkId: "check-1", @@ -246,6 +205,8 @@ describe("resolveFindingIdsByVisibleGroupResources", () => { filters: { "filter[provider_type__in]": "aws", "filter[name__icontains]": "visible subset", + "filter[status]": "FAIL", + "filter[muted]": "false", }, }); expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(2, { @@ -255,168 +216,99 @@ describe("resolveFindingIdsByVisibleGroupResources", () => { filters: { "filter[provider_type__in]": "aws", "filter[name__icontains]": "visible subset", + "filter[status]": "FAIL", + "filter[muted]": "false", }, }); + }); + + it("deduplicates finding IDs across pages", async () => { + // Given — same finding_id appears on both pages + getLatestFindingGroupResourcesMock + .mockResolvedValueOnce({ + data: [ + { id: "r-1", attributes: { finding_id: "finding-1" } }, + { id: "r-2", attributes: { finding_id: "finding-2" } }, + ], + meta: { pagination: { pages: 2 } }, + }) + .mockResolvedValueOnce({ + data: [{ id: "r-3", attributes: { finding_id: "finding-2" } }], + meta: { pagination: { pages: 2 } }, + }); + + // When + const result = await resolveFindingIdsByVisibleGroupResources({ + checkId: "check-1", + }); + + // Then — no duplicates + expect(result).toEqual(["finding-1", "finding-2"]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("uses the dated endpoint when date or scan filters are active", async () => { + // Given + getFindingGroupResourcesMock.mockResolvedValueOnce({ + data: [{ id: "r-1", attributes: { finding_id: "finding-1" } }], + meta: { pagination: { pages: 1 } }, + }); + + // When + await resolveFindingIdsByVisibleGroupResources({ + checkId: "check-1", + hasDateOrScanFilter: true, + filters: { + "filter[scan__in]": "scan-1", + }, + }); + + // Then — uses getFindingGroupResources (dated), not getLatestFindingGroupResources + expect(getFindingGroupResourcesMock).toHaveBeenCalledTimes(1); + expect(getLatestFindingGroupResourcesMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("getLatestFindingsByResourceUid", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + }); + + it("should restrict to FAIL, exclude muted findings, and apply severity/time sorting by default", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + + await getLatestFindingsByResourceUid({ + resourceUid: "resource-1", + }); const calledUrl = new URL(fetchMock.mock.calls[0][0]); expect(calledUrl.pathname).toBe("/api/v1/findings/latest"); - expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1"); - expect(calledUrl.searchParams.get("filter[check_id__in]")).toBeNull(); - expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe( - "resource-1,resource-2,resource-3", + expect(calledUrl.searchParams.get("filter[resource_uid]")).toBe( + "resource-1", ); - }); -}); - -// --------------------------------------------------------------------------- -// Blocker 3: Muting a group mutes ALL historical findings, not just FAIL ones -// -// The fix: resolveFindingIds must include filter[status]=FAIL so only active -// (failing) findings are resolved for mute, not historical/passing ones. -// --------------------------------------------------------------------------- - -describe("resolveFindingIds — Blocker 3: only resolve FAIL findings for mute", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("fetch", fetchMock); - getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + // Status filter is applied server-side so the page[size]=50 window + // always holds FAIL rows — guards against PASS-heavy resources + // starving FAILs out of the result. + expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); + expect(calledUrl.searchParams.get("filter[muted]")).toBe("false"); + expect(calledUrl.searchParams.get("sort")).toBe("severity,-updated_at"); }); - it("should include filter[status]=FAIL in the findings resolution URL for mute", async () => { - // Given + it("should include muted findings only when explicitly requested", async () => { fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ - data: [{ id: "finding-1" }, { id: "finding-2" }], + + await getLatestFindingsByResourceUid({ + resourceUid: "resource-1", + includeMuted: true, }); - // When - await resolveFindingIds({ - checkId: "check-1", - resourceUids: ["resource-1", "resource-2"], - }); - - // Then — the URL must filter to only FAIL status findings const calledUrl = new URL(fetchMock.mock.calls[0][0]); expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); - }); - - it("should include filter[status]=FAIL even when date or scan filters are active", async () => { - // Given - fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ - data: [{ id: "finding-1" }], - }); - - // When - await resolveFindingIds({ - checkId: "check-1", - resourceUids: ["resource-1"], - hasDateOrScanFilter: true, - filters: { - "filter[inserted_at__gte]": "2026-01-01", - }, - }); - - // Then - const calledUrl = new URL(fetchMock.mock.calls[0][0]); - expect(calledUrl.pathname).toBe("/api/v1/findings"); - expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); - }); - - it("should override caller filter[status] with FAIL — no duplicate params", async () => { - // Given — caller passes filter[status]=PASS via filters dict - fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ - data: [{ id: "finding-1" }], - }); - - // When - await resolveFindingIds({ - checkId: "check-1", - resourceUids: ["resource-1"], - filters: { - "filter[status]": "PASS", - }, - }); - - // Then — hardcoded FAIL must win, exactly 1 value - const calledUrl = new URL(fetchMock.mock.calls[0][0] as string); - const statusValues = calledUrl.searchParams.getAll("filter[status]"); - expect(statusValues).toHaveLength(1); - expect(statusValues[0]).toBe("FAIL"); - }); -}); - -// --------------------------------------------------------------------------- -// Fix 4: Unbounded page[size] cap -// -// The bug: createResourceFindingResolutionUrl sets page[size]=resourceUids.length -// with no upper bound guard. The production fix adds Math.min(resourceUids.length, MAX_PAGE_SIZE) -// with MAX_PAGE_SIZE=500 as an explicit defensive cap. -// --------------------------------------------------------------------------- - -describe("resolveFindingIds — Fix 4: page[size] explicit cap at MAX_PAGE_SIZE=500", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("fetch", fetchMock); - getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); - }); - - it("should use resourceUids.length as page[size] for a small batch (under 500)", async () => { - // Given — 3 resources, well under the cap - fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ - data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }], - }); - - // When - await resolveFindingIds({ - checkId: "check-1", - resourceUids: ["resource-1", "resource-2", "resource-3"], - }); - - // Then — page[size] should equal the number of resourceUids (3) - const calledUrl = new URL(fetchMock.mock.calls[0][0]); - expect(calledUrl.searchParams.get("page[size]")).toBe("3"); - }); - - it("should cap page[size] at 500 when the chunk has exactly 500 UIDs (boundary value)", async () => { - // Given — exactly 500 unique UIDs (at the cap boundary) - const resourceUids = Array.from({ length: 500 }, (_, i) => `resource-${i}`); - fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ data: [] }); - - // When - await resolveFindingIds({ - checkId: "check-1", - resourceUids, - }); - - // Then — page[size] must be exactly 500 (not capped lower) - const firstUrl = new URL(fetchMock.mock.calls[0][0] as string); - expect(firstUrl.searchParams.get("page[size]")).toBe("500"); - }); - - it("should cap page[size] at 500 even when a chunk would exceed 500 — Math.min guard in URL builder", async () => { - // Given — 501 UIDs. The chunker splits into [500, 1]. - // The FIRST chunk has 500 UIDs → page[size] should be 500 (Math.min(500, 500)). - // The SECOND chunk has 1 UID → page[size] should be 1 (Math.min(1, 500)). - // This proves the Math.min cap fires correctly on every chunk. - const resourceUids = Array.from({ length: 501 }, (_, i) => `resource-${i}`); - fetchMock.mockResolvedValue(new Response("", { status: 200 })); - handleApiResponseMock.mockResolvedValue({ data: [] }); - - // When - await resolveFindingIds({ - checkId: "check-1", - resourceUids, - }); - - // Then — two fetch calls: one for 500 UIDs, one for 1 UID - expect(fetchMock).toHaveBeenCalledTimes(2); - const firstUrl = new URL(fetchMock.mock.calls[0][0] as string); - const secondUrl = new URL(fetchMock.mock.calls[1][0] as string); - expect(firstUrl.searchParams.get("page[size]")).toBe("500"); - expect(secondUrl.searchParams.get("page[size]")).toBe("1"); + expect(calledUrl.searchParams.get("filter[muted]")).toBe("include"); + expect(calledUrl.searchParams.get("sort")).toBe("severity,-updated_at"); }); }); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 12900ffdca..3d1a6859a0 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -4,7 +4,11 @@ import { getFindingGroupResources, getLatestFindingGroupResources, } from "@/actions/finding-groups"; -import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { + apiBaseUrl, + getAuthHeaders, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, +} from "@/lib"; import { runWithConcurrencyLimit } from "@/lib/concurrency"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; @@ -14,22 +18,12 @@ const FINDING_IDS_RESOLUTION_CONCURRENCY = 4; const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500; const FINDING_FIELDS = "uid"; -/** Explicit upper bound for page[size] in resource-finding resolution requests. */ -const MAX_RESOURCE_FINDING_PAGE_SIZE = 500; - interface ResolveFindingIdsByCheckIdsParams { checkIds: string[]; filters?: Record; hasDateOrScanFilter?: boolean; } -interface ResolveFindingIdsParams { - checkId: string; - resourceUids: string[]; - filters?: Record; - hasDateOrScanFilter?: boolean; -} - interface ResolveFindingIdsByVisibleGroupResourcesParams { checkId: string; filters?: Record; @@ -42,8 +36,8 @@ interface FindingIdsPageResponse { totalPages: number; } -interface FindingGroupResourceUidsPageResponse { - resourceUids: string[]; +interface FindingGroupResourceFindingIdsPageResponse { + findingIds: string[]; totalPages: number; } @@ -100,78 +94,7 @@ async function fetchFindingIdsPage({ }; } -function chunkValues(values: T[], chunkSize: number): T[][] { - const chunks: T[][] = []; - for (let index = 0; index < values.length; index += chunkSize) { - chunks.push(values.slice(index, index + chunkSize)); - } - return chunks; -} - -function createResourceFindingResolutionUrl({ - checkId, - resourceUids, - filters = {}, - hasDateOrScanFilter = false, -}: ResolveFindingIdsParams): URL { - const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest"; - const url = new URL(`${apiBaseUrl}/${endpoint}`); - - url.searchParams.append("filter[check_id]", checkId); - url.searchParams.append("filter[resource_uid__in]", resourceUids.join(",")); - url.searchParams.append("filter[muted]", "false"); - url.searchParams.append( - "page[size]", - Math.min(resourceUids.length, MAX_RESOURCE_FINDING_PAGE_SIZE).toString(), - ); - - appendSanitizedProviderTypeFilters(url, filters); - - // Hardcoded FAIL filter AFTER appendSanitizedProviderTypeFilters — .set() - // guarantees this wins even if the caller passes filter[status] in filters. - url.searchParams.set("filter[status]", "FAIL"); - - return url; -} - -async function fetchFindingIdsForResourceUids({ - headers, - ...params -}: ResolveFindingIdsParams & { - headers: HeadersInit; -}): Promise { - const response = await fetch( - createResourceFindingResolutionUrl(params).toString(), - { - headers, - }, - ); - const data = await handleApiResponse(response); - - if (!data?.data || !Array.isArray(data.data)) { - return []; - } - - return data.data - .map((item: { id?: string }) => item.id) - .filter((id: string | undefined): id is string => Boolean(id)); -} - -function buildFindingGroupResourceFilters({ - filters = {}, - resourceSearch, -}: Pick< - ResolveFindingIdsByVisibleGroupResourcesParams, - "filters" | "resourceSearch" ->): Record { - const nextFilters = { ...filters }; - if (resourceSearch) { - nextFilters["filter[name__icontains]"] = resourceSearch; - } - return nextFilters; -} - -async function fetchFindingGroupResourceUidsPage({ +async function fetchFindingGroupResourceFindingIdsPage({ checkId, filters = {}, hasDateOrScanFilter = false, @@ -179,77 +102,44 @@ async function fetchFindingGroupResourceUidsPage({ resourceSearch, }: ResolveFindingIdsByVisibleGroupResourcesParams & { page: number; -}): Promise { +}): Promise { const fetchFn = hasDateOrScanFilter ? getFindingGroupResources : getLatestFindingGroupResources; + const resolvedFilters: Record = { + ...filters, + "filter[status]": "FAIL", + "filter[muted]": "false", + }; + if (resourceSearch) { + resolvedFilters["filter[name__icontains]"] = resourceSearch; + } + const response = await fetchFn({ checkId, page, pageSize: FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE, - filters: buildFindingGroupResourceFilters({ filters, resourceSearch }), + filters: resolvedFilters, }); const data = response?.data; if (!data || !Array.isArray(data)) { - return { resourceUids: [], totalPages: 1 }; + return { findingIds: [], totalPages: 1 }; } return { - resourceUids: data + findingIds: data .map( - (item: { attributes?: { resource?: { uid?: string } } }) => - item.attributes?.resource?.uid, + (item: { attributes?: { finding_id?: string } }) => + item.attributes?.finding_id, ) - .filter((uid: string | undefined): uid is string => Boolean(uid)), + .filter((id: string | undefined): id is string => Boolean(id)), totalPages: response?.meta?.pagination?.pages ?? 1, }; } -/** - * Resolves resource UIDs + check ID into actual finding UUIDs. - * Uses /findings/latest (or /findings when date/scan filters are active) - * with check_id and resource_uid__in filters to batch-resolve actual finding IDs. - */ -export const resolveFindingIds = async ({ - checkId, - resourceUids, - filters = {}, - hasDateOrScanFilter = false, -}: ResolveFindingIdsParams): Promise => { - if (resourceUids.length === 0) { - return []; - } - - const headers = await getAuthHeaders({ contentType: false }); - const resourceUidChunks = chunkValues( - Array.from(new Set(resourceUids)), - FINDING_IDS_RESOLUTION_PAGE_SIZE, - ); - - try { - const results = await runWithConcurrencyLimit( - resourceUidChunks, - FINDING_IDS_RESOLUTION_CONCURRENCY, - (resourceUidChunk) => - fetchFindingIdsForResourceUids({ - checkId, - resourceUids: resourceUidChunk, - filters, - hasDateOrScanFilter, - headers, - }), - ); - - return Array.from(new Set(results.flat())); - } catch (error) { - console.error("Error resolving finding IDs:", error); - return []; - } -}; - /** * Resolves check IDs into actual finding UUIDs. * Used at the group level where each row represents a check_id. @@ -305,8 +195,12 @@ export const resolveFindingIdsByCheckIds = async ({ }; /** - * Resolves a finding-group row to the actual findings for the resources + * Resolves a finding-group row to the actual finding UUIDs for the resources * currently visible in that group. + * + * Extracts finding_id directly from the group resources endpoint response, + * filtering server-side by status=FAIL and muted=false. No second resolution + * round-trip to /findings/latest is needed. */ export const resolveFindingIdsByVisibleGroupResources = async ({ checkId, @@ -315,7 +209,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({ resourceSearch, }: ResolveFindingIdsByVisibleGroupResourcesParams): Promise => { try { - const firstPage = await fetchFindingGroupResourceUidsPage({ + const firstPage = await fetchFindingGroupResourceFindingIdsPage({ checkId, filters, hasDateOrScanFilter, @@ -332,7 +226,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({ remainingPages, FINDING_IDS_RESOLUTION_CONCURRENCY, (page) => - fetchFindingGroupResourceUidsPage({ + fetchFindingGroupResourceFindingIdsPage({ checkId, filters, hasDateOrScanFilter, @@ -341,19 +235,12 @@ export const resolveFindingIdsByVisibleGroupResources = async ({ }), ); - const resourceUids = Array.from( + return Array.from( new Set([ - ...firstPage.resourceUids, - ...remainingResults.flatMap((result) => result.resourceUids), + ...firstPage.findingIds, + ...remainingResults.flatMap((result) => result.findingIds), ]), ); - - return resolveFindingIds({ - checkId, - resourceUids, - filters, - hasDateOrScanFilter, - }); } catch (error) { console.error( "Error resolving finding IDs from visible group resources:", @@ -367,10 +254,12 @@ export const getLatestFindingsByResourceUid = async ({ resourceUid, page = 1, pageSize = 50, + includeMuted = false, }: { resourceUid: string; page?: number; pageSize?: number; + includeMuted?: boolean; }) => { const headers = await getAuthHeaders({ contentType: false }); @@ -379,6 +268,9 @@ export const getLatestFindingsByResourceUid = async ({ ); url.searchParams.append("filter[resource_uid]", resourceUid); + url.searchParams.append("filter[status]", "FAIL"); + url.searchParams.append("filter[muted]", includeMuted ? "include" : "false"); + url.searchParams.append("sort", RESOURCE_DRAWER_OTHER_FINDINGS_SORT); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); diff --git a/ui/actions/findings/findings-filters.ts b/ui/actions/findings/findings-filters.ts new file mode 100644 index 0000000000..268d71e7cd --- /dev/null +++ b/ui/actions/findings/findings-filters.ts @@ -0,0 +1,40 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** Findings-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const FINDINGS_EXTRA_FIELD = { + DELTA_IN: "delta__in", + SCAN_EXACT: "scan", + SCAN_ID: "scan_id", + SCAN_ID_IN: "scan_id__in", + INSERTED_AT: "inserted_at", + INSERTED_AT_GTE: "inserted_at__gte", + INSERTED_AT_LTE: "inserted_at__lte", + MUTED: "muted", +} as const; + +type FindingsExtraField = + (typeof FINDINGS_EXTRA_FIELD)[keyof typeof FINDINGS_EXTRA_FIELD]; + +/** + * URL filter param keys the findings view supports, e.g. `filter[severity__in]`. + * Composed from the shared fields it uses plus a few findings-only extras + * (alternate scan/date/delta forms not used by other views). + */ +export type FindingsFilterParam = FilterParam< + // findings uses provider_id, not provider_uid + | (typeof FILTER_FIELD)[ + | "PROVIDER_TYPE" + | "PROVIDER_ID" + | "PROVIDER_GROUPS" + | "REGION" + | "SERVICE" + | "SEVERITY" + | "STATUS" + | "DELTA" + | "RESOURCE_TYPE" + | "CATEGORY" + | "RESOURCE_GROUPS" + | "SCAN"] + | FindingsExtraField +>; diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 7ce7f931ad..242bd007a8 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -141,7 +141,15 @@ export const getLatestMetadataInfo = async ({ } }; -export const getFindingById = async (findingId: string, include = "") => { +interface GetFindingByIdOptions { + source?: "resource-detail-drawer"; +} + +export const getFindingById = async ( + findingId: string, + include = "", + _options?: GetFindingByIdOptions, +) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/findings/${findingId}`); diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 40084a0da1..8945842fe4 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -286,7 +286,7 @@ const pollTaskUntilComplete = async ( taskId: string, ): Promise => { const settled = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 20, delayMs: 3000, }); diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 72bfc1176d..9e3b25679f 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -148,7 +148,7 @@ export const pollJiraDispatchTask = async ( { success: true; message: string } | { success: false; error: string } > => { const res = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 30, delayMs: 2000, }); if (!res.ok) { diff --git a/ui/actions/invitations/invitation.ts b/ui/actions/invitations/invitation.ts index 8ad037cbbe..72f591705a 100644 --- a/ui/actions/invitations/invitation.ts +++ b/ui/actions/invitations/invitation.ts @@ -2,10 +2,13 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { z } from "zod"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +const invitationTokenSchema = z.string().min(1).max(500); + export const getInvitations = async ({ page = 1, query = "", @@ -195,3 +198,35 @@ export const revokeInvite = async (formData: FormData) => { handleApiError(error); } }; + +export const acceptInvitation = async (token: string) => { + const parsed = invitationTokenSchema.safeParse(token); + if (!parsed.success) { + return { error: "Invalid invitation token" }; + } + + const headers = await getAuthHeaders({ contentType: true }); + + const url = new URL(`${apiBaseUrl}/invitations/accept`); + + const body = JSON.stringify({ + data: { + type: "invitations", + attributes: { + invitation_token: parsed.data, + }, + }, + }); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body, + }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/actions/manage-groups/manage-groups.test.ts b/ui/actions/manage-groups/manage-groups.test.ts new file mode 100644 index 0000000000..c016832a1f --- /dev/null +++ b/ui/actions/manage-groups/manage-groups.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, + getErrorMessage: vi.fn(), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { getAllProviderGroups } from "./manage-groups"; + +const makeGroup = (id: string, name: string) => ({ + type: "provider-groups" as const, + id, + attributes: { name, inserted_at: "", updated_at: "" }, + relationships: { + providers: { meta: { count: 0 }, data: [] }, + roles: { meta: { count: 0 }, data: [] }, + }, + links: { self: "" }, +}); + +const makePage = ( + data: ReturnType[], + page: number, + pages: number, +) => ({ + links: { first: "", last: "", next: null, prev: null }, + data, + meta: { pagination: { page, pages, count: data.length } }, +}); + +describe("getAllProviderGroups", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + }); + + it("merges every page into a single response with collapsed pagination", async () => { + handleApiResponseMock + .mockResolvedValueOnce( + makePage( + [makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")], + 1, + 2, + ), + ) + .mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2)); + + const result = await getAllProviderGroups(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]); + expect(result?.meta.pagination).toMatchObject({ + page: 1, + pages: 1, + count: 3, + }); + }); + + it("stops after the first page when there is only one page", async () => { + handleApiResponseMock.mockResolvedValueOnce( + makePage([makeGroup("g1", "Group 1")], 1, 1), + ); + + const result = await getAllProviderGroups(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result?.data).toHaveLength(1); + }); + + it("returns undefined when the first page has no data", async () => { + handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1)); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when the request throws", async () => { + fetchMock.mockRejectedValueOnce(new Error("network down")); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when a later page resolves to an error payload", async () => { + handleApiResponseMock + .mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2)) + .mockResolvedValueOnce({ error: "Forbidden", status: 403 }); + + const result = await getAllProviderGroups(); + + expect(result).toBeUndefined(); + }); + + it("returns undefined instead of a truncated list when the max-page cap is hit", async () => { + // Given an API that always reports more pages than the 50-page safety cap + handleApiResponseMock.mockImplementation((response: Response) => { + void response; + return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999)); + }); + + // When fetching every page + const result = await getAllProviderGroups(); + + // Then it must not return a partial/truncated list; bail out instead + expect(result).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(50); + }); +}); diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 8758b66f68..c916a89d39 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -23,7 +23,7 @@ export const getProviderGroups = async ({ const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) - redirect("/providers?tab=account-groups"); + redirect("/providers?tab=provider-groups"); const url = new URL(`${apiBaseUrl}/provider-groups`); @@ -51,6 +51,87 @@ export const getProviderGroups = async ({ } }; +/** + * Fetches all provider groups by iterating through every page. + * Used to populate filter dropdowns (e.g. the Provider Group selector) without + * the pagination cap that `getProviderGroups` applies for the management table. + */ +export const getAllProviderGroups = async (): Promise< + ProviderGroupsResponse | undefined +> => { + const pageSize = 100; // Larger page size to minimize API calls + const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 groups max + let currentPage = 1; + const allGroups: ProviderGroupsResponse["data"] = []; + let lastResponse: ProviderGroupsResponse | undefined; + let hasMorePages = true; + + try { + const headers = await getAuthHeaders({ contentType: false }); + while (hasMorePages && currentPage <= maxPages) { + const url = new URL(`${apiBaseUrl}/provider-groups`); + url.searchParams.append("page[number]", currentPage.toString()); + url.searchParams.append("page[size]", pageSize.toString()); + + const response = await fetch(url.toString(), { headers }); + const data = (await handleApiResponse(response)) as + | ProviderGroupsResponse + | { error: string; status?: number } + | undefined; + + // A later page resolving to an API error payload must abort rather than + // be treated as "no more pages", which would silently truncate groups. + if (data && "error" in data) { + console.error("Error fetching all provider groups:", data.error); + return undefined; + } + + if (!data?.data || data.data.length === 0) { + hasMorePages = false; + continue; + } + + allGroups.push(...data.data); + lastResponse = data; + + const totalPages = data.meta?.pagination?.pages || 1; + if (currentPage >= totalPages) { + hasMorePages = false; + } else { + currentPage++; + } + } + + if (hasMorePages && currentPage > maxPages) { + console.error( + `Error fetching all provider groups: exceeded max page limit (${maxPages})`, + ); + return undefined; + } + + if (lastResponse) { + return { + ...lastResponse, + data: allGroups, + meta: { + ...lastResponse.meta, + pagination: { + ...lastResponse.meta?.pagination, + page: 1, + pages: 1, + count: allGroups.length, + }, + }, + }; + } + + return undefined; + } catch (error) { + console.error("Error fetching all provider groups:", error); + return undefined; + } +}; + export const getProviderGroupInfoById = async (providerGroupId: string) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`); @@ -112,7 +193,7 @@ export const createProviderGroup = async (formData: FormData) => { body, }); - return await handleApiResponse(response, "/providers?tab=account-groups"); + return await handleApiResponse(response, "/providers?tab=provider-groups"); } catch (error) { handleApiError(error); } @@ -169,7 +250,7 @@ export const deleteProviderGroup = async (formData: FormData) => { if (!providerGroupId) { return { - errors: [{ detail: "Account Group ID is required." }], + errors: [{ detail: "Provider Group ID is required." }], }; } diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts index cc31bd90bd..5b571e2bba 100644 --- a/ui/actions/mute-rules/mute-rules.ts +++ b/ui/actions/mute-rules/mute-rules.ts @@ -144,22 +144,36 @@ export const createMuteRule = async ( }, }, }; + const requestBody = JSON.stringify(bodyData); const response = await fetch(url.toString(), { method: "POST", headers, - body: JSON.stringify(bodyData), + body: requestBody, }); if (!response.ok) { let errorMessage = `Failed to create mute rule: ${response.statusText}`; + const responseContentType = response.headers.get("content-type"); try { - const errorData = await response.json(); - errorMessage = - errorData?.errors?.[0]?.detail || errorData?.message || errorMessage; + if (responseContentType?.includes("application/json")) { + const errorData = await response.json(); + errorMessage = + ( + errorData as { + errors?: Array<{ detail?: string }>; + message?: string; + } + )?.errors?.[0]?.detail || + (errorData as { message?: string })?.message || + errorMessage; + } else { + await response.text(); + } } catch { // JSON parsing failed, use default error message } + throw new Error(errorMessage); } diff --git a/ui/actions/overview/overview-filters.ts b/ui/actions/overview/overview-filters.ts new file mode 100644 index 0000000000..186977f9ce --- /dev/null +++ b/ui/actions/overview/overview-filters.ts @@ -0,0 +1,16 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * URL filter param keys the overview dashboard scopes its widgets by. Overview has + * no single action; its widgets read these keys from the URL filters. + */ +export type OverviewFilterParam = FilterParam< + (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"] +>; + +/** The `filter[...]` keys overview widgets read from the URL. */ +export const OVERVIEW_FILTER_PARAM = { + PROVIDER_TYPE: `filter[${FILTER_FIELD.PROVIDER_TYPE}]`, + PROVIDER_ID: `filter[${FILTER_FIELD.PROVIDER_ID}]`, + PROVIDER_GROUPS: `filter[${FILTER_FIELD.PROVIDER_GROUPS}]`, +} as const satisfies Record; diff --git a/ui/actions/overview/regions/threat-map.adapter.test.ts b/ui/actions/overview/regions/threat-map.adapter.test.ts new file mode 100644 index 0000000000..515825bb0a --- /dev/null +++ b/ui/actions/overview/regions/threat-map.adapter.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { adaptRegionsOverviewToThreatMap } from "./threat-map.adapter"; +import type { RegionsOverviewResponse } from "./types"; + +function buildRegionsResponse( + rows: Array<{ providerType: string; region: string }>, +): RegionsOverviewResponse { + return { + data: rows.map(({ providerType, region }, index) => ({ + type: "regions-overview", + id: `region-${index}`, + attributes: { + provider_type: providerType, + region, + total: 10, + fail: 4, + muted: 0, + pass: 6, + }, + })), + meta: { version: "v1" }, + }; +} + +describe("adaptRegionsOverviewToThreatMap", () => { + it("maps okta regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "okta", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "okta", + region: "global", + name: "Okta - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); + + it("maps googleworkspace regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "googleworkspace", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "googleworkspace", + region: "global", + name: "Google Workspace - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); +}); diff --git a/ui/actions/overview/regions/threat-map.adapter.ts b/ui/actions/overview/regions/threat-map.adapter.ts index 0b7852b56a..6ee0958aad 100644 --- a/ui/actions/overview/regions/threat-map.adapter.ts +++ b/ui/actions/overview/regions/threat-map.adapter.ts @@ -261,6 +261,19 @@ const ALIBABACLOUD_COORDINATES: Record = { global: { lat: 30.3, lng: 120.2 }, // Global fallback (Hangzhou HQ) }; +// Okta is a SaaS identity platform without user-facing regions +const OKTA_COORDINATES: Record = { + global: { lat: 37.8, lng: -122.4 }, // Global fallback (San Francisco HQ) +}; + +// Google Workspace is a SaaS suite without user-facing regions +const GOOGLEWORKSPACE_COORDINATES: Record< + string, + { lat: number; lng: number } +> = { + global: { lat: 37.4, lng: -122.1 }, // Global fallback (Mountain View HQ) +}; + const PROVIDER_COORDINATES: Record< string, Record @@ -277,6 +290,8 @@ const PROVIDER_COORDINATES: Record< oraclecloud: ORACLECLOUD_COORDINATES, mongodbatlas: MONGODBATLAS_COORDINATES, alibabacloud: ALIBABACLOUD_COORDINATES, + okta: OKTA_COORDINATES, + googleworkspace: GOOGLEWORKSPACE_COORDINATES, }; // Returns [lng, lat] format for D3/GeoJSON compatibility diff --git a/ui/actions/providers/providers-filters.ts b/ui/actions/providers/providers-filters.ts new file mode 100644 index 0000000000..71184c0b81 --- /dev/null +++ b/ui/actions/providers/providers-filters.ts @@ -0,0 +1,18 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; +import { PROVIDERS_PAGE_FILTER } from "@/types/providers-table"; + +/** + * URL filter param keys the providers list supports, e.g. `filter[provider__in]`. + * Provider scope plus its providers-only extras (`provider__in` API param, + * `connected` status). + */ +export type ProvidersFilterParam = FilterParam< + | (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_GROUPS" | "PROVIDER_UID"] + | (typeof PROVIDERS_PAGE_FILTER)["PROVIDER" | "STATUS"] +>; + +/** `filter[...]` keys used when mapping the provider-type filter to the API param. */ +export const PROVIDERS_FILTER_PARAM = { + PROVIDER: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`, + PROVIDER_TYPE: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`, +} as const satisfies Record; diff --git a/ui/actions/providers/providers.test.ts b/ui/actions/providers/providers.test.ts new file mode 100644 index 0000000000..4d56e45b4b --- /dev/null +++ b/ui/actions/providers/providers.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + getFormValueMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + getFormValueMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, + getFormValue: getFormValueMock, + wait: vi.fn(), +})); + +vi.mock("@/lib/provider-credentials/build-credentials", () => ({ + buildSecretConfig: vi.fn(() => ({ + secretType: "access-secret-key", + secret: { key: "value" }, + })), +})); + +vi.mock("@/lib/provider-filters", () => ({ + appendSanitizedProviderInFilters: vi.fn(), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { + addCredentialsProvider, + addProvider, + checkConnectionProvider, + updateCredentialsProvider, +} from "./providers"; + +describe("providers actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + getFormValueMock.mockImplementation((formData: FormData, field: string) => + formData.get(field), + ); + handleApiErrorMock.mockReturnValue({ error: "Unexpected error" }); + handleApiResponseMock.mockResolvedValue({ data: { id: "secret-1" } }); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { id: "secret-1" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + it("should revalidate providers after linking a cloud provider", async () => { + // Given + const formData = new FormData(); + formData.set("providerType", "aws"); + formData.set("providerUid", "111111111111"); + + // When + await addProvider(formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); + + it("should revalidate providers after adding credentials in the wizard", async () => { + // Given + const formData = new FormData(); + formData.set("providerId", "provider-1"); + formData.set("providerType", "aws"); + + // When + await addCredentialsProvider(formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); + + it("should revalidate providers after updating credentials in the wizard", async () => { + // Given + const formData = new FormData(); + formData.set("providerId", "provider-1"); + formData.set("providerType", "oraclecloud"); + + // When + await updateCredentialsProvider("secret-1", formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); + + it("should revalidate providers when checking connection from the wizard", async () => { + // Given + const formData = new FormData(); + formData.set("providerId", "provider-1"); + + // When + await checkConnectionProvider(formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); +}); diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index 63c01332b1..010646dc34 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders, getFormValue, wait } from "@/lib"; -import { buildSecretConfig } from "@/lib/provider-credentials/build-crendentials"; +import { buildSecretConfig } from "@/lib/provider-credentials/build-credentials"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { appendSanitizedProviderInFilters } from "@/lib/provider-filters"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts index afaf91a381..600346e6c6 100644 --- a/ui/actions/resources/index.ts +++ b/ui/actions/resources/index.ts @@ -3,6 +3,7 @@ export { getLatestResources, getMetadataInfo, getResourceById, + getResourceDrawerData, getResourceEvents, getResources, } from "./resources"; diff --git a/ui/actions/resources/resources-filters.ts b/ui/actions/resources/resources-filters.ts new file mode 100644 index 0000000000..01132943a6 --- /dev/null +++ b/ui/actions/resources/resources-filters.ts @@ -0,0 +1,25 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** Resources-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const RESOURCES_EXTRA_FIELD = { + TYPE: "type__in", + GROUPS: "groups__in", +} as const; + +type ResourcesExtraField = + (typeof RESOURCES_EXTRA_FIELD)[keyof typeof RESOURCES_EXTRA_FIELD]; + +/** + * URL filter param keys the resources view supports, e.g. `filter[type__in]`. + * The shared core plus its resources-only dimensions (`type__in`, `groups__in`). + */ +export type ResourcesFilterParam = FilterParam< + | (typeof FILTER_FIELD)[ + | "PROVIDER_TYPE" + | "PROVIDER_ID" + | "PROVIDER_GROUPS" + | "REGION" + | "SERVICE"] + | ResourcesExtraField +>; diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index b3fc4b257b..d8c5d75456 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -8,9 +8,39 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( }), ); +// Pull every constant transitively required by the modules under test +// (resources.ts → findings action → finding-groups action) so the `@/lib` +// mock is a complete surface. Going via the barrel would drag in next-auth. +import { + includesMutedFindings, + splitCsvFilterValues, +} from "@/lib/findings-filters"; +import { + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + FINDINGS_FILTERED_SORT, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, +} from "@/lib/findings-sort"; + vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", + sanitizeErrorMessage: (message: string, fallback: string) => + / ({ @@ -141,6 +171,29 @@ describe("getResourceEvents", () => { }); }); + it("returns a generic error when a gateway returns HTML", async () => { + // Given + const mockResponse = new Response( + "502 Bad Gateway

    502 Bad Gateway

    ", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 502, + }); + }); + it("returns generic error when fetch throws", async () => { // Given fetchMock.mockRejectedValue(new Error("Network failure")); diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 36f9761959..3ab84efa4f 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -2,9 +2,18 @@ import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { getLatestFindings } from "@/actions/findings"; +import { listOrganizationsSafe } from "@/actions/organizations/organizations"; +import { + apiBaseUrl, + FINDINGS_FILTERED_SORT, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + sanitizeErrorMessage, +} from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import { OrganizationResource } from "@/types/organizations"; export const getResources = async ({ page = 1, @@ -190,22 +199,27 @@ export const getResourceEvents = async ( if (!response.ok) { const rawText = await response.text().catch(() => ""); + const contentType = + response.headers.get("content-type")?.toLowerCase() || ""; const defaultError = "An error occurred while fetching events."; + const fallbackError = contentType.includes("text/html") + ? GENERIC_SERVER_ERROR_MESSAGE + : response.statusText || defaultError; try { const errorData = rawText ? JSON.parse(rawText) : null; + const errorMessage = + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + rawText || + fallbackError; return { - error: - errorData?.errors?.[0]?.detail || - errorData?.error || - errorData?.message || - rawText || - response.statusText || - defaultError, + error: sanitizeErrorMessage(String(errorMessage), fallbackError), status: response.status, }; } catch { return { - error: rawText || response.statusText || defaultError, + error: sanitizeErrorMessage(rawText || fallbackError, fallbackError), status: response.status, }; } @@ -255,3 +269,57 @@ export const getResourceById = async ( return undefined; } }; + +export const getResourceDrawerData = async ({ + resourceId, + resourceUid, + providerId, + providerType, + page = 1, + pageSize = 10, + query = "", +}: { + resourceId: string; + resourceUid: string; + providerId: string; + providerType: string; + page?: number; + pageSize?: number; + query?: string; +}) => { + const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + + const [resourceData, findingsResponse, organizationsResponse] = + await Promise.all([ + getResourceById(resourceId, { fields: ["tags"] }), + getLatestFindings({ + page, + pageSize, + query, + sort: FINDINGS_FILTERED_SORT, + filters: { + "filter[resource_uid]": resourceUid, + "filter[status]": "FAIL", + }, + }), + isCloudEnv && providerType === "aws" + ? listOrganizationsSafe() + : Promise.resolve({ data: [] }), + ]); + + const providerOrg = + providerType === "aws" + ? (organizationsResponse.data.find((organization: OrganizationResource) => + organization.relationships?.providers?.data?.some( + (provider: { id: string }) => provider.id === providerId, + ), + ) ?? null) + : null; + + return { + findings: findingsResponse?.data ?? [], + findingsMeta: findingsResponse?.meta ?? null, + providerOrg, + resourceTags: resourceData?.data?.attributes.tags ?? {}, + }; +}; diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts new file mode 100644 index 0000000000..1e5dfaf189 --- /dev/null +++ b/ui/actions/roles/roles.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { addRole, updateRole } from "./roles"; + +const lastRequestBody = () => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + const [, init] = call; + return JSON.parse(String((init as RequestInit).body)); +}; + +const makeRoleFormData = () => { + const formData = new FormData(); + formData.set("name", "Alert manager"); + formData.set("manage_users", "false"); + formData.set("manage_account", "false"); + formData.set("manage_billing", "false"); + formData.set("manage_providers", "false"); + formData.set("manage_integrations", "false"); + formData.set("manage_scans", "false"); + formData.set("manage_alerts", "true"); + formData.set("unlimited_visibility", "false"); + return formData; +}; + +describe("role actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: { id: "role-1" } }); + handleApiErrorMock.mockReturnValue({ error: "Unexpected error" }); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { id: "role-1" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("includes manage_alerts when creating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect(lastRequestBody().data.attributes.manage_alerts).toBe(true); + }); + + it("omits manage_alerts when creating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_alerts", + ); + }); + + it("includes manage_alerts when updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect(lastRequestBody().data.attributes.manage_alerts).toBe(true); + }); +}); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 24895e4369..645db72951 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -107,10 +107,12 @@ export const addRole = async (formData: FormData) => { }, }; - // Conditionally include manage_billing for cloud environment + // Conditionally include Prowler Cloud permissions. if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { payload.data.attributes.manage_billing = formData.get("manage_billing") === "true"; + payload.data.attributes.manage_alerts = + formData.get("manage_alerts") === "true"; } // Add provider groups relationships only if there are items @@ -162,10 +164,12 @@ export const updateRole = async (formData: FormData, roleId: string) => { }, }; - // Conditionally include manage_billing for cloud environments + // Conditionally include Prowler Cloud permissions. if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { payload.data.attributes.manage_billing = formData.get("manage_billing") === "true"; + payload.data.attributes.manage_alerts = + formData.get("manage_alerts") === "true"; } // Add provider groups relationships only if there are items diff --git a/ui/actions/scan-configurations/index.ts b/ui/actions/scan-configurations/index.ts new file mode 100644 index 0000000000..d9631969df --- /dev/null +++ b/ui/actions/scan-configurations/index.ts @@ -0,0 +1 @@ +export * from "./scan-configurations"; diff --git a/ui/actions/scan-configurations/scan-configurations.ts b/ui/actions/scan-configurations/scan-configurations.ts new file mode 100644 index 0000000000..1ecdb3256b --- /dev/null +++ b/ui/actions/scan-configurations/scan-configurations.ts @@ -0,0 +1,345 @@ +"use server"; + +import yaml from "js-yaml"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; +import { scanConfigurationFormSchema } from "@/types/formSchemas"; +import { + DeleteScanConfigurationActionState, + ScanConfigurationActionState, + ScanConfigurationData, + ScanConfigurationErrors, + ScanConfigurationRequestBody, +} from "@/types/scan-configurations"; + +const SCAN_CONFIGURATION_PATH = "/scan-configurations"; + +// Scan Configuration IDs are UUIDs. Validate before interpolating into request +// URLs so a malformed/crafted value can't inject path segments (SSRF / path +// injection). +const scanConfigurationIdSchema = z.uuid(); + +const parseConfiguration = (value: string): Record => { + // Backend (YamlOrJsonField) accepts either a YAML string or a JSON object. + // We parse client-side so failures surface as form errors, not 500s. + const parsed = yaml.load(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Configuration must be a mapping with provider sections."); + } + return parsed as Record; +}; + +const collectProviderIds = (formData: FormData): string[] => { + return formData + .getAll("provider_ids") + .map((v) => String(v)) + .filter(Boolean); +}; + +export const createScanConfiguration = async ( + _prevState: ScanConfigurationActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = { + name: formData.get("name"), + configuration: formData.get("configuration"), + provider_ids: collectProviderIds(formData), + }; + + const validated = scanConfigurationFormSchema.safeParse(formDataObject); + if (!validated.success) { + const fieldErrors = validated.error.flatten().fieldErrors; + return { + errors: { + name: fieldErrors?.name?.[0], + configuration: fieldErrors?.configuration?.[0], + provider_ids: fieldErrors?.provider_ids?.[0], + }, + }; + } + + const { name, configuration, provider_ids } = validated.data; + + let parsedConfig: Record; + try { + parsedConfig = parseConfiguration(configuration); + } catch (e) { + return { + errors: { + configuration: + e instanceof Error ? e.message : "Failed to parse configuration", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/scan-configurations`); + const bodyData: ScanConfigurationRequestBody = { + data: { + type: "scan-configurations", + attributes: { + name, + configuration: parsedConfig, + provider_ids, + }, + }, + }; + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const detail = + errorData?.errors?.[0]?.detail || + errorData?.message || + `Failed to create Scan Configuration: ${response.statusText}`; + const pointer = errorData?.errors?.[0]?.source?.pointer as + | string + | undefined; + const errors: ScanConfigurationErrors = {}; + if (pointer?.includes("name")) errors.name = detail; + else if (pointer?.includes("configuration")) + errors.configuration = detail; + else if (pointer?.includes("provider_ids")) errors.provider_ids = detail; + else errors.general = detail; + return { errors }; + } + + const data = await response.json(); + revalidatePath(SCAN_CONFIGURATION_PATH); + return { + success: "Scan Configuration created successfully!", + data: data.data as ScanConfigurationData, + }; + } catch (error) { + console.error("Error creating Scan Configuration:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error creating Scan Configuration. Please try again.", + }, + }; + } +}; + +export const updateScanConfiguration = async ( + _prevState: ScanConfigurationActionState, + formData: FormData, +): Promise => { + const id = formData.get("id"); + if (!id) { + return { + errors: { general: "Scan Configuration ID is required for update" }, + }; + } + const idResult = scanConfigurationIdSchema.safeParse(String(id)); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Configuration ID" } }; + } + const validId = idResult.data; + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = { + name: formData.get("name"), + configuration: formData.get("configuration"), + provider_ids: collectProviderIds(formData), + }; + + const validated = scanConfigurationFormSchema.safeParse(formDataObject); + if (!validated.success) { + const fieldErrors = validated.error.flatten().fieldErrors; + return { + errors: { + name: fieldErrors?.name?.[0], + configuration: fieldErrors?.configuration?.[0], + provider_ids: fieldErrors?.provider_ids?.[0], + }, + }; + } + + const { name, configuration, provider_ids } = validated.data; + + let parsedConfig: Record; + try { + parsedConfig = parseConfiguration(configuration); + } catch (e) { + return { + errors: { + configuration: + e instanceof Error ? e.message : "Failed to parse configuration", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`); + const bodyData: ScanConfigurationRequestBody = { + data: { + type: "scan-configurations", + id: validId, + attributes: { + name, + configuration: parsedConfig, + provider_ids, + }, + }, + }; + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const detail = + errorData?.errors?.[0]?.detail || + errorData?.message || + `Failed to update Scan Configuration: ${response.statusText}`; + return { errors: { general: detail } }; + } + + const data = await response.json(); + revalidatePath(SCAN_CONFIGURATION_PATH); + return { + success: "Scan Configuration updated successfully!", + data: data.data as ScanConfigurationData, + }; + } catch (error) { + console.error("Error updating Scan Configuration:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error updating Scan Configuration. Please try again.", + }, + }; + } +}; + +export const getScanConfigurationSchema = async (): Promise | null> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configurations/schema`); + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch Scan Configuration schema: ${response.statusText}`, + ); + } + const json = await response.json(); + const schema = json?.data?.attributes?.schema as + | Record + | undefined; + return schema ?? null; + } catch (error) { + console.error("Error fetching Scan Configuration schema:", error); + return null; + } +}; + +export const listScanConfigurations = async (): Promise< + ScanConfigurationData[] +> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configurations`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) { + throw new Error( + `Failed to list Scan Configurations: ${response.statusText}`, + ); + } + const json = await response.json(); + return (json.data || []) as ScanConfigurationData[]; + } catch (error) { + // Re-throw so callers can distinguish a fetch/auth failure from an empty + // result. Collapsing errors into `[]` would render a false "no scan + // configurations" state and overwrite the table on a failed refresh. + console.error("Error listing Scan Configurations:", error); + throw error; + } +}; + +export const getScanConfiguration = async ( + id: string, +): Promise => { + const idResult = scanConfigurationIdSchema.safeParse(id); + if (!idResult.success) return undefined; + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) return undefined; + const json = await response.json(); + return json.data as ScanConfigurationData; + } catch (error) { + console.error("Error fetching Scan Configuration:", error); + return undefined; + } +}; + +export const deleteScanConfiguration = async ( + _prevState: DeleteScanConfigurationActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const id = formData.get("id"); + if (!id) { + return { + errors: { general: "Scan Configuration ID is required for deletion" }, + }; + } + const idResult = scanConfigurationIdSchema.safeParse(String(id)); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Configuration ID" } }; + } + try { + const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.errors?.[0]?.detail || + `Failed to delete Scan Configuration: ${response.statusText}`, + ); + } + revalidatePath(SCAN_CONFIGURATION_PATH); + return { success: "Scan Configuration deleted successfully!" }; + } catch (error) { + console.error("Error deleting Scan Configuration:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error deleting Scan Configuration. Please try again.", + }, + }; + } +}; diff --git a/ui/actions/scans/scans-filters.ts b/ui/actions/scans/scans-filters.ts new file mode 100644 index 0000000000..19958e9fa5 --- /dev/null +++ b/ui/actions/scans/scans-filters.ts @@ -0,0 +1,34 @@ +import { FILTER_FIELD, FilterParam } from "@/types/filters"; + +/** + * Provider filter fields used to match/clear synthetic pending scan rows — the + * `__in` forms (shared with real scan rows) plus the exact forms, and the + * provider-group `__in` form so pending rows honor the group filter too. + */ +export const SCANS_PROVIDER_FILTER_FIELD = { + PROVIDER_IN: FILTER_FIELD.PROVIDER, + PROVIDER: "provider", + PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE, + PROVIDER_TYPE: "provider_type", + PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS, +} as const; + +/** Scans-only filter fields not shared with other views. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const SCANS_EXTRA_FIELD = { + STATE: "state__in", + TRIGGER: "trigger", +} as const; + +type ScansExtraField = + (typeof SCANS_EXTRA_FIELD)[keyof typeof SCANS_EXTRA_FIELD]; + +/** + * URL filter param keys the scans view supports, e.g. `filter[state__in]`. + * Provider scope (scans filters accounts by provider id) including provider + * groups and the exact pending-row provider forms, plus the scans-only dimensions. + */ +export type ScansFilterParam = FilterParam< + | (typeof SCANS_PROVIDER_FILTER_FIELD)[keyof typeof SCANS_PROVIDER_FILTER_FIELD] + | ScansExtraField +>; diff --git a/ui/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index ae82ba860d..a7f7fd0c7e 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -14,6 +14,8 @@ const { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", getAuthHeaders: getAuthHeadersMock, getErrorMessage: (error: unknown) => error instanceof Error ? error.message : String(error), @@ -28,7 +30,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addScanOperation: vi.fn(), })); -import { launchOrganizationScans } from "./scans"; +import { getExportsZip, launchOrganizationScans } from "./scans"; describe("launchOrganizationScans", () => { beforeEach(() => { @@ -69,3 +71,34 @@ describe("launchOrganizationScans", () => { expect(result.failureCount).toBe(0); }); }); + +describe("getExportsZip", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("returns a generic server error when the report endpoint returns HTML", async () => { + // Given + fetchMock.mockResolvedValue( + new Response( + "502 Bad Gateway

    502 Bad Gateway

    ", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ), + ); + + // When + const result = await getExportsZip("scan-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 8716c5c1c6..5a6ddb7a29 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -1,8 +1,14 @@ "use server"; +import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; import { COMPLIANCE_REPORT_DISPLAY_NAMES, type ComplianceReportType, @@ -14,6 +20,7 @@ import { } from "@/lib/provider-filters"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { SCAN_STATES } from "@/types/attack-paths"; const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5; export const getScans = async ({ @@ -63,6 +70,10 @@ export const getScansByState = async () => { "filter[provider_type__in]", sanitizeProviderTypesCsv(), ); + // Only need to know whether at least one completed scan exists; filter server-side + // and cap to a single row so the answer is correct regardless of total scan count. + url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED); + url.searchParams.append("page[size]", "1"); try { const response = await fetch(url.toString(), { @@ -76,11 +87,18 @@ export const getScansByState = async () => { } }; -export const getScan = async (scanId: string) => { +export const getScan = async ( + scanId: string, + { include }: { include?: string } = {}, +) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans/${scanId}`); + if (include) { + url.searchParams.set("include", include); + } + try { const response = await fetch(url.toString(), { headers, @@ -133,6 +151,7 @@ export const scanOnDemand = async (formData: FormData) => { const result = await handleApiResponse(response, "/scans"); if (result?.data?.id) { addScanOperation("start", result.data.id); + revalidatePath("/scans"); } return result; } catch (error) { @@ -148,18 +167,20 @@ export const scheduleDaily = async (formData: FormData) => { const url = new URL(`${apiBaseUrl}/schedules/daily`); + const body = { + data: { + type: "daily-schedules", + attributes: { + provider_id: providerId, + }, + }, + }; + try { const response = await fetch(url.toString(), { method: "POST", headers, - body: JSON.stringify({ - data: { - type: "daily-schedules", - attributes: { - provider_id: providerId, - }, - }, - }), + body: JSON.stringify(body), }); return handleApiResponse(response, "/scans"); @@ -235,6 +256,27 @@ export const launchOrganizationScans = async ( return summary; }; +async function getScanReportErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + + if (contentType.includes("text/html")) { + return GENERIC_SERVER_ERROR_MESSAGE; + } + + const errorData = await response.json().catch(() => null); + + return ( + errorData?.errors?.[0]?.detail || + errorData?.errors?.detail || + errorData?.error || + errorData?.message || + (response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage) + ); +} + export const updateScan = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); @@ -286,11 +328,11 @@ export const getExportsZip = async (scanId: string) => { } if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, "Unable to fetch scan report. Contact support if the issue continues.", + ), ); } @@ -311,55 +353,109 @@ export const getExportsZip = async (scanId: string) => { } }; -export const getComplianceCsv = async ( - scanId: string, - complianceId: string, -) => { - const headers = await getAuthHeaders({ contentType: false }); +/** + * Discriminated union returned by {@link _fetchScanBinary}. + * + * Exported so `ui/lib/helper.ts::downloadFile` can type-narrow on the + * `success` / `pending` / `error` tags without resorting to `any`. + */ +export type ScanBinaryResult = + | { success: true; data: string; filename: string } + | { pending: true; state: string | undefined; taskId: string | undefined } + | { error: string }; - const url = new URL( - `${apiBaseUrl}/scans/${scanId}/compliance/${complianceId}`, - ); +/** + * Shared binary-report fetcher used by CSV and PDF report downloads. + * + * All report endpoints (`/scans/{id}/compliance/{name}`, + * `/scans/{id}/{reportType}`) speak the same protocol: Bearer auth, 202 + * ACCEPTED while the generation task is still running, 2xx with a binary + * body when the artifact is ready, JSON error body otherwise. This helper + * encapsulates all of that so the public wrappers only have to build the + * URL and pick a filename. + * + * @param urlPath Path segment under `{apiBaseUrl}/scans/{scanId}/`. + * @param filename Download filename to surface to the user. + * @param errorLabel Friendly label used when the backend error body is empty. + * @returns A ``{ success, data, filename }`` object on 2xx, a + * ``{ pending, state, taskId }`` object on 202, or + * ``{ error }`` on any failure. + */ +const _fetchScanBinary = async ( + scanId: string, + urlPath: string, + filename: string, + errorLabel: string, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scans/${scanId}/${urlPath}`); try { const response = await fetch(url.toString(), { headers }); if (response.status === 202) { const json = await response.json(); - const taskId = json?.data?.id; - const state = json?.data?.attributes?.state; return { pending: true, - state, - taskId, + state: json?.data?.attributes?.state, + taskId: json?.data?.id, }; } if (!response.ok) { - const errorData = await response.json(); throw new Error( - errorData?.errors?.detail || - "Unable to retrieve compliance report. Contact support if the issue continues.", + await getScanReportErrorMessage( + response, + `Unable to retrieve ${errorLabel}. Contact support if the issue continues.`, + ), ); } const arrayBuffer = await response.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString("base64"); - return { - success: true, - data: base64, - filename: `scan-${scanId}-compliance-${complianceId}.csv`, - }; + return { success: true, data: base64, filename }; } catch (error) { - return { - error: getErrorMessage(error), - }; + return { error: getErrorMessage(error) }; } }; +export const getComplianceCsv = async (scanId: string, complianceId: string) => + _fetchScanBinary( + scanId, + `compliance/${complianceId}`, + `scan-${scanId}-compliance-${complianceId}.csv`, + "compliance report", + ); + /** - * Generic function to get a compliance PDF report (ThreatScore, ENS, etc.) + * Get the OCSF JSON export for a universal compliance framework. + * + * Only universal frameworks that declare an ``outputs`` block (today: DORA, + * CSA CCM 4.0) produce a per-framework OCSF artifact. For any other framework + * the backend returns 404; callers should gate this download via + * ``isOcsfSupported(framework)``. + * + * NOTE: this is a dedicated path (``compliance/{id}/ocsf``), not a query + * param. The API's JSON:API ``QueryParameterValidationFilter`` rejects any + * non-JSON:API query param with 400, so ``?type=`` / ``?format=`` is not an + * option — the format must be encoded in the route. + */ +export const getComplianceOcsf = async (scanId: string, complianceId: string) => + _fetchScanBinary( + scanId, + `compliance/${complianceId}/ocsf`, + `scan-${scanId}-compliance-${complianceId}.ocsf.json`, + "compliance OCSF report", + ); + +/** + * Get a compliance PDF report for any supported framework. + * + * For frameworks with multiple variants per provider (currently CIS) the + * backend generates a single PDF for the highest available version, so + * callers only need to pass the generic report type. + * * @param scanId - The scan ID * @param reportType - Type of report (from COMPLIANCE_REPORT_TYPES) * @returns Promise with the PDF data or error @@ -368,44 +464,11 @@ export const getCompliancePdfReport = async ( scanId: string, reportType: ComplianceReportType, ) => { - const headers = await getAuthHeaders({ contentType: false }); - - const url = new URL(`${apiBaseUrl}/scans/${scanId}/${reportType}`); - - try { - const response = await fetch(url.toString(), { headers }); - - if (response.status === 202) { - const json = await response.json(); - const taskId = json?.data?.id; - const state = json?.data?.attributes?.state; - return { - pending: true, - state, - taskId, - }; - } - - if (!response.ok) { - const errorData = await response.json(); - const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType]; - throw new Error( - errorData?.errors?.detail || - `Unable to retrieve ${reportName} PDF report. Contact support if the issue continues.`, - ); - } - - const arrayBuffer = await response.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); - - return { - success: true, - data: base64, - filename: `scan-${scanId}-${reportType}.pdf`, - }; - } catch (error) { - return { - error: getErrorMessage(error), - }; - } + const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType]; + return _fetchScanBinary( + scanId, + reportType, + `scan-${scanId}-${reportType}.pdf`, + `${reportName} PDF report`, + ); }; diff --git a/ui/actions/schedules/index.ts b/ui/actions/schedules/index.ts new file mode 100644 index 0000000000..7e6e7b459a --- /dev/null +++ b/ui/actions/schedules/index.ts @@ -0,0 +1 @@ +export * from "./schedules"; diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts new file mode 100644 index 0000000000..3122430f83 --- /dev/null +++ b/ui/actions/schedules/schedules.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SCHEDULE_FREQUENCY, + type ScheduleUpdatePayload, +} from "@/types/schedules"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, + revalidatePathMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { + getSchedule, + getSchedulesPage, + removeSchedule, + updateSchedule, + updateSchedulesBulk, +} from "./schedules"; + +const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc"; +const SECOND_PROVIDER_ID = "9b7fae7d-5e72-49fe-8b0b-5bff5930db1a"; + +const payload = { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 12, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, +}; + +describe("schedule write actions revalidate only on success", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + handleApiErrorMock.mockReturnValue({ error: "Failed" }); + }); + + it("revalidates /scans and /providers after a successful update", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("posts the JSON:API bulk schedule payload", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID, SECOND_PROVIDER_ID], + failed: [], + }, + }, + }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/schedules/bulk", + expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer token" }, + body: JSON.stringify({ + data: { + type: "schedules-bulk", + attributes: { + schedule: payload, + provider_ids: [PROVIDER_ID, SECOND_PROVIDER_ID], + }, + }, + }), + }), + ); + }); + + it("rejects invalid bulk provider ids without issuing a request", async () => { + expect( + await updateSchedulesBulk([PROVIDER_ID, "../users/me"], payload), + ).toEqual({ + error: "Invalid provider ids.", + }); + expect(await updateSchedulesBulk([], payload)).toEqual({ + error: "Invalid provider ids.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid bulk schedule payloads without issuing a request", async () => { + const invalidPayload = { + ...payload, + scan_hour: "12", + } as unknown as ScheduleUpdatePayload; + + expect( + await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + invalidPayload, + ), + ).toEqual({ + error: "Invalid schedule payload.", + }); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("normalizes bulk auth header errors", async () => { + const authError = new Error("Session expired"); + getAuthHeadersMock.mockRejectedValue(authError); + + expect( + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload), + ).toEqual({ error: "Failed" }); + expect(handleApiErrorMock).toHaveBeenCalledWith(authError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a partial bulk success", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID], + failed: [{ provider_id: SECOND_PROVIDER_ID, error: "Denied" }], + }, + }, + }); + + const result = await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + payload, + ); + + expect(result.data?.attributes?.updated).toEqual([PROVIDER_ID]); + expect(result.data?.attributes?.failed).toHaveLength(1); + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the bulk update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Bulk rejected" }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("does not revalidate when the update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a successful delete", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the delete returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Not allowed" }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("rejects non-UUID provider ids without issuing a request", async () => { + const malicious = "../users/me"; + + expect(await getSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(await updateSchedule(malicious, payload)).toEqual({ + error: "Invalid provider id.", + }); + expect(await removeSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("getSchedulesPage delegates pagination to the endpoint", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + handleApiErrorMock.mockReturnValue({ error: "Failed" }); + }); + + it("requests only configured schedules with native pagination and include", async () => { + handleApiResponseMock.mockResolvedValue({ + data: [], + included: [], + meta: { pagination: { page: 2, pages: 4, count: 35 } }, + }); + + const result = await getSchedulesPage({ + page: 2, + pageSize: 10, + sort: "next_scan_at", + filters: { "filter[provider_type__in]": "aws,azure" }, + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0] as string); + expect(calledUrl.pathname).toBe("/api/v1/schedules"); + expect(calledUrl.searchParams.get("filter[configured]")).toBe("true"); + expect(calledUrl.searchParams.get("include")).toBe("provider"); + expect(calledUrl.searchParams.get("page[number]")).toBe("2"); + expect(calledUrl.searchParams.get("page[size]")).toBe("10"); + expect(calledUrl.searchParams.get("sort")).toBe("next_scan_at"); + expect(calledUrl.searchParams.get("filter[provider_type__in]")).toBe( + "aws,azure", + ); + // meta is propagated verbatim so the table paginates natively. + expect(result?.meta?.pagination?.count).toBe(35); + }); + + it("normalizes array filter values into a CSV param", async () => { + handleApiResponseMock.mockResolvedValue({ data: [], meta: {} }); + + await getSchedulesPage({ + filters: { "filter[provider__in]": ["id-1", "id-2"] }, + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0] as string); + expect(calledUrl.searchParams.get("filter[provider__in]")).toBe( + "id-1,id-2", + ); + }); +}); diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts new file mode 100644 index 0000000000..63ef862b3c --- /dev/null +++ b/ui/actions/schedules/schedules.ts @@ -0,0 +1,214 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { scheduleUpdatePayloadSchema } from "@/lib/schedules"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import type { + ScheduleProps, + SchedulesBulkResponse, + ScheduleUpdatePayload, +} from "@/types/schedules"; + +// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass. +const providerIdSchema = z.uuid(); + +function parseProviderId(providerId: string): string | null { + const parsed = providerIdSchema.safeParse(providerId); + return parsed.success ? parsed.data : null; +} + +function parseProviderIds(providerIds: string[]): string[] | null { + if (providerIds.length === 0) return null; + + const ids = providerIds.map(parseProviderId); + return ids.every((id): id is string => id !== null) ? ids : null; +} + +function revalidateScheduleViews() { + revalidatePath("/scans"); + revalidatePath("/providers"); +} + +export const getSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + url.searchParams.set("include", "provider"); + + try { + const response = await fetch(url.toString(), { headers }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +/** + * Lists every schedule (one per provider), following pagination — the backend + * has no multi-provider filter. + */ +export const getSchedules = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const schedules: ScheduleProps[] = []; + const MAX_PAGES = 20; + + try { + for (let page = 1; page <= MAX_PAGES; page++) { + const url = new URL(`${apiBaseUrl}/schedules`); + url.searchParams.set("page[number]", String(page)); + url.searchParams.set("page[size]", "100"); + + const response = await fetch(url.toString(), { headers }); + + const result = await handleApiResponse(response); + if (result?.error) return result; + + schedules.push(...(result?.data ?? [])); + + const totalPages = result?.meta?.pagination?.pages ?? 1; + if (page >= totalPages) break; + } + + return { data: schedules }; + } catch (error) { + return handleApiError(error); + } +}; + +/** Fetches one page of configured schedules for the Scheduled tab, with native pagination. */ +export const getSchedulesPage = async ({ + page = 1, + pageSize = 10, + sort = "", + filters = {}, +}: { + page?: number; + pageSize?: number; + sort?: string; + filters?: Record; +} = {}) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/schedules`); + + url.searchParams.set("filter[configured]", "true"); + url.searchParams.set("include", "provider"); + url.searchParams.set("page[number]", String(page)); + url.searchParams.set("page[size]", String(pageSize)); + if (sort) url.searchParams.set("sort", sort); + + for (const [key, value] of Object.entries(filters)) { + const normalized = Array.isArray(value) ? value.join(",") : value; + if (normalized) url.searchParams.set(key, normalized); + } + + try { + const response = await fetch(url.toString(), { headers }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedule = async ( + providerId: string, + payload: ScheduleUpdatePayload, +) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + const body = { + data: { + type: "schedules", + id, + attributes: payload, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedulesBulk = async ( + providerIds: string[], + payload: ScheduleUpdatePayload, +): Promise => { + const ids = parseProviderIds(providerIds); + if (!ids) return { error: "Invalid provider ids." }; + + const parsedPayload = scheduleUpdatePayloadSchema.safeParse(payload); + if (!parsedPayload.success) return { error: "Invalid schedule payload." }; + + try { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/bulk`); + const body = { + data: { + type: "schedules-bulk", + attributes: { + schedule: parsedPayload.data, + provider_ids: ids, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const result = (await handleApiResponse(response)) as SchedulesBulkResponse; + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + +export const removeSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/actions/task/index.ts b/ui/actions/task/index.ts index f5752264ce..00f29a1b0e 100644 --- a/ui/actions/task/index.ts +++ b/ui/actions/task/index.ts @@ -1,2 +1,3 @@ export * from "./poll"; +export * from "./task.adapter"; export * from "./tasks"; diff --git a/ui/actions/task/task.adapter.test.ts b/ui/actions/task/task.adapter.test.ts new file mode 100644 index 0000000000..82f4919972 --- /dev/null +++ b/ui/actions/task/task.adapter.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { getScanErrorDetails } from "./task.adapter"; + +describe("getScanErrorDetails", () => { + it("returns null when response is not a record", () => { + expect(getScanErrorDetails(null)).toBeNull(); + expect(getScanErrorDetails("oops")).toBeNull(); + expect(getScanErrorDetails(undefined)).toBeNull(); + }); + + it("returns null when data is missing", () => { + expect(getScanErrorDetails({})).toBeNull(); + }); + + it("returns null when attributes.result is missing", () => { + expect(getScanErrorDetails({ data: { attributes: {} } })).toBeNull(); + }); + + it("returns null when result has no recognizable fields", () => { + expect( + getScanErrorDetails({ data: { attributes: { result: {} } } }), + ).toBeNull(); + }); + + it("parses an error with only an exc_type", () => { + const details = getScanErrorDetails({ + data: { attributes: { result: { exc_type: "BotoCoreError" } } }, + }); + + expect(details).toEqual({ + type: "BotoCoreError", + messages: ["-"], + module: undefined, + copyValue: "ErrorType: BotoCoreError\nError: -", + }); + }); + + it("joins multiple exc_message entries in copyValue", () => { + const details = getScanErrorDetails({ + data: { + attributes: { + result: { + exc_type: "ScanError", + exc_message: ["Failed to connect", "Retry exhausted"], + exc_module: "scan.runner", + }, + }, + }, + }); + + expect(details).toEqual({ + type: "ScanError", + messages: ["Failed to connect", "Retry exhausted"], + module: "scan.runner", + copyValue: + "ErrorType: ScanError\nError: Failed to connect\nRetry exhausted", + }); + }); + + it("filters non-string entries out of exc_message", () => { + const details = getScanErrorDetails({ + data: { + attributes: { + result: { + exc_type: "ScanError", + exc_message: ["valid", 42, null, " ", " trimmed "], + }, + }, + }, + }); + + expect(details?.messages).toEqual(["valid", "trimmed"]); + }); + + it("returns null when only whitespace fields are present", () => { + const details = getScanErrorDetails({ + data: { + attributes: { + result: { + exc_type: " ", + exc_message: [""], + exc_module: "", + }, + }, + }, + }); + + expect(details).toBeNull(); + }); +}); diff --git a/ui/actions/task/task.adapter.ts b/ui/actions/task/task.adapter.ts new file mode 100644 index 0000000000..673c22219a --- /dev/null +++ b/ui/actions/task/task.adapter.ts @@ -0,0 +1,55 @@ +export interface ScanErrorDetails { + type: string; + messages: string[]; + module?: string; + copyValue: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" + ? value.trim() + : undefined; +} + +function getStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item !== ""); +} + +export function buildScanErrorDetails( + result: unknown, +): ScanErrorDetails | null { + if (!isRecord(result)) return null; + + const type = getString(result.exc_type) ?? "-"; + const messages = getStringList(result.exc_message); + const module = getString(result.exc_module); + + if (type === "-" && messages.length === 0 && !module) return null; + + const errorText = messages.length > 0 ? messages.join("\n") : "-"; + + return { + type, + messages: messages.length > 0 ? messages : ["-"], + module, + copyValue: `ErrorType: ${type}\nError: ${errorText}`, + }; +} + +export function getScanErrorDetails( + taskResponse: unknown, +): ScanErrorDetails | null { + if (!isRecord(taskResponse) || !isRecord(taskResponse.data)) return null; + if (!isRecord(taskResponse.data.attributes)) return null; + + return buildScanErrorDetails(taskResponse.data.attributes.result); +} diff --git a/ui/actions/users/users.ts b/ui/actions/users/users.ts index 717606594d..446fa81812 100644 --- a/ui/actions/users/users.ts +++ b/ui/actions/users/users.ts @@ -2,17 +2,64 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { z } from "zod"; +import { auth } from "@/auth.config"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { + TENANT_MEMBERSHIP_ROLE, + type TenantMembershipRole, +} from "@/types/users"; + +const getUsersSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + query: z.string().default(""), + sort: z.string().optional().default(""), + filters: z + .record( + z.string(), + z.union([z.string(), z.array(z.string()), z.number()]).optional(), + ) + .default({}), + pageSize: z.coerce.number().int().min(1).default(10), +}); + +const updateUserSchema = z.object({ + userId: z.uuid(), + name: z.string().min(1).optional(), + email: z.email().optional(), + company_name: z.string().optional(), + password: z.string().min(1).optional(), +}); + +const deleteUserSchema = z.object({ + userId: z.uuid(), +}); + +const removeUserFromTenantSchema = z.object({ + userId: z.uuid(), + tenantId: z.uuid(), +}); + +const updateUserRoleSchema = z.object({ + userId: z.uuid(), + roleId: z.uuid(), +}); + +type GetUsersInput = z.input; +type UpdateUserData = z.infer; +type UserAttributes = Omit; +type MembershipResource = { id: string }; + +export const getUsers = async (rawParams: Partial = {}) => { + const parsed = getUsersSchema.safeParse(rawParams); + if (!parsed.success) { + console.error("Invalid getUsers params:", parsed.error.flatten()); + return undefined; + } + const { page, query, sort, filters, pageSize } = parsed.data; -export const getUsers = async ({ - page = 1, - query = "", - sort = "", - filters = {}, - pageSize = 10, -}) => { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/users?include=roles"); @@ -46,22 +93,29 @@ export const getUsers = async ({ export const updateUser = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - const userId = formData.get("userId") as string; // Ensure userId is a string - const userName = formData.get("name") as string | null; - const userPassword = formData.get("password") as string | null; - const userEmail = formData.get("email") as string | null; - const userCompanyName = formData.get("company_name") as string | null; + const rawData = { + userId: formData.get("userId"), + name: formData.get("name") ?? undefined, + email: formData.get("email") ?? undefined, + company_name: formData.get("company_name") ?? undefined, + password: formData.get("password") ?? undefined, + }; + const parsed = updateUserSchema.safeParse(rawData); + if (!parsed.success) { + return { error: "Invalid user data" }; + } + const { userId, name, email, company_name, password } = parsed.data; const url = new URL(`${apiBaseUrl}/users/${userId}`); // Prepare attributes to send based on changes - const attributes: Record = {}; + const attributes: UserAttributes = {}; // Add only changed fields - if (userName !== null) attributes.name = userName; - if (userEmail !== null) attributes.email = userEmail; - if (userCompanyName !== null) attributes.company_name = userCompanyName; - if (userPassword !== null) attributes.password = userPassword; + if (name !== undefined) attributes.name = name; + if (email !== undefined) attributes.email = email; + if (company_name !== undefined) attributes.company_name = company_name; + if (password !== undefined) attributes.password = password; // If no fields have changed, don't send the request if (Object.keys(attributes).length === 0) { @@ -90,13 +144,14 @@ export const updateUser = async (formData: FormData) => { export const updateUserRole = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - const userId = formData.get("userId") as string; - const roleId = formData.get("roleId") as string; - - // Validate required fields - if (!userId || !roleId) { + const parsed = updateUserRoleSchema.safeParse({ + userId: formData.get("userId"), + roleId: formData.get("roleId"), + }); + if (!parsed.success) { return { error: "userId and roleId are required" }; } + const { userId, roleId } = parsed.data; const url = new URL(`${apiBaseUrl}/users/${userId}/relationships/roles`); @@ -124,11 +179,11 @@ export const updateUserRole = async (formData: FormData) => { export const deleteUser = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: false }); - const userId = formData.get("userId"); - - if (!userId) { + const parsed = deleteUserSchema.safeParse({ userId: formData.get("userId") }); + if (!parsed.success) { return { error: "User ID is required" }; } + const { userId } = parsed.data; const url = new URL(`${apiBaseUrl}/users/${userId}`); @@ -158,6 +213,156 @@ export const deleteUser = async (formData: FormData) => { } }; +interface ServerActionErrorDetail { + detail: string; + code?: string; +} + +interface ServerActionErrorResponse { + errors: ServerActionErrorDetail[]; +} + +interface RemoveUserFromTenantSuccess { + success: true; +} + +type RemoveUserFromTenantResult = + | RemoveUserFromTenantSuccess + | ServerActionErrorResponse; + +const toErrorResponse = (detail: string): ServerActionErrorResponse => ({ + errors: [{ detail }], +}); + +export const removeUserFromTenant = async ( + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const parsed = removeUserFromTenantSchema.safeParse({ + userId: formData.get("userId"), + tenantId: formData.get("tenantId"), + }); + if (!parsed.success) { + return toErrorResponse("userId and tenantId are required"); + } + const { userId, tenantId } = parsed.data; + + // Resolve the target user's membership id for the current tenant on the + // server so the client form can open instantly without a prefetch. + // + // We cannot use `/users/{userId}/memberships` here: that endpoint ignores + // the path user id and always returns the authenticated user's memberships, + // which would make us try to delete the caller's own membership. + const listUrl = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`); + listUrl.searchParams.append("filter[user]", userId); + listUrl.searchParams.append("page[size]", "1"); + + let targetMembershipId: string | null = null; + try { + const listResponse = await fetch(listUrl.toString(), { headers }); + if (!listResponse.ok) { + const errorData = await listResponse.json().catch(() => ({})); + return { + errors: errorData.errors ?? [ + { detail: "Failed to resolve the user's membership" }, + ], + }; + } + const listData = (await listResponse.json()) as { + data?: MembershipResource[]; + }; + targetMembershipId = listData?.data?.[0]?.id ?? null; + } catch (error) { + const handled = handleApiError(error); + return toErrorResponse( + handled?.error ?? "Failed to resolve the user's membership", + ); + } + + if (!targetMembershipId) { + return toErrorResponse( + "This user is not a member of the current organization.", + ); + } + + const url = new URL( + `${apiBaseUrl}/tenants/${tenantId}/memberships/${targetMembershipId}`, + ); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + errors: errorData.errors ?? [ + { detail: "Failed to expel the user from the organization" }, + ], + }; + } + + revalidatePath("/users"); + return { success: true }; + } catch (error) { + const handled = handleApiError(error); + return toErrorResponse( + handled?.error ?? "Failed to expel the user from the organization", + ); + } +}; + +interface MembershipAttributesResource { + id: string; + attributes?: { + role?: string; + }; +} + +/** + * Resolve the active user's role inside the current tenant by querying the + * tenant memberships list with `filter[user]`. Returns `null` if the role + * cannot be determined (missing session, API error, or no match), so the + * caller can default-deny the destructive UI action. + */ +export const getCurrentUserTenantRole = + async (): Promise => { + const session = await auth(); + const userId = session?.userId; + const tenantId = session?.tenantId; + if (!userId || !tenantId) { + return null; + } + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`); + url.searchParams.append("filter[user]", userId); + url.searchParams.append("page[size]", "1"); + + try { + const response = await fetch(url.toString(), { headers }); + if (!response.ok) { + return null; + } + const body = (await response.json()) as { + data?: MembershipAttributesResource[]; + }; + const role = body?.data?.[0]?.attributes?.role; + if ( + role === TENANT_MEMBERSHIP_ROLE.Owner || + role === TENANT_MEMBERSHIP_ROLE.Member + ) { + return role; + } + return null; + } catch (error) { + console.error("Error resolving current user's tenant role:", error); + return null; + } + }; + export const getUserInfo = async () => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL( diff --git a/ui/app/(auth)/(guest-only)/layout.tsx b/ui/app/(auth)/(guest-only)/layout.tsx new file mode 100644 index 0000000000..3ff93d836b --- /dev/null +++ b/ui/app/(auth)/(guest-only)/layout.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; +import { ReactNode } from "react"; + +import { auth } from "@/auth.config"; + +export default async function GuestOnlyLayout({ + children, +}: { + children: ReactNode; +}) { + const session = await auth(); + + if (session?.user) { + redirect("/"); + } + + return <>{children}; +} diff --git a/ui/app/(auth)/sign-in/page.tsx b/ui/app/(auth)/(guest-only)/sign-in/page.tsx similarity index 100% rename from ui/app/(auth)/sign-in/page.tsx rename to ui/app/(auth)/(guest-only)/sign-in/page.tsx diff --git a/ui/app/(auth)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx similarity index 100% rename from ui/app/(auth)/sign-up/page.tsx rename to ui/app/(auth)/(guest-only)/sign-up/page.tsx diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts new file mode 100644 index 0000000000..41118bd6e2 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { confirmAlertRecipient } from "./confirm-alert-recipient"; + +const fetchMock = vi.fn(); + +const lastFetchCall = (): { url: string; init: RequestInit } => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + const [url, init] = call; + return { url: String(url), init: (init ?? {}) as RequestInit }; +}; + +describe("confirmAlertRecipient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + state: "confirmed", + message: + "Your subscription has been confirmed. You will receive alert digests at this address.", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("calls the public confirmation endpoint without auth headers", async () => { + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: true, + state: "confirmed", + message: + "Your subscription has been confirmed. You will receive alert digests at this address.", + }); + const { url, init } = lastFetchCall(); + expect(url).toBe( + "https://api.example.com/api/v1/alerts/recipients/confirm?token=token-1", + ); + expect(init).toEqual({ + headers: { Accept: "application/json" }, + cache: "no-store", + }); + }); + + it("returns the API message for invalid tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "invalid_token", + message: "This link is invalid or has expired.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await confirmAlertRecipient("expired-token"); + + // Then + expect(result).toEqual({ + ok: false, + state: "invalid_token", + message: "This link is invalid or has expired.", + }); + }); + + it("returns the API message for missing tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "missing_token", + message: "This link is missing a token.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await confirmAlertRecipient(); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_token", + message: "This link is missing a token.", + }); + expect(lastFetchCall().url).toBe( + "https://api.example.com/api/v1/alerts/recipients/confirm", + ); + }); + + it("returns the fallback message when the API base URL is missing", async () => { + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_api_base_url", + message: + "We could not process this confirmation link. Please try again later.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/confirm?token=token-1", + ); + }); + + it("returns the fallback message when the request fails", async () => { + // Given + fetchMock.mockRejectedValueOnce(new Error("network down")); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "network_error", + message: + "We could not process this confirmation link. Please try again later.", + }); + }); +}); diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts new file mode 100644 index 0000000000..19e9216339 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts @@ -0,0 +1,81 @@ +import { readEnv } from "@/lib/runtime-env"; + +interface AlertConfirmApiResponse { + state?: string; + message?: string; +} + +interface AlertConfirmResult { + ok: boolean; + state: string; + message: string; +} + +const FALLBACK_CONFIRM_ERROR = + "We could not process this confirmation link. Please try again later."; + +const toMessage = (payload: unknown): string | null => { + if ( + typeof payload === "object" && + payload !== null && + "message" in payload && + typeof payload.message === "string" + ) { + return payload.message; + } + + return null; +}; + +const toState = (payload: unknown): string => { + if ( + typeof payload === "object" && + payload !== null && + "state" in payload && + typeof payload.state === "string" + ) { + return payload.state; + } + + return "unknown"; +}; + +export const confirmAlertRecipient = async ( + token?: string, +): Promise => { + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); + if (!apiBaseUrl) { + return { + ok: false, + state: "missing_api_base_url", + message: FALLBACK_CONFIRM_ERROR, + }; + } + + const url = new URL(`${apiBaseUrl}/alerts/recipients/confirm`); + if (token) { + url.searchParams.set("token", token); + } + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + const payload = (await response.json()) as AlertConfirmApiResponse; + + return { + ok: response.ok, + state: toState(payload), + message: toMessage(payload) ?? FALLBACK_CONFIRM_ERROR, + }; + } catch { + return { + ok: false, + state: "network_error", + message: FALLBACK_CONFIRM_ERROR, + }; + } +}; diff --git a/ui/app/(auth)/alerts/confirm/page.test.tsx b/ui/app/(auth)/alerts/confirm/page.test.tsx new file mode 100644 index 0000000000..8b6203c109 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/page.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react"; +import { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import AlertsConfirmPage from "./page"; + +const confirmAlertRecipientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./confirm-alert-recipient", () => ({ + confirmAlertRecipient: confirmAlertRecipientMock, +})); + +vi.mock("@/components/auth/oss/auth-layout", () => ({ + AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => ( +
    {children}
    + ), +})); + +vi.mock("@/components/shadcn", () => ({ + Button: ({ children }: { children: ReactNode }) =>
    {children}
    , +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +describe("AlertsConfirmPage", () => { + it("shows the API message after confirming the alert recipient", async () => { + // Given + confirmAlertRecipientMock.mockResolvedValueOnce({ + ok: true, + state: "confirmed", + message: + "Your subscription has been confirmed. You will receive alert digests at this address.", + }); + + // When + render( + await AlertsConfirmPage({ + searchParams: Promise.resolve({ token: "token-1" }), + }), + ); + + // Then + expect(confirmAlertRecipientMock).toHaveBeenCalledWith("token-1"); + expect(screen.getByLabelText("Subscription confirmed")).toBeInTheDocument(); + expect( + screen.getByText( + "Your subscription has been confirmed. You will receive alert digests at this address.", + ), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Continue to Prowler" }), + ).toHaveAttribute("href", "/"); + }); + + it("shows the subscription link title when confirmation fails", async () => { + // Given + confirmAlertRecipientMock.mockResolvedValueOnce({ + ok: false, + state: "invalid_token", + message: "This link is invalid or has expired.", + }); + + // When + render( + await AlertsConfirmPage({ + searchParams: Promise.resolve({ token: ["expired-token"] }), + }), + ); + + // Then + expect(confirmAlertRecipientMock).toHaveBeenCalledWith("expired-token"); + expect(screen.getByLabelText("Subscription link")).toBeInTheDocument(); + expect( + screen.getByText("This link is invalid or has expired."), + ).toBeVisible(); + }); +}); diff --git a/ui/app/(auth)/alerts/confirm/page.tsx b/ui/app/(auth)/alerts/confirm/page.tsx new file mode 100644 index 0000000000..3791166d60 --- /dev/null +++ b/ui/app/(auth)/alerts/confirm/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; + +import { AuthLayout } from "@/components/auth/oss/auth-layout"; +import { Button } from "@/components/shadcn"; + +import { confirmAlertRecipient } from "./confirm-alert-recipient"; + +interface AlertsConfirmPageProps { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +const getParamValue = ( + params: Awaited, + key: string, +): string | undefined => { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +}; + +export default async function AlertsConfirmPage({ + searchParams, +}: AlertsConfirmPageProps) { + const resolvedSearchParams = await searchParams; + const token = getParamValue(resolvedSearchParams, "token"); + const result = await confirmAlertRecipient(token); + const title = result.ok ? "Subscription confirmed" : "Subscription link"; + + return ( + +
    +

    + {result.message} +

    + +
    +
    + ); +} diff --git a/ui/app/(auth)/alerts/unsubscribe/page.test.tsx b/ui/app/(auth)/alerts/unsubscribe/page.test.tsx new file mode 100644 index 0000000000..4ffeb548b0 --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/page.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import AlertsUnsubscribePage from "./page"; + +const unsubscribeAlertRecipientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./unsubscribe-alert-recipient", () => ({ + unsubscribeAlertRecipient: unsubscribeAlertRecipientMock, +})); + +vi.mock("@/components/auth/oss/auth-layout", () => ({ + AuthLayout: ({ title, children }: { title: string; children: ReactNode }) => ( +
    {children}
    + ), +})); + +vi.mock("@/components/shadcn", () => ({ + Button: ({ children }: { children: ReactNode }) =>
    {children}
    , +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +describe("AlertsUnsubscribePage", () => { + it("shows a neutral link back to the app after unsubscribing", async () => { + // Given + unsubscribeAlertRecipientMock.mockResolvedValueOnce({ + ok: true, + state: "unsubscribed", + message: + "You have been unsubscribed. You will not receive further alerts at this address.", + }); + + // When + render( + await AlertsUnsubscribePage({ + searchParams: Promise.resolve({ token: "token-1" }), + }), + ); + + // Then + expect(unsubscribeAlertRecipientMock).toHaveBeenCalledWith("token-1"); + expect(screen.getByLabelText("Unsubscribed")).toBeInTheDocument(); + expect( + screen.getByText( + "You have been unsubscribed. You will not receive further alerts at this address.", + ), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Continue to Prowler" }), + ).toHaveAttribute("href", "/"); + }); +}); diff --git a/ui/app/(auth)/alerts/unsubscribe/page.tsx b/ui/app/(auth)/alerts/unsubscribe/page.tsx new file mode 100644 index 0000000000..e54798a6e9 --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; + +import { AuthLayout } from "@/components/auth/oss/auth-layout"; +import { Button } from "@/components/shadcn"; + +import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient"; + +interface AlertsUnsubscribePageProps { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +const getParamValue = ( + params: Awaited, + key: string, +): string | undefined => { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +}; + +export default async function AlertsUnsubscribePage({ + searchParams, +}: AlertsUnsubscribePageProps) { + const resolvedSearchParams = await searchParams; + const token = getParamValue(resolvedSearchParams, "token"); + const result = await unsubscribeAlertRecipient(token); + const title = result.ok ? "Unsubscribed" : "Subscription link"; + + return ( + +
    +

    + {result.message} +

    + +
    +
    + ); +} diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts new file mode 100644 index 0000000000..508625b4be --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { unsubscribeAlertRecipient } from "./unsubscribe-alert-recipient"; + +const fetchMock = vi.fn(); + +const lastFetchCall = (): { url: string; init: RequestInit } => { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("fetch was not called"); + const [url, init] = call; + return { url: String(url), init: (init ?? {}) as RequestInit }; +}; + +describe("unsubscribeAlertRecipient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + state: "unsubscribed", + message: + "You have been unsubscribed. You will not receive further alerts at this address.", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("calls the public unsubscribe endpoint without auth headers", async () => { + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: true, + state: "unsubscribed", + message: + "You have been unsubscribed. You will not receive further alerts at this address.", + }); + const { url, init } = lastFetchCall(); + expect(url).toBe( + "https://api.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1", + ); + expect(init).toEqual({ + headers: { Accept: "application/json" }, + cache: "no-store", + }); + }); + + it("returns the API message for invalid tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "invalid_token", + message: "This link is invalid or has expired.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await unsubscribeAlertRecipient("expired-token"); + + // Then + expect(result).toEqual({ + ok: false, + state: "invalid_token", + message: "This link is invalid or has expired.", + }); + }); + + it("returns the API message for missing tokens", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + state: "missing_token", + message: "This link is missing a token.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + // When + const result = await unsubscribeAlertRecipient(); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_token", + message: "This link is missing a token.", + }); + expect(lastFetchCall().url).toBe( + "https://api.example.com/api/v1/alerts/recipients/unsubscribe", + ); + }); + + it("returns the fallback message when the API base URL is missing", async () => { + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_api_base_url", + message: + "We could not process this unsubscribe link. Please try again later.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1", + ); + }); +}); diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts new file mode 100644 index 0000000000..8c3a4e2a79 --- /dev/null +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts @@ -0,0 +1,81 @@ +import { readEnv } from "@/lib/runtime-env"; + +interface AlertUnsubscribeApiResponse { + state?: string; + message?: string; +} + +interface AlertUnsubscribeResult { + ok: boolean; + state: string; + message: string; +} + +const FALLBACK_UNSUBSCRIBE_ERROR = + "We could not process this unsubscribe link. Please try again later."; + +const toMessage = (payload: unknown): string | null => { + if ( + typeof payload === "object" && + payload !== null && + "message" in payload && + typeof payload.message === "string" + ) { + return payload.message; + } + + return null; +}; + +const toState = (payload: unknown): string => { + if ( + typeof payload === "object" && + payload !== null && + "state" in payload && + typeof payload.state === "string" + ) { + return payload.state; + } + + return "unknown"; +}; + +export const unsubscribeAlertRecipient = async ( + token?: string, +): Promise => { + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); + if (!apiBaseUrl) { + return { + ok: false, + state: "missing_api_base_url", + message: FALLBACK_UNSUBSCRIBE_ERROR, + }; + } + + const url = new URL(`${apiBaseUrl}/alerts/recipients/unsubscribe`); + if (token) { + url.searchParams.set("token", token); + } + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + const payload = (await response.json()) as AlertUnsubscribeApiResponse; + + return { + ok: response.ok, + state: toState(payload), + message: toMessage(payload) ?? FALLBACK_UNSUBSCRIBE_ERROR, + }; + } catch { + return { + ok: false, + state: "network_error", + message: FALLBACK_UNSUBSCRIBE_ERROR, + }; + } +}; diff --git a/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts new file mode 100644 index 0000000000..f988eff408 --- /dev/null +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; + +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, + INVITATION_ERROR_MESSAGES, + isInvitationTokenError, +} from "./invitation-errors"; + +describe("getInvitationErrorDisplay", () => { + describe("when mapping invitation accept errors", () => { + it("should show expired message for token_expired responses", () => { + // Given + const response = { + status: 410, + errors: [ + { + status: "410", + code: "token_expired", + detail: "The invitation token has expired and is no longer valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.EXPIRED); + expect(result.canRetry).toBe(false); + }); + + it("should show no-longer-valid message for already accepted or revoked invitations", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "This invitation is no longer valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NO_LONGER_VALID); + expect(result.canRetry).toBe(false); + }); + + it("should show not-valid message for missing invitation tokens", () => { + // Given + const response = { + status: 404, + errors: [ + { + status: "404", + code: "not_found", + detail: "Invitation is not valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID); + expect(result.canRetry).toBe(false); + }); + + it("should not allow retry for client-side malformed tokens", () => { + // Given + const response = { + error: "Invalid invitation token", + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + }); + + describe("when mapping invitation signup errors", () => { + it("should not identify generic data errors as invitation token errors", () => { + // Given + const error = { + status: "400", + code: "invalid", + detail: "Invalid request data.", + source: { pointer: "/data" }, + }; + + // When + const result = isInvitationTokenError(error); + + // Then + expect(result).toBe(false); + }); + + it("should identify invitation token field errors", () => { + // Given + const error = { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }; + + // When + const result = isInvitationTokenError(error); + + // Then + expect(result).toBe(true); + }); + + it("should use generic invalid fallback for non-invitation signup errors", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid email address.", + source: { pointer: "/data/attributes/email" }, + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + + it("should show not-valid message for signup invalid invitation tokens", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID); + expect(result.canRetry).toBe(false); + }); + }); + + describe("when the response is unexpected", () => { + it("should use generic invalid fallback for unmapped invalid responses", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Unexpected invalid invitation response.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + + it("should allow retry for unknown responses", () => { + // Given + const response = { + status: 500, + errors: [ + { + status: "500", + code: "server_error", + detail: "Something exploded.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.UNEXPECTED); + expect(result.canRetry).toBe(true); + }); + }); +}); diff --git a/ui/app/(auth)/invitation/_lib/invitation-errors.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.ts new file mode 100644 index 0000000000..295035c484 --- /dev/null +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.ts @@ -0,0 +1,125 @@ +import type { ApiError, ApiResponse } from "@/types"; + +const CLIENT_INVITATION_ERROR = { + INVALID_TOKEN: "Invalid invitation token", +} as const; + +const INVITATION_ERROR_DETAIL = { + NO_LONGER_VALID: "This invitation is no longer valid.", +} as const; + +const INVITATION_ERROR_POINTER = { + INVITATION_TOKEN: "/data/attributes/invitation_token", +} as const; + +const INVITATION_ERROR_CODE = { + INVALID: "invalid", + NOT_FOUND: "not_found", + TOKEN_EXPIRED: "token_expired", +} as const; + +export const INVITATION_ERROR_FLOW = { + ACCEPT: "accept", + SIGNUP: "signup", +} as const; + +type InvitationErrorFlow = + (typeof INVITATION_ERROR_FLOW)[keyof typeof INVITATION_ERROR_FLOW]; + +export const INVITATION_ERROR_MESSAGES = { + EXPIRED: + "This invitation has expired. Please contact your administrator for a new one.", + NO_LONGER_VALID: + "This invitation is no longer valid. Please contact your administrator for a new invitation.", + NOT_VALID: + "This invitation is not valid. Please check the link you received.", + INVALID_FALLBACK: + "This invitation is invalid. Please check the link or contact your administrator.", + UNEXPECTED: "Something went wrong while accepting the invitation.", +} as const; + +interface InvitationErrorDisplay { + message: string; + canRetry: boolean; +} + +interface InvitationErrorResponse + extends Pick { + errors?: ApiError[]; +} + +function getFirstError( + response: InvitationErrorResponse, +): ApiError | undefined { + return response.errors?.[0]; +} + +export function isInvitationTokenError(error: ApiError): boolean { + return error.source?.pointer === INVITATION_ERROR_POINTER.INVITATION_TOKEN; +} + +export function getInvitationErrorDisplay( + response: InvitationErrorResponse, + flow: InvitationErrorFlow, +): InvitationErrorDisplay { + const firstError = getFirstError(response); + const code = firstError?.code; + const detail = firstError?.detail; + + if (response.error === CLIENT_INVITATION_ERROR.INVALID_TOKEN) { + return { + message: INVITATION_ERROR_MESSAGES.INVALID_FALLBACK, + canRetry: false, + }; + } + + if (response.status === 410 && code === INVITATION_ERROR_CODE.TOKEN_EXPIRED) { + return { + message: INVITATION_ERROR_MESSAGES.EXPIRED, + canRetry: false, + }; + } + + if ( + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + detail === INVITATION_ERROR_DETAIL.NO_LONGER_VALID + ) { + return { + message: INVITATION_ERROR_MESSAGES.NO_LONGER_VALID, + canRetry: false, + }; + } + + if (response.status === 404 && code === INVITATION_ERROR_CODE.NOT_FOUND) { + return { + message: INVITATION_ERROR_MESSAGES.NOT_VALID, + canRetry: false, + }; + } + + if ( + flow === INVITATION_ERROR_FLOW.SIGNUP && + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + firstError && + isInvitationTokenError(firstError) + ) { + return { + message: INVITATION_ERROR_MESSAGES.NOT_VALID, + canRetry: false, + }; + } + + if (code === INVITATION_ERROR_CODE.INVALID) { + return { + message: INVITATION_ERROR_MESSAGES.INVALID_FALLBACK, + canRetry: false, + }; + } + + return { + message: INVITATION_ERROR_MESSAGES.UNEXPECTED, + canRetry: true, + }; +} diff --git a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx new file mode 100644 index 0000000000..0ed6346de7 --- /dev/null +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +import { acceptInvitation } from "@/actions/invitations"; +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, +} from "@/app/(auth)/invitation/_lib/invitation-errors"; +import { Button } from "@/components/shadcn"; + +type AcceptState = + | { kind: "no-token" } + | { kind: "accepting" } + | { kind: "error"; message: string; canRetry: boolean } + | { kind: "choose" }; + +export function AcceptInvitationClient({ + isAuthenticated, + token, +}: { + isAuthenticated: boolean; + token: string | null; +}) { + const router = useRouter(); + const [state, setState] = useState(() => { + if (!token) return { kind: "no-token" }; + if (!isAuthenticated) return { kind: "choose" }; + return { kind: "accepting" }; + }); + const hasStartedRef = useRef(false); + + async function doAccept() { + if (!token) return; + setState({ kind: "accepting" }); + + const result = await acceptInvitation(token); + + if (result?.error) { + const { message, canRetry } = getInvitationErrorDisplay( + result, + INVITATION_ERROR_FLOW.ACCEPT, + ); + setState({ kind: "error", message, canRetry }); + } else { + router.push("/"); + } + } + + useEffect(() => { + if (hasStartedRef.current) return; + hasStartedRef.current = true; + + if (!token) { + setState({ kind: "no-token" }); + return; + } + + if (isAuthenticated) { + doAccept(); + } else { + setState({ kind: "choose" }); + } + }, [token, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
    +
    + {/* No token */} + {state.kind === "no-token" && ( +
    + +

    Invalid Invitation Link

    +

    + No invitation token was provided. Please check the link you + received. +

    + +
    + )} + + {/* Accepting */} + {state.kind === "accepting" && ( +
    + +

    Accepting Invitation...

    +

    + Please wait while we process your invitation. +

    +
    + )} + + {/* Error */} + {state.kind === "error" && ( +
    + +

    + Could Not Accept Invitation +

    +

    {state.message}

    +
    + {state.canRetry && } + +
    +
    + )} + + {/* Choice page for unauthenticated users */} + {state.kind === "choose" && ( +
    + +
    +

    + You've Been Invited +

    +

    + You've been invited to join a tenant on Prowler. How would + you like to continue? +

    +
    +
    + + +
    +
    + )} +
    +
    + ); +} diff --git a/ui/app/(auth)/invitation/accept/page.tsx b/ui/app/(auth)/invitation/accept/page.tsx new file mode 100644 index 0000000000..9bfc5e0110 --- /dev/null +++ b/ui/app/(auth)/invitation/accept/page.tsx @@ -0,0 +1,22 @@ +import { auth } from "@/auth.config"; +import { SearchParamsProps } from "@/types"; + +import { AcceptInvitationClient } from "./accept-invitation-client"; + +export default async function AcceptInvitationPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const session = await auth(); + const resolvedSearchParams = await searchParams; + + const token = + typeof resolvedSearchParams?.invitation_token === "string" + ? resolvedSearchParams.invitation_token + : null; + + return ( + + ); +} diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index ab6b1e9bfa..79a4ce4e89 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -2,14 +2,15 @@ import "@/styles/globals.css"; import { GoogleTagManager } from "@next/third-parties/google"; import { Metadata, Viewport } from "next"; -import { redirect } from "next/navigation"; -import { ReactNode } from "react"; +import { connection } from "next/server"; +import { ReactNode, Suspense } from "react"; -import { auth } from "@/auth.config"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/ui"; import { fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; import { cn } from "@/lib"; +import { readEnv } from "@/lib/runtime-env"; import { Providers } from "../providers"; @@ -31,20 +32,27 @@ export const viewport: Viewport = { ], }; -export default async function RootLayout({ +export default async function AuthLayout({ children, }: { children: ReactNode; }) { - const session = await auth(); + // Force dynamic rendering so the read below resolves from the container env + // at request time rather than being snapshotted at build (independent of the + // island's own connection() call). + await connection(); - if (session?.user) { - redirect("/"); - } + // Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted + const gtmId = readEnv( + "UI_GOOGLE_TAG_MANAGER_ID", + "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID", + ); return ( - + + + - + + + {children} - + {gtmId && } diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx new file mode 100644 index 0000000000..e69d0427ee --- /dev/null +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -0,0 +1,284 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { FILTER_FIELD } from "@/types/filters"; + +import { AccountsSelector } from "./accounts-selector"; + +const multiSelectContentSpy = vi.fn(); +const multiSelectSpy = vi.fn(); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/hooks/use-url-filters", () => ({ + useUrlFilters: () => ({ + navigateWithParams: vi.fn(), + }), +})); + +vi.mock("@/components/icons/providers-badge", () => ({ + AWSProviderBadge: () => AWS, + AzureProviderBadge: () => Azure, + GCPProviderBadge: () => GCP, + CloudflareProviderBadge: () => Cloudflare, + GitHubProviderBadge: () => GitHub, + GoogleWorkspaceProviderBadge: () => Google Workspace, + IacProviderBadge: () => IaC, + ImageProviderBadge: () => Image, + KS8ProviderBadge: () => Kubernetes, + M365ProviderBadge: () => M365, + MongoDBAtlasProviderBadge: () => MongoDB Atlas, + OpenStackProviderBadge: () => OpenStack, + OracleCloudProviderBadge: () => Oracle Cloud, + AlibabaCloudProviderBadge: () => Alibaba Cloud, + VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, +})); + +vi.mock("@/components/shadcn/select/multiselect", () => ({ + MultiSelect: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => { + multiSelectSpy({ open }); + return ( +
    + + {children} +
    + ); + }, + MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( + {placeholder} + ), + MultiSelectContent: ({ + children, + search, + }: { + children: React.ReactNode; + search?: unknown; + }) => { + multiSelectContentSpy(search); + return
    {children}
    ; + }, + MultiSelectItem: ({ + children, + disabled, + value, + keywords, + onSelect, + }: { + children: React.ReactNode; + disabled?: boolean; + value: string; + keywords?: string[]; + onSelect?: (value: string) => void; + }) => ( + + ), +})); + +const providers = [ + { + id: "provider-1", + type: "providers" as const, + attributes: { + provider: "aws" as const, + uid: "123456789012", + alias: "Production AWS", + status: "completed" as const, + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + }, +]; + +describe("AccountsSelector", () => { + it("passes searchable dropdown defaults to MultiSelectContent", () => { + render(); + + expect(multiSelectContentSpy).toHaveBeenCalledWith({ + placeholder: "Search Providers...", + emptyMessage: "No Providers found.", + }); + expect(screen.getByText("All Providers")).toBeInTheDocument(); + expect(screen.getByText("Production AWS")).toBeInTheDocument(); + }); + + it("supports contextual placeholder and empty-selection copy", () => { + render( + , + ); + + expect(screen.getByText("Select a Provider")).toBeInTheDocument(); + expect(screen.getByText("No provider selected")).toBeInTheDocument(); + }); + + it("allows disabling search explicitly", () => { + render(); + + expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false); + }); + + it("passes visible account labels as search keywords instead of only the internal id", () => { + render(); + + expect( + screen.getByText("Production AWS").closest("[data-value]"), + ).toHaveAttribute( + "data-keywords", + expect.stringContaining("Production AWS"), + ); + expect( + screen.getByText("Production AWS").closest("[data-value]"), + ).toHaveAttribute("data-keywords", expect.stringContaining("123456789012")); + }); + + it("can use provider UID values for pages whose API filters by provider_uid__in", () => { + render( + , + ); + + expect( + screen.getByText("Production AWS").closest("[data-value]"), + ).toHaveAttribute("data-value", "123456789012"); + }); + + it("disables select all when every account is already shown", () => { + render(); + + expect( + screen.getByRole("option", { name: /select all Providers/i }), + ).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("All selected")).toBeInTheDocument(); + }); + + it("marks configured account values as disabled", () => { + render( + , + ); + + expect( + screen.getByText("Production AWS").closest("[data-value]"), + ).toHaveAttribute("data-disabled", "true"); + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + }); + + it("can close the dropdown after selecting a launch-scan provider", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /open selector/i })); + expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: true }); + + await user.click(screen.getByRole("button", { name: /production aws/i })); + + expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false }); + }); + + it("shows the provider icon next to the name in the trigger for a single selection", async () => { + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect(within(trigger).getByText("Production AWS")).toBeInTheDocument(); + }); + + it("renders one icon per selected account without deduping by provider type", async () => { + const secondAws = { + ...providers[0], + id: "provider-2", + attributes: { + ...providers[0].attributes, + uid: "999999999999", + alias: "Staging AWS", + }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + // Two AWS accounts -> two AWS icons in the trigger (no dedupe). + expect(await within(trigger).findAllByText("AWS")).toHaveLength(2); + expect( + within(trigger).getByText("2 Providers selected"), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 362396f03f..ab5c27fd6c 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,64 +1,40 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { ReactNode } from "react"; +import { useState } from "react"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, -} from "@/components/icons/providers-badge"; + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; +import { Badge } from "@/components/shadcn"; import { MultiSelect, MultiSelectContent, MultiSelectItem, + type MultiSelectSearchProp, MultiSelectTrigger, MultiSelectValue, } from "@/components/shadcn/select/multiselect"; import { useUrlFilters } from "@/hooks/use-url-filters"; +import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters"; import { getProviderDisplayName, type ProviderProps, type ProviderType, } from "@/types/providers"; -const PROVIDER_ICON: Record = { - aws: , - azure: , - gcp: , - kubernetes: , - m365: , - github: , - googleworkspace: , - iac: , - image: , - oraclecloud: , - mongodbatlas: , - alibabacloud: , - cloudflare: , - openstack: , -}; - /** Common props shared by both batch and instant modes. */ interface AccountsSelectorBaseProps { providers: ProviderProps[]; - /** - * Currently selected provider types (from the pending ProviderTypeSelector state). - * Used only for contextual description/empty-state messaging — does NOT narrow - * the list of available accounts, which remains independent of provider selection. - */ - selectedProviderTypes?: string[]; + search?: MultiSelectSearchProp; + filterKey?: AccountFilterKey; + id?: string; + disabledValues?: string[]; + closeOnSelect?: boolean; + placeholder?: string; + emptySelectionLabel?: string; + clearSelectionLabel?: string; } /** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ @@ -92,113 +68,173 @@ export function AccountsSelector({ providers, onBatchChange, selectedValues, - selectedProviderTypes, + filterKey = FILTER_FIELD.PROVIDER_ID, + id = "accounts-selector", + disabledValues = [], + search = { + placeholder: "Search Providers...", + emptyMessage: "No Providers found.", + }, + closeOnSelect = false, + placeholder = "All Providers", + emptySelectionLabel = "All selected", + clearSelectionLabel = "Select All", }: AccountsSelectorProps) { const searchParams = useSearchParams(); const { navigateWithParams } = useUrlFilters(); + const [selectorOpen, setSelectorOpen] = useState(false); - const filterKey = "filter[provider_id__in]"; - const current = searchParams.get(filterKey) || ""; + const labelId = `${id}-label`; + const urlFilterKey = `filter[${filterKey}]`; + const current = searchParams.get(urlFilterKey) || ""; const urlSelectedIds = current ? current.split(",").filter(Boolean) : []; - // In batch mode, use the parent-controlled pending values; otherwise, use URL state. - const selectedIds = onBatchChange ? selectedValues : urlSelectedIds; const visibleProviders = providers; - // .filter((p) => p.attributes.connection?.connected) + const getProviderValue = (provider: ProviderProps) => + filterKey === FILTER_FIELD.PROVIDER_UID + ? provider.attributes.uid + : provider.id; + const disabledValuesSet = new Set(disabledValues); + + // In batch mode, use the parent-controlled pending values; otherwise, use URL state. + const selectedIds = (onBatchChange ? selectedValues : urlSelectedIds).filter( + (id) => !disabledValuesSet.has(id), + ); const handleMultiValueChange = (ids: string[]) => { + const enabledIds = ids.filter((id) => !disabledValuesSet.has(id)); + if (onBatchChange) { - onBatchChange("provider_id__in", ids); + onBatchChange(filterKey, enabledIds); + if (closeOnSelect) setSelectorOpen(false); return; } navigateWithParams((params) => { - params.delete(filterKey); + params.delete(urlFilterKey); - if (ids.length > 0) { - params.set(filterKey, ids.join(",")); + if (enabledIds.length > 0) { + params.set(urlFilterKey, enabledIds.join(",")); } }); + if (closeOnSelect) setSelectorOpen(false); }; const selectedLabel = () => { if (selectedIds.length === 0) return null; if (selectedIds.length === 1) { - const p = providers.find((pr) => pr.id === selectedIds[0]); + const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]); const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0]; - return {name}; + return ( + + {p && ( + + )} + {name} + + ); } + // One icon per selected account (no dedupe): two accounts of the same + // provider show two icons, disambiguated by the UID tooltip on hover. + const items = selectedIds + .map((selectedId) => + providers.find((pr) => getProviderValue(pr) === selectedId), + ) + .filter((p): p is ProviderProps => Boolean(p)) + .map((p) => ({ + key: p.id, + type: p.attributes.provider as ProviderType, + tooltip: p.attributes.uid, + })); return ( - {selectedIds.length} accounts selected + + + + {selectedIds.length} Providers selected + + ); }; - // Build a contextual description based on currently selected provider types. - // This is purely for user guidance (aria label + empty state) and does NOT - // narrow the list of available accounts — all providers remain selectable. - const filterDescription = - selectedProviderTypes && selectedProviderTypes.length > 0 - ? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}` - : "All connected cloud provider accounts"; - return (
    -